操作系统
程序的执行:相比 32 位,64 位的优势是什么?
程序的执行过程
1.首先,CPU 读取 PC 指针指向的指令,将它导入指令寄存器。具体来说,完成读取指令这件事情有 3 个步骤:
步骤 1:CPU 的控制单元操作地址总线指定需要访问的内存地址(简单理解,就是把 PC 指针中的值拷贝到地址总线中)。
步骤 2:CPU 通知内存设备准备数据(内存设备准备好了,就通过数据总线将数据传送给 CPU)。
步骤 3:CPU 收到内存传来的数据后,将这个数据存入指令寄存器。
完成以上 3 步,CPU 成功读取了 PC 指针指向指令,存入了指令寄存器。
2.然后,CPU 分析指令寄存器中的指令,确定指令的类型和参数。 3.如果是计算类型的指令,那么就交给逻辑运算单元计算;如果是存储类型的指令,那么由控制单元执行。 4.PC 指针自增,并准备获取下一条指令。
比如在 32 位的机器上,指令是 32 位 4 个字节,需要 4 个内存地址存储,因此 PC 指针会自增 4。
指令
- 首先 CPU 通过 PC 指针读取对应内存地址的指令,我们将这个步骤叫作 Fetch,就是获取的意思。
- CPU 对指令进行解码,我们将这个部分叫作 Decode。
- CPU 执行指令,我们将这个部分叫作 Execution。
- CPU 将结果存回寄存器或者将寄存器存入内存,我们将这个步骤叫作 Store。
上面 4 个步骤,我们叫作 CPU 的指令周期。CPU 的工作就是一个周期接着一个周期,周而复始。
64 位和 32 位比较有哪些优势?
(1)如果说的是64 位宽 CPU,那么有 2 个优势:
可以执行更大数字的运算,这个优势在普通应用上不明显,但是对于数值计算较多的应用就非常明显。
64 位 CPU 可以寻址更大的内存空间
(2)如果 32 位/64 位说的是程序,那么说的是指令是 64 位还是 32 位的
32 位指令在 64 位机器上执行,困难不大,可以兼容。 如果是 64 位指令,在 32 位机器上执行就困难了。因为 32 位指令在 64 位机器执行的时候,需要的是一套兼容机制;
但是 64 位指令在 32 位机器上执行,32 位的寄存器都存不下指令的参数。
(3)如果说的是操作系统
操作系统也是一种程序,也就是操作系统中程序的指令都是 64 位指令,因此不能安装在 32 位机器上。
存储器分级:L1 Cache 比内存和 SSD 快多少倍?
就是根据数据的使用频率使用不同的存储器:高频使用的数据,读写越快越好,因此用最贵的材料,放到离 CPU 最近的位置;使用频率越低的数据,我们放到离 CPU 越远的位置,用越便宜的材料。
具体来说,通常我们把存储器分成这么几个级别:
- 寄存器;
- L1-Cache;
- L2-Cache;
- L3-Cahce;
- 内存;
- 硬盘/SSD。
寄存器(Register)
寄存器紧挨着 CPU 的控制单元和逻辑计算单元,它所使用的材料速度也是最快的。就像我们前面讲到的,存储器的速度越快、能耗越高、产热越大,而且花费也是最贵的,因此数量不能很多。
寄存器的数量通常在几十到几百之间,每个寄存器可以用来存储一定字节(byte)的数据。比如:
- 32 位 CPU 中大多数寄存器可以存储 4 个字节;
- 64 位 CPU 中大多数寄存器可以存储 8 个字节。
寄存机的访问速度非常快,一般要求在半个 CPU 时钟周期内完成读写。比如一条要在 4 个周期内完成的指令,除了读写寄存器,还需要解码指令、控制指令执行和计算。如果寄存器的速度太慢,那 4 个周期就可能无法完成这条指令了。
L1-Cache
L1- 缓存在 CPU 中,相比寄存器,虽然它的位置距离 CPU 核心更远,但造价更低。通常 L1-Cache 大小在几十 Kb 到几百 Kb 不等,读写速度在 2~4 个 CPU 时钟周期。
L2-Cache
L2- 缓存也在 CPU 中,位置比 L1- 缓存距离 CPU 核心更远。它的大小比 L1-Cache 更大,具体大小要看 CPU 型号,有 2M 的,也有更小或者更大的,速度在 10~20 个 CPU 周期。
L3-Cache
L3- 缓存同样在 CPU 中,位置比 L2- 缓存距离 CPU 核心更远。大小通常比 L2-Cache 更大,读写速度在 20~60 个 CPU 周期。L3 缓存大小也是看型号的,比如 i9 CPU 有 512KB L1 Cache;有 2MB L2 Cache; 有16MB L3 Cache。
内存
内存的主要材料是半导体硅,是插在主板上工作的。因为它的位置距离 CPU 有一段距离,所以需要用总线和 CPU 连接。因为内存有了独立的空间,所以体积更大,造价也比上面提到的存储器低得多。现在有的个人电脑上的内存是 16G,但有些服务器的内存可以到几个 T。内存速度大概在 200~300 个 CPU 周期之间。
当 CPU 需要内存中某个数据的时候,如果寄存器中有这个数据,我们可以直接使用;如果寄存器中没有这个数据,我们就要先查询 L1 缓存;L1 中没有,再查询 L2 缓存;L2 中没有再查询 L3 缓存;L3 中没有,再去内存中拿。
指令的预读
CPU 执行指令的速度是非常快的,一般是 2~6 个 CPU 时钟周期;内存的读写速度其实是非常慢的,大概有 200~300 个时钟周期。CPU 其实是不能从内存中一条条读取指令再执行的,如果是这样做,那每执行一条指令就需要 200~300 个时钟周期了。
CPU可以把内存中的指令预读几十条或者上百条到读写速度较快的L1-缓存中,L1-缓存通常会分成两个区域,一个是指令区,一个是数据区。
用户和权限管理指令: 请简述 Linux 权限划分的原则?
Linux 中文件的权限可以用 9 个字符,3 组rwx
描述:第一组是用户权限,第二组是组权限,第三组是所有用户的权限。然后用-
代表没有权限。比如rwxrwxrwx
代表所有维度可以读写执行。rw--wxr-x
代表用户维度不可以执行,组维度不可以读取,所有用户维度不可以写入。
权限架构思想
优秀的权限架构主要目标是让系统安全、稳定且用户、程序之间相互制约、相互隔离。这要求权限系统中的权限划分足够清晰,分配权限的成本足够低。
因此,优秀的架构,应该遵循最小权限原则(Least Privilege)。权限设计需要保证系统的安全和稳定。比如:每一个成员拥有的权限应该足够的小,每一段特权程序执行的过程应该足够的短。对于安全级别较高的时候,还需要成员权限互相牵制。比如金融领域通常登录线上数据库需要两次登录,也就是需要两个密码,分别掌握在两个角色手中。这样即便一个成员出了问题,也可以保证整个系统安全。
同样的,每个程序也应该减少权限,比如说只拥有少量的目录读写权限,只可以进行少量的系统调用。
权限划分
此外,权限架构思想还应遵循一个原则,权限划分边界应该足够清晰,尽量做到相互隔离。Linux 提供了用户和分组。当然 Linux 没有强迫你如何划分权限,这是为了应对更多的场景。通常我们服务器上重要的应用,会由不同的账户执行。比如说 Nginx、Web 服务器、数据库不会执行在一个账户下。现在随着容器化技术的发展,我们甚至希望每个应用独享一个虚拟的空间,就好像运行在一个单独的操作系统中一样,让它们互相不用干扰。
分级保护
因为内核可以直接操作内存和 CPU,因此非常危险。驱动程序可以直接控制摄像头、显示屏等核心设备,也需要采取安全措施,比如防止恶意应用开启摄像头盗用隐私。通常操作系统都采取一种环状的保护模式。
权限包围(Privilege Bracking)
之前我们讨论过,当 Mysql 跑在 root 权限时,如果 Mysql 被攻破,整个机器就被攻破了。因此我们所有应用都不要跑在 root 上。如果所有应用都跑在普通账户下,那么就会有临时提升权限的场景。比如说安装程序可能需要临时拥有管理员权限,将应用装到/usr/bin
目录下。
操作系统内核:Linux 内核和 Windows 内核有什么区别?
什么是内核?
说到操作系统,就必须说内核。内核是操作系统中应用连接硬件设备的桥梁。
内核的能力
对于一个现代的操作系统来说,它的内核至少应该提供以下 4 种基本能力:
- 管理进程、线程(决定哪个进程、线程使用 CPU);
- 管理内存(决定内存用来做什么);
- 连接硬件设备(为进程、和设备间提供通信能力);
- 提供系统调用(接收进程发送来的系统调用)。
操作系统分层
从上面 4 种能力来看操作系统和内核之间的关系,通常可以把操作系统分成 3 层,最底层的硬件设备抽象、中间的内核和最上层的应用。
内核是如何工作的?
为了帮助你理解什么是内核,请你先思考一个问题:进程和内核的关系,是不是像浏览器请求服务端服务?你可以先自己思考,然后在留言区写下你此时此刻对这个问题的认知,等学完“模块三”再反过头来回顾这个知识,相信你定会产生新的理解。
接下来,我们先一起分析一下这个问题。
内核权限非常高,它可以管理进程、可以直接访问所有的内存,因此确实需要和进程之间有一定的隔离。这个隔离用类似请求/响应的模型,非常符合常理。
但不同的是在浏览器、服务端模型中,浏览器和服务端是用不同的机器在执行,因此不需要共享一个 CPU。但是在进程调用内核的过程中,这里是存在资源共享的。
- 比如,一个机器有 4 个 CPU,不可能让内核用一个 CPU,其他进程用剩下的 CPU。这样太浪费资源了。
- 再比如,进程向内核请求 100M 的内存,内核把 100M 的数据传回去。 这个模型不可行,因为传输太慢了。
所以,这里多数操作系统的设计都遵循一个原则:进程向内核发起一个请求,然后将 CPU 执行权限让出给内核。内核接手 CPU 执行权限,然后完成请求,再转让出 CPU 执行权限给调用进程。
关于这块知识,我们会在“ 14 |户态和内核态:用户态线程和内核态线程有什么区别?”中详细讨论。
Linux 的设计
Linux 操作系统第一版是1991 年林纳斯托·瓦兹(一个芬兰的小伙子,当时 22 岁)用 C 语音写的。 写完之后他在网络上发布了 Linux 内核的源代码。又经过了 3 年的努力,在 1994 年发布了完整的核心 Version 1.0。
说到 Linux 内核设计,这里有很多有意思的名词。大多数听起来复杂、专业,但是理解起来其实很简单。接下来我们一一讨论。
- Multitask and SMP(Symmetric multiprocessing)
MultiTask 指多任务,Linux 是一个多任务的操作系统。多任务就是多个任务可以同时执行,这里的“同时”并不是要求并发,而是在一段时间内可以执行多个任务。当然 Linux 支持并发。
SMP 指对称多处理。其实是说 Linux 下每个处理器的地位是相等的,内存对多个处理器来说是共享的,每个处理器都可以访问完整的内存和硬件资源。 这个特点决定了在 Linux 上不会存在一个特定的处理器处理用户程序或者内核程序,它们可以被分配到任何一个处理器上执行。
- ELF(Executable and Linkable Format)
这个名词翻译过来叫作可执行文件链接格式。这是一种从 Unix 继承而来的可执行文件的存储格式。我们可以看到 ELF 中把文件分成了一个个分段(Segment),每个段都有自己的作用。如果想要深入了解这块知识,会涉及部分编译原理的知识,如果你感兴趣可以去网上多查些资料或者去留言区我们一起讨论。
- Monolithic Kernel
这个名词翻译过来就是宏内核,宏内核反义词就是 Microkernel ,微内核的意思。Linux 是宏内核架构,这说明 Linux 的内核是一个完整的可执行程序,且内核用最高权限来运行。宏内核的特点就是有很多程序会打包在内核中,比如,文件系统、驱动、内存管理等。当然这并不是说,每次安装驱动都需要重新编译内核,现在 Linux 也可以动态加载内核模块。所以哪些模块在内核层,哪些模块在用户层,这是一种系统层的拆分,并不是很强的物理隔离。
与宏内核对应,接下来说说微内核,内核只保留最基本的能力。比如进程调度、虚拟内存、中断。多数应用,甚至包括驱动程序、文件系统,是在用户空间管理的。
学到这里,你可能会问:在内核层和在用户层有什么区别吗?
感觉分层其实差不多。 我这里说一个很大的区别,比如说驱动程序是需要频繁调用底层能力的,如果在内核中,性能肯定会好很多。对于微内核设计,驱动在内核外,驱动和硬件设备交互就需要频繁做内核态的切换。
当然微内核也有它的好处,比如说微内核体积更小、可移植性更强。不过我认为,随着计算能力、存储技术越来越发达,体积小、安装快已经不能算是一个很大的优势了。现在更重要的是如何有效利用硬件设备的性能。
之所以这么思考,也可能因为我是带着现代的目光回望当时人们对内核的评判,事实上,当时 Linux 团队也因此争论过很长一段时间。 但是我觉得历史往往是螺旋上升的,说不定将来性能发展到了一个新的阶段,像微内核的灵活性、可以提供强大的抽象能力这样的特点,又重新受到人们的重视。
还有一种就是混合类型内核。 混合类型的特点就是架构像微内核,内核中会有一个最小版本的内核,其他功能会在这个能力上搭建。但是实现的时候,是用宏内核的方式实现的,就是内核被做成了一个完整的程序,大部分功能都包含在内核中。就是在宏内核之内有抽象出了一个微内核。
上面我们大体介绍了内核几个重要的特性,有关进程、内存、虚拟化等特性,我们会在后面几个模块中逐步讨论。
Window 设计
接下来我们说说 Windows 的设计,Windows 和 Linux 的设计有很大程度的相似性。Windows也有内核,它的内核是 C/C++ 写的。准确地说,Windows 有两个内核版本。一个是早期的Windows 9x 内核,早期的 Win95, Win98 都是这个内核。我们今天用的 Windows 7, Windows 10 是另一个内核,叫作 Windows NT。NT 指的是 New Technology。接下来我们讨论的都是 NT 版本的内核。
下面我找到一张 Windows 内核架构的图片给你一个直观感受。
Windows 同样支持 Multitask 和 SMP(对称多处理)。Windows 的内核设计属于混合类型。你可以看到内核中有一个 Microkernel 模块。而整个内核实现又像宏内核一样,含有的能力非常多,是一个完整的整体。
Windows 下也有自己的可执行文件格式,这个格式叫作 Portable Executable(PE),也就是可移植执行文件,扩展名通常是.exe
、.dll
、.sys
等。
PE 文件的结构和 ELF 结构有很多相通的地方,我找到了一张图片帮助你更直观地理解。 因为这部分知识涉及编译原理,我这里就不详细介绍了,感兴趣同学可以在留言区和大家一起讨论,或者查阅更多资料。
Windows 还有很多独特的能力,比如 Hyper-V 虚拟化技术
中断和中断向量:Javajs 等语言为什么可以捕获到键盘输入?
为了捕获到键盘输入,硬件层面需要把按键抽象成中断,中断 CPU 执行。CPU 根据中断类型找到对应的中断向量。操作系统预置了中断向量,因此发生中断后操作系统接管了程序。操作系统实现了基本解析按键的算法,将按键抽象成键盘事件,并且提供了队列存储多个按键,还提供了监听按键的 API。因此应用程序,比如 Java/Node.js 虚拟机,就可以通过调用操作系统的 API 使用键盘事件。
进程间通信: 进程间通信都有哪些方法?
- 如果考虑单机模型,有管道、内存共享、消息队列。这三个模型中,内存共享程序最难写,但是性能最高。管道程序最好写,有标准接口。消息队列程序也比较好写,比如用发布/订阅模式实现具体的程序。
- 如果考虑分布式模型,就有远程调用、消息队列和网络请求。直接发送网络请求程序不好写,不如直接用实现好的 RPC 调用框架。RPC 框架会增加系统的耦合,可以考虑 消息队列,以及发布订阅事件的模式,这样可以减少系统间的耦合。
虚拟内存 :一个程序最多能使用多少内存?
Swap 技术允许一部分进程使用内存,不使用内存的进程数据先保存在磁盘上。如果空间不足,就考虑把没有在执行的进程交换(Swap)到磁盘上,把空间腾挪出来给需要的进程。
虚拟化技术中,应用使用的是虚拟内存,操作系统管理虚拟内存和真实内存之间的映射。操作系统将虚拟内存分成整齐小块,每个小块称为一个页(Page)。之所以这样做,原因主要有以下两个方面。
- 一方面应用使用内存是以页为单位,整齐的页能够避免内存碎片问题。
- 另一方面,每个应用都有高频使用的数据和低频使用的数据。这样做,操作系统就不必从应用角度去思考哪个进程是高频的,仅需思考哪些页被高频使用、哪些页被低频使用。如果是低频使用,就将它们保存到硬盘上;如果是高频使用,就让它们保留在真实内存中。
当 CPU 需要执行一条指令时,如果指令中涉及内存读写操作,CPU 会把虚拟地址给 MMU,MMU 自动完成虚拟地址到真实地址的计算;然后,MMU 连接了地址总线,帮助 CPU 操作真实地址。
在程序执行的时候,指令中的地址都是虚拟地址,虚拟地址会通过 MMU,MMU 会查询页表,计算出对应的 Frame Number,然后偏移量不变,组装成真实地址。然后 MMU 通过地址总线直接去访问内存。所以 MMU 承担了虚拟地址到物理地址的转换以及 CPU 对内存的操作这两件事情。
内存管理单元: 什么情况下使用大内存分页?
指令的执行速度非常快,而 MMU 还需要从内存中查询页表。最快的内存查询页需要从 CPU 的缓存中读取,假设缓存有 95% 的命中率,比如读取到 L2 缓存,那么每次操作也需要几个 CPU 周期。
在 fetch、execute 和 store 这 3 个环节中都有可能发生内存操作,因此内存操作最好能在非常短的时间内完成,尤其是 Page Number 到 Frame Number 的映射,我们希望尽快可以完成,最好不到 0.2 个 CPU 周期,这样就不会因为地址换算而增加指令的 CPU 周期。
在 MMU 中往往还有一个微型的设备,叫作转置检测缓冲区(Translation Lookaside Buffer,TLB)。
缓存的设计,通常是一张表,所以 TLB 也称作快表。TLB 中最主要的信息就是 Page Number到 Frame Number 的映射关系。
而且现在的多核 CPU,会为每个核心提供单独的 TLB。这样,相当于减少了 TLB 的冲突。比如酷睿 i7 CPU 当中,每个核心都有自己的 TLB,而且 TLB 还进行了类似 CPU 缓存的分级策略。在 i7 CPU 中,L1 级 TLB 一共 64 个,L2 级 TLB 一共 1024 个。通过这样的设计,绝大多数的页表查询就可以用 TLB 实现了。
TLB 如果miss了,如果Frame还在内存中,那么选一个条目进行覆盖就好了,LRU。另一种没有在内存中,需要从磁盘加载,那么操作系统要触发一个缺页中断。
TLB 缓存的设计
n 路组相联映射(n-way Set-Associative Mapping)
举个例子,比如现在有 64 个条目,要查找地址 100 的位置,可以先用一个固定的方法计算,比如 100%64 = 36。这样计算出应该去条目 36 获取 Frame 数据。但是取出条目 36 看到条目 36 的 Page Number 不是 100,这个时候就顺延一个位置,去查找 37,38,39……如果是 4 路组相联,那么就只看 36,37,38,39,如果是8 路组相联,就只看 36-43 位置。
以上,我们解决了 TLB 的基本设计问题,最后选择采用 n 路组相联映射。 然后还遗留了一个问题,如果一个应用(进程)对内存的需求比较大,比如 1G,而默认分页 4K 比较小。 这种情况下会有 262144 个页。考虑到 1024 个条目的 TLB,那么 262144⁄1024 = 256,如果 256 个地址复用 1 个缓存,很容易冲突。这个问题如何解决呢?
如果我们操作系统能够提供大小为 4M 的页,那么是不是就减少了 1024 倍的页数呢? ——这样就大大提高了 TLB 的查询性能。
缓存置换算法: LRU 用什么数据结构实现更合理?
设计缓存置换算法的期望是:每次将未来使用频率最低的数据置换出去。假设只要我们知道未来的所有指令,就可以计算出哪些内存地址在未来使用频率高,哪些内存地址在未来使用频率低。这样,我们总是可以开发出理论上最高效的缓存置换算法。
随机/FIFO/FILO
FIFO 的结构使用一个链表就能实现,如下图所示:
新元素从链表头部插入链表,旧元素从链表头部离开链表,就构成了一个栈(Stack),栈是一种天然的 FILO 数据结构。
最近未使用(NRU)
一种非常简单、有效的缓存实现就是优先把最近没有使用的数据置换出去(Not Recently Used)。从概率上说,最近没有使用的数据,未来使用的概率会比最近经常使用的数据低。缓存设计本身也是基于概率的,一种方案有没有价值必须经过实践验证——在内存缺页中断后,如果采用 NRU 置换页面,可以提高后续使用内存的命中率,这是实践得到的结论。
在页表中有一个访问位,代表页表有被读取过。还有一个脏位,代表页表被写入过。无论是读还是写,我们都可以认为是访问过。 为了提升效率,一旦页表被使用,可以用硬件将读位置 1,然后再设置一个定时器,比如 100ms 后,再将读位清 0。当有内存写入时,就将写位置 1。过一段时间将有内存写入的页回写到磁盘时,再将写位清 0。这样读写位在读写后都会置为 1,过段时间,也都会回到 0。
上面这种方式,就构成了一个最基本的 NRU 算法。每次置换的时候,操作系统尽量选择读、写位都是 0 的页面。而一个页面如果在内存中停留太久,没有新的读写,读写位会回到 0,就可能会被置换。
每次 FIFO 从队列尾部找到一个条目要置换出去的时候,就检查一下这个条目的读位。如果读位是 0,就删除这个条目。如果读位中有 1,就把这个条目从队列尾部移动到队列的头部,并且把读位清 0,相当于多给这个条目一次机会,因此也被称为第二次机会算法。多给一次机会,就相当于发生访问的页面更容易存活。而且,这样的算法利用天然的数据结构优势(队列),保证了 NRU 的同时,节省了去扫描整个缓存寻找读写位是 0 的条目的时间。
最近使用最少(LRU)
一种比 NRU 考虑更周密,实现成本更高的算法是最近最少使用(Least Recently Used, LRU)算法,它会置换最久没有使用的数据。和 NRU 相比,LRU 会考虑一个时间范围内的数据,对数据的参考范围更大。LRU 认为,最近一段时间最少使用到的数据应该被淘汰,把空间让给最近频繁使用的数据。
一种常见的实现方式是链表,用双向链表维护缓存条目。如果链表中某个缓存条目被使用到,那么就将这个条目重新移动到表头。如果要置换缓存条目出去,就直接从双线链表尾部删除一个条目。
通常 LRU 缓存还要提供查询能力,这里我们可以考虑用类似 Java 中 LinkedHashMap 的数据结构,同时具备双向链表和根据 Key 查找值的能力。
现在某个页表条目的累计值是 0, 接下来在多次计数中看到的读位是:1,0,0,1,1,那么累计值就会变成 3。这代表在某段时间内(5 个计数器 Tick 中)有 3 次访问操作。
通过这种方法,就解决了描述使用次数的问题。如果单纯基于使用次数最少判断置换,我们称为最少使用(Least Frequently Used,,LFU)算法。LFU 的劣势在于它不会忘记数据,累计值不会减少。但是可以根据tick的执行,每一轮修改累计数。
内存回收下篇:三色标记-清除算法是怎么回事?
接下来我们来考虑增加 GC(Incremental GC)的实现。首先对用户的修改进行分类,有这样 3 类修改(Mutation)需要考虑:
- 创建新对象
- 删除已有对象
- 调整已有引用
如果用户程序创建了新对象,可以考虑把新对象直接标记为灰色。虽然,也可以考虑标记为黑色,但是标记为灰色可以让 GC 意识到新增了未完成的任务。比如用户创建了新对象之后,新对象引用了之前删除的对象,就需要重新标记创建的部分。
如果用户删除了已有的对象,通常做法是等待下一次全量 Mark 算法处理。下图中我们删除了 Root Object 到 A 的引用,这个时候如果把 A 标记成白色,那么还需要判断是否还有其他路径引用到 A,而且 B,C 节点的颜色也需要重新计算。关键的问题是,虽然可以实现一个基于 A 的 DFS 去解决这个问题,但实际情况是我们并不着急解决这个问题,因为内存空间往往是有富余的。
在调整已有的引用关系时,三色标记算法的表现明显更好。下图是对象 B 将对 C 的引用改成了对 F 的引用,C,F 被加入灰色集合。接下来 GC 会递归遍历 C,F,最终然后 F,E,G 都会进入灰色集合。
内存回收就好比有人在随手扔垃圾,清洁工需要不停打扫。如果清洁工能够跟上人们扔垃圾的速度,那么就不需要太多的 STL(Stop The World)。如果清洁工跟不上扔垃圾的速度,最终环境就会被全部弄乱,这个时候清洁工就会要求“Stop The World”。三色算法的优势就在于它支持多一些情况的 Mutation,这样能够提高“垃圾”被并发回收的概率。
Linux 下的各个目录有什么作用?
因为 Linux 内文件系统较多,用途繁杂,Linux 对文件系统中的目录进行了一定的归类,如下图所示:
最顶层的目录称作根目录, 用/
表示。/
目录下用户可以再创建目录,但是有一些目录随着系统创建就已经存在,接下来我会和你一起讨论下它们的用途。
/bin(二进制)包含了许多所有用户都可以访问的可执行文件,如 ls, cp, cd 等。这里的大多数程序都是二进制格式的,因此称作bin
目录。bin
是一个命名习惯,比如说nginx
中的可执行文件会在 Nginx 安装目录的 bin 文件夹下面。
/dev(设备文件) 通常挂载在devtmpfs
文件系统上,里面存放的是设备文件节点。通常直接和内存进行映射,而不是存在物理磁盘上。
值得一提的是其中有几个有趣的文件,它们是虚拟设备。
/dev/null是可以用来销毁任何输出的虚拟设备。你可以用>
重定向符号将任何输出流重定向到/dev/null
来忽略输出的结果。
/dev/zero是一个产生数字 0 的虚拟设备。无论你对它进行多少次读取,都会读到 0。
/dev/ramdom是一个产生随机数的虚拟设备。读取这个文件中数据,你会得到一个随机数。你不停地读取这个文件,就会得到一个随机数的序列。
/etc(配置文件),/etc
名字的含义是and so on……
,也就是“等等及其他”,Linux 用它来保管程序的配置。比如说mysql
通常会在/etc/mysql
下创建配置。再比如说/etc/passwd
是系统的用户配置,存储了用户信息。
/proc(进程和内核文件) 存储了执行中进程和内核的信息。比如你可以通过/proc/1122
目录找到和进程1122
关联的全部信息。还可以在/proc/cpuinfo
下找到和 CPU 相关的全部信息。
/sbin(系统二进制) 和/bin
类似,通常是系统启动必需的指令,也可以包括管理员才会使用的指令。
/tmp(临时文件) 用于存放应用的临时文件,通常用的是tmpfs
文件系统。因为tmpfs
是一个内存文件系统,系统重启的时候清除/tmp
文件,所以这个目录不能放应用和重要的数据。
/var (Variable data file,,可变数据文件) 用于存储运行时的数据,比如日志通常会存放在/var/log
目录下面。再比如应用的缓存文件、用户的登录行为等,都可以放到/var
目录下,/var
下的文件会长期保存。
/boot(启动) 目录下存放了 Linux 的内核文件和启动镜像,通常这个目录会写入磁盘最头部的分区,启动的时候需要加载目录内的文件。
/opt(Optional Software,可选软件) 通常会把第三方软件安装到这个目录。以后你安装软件的时候,可以考虑在这个目录下创建。
/root(root 用户家目录) 为了防止误操作,Linux 设计中 root 用户的家目录没有设计在/home/root
下,而是放到了/root
目录。
/home(家目录) 用于存放用户的个人数据,比如用户lagou
的个人数据会存放到/home/lagou
下面。并且通常在用户登录,或者执行cd
指令后,都会在家目录下工作。 用户通常会对自己的家目录拥有管理权限,而无法访问其他用户的家目录。
/media(媒体) 自动挂载的设备通常会出现在/media
目录下。比如你插入 U 盘,通常较新版本的 Linux 都会帮你自动完成挂载,也就是在/media
下创建一个目录代表 U 盘。
/mnt(Mount,挂载) 我们习惯把手动挂载的设备放到这个目录。比如你插入 U 盘后,如果 Linux 没有帮你完成自动挂载,可以用mount
命令手动将 U 盘内容挂载到/mnt
目录下。
/svr(Service Data,,服务数据) 通常用来存放服务数据,比如说你开发的网站资源文件(脚本、网页等)。不过现在很多团队的习惯发生了变化, 有的团队会把网站相关的资源放到/www
目录下,也有的团队会放到/data
下。总之,在存放资源的角度,还是比较灵活的。
/usr(Unix System Resource) 包含系统需要的资源文件,通常应用程序会把后来安装的可执行文件也放到这个目录下,比如说
vim
编辑器的可执行文件通常会在/usr/bin
目录下,区别于ls
会在/bin
目录下/usr/sbin
中会包含有通常系统管理员才会使用的指令。/usr/lib
目录中存放系统的库文件,比如一些重要的对象和动态链接库文件。/usr/lib
目录下会有大量的.so
文件,这些叫作Shared Object
,类似windows
下的dll
文件。/usr/share
目录下主要是文档,比如说 man 的文档都在/usr/share/man
下面。
文件系统的底层实现:FAT、NTFS 和 Ext3 有什么区别?
硬盘分块
在了解文件系统实现之前,我们先来了解下操作系统如何使用硬盘。
使用硬盘和使用内存有一个很大的区别,内存可以支持到字节级别的随机存取,而这种情况在硬盘中通常是不支持的。过去的机械硬盘内部是一个柱状结构,有扇区、柱面等。读取硬盘数据要转动物理的磁头,每转动一次磁头时间开销都很大,因此一次只读取一两个字节的数据,非常不划算。
随着 SSD 的出现,机械硬盘开始逐渐消失(还没有完全结束),现在的固态硬盘内部是类似内存的随机存取结构。但是硬盘的读写速度还是远远不及内存。而连续读多个字节的速度,还远不如一次读一个硬盘块的速度。
因此,为了提高性能,通常会将物理存储(硬盘)划分成一个个小块,比如每个 4KB。这样做也可以让硬盘的使用看起来非常整齐,方便分配和回收空间。况且,数据从磁盘到内存,需要通过电子设备,比如 DMA、总线等,如果一个字节一个字节读取,速度较慢的硬盘就太耗费时间了。过去的机械硬盘的速度可以比内存慢百万倍,现在的固态硬盘,也会慢几十到几百倍。即便是最新的 NvMe 接口的硬盘,和内存相比速度仍然有很大的差距。因此,一次读/写一个块(Block)才是可行的方案。
如上图所示,操作系统会将磁盘分成很多相等大小的块。这样做还有一个好处就是如果你知道块的序号,就可以准确地计算出块的物理位置。
文件的描述
我们将硬盘分块后,如何利用上面的硬盘存储文件,就是文件系统(File System)要负责的事情了。当然目录也是一种文件,因此我们先讨论文件如何读写。不同的文件系统利用方式不同,今天会重点讨论 3 种文件系统:
- 早期的 FAT 格式
- 基于 inode 的传统文件系统
- 日志文件系统(如 NTFS, EXT2、3、4)
FAT 表
早期人们找到了一种方案就是文件分配表(File Allocate Table,FAT)。如下图所示:
一个文件,最基本的就是要描述文件在硬盘中到底对应了哪些块。FAT 表通过一种类似链表的结构描述了文件对应的块。上图中:文件 1 从位置 5 开始,这就代表文件 1 在硬盘上的第 1 个块的序号是 5 的块 。然后位置 5 的值是 2,代表文件 1 的下一个块的是序号 2 的块。顺着这条链路,我们可以找到 5 → 2 → 9 → 14 → 15 → -1。-1 代表结束,所以文件 1 的块是:5,2,9,14,15。同理,文件 2 的块是 3,8,12。
FAT 通过一个链表结构解决了文件和物理块映射的问题,算法简单实用,因此得到过广泛的应用,到今天的 Windows/Linux/MacOS 都还支持 FAT 格式的文件系统。FAT 的缺点就是非常占用内存,比如 1T 的硬盘,如果块的大小是 1K,那么就需要 1G 个 FAT 条目。通常一个 FAT 条目还会存一些其他信息,需要 2~3 个字节,这就又要占用 2-3G 的内存空间才能用 FAT 管理 1T 的硬盘空间。显然这样做是非常浪费的,问题就出在了 FAT 表需要全部维护在内存当中。
索引节点(inode)
为了改进 FAT 的容量限制问题,可以考虑为每个文件增加一个索引节点(inode)。这样,随着虚拟内存的使用,当文件导入内存的时候,先导入索引节点(inode),然后索引节点中有文件的全部信息,包括文件的属性和文件物理块的位置。
如上图,索引节点除了属性和块的位置,还包括了一个指针块的地址。这是为了应对文件非常大的情况。一个大文件,一个索引节点存不下,需要通过指针链接到其他的块去描述文件。
这种文件索引节点(inode)的方式,完美地解决了 FAT 的缺陷,一直被沿用至今。FAT 要把所有的块信息都存在内存中,索引节点只需要把用到的文件形成数据结构,而且可以使用虚拟内存分配空间,随着页表置换,这就解决了 FAT 的容量限制问题。
目录的实现
有了文件的描述,接下来我们来思考如何实现目录(Directory)。目录是特殊的文件,所以每个目录都有自己的 inode。目录是文件的集合,所以目录的内容中必须有所有其下文件的 inode 指针。
文件名也最好不要放到 inode 中,而是放到文件夹中。这样就可以灵活设置文件的别名,及实现一个文件同时在多个目录下。
如上图,/foo 和 /bar 两个目录中的 b.txt 和 c.txt 其实是一个文件,但是拥有不同的名称。这种形式我们称作“硬链接”,就是多个文件共享 inode。
硬链接有一个非常显著的特点,硬链接的双方是平等的。上面的程序我们用ln
指令为文件 a 创造了一个硬链接b
。如果我们创造完删除了 a,那么 b 也是可以正常工作的。如果要删除掉这个文件的 inode,必须 a,b 同时删除。这里你可以看出 a,b 是平等的。
和硬链接相对的是软链接,软链接的原理如下图:
图中c.txt
是b.txt
的一个软链接,软链接拥有自己的inode
,但是文件内容就是一个快捷方式。因此,如果我们删除了b.txt
,那么b.txt
对应的 inode 也就被删除了。但是c.txt
依然存在,只不过指向了一个空地址(访问不到)。如果删除了c.txt
,那么不会对b.txt
造成任何影响。
在 Linux 中可以通过ln -s
创造软链接。
ln -s a b # 将b设置为a的软链接(b是a的快捷方式)
以上,我们对文件系统的实现有了一个初步的了解。从整体设计上,本质还是将空间切块,然后划分成目录和文件管理这些分块。读、写文件需要通过 inode 操作磁盘。操作系统提供的是最底层读写分块的操作,抽象成文件就交给文件系统。比如想写入第 10001 个字节,那么会分成这样几个步骤:
- 修改内存中的数据
- 计算要写入第几个块
- 查询 inode 找到真实块的序号
- 将这个块的数据完整的写入一次磁盘
你可以思考一个问题,如果频繁读写磁盘,上面这个模型会有什么问题?可以把你的思考和想法写在留言区,我们在本讲后面会详细讨论。
解决性能和故障:日志文件系统
在传统的文件系统实现中,inode 解决了 FAT 容量限制问题,但是随着 CPU、内存、传输线路的速度越来越快,对磁盘读写性能的要求也越来越高。传统的设计,每次写入操作都需要进行一次持久化,所谓“持久化”就是将数据写入到磁盘,这种设计会成为整个应用的瓶颈。因为磁盘速度较慢,内存和 CPU 缓存的速度非常快,如果 CPU 进行高速计算并且频繁写入磁盘,那么就会有大量线程阻塞在等待磁盘 I/O 上。磁盘的瓶颈通常在写入上,因为通常读取数据的时候,会从缓存中读取,不存在太大的瓶颈。
加速写入的一种方式,就是利用缓冲区。
上图中所有写操作先存入缓冲区,然后每过一定的秒数,才进行一次持久化。 这种设计,是一个很好的思路,但最大的问题在于容错。 比如上图的步骤 1 或者步骤 2 只执行了一半,如何恢复?如果步骤 2 只写入了一半,那么数据就写坏了。如果步骤 1 只写入了一半,那么数据就丢失了。无论出现哪种问题,都不太好处理。更何况写操作和写操作之间还有一致性问题,比如说一次删除 inode 的操作后又发生了写入……
解决上述问题的一个非常好的方案就是利用日志。假设 A 是文件中某个位置的数据,比起传统的方案我们反复擦写 A,日志会帮助我们把 A 的所有变更记录下来,比如:
A=1 A=2 A=3
上面 A 写入了 3 次,因此有 3 条日志。日志文件系统文件中存储的就是像上面那样的日志,而不是文件真实的内容。当用户读取文件的时候,文件内容会在内存中还原,所以内存中 A 的值是 3,但实际磁盘上有 3 条记录。
从性能上分析,如果日志造成了 3 倍的数据冗余,那么读取的速度并不会真的慢三倍。因为我们多数时候是从内存和 CPU 缓存中读取数据。而写入的时候,因为采用日志的形式,可以考虑下图这种方式,在内存缓冲区中积累一批日志才写入一次磁盘。
上图这种设计可以让写入变得非常快速,多数时间都是写内存,最后写一次磁盘。而上图这样的设计成不成立,核心在能不能解决容灾问题。
你可以思考一下这个问题——丢失一批日志和丢失一批数据的差别大不大。其实它们之间最大的差别在于,如果丢失一批日志,只不过丢失了近期的变更;但如果丢失一批数据,那么就可能造成永久伤害。
举个例子,比如说你把最近一天的订单数据弄乱了,你可以通过第三方支付平台的交易流水、系统的支付记录等帮助用户恢复数据,还可以通过订单关联的用户信息查询具体是哪些用户的订单出了问题。但是如果你随机删了一部分订单, 那问题就麻烦了。你要去第三发支付平台调出所有流水,用大数据引擎进行分析和计算。
为了进一步避免损失,一种可行的方案就是创建还原点(Checkpoint),比如说系统把最近 30s 的日志都写入一个区域中。下一个 30s 的日志,写入下一个区域中。每个区域,我们称作一个还原点。创建还原点的时候,我们将还原点涂成红色,写入完成将还原点涂成绿色。
如上图,当日志文件系统写入磁盘的时候,每隔一段时间就会把这段时间内的所有日志写入一个或几个连续的磁盘块,我们称为还原点(Checkpoint)。操作系统读入文件的时候,依次读入还原点的数据,如果是绿色,那么就应用这些日志,如果是红色,就丢弃。所以上图中还原点 3 的数据是不完整的,这个时候会丢失不到 30s 的数据。如果将还原点的间隔变小,就可以控制风险的粒度。另外,我们还可以对还原点 3 的数据进行深度恢复,这里可以有人工分析,也可以通过一些更加复杂的算法去恢复。
总结
这一讲我们学习了 3 种文件系统的实现,我们再来一起总结回顾一下。
- FAT 的设计简单高效,如果你要自己管理一定的空间,可以优先考虑这种设计。
- inode 的设计在内存中创造了一棵树状结构,对文件、目录进行管理,并且索引到磁盘中的数据。这是一种经典的数据结构,这种思路会被数据库设计、网络资源管理、缓存设计反复利用。
- 日志文件系统——日志结构简单、容易存储、按时间容易分块,这样的设计非常适合缓冲、批量写入和故障恢复。
现在我们很多分布式系统的设计也是基于日志,比如 MySQL 同步数据用 binlog,Redis 的 AOF,著名的分布式一致性算法 Paxos ,因此 Zookeeper 内部也在通过实现日志的一致性来实现分布式一致性。
那么通过这节课的学习,你现在可以尝试来回答本节关联的面试题目:FAT、NTFS 和 Ext3 有什么区别?
【解析】FAT 通过内存中一个类似链表的结构,实现对文件的管理。NTFS 和 Ext3 是日志文件系统,它们和 FAT 最大的区别在于写入到磁盘中的是日志,而不是数据。日志文件系统会先把日志写入到内存中一个高速缓冲区,定期写入到磁盘。日志写入是追加式的,不用考虑数据的覆盖。一段时间内的日志内容,会形成还原点。这种设计大大提高了性能,当然也会有一定的数据冗余。
公私钥体系和网络安全:什么是中间人攻击?
证书为公钥提供方提供公正机制。证书之所以拥有信用,是因为证书的签发方拥有信用。假设 Alice 想让 Bob 承认自己的公钥。Alice 不能把公钥直接给 Bob,而是要提供第三方公证机构签发的、含有自己公钥的证书。如果 Bob 也信任这个第三方公证机构,信任关系和签约就成立。当然,法律也得承认,不然没法打官司。
如上图所示,Alice 将自己的申请提交给机构,产生证书的原文。机构用自己的私钥签名 Alice 的申请原文(先根据原文内容计算摘要,再用私钥加密),得到带有签名信息的证书。Bob 拿到带签名信息的证书,通过第三方机构的公钥进行解密,获得 Alice 证书的摘要、证书的原文。有了 Alice 证书的摘要和原文,Bob 就可以进行验签。验签通过,Bob 就可以确认 Alice 的证书的确是第三方机构签发的。
用上面这样一个机制,合同的双方都无法否认合同。这个解决方案的核心在于需要第三方信用服务机构提供信用背书。这里产生了一个最基础的信任链,如果第三方机构的信任崩溃,比如被黑客攻破,那整条信任链条也就断裂了。
中间人攻击
最后我们再来说说中间人攻击。在 HTTPS 协议当中,客户端需要先从服务器去下载证书,然后再通过信任链验证服务器的证书。当证书被验证为有效且合法时,客户端和服务器之间会利用非对称加密协商通信的密码,双方拥有了一致的密码和加密算法之后,客户端和服务器之间会进行对称加密的传输。
在上述过程当中,要验证一个证书是否合法,就必须依据信任链,逐级的下载证书。但是根证书通常不是下载的,它往往是随着操作系统预安装在机器上的。如果黑客能够通过某种方式在你的计算机中预装证书,那么黑客也可以伪装成中间节点。如下图所示:
一方面,黑客向客户端提供伪造的证书,并且这个伪造的证书会在客户端中被验证为合法。因为黑客已经通过其他非法手段在客户端上安装了证书。举个例子,比如黑客利用 U 盘的自动加载程序,偷偷地将 U 盘插入客户端机器上一小段时间预装证书。
安装证书后,黑客一方面和客户端进行正常的通信,另一方面黑客和服务器之间也建立正常的连接。这样黑客在中间就可以拿到客户端到服务器的所有信息,并从中获利。
虚拟化技术介绍:VMware 和 Docker 的区别?
最直观的方案就是将虚拟机管理程序 Hypervisor 作为操作系统,在虚拟机管理程序(Hypervisor)之上再去构建更多的虚拟机。像这种管理虚拟机的架构,也称为 Type-1 虚拟机,如下图所示:
Type-1 虚拟机本身是一个操作系统,所以需要用户预装。为了方便用户的使用,VMware 还推出了 Type-2 虚拟机,如下图所示:
在第二种设计当中,虚拟机本身也作为一个进程。它和操作系统中执行的其他进程并没有太大的区别。但是为了提升性能,有一部分 Hypervisor 程序会作为内核中的驱动执行。当虚拟机操作系统(Guest OS)执行程序的时候,会通过 Hypervisor 实现世界切换。因此,虽然和 Type-1 虚拟机有一定的区别,但是从本质上来看差距不大,同样是需要二进制翻译技术和虚拟化技术。
随着虚拟机的发展,现在也出现了很多混合型的虚拟机,比如微软的 Hyper-v 技术。从下图中你会看到,虚拟机的管理程序(Parent Partition)及 Windows 的核心程序,都会作为一个虚拟化的节点,拥有一个自己的 VMBus,并且通过 Hypervisor 实现虚拟化。
在 Hyper-V 的架构当中不存在一个主的操作系统。实际上,用户开机之后就在使用虚拟机,Windows 通过虚拟机执行。在这种架构下,其他的虚拟机,比如用 VMware 管理的虚拟机也可以复用这套架构。
Linux 架构优秀在哪里
组合性设计(Composability)
管道设计(Pipeline)
一个应用的输出,应该是另一个应用的输入。这句话,其实道出了计算的本质。
计算其实就是将一个计算过程的输出给另一个计算过程作为输入。在构造流计算、管道运算、Monad 类型、泛型容器体系时——很大程度上,我们希望计算过程间拥有一定的相似性,比如泛型类型的统一。这样才可以把一个过程的输出给到另一个过程的输入。
重构和丢弃
写复杂的程序就是写错了
优先使用工具而不是“熟练”
很多程序员在工作当中都忽略了去积累工具。比如说:
- 你经常要重新配置自己的开发环境,也不肯做一个 Docker 的镜像;
- 你经常要重新部署自己的测试环境,而且有时候还会出现使用者太多而不够用的情况。即使这样的情况屡屡发生,也不肯做一下容器化的管理;
- Git 的代码提交之后,不会触发自动化测试,需要人工去点鼠标,甚至需要由资深的测试手动去测。
其他优秀的原则
比如:不要试图猜测程序可能的瓶颈在哪里,而是试图证明这个瓶颈,因为瓶颈会出现在出乎意料的地方。这句话告诉我们,要多写性能测试程序并且构造压力测试的场景。只有这样,才能让你的程序更健壮,承载更大的压力。
再比如:花哨的算法在业务规模小的时候通常运行得很慢,因此业务规模小的时候不要用花哨的算法。简单的算法,往往性能更高。如果你的业务规模很大,可以尝试去测试并证明需要用怎样的算法。
这也是我们在架构程序的时候经常会出错的地方。我们习惯性地选择用脑海中记忆的时间复杂度最低的算法,但是却忽略了时间复杂度只是一种增长关系,一个算法在某个场景中到底可不可行,是要以实际执行时收集数据为准的。
再比如:数据主导规则。当你的数据结构设计得足够好,那么你的计算方法就会深刻地反映出你系统的逻辑。这也叫作自证明代码。编程的核心是构造好的数据结构,而不是算法。
尽管我们在学习的时候,算法和数据结构是一起学的。但是在大牛们看来,数据结构的抽象可以深刻反映系统的本质。比如抽象出文件描述符反应文件、抽象出页表反应内存、抽象出 Socket 反应连接——这些数据结构才是设计系统最核心的东西。