图解操作系统
2023-01-09 00:14:10 39 举报AI智能生成
小林coding 图解 OS 脑图总结
操作系统
OS
图解
模版推荐
作者其他创作
大纲/内容
硬件结构
CPU 是如何执行程序的?
介绍
<font color="#2c3e50">代码写了那么多,你知道 </font><code><font color="#476582">a = 1 + 2</font></code><font color="#2c3e50"> 这条代码是怎么被 CPU 执行的吗?</font>
<font color="#2c3e50">软件用了那么多,你知道软件的 32 位和 64 位之间的区别吗?再来 32 位的操作系统可以运行<br>在 64 位的电脑上吗?64 位的操作系统可以运行在 32 位的电脑上吗?如果不行,原因是什么?</font>
<font color="#2c3e50">CPU 看了那么多,我们都知道 CPU 通常分为 32 位和 64 位,你知道 64 位相比 <br>32位 CPU 的优势在哪吗?64 位 CPU 的计算性能一定比 32 位 CPU 高很多吗?</font>
不知道也不用慌张,接下来就循序渐进的、一层一层的攻破这些问题。<br>
图灵机的工作方式
<font color="#2c3e50">要想知道程序执行的原理,我们可以先从「图灵机」说起,图灵的基本思想是用机器来模拟人们<br>用纸笔进行数学运算的过程,而且还定义了计算机由哪些部分组成,程序又是如何执行的。</font>
<font color="#2c3e50">图灵机长什么样子呢?你从下图可以看到图灵机的实际样子:</font>
图灵机的基本组成如下:<br><br><ul><li>有一条「纸带」,纸带由一个个连续的格子组成,每个格子可以写入字符,纸带就好比内存,而纸带上的格子的字符就好比内存中的数据或程序;</li><li>有一个「读写头」,读写头可以读取纸带上任意格子的字符,也可以把字符写入到纸带的格子;</li><li>读写头上有一些部件,比如存储单元、控制单元以及运算单元: <br>1、存储单元用于存放数据; <br>2、控制单元用于识别字符是数据还是指令,以及控制程序的流程等; <br>3、运算单元用于执行运算指令;</li></ul>
知道了图灵机的组成后,我们以简单数学运算的 1 + 2 作为例子,来看看它是怎么执行这行代码的。<br>
首先,用读写头把 「1、2、+」这 3 个字符分别写入到纸带上的 3 个格子,然后读写头先停在 1 字符对应的格子上;
接着,读写头读入 1 到存储设备中,这个存储设备称为图灵机的状态;
然后读写头向右移动一个格,用同样的方式把 2 读入到图灵机的状态,于是现在图灵机的状态中存储着两个连续的数字, 1 和 2;
读写头再往右移动一个格,就会碰到 + 号,读写头读到 + 号后,将 + 号传输给「控制单元」,控制单元发现是一个 + 号而不是数字,<br>所以没有存入到状态中,因为 + 号是运算符指令,作用是加和目前的状态,于是通知「运算单元」工作。运算单元收到要加和状态中<br>的值的通知后,就会把状态中的 1 和 2 读入并计算,再将计算的结果 3 存放到状态中;
最后,运算单元将结果返回给控制单元,控制单元将结果传输给读写头,读写头向右移动,把结果 3 写入到纸带的格子中;
<font color="#2c3e50">通过上面的图灵机计算 </font><code><font color="#476582">1 + 2</font></code><font color="#2c3e50"> 的过程,可以发现图灵机主要功能就是读取纸带格子中的内容,然后交给控制单元识别字符是数字还是运算符指令,如果是数字则存入到图灵机状态中,如果是运算符,则通知运算符单元读取状态中的数值进行计算,计算结果最终返回给读写头,读写头把结果写入到纸带的格子中。</font>
事实上,图灵机这个看起来很简单的工作方式,和我们今天的计算机是基本一样的。接下来,我们一同再看看当今计算机的组成以及工作方式。<br>
冯诺依曼模型
介绍
<font color="#2c3e50">在 1945 年冯诺依曼和其他计算机科学家们提出了计算机具体实现的报告,其遵循了图灵机的设计,<br>而且还提出用电子元件构造计算机,并约定了用二进制进行计算和存储。</font>
<font color="#2c3e50">最重要的是定义计算机基本结构为 5 个部分,分别是 </font><strong><font color="#304ffe">运算器、控制器、存储器、输入设备、输出设备</font></strong><font color="#2c3e50">,这 5 个部分也被称为 </font><strong><font color="#304ffe">冯诺依曼模型</font></strong><font color="#2c3e50">。</font>
<font color="#2c3e50">运算器、控制器是在中央处理器里的,存储器就我们常见的内存,输入输出设备则是计算机外接的设备,比如键盘就是输入设备,显示器就是输出设备。</font>
<font color="#2c3e50">存储单元和输入输出设备要与中央处理器打交道的话,离不开 </font><b style=""><font color="#0000ff">总线</font></b><font color="#2c3e50">。所以,它们之间的关系如下图:</font>
接下来,分别介绍内存、中央处理器、总线、输入输出设备。<br>
内存
<font color="#2c3e50">我们的程序和数据都是存储在内存,存储的区域是线性的。</font>
<font color="#2c3e50">在计算机数据存储中,存储数据的基本单位是 </font><strong><font color="#304ffe">字节(<em style="color: rgb(200, 73, 255);">byte</em>)</font></strong><font color="#2c3e50">,1 字节等于 8 位(8 bit)。每一个字节都对应一个内存地址。</font>
<font color="#2c3e50">内存的地址是从 0 开始编号的,然后自增排列,最后一个地址为内存总字节数 - 1,<br>这种结构好似我们程序里的数组,所以内存读写任何一个数据的速度都是一样的。</font>
中央处理器
<span style="font-size: inherit;">中央处理器也就是我们常说的 CPU,32 位和 64 位 CPU 最主要区别在于 <b><font color="#0000ff">一次能计算多少字节数据</font></b>:</span><br><br><ul><li><span style="font-size: inherit;">32 位 CPU 一次可以计算 4 个字节;</span></li><li><span style="font-size: inherit;">64 位 CPU 一次可以计算 8 个字节;</span></li></ul>
<font color="#2c3e50">这里的 32 位和 64 位,通常称为 CPU 的位宽。</font>
<font color="#2c3e50">之所以 CPU 要这样设计,是为了能计算更大的数值,如果是 8 位的 CPU,那么一次只能计算 1 个字节 </font><code><font color="#476582">0~255</font></code><font color="#2c3e50"> 范围内的数值,<br>这样就无法一次完成计算 </font><code><font color="#476582">10000 * 500</font></code><font color="#2c3e50"> ,于是为了能一次计算大数的运算,CPU 需要支持多个 byte 一起计算,所以 CPU <br>位宽越大,可以计算的数值就越大,比如说 32 位 CPU 能计算的最大整数是 </font><code><font color="#476582">4294967295</font></code><font color="#2c3e50">。</font>
<font color="#2c3e50">CPU 内部还有一些组件,常见的有 </font><strong><font color="#304ffe">寄存器、控制单元和逻辑运算单元 </font></strong><font color="#2c3e50">等。其中:<br><ul><li><font color="#2c3e50">控制单元负责控制 CPU 工作;</font></li><li><font color="#2c3e50">逻辑运算单元负责计算;</font></li><li><font color="#2c3e50">而寄存器可以分为多种类,每种寄存器的功能又不尽相同。</font></li></ul></font>
<font color="#2c3e50">CPU 中的寄存器主要作用是存储计算时的数据,你可能好奇为什么有了内存还需要寄存器?原因很简单,<br>因为内存离 CPU 太远了,而 </font><font color="#0000ff"><b>寄存器就在 CPU 里,还紧挨着控制单元和逻辑运算单元</b></font><font color="#2c3e50">,自然 </font><font color="#0000ff"><b>计算时速度会很快</b></font><font color="#2c3e50">。</font>
常见的寄存器种类:<br><br><ul><li><font color="#ff00ff"><i>通用寄存器</i></font>,用来存放需要进行运算的数据,比如需要进行加和运算的两个数据。</li><li><i><font color="#ff00ff">程序计数器</font></i>,用来存储 CPU 要执行下一条指令「所在的内存地址」,注意不是存储了<br>下一条要执行的指令,此时指令还在内存中,程序计数器只是存储了下一条指令「的地址」。</li><li><i><font color="#ff00ff">指令寄存器</font></i>,用来存放当前正在执行的指令,也就是指令本身,指令被执行完成之前,指令都存储在这里。</li></ul>
总线
总线是用于 CPU 和内存以及其他设备之间的通信,总线可分为 3 种:<br><br><ul><li><i><font color="#ff00ff">地址总线</font></i>,用于指定 CPU 将要操作的内存地址;</li><li><i><font color="#ff00ff">数据总线</font></i>,用于读写内存的数据;</li><li><i><font color="#ff00ff">控制总线</font></i>,用于发送和接收信号,比如中断、设备复位等信号,CPU 收到信号后自然进行响应,这时也需要控制总线;</li></ul>
当 CPU 要读写内存数据的时候,一般需要通过下面这三个总线:<br><br><ul><li>首先要通过「地址总线」来指定内存的地址;</li><li>然后通过「控制总线」控制是读或写命令;</li><li>最后通过「数据总线」来传输数据;</li></ul>
输入输出设备
<font color="#2c3e50">输入设备向计算机输入数据,计算机经过计算后,把数据输出给输出设备。</font>
<font color="#2c3e50">如果输入设备是键盘,按下按键时是需要和 CPU 进行交互的,这时就需要用到控制总线了。</font>
线路位宽与 CPU 位宽
<font color="#2c3e50">数据是如何通过线路传输的呢?其实是通过操作电压,低电压表示 0,高压电压则表示 1。</font>
<font color="#2c3e50">如果构造了高低高这样的信号,其实就是 101 二进制数据,十进制则表示 5,如果只有一条线路,就意味着每次<br>只能传递 1 bit 的数据,即 0 或 1,那么传输 101 这个数据,就需要 3 次才能传输完成,这样的效率非常低。</font>
<font color="#2c3e50">这样一位一位传输的方式,称为串行,下一个 bit 必须等待上一个 bit 传输完成才能进行传输。<br>当然,想一次多传一些数据,增加线路即可,这时数据就可以并行传输。</font>
<font color="#2c3e50">为了避免低效率的串行传输的方式,线路的位宽最好一次就能访问到所有的内存地址。</font>
CPU 想要操作「内存地址」就需要「地址总线」:<br><br><ul><li>如果地址总线只有 1 条,那每次只能表示 「0 或 1」这两种地址,所以 CPU 能操作的<br>内存地址最大数量为 2(2^1)个(注意,不要理解成同时能操作 2 个内存地址);</li><li>如果地址总线有 2 条,那么能表示 00、01、10、11 这四种地址,所以 CPU 能操作的<br>内存地址最大数量为 4(2^2)个。</li></ul>
<font color="#2c3e50">那么,想要 CPU 操作 4G 大的内存,那么就需要 32 条地址总线,因为 </font><code><font color="#476582">2 ^ 32 = 4G</font></code><font color="#2c3e50">。</font>
<font color="#2c3e50">知道了线路位宽的意义后,我们再来看看 CPU 位宽。</font>
<font color="#2c3e50">CPU 的位宽最好不要小于线路位宽,比如 32 位 CPU 控制 40 位宽的地址总线和数据总线的话,工作起来就会非常复杂且麻烦,<br>所以 32 位的 CPU 最好和 32 位宽的线路搭配,因为 32 位 CPU 一次最多只能操作 32 位宽的地址总线和数据总线。</font>
<font color="#2c3e50">如果用 32 位 CPU 去加和两个 64 位大小的数字,就需要把这 2 个 64 位的数字分成 2 个低位 32 位数字和 2 个高位 32 位数字来计算,<br>先加个两个低位的 32 位数字,算出进位,然后加和两个高位的 32 位数字,最后再加上进位,就能算出结果了,可以发现 32 位 CPU <br>并不能一次性计算出加和两个 64 位数字的结果。</font>
<font color="#2c3e50">对于 64 位 CPU 就可以一次性算出加和两个 64 位数字的结果,因为 64 位 CPU 可以一次读入 64 位的数字,<br>并且 64 位 CPU 内部的逻辑运算单元也支持 64 位数字的计算。</font>
<font color="#2c3e50">但是并不代表 64 位 CPU 性能比 32 位 CPU 高很多,很少应用需要算超过 32 位的数字,所以 </font><strong><font color="#304ffe">如果计算的数额不超过 32位<br>数字的情况下,32 位和 64 位 CPU 之间没什么区别的,只有当计算超过 32 位数字的情况下,64 位的优势才能体现出来</font></strong><font color="#2c3e50">。</font>
<font color="#2c3e50">另外,32 位 CPU 最大只能操作 4GB 内存,就算你装了 8 GB 内存条,也没用。而 64 位 CPU 寻址范围则很大,理论最大的寻址空间为 </font><code><font color="#476582">2^64</font></code><font color="#2c3e50">。</font>
程序执行的基本过程
<font color="#2c3e50">在前面,我们知道了程序在图灵机的执行过程,接下来我们来看看程序在冯诺依曼模型上是怎么执行的。</font>
程序实际上是一条一条指令,所以程序的运行过程就是把每一条指令一步一步的执行起来,负责执行指令的就是 CPU 了。<br>
那 CPU 执行程序的过程如下:<br><br><ul><li>第一步,CPU 读取「程序计数器」的值,这个值是指令的内存地址,然后 CPU 的「控制单元」操作「地址总线」指定需要访问的内存地址,接着通知<br>内存设备准备数据,数据准备好后通过「数据总线」将指令数据传给 CPU,CPU 收到内存传来的数据后,将这个指令数据存入到「指令寄存器」。</li><li>第二步,「程序计数器」的值自增,表示指向下一条指令。这个自增的大小,由 CPU 的位宽决定,<br>比如 32 位的 CPU,指令是 4 个字节,需要 4 个内存地址存放,因此「程序计数器」的值会自增 4;</li><li>第三步,CPU 分析「指令寄存器」中的指令,确定指令的类型和参数,如果是计算类型的指令,<br>就把指令交给「逻辑运算单元」运算;如果是存储类型的指令,则交由「控制单元」执行;</li></ul>
<font color="#2c3e50">简单总结一下就是,一个程序执行的时候,CPU 会根据程序计数器里的内存地址,从内存里面把<br>需要执行的指令读取到指令寄存器里面执行,然后根据指令长度自增,开始顺序读取下一条指令。</font>
<font color="#2c3e50">CPU 从程序计数器读取指令、到执行、再到下一条指令,这个过程会不断循环,直到程序执行结束,这个不断循环的过程被称为 </font><strong><font color="#304ffe">CPU 的指令周期</font></strong><font color="#2c3e50">。</font>
a = 1 + 2 执行具体过程
执行过程
<font color="#2c3e50">知道了基本的程序执行过程后,接下来用 </font><code><font color="#476582">a = 1 + 2</font></code><font color="#2c3e50"> 的作为例子,进一步分析该程序在冯诺伊曼模型的执行过程。</font>
<font color="#2c3e50">CPU 是不认识 </font><code><font color="#476582">a = 1 + 2</font></code><font color="#2c3e50"> 这个字符串,这些字符串只是方便我们程序员认识,要想这段程序能跑起来,<br>还需要把整个程序翻译成 </font><strong><font color="#304ffe">汇编语言 </font></strong><font color="#2c3e50">的程序,这个过程称为编译成汇编代码。</font>
<font color="#2c3e50">针对汇编代码,我们还需要用汇编器翻译成机器码,这些机器码由 0 和 1 组成的机器语言,<br>这一条条机器码,就是一条条的 </font><strong><font color="#304ffe">计算机指令</font></strong><font color="#2c3e50">,这个才是 CPU 能够真正认识的东西。</font>
<font color="#2c3e50">下面来看看 </font><code><font color="#476582">a = 1 + 2</font></code><font color="#2c3e50"> 在 32 位 CPU 的执行过程。</font>
程序编译过程中,编译器通过分析代码,发现 1 和 2 是数据,于是程序运行时,内存会有个<br>专门的区域来存放这些数据,这个区域就是「数据段」。如下图,数据 1 和 2 的区域位置:<br><br><ul><li>数据 1 被存放到 0x200 位置;</li><li>数据 2 被存放到 0x204 位置;</li></ul>
<font color="#2c3e50">注意,数据和指令是分开区域存放的,存放指令区域的地方称为「正文段」。</font>
编译器会把 a = 1 + 2 翻译成 4 条指令,存放到正文段中。如图,这 4 条指令被存放到了 0x100 ~ 0x10c 的区域中:<br><br><ul><li>0x100 的内容是 load 指令将 0x200 地址中的数据 1 装入到寄存器 R0;</li><li>0x104 的内容是 load 指令将 0x204 地址中的数据 2 装入到寄存器 R1;</li><li>0x108 的内容是 add 指令将寄存器 R0 和 R1 的数据相加,并把结果存放到寄存器 R2;</li><li>0x10c 的内容是 store 指令将寄存器 R2 中的数据存回数据段中的 0x208 地址中,这个地址也就是变量 a 内存中的地址;</li></ul>
<font color="#2c3e50">编译完成后,具体执行程序的时候,程序计数器会被设置为 0x100 地址,然后依次执行这 4 条指令。</font>
<font color="#2c3e50">上面的例子中,由于是在 32 位 CPU 执行的,因此一条指令是占 32 位大小,所以你会发现每条指令间隔 4 个字节。</font>
<font color="#2c3e50">而数据的大小是根据你在程序中指定的变量类型,比如 </font><code><font color="#476582">int</font></code><font color="#2c3e50"> 类型的数据则占 4 个字节,</font><code><font color="#476582">char</font></code><font color="#2c3e50"> 类型的数据则占 1 个字节。</font>
机器所识别的指令
<font color="#2c3e50">上面的例子中,图中指令的内容我写的是简易的汇编代码,目的是为了方便理解指令的具体内容,事实上指令<br>的内容是一串二进制数字的机器码,每条指令都有对应的机器码,CPU 通过解析机器码来知道指令的内容。</font>
<font color="#2c3e50">不同的 CPU 有不同的指令集,也就是对应着不同的汇编语言和不同的机器码,接下来选用最简单的 MIPS 指集,<br>来看看机器码是如何生成的,这样也能明白二进制的机器码的具体含义。</font>
<font color="#2c3e50">MIPS 的指令是一个 32 位的整数,高 6 位代表着操作码,表示这条指令是一条什么样的指令,<br>剩下的 26 位不同指令类型所表示的内容也就不相同,主要有三种类型 R、I 和 J。</font>
一起具体看看这三种类型的含义:<br><br><ul><li><i><font color="#ff00ff">R 指令</font></i>,用在算术和逻辑操作,里面有读取和写入数据的寄存器地址。如果是逻辑位移操作,后面还有位移操作的「位移量」,<br>而最后的「功能码」则是再前面的操作码不够的时候,扩展操作码来表示对应的具体指令的;</li><li><i><font color="#ff00ff">I 指令</font></i>,用在数据传输、条件分支等。这个类型的指令,就没有了位移量和功能码,也没有了第三个寄存器,<br>而是把这三部分直接合并成了一个地址值或一个常数;</li><li><font color="#ff00ff"><i>J 指令</i></font>,用在跳转,高 6 位之外的 26 位都是一个跳转后的地址;</li></ul>
<font color="#2c3e50">接下来,我们把前面例子的这条指令:「</font><code><font color="#476582">add</font></code><font color="#2c3e50"> 指令将寄存器 </font><code><font color="#476582">R0</font></code><font color="#2c3e50"> 和 </font><code><font color="#476582">R1</font></code><font color="#2c3e50"> 的数据相加,并把结果放入到 </font><code><font color="#476582">R2</font></code><font color="#2c3e50">」,翻译成机器码。</font>
加和运算 add 指令是属于 R 指令类型:<br><br><ul><li>add 对应的 MIPS 指令里操作码是 000000,以及最末尾的功能码是 100000,<br>这些数值都是固定的,查一下 MIPS 指令集的手册就能知道的;</li><li>rs 代表第一个寄存器 R0 的编号,即 00000;</li><li>rt 代表第二个寄存器 R1 的编号,即 00001;</li><li>rd 代表目标的临时寄存器 R2 的编号,即 00010;</li><li>因为不是位移操作,所以位移量是 00000</li></ul>
<font color="#2c3e50">把上面这些数字拼在一起就是一条 32 位的 MIPS 加法指令了,那么用 16 进制表示的机器码则是 </font><code><font color="#476582">0x00011020</font></code><font color="#2c3e50">。</font>
<font color="#2c3e50">编译器在编译程序的时候,会构造指令,这个过程叫做指令的编码。CPU 执行程序的时候,就会解析指令,这个过程叫作指令的解码。</font>
<font color="#2c3e50">现代大多数 CPU 都使用来流水线的方式来执行指令,所谓的流水线就是把一个任务拆分成多个小任务,<br>于是一条指令通常分为 4 个阶段,称为 4 级流水线,如下图:</font>
四个阶段的具体含义:<br><br><ul><li>CPU 通过程序计数器读取对应内存地址的指令,这个部分称为 <b><font color="#0000ff">Fetch(取得指令)</font></b>;</li><li>CPU 对指令进行解码,这个部分称为 <b><font color="#0000ff">Decode(指令译码)</font></b>;</li><li>CPU 执行指令,这个部分称为 <b><font color="#0000ff">Execution(执行指令)</font></b>;</li><li>CPU 将计算结果存回寄存器或者将寄存器的值存入内存,这个部分称为 <b><font color="#0000ff">Store(数据回写)</font></b>;</li></ul>
<font color="#2c3e50">上面这 4 个阶段,我们称为 </font><strong><font color="#304ffe">指令周期(<em style="color: rgb(200, 73, 255);">Instrution Cycle</em>)</font></strong><font color="#2c3e50">,CPU 的工作就是一个周期接着一个周期,周而复始。</font>
<font color="#2c3e50">事实上,不同的阶段其实是由计算机中的不同组件完成的:</font><br><br><ul style=""><li style=""><font color="#2c3e50">取指令的阶段,我们的指令是存放在 </font><b style="color: rgb(44, 62, 80);"><font color="#0000ff">存储器 </font></b><font color="#2c3e50">里的,实际上,通过程序计数器和指令寄存器取出指令的过程,是由 </font><b style=""><font color="#0000ff">控制器 </font></b><font color="#2c3e50">操作的;</font></li><li>指令的译码过程,也是由 <b><font color="#0000ff">控制器</font></b> 进行的;</li><li>指令执行的过程,无论是进行算术操作、逻辑操作,还是进行数据传输、条件分支操作,都是由 <b><font color="#0000ff">算术逻辑单元</font></b> 操作的,<br>也就是由 <b><font color="#0000ff">运算器</font></b> 处理的。但是如果是一个简单的无条件地址跳转,则是直接在 <b><font color="#0000ff">控制器</font></b> 里面完成的,不需要用到运算器。</li></ul>
指令的类型
指令从功能角度划分,可以分为 5 大类:<br><br><ul><li><i><font color="#ff00ff">数据传输类型的指令</font></i>,比如 store/load 是寄存器与内存间数据传输的指令,mov 是将一个内存地址的数据移动到另一个内存地址的指令;</li><li><i><font color="#ff00ff">运算类型的指令</font></i>,比如加减乘除、位运算、比较大小等等,它们最多只能处理两个寄存器中的数据;</li><li><i><font color="#ff00ff">跳转类型的指令</font></i>,通过修改程序计数器的值来达到跳转执行指令的过程,比如编程中常见的 if-else、switch-case、函数调用等。</li><li><i><font color="#ff00ff">信号类型的指令</font></i>,比如发生中断的指令 trap;</li><li><i><font color="#ff00ff">闲置类型的指令</font></i>,比如指令 nop,执行后 CPU 会空转一个周期;</li></ul>
指令的执行速度
介绍
<font color="#2c3e50">CPU 的硬件参数都会有 </font><code><font color="#476582">GHz</font></code><font color="#2c3e50"> 这个参数,比如一个 1 GHz 的 CPU,指的是时钟频率是 1 G,代表<br>着 1 秒会产生 1G次数的脉冲信号,每一次脉冲信号高低电平的转换就是一个周期,称为时钟周期。</font>
<font color="#2c3e50">对于 CPU 来说,在一个时钟周期内,CPU 仅能完成一个最基本的动作,时钟频率越高,时钟周期就越短,工作速度也就越快。</font>
<font color="#2c3e50">一个时钟周期一定能执行完一条指令吗?答案是不一定的,大多数指令不能在一个时钟周期完成,通常需要若干个时钟周期。<br>不同的指令需要的时钟周期是不同的,加法和乘法都对应着一条 CPU 指令,但是乘法需要的时钟周期就要比加法多。</font>
如何让程序跑的更快?
<font color="#2c3e50">程序执行的时候,耗费的 CPU 时间少就说明程序是快的,对于程序的 CPU 执行时间,我们可以拆解成 <br></font><strong><font color="#304ffe">CPU 时钟周期数(<em style="color: rgb(200, 73, 255);">CPU Cycles</em>)和时钟周期时间(<em style="color: rgb(200, 73, 255);">Clock Cycle Time</em>)的乘积</font></strong><font color="#2c3e50">。</font>
<font color="#2c3e50">时钟周期时间就是我们前面提及的 CPU 主频,主频越高说明 CPU 的工作速度就越快,比如我手头上的电脑<br>的 CPU 是 2.4 GHz 四核 Intel Core i5,这里的 2.4 GHz 就是电脑的主频,时钟周期时间就是 1/2.4G。</font>
<font color="#2c3e50">要想 CPU 跑的更快,自然缩短时钟周期时间,也就是提升 CPU 主频,但是今非彼日,<br>摩尔定律早已失效,当今的 CPU 主频已经很难再做到翻倍的效果了。</font>
<font color="#2c3e50">另外,换一个更好的 CPU,这个也是我们软件工程师控制不了的事情,我们应该把目光放到另外一个乘法<br>因子 —— CPU 时钟周期数,如果能减少程序所需的 CPU 时钟周期数量,一样也是能提升程序的性能的。</font>
<font color="#2c3e50">对于 CPU 时钟周期数我们可以进一步拆解成:「</font><strong><font color="#304ffe">指令数 x 每条指令的平均时钟周期数(<em style="color: rgb(200, 73, 255);">Cycles Per Instruction</em>,简称 <code style="font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New", monospace; color: rgb(71, 101, 130); padding: 0.25rem 0.5rem; margin: 0px; font-size: 0.85em; background-color: rgba(27, 31, 35, 0.05); border-radius: 3px;">CPI</code>)</font></strong><font color="#2c3e50">」,<br>于是程序的 CPU 执行时间的公式可变成如下:</font>
因此,要想程序跑的更快,优化这三者即可:<br><br><ul><li>指令数,表示执行程序所需要多少条指令,以及哪些指令。这个层面是基本靠编译器来优化,毕竟同样的代码,<br>在不同的编译器,编译出来的计算机指令会有各种不同的表示方式。</li><li>每条指令的平均时钟周期数 CPI,表示一条指令需要多少个时钟周期数,现代大多数 CPU 通过流水线技术(Pipeline),<br>让一条指令需要的 CPU 时钟周期数尽可能的少;</li><li>时钟周期时间,表示计算机主频,取决于计算机硬件。有的 CPU 支持超频技术,打开了超频意味着把 CPU 内部的时钟<br>给调快了,于是 CPU 工作速度就变快了,但是也是有代价的,CPU 跑的越快,散热的压力就会越大,CPU 会很容易奔溃。</li></ul>
<font color="#2c3e50">很多厂商为了跑分而跑分,基本都是在这三个方面入手的哦,特别是超频这一块。</font>
总结
<font color="#2c3e50">最后我们再来回答开头的问题。</font>
<font color="#2c3e50">64 位相比 32 位 CPU 的优势在哪吗?64 位 CPU 的计算性能一定比 32 位 CPU 高很多吗?<br><br></font>64 位相比 32 位 CPU 的优势主要体现在两个方面:<br><br><ul><li>64 位 CPU 可以一次计算超过 32 位的数字,而 32 位 CPU 如果要计算超过 32 位的数字,要分多步骤进行计算,效率就没那么高,但是大部分<br>应用程序很少会计算那么大的数字,所以 <font color="#0000ff"><b>只有运算大数字的时候,64 位 CPU 的优势才能体现出来,否则和 32 位 CPU 的计算性能相差不大</b></font>。</li><li>64 位 CPU 可以 <b><font color="#0000ff">寻址更大的内存空间</font></b>,32 位 CPU 最大的寻址地址是 4G,即使你加了 8G 大小的内存,也还是只能寻址到 4G,<br>而 64 位 CPU 最大寻址地址是 2^64,远超于 32 位 CPU 最大寻址地址的 2^32。</li></ul>
<font color="#2c3e50">你知道软件的 32 位和 64 位之间的区别吗?再来 32 位的操作系统可以运行在 64 位的电脑上吗?<br>64 位的操作系统可以运行在 32 位的电脑上吗?如果不行,原因是什么?<br><br></font>64 位和 32 位软件,实际上代表指令是 64 位还是 32 位的:<br><br><ul><li>如果 32 位指令在 64 位机器上执行,需要一套兼容机制,就可以做到兼容运行了。但是 <b><font color="#0000ff">如果 64 位指令在 32 位<br>机器上执行,就比较困难了,因为 32 位的寄存器存不下 64 位的指令</font></b>;</li><li>操作系统其实也是一种程序,我们也会看到操作系统会分成 32 位操作系统、64 位操作系统,其代表意义就是<br>操作系统中程序的指令是多少位,比如 64 位操作系统,指令也就是 64 位,因此不能装在 32 位机器上。<br></li></ul>
<font color="#0d0b22">总之,硬件的 64 位和 32 位指的是 CPU 的位宽,软件的 64 位和 32 位指的是指令的位宽。</font>
磁盘比内存慢几万倍?
介绍
大家如果想自己组装电脑的话,肯定需要购买一个 CPU,但是存储器方面的设备,分类比较多,那我们肯定不能<br>只买一种存储器,比如你除了要买内存,还要买硬盘,而针对硬盘我们还可以选择是固态硬盘还是机械硬盘。
相信大家都知道内存和硬盘都属于计算机的存储设备,断电后内存的数据是会丢失的,而硬盘则不会,因为硬盘是持久化存储设备,同时也是一个 I/O 设备。
但其实 CPU 内部也有存储数据的组件,这个应该比较少人注意到,比如 <b><font color="#0000ff">寄存器、CPU L1/L2/L3 Cache</font></b> 也都是属于存储设备,<br>只不过它们能存储的数据非常小,但是它们因为靠近 CPU 核心,所以访问速度都非常快,快过硬盘好几个数量级别。
问题来了,那 <b><font color="#0000ff">机械硬盘、固态硬盘、内存这三个存储器,到底和 CPU L1 Cache 相比速度差多少倍呢?</font></b>
在回答这个问题之前,我们先来看看「<b><font color="#0000ff">存储器的层次结构</font></b>」,好让我们对存储器设备有一个整体的认识。
存储器的层次结构
介绍
我们想象中一个场景,大学期末准备考试了,你前去图书馆临时抱佛脚。那么,在看书的时候,我们的大脑会思考问题,<br>也会记忆知识点,另外我们通常也会把常用的书放在自己的桌子上,当我们要找一本不常用的书,则会去图书馆的书架找。
就是这么一个小小的场景,已经把计算机的存储结构基本都涵盖了。<br>
我们可以把 CPU 比喻成我们的大脑,大脑正在思考的东西,就好比 CPU 中的 <font color="#0000ff"><b>寄存器</b></font>,处理速度是最快的,<br>但是能存储的数据也是最少的,毕竟我们也不能一下同时思考太多的事情,除非你练过。
我们大脑中的记忆,就好比 CPU Cache,中文称为 <font color="#0000ff"><b>CPU 高速缓存</b></font>,处理速度相比寄存器慢了一点,但是能存储的数据也稍微多了一些。
CPU Cache 通常会分为 <b><font color="#0000ff">L1、L2、L3 三层</font></b>,其中 L1 Cache 通常分成「数据缓存」和「指令缓存」,L1 是距离 CPU 最近的,<br>因此它比 L2、L3 的读写速度都快、存储空间都小。我们大脑中短期记忆,就好比 L1 Cache,而长期记忆就好比 L2/L3 Cache。
寄存器和 CPU Cache 都是在 CPU 内部,跟 CPU 挨着很近,因此它们的读写速度都相当的快,但是能存储的数据很少,毕竟 CPU 就这么丁点大。
知道 CPU 内部的存储器的层次分布,我们放眼看看 CPU 外部的存储器。<br>
当我们大脑记忆中没有资料的时候,可以从书桌或书架上拿书来阅读,那我们桌子上的书,就好比 <b><font color="#0000ff">内存</font></b>,我们虽然可以一伸手就可以拿到,但读写速度<br>肯定远慢于寄存器,那图书馆书架上的书,就好比 <b><font color="#0000ff">硬盘</font></b>,能存储的数据非常大,但是读写速度相比内存差好几个数量级,更别说跟寄存器的差距了。
我们从图书馆书架取书,把书放到桌子上,再阅读书,我们大脑就会记忆知识点,然后再经过大脑思考,这一系列过程<br>相当于,数据从硬盘加载到内存,再从内存加载到 CPU 的寄存器和 Cache 中,然后再通过 CPU 进行处理和计算。
<b><font color="#0000ff">对于存储器,它的速度越快、能耗会越高、而且材料的成本也是越贵的,以至于速度快的存储器的容量都比较小。</font></b>
CPU 里的寄存器和 Cache,是整个计算机存储器中价格最贵的,虽然存储空间很小,但是读写速度是极快的,<br>而相对比较便宜的内存和硬盘,速度肯定比不上 CPU 内部的存储器,但是能弥补存储空间的不足。
<span style="font-size: inherit;">存储器通常可以分为这么几个级别:</span><br><ul><li><span style="font-size: inherit;">寄存器;</span></li><li><span style="font-size: inherit;">CPU Cache;</span></li><li><span style="font-size: inherit;">L1-Cache;</span></li><li><span style="font-size: inherit;">L2-Cache;</span></li><li><span style="font-size: inherit;">L3-Cahce;</span></li><li><span style="font-size: inherit;">内存;</span></li><li><span style="font-size: inherit;">SSD/HDD 硬盘</span></li></ul>
寄存器
最靠近 CPU 的控制单元和逻辑计算单元的存储器,就是寄存器了,它使用的材料速度也是最快的,因此价格也是最贵的,那么数量不能很多。
寄存器的数量通常在几十到几百之间,每个寄存器可以用来存储一定的字节(byte)的数据。比如:<br><br><ul><li>32 位 CPU 中大多数寄存器可以存储 4 个字节;</li><li>64 位 CPU 中大多数寄存器可以存储 8 个字节。</li></ul>
寄存器的访问速度非常快,一般要求在半个 CPU 时钟周期内完成读写,CPU 时钟周期跟 CPU 主频息息相关,<br>比如 2 GHz 主频的 CPU,那么它的时钟周期就是 1/2G,也就是 0.5ns(纳秒)。
CPU 处理一条指令的时候,除了读写寄存器,还需要解码指令、控制指令执行和计算。<br>如果寄存器的速度太慢,则会拉长指令的处理周期,从而给用户电脑「很慢」的感觉。
CPU Cache
CPU Cache 用的是一种叫 <b><font color="#0000ff">SRAM(Static Random-Access Memory,静态随机存储器)</font></b> 的芯片。
SRAM 之所以叫「静态」存储器,是因为只要有电,数据就可以保持存在,而一旦断电,数据就会丢失了。
在 SRAM 里面,一个 bit 的数据,通常需要 6 个晶体管,所以 SRAM 的存储密度不高,同样的物理空间下,<br>能存储的数据是有限的,不过也因为 SRAM 的电路简单,所以访问速度非常快。
CPU 的高速缓存,通常可以分为 L1、L2、L3 这样的三层高速缓存,也称为一级缓存、二级缓存、三级缓存。
L1 高速缓存
L1 高速缓存的访问速度几乎和寄存器一样快,通常只需要 2~4 个时钟周期,而大小在几十 KB 到几百 KB 不等。
每个 CPU 核心都有一块属于自己的 L1 高速缓存,指令和数据在 L1 是分开存放的,所以 L1 高速缓存通常分成 <b><font color="#0000ff">指令缓存 </font></b>和 <b><font color="#0000ff">数据缓存</font></b>。
在 Linux 系统,我们可以通过这条命令,查看 CPU 里的 L1 Cache 「数据」缓存的容量大小:
而查看 L1 Cache 「指令」缓存的容量大小,则是:
L2 高速缓存<br>
L2 高速缓存同样每个 CPU 核心都有,但是 L2 高速缓存位置比 L1 高速缓存距离 CPU 核心 更远,它大小比 L1 高速缓存更大,<br>CPU 型号不同大小也就不同,通常大小在几百 KB 到几 MB 不等,访问速度则更慢,速度在 10~20 个时钟周期。
在 Linux 系统,我们可以通过这条命令,查看 CPU 里的 L2 Cache 的容量大小:
L3 高速缓存<br>
L3 高速缓存通常是多个 CPU 核心共用的,位置比 L2 高速缓存距离 CPU 核心 更远,<br>大小也会更大些,通常大小在几 MB 到几十 MB 不等,具体值根据 CPU 型号而定。
访问速度相对也比较慢一些,访问速度在 20~60个时钟周期。
在 Linux 系统,我们可以通过这条命令,查看 CPU 里的 L3 Cache 的容量大小:
内存
内存用的芯片和 CPU Cache 有所不同,它使用的是一种叫作 <b><font color="#0000ff">DRAM (Dynamic Random Access Memory,动态随机存取存储器)</font></b> 的芯片。
相比 SRAM,DRAM 的密度更高,功耗更低,有更大的容量,而且造价比 SRAM 芯片便宜很多。
DRAM 存储一个 bit 数据,只需要一个晶体管和一个电容就能存储,但是因为数据会被存储在电容里,电容会不断漏电,所以需要「定时刷新」<br>电容,才能保证数据不会被丢失,这就是 DRAM 之所以被称为「动态」存储器的原因,只有不断刷新,数据才能被存储起来。
DRAM 的数据访问电路和刷新电路都比 SRAM 更复杂,所以访问的速度会更慢,内存速度大概在 200~300 个 时钟周期之间。<br>
SSD/HDD 硬盘
SSD(<i><font color="#ff00ff">Solid-state disk</font></i>) 就是我们常说的固体硬盘,结构和内存类似,但是它相比内存的优点是断电后数据<br>还是存在的,而内存、寄存器、高速缓存断电后数据都会丢失。内存的读写速度比 SSD 大概快 10~1000 倍。
当然,还有一款传统的硬盘,也就是机械硬盘(<font color="#ff00ff"><i>Hard Disk Drive, HDD</i></font>),它是通过物理<br>读写的方式来访问数据的,因此它访问速度是非常慢的,它的速度比内存慢 10W 倍左右。
由于 SSD 的价格快接近机械硬盘了,因此机械硬盘已经逐渐被 SSD 替代了。
存储器的层次关系
现代的一台计算机,都用上了 CPU Cahce、内存、到 SSD 或 HDD 硬盘这些存储器设备了。
其中,存储空间越大的存储器设备,其访问速度越慢,所需成本也相对越少。
CPU 并不会直接和每一种存储器设备直接打交道,而是每一种存储器设备只和它相邻的存储器设备打交道。
比如,CPU Cache 的数据是从内存加载过来的,写回数据的时候也只写回到内存,CPU Cache 不会直接<br>把数据写到硬盘,也不会直接从硬盘加载数据,而是先加载到内存,再从内存加载到 CPU Cache 中。
所以,<b><font color="#0000ff">每个存储器只和相邻的一层存储器设备打交道,并且存储设备为了追求更快的速度,所需的材料成本必然也是更高,也正因为成本太高,<br>所以 CPU 内部的寄存器、L1\L2\L3 Cache 只好用较小的容量,相反内存、硬盘则可用更大的容量,这就我们今天所说的存储器层次结构。</font></b>
另外,当 CPU 需要访问内存中某个数据的时候,如果寄存器有这个数据,CPU 就直接从寄存器取数据即可,如果寄存器没有这个数据,CPU <br>就会查询 L1 高速缓存,如果 L1 没有,则查询 L2 高速缓存,L2 还是没有的话就查询 L3 高速缓存,L3 依然没有的话,才去内存中取数据。
所以,存储层次结构也形成了 <b><font color="#0000ff">缓存</font></b> 的体系。<br>
存储器之间的实际价格和性能差距
前面我们知道了,速度越快的存储器,造价成本往往也越高,那我们就以实际的数据来看看,不同层级的存储器之间的性能和价格差异。
下面这张表格是不同层级的存储器之间的成本对比图:<br>
你可以看到 L1 Cache 的访问延时是 1 纳秒,而内存已经是 100 纳秒了,相比 L1 Cache 速度慢了 100 倍。<br>另外,机械硬盘的访问延时更是高达 10 毫秒,相比 L1 Cache 速度慢了 10000000 倍,差了好几个数量级别。
在价格上,每生成 MB 大小的 L1 Cache 相比内存贵了 466 倍,相比机械硬盘那更是贵了 175000 倍。
总结
各种存储器之间的关系,可以用我们在图书馆学习这个场景来理解。
CPU 可以比喻成我们的大脑,我们当前正在思考和处理的知识的过程,就好比 CPU 中的 <b><font color="#0000ff">寄存器</font></b> 处理数据的过程,速度极快,<br>但是容量很小。而 CPU 中的 <b><font color="#0000ff">L1-L3 Cache</font></b> 好比我们大脑中的短期记忆和长期记忆,需要小小花费点时间来调取数据并处理。
我们面前的桌子就相当于 <b><font color="#0000ff">内存</font></b>,能放下更多的书(数据),但是找起来和看起来就要花费一些时间,相比 CPU Cache 慢不少。<br>而图书馆的书架相当于 <b><font color="#0000ff">硬盘</font></b>,能放下比内存更多的数据,但找起来就更费时间了,可以说是最慢的存储器设备了。
从 寄存器、CPU Cache,到内存、硬盘,这样一层层下来的存储器,访问速度越来越慢,存储容量越来越大,<br>价格也越来越便宜,而且每个存储器只和相邻的一层存储器设备打交道,于是这样就形成了存储器的层次结构。
再来回答,开头的问题:那机械硬盘、固态硬盘、内存这三个存储器,到底和 CPU L1 Cache 相比速度差多少倍呢?
CPU L1 Cache 随机访问延时是 1 纳秒,内存则是 100 纳秒,所以 <b><font color="#0000ff">CPU L1 Cache 比内存快 100 倍左右</font></b>。
SSD 随机访问延时是 150 微秒,所以 <b><font color="#0000ff">CPU L1 Cache 比 SSD 快 150000 倍左右</font></b>。
最慢的机械硬盘随机访问延时已经高达 10 毫秒,我们来看看机械硬盘到底有多「龟速」:<br><br><ul><li><b>SSD 比机械硬盘快 70 倍左右;</b></li><li><b>内存比机械硬盘快 100000 倍左右;</b></li><li><b>CPU L1 Cache 比机械硬盘快 10000000 倍左右;</b></li></ul>
我们把上述的时间比例差异放大后,就能非常直观感受到它们的性能差异了。如果 CPU 访问 L1 Cache 的缓存时间是 1 秒,<br>那访问内存则需要大约 2 分钟,随机访问 SSD 里的数据则需要 1.7 天,访问机械硬盘那更久,长达近 4 个月。
可以发现,不同的存储器之间性能差距很大,构造存储器分级很有意义,分级的目的是要构造 <b><font color="#0000ff">缓存</font></b> 体系。
如何写出让 CPU 跑得更快的代码?
介绍
代码都是由 CPU 跑起来的,我们代码写的好与坏就决定了 CPU 的执行效率,特别是<br>在编写计算密集型的程序,更要注重 CPU 的执行效率,否则将会大大影响系统性能。
CPU 内部嵌入了 CPU Cache(高速缓存),它的存储容量很小,但是离 CPU 核心很近,所以缓存的读写速度<br>是极快的,那么如果 CPU 运算时,直接从 CPU Cache 读取数据,而不是从内存的话,运算速度就会很快。
但是,大多数人不知道 CPU Cache 的运行机制,以至于不知道如何才能够写出能够配合 <br>CPU Cache 工作机制的代码,一旦你掌握了它,你写代码的时候,就有新的优化思路了。
那么,接下来我们就来看看,CPU Cache 到底是什么样的,是如何工作的呢,又该如何写出让 CPU 执行更快的代码呢?
CPU Cache 有多快?
你可能会好奇为什么有了内存,还需要 CPU Cache?根据摩尔定律,CPU 的访问速度每 18 个月就会翻倍,相当于每年增长 60% 左右,<br>内存的速度当然也会不断增长,但是增长的速度远小于 CPU,平均每年只增长 7% 左右。于是,CPU 与内存的访问性能的差距不断拉大。
到现在,一次内存访问所需时间是 200~300 多个时钟周期,这意味着 CPU 和内存的访问速度已经相差 200~300 多倍了。
为了弥补 CPU 与内存两者之间的性能差异,就在 CPU 内部引入了 CPU Cache,也称高速缓存。
CPU Cache 通常分为大小不等的三级缓存,分别是 <b><font color="#0000ff">L1 Cache、L2 Cache 和 L3 Cache</font></b>。
由于 CPU Cache 所使用的材料是 SRAM,价格比内存使用的 DRAM 高出很多,在当今每生产 1 MB 大小的 CPU Cache 需要 7 美金的成本,<br>而内存只需要 0.015 美金的成本,成本方面相差了 466 倍,所以 CPU Cache 不像内存那样动辄以 GB 计算,它的大小是以 KB 或 MB 来计算的。
在 Linux 系统中,我们可以使用下图的方式来查看各级 CPU Cache 的大小,比如我这手上这台服务器,<br>离 CPU 核心最近的 L1 Cache 是 32KB,其次是 L2 Cache 是 256KB,最大的 L3 Cache 则是 3MB。
其中,L1 Cache 通常会分为「数据缓存」和「指令缓存」,这意味着数据和指令在 L1 Cache 这一层是<br>分开缓存的,上图中的 index0 也就是数据缓存,而 index1 则是指令缓存,它两的大小通常是一样的。
另外,你也会注意到,L3 Cache 比 L1 Cache 和 L2 Cache 大很多,这是因为 <br><font color="#0000ff"><b>L1 Cache 和 L2 Cache 都是每个 CPU 核心独有的,而 L3 Cache 是多个 CPU 核心共享的</b></font>。
程序执行时,会先将内存中的数据加载到共享的 L3 Cache 中,再加载到每个核心独有的 L2 Cache,<br>最后进入到最快的 L1 Cache,之后才会被 CPU 读取。它们之间的层级关系,如下图:
越靠近 CPU 核心的缓存其访问速度越快,CPU 访问 L1 Cache 只需要 2~4 个时钟周期,访问 L2 Cache 大约 10~20 个时钟周期,<br>访问 L3 Cache 大约 20~60 个时钟周期,而访问内存速度大概在 200~300 个 时钟周期之间。如下表格:
<b><font color="#0000ff">所以,CPU 从 L1 Cache 读取数据的速度,相比从内存读取的速度,会快 100 多倍。</font></b>
CPU Cache 得数据结构和读取过程是怎样的?
我们先简单了解下 CPU Cache 的结构,CPU Cache 是由很多个 Cache Line 组成的,Cache Line 是 CPU 从内存<br>读取数据的基本单位,而 Cache Line 是由各种标志(Tag)+ 数据块(Data Block)组成,你可以在下图清晰的看到:
CPU Cache 的数据是从内存中读取过来的,它是以一小块一小块读取数据的,而不是按照单个数组<br>元素来读取数据的,在 CPU Cache 中的,这样一小块一小块的数据,称为 <b><font color="#0000ff">Cache Line(缓存块)</font></b>。
你可以在你的 Linux 系统,用下面这种方式来查看 CPU 的 Cache Line,你可以看我服务器的 <br>L1 Cache Line 大小是 64 字节,也就意味着 <font color="#0000ff"><b>L1 Cache 一次载入数据的大小是 64 字节</b></font>。
比如,有一个 int array[100] 的数组,当载入 array[0] 时,由于这个数组元素的大小在内存只占 4 字节,不足 64 字节,<br>CPU 就会 <b><font color="#0000ff">顺序加载</font></b> 数组元素到 array[15],意味着 array[0]~array[15] 数组元素都会被缓存在 CPU Cache 中了,因此<br>当下次访问这些数组元素时,会直接从 CPU Cache 读取,而不用再从内存中读取,大大提高了 CPU 读取数据的性能。
事实上,CPU 读取数据的时候,无论数据是否存放到 Cache 中,CPU 都是先访问 Cache,只有当 Cache <br>中找不到数据时,才会去访问内存,并把内存中的数据读入到 Cache 中,CPU 再从 CPU Cache 读取数据。
这样的访问机制,跟我们使用「内存作为硬盘的缓存」的逻辑是一样的,如果内存有缓存的数据,则直接返回,否则要访问龟速一般的硬盘。
那 CPU 怎么知道要访问的内存数据,是否在 Cache 里?如果在的话,如何找到 Cache 对应的数据呢?<br>从最基础的 <b><font color="#0000ff">直接映射 Cache(Direct Mapped Cache)</font></b> 说起,来看看整个 CPU Cache 的数据结构和访问逻辑。
前面,我们提到 CPU 访问内存数据时,是一小块一小块数据读取的,具体这一小块数据的大小,取决于 coherency_line_size 的值,<br>一般 64 字节。在内存中,这一块的数据我们称为 <b><font color="#0000ff">内存块(Block)</font></b>,读取的时候我们要拿到数据所在内存块的地址。
对于直接映射 Cache 采用的策略,就是把内存块的地址始终「映射」在一个 CPU Cache Line(缓存块) 的地址,至于<br>映射关系实现方式,则是使用「取模运算」,取模运算的结果就是内存块地址对应的 CPU Cache Line(缓存块) 的地址。
举个例子,内存共被划分为 32 个内存块,CPU Cache 共有 8 个 CPU Cache Line,假设 CPU 想要访问第 15 号内存块,如果 <br>15 号内存块中的数据已经缓存在 CPU Cache Line 中的话,则是一定映射在 7 号 CPU Cache Line 中,因为 15 % 8 的值是 7。
机智的你肯定发现了,使用取模方式映射的话,就会出现多个内存块对应同一个 CPU Cache Line,比如上面的例子,<br>除了 15 号内存块是映射在 7 号 CPU Cache Line 中,还有 7 号、23 号、31 号内存块都是映射到 7 号 CPU Cache Line 中。
因此,为了区别不同的内存块,在对应的 CPU Cache Line 中我们还会存储一个 <b><font color="#0000ff">组标记(Tag)</font></b>。这个组标记<br>会记录当前 CPU Cache Line 中存储的数据对应的内存块,我们可以用这个组标记来区分不同的内存块。
除了组标记信息外,CPU Cache Line 还有两个信息:<br><br><ul><li>一个是,从内存加载过来的实际存放 <font color="#0000ff"><b>数据(Data)</b></font>。</li><li>另一个是,<font color="#0000ff"><b>有效位(Valid bit)</b></font>,它是用来标记对应的 CPU Cache Line 中的数据是否是有效的,<br>如果有效位是 0,无论 CPU Cache Line 中是否有数据,CPU 都会直接访问内存,重新加载数据。</li></ul>
CPU 在从 CPU Cache 读取数据的时候,并不是读取 CPU Cache Line 中的整个数据块,而是读取 CPU 所需要的一个数据片段,这样<br>的数据统称为一个 <b><font color="#0000ff">字(Word)</font></b>。那怎么在对应的 CPU Cache Line 中数据块中找到所需的字呢?答案是,需要一个 <b><font color="#0000ff">偏移量(Offset)</font></b>。
因此,一个内存的访问地址,包括 <b><font color="#0000ff">组标记、CPU Cache Line 索引、偏移量 </font></b>这三种信息,于是 CPU 就能通过这些信息,<br>在 CPU Cache 中找到缓存的数据。而对于 CPU Cache 里的数据结构,则是由 <b><font color="#0000ff">索引 + 有效位 + 组标记 + 数据块 </font></b>组成。
如果内存中的数据已经在 CPU Cahe 中了,那 CPU 访问一个内存地址的时候,会经历这 4 个步骤:<br><br><ol><li>根据内存地址中索引信息,计算在 CPU Cahe 中的索引,也就是找出对应的 CPU Cache Line 的地址;</li><li>找到对应 CPU Cache Line 后,判断 CPU Cache Line 中的有效位,确认 CPU Cache Line 中数据是<br>否是有效的,如果是无效的,CPU 就会直接访问内存,并重新加载数据,如果数据有效,则往下执行;</li><li>对比内存地址中组标记和 CPU Cache Line 中的组标记,确认 CPU Cache Line 中的数据是我们要访问<br>的内存数据,如果不是的话,CPU 就会直接访问内存,并重新加载数据,如果是的话,则往下执行;</li><li>根据内存地址中偏移量信息,从 CPU Cache Line 的数据块中,读取对应的字。</li></ol>
到这里,相信你对直接映射 Cache 有了一定认识,但其实除了直接映射 Cache 之外,还有其他通过内存地址找到 CPU Cache <br>中的数据的策略,比如 <b><font color="#0000ff">全相连 Cache (Fully Associative Cache)、组相连 Cache (Set Associative Cache)</font></b>等,这几种<br>策略的数据结构都比较相似,我们理解了直接映射 Cache 的工作方式,其他的策略如果你有兴趣去看,相信很快就能理解的了。
如何写出让 CPU 跑得更快的代码?
介绍
我们知道 CPU 访问内存的速度,比访问 CPU Cache 的速度慢了 100 多倍,所以如果 CPU 所要操作的数据在 CPU Cache 中的话,这样将会<br>带来很大的性能提升。访问的数据在 CPU Cache 中的话,意味着 <b><font color="#0000ff">缓存命中</font></b>,缓存命中率越高的话,代码的性能就会越好,CPU 也就跑的越快。
于是,「如何写出让 CPU 跑得更快的代码?」这个问题,可以改成「如何写出 CPU 缓存命中率高的代码?」。
在前面我也提到, L1 Cache 通常分为「数据缓存」和「指令缓存」,这是因为 CPU 会分别处理数据和指令,<br>比如 1+1=2 这个运算,+ 就是指令,会被放在「指令缓存」中,而输入数字 1 则会被放在「数据缓存」里。
因此,我们要分开来看<font color="#0000ff" style=""><b>「数据缓存」和「指令缓存」的缓存命中率</b></font>。<br>
如何提升数据缓存的命中率?
假设要遍历二维数组,有以下两种形式,虽然代码执行结果是一样,但你觉得哪种形式效率最高呢?为什么高呢?
经过测试,形式一 array[i][j] 执行时间比形式二 array[j][i] 快好几倍。
之所以有这么大的差距,是因为二维数组 array 所占用的内存是连续的,比如长度 N 的值是 2 的话,那么内存中的数组元素的布局顺序是这样的:
形式一用 array[i][j] 访问数组元素的顺序,正是和内存中数组元素存放的顺序一致。当 CPU 访问 array[0][0] 时,由于该数据<br>不在 Cache 中,于是会「顺序」把跟随其后的 3 个元素从内存中加载到 CPU Cache,这样当 CPU 访问后面的 3 个数组元素时,<br>就能在 CPU Cache 中成功地找到数据,这意味着缓存命中率很高,缓存命中的数据不需要访问内存,这便大大提高了代码的性能。
而如果用形式二的 array[j][i] 来访问,则访问的顺序就是:
你可以看到,访问的方式跳跃式的,而不是顺序的,那么如果 N 的数值很大,那么操作 array[j][i] 时,是没办法把 array[j+1][i] <br>也读入到 CPU Cache 中的,既然 array[j+1][i] 没有读取到 CPU Cache,那么就需要从内存读取该数据元素了。很明显,这种<br>不连续性、跳跃式访问数据元素的方式,可能不能充分利用到了 CPU Cache 的特性,从而代码的性能不高。
那访问 array[0][0] 元素时,CPU 具体会一次从内存中加载多少元素到 CPU Cache 呢?这个问题,在前面我们也提到过,这跟 CPU Cache Line <br>有关,它表示 <b><font color="#0000ff">CPU Cache 一次性能加载数据的大小</font></b>,可以在 Linux 里通过 coherency_line_size 配置查看 它的大小,通常是 64 个字节。
也就是说,当 CPU 访问内存数据时,如果数据不在 CPU Cache 中,则会一次性会连续加载 64 字节大小的数据到 CPU Cache,<br>那么当访问 array[0][0] 时,由于该元素不足 64 字节,于是就会往后顺序读取 array[0][0]~array[0][15] 到 CPU Cache 中。<br>顺序访问的 array[i][j] 因为利用了这一特点,所以就会比跳跃式访问的 array[j][i] 要快。
<b><font color="#0000ff">因此,遇到这种遍历数组的情况时,按照内存布局顺序访问,将可以有效的利用 CPU Cache 带来的好处,这样我们代码的性能就会得到很大的提升,</font></b><br>
如何提升指令缓存的命中率?
提升数据的缓存命中率的方式,是按照内存布局顺序访问,那针对指令的缓存该如何提升呢?
我们以一个例子来看看,有一个元素为 0 到 100 之间随机数字组成的一维数组:
接下来,对这个数组做两个操作:<br><ul><li>第一个操作,循环遍历数组,把小于 50 的数组元素置为 0;</li><li>第二个操作,将数组排序;</li></ul>
那么问题来了,你觉得先遍历再排序速度快,还是先排序再遍历速度快呢?
在回答这个问题之前,我们先了解 CPU 的 <b><font color="#0000ff">分支预测器</font></b>。对于 if 条件语句,意味着此时至少可以选择跳转到两段不同的指令执行,<br>也就是 if 还是 else 中的指令。那么,<b><font color="#0000ff">如果分支预测可以预测到接下来要执行 if 里的指令,还是 else 指令的话,就可以「提前」<br>把这些指令放在指令缓存中,这样 CPU 可以直接从 Cache 读取到指令,于是执行速度就会很快。</font></b>
当数组中的元素是随机的,分支预测就无法有效工作,而当数组元素都是是顺序的,<br>分支预测器会动态地根据历史命中数据对未来进行预测,这样命中率就会很高。
因此,先排序再遍历速度会更快,这是因为排序之后,数字是从小到大的,那么前几次循环命中 if < 50 的次数会比较多,<br>于是分支预测就会缓存 if 里的 array[i] = 0 指令到 Cache 中,后续 CPU 执行该指令就只需要从 Cache 读取就好了。
如果你肯定代码中的 if 中的表达式判断为 true 的概率比较高,我们可以使用显示分支预测工具,比如在 C/C++ 语言中编译器提供<br>了 likely 和 unlikely 这两种宏,如果 if 条件为 ture 的概率大,则可以用 likely 宏把 if 里的表达式包裹起来,反之用 unlikely 宏。
实际上,CPU 自身的动态分支预测已经是比较准的了,所以只有当非常确信 CPU 预测的不准,且能够知道实际的概率情况时,才建议使用这两种宏。<br>
如何提升多核 CPU 的缓存命中率?
在单核 CPU,虽然只能执行一个线程,但是操作系统给每个线程分配了一个时间片,时间片用完了,<br>就调度下一个线程,于是各个线程就按时间片交替地占用 CPU,从宏观上看起来各个线程同时在执行。
而现代 CPU 都是多核心的,线程可能在不同 CPU 核心来回切换执行,这对 CPU Cache 不是有利的,虽然 L3 Cache 是多核心之间共享的,<br>但是 L1 和 L2 Cache 都是每个核心独有的,<b><font color="#0000ff">如果一个线程在不同核心来回切换,各个核心的缓存命中率就会受到影响</font></b>,相反如果线程都在<br>同一个核心上执行,那么其数据的 L1 和 L2 Cache 的缓存命中率可以得到有效提高,缓存命中率高就意味着 CPU 可以减少访问内存的频率。
当有多个同时执行「计算密集型」的线程,为了防止因为切换到不同的核心,而导致缓存命中率下降的问题,<br>我们可以把 <b><font color="#0000ff">线程绑定在某一个 CPU 核心上</font></b>,这样性能可以得到非常可观的提升。
在 Linux 上提供了 sched_setaffinity 方法,来实现将线程绑定到某个 CPU 核心这一功能。
总结
由于随着计算机技术的发展,CPU 与 内存的访问速度相差越来越多,如今差距已经高达好几百倍了,所以 CPU <br>内部嵌入了 CPU Cache 组件,作为内存与 CPU 之间的缓存层,CPU Cache 由于离 CPU 核心很近,所以访问<br>速度也是非常快的,但由于所需材料成本比较高,它不像内存动辄几个 GB 大小,而是仅有几十 KB 到 MB 大小。
当 CPU 访问数据的时候,先是访问 CPU Cache,如果缓存命中的话,则直接返回数据,<br>就不用每次都从内存读取数据了。因此,缓存命中率越高,代码的性能越好。
但需要注意的是,当 CPU 访问数据时,如果 CPU Cache 没有缓存该数据,则会从内存读取数据,<br>但是并不是只读一个数据,而是一次性读取一块一块的数据存放到 CPU Cache 中,之后才会被 CPU 读取。
内存地址映射到 CPU Cache 地址里的策略有很多种,其中比较简单是直接映射 Cache,它巧妙的把内存地址拆分<br>成「索引 + 组标记 + 偏移量」的方式,使得我们可以将很大的内存地址,映射到很小的 CPU Cache 地址里。
要想写出让 CPU 跑得更快的代码,就需要写出缓存命中率高的代码,CPU L1 Cache 分为数据缓存和指令缓存,因而需要分别提高它们的缓存命中率:<br><br><ul><li>对于数据缓存,我们在遍历数据的时候,应该按照内存布局的顺序操作,这是因为 CPU Cache 是根据 CPU Cache Line <br>批量操作数据的,所以顺序地操作连续内存数据时,性能能得到有效的提升;</li><li>对于指令缓存,有规律的条件分支语句能够让 CPU 的分支预测器发挥作用,进一步提高执行的效率;</li></ul>
另外,对于多核 CPU 系统,线程可能在不同 CPU 核心来回切换,这样各个核心的缓存命中率就会受到影响,<br>于是要想提高线程的缓存命中率,可以考虑把线程绑定 CPU 到某一个 CPU 核心。
CPU 缓存一致性
CPU Cache 的数据写入
介绍
随着时间的推移,CPU 和内存的访问性能相差越来越大,于是就在 CPU 内部嵌入了 CPU Cache(高速缓存),<br><b><font color="#0000ff">CPU Cache</font></b> 离 CPU 核心相当近,因此它的访问速度是很快的,于是它 <font color="#0000ff"><b>充当了 </b></font><b><font color="#0000ff">CPU 与内存之间的缓存角色</font></b>。
CPU Cache 通常分为三级缓存:L1 Cache、L2 Cache、L3 Cache,级别越低的离 CPU 核心越近,访问速度也快,但是存储<br>容量相对就会越小。其中,在多核心的 CPU 里,每个核心都有各自的 L1/L2 Cache,而 L3 Cache 是所有核心共享使用的。
我们先简单了解下 CPU Cache 的结构,CPU Cache 是由很多个 Cache Line 组成的,CPU Line 是 CPU 从内存读取<br>数据的基本单位,而 CPU Line 是由各种标志(Tag)+ 数据块(Data Block)组成,你可以在下图清晰的看到:
我们当然期望 CPU 读取数据的时候,都是尽可能地从 CPU Cache 中读取,而不是每一次都要从内存中获取数据。<br>所以,身为程序员,我们要尽可能写出缓存命中率高的代码,这样就有效提高程序的性能。
事实上,数据不光是只有读操作,还有写操作,那么如果数据写入 Cache 之后,内存与 Cache 相对应的数据将会不同,<br>这种情况下 Cache 和内存数据都不一致了,于是我们肯定是要把 Cache 中的数据同步到内存里的。
问题来了,那在什么时机才把 Cache 中的数据写回到内存呢?为了应对这个问题,下面介绍两种针对写入数据的方法:<br><br><ul><li><b><font color="#0000ff">写直达(Write Through)</font></b></li><li><b><font color="#0000ff">写回(Write Back)</font></b></li></ul>
写直达
保持内存与 Cache 一致性最简单的方式是,<b><font color="#0000ff">把数据同时写入内存和 Cache 中</font></b>,这种方法称为 <b><font color="#0000ff">写直达(Write Through)</font></b>。
在这个方法里,写入前会先判断数据是否已经在 CPU Cache 里面了:<br><br><ul><li><span style="font-size: inherit;">如果数据已经在 Cache 里面,先将数据更新到 Cache 里面,再写入到内存里面;</span></li><li>如果数据没有在 Cache 里面,就直接把数据更新到内存里面。</li></ul>
写直达法很直观,也很简单,但是问题明显,<b><font color="#0000ff">无论数据在不在 Cache 里面,每次写操作都会写回到内存</font></b>,<br>这样写操作将会花费大量的时间,无疑 <b><font color="#0000ff">性能会受到很大的影响</font></b>。
写回
既然写直达由于每次写操作都会把数据写回到内存,而导致影响性能,于是为了要减少数据写回内存的频率,就出现了 <b><font color="#0000ff">写回(Write Back)</font></b>的方法。
在写回机制中,<b><font color="#0000ff">当发生写操作时,新的数据仅仅被写入 Cache Block 里,只有当修改过的 Cache Block「被替换」<br>时才需要写到内存中</font></b>,减少了数据写回内存的频率,这样便可以提高系统的性能。
那具体如何做到的呢?下面来详细说一下:<br><br><ul><li>如果当发生写操作时,数据已经在 CPU Cache 里,则把数据更新到 CPU Cache 里,同时标记 CPU Cache 里的这个 Cache Block 为脏(Dirty)的,<br>这个脏的标记代表这个时候,我们 CPU Cache 里面的这个 Cache Block 的数据和内存是不一致的,这种情况是不用把数据写到内存里的;<br><br></li><li>如果当发生写操作时,数据所对应的 Cache Block 里存放的是「别的内存地址的数据」,就要检查这个 Cache Block 里的数据有没有被标记为脏的:</li></ul><span style="font-size: inherit;"> (1)如果是脏的话,我们就要把这个 Cache Block 里的数据写回到内存,然后再把当前要写入的数据,先从内存读入<br> 到 Cache Block 里(注意,这一步不是没用的,具体为什么要这一步,可以看这个「回答 (opens new window)」),<br> 然后再把当前要写入的数据写入到 Cache Block,最后也把它标记为脏的;</span><br> (2)如果不是脏的话,把当前要写入的数据先从内存读入到 Cache Block 里,接着将数据写入到这个 Cache Block 里,<br> 然后再把这个 Cache Block 标记为脏的就好了。
可以发现写回这个方法,在把数据写入到 Cache 的时候,只有在缓存不命中,同时数据对应的 Cache 中的 Cache Block 为脏标记的情况下,<br>才会将数据写到内存中,而在缓存命中的情况下,则在写入后 Cache 后,只需把该数据对应的 Cache Block 标记为脏即可,而不用写到内存里。
这样的好处是,如果我们大量的操作都能够命中缓存,那么大部分时间里 CPU 都不需要读写内存,自然性能相比写直达会高很多。
为什么缓存没命中时,还要定位 Cache Block?这是因为此时是要判断数据即将写入到 Cache block 里的位置,<br>是否被「其他数据」占用了此位置,如果这个「其他数据」是脏数据,那么就要帮忙把它写回到内存。
CPU 缓存与内存使用「写回」机制的流程图如下,左半部分就是读操作的流程,右半部分就是写操作的流程,也就是我们上面讲的内容。
缓存一致性问题
现在 CPU 都是多核的,由于 L1/L2 Cache 是多个核心各自独有的,那么会带来多核心的 <b><font color="#0000ff">缓存一致性(Cache Coherence)</font></b> <br>的问题,如果不能保证缓存一致性的问题,就可能造成结果错误。
那缓存一致性的问题具体是怎么发生的呢?我们以一个含有两个核心的 CPU 作为例子看一看。
假设 A 号核心和 B 号核心同时运行两个线程,都操作共同的变量 i(初始值为 0 )。<br>
这时如果 A 号核心执行了 i++ 语句的时候,为了考虑性能,使用了我们前面所说的写回策略,先把值为 1 的执行结果<br>写入到 L1/L2 Cache 中,然后把 L1/L2 Cache 中对应的 Block 标记为脏的,这个时候数据其实没有被同步到内存中的,<br>因为写回策略,只有在 A 号核心中的这个 Cache Block 要被替换的时候,数据才会写入到内存里。
如果这时旁边的 B 号核心尝试从内存读取 i 变量的值,则读到的将会是错误的值,因为刚才 A 号核心更新 i 值还没写入到内存中,内存<br>中的值还依然是 0。<b><font color="#0000ff">这个就是所谓的缓存一致性问题,A 号核心和 B 号核心的缓存,在这个时候是不一致,从而会导致执行结果的错误。</font></b>
那么,要解决这一问题,就需要一种机制,<b><font color="#0000ff">来同步两个不同核心里面的缓存数据</font></b>。要实现的这个机制的话,要保证做到下面这 2 点:<br><br><ul><li>第一点,某个 CPU 核心里的 Cache 数据更新时,必须要传播到其他核心的 Cache,这个称为 <b><font color="#0000ff">写传播(Write Propagation)</font></b>;</li><li>第二点,某个 CPU 核心里对数据的操作顺序,必须在其他核心看起来顺序是一样的,这个称为 <b><font color="#0000ff">事务的串行化(Transaction Serialization)</font></b>。</li></ul>
第一点写传播很容易就理解,当某个核心在 Cache 更新了数据,就需要同步到其他核心的 Cache 里。<br>而对于第二点事务的串行化,我们举个例子来理解它。
假设我们有一个含有 4 个核心的 CPU,这 4 个核心都操作共同的变量 i(初始值为 0 )。A 号核心先把 i 值变为 100,<br>而此时同一时间,B 号核心先把 i 值变为 200,这里两个修改,都会「传播」到 C 和 D 号核心。
那么问题就来了,C 号核心先收到了 A 号核心更新数据的事件,再收到 B 号核心更新数据的事件,<br>因此 C 号核心看到的变量 i 是先变成 100,后变成 200。<br><br>而如果 D 号核心收到的事件是反过来的,则 D 号核心看到的是变量 i 先变成 200,再变成 100,<br>虽然是做到了写传播,但是各个 Cache 里面的数据还是不一致的。<br>
所以,我们要保证 C 号核心和 D 号核心都能看到 <b><font color="#0000ff">相同顺序的数据变化</font></b>,比如变量 i 都是先变成 100,再变成 200,这样的过程就是事务的串行化。
<b><font color="#0000ff">要实现事务串行化</font></b>,要做到 2 点:<br><br><ul><li>CPU 核心对于 Cache 中数据的操作,需要同步给其他 CPU 核心;</li><li>要引入「锁」的概念,如果两个 CPU 核心里有相同数据的 Cache,那么<br>对于这个 Cache 数据的更新,只有拿到了「锁」,才能进行对应的数据更新。</li></ul>
那接下来我们看看,写传播和事务串行化具体是用什么技术实现的。
总线嗅探
<b><font color="#0000ff">写传播 </font></b>的原则就是当某个 CPU 核心更新了 Cache 中的数据,要把 <b><font color="#0000ff">该事件广播通知到其他核心</font></b>。<br>最常见实现的方式是 <b><font color="#0000ff">总线嗅探(Bus Snooping)</font></b>。
我还是以前面的 i 变量例子来说明总线嗅探的工作机制,当 A 号 CPU 核心修改了 L1 Cache 中 i 变量的值,通过总线<br>把这个事件广播通知给其他所有的核心,然后每个 CPU 核心都会监听总线上的广播事件,并检查是否有相同的数据在<br>自己的 L1 Cache 里面,如果 B 号 CPU 核心的 L1 Cache 中有该数据,那么也需要把该数据更新到自己的 L1 Cache。
可以发现,总线嗅探方法很简单, CPU 需要每时每刻监听总线上的一切活动,但是不管别的<br>核心的 Cache 是否缓存相同的数据,都需要发出一个广播事件,这无疑会 <b><font color="#0000ff">加重总线的负载</font></b>。
另外,总线嗅探只是保证了某个 CPU 核心的 Cache 更新数据这个事件能被其他 CPU 核心知道,但是并 <b><font color="#0000ff">不能保证事务串行化</font></b>。
于是,有一个协议 <b><font color="#0000ff">基于总线嗅探机制实现了事务串行化</font></b>,也用 <b><font color="#0000ff">状态机机制降低了总线带宽压力</font></b>,<br>这个协议就是 <b><font color="#0000ff">MESI 协议</font></b>,这个协议就 <b><font color="#0000ff">做到了 CPU 缓存一致性</font></b>。<br>
MESI 协议
MESI 协议其实是 4 个状态单词的开头字母缩写,分别是:<br><br><ul><li><span style="font-size: inherit;"><i><font color="#ff00ff"><b>Modified</b></font></i>,已修改</span></li><li><b><font color="#ff00ff"><i>Exclusive</i></font></b>,独占</li><li><b><font color="#ff00ff"><i>Shared</i></font></b>,共享</li><li><b style="font-size: inherit;"><font color="#ff00ff"><i>Invalidated</i></font></b><span style="font-size: inherit;">,已失效</span></li></ul><br style="font-size: inherit;"><span style="font-size: inherit;">这四个状态来标记 Cache Line 四个不同的状态。</span><br>
「<font color="#0000ff"><b>已修改</b></font>」状态就是我们前面提到的脏标记,代表该 Cache Block 上的 <b><font color="#0000ff">数据已经被更新过,但是还没有写到内存里</font></b>。<br>而「<b><font color="#0000ff">已失效</font></b>」状态,表示的是这个 Cache Block 里的 <b><font color="#0000ff">数据已经失效了,不可以读取该状态的数据</font></b>。
「<b><font color="#0000ff">独占</font></b>」和「<b><font color="#0000ff">共享</font></b>」状态都代表 Cache Block 里的 <b><font color="#0000ff">数据是干净的</font></b>,也就是说,这个时候 <b><font color="#0000ff">Cache Block 里的数据和内存里面的数据是一致性的</font></b>。
「独占」和「共享」的差别在于:<br><br><ul><li><b style="font-size: inherit;"><font color="#0000ff">独占 </font></b><span style="font-size: inherit;">状态的时候,</span><b style="font-size: inherit;"><font color="#0000ff">数据只存储在一个 CPU 核心的 Cache 里</font></b><span style="font-size: inherit;">,而其他 CPU 核心的 Cache 没有该数据。这个时候,如果要向独占的 Cache 写数据,<br></span>就可以直接自由地写入,而不需要通知其他 CPU 核心。因为只有你这有这个数据,就不存在缓存一致性的问题了,于是就可以随便操作该数据。</li></ul><br style="font-size: inherit;"><ul><li><b style="font-size: inherit;"><font color="#0000ff">共享 </font></b><span style="font-size: inherit;">状态代表着 </span><b style="font-size: inherit;"><font color="#0000ff">相同的数据在多个 CPU 核心的 Cache 里都有</font></b><span style="font-size: inherit;">,所以当我们要更新 Cache 里面的数据的时候,不能直接修改,而是要先向所有<br></span>的其他 CPU 核心广播一个请求,要求先把其他核心的 Cache 中对应的 Cache Line 标记为「无效」状态,然后再更新当前 Cache 里面的数据。</li></ul><br style="font-size: inherit;"><span style="font-size: inherit;">另外,在「独占」状态下的数据,如果有其他核心从内存读取了相同的数据到各自的 Cache ,那么这个时候,独占状态下的数据就会变成共享状态。</span><br>
我们举个具体的例子来看看这四个状态的转换:<br><br><ol><li>当 A 号 CPU 核心从内存读取变量 i 的值,数据被缓存在 A 号 CPU 核心自己的 Cache 里面,此时其他 CPU 核心的 Cache 没有缓存该数据,<br>于是标记 Cache Line 状态为「独占」,此时其 Cache 中的数据与内存是一致的;</li><li>然后 B 号 CPU 核心也从内存读取了变量 i 的值,此时会发送消息给其他 CPU 核心,由于 A 号 CPU 核心已经缓存了该数据,所以会把数据返回给 <br>B 号 CPU 核心。在这个时候, A 和 B 核心缓存了相同的数据,Cache Line 的状态就会变成「共享」,并且其 Cache 中的数据与内存也是一致的;</li><li>当 A 号 CPU 核心要修改 Cache 中 i 变量的值,发现数据对应的 Cache Line 的状态是共享状态,则要向所有的其他 CPU 核心广播一个请求,<br>要求先把其他核心的 Cache 中对应的 Cache Line 标记为「无效」状态,然后 A 号 CPU 核心才更新 Cache 里面的数据,同时标记 Cache Line <br>为「已修改」状态,此时 Cache 中的数据就与内存不一致了。</li><li>如果 A 号 CPU 核心「继续」修改 Cache 中 i 变量的值,由于此时的 Cache Line 是「已修改」状态,因此不需要给其他 CPU 核心发送消息,<br>直接更新数据即可。</li><li>如果 A 号 CPU 核心的 Cache 里的 i 变量对应的 Cache Line 要被「替换」,发现 Cache Line 状态是「已修改」状态,就会在替换前先把<br>数据同步到内存。</li></ol>
所以,可以发现当 Cache Line 状态是<b><font color="#0000ff">「已修改」或者「独占」</font></b>状态时,修改更新<br>其数据 <b><font color="#0000ff">不需要发送广播给其他 CPU 核心</font></b>,这在一定程度上 <b><font color="#0000ff">减少了总线带宽压力</font></b>。
事实上,整个 <b><font color="#0000ff">MESI 的状态可以用一个有限状态机来表示它的状态流转</font></b>。还有一点,对于不同状态触发的事件操作,<br>可能是来自本地 CPU 核心发出的广播事件,也可以是来自其他 CPU 核心通过总线发出的广播事件。
下图即是 MESI 协议的状态图:
MESI 协议的四种状态之间的流转过程,我汇总成了下面的表格,你可以更详细的看到每个状态转换的原因:
总结
CPU 在读写数据的时候,都是在 CPU Cache 读写数据的,原因是 Cache 离 CPU 很近,读写性能相比内存高出很多。对于 Cache 里没有<br>缓存 CPU 所需要读取的数据的这种情况,CPU 则会从内存读取数据,并将数据缓存到 Cache 里面,最后 CPU 再从 Cache 读取数据。
而对于数据的写入,CPU 都会先写入到 Cache 里面,然后再在找个合适的时机写入到内存,<br>那就有「写直达」和「写回」这两种策略来保证 Cache 与内存的数据一致性:<br><br><ul><li>写直达,只要有数据写入,都会直接把数据写入到内存里面,这种方式简单直观,但是性能就会受限于内存的访问速度;</li><li>写回,对于已经缓存在 Cache 的数据的写入,只需要更新其数据就可以,不用写入到内存,只有在需要把缓存里面的<br>脏数据交换出去的时候,才把数据同步到内存里,这种方式在缓存命中率高的情况,性能会更好;</li></ul>
当今 CPU 都是多核的,每个核心都有各自独立的 L1/L2 Cache,只有 L3 Cache 是多个核心之间共享的。<br>所以,我们要确保多核缓存是一致性的,否则会出现错误的结果。
要想实现缓存一致性,关键是要满足 2 点:<br><br><ul><li>第一点是写传播,也就是当某个 CPU 核心发生写入操作时,需要把该事件广播通知给其他核心;</li><li>第二点是事物的串行化,这个很重要,只有保证了这个,才能保障我们的数据是真正一致的,<br>我们的程序在各个不同的核心上运行的结果也是一致的;</li></ul>
基于总线嗅探机制的 MESI 协议,就满足上面了这两点,因此它是保障缓存一致性的协议。
MESI 协议,是已修改、独占、共享、已失效这四个状态的英文缩写的组合。整个 MSI 状态的变更,则是根据来自本地 CPU 核心的请求,<br>或者来自其他 CPU 核心通过总线传输过来的请求,从而构成一个流动的状态机。另外,对于在「已修改」或者「独占」状态的 Cache Line,<br>修改更新其数据不需要发送广播给其他 CPU 核心。
CPU 是如何执行任务的?
CPU 如何读写数据的?
介绍
先来认识 CPU 的架构,只有理解了 CPU 的 架构,才能更好地理解 CPU 是如何读写数据的,对于现代 CPU 的架构图如下:
可以看到,一个 CPU 里通常会有多个 CPU 核心,比如上图中的 1 号和 2 号 CPU 核心,并且每个 CPU 核心都有自己的 L1 Cache 和 L2 Cache,<br>而 L1 Cache 通常分为 dCache(数据缓存) 和 iCache(指令缓存),L3 Cache 则是多个核心共享的,这就是 CPU 典型的缓存层次。
上面提到的都是 CPU 内部的 Cache,放眼外部的话,还会有内存和硬盘,这些存储设备共同构成了金字塔存储层次。如下图所示:<br>
从上图也可以看到,从上往下,存储设备的容量会越大,而访问速度会越慢。至于每个存储设备的访问延时,你可以看下图的表格:
你可以看到, CPU 访问 L1 Cache 速度比访问内存快 100 倍,这就是为什么 CPU 里会有 L1~L3 Cache 的原因,<br>目的就是把 Cache 作为 CPU 与内存之间的缓存层,以减少对内存的访问频率。
CPU 从内存中读取数据到 Cache 的时候,并不是一个字节一个字节读取,而是一块一块的方式来读取数据的,<br>这一块一块的数据被称为 CPU Cache Line(缓存块),所以 <b><font color="#0000ff">CPU Cache Line 是 CPU 从内存读取数据到 Cache 的单位</font></b>。
至于 CPU Cache Line 大小,在 Linux 系统可以用下面的方式查看到,你可以看我服务器的 <br>L1 Cache Line 大小是 64 字节,也就意味着 <b><font color="#0000ff">L1 Cache 一次载入数据的大小是 64 字节</font></b>。
那么对数组的加载, CPU 就会加载数组里面连续的多个数据到 Cache 里,因此我们应该按照物理内存地址分布的顺序去访问元素,<br>这样访问数组元素的时候,Cache 命中率就会很高,于是就能减少从内存读取数据的频率, 从而可提高程序的性能。
但是,在我们不使用数组,而是使用单独的变量的时候,则会有 Cache 伪共享的问题,Cache 伪共享问题上是一个性能杀手,我们应该要规避它。
接下来,就来看看 Cache 伪共享是什么?又如何避免这个问题?
现在假设有一个双核心的 CPU,这两个 CPU 核心并行运行着两个不同的线程,它们同时从内存中读取两个不同的数据,分别是类型为 long 的变量 A 和 B,这个两个数据的地址在物理内存上是连续的,如果 Cahce Line 的大小是 64 字节,并且变量 A 在 Cahce Line 的开头位置,那么这两个数据是位于同一个 <br>Cache Line 中,又因为 CPU Cache Line 是 CPU 从内存读取数据到 Cache 的单位,所以这两个数据会被同时读入到了两个 CPU 核心中各自 Cache 中。
我们来思考一个问题,如果这两个不同核心的线程分别修改不同的数据,比如 1 号 CPU 核心<br>的线程只修改了 变量 A,或 2 号 CPU 核心的线程的线程只修改了变量 B,会发生什么呢?<br>
分析伪共享的问题
现在我们结合保证多核缓存一致的 MESI 协议,来说明这一整个的过程。
①. 最开始变量 A 和 B 都还不在 Cache 里面,假设 1 号核心绑定了线程 A,2 号核心绑定了线程 B,线程 A 只会读写变量 A,线程 B 只会读写变量 B。
②. 1 号核心读取变量 A,由于 CPU 从内存读取数据到 Cache 的单位是 Cache Line,也正好变量 A 和 变量 B 的数据<br>归属于同一个 Cache Line,所以 A 和 B 的数据都会被加载到 Cache,并将此 Cache Line 标记为「独占」状态。
③. 接着,2 号核心开始从内存里读取变量 B,同样的也是读取 Cache Line 大小的数据到 Cache 中,此 Cache Line <br>中的数据也包含了变量 A 和 变量 B,此时 1 号和 2 号核心的 Cache Line 状态变为「共享」状态。
④. 1 号核心需要修改变量 A,发现此 Cache Line 的状态是「共享」状态,所以先需要通过总线发送消息给 2 号核心,通知 2 号核心|<br>把 Cache 中对应的 Cache Line 标记为「已失效」状态,然后 1 号核心对应的 Cache Line 状态变成「已修改」状态,并且修改变量 A。
⑤. 之后,2 号核心需要修改变量 B,此时 2 号核心的 Cache 中对应的 Cache Line 是已失效状态,另外由于 1 号核心的 Cache <br>也有此相同的数据,且状态为「已修改」状态,所以要先把 1 号核心的 Cache 对应的 Cache Line 写回到内存,然后 2 号核心再<br>从内存读取 Cache Line 大小的数据到 Cache 中,最后把变量 B 修改到 2 号核心的 Cache 中,并将状态标记为「已修改」状态。
所以,可以发现 <b><font color="#0000ff">如果 1 号和 2 号 CPU 核心这样持续交替的分别修改变量 A 和 B</font></b>,就会 <b><font color="#0000ff">重复 ④ 和 ⑤ 这两个步骤</font></b>,<br><b><font color="#0000ff">Cache 并没有起到缓存的效果</font></b>,虽然 <b><font color="#0000ff">变量 A 和 B 之间其实并没有任何的关系,但是因为同时归属于一个 Cache Line</font></b> ,<br>这个 Cache Line 中的任意数据被修改后,都会相互影响,从而出现 ④ 和 ⑤ 这两个步骤。
因此,这种因为 <b><font color="#0000ff">多个线程同时读写同一个 Cache Line 的不同变量时</font></b>,而导致 <b><font color="#0000ff">CPU Cache 失效的现象称为伪共享(False Sharing)</font></b>。
避免伪共享的方法
因此,对于多个线程共享的热点数据,即经常会修改的数据,应该避免这些数据刚好在同一个 Cache Line 中,否则就会出现为伪共享的问题。
接下来,看看在实际项目中是用什么方式来避免伪共享的问题的。
在 Linux 内核中存在 __cacheline_aligned_in_smp 宏定义,是用于解决伪共享的问题。<br>
从上面的宏定义,我们可以看到:<br><br><ul><li>如果在多核(MP)系统里,该宏定义是 __cacheline_aligned,也就是 Cache Line 的大小;</li><li>而如果在单核系统里,该宏定义是空的;</li></ul>
因此,针对在同一个 Cache Line 中的共享的数据,如果在多核之间竞争比较严重,为了<br>防止伪共享现象的发生,可以采用上面的宏定义使得变量在 Cache Line 里是对齐的。
举个例子,有下面这个结构体:
结构体里的两个成员变量 a 和 b 在物理内存地址上是连续的,于是它们可能会位于同一个 Cache Line 中,如下图:
所以,为了防止前面提到的 Cache 伪共享问题,我们可以使用上面介绍的宏定义,将 b 的地址设置为 Cache Line 对齐地址,如下:
这样 a 和 b 变量就不会在同一个 Cache Line 中了,如下图:
所以,避免 Cache 伪共享实际上是用空间换时间的思想,浪费一部分 Cache 空间,从而换来性能的提升。
我们再来看一个应用层面的规避方案,有一个 Java 并发框架 Disruptor 使用「字节填充 + 继承」的方式,来避免伪共享的问题。
Disruptor 中有一个 RingBuffer 类会经常被多个线程使用,代码如下:
你可能会觉得 RingBufferPad 类里 7 个 long 类型的名字很奇怪,但事实上,它们虽然看起来毫无作用,但却对性能的提升起到了至关重要的作用。
我们都知道,CPU Cache 从内存读取数据的单位是 CPU Cache Line,一般 64 位 CPU 的 CPU Cache Line <br>的大小是 64 个字节,一个 long 类型的数据是 8 个字节,所以 CPU 一下会加载 8 个 long 类型的数据。
根据 JVM 对象继承关系中父类成员和子类成员,内存地址是连续排列布局的,因此 RingBufferPad 中的 7 个 long 类型数据作为 Cache Line 前置<br>填充,而 RingBuffer 中的 7 个 long 类型数据则作为 Cache Line 后置填充,这 14 个 long 变量没有任何实际用途,更不会对它们进行读写操作。
另外,RingBufferFelds 里面定义的这些变量都是 final 修饰的,意味着第一次加载之后不会再修改, 又 <b><font color="#0000ff">由于「前后」<br>各填充了 7 个不会被读写的 long 类型变量,所以无论怎么加载 Cache Line,这整个 Cache Line 里都没有会发生更新<br>操作的数据,于是只要数据被频繁地读取访问,就自然没有数据被换出 Cache 的可能,也因此不会产生伪共享的问题。</font></b>
CPU 如何选择线程的?
介绍
了解完 CPU 读取数据的过程后,我们再来看看 CPU 是根据什么来选择当前要执行的线程。<br>
在 Linux 内核中,进程和线程都是用 task_struct 结构体表示的,区别在于线程的 task_struct 结构体里部分资源是共享了<br>进程已创建的资源,比如内存地址空间、代码段、文件描述符等,所以 Linux 中的线程也被称为轻量级进程,因为线程的 <br>task_struct 相比进程的 task_struct 承载的 资源比较少,因此以「轻」得名。
一般来说,没有创建线程的进程,是只有单个执行流,它被称为是主线程。如果想让进程处理更多的事情,<br>可以创建多个线程分别去处理,但不管怎么样,它们对应到内核里都是 task_struct。
所以,Linux 内核里的调度器,调度的对象就是 task_struct,接下来我们就把这个数据结构统称为 <b><font color="#0000ff">任务</font></b>。
在 Linux 系统中,根据任务的优先级以及响应要求,主要分为两种,其中优先级的数值越小,优先级越高:<br><br><ul><li>实时任务,对系统的响应时间要求很高,也就是要尽可能快的执行实时任务,优先级在 0~99 范围内的就算实时任务;</li><li>普通任务,响应时间没有很高的要求,优先级在 100~139 范围内都是普通任务级别;</li></ul>
调度类
由于任务有优先级之分,Linux 系统为了保障高优先级的任务能够尽可能早的被执行,于是分为了这几种调度类,如下图:
<b><font color="#ff00ff"><i>Deadline</i></font></b> 和 <b><font color="#ff00ff"><i>Realtime</i></font></b> 这两个调度类,都是应用于 <b><font color="#0000ff">实时任务 </font></b>的,这两个调度类的调度策略合起来共有这三种,它们的作用如下:<br><br><ul><li><font color="#ff00ff"><i>SCHED_DEADLINE</i></font>:是按照 deadline 进行调度的,距离当前时间点最近的 deadline 的任务会被优先调度;</li><li><font color="#ff00ff"><i>SCHED_FIFO</i></font>:对于相同优先级的任务,按先来先服务的原则,但是优先级更高的任务,可以抢占低优先级的<br>任务,也就是优先级高的可以「插队」;</li><li><i><font color="#ff00ff">SCHED_RR</font></i>:对于相同优先级的任务,轮流着运行,每个任务都有一定的时间片,当用完时间片的任务会被放到<br>队列尾部,以保证相同优先级任务的公平性,但是高优先级的任务依然可以抢占低优先级的任务;</li></ul>
而 <b><font color="#ff00ff"><i>Fair</i></font></b> 调度类是应用于普通任务,都是由 CFS 调度器管理的,分为两种调度策略:<br><br><ul><li><i><font color="#ff00ff">SCHED_NORMAL</font></i>:普通任务使用的调度策略;</li><li><i><font color="#ff00ff">SCHED_BATCH</font></i>:后台任务的调度策略,不和终端进行交互,<br>因此在不影响其他需要交互的任务,可以适当降低它的优先级。</li></ul>
完全公平调度
我们平日里遇到的基本都是普通任务,对于普通任务来说,公平性最重要,在 Linux 里面,<br>实现了一个基于 CFS 的调度算法,也就是<b><font color="#0000ff"> 完全公平调度(Completely Fair Scheduling)</font></b>。
这个算法的理念是想让分配给每个任务的 CPU 时间是一样,于是它为每个任务安排一个虚拟运行时间 vruntime,<br>如果一个任务在运行,其运行的越久,该任务的 vruntime 自然就会越大,而没有被运行的任务,vruntime 不变。
那么,<b><font color="#0000ff">在 CFS 算法调度的时候,会优先选择 vruntime 少的任务</font></b>,以保证每个任务的公平性。
这就好比,让你把一桶的奶茶平均分到 10 杯奶茶杯里,你看着哪杯奶茶少,就多倒一些;哪个多了,<br>就先不倒,这样经过多轮操作,虽然不能保证每杯奶茶完全一样多,但至少是公平的。
当然,上面提到的例子没有考虑到优先级的问题,虽然是普通任务,但是普通任务之间还是有优先级区分的,所以在计算<br>虚拟运行时间 vruntime 还要考虑普通任务的 <b><font color="#0000ff">权重值</font></b>,注意权重值并不是优先级的值,内核中会有一个 nice 级别与权重值<br>的转换表,nice 级别越低的权重值就越大,至于 nice 值是什么,我们后面会提到。 于是就有了以下这个公式:
你可以不用管 NICE_0_LOAD 是什么,你就认为它是一个常量。那么在「<b><font color="#0000ff">同样的实际运行时间</font></b>」里,高权重任务<br>的 vruntime 比低权重任务的 vruntime <b><font color="#0000ff">少</font></b>,你可能会奇怪为什么是少?你还记得 CFS 调度吗,它是会优先选择 <br>vruntime 少的任务进行调度,所以高权重的任务会被优先调度,<b><font color="#0000ff">于是高权重的获得的实际运行时间自然就多了</font></b>。
CPU 运行队列
一个系统通常都会运行着很多任务,多任务的数量基本都是远超 CPU 核心数量,因此这时候就需要 <b><font color="#0000ff">排队</font></b>。
事实上,每个 CPU 都有自己的 <b><font color="#0000ff">运行队列(Run Queue, rq)</font></b>,用于描述在此 CPU 上所运行的所有进程,其<br>队列包含三个运行队列,Deadline 运行队列 dl_rq、实时任务运行队列 rt_rq 和 CFS 运行队列 cfs_rq,其中 <br>cfs_rq 是用红黑树来描述的,按 vruntime 大小来排序的,最左侧的叶子节点,就是下次会被调度的任务。
PS:下图中的 csf_rq 应该是 cfs_rq。
这几种调度类是有优先级的,<b><font color="#0000ff">优先级 </font></b>如下:Deadline > Realtime > Fair,这意味着 Linux 选择下一个任务执行的时候,会按照此优先级顺序进行<br>选择,也就是说先从 dl_rq 里选择任务,然后从 rt_rq 里选择任务,最后从 cfs_rq 里选择任务。因此,<b><font color="#0000ff">实时任务总是会比普通任务优先被执行</font></b>。
调整优先级
如果我们启动任务的时候,没有特意去指定优先级的话,默认情况下都是普通任务,普通任务的调度类是 Fair,由 <br>CFS 调度器来进行管理。CFS 调度器的目的是实现任务运行的公平性,也就是保障每个任务的运行的时间是差不多的。
如果你想让某个普通任务有更多的执行时间,可以调整任务的 nice 值,从而让优先级高一些的任务执行更多时间。nice 的<br>值能设置的范围是 -20~19, 值越低,表明优先级越高,因此 -20 是最高优先级,19 则是最低优先级,默认优先级是 0。
是不是觉得 nice 值的范围很诡异?事实上,<b><font color="#0000ff">nice 值并不是表示优先级,而是表示优先级的修正数值</font></b>,它与优先级(priority)的关系是这样的:<br>priority(new) = priority(old) + nice。内核中,<b><font color="#0000ff">priority</font></b> 的范围是 0~139,<b><font color="#0000ff">值越低,优先级越高</font></b>,其中前面的 0~99 范围是提供给实时任务<br>使用的,而 nice 值是映射到 100~139,这个范围是提供给普通任务用的,因此 nice 值调整的是普通任务的优先级。
在前面我们提到了,权重值与 nice 值的关系的,<b><font color="#0000ff">nice 值越低,权重值就越大</font></b>,计算出来的 <b><font color="#0000ff">vruntime 就会越少</font></b>,<br>由于 <b><font color="#0000ff">CFS 算法调度 </font></b>的时候,就会优先选择 vruntime 少的任务进行执行,所以 <b><font color="#0000ff">nice 值越低,任务的优先级就越高</font></b>。
我们可以在启动任务的时候,可以指定 nice 的值,比如将 mysqld 以 -3 优先级:
如果想修改已经运行中的任务的优先级,则可以使用 renice 来调整 nice 值:
nice 调整的是普通任务的优先级,所以不管怎么缩小 nice 值,任务永远都是普通任务,如果某些任务<br>要求实时性比较高,那么你可以考虑改变任务的优先级以及调度策略,使得它变成实时任务,比如:
总结
理解 CPU 是如何读写数据的前提,是要理解 CPU 的架构,CPU 内部的多个 Cache + 外部的内存和磁盘<br>都就构成了金字塔的存储器结构,在这个金字塔中,越往下,存储器的容量就越大,但访问速度就会小。
CPU 读写数据的时候,并不是按一个一个字节为单位来进行读写,而是以 CPU Cache Line 大小为单位,CPU Cache Line <br>大小一般是 64 个字节,也就意味着 CPU 读写数据的时候,每一次都是以 64 字节大小为一块进行操作。<br>
因此,如果我们操作的数据是数组,那么访问数组元素的时候,按内存分布的地址顺序进行访问,这样能充分利用到 Cache,程序的<br>性能得到提升。但如果操作的数据不是数组,而是普通的变量,并在多核 CPU 的情况下,我们还需要避免 Cache Line 伪共享的问题。
所谓的 Cache Line 伪共享问题就是,多个线程同时读写同一个 Cache Line 的不同变量时,而导致 CPU Cache 失效的现象。那么对于多个线程共享的<br>热点数据,即经常会修改的数据,应该避免这些数据刚好在同一个 Cache Line 中,避免的方式一般有 Cache Line 大小字节对齐,以及字节填充等方法。
系统中需要运行的多线程数一般都会大于 CPU 核心,这样就会导致线程排队等待 CPU,这可能会产生一定的延时,<br>如果我们的任务对延时容忍度很低,则可以通过一些人为手段干预 Linux 的默认调度策略和优先级。
什么是软中断?
中断是什么?
先来看看什么是中断?在计算机中,中断是系统用来响应硬件设备请求的一种机制,操作系统<br>收到硬件的中断请求,会打断正在执行的进程,然后调用内核中的中断处理程序来响应请求。
这样的解释可能过于学术了,容易云里雾里,我就举个生活中取外卖的例子。
小林中午搬完砖,肚子饿了,点了份白切鸡外卖,这次我带闪了,没有被某团大数据杀熟。虽然平台上会显示配送进度,但是我也不能一直<br>傻傻地盯着呀,时间很宝贵,当然得去干别的事情,等外卖到了配送员会通过「电话」通知我,电话响了,我就会停下手中地事情,去拿外卖。
这里的打电话,其实就是对应计算机里的中断,没接到电话的时候,我可以做其他的事情,<br>只有接到了电话,也就是发生中断,我才会停下当前的事情,去进行另一个事情,也就是拿外卖。
从这个例子,我们可以知道,中断是一种异步的事件处理机制,可以提高系统的并发处理能力。
操作系统收到了中断请求,会打断其他进程的运行,所以 <b><font color="#0000ff">中断请求的响应程序,也<br>就是中断处理程序,要尽可能快的执行完,这样可以减少对正常进程运行调度地影响</font></b>。
而且,中断处理程序在响应中断时,可能还会「临时关闭中断」,这意味着,如果当前中断处理程序没有<br>执行完之前,系统中其他的中断请求都无法被响应,也就说中断有可能会丢失,所以中断处理程序要短且快。
还是回到外卖的例子,小林到了晚上又点起了外卖,这次为了犒劳自己,共点了两份外卖,一份小龙虾和一份奶茶,并且是由不同地配送员来配送,那么<br>问题来了,当第一份外卖送到时,配送员给我打了长长的电话,说了一些杂七杂八的事情,比如给个好评等等,但如果这时另一位配送员也想给我打电话。
很明显,这时第二位配送员因为我在通话中(相当于关闭了中断响应),自然就<br>无法打通我的电话,他可能尝试了几次后就走掉了(相当于丢失了一次中断)。<br>
什么是软中断?
前面我们也提到了,中断请求的处理程序应该要短且快,这样才能减少对正常进程运行调度地影响,而且中断处理程序可能会<br>暂时关闭中断,这时如果中断处理程序执行时间过长,可能在还未执行完中断处理程序前,会丢失当前其他设备的中断请求。
那 Linux 系统 <b><font color="#0000ff">为了解决中断处理程序执行过长和中断丢失的问题,将中断过程分成了两个阶段,分别是「上半部和下半部分」</font></b>:<br><br><ul><li><b><font color="#0000ff">上半部用来快速处理中断</font></b>,一般会暂时关闭中断请求,主要负责处理跟硬件紧密相关或者时间敏感的事情。</li><li><b><font color="#0000ff">下半部用来延迟处理上半部未完成的工作</font></b>,一般以「内核线程」的方式运行。</li></ul>
前面的外卖例子,由于第一个配送员长时间跟我通话,则导致第二位配送员无法拨通我的电话,其实当我接到第一位配送员的电话,可以告诉配送员<br>说我现在下楼,剩下的事情,等我们见面再说(上半部),然后就可以挂断电话,到楼下后,在拿外卖,以及跟配送员说其他的事情(下半部)。
这样,第一位配送员就不会占用我手机太多时间,当第二位配送员正好过来时,会有很大几率拨通我的电话。
再举一个计算机中的例子,常见的网卡接收网络包的例子。
网卡收到网络包后,通过 DMA 方式将接收到的数据写入内存,接着会通过 <b><font color="#0000ff">硬件中断</font></b> 通知内核有新的数据到了,<br>于是内核就会调用对应的中断处理程序来处理该事件,这个事件的处理也是会分成上半部和下半部。
上部分要做的事情很少,会先禁止网卡中断,避免频繁硬中断,而降低内核的工作效率。接着,内核会触发一个 <b><font color="#0000ff">软中断</font></b>,<br>把一些处理比较耗时且复杂的事情,交给「软中断处理程序」去做,也就是中断的下半部,其主要是需要从内存中找到<br>网络数据,再按照网络协议栈,对网络数据进行逐层解析和处理,最后把数据送给应用程序。
所以,中断处理程序的上部分和下半部可以理解为:<br><br><ul><li><b><font color="#0000ff">上半部直接处理硬件请求,也就是硬中断</font></b>,主要是负责耗时短的工作,特点是快速执行;</li><li><b><font color="#0000ff">下半部是由内核触发,也就说软中断</font></b>,主要是负责上半部未完成的工作,通常都是耗时比较长的事情,特点是延迟执行;</li></ul>
还有一个区别,硬中断(上半部)是会打断 CPU 正在执行的任务,然后立即执行中断处理程序,而软中断(下半部)是以内核线程的方式执行,<br>并且每一个 CPU 都对应一个软中断内核线程,名字通常为「ksoftirqd/CPU 编号」,比如 0 号 CPU 对应的软中断内核线程的名字是 ksoftirqd/0
不过,软中断不只是包括硬件设备中断处理程序的下半部,一些内核自定义事件也属于软中断,比如内核调度等、RCU 锁(内核里常用的一种锁)等。
系统里有那些软中断?
在 Linux 系统里,我们可以通过查看 /proc/softirqs 的 内容来知晓「软中断」的运行情况,以及 /proc/interrupts 的 内容来知晓「硬中断」的运行情况。
接下来,就来简单的解析下 /proc/softirqs 文件的内容,在我服务器上查看到的文件内容如下:
你可以看到,每一个 CPU 都有自己对应的不同类型软中断的累计运行次数,有 3 点需要注意下。
第一点,要注意第一列的内容,它是代表着软中断的类型,在我的系统里,软中断包括了 10 个类型,分别对应不同的工作类型,比如 <br>NET_RX 表示网络接收中断,NET_TX 表示网络发送中断、TIMER 表示定时中断、RCU 表示 RCU 锁中断、SCHED 表示内核调度中断。
第二点,要注意同一种类型的软中断在不同 CPU 的分布情况,正常情况下,同一种中断在不同 CPU 上的累计次数相差<br>不多,比如我的系统里,NET_RX 在 CPU0 、CPU1、CPU2、CPU3 上的中断次数基本是同一个数量级,相差不多。
第三点,这些数值是系统运行以来的累计中断次数,数值的大小没什么参考意义,但是系统的 <b><font color="#0000ff">中断次数的<br>变化速率 </font></b>才是我们要关注的,我们可以使用 watch -d cat /proc/softirqs 命令查看中断次数的变化速率。
前面提到过,软中断是以内核线程的方式执行的,我们可以用 ps 命令可以查看到,下面这个就是在我的服务器上查到软中断内核线程的结果:
可以发现,内核线程的名字外面都有有中括号,这说明 ps 无法获取它们的命令行参数,所以一般来说,名字在中括号里的都可以认为是内核线程。
而且,你可以看到有 4 个 ksoftirqd 内核线程,这是因为我这台服务器的 CPU 是 4 核心的,每个 CPU 核心都对应着一个内核线程。<br>
如何定位软中断 CPU 使用率过高的问题?
要想知道当前的系统的软中断情况,我们可以使用 top 命令查看,下面是一台服务器上的 top 的数据:
上图中的黄色部分 si,就是 CPU 在软中断上的使用率,而且可以发现,每个 CPU 使用率都不高,<br>两个 CPU 的使用率虽然只有 3% 和 4% 左右,但是都是用在软中断上了。
另外,也可以看到 CPU 使用率最高的进程也是软中断 ksoftirqd,因此可以认为此时系统的开销主要来源于软中断。
如果要知道是哪种软中断类型导致的,我们可以使用 watch -d cat /proc/softirqs 命令查看每个软中断类型的中断次数的变化速率。
一般对于网络 I/O 比较高的 Web 服务器,NET_RX 网络接收中断的变化速率相比其他中断类型快很多。
如果发现 NET_RX 网络接收中断次数的变化速率过快,接下来就可以使用 sar -n DEV 查看网卡的<br>网络包接收速率情况,然后分析是哪个网卡有大量的网络包进来。
接着,在通过 tcpdump 抓包,分析这些包的来源,如果是非法的地址,可以考虑加防火墙,如果是正常流量,则要考虑硬件升级等。
总结
为了避免由于中断处理程序执行时间过长,而影响正常进程的调度,Linux 将中断处理程序分为上半部和下半部:<br><br><ul><li>上半部,对应硬中断,由硬件触发中断,用来快速处理中断;</li><li>下半部,对应软中断,由内核触发中断,用来异步处理上半部未完成的工作;</li></ul>
Linux 中的软中断包括网络收发、定时、调度、RCU 锁等各种类型,可以通过查看 /proc/softirqs 来观察软中断<br>的累计中断次数情况,如果要实时查看中断次数的变化率,可以使用 watch -d cat /proc/softirqs 命令。
每一个 CPU 都有各自的软中断内核线程,我们还可以用 ps 命令来查看内核线程,一般名字在中括号里面到,都认为是内核线程。
如果在 top 命令发现,CPU 在软中断上的使用率比较高,而且 CPU 使用率最高的进程也是软中断 ksoftirqd 的时候,<br>这种一般可以认为系统的开销被软中断占据了。
这时我们就可以分析是哪种软中断类型导致的,一般来说都是因为网络接收软中断导致的,如果是的话,可以用 sar 命令查看是哪个网卡的有大量的网络包<br>接收,再用 tcpdump 抓网络包,做进一步分析该网络包的源头是不是非法地址,如果是就需要考虑防火墙增加规则,如果不是,则考虑硬件升级等。
为什么 0.1 + 0.2 不等于 0.3 ?
为什么负数要用补码表示?
十进制转换二进制的方法相信大家都熟能生巧了,如果你说你还不知道,我觉得你还是太谦虚,<br>可能你只是忘记了,即使你真的忘记了,不怕,贴心的小林在和你一起回忆一下。
十进制数转二进制采用的是 <b><font color="#0000ff">除 2 取余法</font></b>,比如数字 8 转二进制的过程如下图:
接着,我们看看「整数类型」的数字在计算机的存储方式,这其实很简单,也很直观,就是将十进制的数字转换成二进制即可。
我们以 int 类型的数字作为例子,int 类型是 32 位的,其中 <b><font color="#0000ff">最高位是作为「符号标志位」</font></b>,<br>正数的符号位是 0,负数的符号位是 1,<b><font color="#0000ff">剩余的 31 位则表示二进制数据</font></b>。
那么,对于 int 类型的数字 1 的二进制数表示如下:
而负数就比较特殊了点,负数在计算机中是以「补码」表示的,所谓的 <b><font color="#0000ff">补码就是把正数的<br>二进制全部取反再加 1</font></b>,比如 -1 的二进制是把数字 1 的二进制取反后再加 1,如下图:
不知道你有没有想过,为什么计算机要用补码的方式来表示负数?在回答这个问题前,<br>我们假设不用补码的方式来表示负数,而只是把最高位的符号标志位变为 1 表示负数,如下图过程:
如果采用这种方式来表示负数的二进制的话,试想一下 -2 + 1 的运算过程,如下图:
按道理,-2 + 1 = -1,但是上面的运算过程中得到结果却是 -3,所可以发现,这种负数的表示方式是不能用常规的加法<br>来计算了,就需要特殊处理,要先判断数字是否为负数,如果是负数就要把加法操作变成减法操作才可以得到正确对结果。
到这里,我们就可以回答前面提到的「负数为什么要用补码方式来表示」的问题了。
如果负数不是使用补码的方式表示,则在做基本对加减法运算的时候,<b><font color="#0000ff">还需要多一步操作来判断是否为负数,如果为负数,还得把加法反转成减法,<br>或者把减法反转成加法</font></b>,这就非常不好了,毕竟加减法运算在计算机里是很常使用的,所以为了性能考虑,应该要尽量简化这个运算过程。
<b><font color="#0000ff">而用了补码的表示方式,对于负数的加减法操作,实际上是和正数加减法操作一样的</font></b>。<br>你可以看到下图,用补码表示的负数在运算 -2 + 1 过程的时候,其结果是正确的:
十进制小数与二进制的转换
好了,整数十进制转二进制我们知道了,接下来看看小数是怎么转二进制的,小数部分的转换不同于整数部分,它采用的是 <b><font color="#0000ff">乘 2 取整法</font></b>,<br>将十进制中的小数部分乘以 2 作为二进制的一位,然后继续取小数部分乘以 2 作为下一位,直到不存在小数为止。
最后把「整数部分 + 小数部分」结合在一起后,其结果就是 1000.101。
但是,并不是所有小数都可以用二进制表示,前面提到的 0.625 小数是一个特例,刚好通过乘 2 取整法的方式完整的转换成二进制。
如果我们用相同的方式,来把 0.1 转换成二进制,过程如下:
可以发现,0.1 的二进制表示是无限循环的。
<b><font color="#0000ff">由于计算机的资源是有限的,所以是没办法用二进制精确的表示 0.1,只能用「近似值」来表示,<br>就是在有限的精度情况下,最大化接近 0.1 的二进制数,于是就会造成精度缺失的情况。</font></b>
对于二进制小数转十进制时,需要注意一点,小数点后面的指数幂是 <b><font color="#0000ff">负数</font></b>。
比如,二进制 0.1 转成十进制就是 2^(-1),也就是十进制 0.5,二进制 0.01 转成十进制就是 2^-2,也就是十进制 0.25,以此类推。
举个例子,二进制 1010.101 转十进制的过程,如下图:
计算机是怎么存小数的?
1000.101 这种二进制小数是「定点数」形式,代表着小数点是定死的,不能<br>移动,如果你移动了它的小数点,这个数就变了, 就不再是它原来的值了。
然而,计算机并不是这样存储的小数的,计算机存储小数的采用的是 <b><font color="#0000ff">浮点数</font></b>,名字里的「浮点」表示小数点是可以浮动的。
比如 1000.101 这个二进制数,可以表示成 1.000101 x 2^3,类似于数学上的科学记数法。<br>
既然提到了科学计数法,我再帮大家复习一下。
比如有个很大的十进制数 1230000,我们可以也可以表示成 1.23 x 10^6,这种方式就称为科学记数法。
该方法在小数点左边只有一个数字,而且把这种整数部分没有前导 0 的数字称为规格化,<br>比如 1.0 x 10^(-9) 是规格化的科学记数法,而 0.1 x 10^(-9) 和 10.0 x 10^(-9) 就不是了。
因此,如果二进制要用到科学记数法,同时要规范化,那么不仅要保证基数为 2,还要保证小数点左侧只有 1 位,而且必须为 1。
所以通常将 1000.101 这种二进制数,规格化表示成 1.000101 x 2^3,其中,最为<br>关键的是 000101 和 3 这两个东西,它就可以包含了这个二进制小数的所有信息:<br><br><ul><li>000101 称为 <b><font color="#0000ff">尾数</font></b>,即小数点后面的数字;</li><li>3 称为 <b><font color="#0000ff">指数</font></b>,指定了小数点在数据中的位置;</li></ul>
现在绝大多数计算机使用的浮点数,一般采用的是 IEEE 制定的国际标准,这种标准形式如下图:
这三个重要部分的意义如下:<br><br><ul><li><i><b><font color="#ff00ff">符号位</font></b></i>:表示数字是正数还是负数,为 0 表示正数,为 1 表示负数;</li><li><b><i><font color="#ff00ff">指数位</font></i></b>:指定了小数点在数据中的位置,指数可以是负数,也可以是正数,<b><font color="#0000ff">指数位的长度越长则数值的表达范围就越大</font></b>;</li><li><b><i><font color="#ff00ff">尾数位</font></i></b>:小数点右侧的数字,也就是小数部分,比如二进制 1.0011 x 2^(-2),尾数部分就是 0011,而且 <b><font color="#0000ff">尾数的长度<br>决定了这个数的精度</font></b>,因此如果要表示精度更高的小数,则就要提高尾数位的长度;</li></ul>
用 32 位来表示的浮点数,则称为单精度浮点数,也就是我们编程语言中的 float 变量,<br>而用 64 位来表示的浮点数,称为双精度浮点数,也就是 double 变量,它们的结构如下:
可以看到:<br><br><ul><li>double 的尾数部分是 52 位,float 的尾数部分是 23 位,由于同时都带有一个固定隐含位(这个后面会说),<br>所以 double 有 53 个二进制有效位,float 有 24 个二进制有效位,所以所以它们的精度在十进制中分别是 <br>log10(2^53) 约等于 15.95 和 log10(2^24) 约等于 7.22 位,因此 double 的有效数字是 15~16 位,float <br>的有效数字是 7~8 位,这些有效位是包含整数部分和小数部分;</li><li>double 的指数部分是 11 位,而 float 的指数位是 8 位,意味着 double 相比 float 能表示更大的数值范围;</li></ul>
那二进制小数,是如何转换成二进制浮点数的呢?
我们就以 10.625 作为例子,看看这个数字在 float 里是如何存储的:<br><br><ul><li>首先,我们计算出 10.625 的二进制小数为 1010.101。</li><li>然后 <b><font color="#0000ff">把小数点移动到第一个有效数字后面</font></b>,即将 1010.101 右移 3 位成 1.010101,右移 3 位就代表 +3,左移 3 位就是 -3。</li><li><b><font color="#0000ff">float 中的「指数位」就跟这里移动的位数有关系,把移动的位数再加上「偏移量」,float 的话偏移量是 127,相加后就是指数<br>位的值了</font></b>,即指数位这 8 位存的是 10000010(十进制 130),因此你可以认为「指数位」相当于指明了小数点在数据中的位置。</li><li>1.010101 这个数的 <b><font color="#0000ff">小数点右侧的数字就是 float 里的「尾数位」</font></b>,由于尾数位是 23 位,则 <b><font color="#0000ff">后面要补充 0</font></b>,所以最终尾数位存储<br>的数字是 01010100000000000000000。</li></ul><br>
在算指数的时候,你可能会有疑问为什么要加上偏移量呢?
前面也提到,指数可能是正数,也可能是负数,即指数是有符号的整数,而有符号整数的计算是比无符<br>号整数麻烦的,所以为了减少不必要的麻烦,在实际存储指数的时候,需要把指数转换成<b><font color="#0000ff"> 无符号整数</font></b>。
float 的指数部分是 8 位,IEEE 标准规定单精度浮点的指数取值范围是 -126 ~ +127,于是 <b><font color="#0000ff">为了把指数<br>转换成无符号整数,就要加个偏移量</font></b>,比如 float 的指数偏移量是 127,这样指数就不会出现负数了。
比如,指数如果是 8,则实际存储的指数是 8 + 127(偏移量)= 135,即把 135 转换为二进制<br>之后再存储,而当我们需要计算实际的十进制数的时候,再把指数减去「偏移量」即可。
细心的朋友肯定发现,<b><font color="#0000ff">移动后的小数点左侧的有效位(即 1)消失了,它并没有存储到 float 里</font></b>。
这是因为 IEEE 标准规定,二进制浮点数的小数点左侧只能有 1 位,并且还只能是 1,<b><font color="#0000ff">既然这一位永远都是 1,那就可以不用存起来了</font></b>。
于是就让 23 位尾数只存储小数部分,然后在计算时会 <b><font color="#0000ff">自动把这个 1 加上,这样<br>就可以节约 1 位的空间,尾数就能多存一位小数,相应的精度就更高了一点。</font></b>
那么,对于我们在从 float 的二进制浮点数转换成十进制时,要考虑到这个隐含的 1,转换公式如下:
举个例子,我们把下图这个 float 的数据转换成十进制,过程如下:
0.1 + 0.2 == 0.3?
前面提到过,并不是所有小数都可以用「完整」的二进制来表示的,比如十进制 0.1 在转换成二进制小数的<br>时候,是一串无限循环的二进制数,计算机是无法表达无限循环的二进制数的,毕竟计算机的资源是有限。
因此,<b><font color="#0000ff">计算机只能用「近似值」来表示该二进制,那么意味着计算机存放的小数可能不是一个真实值</font></b>。
现在基本都是用 IEEE 754 规范的「单精度浮点类型」或「双精度浮点类型」来存储小数的,根据精度的不同,近似值也会不同。
那计算机是存储 0.1 是一个怎么样的二进制浮点数呢?
偷个懒,我就不自己手动算了,可以使用 binaryconvert 这个工具,将十进制 0.1 小数转换成 float 浮点数:
可以看到,8 位指数部分是 01111011,23 位的尾数部分是 10011001100110011001101,可以看到尾数部分<br>是 0011 是一直循环的,只不过尾数是有长度限制的,所以只会显示一部分,所以是一个近似值,精度十分有限。
接下来,我们看看 0.2 的 float 浮点数:
可以看到,8 位指数部分是 01111100,稍微和 0.1 的指数不同,23 位的尾数部分<br>是 10011001100110011001101 和 0.1 的尾数部分是相同的,也是一个近似值。
0.1 的二进制浮点数转换成十进制的结果是 0.100000001490116119384765625:
0.2 的二进制浮点数转换成十进制的结果是 0.20000000298023223876953125:
这两个结果相加就是 0.300000004470348358154296875:
所以,你会看到在计算机中 0.1 + 0.2 并不等于完整的 0.3。
这主要是 <b><font color="#0000ff">因为有的小数无法可以用「完整」的二进制来表示,所以计算机里只能<br>采用近似数的方式来保存,那两个近似数相加,得到的必然也是一个近似数。</font></b>
我们在 JavaScript 里执行 0.1 + 0.2,你会得到下面这个结果:
结果和我们前面推到的类似,因为 JavaScript 对于数字都是使用 IEEE 754 标准下的双精度浮点类型来存储的。
而我们二进制只能精准表达 2 除尽的数字 1/2, 1/4, 1/8,但是对于 0.1(1/10) 和 0.2(1/5),在二进制中都无法精准表示时,需要根据精度舍入。
我们人类熟悉的十进制运算系统,可以精准表达 2 和 5 除尽的数字,例如 1/2, 1/4, 1/5(0.2), 1/8, 1/10(0.1)。
当然,十进制也有无法除尽的地方,例如 1/3, 1/7,也需要根据精度舍入。<br>
总结
<i><font color="#006064"><b>为什么负数要用补码表示?</b></font></i><br><br>负数之所以用补码的方式来表示,主要是为了统一和正数的加减法操作一样,毕竟<br>数字的加减法是很常用的一个操作,就不要搞特殊化,尽量以统一的方式来运算。
<b><font color="#006064"><i>十进制小数怎么转成二进制?<br></i></font></b><br>十进制整数转二进制使用的是「除 2 取余法」,十进制小数使用的是「乘 2 取整法」。
<b><font color="#006064"><i>计算机是怎么存小数的?<br></i></font></b><br>计算机是以浮点数的形式存储小数的,大多数计算机都是 IEEE 754 标准定义的浮点数格式,包含三个部分:<br><br><ul><li>符号位:表示数字是正数还是负数,为 0 表示正数,为 1 表示负数;</li><li>指数位:指定了小数点在数据中的位置,指数可以是负数,也可以是正数,指数位的长度越长则数值的表达范围就越大;</li><li>尾数位:小数点右侧的数字,也就是小数部分,比如二进制 1.0011 x 2^(-2),尾数部分就是 0011,而且尾数的长度<br>决定了这个数的精度,因此如果要表示精度更高的小数,则就要提高尾数位的长度;<br><br></li></ul>用 32 位来表示的浮点数,则称为单精度浮点数,也就是我们编程语言中的 float 变量,而用 64 位来表示的浮点数,称为双精度浮点数,也就是 double 变量。
<b><i><font color="#006064">0.1 + 0.2 == 0.3 吗?<br></font></i></b><br>不是的,0.1 和 0.2 这两个数字用二进制表达会是一个一直循环的二进制数,比如 0.1 的二进制表示为 0.0 0011 0011 0011… (0011 无限循环),<br>对于计算机而言,0.1 无法精确表达,这是浮点数计算造成精度损失的根源。<br><br>因此,IEEE 754 标准定义的浮点数只能根据精度舍入,然后用「近似值」来表示该二进制,那么意味着计算机存放的小数可能不是一个真实值。<br><br>0.1 + 0.2 并不等于完整的 0.3,这主要是因为这两个小数无法用「完整」的二进制来表示,只能根据精度舍入,所以计算机里只能采用近似数的<br>方式来保存,那两个近似数相加,得到的必然也是一个近似数。
操作系统结构
Linux 内核 vs Windows 内核
介绍
Windows 和 Linux 可以说是我们比较常见的两款操作系统的。<br>
Windows 基本占领了电脑时代的市场,商业上取得了很大成就,但是它并不开源,所以要想接触源码得加入 Windows 的开发团队中。
对于服务器使用的操作系统基本上都是 Linux,而且内核源码也是开源的,任何人都可以下载,<br>并增加自己的改动或功能,Linux 最大的魅力在于,全世界有非常多的技术大佬为它贡献代码。
这两个操作系统各有千秋,不分伯仲。<br>
操作系统核心的东西就是内核,这次我们就来看看,<b><font color="#0000ff">Linux 内核和 Windows 内核有什么区别?</font></b><br>
内核
计算机是由各种外部硬件设备组成的,比如内存、cpu、硬盘等,如果每个应用都要和这些硬件设备对接通信协议,那这样太累了,<br>所以这个中间人就由内核来负责,<b><font color="#0000ff">让内核作为应用连接硬件设备的桥梁</font></b>,应用程序只需关心与内核交互,不用关心硬件的细节。
内核有哪些能力呢?<br>
现代操作系统,内核一般会提供 4 个基本能力:<br><br><ul><li>管理进程、线程,决定哪个进程、线程使用 CPU,也就是进程调度的能力;</li><li>管理内存,决定内存的分配和回收,也就是内存管理的能力;</li><li>管理硬件设备,为进程与硬件设备之间提供通信能力,也就是硬件通信能力;</li><li>提供系统调用,如果应用程序要运行更高权限运行的服务,那么就需要有系统<br>调用,它是用户程序与操作系统之间的接口。</li></ul>
内核是怎么工作的?
内核具有很高的权限,可以控制 cpu、内存、硬盘等硬件,而应用程序<br>具有的权限很小,因此大多数操作系统,把内存分成了两个区域:<br><br><ul><li>内核空间,这个内存空间只有内核程序可以访问;</li><li>用户空间,这个内存空间专门给应用程序使用;</li></ul>
用户空间的代码只能访问一个局部的内存空间,而内核空间的代码可以访问所有内存空间。因此,当<br>程序使用用户空间时,我们常说该程序在 <b><font color="#0000ff">用户态 </font></b>执行,而当程序使内核空间时,程序则在 <b><font color="#0000ff">内核态 </font></b>执行。
应用程序如果需要进入内核空间,就需要通过 <b><font color="#0000ff">系统调用</font></b>,下面来看看系统调用的过程:
内核程序执行在内核态,用户程序执行在用户态。当应用程序使用系统调用时,会产生一个中断。发生中断后, CPU 会中断当前在执行的用户程序,<br>转而跳转到中断处理程序,也就是开始执行内核程序。内核处理完后,主动触发中断,把 CPU 执行权限交回给用户程序,回到用户态继续工作。
Linux 的设计
介绍
Linux 的开山始祖是来自一位名叫 Linus Torvalds 的芬兰小伙子,他在 1991 年用 C 语言写出了第一版的 Linux 操作系统,那年他 22 岁。
完成第一版 Linux 后,Linus Torvalds 就在网络上发布了 Linux 内核的源代码,每个人都可以免费下载和使用。
Linux 内核设计的理念主要有这几个点:<br><br><ul><li><i><font color="#ff00ff">MultiTask</font></i>,多任务</li><li><font color="#ff00ff"><i>SMP</i></font>,对称多处理</li><li><i><font color="#ff00ff">ELF</font></i>,可执行文件链接格式</li><li><i><font color="#ff00ff">Monolithic Kernel</font></i>,宏内核</li></ul>
MultiTask
MultiTask 的意思是 <b><font color="#0000ff">多任务</font></b>,代表着 Linux 是一个多任务的操作系统。<br>
多任务意味着可以有多个任务 <b><font color="#0000ff">同时 </font></b>执行,这里的「同时」可以是 <b><font color="#0000ff">并发或并行</font></b>:<br><br><ul><li>对于单核 CPU 时,可以让每个任务执行一小段时间,时间到就切换另外一个任务,<br>从宏观角度看,一段时间内执行了多个任务,这被称为并发。</li><li>对于多核 CPU 时,多个任务可以同时被不同核心的 CPU 同时执行,这被称为并行。</li></ul>
SMP
SMP 的意思是 <b><font color="#0000ff">对称多处理</font></b>,代表着每个 CPU 的地位是相等的,对资源的使用权限也是相同的,<br>多个 CPU 共享同一个内存,每个 CPU 都可以访问完整的内存和硬件资源。
这个特点决定了 Linux 操作系统不会有某个 CPU 单独服务应用程序或内核程序,<br>而是每个程序都可以被分配到任意一个 CPU 上被执行。
ELF
ELF 的意思是 <font color="#0000ff"><b>可执行文件链接格式</b></font>,它是 Linux 操作系统中可执行文件的存储格式,你可以从下图看到它的结构:
ELF 把文件分成了一个个分段,每一个段都有自己的作用,具体每个段的作用这里我就不详细说明了,<br>感兴趣的同学可以去看《程序员的自我修养——链接、装载和库》这本书。
另外,ELF 文件有两种索引,Program header table 中记录了「运行时」所需的段,<br>而 Section header table 记录了二进制文件中各个「段的首地址」。
那 ELF 文件怎么生成的呢?
我们编写的代码,首先通过「编译器」编译成汇编代码,接着通过「汇编器」变成目标代码,也就是目标文件,<br>最后通过「链接器」把多个目标文件以及调用的各种函数库链接起来,形成一个可执行文件,也就是 ELF 文件。
那 ELF 文件是怎么被执行的呢?
执行 ELF 文件的时候,会通过「装载器」把 ELF 文件装载到内存里,CPU 读取内存中的指令和数据,于是程序就被执行起来了。
Monolithic Kernel
Monolithic Kernel 的意思是 <b><font color="#0000ff">宏内核</font></b>,Linux 内核架构就是宏内核,意味着 Linux 的内核是一个完整的可执行程序,且拥有最高的权限。
宏内核的特征是系统内核的所有模块,比如进程调度、内存管理、文件系统、设备驱动等,都运行在内核态。
不过,Linux 也实现了动态加载内核模块的功能,例如大部分设备驱动是以可加载模块的形式存在的,<br>与内核其他模块解藕,让驱动开发和驱动加载更为方便、灵活。
与宏内核相反的是 <b><font color="#0000ff">微内核</font></b>,微内核架构的内核只保留最基本的能力,比如进程调度、虚拟机内存、中断等,把一些应用放到了用户空间,比如驱动程序、<br>文件系统等。这样服务与服务之间是隔离的,单个服务出现故障或者完全攻击,也不会导致整个操作系统挂掉,提高了操作系统的稳定性和可靠性。
微内核内核功能少,可移植性高,相比宏内核有一点不好的地方在于,由于驱动程序不在内核中,而且驱动程序一般会频繁调用底层<br>能力的,于是驱动和硬件设备交互就需要频繁切换到内核态,这样会带来性能损耗。华为的鸿蒙操作系统的内核架构就是微内核。
还有一种内核叫 <b><font color="#0000ff">混合类型内核</font></b>,它的架构有点像微内核,内核里面会有一个最小版本的内核,然后其他模块会在这个基础上搭建,然后<br>实现的时候会跟宏内核类似,也就是把整个内核做成一个完整的程序,大部分服务都在内核中,这就像是宏内核的方式包裹着一个微内核。
Windows 设计
当今 Windows 7、Windows 10 使用的内核叫 Windows NT,NT 全称叫 New Technology。
下图是 Windows NT 的结构图片:
Windows 和 Linux 一样,同样支持 MultiTask 和 SMP,但不同的是,<b><font color="#0000ff">Window 的内核设计是混合型内核</font></b>,在上图你可以<br>看到内核中有一个 MicroKernel 模块,这个就是最小版本的内核,而整个内核实现是一个完整的程序,含有非常多模块。
Windows 的可执行文件的格式与 Linux 也不同,所以这两个系统的可执行文件是不可以在对方上运行的。
Windows 的可执行文件格式叫 PE,称为 <b><font color="#0000ff">可移植执行文件</font></b>,扩展名通常是.exe、.dll、.sys等。
PE 的结构你可以从下图中看到,它与 ELF 结构有一点相似。
总结
对于内核的架构一般有这三种类型:<br><br><ul><li>宏内核,包含多个模块,整个内核像一个完整的程序;</li><li>微内核,有一个最小版本的内核,一些模块和服务则由用户态管理;</li><li>混合内核,是宏内核和微内核的结合体,内核中抽象出了微内核的概念,也就是内核<br>中会有一个小型的内核,其他模块就在这个基础上搭建,整个内核是个完整的程序;</li></ul>
Linux 的内核设计是采用了宏内核,Window 的内核设计则是采用了混合内核。
这两个操作系统的可执行文件格式也不一样, Linux 可执行文件格式叫作 ELF,Windows 可执行文件格式叫作 PE。
内存管理
为什么要有虚拟内存?
虚拟内存
如果你是电子相关专业的,肯定在大学里捣鼓过单片机。
单片机是没有操作系统的,所以每次写完代码,都需要借助工具把程序烧录进去,这样程序才能跑起来。
另外,单片机的 CPU 是直接操作内存的「物理地址」。
在这种情况下,要想在内存中同时运行两个程序是不可能的。如果第一个程序在 2000 的位置写入一个新的值,将<br>会擦掉第二个程序存放在相同位置上的所有内容,所以同时运行两个程序是根本行不通的,这两个程序会立刻崩溃。
操作系统是如何解决这个问题呢?<br>
这里关键的问题是这两个程序都引用了绝对物理地址,而这正是我们最需要避免的。
我们可以把进程所使用的地址「隔离」开来,即让操作系统为每个进程分配独立的一套「虚拟地址」,人人都有,大家自己玩自己的地址就行,互不干涉。<br>但是有个前提每个进程都不能访问物理地址,至于虚拟地址最终怎么落到物理内存里,对进程来说是透明的,操作系统已经把这些都安排的明明白白了。
操作系统会提供一种机制,<b><font color="#0000ff">将不同进程的虚拟地址和不同内存的物理地址映射起来</font></b>。
如果程序要访问虚拟地址的时候,由操作系统转换成不同的物理地址,这样不同的进程运行的时候,写入的是不同的物理地址,这样就不会冲突了。
于是,这里就引出了两种地址的概念:<br><br><ul><li>我们程序所使用的内存地址叫做 <b><font color="#0000ff">虚拟内存地址(Virtual Memory Address)</font></b></li><li>实际存在硬件里面的空间地址叫 <b><font color="#0000ff">物理内存地址(Physical Memory Address)</font></b></li></ul>
操作系统引入了虚拟内存,进程持有的虚拟地址会通过 CPU 芯片中的 <b><font color="#0000ff">内存管理单元(MMU)</font></b>的映射关系,<br>来转换变成物理地址,然后再通过物理地址访问内存,如下图所示:
操作系统是如何管理虚拟地址与物理地址之间的关系?
主要有两种方式,分别是 <b><font color="#0000ff">内存分段和内存分页</font></b>,分段是比较早提出的,我们先来看看内存分段。
内存分段
程序是由若干个逻辑分段组成的,如可由代码分段、数据分段、栈段、堆段组成。<b><font color="#0000ff">不同<br>的段是有不同的属性的,所以就用分段(Segmentation)的形式把这些段分离出来。</font></b>
分段机制下,虚拟地址和物理地址是如何映射的?
分段机制下的虚拟地址由两部分组成,<b><font color="#0000ff">段选择因子和段内偏移量</font></b>。
段选择因子和段内偏移量:<br><br><ul><li><b><font color="#0000ff">段选择因子 </font></b>就保存在段寄存器里面。段选择子里面最重要的是 <b><font color="#0000ff">段号</font></b>,用作段表的索引。<br><b><font color="#0000ff">段表 </font></b>里面保存的是这个 <b><font color="#0000ff">段的基地址、段的界限和特权等级 </font></b>等。</li></ul><br><ul><li>虚拟地址中的 <b><font color="#0000ff">段内偏移量 </font></b>应该位于 0 和段界限之间,如果段内偏移量是合法的,<br>就将段基地址加上段内偏移量得到物理内存地址。</li></ul>
在上面,知道了虚拟地址是通过 <b><font color="#0000ff">段表 </font></b>与物理地址进行映射的,分段机制会把程序的虚拟地址分成 4 个段,每个<br>段在段表中有一个项,在这一项找到段的基地址,再加上偏移量,于是就能找到物理内存中的地址,如下图:
如果要访问段 3 中偏移量 500 的虚拟地址,我们可以计算出物理地址为:段 3 基地址 7000 + 偏移量 500 = 7500。
分段的办法很好,解决了程序本身不需要关心具体的物理内存地址的问题,但它也有一些 <b><font color="#0000ff">不足 </font></b>之处:<br><br><ul><li>第一个就是 <b><font color="#0000ff">内存碎片</font></b> 的问题。</li><li>第二个就是 <b><font color="#0000ff">内存交换的效率低 </font></b>的问题。</li></ul>
接下来,说说为什么会有这两个问题。<br>
我们先来看看,分段为什么会产生内存碎片的问题?<br>
我们来看看这样一个例子。假设有 1G 的物理内存,用户执行了多个程序,其中:<br><br><ul><li>游戏占用了 512MB 内存</li><li>浏览器占用了 128MB 内存</li><li>音乐占用了 256 MB 内存。</li></ul>
这个时候,如果我们关闭了浏览器,则空闲内存还有 1024 - 512 - 256 = 256MB。
如果这个 256MB 不是连续的,被分成了两段 128 MB 内存,这就会导致没有空间再打开一个 200MB 的程序。
内存分段会出现内存碎片吗?<br>
内存碎片主要分为,<b><font color="#0000ff">内部内存碎片和外部内存碎片</font></b>。
内存分段管理可以做到 <b><font color="#0000ff">段根据实际需求分配内存</font></b>,所以有多少需求就分配多大的段,所以 <b><font color="#0000ff">不会出现内部内存碎片</font></b>。
但是由于每个段的长度不固定,所以 <b><font color="#0000ff">多个段未必能恰好使用所有的内存空间</font></b>,会产生了<br>多个不连续的小物理内存,导致新的程序无法被装载,所以 <b><font color="#0000ff">会出现外部内存碎片 </font></b>的问题。
解决「外部内存碎片」的问题就是 <b><font color="#0000ff">内存交换</font></b>。
可以把音乐程序占用的那 256MB 内存写到硬盘上,然后再从硬盘上读回来到内存里。不过再读回的时候,我们不能装载回原来的位置,<br>而是紧紧跟着那已经被占用了的 512MB 内存后面。这样就能空缺出连续的 256MB 空间,于是新的 200MB 程序就可以装载进来。
这个内存交换空间,在 Linux 系统里,也就是我们常看到的 <b><font color="#0000ff">Swap 空间</font></b>,这块空间是从硬盘划分出来的,用于 <b><font color="#0000ff">内存与硬盘的空间交换</font></b>。
再来看看,分段为什么会导致内存交换效率低的问题?<br>
对于多进程的系统来说,用分段的方式,外部内存碎片是很容易产生的,产生了外部内存碎片,那不得不重新 Swap 内存区域,这个过程会产生性能瓶颈。
因为硬盘的访问速度要比内存慢太多了,每一次内存交换,我们都需要把一大段连续的内存数据写到硬盘上。
所以,<b><font color="#0000ff">如果内存交换的时候,交换的是一个占内存空间很大的程序,这样整个机器都会显得卡顿</font></b>。
为了解决内存分段的「外部内存碎片和内存交换效率低」的问题,就出现了内存分页。<br>
内存分页
介绍
分段的好处就是能产生连续的内存空间,但是会出现「外部内存碎片和内存交换的空间太大」的问题。
要解决这些问题,那么就要想出能少出现一些内存碎片的办法。另外,当需要进行内存交换的时候,让需要<br>交换写入或者从磁盘装载的数据更少一点,这样就可以解决问题了。这个办法,也就是 <b><font color="#0000ff">内存分页(Paging)</font></b>。
<b><font color="#0000ff">分页是把整个虚拟和物理内存空间切成一段段固定尺寸的大小</font></b>。这样一个连续并且<br>尺寸固定的内存空间,我们叫页(Page)。在 Linux 下,每一页的大小为 4KB。
虚拟地址与物理地址之间通过 <b><font color="#0000ff">页表</font></b> 来映射,如下图:
页表是存储在内存里的,<b><font color="#0000ff">内存管理单元 (MMU)</font></b>就做 <b><font color="#0000ff">将虚拟内存地址转换成物理地址 </font></b>的工作。
而 <b><font color="#0000ff">当进程访问的虚拟地址在页表中查不到时</font></b>,系统会产生一个 <b><font color="#0000ff">缺页异常</font></b>,进入系统<br>内核空间分配物理内存、更新进程页表,最后再返回用户空间,恢复进程的运行。
分页是怎么解决分段的「外部内存碎片和内存交换效率低」的问题?<br>
内存分页由于内存空间都是预先划分好的,也就不会像内存分段一样,在段与段之间会产生间隙非常小的内存,<br>这正是分段会产生外部内存碎片的原因。而 <b><font color="#0000ff">采用了分页,页与页之间是紧密排列的,所以不会有外部碎片</font></b>。
但是,因为内存分页机制分配内存的最小单位是一页,<b><font color="#0000ff">即使程序不足一页大小,我们最少只能分配一个页</font></b>,<br>所以 <b><font color="#0000ff">页内会出现内存浪费</font></b>,所以针对 <b><font color="#0000ff">内存分页机制会有内部内存碎片的现象</font></b>。
如果内存空间不够,操作系统会把其他正在运行的进程中的「最近没被使用」的内存页面给释放掉,也就是暂时写在硬盘上,<br>称为 <b><font color="#0000ff">换出(Swap Out)</font></b>。一旦需要的时候,再加载进来,称为 <b><font color="#0000ff">换入(Swap In)</font></b>。所以,一次性写入磁盘的也只有少数的<br>一个页或者几个页,不会花太多时间,<b><font color="#0000ff">内存交换的效率就相对比较高</font></b>。
更进一步地,分页的方式使得我们在加载程序的时候,<b><font color="#0000ff">不再需要一次性都把程序加载到物理内存中</font></b>。我们完全可以在进行虚拟内存和物理内存的页之间<br>的映射之后,并不真的把页加载到物理内存里,而是 <b><font color="#0000ff">只有在程序运行中,需要用到对应虚拟内存页里面的指令和数据时,再加载到物理内存里面去</font></b>。
分页机制下,虚拟地址和物理地址是如何映射的?<br>
在分页机制下,虚拟地址分为两部分,<b><font color="#0000ff">页号和页内偏移</font></b>。页号作为页表的索引,<b><font color="#0000ff">页表 </font></b>包含物理页<br>每页所在 <b><font color="#0000ff">物理内存的基地址</font></b>,这个基地址与页内偏移的组合就形成了物理内存地址,见下图:
总结一下,对于一个内存地址转换,其实就是这样三个步骤:<br><br><ol><li>把虚拟内存地址,切分成页号和偏移量;</li><li>根据页号,从页表里面,查询对应的物理页号;</li><li>直接拿物理页号,加上前面的偏移量,就得到了物理内存地址。</li></ol>
下面举个例子,虚拟内存中的页通过页表映射为了物理内存中的页,如下图:
这看起来似乎没什么毛病,但是放到实际中操作系统,这种简单的分页是肯定是会有问题的。
简单的分页有什么缺陷吗?<br>
有空间上的缺陷。
因为操作系统是可以同时运行非常多的进程的,那这不就意味着页表会非常的庞大。
在 32 位的环境下,虚拟地址空间共有 4GB,假设一个页的大小是 4KB(2^12),那么就需要大约 100 万 (2^20) <br>个页,每个「页表项」需要 4 个字节大小来存储,那么整个 4GB 空间的映射就需要有 4MB 的内存来存储页表。
这 4MB 大小的页表,看起来也不是很大。但是要知道 <b><font color="#0000ff">每个进程都是有自己的虚拟地址空间的</font></b>,也就说 <b><font color="#0000ff">都有自己的页表</font></b>。
那么,100 个进程的话,就需要 400MB 的内存来存储页表,这是非常大的内存了,更别说 64 位的环境了。
多级页表
要解决上面的问题,就需要采用一种叫作 <b><font color="#0000ff">多级页表(Multi-Level Page Table)</font></b>的解决方案。
在前面我们知道了,对于单页表的实现方式,在 32 位和页大小 4KB 的环境下,一个进程的页表需要装下 100 <br>多万个「页表项」,并且每个页表项是占用 4 字节大小的,于是相当于每个页表需占用 4MB 大小的空间。
我们把这个 100 多万个「页表项」的单级页表再分页,将页表(一级页表)分为 1024 个页表(二级页表),<br>每个表(二级页表)中包含 1024 个「页表项」,形成二级分页。如下图所示:
你可能会问,分了二级表,映射 4GB 地址空间就需要 4KB(一级页表)+ 4MB(二级页表)的内存,这样占用空间不是更大了吗?
当然如果 4GB 的虚拟地址全部都映射到了物理内存上的话,二级分页占用空间确实是更大了,但是,我们往往不会为一个进程分配那么多内存。
其实我们应该换个角度来看问题,还记得计算机组成原理里面无处不在的 <b><font color="#0000ff">局部性原理 </font></b>么?
每个进程都有 4GB 的虚拟地址空间,而显然对于大多数程序来说,其使用到的空间远未达到 4GB,因为会存在部分对应的页表项都是空的,根本没有分配,对于已分配的页表项,如果存在最近一定时间未访问的页表,在物理内存紧张的情况下,操作系统会将页面换出到硬盘,也就是说不会占用物理内存。
如果使用了二级分页,一级页表就可以覆盖整个 4GB 虚拟地址空间,但 <b><font color="#0000ff">如果某个一级页表的页表项没有被用到,也就不需要创建这个页表项<br>对应的二级页表了,即可以在需要时才创建二级页表</font></b>。做个简单的计算,假设只有 20% 的一级页表项被用到了,那么 <b><font color="#0000ff">页表占用的内存空间 </font></b>就<br>只有 4KB(一级页表) + 20% * 4MB(二级页表)= 0.804MB,这对比单级页表的 4MB 是不是一个巨大的节约?
那么为什么不分级的页表就做不到这样节约内存呢?
我们从页表的性质来看,保存在内存中的页表承担的职责是将虚拟地址翻译成物理地址。假如虚拟地址在页表中找不到对应的页表项,<br>计算机系统就不能工作了。所以 <b><font color="#0000ff">页表一定要覆盖全部虚拟地址空间,不分级的页表就需要有 100 多万个页表项来映射,而二级分页则<br>只需要 1024 个页表项</font></b>(此时一级页表覆盖到了全部虚拟地址空间,二级页表在需要时创建)。
我们把二级分页再推广到多级页表,就会发现页表占用的内存空间更少了,这一切都要归功于对局部性原理的充分应用。
对于 64 位的系统,两级分页肯定不够了,就变成了四级目录,分别是:<br><br><ul><li><b><font color="#0000ff">全局页目录项 PGD</font></b>(Page Global Directory);</li><li><b><font color="#0000ff">上层页目录项 PUD</font></b>(Page Upper Directory);</li><li><b><font color="#0000ff">中间页目录项 PMD</font></b>(Page Middle Directory);</li><li><b><font color="#0000ff">页表项 PTE</font></b>(Page Table Entry);</li></ul>
TLB
多级页表虽然解决了空间上的问题,但是虚拟地址到物理地址的转换就多了几道转换的工序,<br>这显然就降低了这俩地址转换的速度,也就是带来了时间上的开销。
程序是有局部性的,即在一段时间内,整个程序的执行仅限于程序中的某一部分。<br>相应地,执行所访问的存储空间也局限于某个内存区域。
我们就可以利用这一特性,把最常访问的几个页表项存储到访问速度更快的硬件,于是计算机科学家们就在 CPU 芯片中,加入了一个专门存放<br>程序最常访问的页表项的 Cache,这个 Cache 就是 <font color="#ff00ff"><i><b>TLB(Translation Lookaside Buffer)</b></i></font> ,通常称为 <b><font color="#0000ff">页表缓存、转址旁路缓存、快表 </font></b>等。
在 CPU 芯片里面,封装了内存管理单元 MMU(Memory Management Unit)芯片,它用来完成地址转换和 TLB 的访问与交互。
有了 TLB 后,那么 CPU 在寻址时,会先查 TLB,如果没找到,才会继续查常规的页表。
TLB 的命中率其实是很高的,因为程序最常访问的页就那么几个。
段页式内存管理
内存分段和内存分页并不是对立的,它们是可以组合起来在同一个系统中使用的,那么 <b><font color="#0000ff">组合 </font></b>起来后,通常称为 <b><font color="#0000ff">段页式内存管理</font></b>。
段页式内存管理实现的方式:<br><br><ul><li>先将程序划分为多个有逻辑意义的段,也就是前面提到的分段机制;</li><li>接着再把每个段划分为多个页,也就是对分段划分出来的连续空间,再划分固定大小的页;</li></ul>
这样,地址结构就由 <b><font color="#0000ff">段号、段内页号和页内位移 </font></b>三部分组成。
用于段页式地址变换的数据结构是每一个程序一张段表,每个段又建立一张页表,<br>段表中的地址是页表的起始地址,而页表中的地址则为某页的物理页号,如图所示:
段页式地址变换中要得到物理地址须经过三次内存访问:<br><br><ul><li>第一次访问段表,得到页表起始地址;</li><li>第二次访问页表,得到物理页号;</li><li>第三次将物理页号与页内位移组合,得到物理地址。</li></ul>
可用软、硬件相结合的方法实现段页式地址变换,这样虽然增加了硬件成本和系统开销,但提高了内存的利用率。<br>
Linux 内存布局
那么,Linux 操作系统采用了哪种方式来管理内存呢?<br>
在回答这个问题前,我们得先看看 Intel 处理器的发展历史。
早期 Intel 的处理器从 80286 开始使用的是段式内存管理。但是很快发现,光有段式内存管理而没有页式内存管理是不够的,<br>这会使它的 X86 系列会失去市场的竞争力。因此,在不久以后的 80386 中就实现了页式内存管理。也就是说,80386 除了<br>完成并完善从 80286 开始的段式内存管理的同时还实现了页式内存管理。
但是这个 80386 的页式内存管理设计时,没有绕开段式内存管理,而是建立在段式内存管理的基础上,<br>这就意味着,<b><font color="#0000ff">页式内存管理的作用是在由段式内存管理所映射而成的地址上再加上一层地址映射</font></b>。
由于此时由段式内存管理映射而成的地址不再是“物理地址”了,Intel 就称之为“线性地址”(也称虚拟地址)。<br>于是,段式内存管理先将逻辑地址映射成线性地址,然后再由页式内存管理将线性地址映射成物理地址。
这里说明下逻辑地址和线性地址:<br><br><ul><li>程序所使用的地址,通常是没被段式内存管理映射的地址,称为逻辑地址;</li><li>通过段式内存管理映射的地址,称为线性地址,也叫虚拟地址;</li></ul>
逻辑地址是「段式内存管理」转换前的地址,线性地址则是「页式内存管理」转换前的地址。
再来说说 Linux 采用了什么方式管理内存?
<b><font color="#0000ff">Linux 内存主要采用的是页式内存管理,但同时也不可避免地涉及了段机制。</font></b>
这主要是上面 Intel 处理器发展历史导致的,因为 Intel X86 CPU 一律对程序中使用的地址先进行段式映射,<br>然后才能进行页式映射。既然 CPU 的硬件结构是这样,Linux 内核也只好服从 Intel 的选择。
但是事实上,Linux 内核所采取的办法是使段式映射的过程实际上不起什么作用。也就是说“上有政策,下有对策”,若惹不起就躲着走。
<b><font color="#0000ff">Linux 系统中的每个段都是从 0 地址开始的整个 4GB 虚拟空间(32 位环境下),也就是所有的段的起始地址都是一样的。<br>这意味着,Linux 系统中的代码,包括操作系统本身的代码和应用程序代码,所面对的地址空间都是线性地址空间(虚拟地址),<br>这种做法相当于屏蔽了处理器中的逻辑地址概念,段只被用于访问控制和内存保护。</font></b>
Linux 的虚拟地址空间是如何分布的?
在 Linux 操作系统中,虚拟地址空间的内部又被分为 <b><font color="#0000ff">内核空间和用户空间 </font></b>两部分,不同<br>位数的系统,地址空间的范围也不同。比如最常见的 32 位和 64 位系统,如下所示:
通过这里可以看出:<br><br><ul><li>32 位系统的内核空间占用 1G,位于最高处,剩下的 3G 是用户空间;</li><li>64 位系统的内核空间和用户空间都是 128T,分别占据整个内存空间的最高和最低处,剩下的中间部分是未定义的。</li></ul>
再来说说,内核空间与用户空间的区别:<br><br><ul><li>进程在用户态时,只能访问用户空间内存;</li><li>只有进入内核态后,才可以访问内核空间的内存;</li></ul>
虽然每个进程都各自有独立的虚拟内存,但是 <b><font color="#0000ff">每个虚拟内存中的内核地址,其实关联的<br>都是相同的物理内存</font></b>。这样,进程切换到内核态后,就可以很方便地访问内核空间内存。
接下来,进一步了解虚拟空间的划分情况,用户空间和内核空间划分的方式是不同的,内核空间的分布情况就不多说了。<br>
我们看看用户空间分布的情况,以 32 位系统为例,我画了一张图来表示它们的关系:
<span style="font-size: inherit;">通过这张图你可以看到,用户空间内存,从低到高分别是 6 种不同的内存段:</span><br><br><ul><li><span style="font-size: inherit;">代码段,包括二进制可执行代码;</span></li><li><span style="font-size: inherit;">数据段,包括已初始化的静态常量和全局变量;</span></li><li><span style="font-size: inherit;">BSS 段,包括未初始化的静态变量和全局变量;</span></li><li><span style="font-size: inherit;">堆段,包括动态分配的内存,从低地址开始向上增长;</span></li><li><span style="font-size: inherit;">文件映射段,包括动态库、共享内存等,从低地址开始向上增长(跟硬件和内核版本有关);</span></li><li><span style="font-size: inherit;">栈段,包括局部变量和函数调用的上下文等。栈的大小是固定的,一般是 8 MB。当然系统也提供了参数,以便我们自定义大小;</span></li></ul>
上图中的内存布局可以看到,代码段下面还有一段内存空间的(灰色部分),这一块区域是「保留区」,之所以要有保留区是因为在<br>大多数的系统里,我们认为比较小数值的地址不是一个合法地址,例如,我们通常在 C 的代码里会将无效的指针赋值为 NULL。因此,<br>这里会出现一段不可访问的内存保留区,防止程序因此出现 bug,导致读或写了一些小内存地址的数据,而使得程序跑飞。
在这 7 个内存段中,堆和文件映射段的内存是动态分配的。比如使用 C 标准库的 malloc() 或者 mmap() ,就可以分别在堆和文件映射段动态分配内存。
总结
为了在多进程环境下,使得进程之间的内存地址不受影响,相互隔离,于是操作系统就为每个进程独立分配一套 <b><font color="#0000ff">虚拟地址空间</font></b>,每个程序只<br>关心自己的虚拟地址就可以,实际上大家的虚拟地址都是一样的,但分布到物理地址内存是不一样的。作为程序,也不用关心物理地址的事情。
每个进程都有自己的虚拟空间,而物理内存只有一个,所以当启用了大量的进程,物理内存必然会很紧张,于是操作<br>系统会通过 <b><font color="#0000ff">内存交换技术</font></b>,把不常使用的内存暂时存放到硬盘(换出),在需要的时候再装载回物理内存(换入)。
那既然有了虚拟地址空间,那必然要把虚拟地址「映射」到物理地址,这个事情通常由操作系统来维护。
那么对于虚拟地址与物理地址的映射关系,可以有 <b><font color="#0000ff">分段和分页 </font></b>的方式,同时两者结合都是可以的。
内存分段是根据程序的逻辑角度,分成了栈段、堆段、数据段、代码段等,这样可以分离出不同属性的段,<br>同时是一块连续的空间。但是每个段的大小都不是统一的,这就会导致外部内存碎片和内存交换效率低的问题。
于是,就出现了内存分页,把虚拟空间和物理空间分成大小固定的页,如在 Linux 系统中,每一页的大小为 4KB。由于分了页后,就不会产生<br>细小的内存碎片,解决了内存分段的外部内存碎片问题。同时在内存交换的时候,写入硬盘也就一个页或几个页,这就大大提高了内存交换的效率。
再来,为了解决简单分页产生的页表过大的问题,就有了 <b><font color="#0000ff">多级页表</font></b>,它解决了空间上的问题,但这就会导致 CPU 在寻址的过程中,需要有很多层表<br>参与,加大了时间上的开销。于是根据程序的 <b><font color="#0000ff">局部性原理</font></b>,在 CPU 芯片中加入了 TLB,负责缓存最近常被访问的页表项,大大提高了地址的转换速度。
<b><font color="#0000ff">Linux 系统主要采用了分页管理,但是由于 Intel 处理器的发展史,Linux 系统无法避免分段管理。</font></b>于是 Linux 就把所有段的基地址设为 0,<br>也就意味着所有程序的地址空间都是线性地址空间(虚拟地址),相当于屏蔽了 CPU 逻辑地址的概念,所以段只被用于访问控制和内存保护。
另外,Linux 系统中虚拟空间分布可分为 <b><font color="#0000ff">用户态和内核态</font> </b>两部分,其中用户态的分布:代码段、全局变量、BSS、函数栈、堆内存、映射区。
<font color="#1b5e20"><b>虚拟内存有什么作用?</b></font><br><br><ul><li>第一,虚拟内存可以使得 <b><font color="#0000ff">进程对运行内存超过物理内存大小</font></b>,因为程序运行符合局部性原理,CPU 访问内存会有很明显<br>的重复访问的倾向性,对于那些没有被经常使用到的内存,我们可以把它换出到物理内存之外,比如硬盘上的 swap 区域。</li><li>第二,由于每个进程都有自己的页表,所以每个进程的虚拟内存空间就是相互独立的。进程也没有办法访问其他进程的<br>页表,所以这些页表是私有的,这就<font color="#0000ff"> <b>解决了多进程之间地址冲突的问题</b></font>。</li><li>第三,页表里的页表项中除了物理地址之外,还有一些标记属性的比特,比如控制一个页的读写权限,标记该页是否<br>存在等。<b><font color="#0000ff">在内存访问方面,操作系统提供了更好的安全性</font></b>。</li></ul>
malloc 是如何分配内存的?
Linux 进程的内存分布长什么样?
在 Linux 操作系统中,虚拟地址空间的内部又被分为 <b><font color="#0000ff">内核空间和用户空间两部分</font></b>,<br>不同位数的系统,地址空间的范围也不同。比如最常见的 32 位和 64 位系统,如下所示:
通过这里可以看出:<br><br><ul><li>32 位系统的内核空间占用 1G,位于最高处,剩下的 3G 是用户空间;</li><li>64 位系统的内核空间和用户空间都是 128T,分别占据整个内存空间的最高和最低处,剩下的中间部分是未定义的。</li></ul>
再来说说,内核空间与用户空间的区别:<br><br><ul><li>进程在用户态时,只能访问用户空间内存;</li><li>只有进入内核态后,才可以访问内核空间的内存;</li></ul>
虽然每个进程都各自有独立的虚拟内存,但是 <b><font color="#0000ff">每个虚拟内存中的内核地址,其实关联的<br>都是相同的物理内存。</font></b>这样,进程切换到内核态后,就可以很方便地访问内核空间内存。
接下来,进一步了解虚拟空间的划分情况,用户空间和内核空间划分的方式是不同的,内核空间的分布情况就不多说了。
我们看看用户空间分布的情况,以 32 位系统为例,我画了一张图来表示它们的关系:<br><br>通过这张图你可以看到,用户空间内存从 <b><font color="#0000ff">低到高</font></b> 分别是 6 种不同的内存段:<br><br><ul><li>代码段,包括二进制可执行代码;</li><li>数据段,包括已初始化的静态常量和全局变量;</li><li>BSS 段,包括未初始化的静态变量和全局变量;</li><li>堆段,包括动态分配的内存,从低地址开始向上增长;</li><li>文件映射段,包括动态库、共享内存等,从低地址开始向上增长(跟硬件和内核版本有关);</li><li>栈段,包括局部变量和函数调用的上下文等。栈的大小是固定的,一般是 8 MB。当然系统也提供了参数,以便我们自定义大小;</li></ul>
在这 6 个内存段中,堆和文件映射段的内存是动态分配的。比如说,使用 C 标准库的 malloc() 或者 mmap() ,就可以分别在堆和文件映射段动态分配内存。
malloc 是如何分配内存的?
实际上,malloc() 并不是系统调用,而是 C 库里的函数,用于动态分配内存。
malloc 申请内存的时候,会有两种方式向操作系统申请堆内存:<br><br><ul><li>方式一:通过 brk() 系统调用从堆分配内存</li><li>方式二:通过 mmap() 系统调用在文件映射区域分配内存;</li></ul>
方式一实现的方式很简单,就是通过 brk() 函数将「堆顶」指针向高地址移动,获得新的内存空间。如下图:
方式二通过 mmap() 系统调用中「私有匿名映射」的方式,在文件映射区分配一块内存,也就是从文件映射区“偷”了一块内存。如下图:
什么场景下 malloc() 会通过 brk() 分配内存?又是什么场景下通过 mmap() 分配内存?
malloc() 源码里默认定义了一个阈值:<br><br><ul><li>如果用户分配的内存小于 128 KB,则通过 brk() 申请内存;</li><li>如果用户分配的内存大于 128 KB,则通过 mmap() 申请内存;</li></ul>
注意,不同的 glibc 版本定义的阈值也是不同的。<br>
molloc() 分配的是物理内存吗?
不是的,<b><font color="#0000ff">malloc() 分配的是虚拟内存</font></b>。
如果分配后的虚拟内存没有被访问的话,虚拟内存是不会映射到物理内存的,这样就不会占用物理内存了。<br>
只有在访问已分配的虚拟地址空间的时候,操作系统通过查找页表,发现虚拟内存对应的页没有<br>在物理内存中,就会触发缺页中断,然后操作系统会建立虚拟内存和物理内存之间的映射关系。<br>
malloc(1) 会分配多大的虚拟内存?
malloc() 在分配内存的时候,并不是老老实实按用户预期申请的字节数来分配内存空间大小,而是会 <b><font color="#0000ff">预分配更大的空间作为内存池</font></b>。
具体会预分配多大的空间,跟 malloc 使用的内存管理器有关系,我们就以 malloc 默认的内存管理器(Ptmalloc2)来分析。
接下里,我们做个实验,用下面这个代码,通过 malloc 申请 1 字节的内存时,看看操作系统实际分配了多大的内存空间。
执行代码(先提前说明,我使用的 glibc 库的版本是 2.17):
我们可以通过 /proc//maps 文件查看进程的内存分布情况。我在 maps 文件通过此 1 字节的内存起始地址过滤出了内存地址的范围。
这个例子分配的内存小于 128 KB,所以是通过 brk() 系统调用向堆空间申请的内存,因此可以看到最右边有 [heap] 的标识。
可以看到,堆空间的内存地址范围是 00d73000-00d94000,这个范围大小是 132KB,也就说明了 <b><font color="#0000ff">malloc(1) 实际上预分配 132K 字节的内存</font></b>。<br>
可能有的同学注意到了,程序里打印的内存起始地址是 d73010,而 maps 文件显示堆内存空间的<br>起始地址是 d73000,为什么会多出来 0x10 (16字节)呢?这个问题,我们先放着,后面会说。<br>
free 释放内存,会归还给操作系统吗?
我们在上面的进程往下执行,看看通过 free() 函数释放内存后,堆内存还在吗?
子主题
从下图可以看到,通过 free 释放内存后,堆内存还是存在的,并没有归还给操作系统。
这是因为与其把这 1 字节释放给操作系统,不如 <b><font color="#0000ff">先缓存着放进 malloc 的内存池里,当进程再次申请 1 字节<br>的内存时就可以直接复用</font></b>,这样速度快了很多。当然,当进程退出后,操作系统就会回收进程的所有资源。<br>
上面说的 <b><font color="#0000ff">free 内存后堆内存还存在</font></b>,是针对 <b><font color="#0000ff">malloc 通过 brk() 方式申请的内存的情况</font></b>。
如果 <b><font color="#0000ff">malloc 通过 mmap 方式申请的内存,free 释放内存后就会归归还给操作系统</font></b>。
我们做个实验验证下, 通过 malloc 申请 128 KB 字节的内存,来使得 malloc 通过 mmap 方式来分配内存。
执行代码:<br>
查看进程的内存的分布情况,可以发现最右边没有 [head] 标志,说明是通过 mmap 以匿名映射的方式从文件映射区分配的匿名内存。
然后我们释放掉这个内存看看:<br>
再次查看该 128 KB 内存的起始地址,可以发现已经不存在了,说明归还给了操作系统。<br>
对于 「malloc 申请的内存,free 释放内存会归还给操作系统吗?」这个问题,我们可以做个总结了:<br><br><ul><li>malloc 通过 <b><font color="#0000ff">brk()</font></b> 方式申请的内存,free 释放内存的时候,<b><font color="#0000ff">并不会把内存归还给操作系统,而是缓存在 malloc 的内存池中,待下次使用</font></b>;</li><li>malloc 通过 <b><font color="#0000ff">mmap()</font></b> 方式申请的内存,free 释放内存的时候,<b><font color="#0000ff">会把内存归还给操作系统,内存得到真正的释放</font></b>。</li></ul>
为什么不全部使用 mmap 来分配内存?
因为向操作系统申请内存,是要通过系统调用的,执行系统调用是要进入内核态的,然后在回到用户态,运行态的切换会耗费不少时间。
所以,申请内存的操作应该避免频繁的系统调用,如果都用 mmap 来分配内存,等于每次都要执行系统调用。
另外,因为 mmap 分配的内存每次释放的时候,都会归还给操作系统,于是每次 mmap <br>分配的虚拟地址都是缺页状态的,然后在第一次访问该虚拟地址的时候,就会触发缺页中断。
也就是说,<b><font color="#0000ff">频繁通过 mmap 分配的内存话,不仅每次都会发生运行态的切换,<br>还会发生缺页中断(在第一次访问虚拟地址后),这样会导致 CPU 消耗较大。</font></b>
为了改进这两个问题,malloc 通过 brk() 系统调用在堆空间申请内存的时候,由于堆空间是<br>连续的,所以直接 <b><font color="#0000ff">预分配更大的内存来作为内存池</font></b>,当内存释放的时候,就缓存在内存池中。
<b><font color="#0000ff">等下次在申请内存的时候,就直接从内存池取出对应的内存块就行了,而且可能这个内存块的虚拟地址与物理地址<br>的映射关系还存在,这样不仅减少了系统调用的次数,也减少了缺页中断的次数,这将大大降低 CPU 的消耗。</font></b>
既然 brk 那么牛逼,为什么不全部使用 brk 来分配?
前面我们提到通过 brk 从堆空间分配的内存,并不会归还给操作系统,那么我们那考虑这样一个场景。
如果我们连续申请了 10k,20k,30k 这三片内存,如果 10k 和 20k 这两片释放了,变为<br>了空闲内存空间,如果下次申请的内存小于 30k,那么就可以重用这个空闲内存空间。
但是如果下次申请的内存大于 30k,没有可用的空闲内存空间,必须向 OS 申请,实际使用内存继续增大。
因此,随着系统频繁地 malloc 和 free ,尤其对于小块内存,堆内将产生越来越多不可用的碎片,<br>导致“内存泄露”。而这种“泄露”现象使用 valgrind 是无法检测出来的。
所以,malloc 实现中,充分考虑了 brk 和 mmap 行为上的差异及优缺点,默认分配大块内存 (128KB) 才使用 mmap 分配内存空间。
free() 函数只传入一个内存地址,为什么能知道要释放多大的内存?
还记得,我前面提到, malloc 返回给用户态的内存起始地址比进程的堆空间起始地址多了 16 字节吗?<br>
这个多出来的 16 字节就是保存了 <b><font color="#0000ff">该内存块的描述信息</font></b>,比如有该内存块的大小。<br>
这样当执行 free() 函数时,<b><font color="#0000ff">free 会对传入进来的内存地址向左偏移 16 字节,然后<br>从这个 16 字节的分析出当前的内存块的大小,自然就知道要释放多大的内存了</font></b>。
内存满了,会发生什么?
内存分配的过程是怎样的?
应用程序通过 malloc 函数申请内存的时候,实际上申请的是虚拟内存,此时并不会分配物理内存。
当应用程序读写了这块虚拟内存,CPU 就会去访问这个虚拟内存, 这时会发现这个虚拟内存没有映射到物理内存, CPU <br>就会产生 <b><font color="#0000ff">缺页中断</font></b>,进程会从用户态切换到内核态,并将缺页中断交给内核的 Page Fault Handler (缺页中断函数)处理。
缺页中断处理函数会看是否有空闲的物理内存,如果有,就直接分配物理内存,并建立虚拟内存与物理内存之间的映射关系。
如果没有空闲的物理内存,那么内核就会开始进行 <b><font color="#0000ff">回收内存</font></b> 的工作,回收的方式主要是两种:直接内存回收和后台内存回收。<br><br><ul><li><b><font color="#0000ff">后台内存回收(kswapd)</font></b>:在物理内存紧张的时候,会唤醒 kswapd 内核线程来回收内存,<br>这个回收内存的过程 <b><font color="#0000ff">异步 </font></b>的,不会阻塞进程的执行。</li><li><b><font color="#0000ff">直接内存回收(direct reclaim)</font></b>:如果后台异步回收跟不上进程内存申请的速度,就会开始<br>直接回收,这个回收内存的过程是 <b><font color="#0000ff">同步</font></b> 的,会阻塞进程的执行。</li></ul>
如果直接内存回收后,空闲的物理内存仍然无法满足此次物理内存的申请,那么内核就会放最后的大招了 ——<b><font color="#0000ff">触发 OOM (Out of Memory)机制</font></b>。
OOM Killer 机制会根据算法选择一个占用物理内存较高的进程,然后将其杀死,以便释放内存资源,<br>如果物理内存依然不足,OOM Killer 会继续杀死占用物理内存较高的进程,直到释放足够的内存位置。
申请物理内存的过程如下图:<br>
哪些内存可以被回收?
系统内存紧张的时候,就会进行回收内存的工作,那具体哪些内存是可以被回收的呢?
主要有两类内存可以被回收,而且它们的回收方式也不同。<br><br><ul><li><b><font color="#0000ff">文件页(File-backed Page)</font></b>:<b><font color="#0000ff">内核缓存的磁盘数据(Buffer)和内核缓存的文件数据(Cache)都叫作文件页</font></b>。大部分文件页,都可以<br>直接释放内存,以后有需要时,再从磁盘重新读取就可以了。而那些被应用程序修改过,并且暂时还没写入磁盘的数据(也就是脏页),<br>就得先写入磁盘,然后才能进行内存释放。所以,<b><font color="#0000ff">回收干净页的方式是直接释放内存,回收脏页的方式是先写回磁盘后再释放内存</font></b>。</li><li><b><font color="#0000ff">匿名页(Anonymous Page)</font></b>:<b><font color="#0000ff">这部分内存没有实际载体</font></b>,不像文件缓存有硬盘文件这样一个载体,比如堆、栈数据等。这部分内存<br>很可能还要再次被访问,所以 <b><font color="#0000ff">不能直接释放内存</font></b>,它们回收的方式是 <b><font color="#0000ff">通过 Linux 的 Swap 机制</font></b>,Swap 会把不常访问的内存先写到磁盘<br>中,然后释放这些内存,给其他更需要的进程使用。再次访问这些内存时,重新从磁盘读入内存就可以了。</li></ul>
文件页和匿名页的回收都是基于 <b><font color="#0000ff">LRU 算法</font></b>,也就是优先回收不常访问的内存。LRU 回收算法,实际上维护着 <b><font color="#0000ff">active 和 inactive 两个双向链表</font></b>:<br><br><ul><li>active_list 活跃内存页链表,这里存放的是最近被访问过(活跃)的内存页;</li><li>inactive_list 不活跃内存页链表,这里存放的是很少被访问(非活跃)的内存页;</li></ul>
越接近链表尾部,就表示内存页越不常访问。这样,在回收内存时,系统就可以根据活跃程度,优先回收不活跃的内存。
活跃和非活跃的内存页,按照类型的不同,又分别分为文件页和匿名页。可以从 /proc/meminfo 中,查询它们的大小,比如:
回收内存带来的性能影响
介绍
在前面我们知道了回收内存有两种方式:<br><br><ul><li>一种是后台内存回收,也就是唤醒 kswapd 内核线程,这种方式是异步回收的,不会阻塞进程。</li><li>一种是直接内存回收,这种方式是同步回收的,会阻塞进程,这样就会造成很长时间的延迟,<br>以及系统的 CPU 利用率会升高,最终引起系统负荷飙高。</li></ul>
可被回收的内存类型有文件页和匿名页:<br><br><ul><li>文件页的回收:对于干净页是直接释放内存,这个操作不会影响性能,而对于脏页会先<br>写回到磁盘再释放内存,这个操作会发生磁盘 I/O 的,这个操作是会影响系统性能的。</li><li>匿名页的回收:如果开启了 Swap 机制,那么 Swap 机制会将不常访问的匿名页换出<br>到磁盘中,下次访问时,再从磁盘换入到内存中,这个操作是会影响系统性能的。</li></ul>
可以看到,回收内存的操作基本都会发生磁盘 I/O 的,如果回收内存的操作很频繁,意味<br>着磁盘 I/O 次数会很多,这个过程势必会影响系统的性能,整个系统给人的感觉就是很卡。
下面针对回收内存导致的性能影响,说说常见的解决方式。<br>
调整文件和匿名页的回收倾向
从文件页和匿名页的回收操作来看,文件页的回收操作对系统的影响相比匿名页的回收操作会少一点,因为<br>文件页对于干净页回收是不会发生磁盘 I/O 的,而匿名页的 Swap 换入换出这两个操作都会发生磁盘 I/O。
Linux 提供了一个 /proc/sys/vm/swappiness 选项,用来调整文件页和匿名页的回收倾向。
swappiness 的范围是 0-100:<br><ul><li>数值越大,越积极使用 Swap,也就是更倾向于回收匿名页;</li><li>数值越小,越消极使用 Swap,也就是更倾向于回收文件页。</li></ul>
一般建议 swappiness 设置为 0(默认值是 60),这样在回收内存的时候,会更倾向于文件页的回收,但是并不代表不会回收匿名页。<br>
尽早触发 kswapd 内核线程异步回收内存
如何查看系统的直接内存回收和后台内存回收的指标?
我们可以使用 sar -B 1 命令来观察:
图中红色框住的就是后台内存回收和直接内存回收的指标,它们分别表示:<br><br><ul><li>pgscank/s : kswapd(后台回收线程) 每秒扫描的 page 个数。</li><li>pgscand/s: 应用程序在内存申请过程中每秒直接扫描的 page 个数。</li><li>pgsteal/s: 扫描的 page 中每秒被回收的个数(pgscank+pgscand)。</li></ul>
如果系统时不时发生抖动,并且在抖动的时间段里如果通过 sar -B 观察到 pgscand 数值很大,那大概率是因为「直接内存回收」导致的。
针对这个问题,解决的办法就是,可以通过尽早的触发「后台内存回收」来避免应用程序进行直接内存回收。
什么条件下才能触发 kswapd 内核线程回收内存呢?
内核定义了三个内存阈值(watermark,也称为水位),用来衡量当前剩余内存(pages_free)是否充裕或者紧张,分别是:<br><br><ul><li>页最小阈值(pages_min);</li><li>页低阈值(pages_low);</li><li>页高阈值(pages_high);</li></ul>
这三个内存阈值会划分为四种内存使用情况,如下图:<br>
kswapd 会定期扫描内存的使用情况,根据剩余内存(pages_free)的情况来进行内存回收的工作。<br><br><ul><li>图中绿色部分:如果剩余内存(pages_free)大于 页高阈值(pages_high),说明剩余内存是充足的;</li></ul><br><ul><li>图中蓝色部分:如果剩余内存(pages_free)在页高阈值(pages_high)和页低阈值(pages_low)之间,<br>说明内存有一定压力,但还可以满足应用程序申请内存的请求;</li></ul><br><ul><li>图中橙色部分:如果剩余内存(pages_free)在页低阈值(pages_low)和页最小阈值(pages_min)之间,<br>说明内存压力比较大,剩余内存不多了。<b><font color="#0000ff">这时 kswapd0 会执行内存回收,直到剩余内存大于高阈值(pages_high)<br>为止</font></b>。虽然会触发内存回收,但是不会阻塞应用程序,因为两者关系是异步的。</li></ul><br><ul><li>图中红色部分:如果剩余内存(pages_free)小于页最小阈值(pages_min),说明用户可用内存都耗尽了,<br>此时就会 <b><font color="#0000ff">触发直接内存回收</font></b>,这时应用程序就会被阻塞,因为两者关系是同步的。</li></ul>
可以看到,当剩余内存页(pages_free)小于页低阈值(pages_low),就会触发 kswapd 进行<br>后台回收,然后 kswapd 会一直回收到剩余内存页(pages_free)大于页高阈值(pages_high)。
也就是说 kswapd 的活动空间只有 pages_low 与 pages_min 之间的这段区域,如果剩余<br>内存低于了 pages_min 会触发直接内存回收,高于了 pages_high 又不会唤醒 kswapd。
页低阈值(pages_low)可以通过内核选项 /proc/sys/vm/min_free_kbytes (该参数代表系统所保留空闲内存的最低限)来间接设置。
min_free_kbytes 虽然设置的是页最小阈值(pages_min),但是页高阈值(pages_high)和页低阈值(pages_low)<br>都是根据页最小阈值(pages_min)计算生成的,它们之间的计算关系如下:
如果系统时不时发生抖动,并且通过 sar -B 观察到 pgscand 数值很大,那大概率是因为直接内存回收导致的,<br>这时可以增大 min_free_kbytes 这个配置选项来及早地触发后台回收,然后继续观察 pgscand 是否会降为 0。
增大了 min_free_kbytes 配置后,这会使得系统预留过多的空闲内存,从而在一定程度上降低了应用程序可使用的内存量,这在一定程度上<br>浪费了内存。极端情况下设置 min_free_kbytes 接近实际物理内存大小时,留给应用程序的内存就会太少而可能会频繁地导致 OOM 的发生。
所以在调整 min_free_kbytes 之前,需要先思考一下,应用程序更加关注什么,如果关注延迟<br>那就适当地增大 min_free_kbytes,如果关注内存的使用量那就适当地调小 min_free_kbytes。
NUMA 架构下的内存回收策略
什么是 NUMA 架构?
再说 NUMA 架构前,先给大家说说 SMP 架构,这两个架构都是针对 CPU 的。
SMP 指的是一种 <b><font color="#0000ff">多个 CPU 处理器共享资源的电脑硬件架构</font></b>,也就是说每个 CPU 地位平等,它们共享相同的物理资源,包括总线、内存、IO、<br>操作系统等。每个 CPU 访问内存所用时间都是相同的,因此,这种系统也被称为一致存储访问结构(UMA,Uniform Memory Access)。
随着 CPU 处理器核数的增多,多个 CPU 都通过一个总线访问内存,这样总线的带宽压力会越来越大,<br>同时每个 CPU 可用带宽会减少,这也就是 SMP 架构的问题。
为了解决 SMP 架构的问题,就研制出了 NUMA 结构,即非一致存储访问结构(Non-uniform memory access,NUMA)。
NUMA 架构将每个 CPU 进行了分组,每一组 CPU 用 Node 来表示,一个 Node 可能包含多个 CPU 。
<b><font color="#0000ff">每个 Node 有自己独立的资源,包括内存、IO 等</font></b>,每个 Node 之间可以通过互联模块总线(QPI)进行通信,所以,也就意味<br>着每个 Node 上的 CPU 都可以访问到整个系统中的所有内存。但是,访问远端 Node 的内存比访问本地内存要耗时很多。
NUMA 架构跟回收内存有什么关系?<br>
在 NUMA 架构下,当某个 Node 内存不足时,系统可以从其他 Node 寻找空闲内存,也可以从本地内存中回收内存。
具体选哪种模式,可以通过 /proc/sys/vm/zone_reclaim_mode 来控制。它支持以下几个选项:<br><br><ul><li>0 (默认值):在回收本地内存之前,在其他 Node 寻找空闲内存;</li><li>1:只回收本地内存;</li><li>2:只回收本地内存,在本地回收内存时,可以将文件页中的脏页写回硬盘,以回收内存。</li><li>4:只回收本地内存,在本地回收内存时,可以用 swap 方式回收内存。</li></ul>
在使用 NUMA 架构的服务器,如果系统出现还有一半内存的时候,却发现系统频繁触发「直接内存回收」,导致了影响了系统性能,那么大概率<br>是因为 zone_reclaim_mode 没有设置为 0 ,导致当本地内存不足的时候,只选择回收本地内存的方式,而不去使用其他 Node 的空闲内存。
虽然说访问远端 Node 的内存比访问本地内存要耗时很多,但是相比内存回收的危害而言,访问<br>远端 Node 的内存带来的性能影响还是比较小的。因此,zone_reclaim_mode 一般建议设置为 0。<br>
如何保护一个进程不被 OOM 杀掉?
在系统空闲内存不足的情况,进程申请了一个很大的内存,如果直接内存回收都无法回收<br>出足够大的空闲内存,那么就会触发 OOM 机制,内核就会根据算法选择一个进程杀掉。
Linux 到底是根据什么标准来选择被杀的进程呢?这就要提到一个在 Linux 内核里有一个 oom_badness() 函数,<br>它会把系统中可以被杀掉的进程扫描一遍,并对每个进程打分,得分最高的进程就会被首先杀掉。
进程得分的结果受下面这两个方面影响:<br><br><ul><li>第一,进程已经使用的物理内存页面数。</li><li>第二,每个进程的 OOM 校准值 oom_score_adj。它是可以通过 /proc/[pid]/oom_score_adj <br>来配置的。我们可以在设置 -1000 到 1000 之间的任意一个数值,调整进程被 OOM Kill 的几率。</li></ul>
函数 oom_badness() 里的最终计算方法是这样的:<br>
<b><font color="#0000ff">用「系统总的可用页面数」乘以 「OOM 校准值 oom_score_adj」再除以 1000,最后再加上<br>进程已经使用的物理页面数,计算出来的值越大,那么这个进程被 OOM Kill 的几率也就越大。</font></b>
每个进程的 oom_score_adj 默认值都为 0,所以最终得分跟进程自身消耗的内存有关,消耗的<br>内存越大越容易被杀掉。我们可以通过 <b><font color="#0000ff">调整 oom_score_adj 的数值</font></b>,来改成进程的得分结果:<br><br><ul><li><span style="font-size: inherit;">如果你不想某个进程被首先杀掉,那你可以调整该进程的 oom_score_adj,<br>从而改变这个进程的得分结果,降低该进程被 OOM 杀死的概率。</span></li><li>如果你想某个进程无论如何都不能被杀掉,那你可以将 oom_score_adj 配置为 -1000。</li></ul>
我们最好将一些很重要的系统服务的 oom_score_adj 配置为 -1000,比如 sshd,因为这些系统服务一旦被杀掉,我们就很难再登陆进系统了。
但是,不建议将我们自己的业务程序的 oom_score_adj 设置为 -1000,因为业务程序一旦发生了内存泄漏,<br>而它又不能被杀掉,这就会导致随着它的内存开销变大,OOM killer 不停地被唤醒,从而把其他进程一个个给杀掉。
总结
内核在给应用程序分配物理内存的时候,如果空闲物理内存不够,那么就会进行内存回收的工作,主要有两种方式:<br><br><ul><li>后台内存回收:在物理内存紧张的时候,会唤醒 kswapd 内核线程来回收内存,这个回收内存的过程异步的,不会阻塞进程的执行。</li><li>直接内存回收:如果后台异步回收跟不上进程内存申请的速度,就会开始直接回收,这个回收内存的过程是同步的,会阻塞进程的执行。</li></ul>
可被回收的内存类型有文件页和匿名页:<br><br><ul><li>文件页的回收:对于干净页是直接释放内存,这个操作不会影响性能,而对于脏页会先<br>写回到磁盘再释放内存,这个操作会发生磁盘 I/O 的,这个操作是会影响系统性能的。</li><li>匿名页的回收:如果开启了 Swap 机制,那么 Swap 机制会将不常访问的匿名页换出到<br>磁盘中,下次访问时,再从磁盘换入到内存中,这个操作是会影响系统性能的。</li></ul>
文件页和匿名页的回收都是基于 LRU 算法,也就是优先回收不常访问的内存。回收内存的操作基本都会发生<br>磁盘 I/O 的,如果回收内存的操作很频繁,意味着磁盘 I/O 次数会很多,这个过程势必会影响系统的性能。
针对回收内存导致的性能影响,常见的解决方式。<br><br><ul><li>设置 /proc/sys/vm/swappiness,调整文件页和匿名页的回收倾向,尽量倾向于回收文件页;</li><li>设置 /proc/sys/vm/min_free_kbytes,调整 kswapd 内核线程异步回收内存的时机;</li><li>设置 /proc/sys/vm/zone_reclaim_mode,调整 NUMA 架构下内存回收策略,建议设置为 0,这样在回收本地内存之前,会在其他 Node <br>寻找空闲内存,从而避免在系统还有很多空闲内存的情况下,因本地 Node 的本地内存不足,发生频繁直接内存回收导致性能下降的问题;</li></ul>
在经历完直接内存回收后,空闲的物理内存大小依然不够,那么就会触发 OOM 机制,OOM killer <br>就会根据每个进程的内存占用情况和 oom_score_adj 的值进行打分,得分最高的进程就会被首先杀掉。
我们可以通过调整进程的 /proc/[pid]/oom_score_adj 值,来降低被 OOM killer 杀掉的概率。
在 4GB 物理内存的机器上申请 8GB 内存会怎么样?<br>
介绍
这个问题在没有前置条件下,就说出答案就是耍流氓。这个问题要考虑三个前置条件:<br><br><ul><li>操作系统是 32 位的,还是 64 位的?</li><li>申请完 8G 内存后会不会被使用?</li><li>操作系统有没有使用 Swap 机制?</li></ul>
所以,我们要分场景讨论。<br>
操作系统虚拟内存大小
应用程序通过 malloc 函数申请内存的时候,实际上申请的是虚拟内存,此时并不会分配物理内存。
当应用程序读写了这块虚拟内存,CPU 就会去访问这个虚拟内存, 这时会发现这个虚拟内存没有映射到物理内存, CPU <br>就会产生<b><font color="#0000ff"> 缺页中断</font></b>,进程会从用户态切换到内核态,并将缺页中断交给内核的 Page Fault Handler (缺页中断函数)处理。
缺页中断处理函数会看是否有空闲的物理内存:<br><br><ul><li>如果有,就直接分配物理内存,并建立虚拟内存与物理内存之间的映射关系。</li><li>如果没有空闲的物理内存,那么内核就会开始进行回收内存的工作,如果回收内存工作结束后,空闲的物理<br>内存仍然无法满足此次物理内存的申请,那么内核就会放最后的大招了触发 OOM (Out of Memory)机制。</li></ul>
32 位操作系统和 64 位操作系统的虚拟地址空间大小是不同的,在 Linux 操作系统中,<br>虚拟地址空间的内部又被分为内核空间和用户空间两部分,如下所示:
通过这里可以看出:<br><br><ul><li>32 位系统的内核空间占用 1G,位于最高处,剩下的 3G 是用户空间;</li><li>64 位系统的内核空间和用户空间都是 128T,分别占据整个内存空间的最高和最低处,剩下的中间部分是未定义的。</li></ul>
现在可以回答这个问题了:<b><font color="#1b5e20">在 32 位操作系统、4GB 物理内存的机器上,申请 8GB 内存,会怎么样?</font></b>
因为 32 位操作系统,进程最多只能申请 3 GB 大小的虚拟内存空间,所以进程申请 8GB 内存的话,<br>在申请虚拟内存阶段就会失败(我手上没有 32 位操作系统测试,我估计失败的原因是 OOM)。
<b><font color="#1b5e20">在 64 位操作系统、4GB 物理内存的机器上,申请 8G 内存,会怎么样?</font></b>
64 位操作系统,进程可以使用 128 TB 大小的虚拟内存空间,所以进程申请 8GB 内存是没问题的,<br>因为进程申请内存是申请虚拟内存,只要不读写这个虚拟内存,操作系统就不会分配物理内存。
我们可以简单做个测试,我的服务器是 64 位操作系统,但是物理内存只有 2 GB:
现在,我在机器上,连续申请 4 次 1 GB 内存,也就是一共申请了 4 GB 内存,<br>注意下面代码只是单纯分配了虚拟内存,并没有使用该虚拟内存:<br>
然后运行这个代码,可以看到,我的物理内存虽然只有 2GB,但是程序正常分配了 4GB 大小的虚拟内存:
我们可以通过下面这条命令查看进程(test)的虚拟内存大小:<br><br><ul><li>其中,VSZ 就代表进程使用的虚拟内存大小,RSS 代表进程使用的物理内存大小。<br>可以看到,VSZ 大小为 4198540,也就是 4GB 的虚拟内存。</li></ul>
Swap 机制的作用
介绍
前面讨论在 32 位/64 位操作系统环境下,申请的虚拟内存超过物理内存后会怎么样?<br><br><ul><li>在 32 位操作系统,因为进程最大只能申请 3 GB 大小的虚拟内存,所以直接申请 8G 内存,会申请失败。</li><li>在 64 位操作系统,因为进程最大只能申请 128 TB 大小的虚拟内存,即使物理内存只有 4GB,申请 8G 内存也是没问题,因为申请的内存是虚拟内存。</li></ul>
程序申请的虚拟内存,如果没有被使用,它是不会占用物理空间的。当访问这块虚拟内存后,操作系统才会进行物理内存分配。
如果申请物理内存大小超过了空闲物理内存大小,就要看操作系统有没有开启 Swap 机制:<br><br><ul><li>如果没有开启 Swap 机制,程序就会直接 OOM;</li><li>如果有开启 Swap 机制,程序可以正常运行。</li></ul>
什么是 Swap 机制?
当系统的物理内存不够用的时候,就需要将物理内存中的一部分空间释放出来,以供当前运行的程序使用。那些被释放的空间可能来自一些<br>很长时间没有什么操作的程序,这些被释放的空间会被临时保存到磁盘,等到那些程序要运行时,再从磁盘中恢复保存的数据到内存中。
另外,当内存使用存在压力的时候,会开始触发内存回收行为,会把这些不常访问的内存先写到磁盘中,<br>然后释放这些内存,给其他更需要的进程使用。再次访问这些内存时,重新从磁盘读入内存就可以了。
这种,将内存数据换出磁盘,又从磁盘中恢复数据到内存的过程,就是 Swap 机制负责的。
Swap 就是把一块磁盘空间或者本地文件,当成内存来使用,它包含换出和换入两个过程:<br><br><ul><li><b><font color="#0000ff">换出(Swap Out)</font></b> ,是把进程暂时不用的内存数据存储到磁盘中,并释放这些数据占用的内存;</li><li><b><font color="#0000ff">换入(Swap In)</font></b>,是在进程再次访问这些内存的时候,把它们从磁盘读到内存中来;</li></ul>
Swap 换入换出的过程如下图:
使用 Swap 机制优点是,应用程序实际可以使用的内存空间将远远超过系统的物理内存。由于硬盘空间的价格远比内存要低,<br>因此这种方式无疑是经济实惠的。当然,频繁地读写硬盘,会显著降低操作系统的运行速率,这也是 Swap 的弊端。
Linux 中的 Swap 机制会在内存不足和内存闲置的场景下触发:<br><br><ul><li><b><font color="#0000ff">内存不足</font></b>:当系统需要的内存超过了可用的物理内存时,内核会将内存中不常使用的内存页交换到磁盘上为当前进程让出内存,保证正在执行的<br>进程的可用性,这个内存回收的过程是强制的直接内存回收(Direct Page Reclaim)。直接内存回收是同步的过程,会阻塞当前申请内存的进程。</li><li><b><font color="#0000ff">内存闲置</font></b>:应用程序在启动阶段使用的大量内存在启动后往往都不会使用,通过后台运行的守护进程(kSwapd),我们可以将这部分只使用一次<br>的内存交换到磁盘上为其他内存的申请预留空间。kSwapd 是 Linux 负责页面置换(Page replacement)的守护进程,它也是负责交换闲置内存<br>的主要进程,它会在空闲内存低于一定水位时,回收内存页中的空闲内存保证系统中的其他进程可以尽快获得申请的内存。kSwapd 是后台进程,<br>所以回收内存的过程是异步的,不会阻塞当前申请内存的进程。</li></ul>
Linux 提供了两种不同的方法启用 Swap,分别是 Swap 分区(Swap Partition)和 Swap 文件(Swapfile):<br><br><ul><li>Swap 分区是硬盘上的独立区域,该区域只会用于交换分区,其他的文件不能存储在该区域上,<br>我们可以使用 swapon -s 命令查看当前系统上的交换分区;</li><li>Swap 文件是文件系统中的特殊文件,它与文件系统中的其他文件也没有太多的区别;</li></ul>
Swap 换入换出的是什么类型的内存?<br>
内核缓存的文件数据,因为都有对应的磁盘文件,所以在回收文件数据的时候, 直接写回到对应的文件就可以了。
但是像进程的堆、栈数据等,它们是没有实际载体,这部分内存被称为匿名页。而且这部分内存很可能还要<br>再次被访问,所以不能直接释放内存,于是就需要有一个能保存匿名页的磁盘载体,这个载体就是 Swap 分区。
匿名页回收的方式是通过 Linux 的 Swap 机制,Swap 会把不常访问的内存先写到磁盘中,然后<br>释放这些内存,给其他更需要的进程使用。再次访问这些内存时,重新从磁盘读入内存就可以了。
接下来,通过两个实验,看看申请的物理内存超过物理内存会怎样?<br><br><ul><li>实验一:没有开启 Swap 机制</li><li>实验二:有开启 Swap 机制</li></ul>
实验一:没有开启 Swap 机制
我的服务器是 64 位操作系统,但是物理内存只有 2 GB,而且没有 Swap 分区:
我们改一下前面的代码,使得在申请完 4GB 虚拟内存后,通过 memset 函数访问这个虚拟内存,看看在没有 Swap 分区的情况下,会发生什么?
运行结果:
可以看到,在访问第 2 块虚拟内存(每一块虚拟内存是 1 GB)的时候,因为超过了机器的物理内存(2GB),进程(test)被操作系统杀掉了。
通过查看 message 系统日志,可以发现该进程是被操作系统 OOM killer 机制杀掉了,<br>日志里报错了 Out of memory,也就是发生 OOM(内存溢出错误)。
内存溢出(Out Of Memory,简称OOM)是指应用系统中存在无法回收的内存或使用的内存过多,<br>最终使得程序运行要用到的内存大于能提供的最大内存。此时程序就运行不了,系统会提示内存溢出。
实验二:开启了 Swap 机制
我用我的 mac book pro 笔记本做测试,我的笔记本是 64 位操作系统,物理内存是 8 GB, 目前 Swap 分区大小为 1 GB(注意这个大小不是固定<br>不变的,Swap 分区总大小是会动态变化的,当没有使用 Swap 分区时,Swap 分区总大小是 0;当使用了 Swap 分区,Swap 分区总大小会增加至 <br>1 GB;当 Swap 分区已使用的大小超过 1 GB 时;Swap 分区总大小就会增加到至 2 GB;当 Swap 分区已使用的大小超过 2 GB 时;Swap 分区总<br>大小就增加至 3GB,如此往复。这个估计是 macos 自己实现的,Linux 的分区则是固定大小的,Swap 分区不会根据使用情况而自动增长)。
为了方便观察磁盘 I/O 情况,我们改进一下前面的代码,分配完 32 GB虚拟内存后(笔记本物理内存是 8 GB),<br>通过一个 while 循环频繁访问虚拟内存,代码如下:
运行结果如下:<br>
可以看到,在有 Swap 分区的情况下,即使笔记本物理内存是 8 GB,申请并使用 32 GB 内存是没问题,程序正常运行了,并没有发生 OOM。
从下图可以看到,进程的内存显示 32 GB(这个不要理解为占用的物理内存,理解为已被访问<br>的虚拟内存大小,也就是在物理内存呆过的内存大小),系统已使用的 Swap 分区达到 2.3 GB。
此时我的笔记本电脑的磁盘开始出现“沙沙”的声音,通过查看磁盘的 I/O 情况,可以看到磁盘 I/O 达到了一个峰值,非常高:
<b><font color="#1b5e20">那么有了 Swap 分区,是不是意味着进程可以使用的内存是无上限的?</font></b>
当然不是,我把上面的代码改成了申请 64GB 内存后,当进程申请完 64GB 虚拟内存后,使用到 56 GB (这个不要理解为占用<br>的物理内存,理解为已被访问的虚拟内存大小,也就是在物理内存呆过的内存大小)的时候,进程就被系统 kill 掉了,如下图:
因为 swap 空间也是有限的,当 swap 空间用完时,就无法再向 swap 空间换出内存数据了。
当系统多次尝试回收内存,还是无法满足所需使用的内存大小,进程就会被系统 kill 掉了,意味着发生了 OOM。<br>(PS:我没有在 macos 系统找到像 linux 系统里的 /var/log/message 系统日志文件,所以无法通过查看日志确认是否发生了 OOM)。
总结
在 32 位操作系统,因为进程最大只能申请 3 GB 大小的虚拟内存,所以直接申请 8G 内存,会申请失败。
在 64位 位操作系统,因为进程最大只能申请 128 TB 大小的虚拟内存,即使物理内存只有 4GB,申请 8G <br>内存也是没问题,因为申请的内存是虚拟内存。如果这块虚拟内存被访问了,要看系统有没有 Swap 分区:<br><br><ul><li>如果没有 Swap 分区,因为物理空间不够,进程会被操作系统杀掉,原因是 OOM(内存溢出);</li><li>如果有 Swap 分区,即使物理内存只有 4GB,程序也能正常使用 8GB 的内存,进程可以正常运行;</li></ul>
需要注意,并不是有 Swap 分区就可以申请无限大的内存空间,因为 Swap 分区空间也是有限的。
如何避免预读失效和缓存污染的问题?
介绍
先来看两个问题:
乍一看,以为是在问操作系统的问题,其实这两个题目都是在问 <b><font color="#0000ff">如何改进 LRU 算法</font></b>。
因为传统的 LRU 算法存在这两个问题:<br><br><ul><li><b><font color="#0000ff">「预读失效」导致缓存命中率下降</font></b>(对应第一个题目)</li><li><b><font color="#0000ff">「缓存污染」导致缓存命中率下降</font></b>(对应第二个题目)</li></ul>
Redis 的缓存淘汰算法则是通过 <b><font color="#0000ff">实现</font></b> <b><font color="#0000ff">LFU 算法 </font></b>来避免「缓存污染」而导致缓存命中率下降的问题(Redis 没有预读机制)。
MySQL 和 Linux 操作系统是通过 <b><font color="#0000ff">改进 LRU 算法 </font></b>来避免「预读失效和缓存污染」而导致缓存命中率下降的问题。
这次,就重点讲讲 <b><font color="#0000ff" style="">MySQL 和 Linux 操作系统是如何改进 LRU 算法的?</font></b>
Linux 和 MySQL 的缓存
Linux 的缓存
在应用程序读取文件的数据的时候,Linux 操作系统是会对读取的文件数据进行缓存的,会缓存在文件系统中的 Page Cache(如下图中的页缓存)。
<b><font color="#0000ff">Page Cache 属于内存空间里的数据</font></b>,由于内存访问比磁盘访问快很多,在下一次访问相同的数据就不需要通过磁盘 I/O 了,命中缓存就直接返回数据即可。
因此,Page Cache 起到了加速访问数据的作用。<br>
MySQL 的缓存
MySQL 的数据是存储在磁盘里的,为了提升数据库的读写性能,Innodb 存储引擎<br>设计了一个 <b><font color="#0000ff">缓冲池(Buffer Pool)</font></b>,Buffer Pool 属于内存空间里的数据。
有了缓冲池后:<br><br><ul><li>当读取数据时,如果数据存在于 Buffer Pool 中,客户端就会直接读取 Buffer Pool 中的数据,否则再去磁盘中读取。</li><li>当修改数据时,首先是修改 Buffer Pool 中数据所在的页,然后将其页设置为脏页,最后由后台线程将脏页写入到磁盘。</li></ul>
传统 LRU 是如何管理内存数据的?
Linux 的 Page Cache 和 MySQL 的 Buffer Pool 的大小是有限的,并不能无限的缓存数据,对于一些频繁访问的数据我们希望可以一直留在内存中,<br>而一些很少访问的数据希望可以在某些时机可以淘汰掉,从而保证内存不会因为满了而导致无法再缓存新的数据,同时还能保证常用数据留在内存中。
要实现这个,最容易想到的就是 LRU(Least recently used)算法。
LRU 算法一般是用「链表」作为数据结构来实现的,链表头部的数据是最近使用的,而链表末尾的数据是最久<br>没被使用的。那么,当空间不够了,就淘汰最久没被使用的节点,也就是链表末尾的数据,从而腾出内存空间。
因为 Linux 的 Page Cache 和 MySQL 的 Buffer Pool 缓存的 <b><font color="#0000ff">基本数据单位都是页(Page)单位</font></b>,所以 <b><font color="#0000ff">后续以「页」名称代替「数据」</font></b>。
传统的 LRU 算法的实现思路是这样的:<br><br><ul><li>当访问的页在内存里,就直接把该页对应的 LRU 链表节点移动到链表的头部。</li><li>当访问的页不在内存里,除了要把该页放入到 LRU 链表的头部,还要淘汰 LRU 链表末尾的页。</li></ul>
比如下图,假设 LRU 链表长度为 5,LRU 链表从左到右有编号为 1,2,3,4,5 的页。
如果访问了 3 号页,因为 3 号页已经在内存了,所以把 3 号页移动到链表头部即可,表示最近被访问了。
而如果接下来,访问了 8 号页,因为 8 号页不在内存里,且 LRU 链表长度为 5,所以必须要淘汰数据,<br>以腾出内存空间来缓存 8 号页,于是就会淘汰末尾的 5 号页,然后再将 8 号页加入到头部。<br>
传统的 LRU 算法并没有被 Linux 和 MySQL 使用,因为传统的 LRU 算法无法避免下面这两个问题:<br><br><ul><li>预读失效导致缓存命中率下降;</li><li>缓存污染导致缓存命中率下降;</li></ul>
预读失效怎么办?
什么是预读机制?
Linux 操作系统为基于 Page Cache 的读缓存机制提供 <b><font color="#0000ff">预读机制</font></b>,一个例子是:<br><br><ul><li>应用程序只想读取磁盘上文件 A 的 offset 为 0-3KB 范围内的数据,由于磁盘的基本读写单位为 block(4KB),<br>于是操作系统至少会读 0-4KB 的内容,这恰好可以在一个 page 中装下。</li><li>但是操作系统出于 <b><font color="#0000ff">空间局部性原理</font></b>(靠近当前被访问数据的数据,在未来很大概率会被访问到),会选择将磁盘块 <br>offset [4KB,8KB)、[8KB,12KB) 以及 [12KB,16KB) 都加载到内存,于是额外在内存中申请了 3 个 page;</li></ul>
下图代表了操作系统的预读机制:
上图中,应用程序利用 read 系统调动读取 4KB 数据,实际上内核使用预读机制(ReadaHead) <br>机制完成了 16KB 数据的读取,也就是通过一次磁盘顺序读将多个 Page 数据装入 Page Cache。
这样下次读取 4KB 数据后面的数据的时候,就不用从磁盘读取了,直接在 Page Cache 即可命中数据。<br>因此,预读机制带来的好处就是 <b><font color="#0000ff">减少了 磁盘 I/O 次数,提高系统磁盘 I/O 吞吐量</font></b>。
MySQL Innodb 存储引擎的 Buffer Pool 也有类似的预读机制,MySQL 从磁盘加载页时,会提前把它相邻的页一并加载进来,目的是为了减少磁盘 IO。
预读失效会带来什么问题?
如果这些 <b><font color="#0000ff">被提前加载进来的页,并没有被访问</font></b>,相当于这个预读工作是白做了,这个就是 <b><font color="#0000ff">预读失效</font></b>。
如果使用传统的 LRU 算法,就会把「预读页」放到 LRU 链表头部,而当内存空间不够的时候,还需要把末尾的页淘汰掉。
如果这些「预读页」如果一直不会被访问到,就会出现一个很奇怪的问题,<b><font color="#0000ff">不会被访问的预读页却<br>占用了 LRU 链表前排的位置,而末尾淘汰的页,可能是热点数据,这样就大大降低了缓存命中率 </font></b>。
如何避免预读失效造成的影响?
我们不能因为害怕预读失效,而将预读机制去掉,大部分情况下,空间局部性原理还是成立的。
要避免预读失效带来影响,最好就是 <b><font color="#0000ff">让预读页停留在内存里的时间要尽可能的短,让真正被访问<br>的页才移动到 LRU 链表的头部,从而保证真正被读取的热数据留在内存里的时间尽可能长。</font></b>
那到底怎么才能避免呢?
Linux 操作系统和 MySQL Innodb 通过改进传统 LRU 链表来避免预读失效带来的影响,具体的改进分别如下:<br><br><ul><li>Linux 操作系统实现两个了 LRU 链表:<b><font color="#0000ff">活跃 LRU 链表(active_list)和非活跃 LRU 链表(inactive_list)</font></b>;</li><li>MySQL 的 Innodb 存储引擎是在一个 LRU 链表上划分来 2 个区域:<b><font color="#0000ff">young 区域 和 old 区域</font></b>。</li></ul>
这两个改进方式,设计思想都是类似的,<b><font color="#0000ff">都是将数据分为了冷数据和热数据,然后分别进行 LRU 算法</font></b>。<br>不再像传统的 LRU 算法那样,所有数据都只用一个 LRU 算法管理。
接下来,具体聊聊 Linux 和 MySQL 是如何避免预读失效带来的影响?<br>
<b><font color="#1b5e20">Linux 是如何避免预读失效带来的影响?</font></b><br>
Linux 操作系统实现两个了 LRU 链表:<b><font color="#0000ff">活跃 LRU 链表(active_list)和非活跃 LRU 链表(inactive_list)</font></b>。<br><br><ul><li>active list 活跃内存页链表,这里存放的是最近被访问过(活跃)的内存页;</li><li>inactive list 不活跃内存页链表,这里存放的是很少被访问(非活跃)的内存页;</li></ul>
有了这两个 LRU 链表后,<b><font color="#0000ff">预读页就只需要加入到 inactive list 区域的头部,当页被真正访问的时候,才将页插入 active list 的头部</font></b>。<br>如果预读的页一直没有被访问,就会从 inactive list 移除,这样就不会影响 active list 中的热点数据。
假设 active list 和 inactive list 的长度为 5,目前内存中已经有如下 10 个页:
现在有个编号为 20 的页被预读了,这个页只会被插入到 inactive list 的头部,而 inactive list 末尾的页(10号)会被淘汰掉。
<b><font color="#0000ff">即使编号为 20 的预读页一直不会被访问,它也没有占用到 active list 的位置</font></b>,而且还会比 active list 中的页更早被淘汰出去。
如果 20 号页被预读后,立刻被访问了,那么就会将它插入到 active list 的头部, active list 末尾的页(5号),<br>会被降级到 inactive list ,作为 inactive list 的头部,这个过程并不会有数据被淘汰。
<font color="#1b5e20"><b>MySQL 是如何避免预读失效带来的影响?</b></font><br>
MySQL 的 Innodb 存储引擎是在一个 LRU 链表上划分来 2 个区域,<b><font color="#0000ff">young 区域 和 old 区域</font></b>。
young 区域在 LRU 链表的前半部分,old 区域则是在后半部分,这两个区域都有各自的头和尾节点,如下图:<br>
young 区域与 old 区域在 LRU 链表中的占比关系并不是一比一的关系,而是 63:37(默认比例)的关系。
划分这两个区域后,<b><font color="#0000ff">预读的页就只需要加入到 old 区域的头部,当页被真正访问的时候,才将页插入 young 区域的头部</font></b>。<br>如果预读的页一直没有被访问,就会从 old 区域移除,这样就不会影响 young 区域中的热点数据。
假设有一个长度为 10 的 LRU 链表,其中 young 区域占比 70 %,old 区域占比 30 %。
现在有个编号为 20 的页被预读了,这个页只会被插入到 old 区域头部,而 old 区域末尾的页(10号)会被淘汰掉。
如果 20 号页一直不会被访问,它也没有占用到 young 区域的位置,而且还会比 young 区域的数据更早被淘汰出去。
如果 20 号页被预读后,立刻被访问了,那么就会将它插入到 young 区域的头部,young 区域<br>末尾的页(7号),会被挤到 old 区域,作为 old 区域的头部,这个过程并不会有页被淘汰。
缓存污染怎么办?
什么是缓存污染?
虽然 Linux (实现两个 LRU 链表)和 MySQL (划分两个区域)通过改进传统的 LRU 数据结构,避免了预读失效带来的影响。
但是如果还是使用「只要数据被访问一次,就将数据加入到活跃 LRU 链表头部(或者 young 区域)」这种方式的话,那么 <b><font color="#0000ff">还存在缓存污染的问题</font></b>。
当我们在批量读取数据的时候,由于数据被访问了一次,这些大量数据都会被加入到「活跃 LRU 链表」里,然后之前缓存在活跃 LRU 链表(或者 young <br>区域)里的热点数据全部都被淘汰了,<b><font color="#0000ff">如果这些大量的数据在很长一段时间都不会被访问的话,那么整个活跃 LRU 链表(或者 young 区域)就被污染了</font></b>。
缓存污染会带来什么问题?
缓存污染带来的影响就是很致命的,等这些热数据又被再次访问的时候,由于缓存未命中,就会产生大量的磁盘 I/O,系统性能就会急剧下降。
我以 MySQL 举例子,Linux 发生缓存污染的现象也是类似。
当某一个 SQL 语句 <b><font color="#0000ff">扫描了大量的数据 </font></b>时,在 Buffer Pool 空间比较有限的情况下,可能会将 <b><font color="#0000ff">Buffer Pool 里的所有页都替换出去,导致<br>大量热数据被淘汰了</font></b>,等这些热数据又被再次访问的时候,由于缓存未命中,就会产生大量的磁盘 I/O,MySQL 性能就会急剧下降。
注意, 缓存污染并不只是查询语句查询出了大量的数据才出现的问题,即使查询出来的结果集很小,也会造成缓存污染。
比如,在一个数据量非常大的表,执行了这条语句:
可能这个查询出来的结果就几条记录,但是由于这条语句会发生索引失效,所以这个查询过程是 <b><font color="#0000ff">全表扫描 </font></b>的,接着会发生如下的过程:<br><br><ul><li>从磁盘读到的页加入到 LRU 链表的 old 区域头部;</li><li>当从页里读取行记录时,也就是页被访问的时候,就要将该页放到 young 区域头部;</li><li>接下来拿行记录的 name 字段和字符串 xiaolin 进行模糊匹配,如果符合条件,就加入到结果集里;</li><li>如此往复,直到扫描完表中的所有记录。</li></ul>
经过这一番折腾,由于这条 SQL 语句访问的页非常多,每访问一个页,都会将其加入 young 区域头部,那么 <b><font color="#0000ff">原本 young 区域的热点数据都会被<br>替换掉,导致缓存命中率下降</font></b>。那些在批量扫描时被加入到 young 区域的页,如果在很长一段时间都不会再被访问的话,就污染了 young 区域。
举个例子,假设需要批量扫描:21,22,23,24,25 这五个页,这些页都会被逐一访问(读取页里的记录)。
在批量访问这些页的时候,会被逐一插入到 young 区域头部。
可以看到,原本在 young 区域的 6 和 7 号页都被淘汰了,而批量扫描的页基本占满了 young 区域,<br>如果这些页在很长一段时间都不会被访问,那么就对 young 区域造成了污染。
如果 6 和 7 号页是热点数据,那么在被淘汰后,后续有 SQL 再次读取 6 和 7 号页时,由于<br>缓存未命中,就要从磁盘中读取了,降低了 MySQL 的性能,这就是缓存污染带来的影响。
怎么避免缓存污染造成的影响?
前面的 LRU 算法只要数据被访问一次,就将数据加入活跃 LRU 链表(或者 young 区域),<b><font color="#0000ff">这种 LRU 算法进入活跃 LRU <br>链表的门槛太低了</font></b>!正式因为门槛太低,才导致在发生缓存污染的时候,很容就将原本在活跃 LRU 链表里的热点数据淘汰了。
所以,<b><font color="#0000ff">只要我们提高进入到活跃 LRU 链表(或者 young 区域)的门槛,就能<br>有效地保证活跃 LRU 链表(或者 young 区域)里的热点数据不会被轻易替换掉。</font></b>
Linux 操作系统和 MySQL Innodb 存储引擎分别是这样提高门槛的:<br><br><ul><li><b><font color="#0000ff">Linux 操作系统</font></b>:在内存页被访问 <b><font color="#0000ff">第二次 </font></b>的时候,才将页从 inactive list 升级到 active list 里。<br><br></li><li>MySQL Innodb:在内存页被访问 <b><font color="#0000ff">第二次</font></b> 的时候,并不会马上将该页从 old 区域升级到 young 区域,<br>因为还要 <b><font color="#0000ff">进行停留在 old 区域的时间判断</font></b>:</li></ul> · 如果第二次的访问时间与第一次访问的时间 <b><font color="#0000ff">在 1 秒内</font></b>(默认值),那么该页就 <b><font color="#0000ff">不会 </font></b>被从 old 区域升级到 young 区域;<br> · 如果第二次的访问时间与第一次访问的时间 <b><font color="#0000ff">超过 1 秒</font></b>,那么该页就 <b><font color="#0000ff">会</font></b> 从 old 区域升级到 young 区域;
提高了进入活跃 LRU 链表(或者 young 区域)的门槛后,就很好了避免缓存污染带来的影响。
在批量读取数据时候,<b><font color="#0000ff">如果这些大量数据只会被访问一次,那么它们就不会进入到活跃 LRU 链表(或者 young 区域)</font></b>,<br>也就不会把热点数据淘汰,只会待在非活跃 LRU 链表(或者 old 区域)中,后续很快也会被淘汰。
总结
传统的 LRU 算法法无法避免下面这两个问题:<br><br><ul><li>预读失效导致缓存命中率下降;</li><li>缓存污染导致缓存命中率下降;</li></ul>
为了避免「预读失效」造成的影响,Linux 和 MySQL 对传统的 LRU 链表做了改进:<br><br><ul><li>Linux 操作系统实现两个了 LRU 链表:<b><font color="#0000ff">活跃 LRU 链表(active list)和非活跃 LRU 链表(inactive list)</font></b>。</li><li>MySQL Innodb 存储引擎是在一个 LRU 链表上划分来 2 个区域:<b><font color="#0000ff">young 区域 和 old 区域</font></b>。</li></ul>
但是如果还是使用「只要数据被访问一次,就将数据加入到活跃 LRU 链表头部(或者 young 区域)」这种方式的话,那么还 <b><font color="#0000ff">存在缓存污染的问题</font></b>。
为了避免「缓存污染」造成的影响,Linux 操作系统和 MySQL Innodb 存储引擎分别是这样提高门槛的:<br><br><ul><li><b><font color="#0000ff">Linux 操作系统</font></b>:在内存页被访问 <b><font color="#0000ff">第二次 </font></b>的时候,才将页从 inactive list 升级到 active list 里。<br><br></li><li>MySQL Innodb:在内存页被访问 <b><font color="#0000ff">第二次</font></b> 的时候,并不会马上将该页从 old 区域升级到 young 区域,<br>因为还要 <b><font color="#0000ff">进行停留在 old 区域的时间判断</font></b>:</li></ul> · 如果第二次的访问时间与第一次访问的时间 <b><font color="#0000ff">在 1 秒内</font></b>(默认值),那么该页就 <b><font color="#0000ff">不会 </font></b>被从 old 区域升级到 young 区域;<br> · 如果第二次的访问时间与第一次访问的时间 <b><font color="#0000ff">超过 1 秒</font></b>,那么该页就 <b><font color="#0000ff">会</font></b> 从 old 区域升级到 young 区域;
通过提高了进入 active list (或者 young 区域)的门槛后,就很好了避免缓存污染带来的影响。
深入理解 Linux 虚拟内存管理(硬核)
深入理解 Linux 物理内存管理(硬核)
进程管理
进程、线程基础知识
介绍
先来看看一则小故事<br>
我们写好的一行行代码,为了让其工作起来,我们还得把它送进城(<b><font color="#0000ff">进程</font></b>)里,那既然进了城里,那肯定不能胡作非为了。
城里人有城里人的规矩,城中有个专门管辖你们的城管(<b><font color="#0000ff">操作系统</font></b>),人家让你休息就休息,<br>让你工作就工作,毕竟摊位不多,每个人都要占这个摊位来工作,城里要工作的人多着去了。
所以城管为了公平起见,它使用一种策略(<b><font color="#0000ff">调度</font></b>)方式,给每个人一个固定的工作时间(<b><font color="#0000ff">时间片</font></b>),<br>时间到了就会通知你去休息而换另外一个人上场工作。
另外,在休息时候你也不能偷懒,要记住工作到哪了,不然下次到你工作了,你忘记工作到哪了,那还怎么继续?
有的人,可能还进入了县城(<b><font color="#0000ff">线程</font></b>)工作,这里相对轻松一些,在休息的时候,要记住的东西相对较少,而且还能共享城里的资源。
以上故事讲的就是线程与进程。进程和线程对于写代码的我们,真的天天见、日日见了,但见的多不代表你就熟悉它们。
进程
介绍
我们编写的代码只是一个存储在硬盘的静态文件,通过编译后就会生成二进制可执行文件,当我们运行这个可执行文件后,<br>它会被装载到内存中,接着 CPU 会执行程序中的每一条指令,那么这个 <b><font color="#0000ff">运行中的程序,就被称为「进程」(Process)</font></b>。
现在我们考虑有一个会读取硬盘文件数据的程序被执行了,那么当运行到读取文件的指令时,就会去从硬盘读取数据,<br>但是硬盘的读写速度是非常慢的,那么在这个时候,如果 CPU 傻傻的等硬盘返回数据的话,那 CPU 的利用率是非常低的。
做个类比,你去煮开水时,你会傻傻的等水壶烧开吗?很明显,小孩也不会傻等。我们可以在水壶烧开之前<br>去做其他事情。当水壶烧开了,我们自然就会听到“嘀嘀嘀”的声音,于是再把烧开的水倒入到水杯里就好了。
所以,当进程要从硬盘读取数据时,CPU 不需要阻塞等待数据的返回,而是去执行另外的进程。<br>当硬盘数据返回时,CPU 会收到个 <b><font color="#0000ff">中断</font></b>,于是 CPU 再继续运行这个进程。
这种 <b><font color="#0000ff">多个程序、交替执行 </font></b>的思想,就有 CPU 管理多个进程的初步想法。
对于一个支持多进程的系统,CPU 会从一个进程快速切换至另一个进程,其间每个进程各运行几十或几百个毫秒。
虽然单核的 CPU 在某一个瞬间,只能运行一个进程。但在 1 秒钟期间,<br>它可能会运行多个进程,这样就产生 <b><font color="#0000ff">并行的错觉</font></b>,实际上这是 <b><font color="#0000ff">并发</font></b>。
<b><font color="#1b5e20">并发和并行有什么区别?</font></b><br>
一图胜千言。<br>
<b><font color="#1b5e20">进程与程序的关系的类比</font></b><br>
到了晚饭时间,一对小情侣肚子都咕咕叫了,于是男生见机行事,就想给女生做晚饭,所以他就<br>在网上找了辣子鸡的菜谱,接着买了一些鸡肉、辣椒、香料等材料,然后边看边学边做这道菜。
突然,女生说她想喝可乐,那么男生只好把做菜的事情暂停一下,并在手机菜谱标记做到哪一个步骤,把状态信息记录了下来。
然后男生听从女生的指令,跑去下楼买了一瓶冰可乐后,又回到厨房继续做菜。
这体现了,<b><font color="#0000ff">CPU 可以从一个进程(做菜)切换到另外一个进程(买可乐),在切换<br>前必须要记录当前进程中运行的状态信息,以备下次切换回来的时候可以恢复执行。</font></b>
所以,可以发现进程有着「<b><font color="#0000ff">运行 - 暂停 - 运行</font></b>」的活动规律。<br>
进程的状态
在上面,我们知道了进程有着「运行 - 暂停 - 运行」的活动规律。一般说来,一个进程<br>并不是自始至终连续不停地运行的,它与并发执行中的其他进程的执行是相互制约的。
它有时处于运行状态,有时又由于某种原因而暂停运行处于等待状态,当使它暂停的原因消失后,它又进入准备运行状态。
所以,在一个进程的 <b><font color="#0000ff">活动期间 </font></b>至少具备三种基本状态,即 <b style="color: rgb(0, 0, 255);">运行状态、就绪状态、阻塞状态</b><font color="#212121">:<br></font><br><ul><li><b><font color="#0000ff">运行状态(Running)</font></b>:该时刻进程占用 CPU;</li><li><b><font color="#0000ff">就绪状态(Ready)</font></b>:可运行,由于其他进程处于运行状态而暂时停止运行;</li><li><b><font color="#0000ff">阻塞状态(Blocked)</font></b>:该进程正在等待某一事件发生(如等待输入/输出操作的完成)<br>而暂时停止运行,这时,即使给它CPU控制权,它也无法运行;</li></ul>
当然,进程还有 <b><font color="#0000ff">另外两个基本状态</font></b>:<br><br><ul><li><b><font color="#0000ff">创建状态(new)</font></b>:进程正在被创建时的状态;</li><li><b><font color="#0000ff">结束状态(Exit)</font></b>:进程正在从系统中消失时的状态;</li></ul>
于是,一个完整的进程状态的变迁如下图:
再来详细说明一下进程的状态变迁:<br><br><ul><li><font color="#ff00ff"><i style="">NULL -> 创建状态</i></font>:一个新进程被创建时的第一个状态;</li><li><font color="#ff00ff"><i>创建状态 -> 就绪状态</i></font>:当进程被创建完成并初始化后,一切就绪准备运行时,变为就绪状态,这个过程是很快的;</li><li><i><font color="#ff00ff">就绪态 -> 运行状态</font></i>:处于就绪状态的进程被操作系统的进程调度器选中后,就分配给 CPU 正式运行该进程;</li><li><i><font color="#ff00ff">运行状态 -> 结束状态</font></i>:当进程已经运行完成或出错时,会被操作系统作结束状态处理;</li><li><i><font color="#ff00ff">运行状态 -> 就绪状态</font></i>:处于运行状态的进程在运行过程中,由于分配给它的运行时间片用完,<br>操作系统会把该进程变为就绪态,接着从就绪态选中另外一个进程运行;</li><li><font color="#ff00ff" style="color: rgb(255, 0, 255); font-size: inherit;"><i>运行状态 -> 阻塞状态</i></font><span style="color: rgb(255, 0, 255); font-size: inherit;">:</span><span style="font-size: inherit;"><font color="#212121">当进程请求某个事件且必须等待时,例如请求 I/O 事件;</font></span></li><li><i><font color="#ff00ff">阻塞状态 -> 就绪状态</font></i>:当进程要等待的事件完成时,它从阻塞状态变到就绪状态;</li></ul>
如果有大量处于阻塞状态的进程,进程可能会占用着物理内存空间,显然不是我们所希望的,<br>毕竟物理内存空间是有限的,被阻塞状态的进程占用着物理内存就一种浪费物理内存的行为。
所以,在虚拟内存管理的操作系统中,通常会把阻塞状态的进程的物理内存空间换出到硬盘,<br>等需要再次运行的时候,再从硬盘换入到物理内存。
那么,就需要一个新的状态,<b><font color="#0000ff">来描述进程没有占用实际的物理内存空间的情况,这个状态就是挂起状态</font></b>。<br>这跟阻塞状态是不一样,阻塞状态是等待某个事件的返回。
另外,挂起状态可以分为两种:<br><br><ul><li>阻塞挂起状态:进程在外存(硬盘)并等待某个事件的出现;</li><li>就绪挂起状态:进程在外存(硬盘),但只要进入内存,即刻立刻运行;</li></ul>
这两种挂起状态加上前面的五种状态,就变成了七种状态变迁(留给我的颜色不多了),见如下图:
导致进程挂起的原因不只是因为进程所使用的内存空间不在物理内存,还包括如下情况:<br><br><ul><li>通过 sleep 让进程间歇性挂起,其工作原理是设置一个定时器,到期后唤醒进程。</li><li>用户希望挂起一个程序的执行,比如在 Linux 中用 Ctrl+Z 挂起进程;</li></ul>
进程的控制结构
在操作系统中,是用 <b><font color="#0000ff">进程控制块(process control block,PCB)</font></b>数据结构来描述进程的。
<b><font color="#0000ff">PCB 是进程存在的唯一标识</font></b>,这意味着一个进程的存在,必然会有一个 PCB,如果进程消失了,那么 PCB 也会随之消失。
<b><font color="#1b5e20">PCB 具体包含什么信息呢?</font></b>
<b><font color="#0000ff">进程描述信息</font></b>:<br><ul><li>进程标识符:标识各个进程,每个进程都有一个并且唯一的标识符;</li><li>用户标识符:进程归属的用户,用户标识符主要为共享和保护服务;</li></ul>
<b><font color="#0000ff">进程控制和管理信息</font></b>:<br><ul><li>进程当前状态,如 new、ready、running、waiting 或 blocked 等;</li><li>进程优先级:进程抢占 CPU 时的优先级;</li></ul>
<b><font color="#0000ff">资源分配清单</font></b>:<br><ul><li>有关内存地址空间或虚拟地址空间的信息,所打开文件的列表和所使用的 I/O 设备信息。</li></ul>
<b><font color="#0000ff">CPU 相关信息</font></b>:<br><ul><li>CPU 中各个寄存器的值,当进程被切换时,CPU 的状态信息都会被保存在相应的 PCB 中,<br>以便进程重新执行时,能从断点处继续执行。</li></ul>
可见,PCB 包含信息还是比较多的。<br>
<b><font color="#1b5e20">每个 PCB 是如何组织的呢?</font></b><br>
通常是通过<b><font color="#0000ff"> 链表</font></b> 的方式进行组织,把 <b><font color="#0000ff">具有相同状态的进程链在一起,组成各种队列</font></b>。比如:<br><br><ul><li>将所有处于就绪状态的进程链在一起,称为 <b><font color="#0000ff">就绪队列</font></b>;</li><li>把所有因等待某事件而处于等待状态的进程链在一起就组成各种 <b><font color="#0000ff">阻塞队列</font></b>;</li><li>另外,对于运行队列在单核 CPU 系统中则只有一个运行指针了,因为单核 CPU 在某个时间,只能运行一个程序。</li></ul>
那么,就绪队列和阻塞队列链表的组织形式如下图:<br>
除了链接的组织方式,还有索引方式。它的工作原理:将同一状态的进程组织在一个索引表中,索引表项指向相应的 PCB,不同状态对应不同的索引表。
一般会选择链表,因为可能面临进程创建,销毁等调度导致进程状态发生变化,所以链表能够更加灵活的插入和删除。
进程的控制
我们熟知了进程的状态变迁和进程的数据结构 PCB 后,再来看看进程的 <b><font color="#0000ff">创建、终止、阻塞、唤醒 </font></b>的过程,这些过程也就是进程的控制。
<b><font color="#0000ff">1、创建进程</font></b>
操作系统允许一个进程创建另一个进程,而且允许子进程继承父进程所拥有的资源。
创建进程的过程如下:<br><br><ul><li>申请一个空白的 PCB,并向 PCB 中填写一些控制和管理进程的信息,比如进程的唯一标识等;</li><li>为该进程分配运行时所必需的资源,比如内存资源;</li><li>将 PCB 插入到就绪队列,等待被调度运行;</li></ul>
<b><font color="#0000ff">2、终止进程</font></b>
进程可以有 3 种终止方式:正常结束、异常结束以及外界干预(信号 kill 掉)。<br>
当子进程被终止时,其在父进程处继承的资源应当还给父进程。而当父进程被终止时,该父进程<br>的子进程就变为孤儿进程,会被 1 号进程收养,并由 1 号进程对它们完成状态收集工作。
终止进程的过程如下:<br><br><ul><li>查找需要终止的进程的 PCB;</li><li>如果处于执行状态,则立即终止该进程的执行,然后将 CPU 资源分配给其他进程;</li><li>如果其还有子进程,则应将该进程的子进程交给 1 号进程接管;</li><li>将该进程所拥有的全部资源都归还给操作系统;</li><li>将其从 PCB 所在队列中删除;</li></ul>
<b><font color="#0000ff">3、阻塞进程</font></b>
当进程需要等待某一事件完成时,它可以调用阻塞语句把自己阻塞等待。而一旦被阻塞等待,它只能由另一个进程唤醒。
阻塞进程的过程如下:<br><br><ul><li>找到将要被阻塞进程标识号对应的 PCB;</li><li>如果该进程为运行状态,则保护其现场,将其状态转为阻塞状态,停止运行;</li><li>将该 PCB 插入到阻塞队列中去;</li></ul>
<b><font color="#0000ff">4、唤醒进程</font></b>
进程由「运行」转变为「阻塞」状态是由于进程必须等待某一事件的完成,所以处于阻塞状态的进程是绝对不可能叫醒自己的。
如果某进程正在等待 I/O 事件,需由别的进程发消息给它,则只有当该进程所期待的事件出现时,才由发现者进程用唤醒语句叫醒它。
唤醒进程的过程如下:<br><br><ul><li>在该事件的阻塞队列中找到相应进程的 PCB;</li><li>将其从阻塞队列中移出,并置其状态为就绪状态;</li><li>把该 PCB 插入到就绪队列中,等待调度程序调度;</li></ul>
进程的阻塞和唤醒是一对功能相反的语句,如果某个进程调用了阻塞语句,则必有一个与之对应的唤醒语句。<br>
进程的上下文切换
各个进程之间是共享 CPU 资源的,在不同的时候进程之间需要切换,让不同的进程<br>可以在 CPU 执行,<b><font color="#0000ff">那么这个一个进程切换到另一个进程运行,称为进程的上下文切换</font></b>。
<b><font color="#1b5e20">在详细说进程上下文切换前,我们先来看看 CPU 上下文切换</font></b>
大多数操作系统都是多任务,通常支持大于 CPU 数量的任务同时运行。实际上,这些任务并不是同时<br>运行的,只是因为系统在很短的时间内,让各个任务分别在 CPU 运行,于是就造成同时运行的错觉。
任务是交给 CPU 运行的,那么在每个任务运行前,CPU 需要知道任务从哪里加载,又从哪里开始运行。
所以,操作系统需要事先帮 CPU 设置好 <b><font color="#0000ff">CPU 寄存器和程序计数器</font></b>。
CPU 寄存器是 CPU 内部一个容量小,但是速度极快的内存(缓存)。
再来,程序计数器则是用来存储 CPU 正在执行的指令位置、或者即将执行的下一条指令位置。
所以说,CPU 寄存器和程序计数是 CPU 在运行任何任务前,所必须依赖的环境,这些 <b><font color="#0000ff">环境 </font></b>就叫做 <b><font color="#0000ff">CPU 上下文</font></b>。
既然知道了什么是 CPU 上下文,那理解 CPU 上下文切换就不难了。
CPU 上下文切换就是先把前一个任务的 CPU 上下文(CPU 寄存器和程序计数器)保存起来,然后加载<br>新任务的上下文到这些寄存器和程序计数器,最后再跳转到程序计数器所指的新位置,运行新任务。
系统内核会存储保持下来的上下文信息,当此任务再次被分配给 CPU 运行时,CPU 会重新<br>加载这些上下文,这样就能保证任务原来的状态不受影响,让任务看起来还是连续运行。
上面说到所谓的「任务」,主要包含进程、线程和中断。所以,可以根据任务的不同,<br>把 CPU 上下文切换分成:<b><font color="#0000ff">进程上下文切换、线程上下文切换和中断上下文切换</font></b>。
<b><font color="#1b5e20">进程的上下文切换到底是切换什么呢?</font></b>
进程是由内核管理和调度的,所以进程的切换只能发生在内核态。
所以,<b><font color="#0000ff">进程的上下文切换不仅包含了虚拟内存、栈、全局变量等用户空间的资源,还包括了内核堆栈、寄存器等内核空间的资源</font></b>。
通常,会把交换的信息保存在进程的 PCB,当要运行另外一个进程的时候,我们需要从这个<br>进程的 PCB 取出上下文,然后恢复到 CPU 中,这使得这个进程可以继续执行,如下图所示:
大家需要注意,进程的上下文开销是很关键的,我们希望它的开销越小越好,<br>这样可以使得进程可以把更多时间花费在执行程序上,而不是耗费在上下文切换。
<b><font color="#1b5e20">发生进程上下文切换有哪些场景?</font></b>
<ul><li>为了保证所有进程可以得到公平调度,CPU 时间被划分为一段段的时间片,这些时间片再被轮流分配给各个进程。<br>这样,当某个进程的时间片耗尽了,进程就从运行状态变为就绪状态,系统从就绪队列选择另外一个进程运行;<br><br></li><li>进程在系统资源不足(比如内存不足)时,要等到资源满足后才可以运行,这个时候进程也会被挂起,并由系统调度其他进程运行;<br><br></li><li>当进程通过睡眠函数 sleep 这样的方法将自己主动挂起时,自然也会重新调度;<br><br></li><li>当有优先级更高的进程运行时,为了保证高优先级进程的运行,当前进程会被挂起,由高优先级进程来运行;<br><br></li><li>发生硬件中断时,CPU 上的进程会被中断挂起,转而执行内核中的中断服务程序;</li></ul>
线程
在早期的操作系统中都是以进程作为独立运行的基本单位,直到后面,<br>计算机科学家们又提出了更小的能独立运行的基本单位,也就是 <b><font color="#0000ff">线程</font></b>。
为什么使用线程?
我们举个例子,假设你要编写一个视频播放器软件,那么该软件功能的核心模块有三个:<br><br><ul><li>从视频文件当中读取数据;</li><li>对读取的数据进行解压缩;</li><li>把解压缩后的视频数据播放出来;</li></ul>
对于单进程的实现方式,我想大家都会是以下这个方式:
对于单进程的这种方式,存在以下问题:<br><br><ul><li>播放出来的画面和声音会不连贯,因为当 CPU 能力不够强的时候,Read <br>的时候可能进程就等在这了,这样就会导致等半天才进行数据解压和播放;</li><li>各个函数之间不是并发执行,影响资源的使用效率;</li></ul>
那改进成多进程的方式:
对于多进程的这种方式,依然会存在问题:<br><br><ul><li>进程之间如何通信,共享数据?</li><li>维护进程的系统开销较大,如创建进程时,分配资源、建立 PCB;<br>终止进程时,回收资源、撤销 PCB;进程切换时,保存当前进程的状态信息;</li></ul>
那到底如何解决呢?需要有一种新的实体,满足以下特性:<br><br><ul><li>实体之间可以并发运行;</li><li>实体之间共享相同的地址空间;</li></ul>
这个新的实体,就是 <b><font color="#0000ff">线程(Thread)</font></b>,线程之间可以并发运行且共享相同的地址空间。<br>
什么是线程?
<b><font color="#0000ff">线程是进程当中的一条执行流程。</font></b><br>
同一个进程内多个线程之间可以共享代码段、数据段、打开的文件等资源,但每个<br>线程各自都有一套独立的寄存器和栈,这样可以确保线程的控制流是相对独立的。
<b><font color="#1b5e20">线程的优缺点?</font></b>
线程的优点:<br><br><ul><li>一个进程中可以同时存在多个线程;</li><li>各个线程之间可以并发执行;</li><li>各个线程之间可以共享地址空间和文件等资源;</li></ul>
线程的缺点:<br><br><ul><li>当进程中的一个线程崩溃时,会导致其所属进程的所有线程崩溃(这里是针对 C/C++ 语言,Java <br>语言中的线程奔溃不会造成进程崩溃,具体分析原因可以看这篇:线程崩溃了,进程也会崩溃吗?)。</li></ul>
举个例子,对于游戏的用户设计,则不应该使用多线程的方式,否则一个用户挂了,会影响其他同个进程的线程。
线程与进程的比较
线程与进程的比较如下:<br><br><ul><li>进程是资源(包括内存、打开的文件等)分配的单位,而线程是 CPU 调度的单位;</li><li>进程拥有一个完整的资源平台,而线程只独享必不可少的资源,如寄存器和栈;</li><li>线程同样具有就绪、阻塞、执行三种基本状态,同样具有状态之间的转换关系;</li><li>线程能减少并发执行的时间和空间开销;</li></ul>
对于,线程相比进程能减少开销,体现在:<br><br><ul><li>线程的创建时间比进程快,因为进程在创建的过程中,还需要资源管理信息,比如内存管理信息、<br>文件管理信息,而线程在创建的过程中,不会涉及这些资源管理信息,而是共享它们;</li><li>线程的终止时间比进程快,因为线程释放的资源相比进程少很多;</li><li>同一个进程内的线程切换比进程切换快,因为线程具有相同的地址空间(虚拟内存共享),这意味着同一个进程的线程都具有同一个<br>页表,那么在切换的时候不需要切换页表。而对于进程之间的切换,切换的时候要把页表给切换掉,而页表的切换过程开销是比较大的;</li><li>由于同一进程的各线程间共享内存和文件资源,那么在线程之间数据传递的时候,就不需要经过内核了,这就使得线程之间的数据交互效率更高了;</li></ul>
所以,不管是时间效率,还是空间效率线程比进程都要高。<br>
线程的上下文切换
在前面我们知道了,线程与进程最大的区别在于:<b><font color="#0000ff">线程是调度的基本单位,而进程则是资源拥有的基本单位。</font></b>
所以,所谓操作系统的任务调度,实际上的调度对象是线程,而进程只是给线程提供了虚拟内存、全局变量等资源。
对于线程和进程,我们可以这么理解:<br><br><ul><li>当进程只有一个线程时,可以认为进程就等于线程;</li><li>当进程拥有多个线程时,这些线程会共享相同的虚拟内存和全局变量等资源,这些资源在上下文切换时是不需要修改的;</li></ul>
另外,线程也有自己的私有数据,比如栈和寄存器等,这些在上下文切换时也是需要保存的。<br>
<b><font color="#1b5e20">线程上下文切换的是什么?</font></b><br>
这还得看线程是不是属于同一个进程:<br><br><ul><li>当两个线程不是属于同一个进程,则切换的过程就跟进程上下文切换一样;</li><li>当两个线程是属于同一个进程,因为虚拟内存是共享的,所以在切换时,虚拟<br>内存这些资源就保持不动,<b><font color="#0000ff">只需要切换线程的私有数据、寄存器等不共享的数据</font></b>;</li></ul>
所以,线程的上下文切换相比进程,开销要小很多。<br>
线程的实现
主要有三种线程的实现方式:<br><br><ul><li><b><font color="#0000ff">用户线程(User Thread)</font></b>:在用户空间实现的线程,不是由内核管理的线程,是由用户态的线程库来完成线程的管理;</li><li><b><font color="#0000ff">内核线程(Kernel Thread)</font></b>:在内核中实现的线程,是由内核管理的线程;</li><li><b><font color="#0000ff">轻量级进程(LightWeight Process)</font></b>:在内核中来支持用户线程;</li></ul>
那么,这还需要考虑一个问题,用户线程和内核线程的对应关系。<br>
首先,第一种关系是多对一的关系,也就是多个用户线程对应同一个内核线程:
第二种是一对一的关系,也就是一个用户线程对应一个内核线程:
第三种是多对多的关系,也就是多个用户线程对应到多个内核线程:
<b><font color="#1b5e20">用户线程如何理解?存在什么优势和缺陷?</font></b>
用户线程是基于用户态的线程管理库来实现的,那么 <b><font color="#0000ff">线程控制块(Thread Control Block, TCB)</font></b> <br>也是在库里面来实现的,对于操作系统而言是看不到这个 TCB 的,它只能看到整个进程的 PCB。
所以,<b><font color="#0000ff">用户线程的整个线程管理和调度,操作系统是不直接参与的,而是由<br>用户级线程库函数来完成线程的管理,包括线程的创建、终止、同步和调度等</font></b>。
用户级线程的模型,也就类似前面提到的 <b><font color="#0000ff">多对一</font></b> 的关系,即多个用户线程对应同一个内核线程,如下图所示:
用户线程的 <b><font color="#0000ff">优点</font></b>:<br><br><ul><li>每个进程都需要有它私有的线程控制块(TCB)列表,用来跟踪记录它各个线程状态信息(PC、栈指针、寄存器),<br>TCB 由用户级线程库函数来维护,可用于不支持线程技术的操作系统;</li><li>用户线程的切换也是由线程库函数来完成的,无需用户态与内核态的切换,所以速度特别快;</li></ul>
用户线程的 <b><font color="#0000ff">缺点</font></b>:<br><br><ul><li>由于操作系统不参与线程的调度,如果一个线程发起了系统调用而阻塞,那进程所包含的用户线程都不能执行了。</li><li>当一个线程开始运行后,除非它主动地交出 CPU 的使用权,否则它所在的进程当中的其他线程无法运行,因为用户<br>态的线程没法打断当前运行中的线程,它没有这个特权,只有操作系统才有,但是用户线程不是由操作系统管理的。</li><li>由于时间片分配给进程,故与其他进程比,在多线程执行时,每个线程得到的时间片较少,执行会比较慢;</li></ul>
<b><font color="#1b5e20">那内核线程如何理解?存在什么优势和缺陷?</font></b>
<b><font color="#0000ff">内核线程是由操作系统管理的,线程对应的 TCB 自然是放在操作系统里的,这样线程的创建、终止和管理都是由操作系统负责。</font></b>
内核线程的模型,也就类似前面提到的 <b><font color="#0000ff">一对一</font></b> 的关系,即一个用户线程对应一个内核线程,如下图所示:<br>
内核线程的 <b><font color="#0000ff">优点</font></b>:<br><br><ul><li>在一个进程当中,如果某个内核线程发起系统调用而被阻塞,并不会影响其他内核线程的运行;</li><li>分配给线程,多线程的进程获得更多的 CPU 运行时间;</li></ul>
内核线程的 <b><font color="#0000ff">缺点</font></b>:<br><br><ul><li>在支持内核线程的操作系统中,由内核来维护进程和线程的上下文信息,如 PCB 和 TCB;</li><li>线程的创建、终止和切换都是通过系统调用的方式来进行,因此对于系统来说,系统开销比较大;</li></ul>
<b><font color="#1b5e20">最后的轻量级进程如何理解?</font></b>
<b><font color="#0000ff">轻量级进程(Light-weight process,LWP)是内核支持的用户线程,一个进程可有一个或多个 LWP,每个 LWP 是<br>跟内核线程一对一映射的,也就是 LWP 都是由一个内核线程支持,而且 LWP 是由内核管理并像普通进程一样被调度。</font></b>
在大多数系统中,<b><font color="#0000ff">LWP与普通进程的区别也在于它只有一个最小的执行上下文和调度程序所需的统计信息</font></b>。一般来说,一个进程代表<br>程序的一个实例,而 LWP 代表程序的执行线程,因为一个执行线程不像进程那样需要那么多状态信息,所以 LWP 也不带有这样的信息。
在 LWP 之上也是可以使用用户线程的,那么 LWP 与用户线程的对应关系就有三种:<br><br><ul><li>1 : 1,即一个 LWP 对应 一个用户线程;</li><li>N : 1,即一个 LWP 对应多个用户线程;</li><li>M : N,即多个 LWP 对应多个用户线程;</li></ul>
接下来针对上面这三种对应关系说明它们优缺点。先看下图的 LWP 模型:
<b><font color="#0000ff">1 : 1 模式</font></b><br>
一个线程对应到一个 LWP 再对应到一个内核线程,如上图的进程 4,属于此模型。
<ul><li>优点:实现并行,当一个 LWP 阻塞,不会影响其他 LWP;</li><li>缺点:每一个用户线程,就产生一个内核线程,创建线程的开销较大。</li></ul>
<b><font color="#0000ff">N : 1 模式</font></b><br>
多个用户线程对应一个 LWP 再对应一个内核线程,如上图的进程 2,线程管理是在用户空间完成的,此模式中用户的线程对操作系统不可见。
<ul><li>优点:用户线程要开几个都没问题,且上下文切换发生用户空间,切换的效率较高;</li><li>缺点:一个用户线程如果阻塞了,则整个进程都将会阻塞,另外在多核 CPU 中,是没办法充分利用 CPU 的。</li></ul>
<b><font color="#0000ff">M : N 模式</font></b><br>
根据前面的两个模型混搭一起,就形成 M:N 模型,该模型提供了两级控制,首先<br>多个用户线程对应到多个 LWP,LWP 再一一对应到内核线程,如上图的进程 3。
优点:综合了前两种优点,大部分的线程上下文发生在用户空间,且多个线程又可以充分利用多核 CPU 的资源。
<b><font color="#0000ff">组合模式</font></b><br>
如上图的进程 5,此进程结合 1:1 模型和 M:N 模型。开发人员可以针对不同的应用特点调节内核线程的数目来达到物理并行性和逻辑并行性的最佳方案。<br>
调度
介绍
进程都希望自己能够占用 CPU 进行工作,那么这涉及到前面说过的进程上下文切换。
一旦操作系统把进程切换到运行状态,也就意味着该进程占用着 CPU 在执行,但是当操作系统<br>把进程切换到其他状态时,那就不能在 CPU 中执行了,于是操作系统会选择下一个要运行的进程。<br>
选择一个进程运行这一功能是在操作系统中完成的,通常称为 <b><font color="#0000ff">调度程序(scheduler)</font></b>。
那到底什么时候调度进程,或以什么原则来调度进程呢?<br>
调度时机
在进程的生命周期中,当进程从一个运行状态到另外一状态变化或者从某一状态转变到运行态的时候,其实会触发一次调度。
比如,以下状态的变化都会触发操作系统的调度:<br><br><ul><li><b><font color="#0000ff">就绪态 -> 运行态</font></b>:当进程被创建时,会进入到就绪队列,操作系统会从就绪队列选择一个进程运行;</li><li><b><font color="#0000ff">运行态 -> 阻塞态</font></b>:当进程发生 I/O 事件而阻塞时,操作系统必须选择另外一个进程运行;</li><li><b><font color="#0000ff">运行态 -> 结束态</font></b>:当进程退出结束后,操作系统得从就绪队列选择另外一个进程运行;</li></ul>
因为,这些状态变化的时候,<b><font color="#0000ff">操作系统需要考虑是否要让新的进程给 CPU 运行,或者是否让当前进程从 CPU 上退出来而换另一个进程运行</font></b>。
另外,如果硬件时钟提供某个频率的周期性中断,那么可以根据如何处理时钟中断 ,把调度算法分为两类:<br><br><ul><li><b><font color="#0000ff">非抢占式调度算法 </font></b>挑选一个进程,然后让该进程运行直到被阻塞,或者直到该进程退出,才会调用另外一个进程,也就是说不会理时钟中断这个事情。</li><li><b><font color="#0000ff">抢占式调度算法 </font></b>挑选一个进程,然后让该进程只运行某段时间,如果在该时段结束时,该进程仍然在运行时,则会把它挂起,接着调度程序从就绪队列<br>挑选另外一个进程。这种抢占式调度处理,需要在时间间隔的末端发生时钟中断,以便把 CPU 控制返回给调度程序进行调度,也就是常说的时间片机制。</li></ul>
调度原则
<i><font color="#ff00ff">原则一</font></i>:如果运行的程序,发生了 I/O 事件的请求,那 CPU 使用率必然会很低,因为此时进程在阻塞等待硬盘的数据返回。这样的过程,势必会造成 <br>CPU 突然的空闲。所以,<b><font color="#0000ff">为了提高 CPU 利用率,在这种发送 I/O 事件致使 CPU 空闲的情况下,调度程序需要从就绪队列中选择一个进程来运行</font></b>。
<i><font color="#ff00ff">原则二</font></i>:有的程序执行某个任务花费的时间会比较长,如果这个程序一直占用着 CPU,会造成系统吞吐量(CPU 在单位时间内完成的进程数量)<br>的降低。所以,<b><font color="#0000ff">要提高系统的吞吐率,调度程序要权衡长任务和短任务进程的运行完成数量</font></b>。
<i><font color="#ff00ff">原则三</font></i>:从进程开始到结束的过程中,实际上是包含两个时间,分别是进程运行时间和进程等待时间,这两个时间总和就称为周转时间。进程<br>的周转时间越小越好,<b><font color="#0000ff">如果进程的等待时间很长而运行时间很短,那周转时间就很长,这不是我们所期望的,调度程序应该避免这种情况发生</font></b>。
<i><font color="#ff00ff">原则四</font></i>:处于就绪队列的进程,也不能等太久,当然希望这个等待的时间越短越好,这样可以使得<br>进程更快的在 CPU 中执行。所以,<b><font color="#0000ff">就绪队列中进程的等待时间也是调度程序所需要考虑的原则</font></b>。
<i><font color="#ff00ff">原则五</font></i>:对于鼠标、键盘这种交互式比较强的应用,我们当然希望它的响应时间越快越好,否则<br>就会影响用户体验了。所以,<b><font color="#0000ff">对于交互式比较强的应用,响应时间也是调度程序需要考虑的原则</font></b>。
针对上面的五种调度原则,总结成如下:<br><br><ul><li><b><font color="#0000ff">CPU 利用率</font></b>:调度程序应确保 CPU 是始终匆忙的状态,这可提高 CPU 的利用率;</li><li><b><font color="#0000ff">系统吞吐量</font></b>:吞吐量表示的是单位时间内 CPU 完成进程的数量,长作业的进程会<br>占用较长的 CPU 资源,因此会降低吞吐量,相反,短作业的进程会提升系统吞吐量;</li><li><b><font color="#0000ff">周转时间</font></b>:周转时间是进程运行+阻塞时间+等待时间的总和,一个进程的周转时间越小越好;</li><li><b><font color="#0000ff">等待时间</font></b>:这个等待时间不是阻塞状态的时间,而是进程处于就绪队列的时间,等待的时间越长,用户越不满意;</li><li><b><font color="#0000ff">响应时间</font></b>:用户提交请求到系统第一次产生响应所花费的时间,在交互式系统中,响应时间是衡量调度算法好坏的主要标准。</li></ul>
说白了,这么多调度原则,目的就是要使得进程要「快」。<br>
调度算法
进程间有哪些通信方式?
介绍
每个进程的用户地址空间都是独立的,一般而言是不能互相访问的,但内核空间是每个进程都共享的,所以进程之间要通信必须通过内核。
Linux 内核提供了不少进程间通信的机制,我们来一起瞧瞧有哪些?<br>
管道
如果你学过 Linux 命令,那你肯定很熟悉「 | 」这个竖线。<br>
上面命令行里的「 | 」竖线就是一个管道,它的功能是将前一个命令(ps auxf)的输出,作为后一个命令(grep mysql)<br>的输入,从这功能描述,可以看出管道传输数据是单向的,如果想相互通信,我们需要创建两个管道才行。
同时,我们得知上面这种管道是没有名字,所以「 | 」表示的管道称为 <b><font color="#0000ff">匿名管道</font></b>,<b><font color="#0000ff">用完了就销毁</font></b>。
管道还有另外一个类型是 <b><font color="#0000ff">命名管道</font></b>,也被叫做 FIFO,因为数据是 <b><font color="#0000ff">先进先出的传输方式</font></b>。
在使用命名管道前,先需要通过 mkfifo 命令来创建,并且指定管道名字:<br>
myPipe 就是这个管道的名称,基于 Linux 一切皆文件的理念,所以管道也是以文件的方式存在,<br>我们可以用 ls 看一下,这个文件的类型是 p,也就是 pipe(管道) 的意思:
接下来,我们往 myPipe 这个管道写入数据:
你操作了后,你会发现命令执行后就停在这了,这是因为管道里的内容没有被读取,只有当管道里的数据被读完后,命令才可以正常退出。
于是,我们执行另外一个命令来读取这个管道里的数据:<br>
可以看到,管道里的内容被读取出来了,并打印在了终端上,另外一方面,echo 那个命令也正常退出了。
我们可以看出,<b><font color="#0000ff">管道这种通信方式效率低,不适合进程间频繁地交换数据</font></b>。当然,它的<br>好处,自然就是简单,同时也我们很容易得知管道里的数据已经被另一个进程读取了。
<b><font color="#1b5e20">那管道如何创建呢,背后原理是什么?</font></b>
匿名管道的创建,需要通过下面这个系统调用:<br>
这里表示创建一个匿名管道,并返回了两个描述符,一个是管道的读取端描述符 fd[0],另一个是管道的写入端描述符 fd[1]。<br>注意,这个匿名管道是特殊的文件,只存在于内存,不存于文件系统中。
其实,<b><font color="#0000ff">所谓的管道,就是内核里面的一串缓存</font></b>。从管道的一段写入的数据,实际上是缓存在内核中的,<br>另一端读取,也就是从内核中读取这段数据。另外,管道传输的数据是无格式的流且大小受限。
看到这,你可能会有疑问了,这两个描述符都是在一个进程里面,并没有起到进程间通信的作用,怎么样才能使得管道是跨过两个进程的呢?
我们可以使用 fork 创建子进程,创建的子进程会复制父进程的文件描述符,这样就做到了两个进程各有<br>两个「 fd[0] 与 fd[1]」,两个进程就可以通过各自的 fd 写入和读取 <b><font color="#0000ff">同一个管道文件 </font></b>实现跨进程通信了。
管道只能一端写入,另一端读出,所以上面这种模式容易造成混乱,因为父进程和<br>子进程都可以同时写入,也都可以读出。那么,为了避免这种情况,通常的做法是:<br><br><ul><li>父进程关闭读取的 fd[0],只保留写入的 fd[1];</li><li>子进程关闭写入的 fd[1],只保留读取的 fd[0];</li></ul>
所以说如果需要双向通信,则应该创建两个管道。
到这里,我们仅仅解析了使用管道进行父进程与子进程之间的通信,但是在我们 shell 里面并不是这样的。
在 shell 里面执行 A | B命令的时候,A 进程和 B 进程都是 shell 创建出来的子进程,<br>A 和 B 之间不存在父子关系,它俩的父进程都是 shell。
所以说,在 shell 里通过「 | 」匿名管道将多个命令连接在一起,实际上也就是创建了多个子进程,那么在我们<br>编写 shell 脚本时,能使用一个管道搞定的事情,就不要多用一个管道,这样可以减少创建子进程的系统开销。
我们可以得知,<b><font color="#0000ff">对于匿名管道,它的通信范围是存在父子关系的进程</font></b>。因为管道没有实体,<br>也就是没有管道文件,只能通过 fork 来复制父进程 fd 文件描述符,来达到通信的目的。
另外,<b><font color="#0000ff">对于命名管道,它可以在不相关的进程间也能相互通信</font></b>。因为命令管道,提前创建<br>了一个类型为管道的设备文件,在进程里只要使用这个设备文件,就可以相互通信。
不管是匿名管道还是命名管道,进程写入的数据都是缓存在内核中,另一个进程读取数据时候<br>自然也是从内核中获取,同时通信数据都遵循 <b><font color="#0000ff">先进先出原则</font></b>,不支持 lseek 之类的文件定位操作。<br>
消息队列
前面说到管道的通信方式是效率低的,因此管道不适合进程间频繁地交换数据。
对于这个问题,<b><font color="#0000ff">消息队列</font></b> 的通信模式就可以解决。比如,A 进程要给 B 进程发送消息,A 进程把数据放在对应的消息<br>队列后就可以正常返回了,B 进程需要的时候再去读取数据就可以了。同理,B 进程要给 A 进程发送消息也是如此。
再来,<b><font color="#0000ff">消息队列是保存在内核中的消息链表</font></b>,在发送数据时,会分成一个一个独立的数据单元,也就是消息体(数据块),<br>消息体是用户自定义的数据类型,消息的发送方和接收方要约定好消息体的数据类型,所以每个消息体都是固定大小的<br>存储块,不像管道是无格式的字节流数据。如果进程从消息队列中读取了消息体,内核就会把这个消息体删除。
消息队列生命周期随内核,如果没有释放消息队列或者没有关闭操作系统,消息队列会一直存在,<br>而前面提到的匿名管道的生命周期,是随进程的创建而建立,随进程的结束而销毁。
消息这种模型,两个进程之间的通信就像平时发邮件一样,你来一封,我回一封,可以频繁沟通了。
但邮件的通信方式存在不足的地方有两点,<b><font color="#0000ff">一是通信不及时,二是附件也有大小限制</font></b>,这同样 <b><font color="#0000ff">也是消息队列通信不足的点</font></b>。
<b><font color="#0000ff">消息队列不适合比较大数据的传输</font></b>,因为在内核中每个消息体都有一个最大长度的限制,同时所有队列所包含的全部消息体的总长度也是有上限。<br>在 Linux 内核中,会有两个宏定义 MSGMAX 和 MSGMNB,它们以字节为单位,分别定义了一条消息的最大长度和一个队列的最大长度。
<b><font color="#0000ff">消息队列通信过程中,存在用户态与内核态之间的数据拷贝开销</font></b>,因为进程写入数据到内核中的消息队列时,会发生从<br>用户态拷贝数据到内核态的过程,同理另一进程读取内核中的消息数据时,会发生从内核态拷贝数据到用户态的过程。<br>
共享内存
消息队列的读取和写入的过程,都会有发生用户态与内核态之间的消息拷贝过程。那 <b><font color="#0000ff">共享内存 </font></b>的方式,就很好的解决了这一问题。
现代操作系统,对于内存管理,采用的是虚拟内存技术,也就是每个进程都有自己独立的虚拟内存空间,不同进程的虚拟内存映射到不同<br>的物理内存中。所以,即使进程 A 和 进程 B 的虚拟地址是一样的,其实访问的是不同的物理内存地址,对于数据的增删查改互不影响。
<b><font color="#0000ff">共享内存的机制,就是拿出一块虚拟地址空间来,映射到相同的物理内存中</font></b>。这样这个进程写入的东西,<br>另外一个进程马上就能看到了,都不需要拷贝来拷贝去,传来传去,大大提高了进程间通信的速度。
信号量
用了共享内存通信方式,带来新的问题,那就是如果 <b><font color="#0000ff">多个进程同时修改同一个共享内存</font></b>,很有<br>可能就 <b><font color="#0000ff">冲突 </font></b>了。例如两个进程都同时写一个地址,那先写的那个进程会发现内容被别人覆盖了。
为了防止多进程竞争共享资源,而造成的数据错乱,所以需要保护机制,使得共享<br>的资源,在任意时刻只能被一个进程访问。正好,<b><font color="#0000ff">信号量 </font></b>就实现了这一保护机制。
<b><font color="#0000ff">信号量其实是一个整型的计数器,主要用于实现进程间的互斥与同步,而不是用于缓存进程间通信的数据。</font></b>
信号量表示资源的数量,控制信号量的方式有 <b><font color="#0000ff">两种原子操作</font></b>:<br><br><ul><li>一个是 <b><font color="#0000ff">P 操作</font></b>,这个操作会把信号量减去 1,相减后如果信号量 < 0,则表明资源已被占用,<br>进程需阻塞等待;相减后如果信号量 >= 0,则表明还有资源可使用,进程可正常继续执行。</li><li>另一个是 <b><font color="#0000ff">V 操作</font></b>,这个操作会把信号量加上 1,相加后如果信号量 <= 0,则表明当前有阻塞<br>中的进程,于是会将该进程唤醒运行;相加后如果信号量 > 0,则表明当前没有阻塞中的进程;</li></ul>
P 操作是用在进入共享资源之前,V 操作是用在离开共享资源之后,这两个操作是必须成对出现的。
接下来,举个例子,如果要使得两个进程互斥访问共享内存,我们可以初始化信号量为 1。
具体的过程如下:<br><br><ul><li>进程 A 在访问共享内存前,先执行了 P 操作,由于信号量的初始值为 1,故在进程 A 执行 P 操作后信号量<br>变为 0,表示共享资源可用,于是进程 A 就可以访问共享内存。</li><li>若此时,进程 B 也想访问共享内存,执行了 P 操作,结果信号量变为了 -1,这就意味着临界资源已被占用,因此进程 B 被阻塞。</li><li>直到进程 A 访问完共享内存,才会执行 V 操作,使得信号量恢复为 0,接着就会唤醒阻塞中的线程 B,<br>使得进程 B 可以访问共享内存,最后完成共享内存的访问后,执行 V 操作,使信号量恢复到初始值 1。</li></ul>
可以发现,<b><font color="#0000ff">信号初始化为 1</font></b>,就代表着是 <b><font color="#0000ff">互斥信号量</font></b>,它可以保证共享内存在任何时刻只有一个进程在访问,这就很好的保护了共享内存。
另外,在多进程里,每个进程并不一定是顺序执行的,它们基本是以各自独立的、不可预知<br>的速度向前推进,但有时候我们又希望多个进程能密切合作,以实现一个共同的任务。
例如,进程 A 是负责生产数据,而进程 B 是负责读取数据,这两个进程是相互合作、<br>相互依赖的,进程 A 必须先生产了数据,进程 B 才能读取到数据,所以执行是有前后顺序的。
那么这时候,就可以用信号量来实现多进程同步的方式,我们可以初始化信号量为 0。<br>
具体过程:<br><br><ul><li>如果进程 B 比进程 A 先执行了,那么执行到 P 操作时,由于信号量初始值为 0,故信号量会变为 -1,<br>表示进程 A 还没生产数据,于是进程 B 就阻塞等待;</li><li>接着,当进程 A 生产完数据后,执行了 V 操作,就会使得信号量变为 0,于是就会唤醒阻塞在 P 操作的进程 B;</li><li>最后,进程 B 被唤醒后,意味着进程 A 已经生产了数据,于是进程 B 就可以正常读取数据了。</li></ul>
可以发现,<b><font color="#0000ff">信号初始化为 0</font></b>,就代表着是<b><font color="#0000ff"> 同步信号量</font></b>,它可以保证进程 A 应在进程 B 之前执行。<br>
信号
上面说的进程间通信,都是常规状态下的工作模式。<b><font color="#0000ff">对于异常情况下的工作模式,就需要用「信号」的方式来通知进程</font></b>。
信号跟信号量虽然名字相似度 66.66%,但两者用途完全不一样,就好像 Java 和 JavaScript 的区别。<br>
在 Linux 操作系统中, 为了响应各种各样的事件,提供了几十种信号,分别代表不同的意义。我们可以通过 kill -l 命令,查看所有的信号:
运行在 shell 终端的进程,我们可以通过键盘输入某些组合键的时候,给进程发送信号。例如:<br><br><ul><li>Ctrl+C 产生 SIGINT 信号,表示终止该进程;</li><li>Ctrl+Z 产生 SIGTSTP 信号,表示停止该进程,但还未结束;</li></ul>
如果进程在后台运行,可以通过 kill 命令的方式给进程发送信号,但前提需要知道运行中的进程 PID 号,例如:<br><br><ul><li>kill -9 1050 ,表示给 PID 为 1050 的进程发送 SIGKILL 信号,用来立即结束该进程;</li></ul>
所以,信号事件的来源主要有硬件来源(如键盘 Cltr+C )和软件来源(如 kill 命令)。
信号是进程间通信机制中 <b><font color="#0000ff">唯一的异步通信机制</font></b>,因为可以在任何时候发送信号给某一进程,<br>一旦有信号产生,我们就有下面这几种,用户进程对信号的处理方式:<br><br><b><font color="#0000ff">1、执行默认操作</font></b>。Linux 对每种信号都规定了默认操作,例如,上面列表中的 SIGTERM 信号,就是终止进程的意思。<br><b><font color="#0000ff">2、捕捉信号</font></b>。我们可以为信号定义一个信号处理函数。当信号发生时,我们就执行相应的信号处理函数。<br><b><font color="#0000ff">3、忽略信号</font></b>。当我们不希望处理某些信号的时候,就可以忽略该信号,不做任何处理。有两个信号<br>是应用进程无法捕捉和忽略的,即 SIGKILL 和 SEGSTOP,它们用于在任何时候中断或结束某一进程。<br>
Socket
前面提到的管道、消息队列、共享内存、信号量和信号都是在同一台主机上进行进程间通信,<br>那要 <b><font color="#0000ff">想跨网络与不同主机上的进程之间通信,就需要 Socket 通信了。</font></b>
实际上,Socket 通信不仅可以跨网络与不同主机的进程间通信,还可以在同主机上进程间通信。
我们来看看创建 socket 的系统调用:<br>
三个参数分别代表:<br><br><ul><li>domain 参数用来指定协议族,比如 AF_INET 用于 IPV4、AF_INET6 用于 IPV6、AF_LOCAL/AF_UNIX 用于本机;</li><li>type 参数用来指定通信特性,比如 SOCK_STREAM 表示的是字节流,对应 TCP、SOCK_DGRAM 表示的是数据报,<br>对应 UDP、SOCK_RAW 表示的是原始套接字;</li><li>protocal 参数原本是用来指定通信协议的,但现在基本废弃。因为协议已经通过前面两个参数指定完成,protocol 目前一般写成 0 即可;</li></ul>
根据创建 socket 类型的不同,通信的方式也就不同:<br><br><ul><li>实现 TCP 字节流通信: socket 类型是 AF_INET 和 SOCK_STREAM;</li><li>实现 UDP 数据报通信:socket 类型是 AF_INET 和 SOCK_DGRAM;</li><li>实现本地进程间通信: 「本地字节流 socket 」类型是 AF_LOCAL 和 SOCK_STREAM,「本地数据报 socket 」类型<br>是 AF_LOCAL 和 SOCK_DGRAM。另外,AF_UNIX 和 AF_LOCAL 是等价的,所以 AF_UNIX 也属于本地 socket;</li></ul>
接下来,简单说一下这三种通信的编程模式。
<b><font color="#1b5e20">针对 TCP 协议通信的 socket 编程模型</font></b><br>
<ul><li>服务端和客户端初始化 socket,得到文件描述符;</li><li>服务端调用 bind,将绑定在 IP 地址和端口;</li><li>服务端调用 listen,进行监听;</li><li>服务端调用 accept,等待客户端连接;</li><li>客户端调用 connect,向服务器端的地址和端口发起连接请求;</li><li>服务端 accept 返回用于传输的 socket 的文件描述符;</li><li>客户端调用 write 写入数据;服务端调用 read 读取数据;</li><li>客户端断开连接时,会调用 close,那么服务端 read 读取数据的时候,<br>就会读取到了 EOF,待处理完数据后,服务端调用 close,表示连接关闭。</li></ul>
这里需要注意的是,服务端调用 accept 时,连接成功了会返回一个已完成连接的 socket,后续用来传输数据。
所以,监听的 socket 和真正用来传送数据的 socket,是「<b><font color="#0000ff">两个</font></b>」 socket,一个叫作 <b><font color="#0000ff">监听 socket</font></b>,一个叫作 <b><font color="#0000ff">已完成连接 socket</font></b>。
成功连接建立之后,双方开始通过 read 和 write 函数来读写数据,就像往一个文件流里面写东西一样。
<b><font color="#1b5e20">针对 UDP 协议通信的 socket 编程模型</font></b><br>
UDP 是没有连接的,所以不需要三次握手,也就不需要像 TCP 调用 listen 和 connect,<br>但是 UDP 的交互仍然需要 IP 地址和端口号,因此也需要 bind。
对于 UDP 来说,不需要要维护连接,那么也就没有所谓的发送方和接收方,甚至都不存在客户端和<br>服务端的概念,只要有一个 socket 多台机器就可以任意通信,因此每一个 UDP 的 socket 都需要 bind。
另外,每次通信时,调用 sendto 和 recvfrom,都要传入目标主机的 IP 地址和端口。<br>
<b><font color="#1b5e20">针对本地进程间通信的 socket 编程模型</font></b>
本地 socket 被用于在 <b><font color="#0000ff">同一台主机上进程间通信 </font></b>的场景:<br><br><ul><li>本地 socket 的编程接口和 IPv4 、IPv6 套接字编程接口是一致的,可以支持「字节流」和「数据报」两种协议;</li><li>本地 socket 的实现效率大大高于 IPv4 和 IPv6 的字节流、数据报 socket 实现;</li></ul>
对于本地字节流 socket,其 socket 类型是 AF_LOCAL 和 SOCK_STREAM。
对于本地数据报 socket,其 socket 类型是 AF_LOCAL 和 SOCK_DGRAM。<br>
本地字节流 socket 和 本地数据报 socket 在 bind 的时候,不像 TCP 和 UDP 要绑定 IP 地址和端口,<br>而是 <b><font color="#0000ff">绑定一个本地文件</font></b>,这也就是它们之间的最大区别。<br>
总结
由于每个进程的用户空间都是独立的,不能相互访问,这时就需要借助内核空间来实现进程间通信,原因很简单,每个进程都是共享一个内核空间。
Linux 内核提供了不少进程间通信的方式,其中最简单的方式就是管道,管道分为「匿名管道」和「命名管道」。
<b><font color="#0000ff">匿名管道 </font></b>顾名思义,它没有名字标识,匿名管道是特殊文件只存在于内存,没有存在于文件系统中,shell 命令中的「 | 」竖线就是<br>匿名管道,通信的数据是<b><font color="#0000ff">无格式的流并且大小受限</font></b>,通信的方式是 <b><font color="#0000ff">单向的</font></b>,数据只能在一个方向上流动,如果要双向通信,需要创建<br>两个管道,再来 <b><font color="#0000ff">匿名管道是只能用于存在父子关系的进程间通信</font></b>,匿名管道的生命周期随着进程创建而建立,随着进程终止而消失。
<b><font color="#0000ff">命名管道 </font></b>突破了匿名管道只能在亲缘关系进程间的通信限制,因为使用命名管道的前提,需要在文件系统创建一个类型为 p 的设备文件,<br>那么毫无关系的进程就可以通过这个设备文件进行通信。另外,不管是匿名管道还是命名管道,进程写入的数据都是 <b><font color="#0000ff">缓存在内核中</font></b>,<br>另一个进程读取数据时候自然也是从内核中获取,同时通信数据都遵循 <b><font color="#0000ff">先进先出原则</font></b>,不支持 lseek 之类的文件定位操作。
<b><font color="#0000ff">消息队列 </font></b>克服了管道通信的数据是无格式的字节流的问题,消息队列实际上是保存在内核的「消息链表」,消息队列的消息体是可以用户自定义<br>的数据类型,发送数据时,会被分成一个一个独立的消息体,当然接收数据时,也要与发送方发送的消息体的数据类型保持一致,这样才能保证<br>读取的数据是正确的。消息队列通信的速度不是最及时的,毕竟 <b><font color="#0000ff">每次数据的写入和读取都需要经过用户态与内核态之间的拷贝过程</font></b>。
<b><font color="#0000ff">共享内存 </font></b>可以解决消息队列通信中用户态与内核态之间数据拷贝过程带来的开销,<b><font color="#0000ff">它直接分配一个共享空间,每个进程都可以直接访问</font></b>,<br>就像访问进程自己的空间一样快捷方便,不需要陷入内核态或者系统调用,大大提高了通信的速度,享有 <b><font color="#0000ff">最快 </font></b>的进程间通信方式之名。<br>但是便捷高效的共享内存通信,带来新的问题,<b><font color="#0000ff">多进程竞争同个共享资源会造成数据的错乱</font></b>。
那么,就需要 <b><font color="#0000ff">信号量</font></b> 来保护共享资源,以确保任何时刻只能有一个进程访问共享资源,这种方式就是互斥访问。<b><font color="#0000ff">信号量不仅可以实现访问的<br>互斥性,还可以实现进程间的同步</font></b>,信号量其实是一个计数器,表示的是资源个数,其值可以通过 <font color="#0000ff" style="font-weight: bold;">两个原子操作 </font>来控制,分别是<b><font color="#0000ff"> P 操作和 V 操作</font></b>。
与信号量名字很相似的叫 <b><font color="#0000ff">信号</font></b>,它俩名字虽然相似,但功能一点儿都不一样。信号是 <b><font color="#0000ff">异步通信机制</font></b>,信号可以在应用进程和内核之间直接交互,<br>内核也可以利用信号来通知用户空间的进程发生了哪些系统事件,信号事件的来源主要有硬件来源(如键盘 Cltr+C )和软件来源(如 kill 命令),<br>一旦有信号发生,<b><font color="#0000ff">进程有三种方式响应信号 1. 执行默认操作、2. 捕捉信号、3. 忽略信号</font></b>。有两个信号是应用进程无法捕捉和忽略的,即 SIGKILL <br>和 SIGSTOP,这是为了方便我们能在任何时候结束或停止某个进程。
前面说到的通信机制,都是工作于同一台主机,如果 <b><font color="#0000ff">要与不同主机的进程间通信,那么就需要 Socket 通信了</font></b>。Socket 实际上<br>不仅用于不同的主机进程间通信,还可以用于本地主机进程间通信,可根据创建 Socket 的类型不同,分为三种常见的通信方式,<br>一个是基于 TCP 协议的通信方式,一个是基于 UDP 协议的通信方式,一个是本地进程间通信方式。
以上,就是进程间通信的主要机制了。你可能会问了,那线程通信间的方式呢?
同个进程下的线程之间都是 <b><font color="#0000ff">共享进程的资源</font></b>,只要是共享变量都可以做到线程间通信,比如全局变量,所以对于<br>线程间关注的不是通信方式,而是 <b><font color="#0000ff">关注多线程竞争共享资源的问题</font></b>,信号量也同样可以在线程间实现互斥与同步:<br><br><ul><li>互斥的方式,可保证任意时刻只有一个线程访问共享资源;</li><li>同步的方式,可保证线程 A 应在线程 B 之前执行;</li></ul>
多线程冲突了怎么办?
竞争与协作
介绍
在单核 CPU 系统里,为了实现多个程序同时运行的假象,操作系统通常以时间片调度的方式,让每个进程执行每次<br>执行一个时间片,时间片用完了,就切换下一个进程运行,由于这个时间片的时间很短,于是就造成了「并发」的现象。
另外,操作系统也为每个进程创建巨大、私有的虚拟内存的假象,这种地址空间的抽象让每个程序<br>好像拥有自己的内存,而实际上操作系统在背后秘密地让多个地址空间「复用」物理内存或者磁盘。
如果一个程序只有一个执行流程,也代表它是单线程的。当然一个程序可以有多个执行流程,<br>也就是所谓的多线程程序,线程是调度的基本单位,进程则是资源分配的基本单位。<br>
所以,线程之间是可以共享进程的资源,比如代码段、堆空间、数据段、打开的文件等资源,但每个线程都有自己独立的栈空间。
那么问题就来了,多个线程如果竞争共享资源,如果不采取有效的措施,则会造成共享数据的混乱。
我们做个小实验,创建两个线程,它们分别对共享变量 i 自增 1 执行 10000 次,如下代码:
按理来说,i 变量最后的值应该是 20000,但很不幸,并不是如此。我们对上面的程序执行一下:
运行了两次,发现出现了 i 值的结果是 15173,也会出现 20000 的 i 值结果。
每次运行不但会产生错误,而且得到不同的结果。在计算机里是不能容忍的,虽然是小概率出现的错误,<br>但是小概率事件它一定是会发生的,「墨菲定律」大家都懂吧。
<b><font color="#1b5e20">为什么会发生这种情况?</font></b><br>
为了理解为什么会发生这种情况,我们必须了解编译器为更新计数器 i 变量生成的代码序列,也就是要了解汇编指令的执行顺序。
在这个例子中,我们只是想给 i 加上数字 1,那么它对应的汇编指令执行过程是这样的:<br>
可以发现,只是单纯给 i 加上数字 1,在 CPU 运行的时候,实际上要执行 3 条指令。
设想我们的线程 1 进入这个代码区域,它将 i 的值(假设此时是 50 )从内存加载到它的寄存器中,然后它向寄存器加 1,此时在寄存器中的 i 值是 51。
现在,一件不幸的事情发生了:时钟中断发生。因此,操作系统将当前正在运行的线程的状态保存到线程的线程控制块 TCB。
现在更糟的事情发生了,线程 2 被调度运行,并进入同一段代码。它也执行了第一条指令,从内存获取 i 值并将其放入到寄存器中,<br>此时内存中 i 的值仍为 50,因此线程 2 寄存器中的 i 值也是 50。假设线程 2 执行接下来的两条指令,将寄存器中的 i 值 + 1,<br>然后将寄存器中的 i 值保存到内存中,于是此时全局变量 i 值是 51。
最后,又发生一次上下文切换,线程 1 恢复执行。还记得它已经执行了两条汇编指令,现在准备执行最后一条指令。<br>回忆一下, 线程 1 寄存器中的 i 值是51,因此,执行最后一条指令后,将值保存到内存,全局变量 i 的值再次被设置为 51。
简单来说,增加 i (值为 50 )的代码被运行两次,按理来说,最后的 i 值应该是 52,但是由于 <b><font color="#0000ff">不可控的调度</font></b>,导致最后 i 值却是 51。
针对上面线程 1 和线程 2 的执行过程,我画了一张流程图,会更明确一些:<br>
互斥的概念
上面展示的情况称为 <b><font color="#0000ff">竞争条件(race condition)</font></b>,当多线程相互竞争操作共享变量时,由于运气不好,即在执行过程中发生了上下文切换,<br>我们得到了错误的结果。事实上,每次运行都可能得到不同的结果,因此输出的结果存在 <b><font color="#0000ff">不确定性(indeterminate)</font></b>。
由于多线程执行操作共享变量的这段代码可能会导致竞争状态,因此我们将此段代码称为 <br><b><font color="#0000ff">临界区(critical section)</font></b>,<b><font color="#0000ff">它是访问共享资源的代码片段,一定不能给多线程同时执行</font></b>。
我们希望这段代码是 <b><font color="#0000ff">互斥(mutualexclusion)</font></b>的,也就说 <b><font color="#0000ff">保证一个线程在临界区执行时,<br>其他线程应该被阻止进入临界区</font></b>,说白了,就是这段代码执行过程中,最多只能出现一个线程。
另外,说一下互斥也并不是只针对多线程。在多进程竞争共享资源的时候,也同样是可以使用互斥的方式来避免资源竞争造成的资源混乱。<br>
同步的概念
互斥解决了并发进程/线程对临界区的使用问题。这种基于临界区控制的交互作用是比较简单的,只要一个<br>进程/线程进入了临界区,其他试图想进入临界区的进程/线程都会被阻塞着,直到第一个进程/线程离开了临界区。
我们都知道在多线程里,每个线程并不一定是顺序执行的,它们基本是以各自独立的、不可<br>预知的速度向前推进,但有时候我们又希望多个线程能密切合作,以实现一个共同的任务。
例子,线程 1 是负责读入数据的,而线程 2 是负责处理数据的,这两个线程是相互合作、相互依赖的。线程 2 在没有收到线程 1 的<br>唤醒通知时,就会一直阻塞等待,当线程 1 读完数据需要把数据传给线程 2 时,线程 1 会唤醒线程 2,并把数据交给线程 2 处理。
所谓 <b><font color="#0000ff">同步</font></b>,就是 <b><font color="#0000ff">并发进程/线程在一些关键点上可能需要互相等待与互通消息</font></b>,这种 <b><font color="#0000ff">相互制约的等待与互通信息称为进程/线程同步</font></b>。
举个生活的同步例子,你肚子饿了想要吃饭,你叫妈妈早点做菜,妈妈听到后就开始做菜,但是在妈妈<br>没有做完饭之前,你必须阻塞等待,等妈妈做完饭后,自然会通知你,接着你吃饭的事情就可以进行了。
注意,同步与互斥是两种不同的概念:<br><br><ul><li>同步就好比:「操作 A 应在操作 B 之前执行」,「操作 C 必须在操作 A 和操作 B 都完成之后才能执行」等;</li><li>互斥就好比:「操作 A 和操作 B 不能在同一时刻执行」;</li></ul>
互斥与同步的实现
介绍
在进程/线程并发执行的过程中,进程/线程之间存在协作的关系,例如有互斥、同步的关系。
为了实现进程/线程间正确的协作,操作系统必须提供实现进程协作的措施和方法,主要的方法有两种:<br><br><ul><li><b><font color="#0000ff">锁</font></b>:加锁、解锁操作;</li><li><b><font color="#0000ff">信号量</font></b>:P、V 操作;</li></ul>
这两个都可以方便地实现进程/线程互斥,而信号量比锁的功能更强一些,它还可以方便地实现进程/线程同步。<br>
锁
使用加锁操作和解锁操作可以解决并发线程/进程的互斥问题。
任何想进入临界区的线程,必须先执行加锁操作。若加锁操作顺利通过,则线程可进入临界区;<br>在完成对临界资源的访问后再执行解锁操作,以释放该临界资源。
根据锁的实现不同,可以分为「忙等待锁」和「无忙等待锁」。
<b><font color="#1b5e20">先来看看「忙等待锁」的实现</font></b>
在说明「忙等待锁」的实现之前,先介绍现代 CPU 体系结构提供的特殊 <b><font color="#0000ff">原子操作指令</font></b> —— <b><font color="#0000ff">测试和置位(Test-and-Set)指令</font></b>。
如果用 C 代码表示 Test-and-Set 指令,形式如下:
测试并设置指令做了下述事情:<br><br><ul><li>把 old_ptr 更新为 new 的新值</li><li>返回 old_ptr 的旧值;</li></ul>
当然,<b><font color="#0000ff">关键是这些代码是原子执行</font></b>。因为既可以测试旧值,又可以设置新值,所以我们把这条指令叫作「测试并设置」。
那什么是原子操作呢?<b><font color="#0000ff">原子操作就是要么全部执行,要么都不执行,不能出现执行到一半的中间状态</font></b>
我们可以运用 Test-and-Set 指令来实现「忙等待锁」,代码如下:
我们来确保理解为什么这个锁能工作:<br><br><ul><li>第一个场景是,首先假设一个线程在运行,调用 lock(),没有其他线程持有锁,所以 flag 是 0。当调用 TestAndSet(flag, 1) 方法,返回 0,<br>线程会跳出 while 循环,获取锁。同时也会原子的设置 flag 为1,标志锁已经被持有。当线程离开临界区,调用 unlock() 将 flag 清理为 0。</li></ul><br><ul><li>第二种场景是,当某一个线程已经持有锁(即 flag 为1)。本线程调用 lock(),然后调用 TestAndSet(flag, 1),这一次返回 1。<br>只要另一个线程一直持有锁,TestAndSet() 会重复返回 1,本线程会一直 <b><font color="#0000ff">忙等</font></b>。当 flag 终于被改为 0,本线程会调用 TestAndSet(),<br>返回 0 并且原子地设置为 1,从而获得锁,进入临界区。</li></ul>
很明显,当获取不到锁时,线程就会一直 while 循环,不做任何事情,所以就被称为「<b><font color="#0000ff">忙等待锁</font></b>」,也被称为 <b><font color="#0000ff">自旋锁(spin lock)</font></b>。
这是最简单的一种锁,一直自旋,利用 CPU 周期,直到锁可用。在单处理器上,需要抢占式的调度器(即不断通过<br>时钟中断一个线程,运行其他线程)。否则,自旋锁在单 CPU 上无法使用,因为一个自旋的线程永远不会放弃 CPU。
<b><font color="#1b5e20">再来看看「无等待锁」的实现</font></b><br>
无等待锁顾明思议就是获取不到锁的时候,不用自旋。
既然不想自旋,那当没获取到锁的时候,就把当前线程放入到锁的 <b><font color="#0000ff">等待队列</font></b>,然后执行调度程序,把 CPU 让给其他线程执行。
本次只是提出了两种简单锁的实现方式。当然,在具体操作系统实现中,会更复杂,但也离不开本例子两个基本元素。
信号量
信号量是操作系统提供的一种协调共享资源访问的方法。
通常<b><font color="#0000ff"> 信号量表示资源的数量</font></b>,对应的变量是一个整型(sem)变量。
另外,还有 <b><font color="#0000ff">两个原子操作的系统调用函数来控制信号量的</font></b>,分别是:<br><br><ul><li><b><font color="#0000ff">P 操作</font></b>:将 sem 减 1,相减后,如果 sem < 0,则进程/线程进入阻塞等待,否则继续,表明 P 操作可能会阻塞;</li><li><b><font color="#0000ff">V 操作</font></b>:将 sem 加 1,相加后,如果 sem <= 0,唤醒一个等待中的进程/线程,表明 V 操作不会阻塞;</li></ul>
举个例子,如果 sem = 1,有三个线程进行了 P 操作:<br><br><ul><li>第一个线程 P 操作后,sem = 0;</li><li>第二个线程 P 操作后,sem = -1;</li><li>第三个线程 P 操作后,sem = -2;<br><br></li></ul>这时,第一个线程执行 V 操作后, sem 是 -1,因为 sem <= 0,所以要唤醒第二或第三个线程。
P 操作是用在进入临界区之前,V 操作是用在离开临界区之后,这两个操作是必须成对出现的。
<b><font color="#1b5e20">操作系统是如何实现 PV 操作的呢?</font></b><br>
信号量数据结构与 PV 操作的算法描述如下图:<br>
PV 操作的函数是由操作系统管理和实现的,所以操作系统已经使得执行 PV 函数时是具有原子性的。
<b><font color="#1b5e20">PV 操作如何使用的呢?</font></b><br>
信号量不仅可以实现临界区的互斥访问控制,还可以线程间的事件同步。<br>
我们先来说说如何使用 <b><font color="#0000ff">信号量实现临界区的互斥访问</font></b>。
为每类共享资源设置一个信号量 s,其初值为 1,表示该临界资源未被占用。
只要把进入临界区的操作置于 P(s) 和 V(s) 之间,即可实现进程/线程互斥:
此时,任何想进入临界区的线程,必先在互斥信号量上执行 P 操作,在完成对临界资源的访问后再执行 V 操作。由于互斥<br>信号量的初始值为 1,故在第一个线程执行 P 操作后 s 值变为 0,表示临界资源为空闲,可分配给该线程,使之进入临界区。
若此时又有第二个线程想进入临界区,也应先执行 P 操作,结果使 s 变为负值,这就意味着临界资源已被占用,因此,第二个线程被阻塞。
并且,直到第一个线程执行 V 操作,释放临界资源而恢复 s 值为 0 后,才唤醒第二个线程,<br>使之进入临界区,待它完成临界资源的访问后,又执行 V 操作,使 s 恢复到初始值 1。
对于两个并发线程,互斥信号量的值仅取 1、0 和 -1 三个值,分别表示:<br><br><ul><li>如果互斥信号量为 1,表示没有线程进入临界区;</li><li>如果互斥信号量为 0,表示有一个线程进入临界区;</li><li>如果互斥信号量为 -1,表示一个线程进入临界区,另一个线程等待进入。</li></ul>
通过互斥信号量的方式,就能保证临界区任何时刻只有一个线程在执行,就达到了互斥的效果。
再来,我们说说如何使用 <b><font color="#0000ff">信号量实现事件同步</font></b>。<br>
同步的方式是设置一个信号量,其初值为 0。<br>
我们把前面的「吃饭-做饭」同步的例子,用代码的方式实现一下:<br>
妈妈一开始询问儿子要不要做饭时,执行的是 P(s1) ,相当于询问儿子需不需要吃饭,由于 s1 初始值为 0,<br>此时 s1 变成 -1,表明儿子不需要吃饭,所以妈妈线程就进入等待状态。<br>
当儿子肚子饿时,执行了 V(s1),使得 s1 信号量从 -1 变成 0,表明此时儿子需要吃饭了,于是就唤醒了阻塞中的妈妈线程,妈妈线程就开始做饭。
接着,儿子线程执行了 P(s2),相当于询问妈妈饭做完了吗,由于 s2 初始值是 0,则此时 s2 变成 -1,说明妈妈还没做完饭,儿子线程就等待状态。
最后,妈妈终于做完饭了,于是执行 V(s2),s2 信号量从 -1 变回了 0,于是就唤醒等待中的儿子线程,唤醒后,儿子线程就可以进行吃饭了。<br>
生产者 - 消费者问题
生产者-消费者问题描述:<br><br><ul><li><b><font color="#0000ff">生产者 </font></b>在生成数据后,放在一个缓冲区中;</li><li><b><font color="#0000ff">消费者 </font></b>从缓冲区取出数据处理;</li><li>任何时刻,<b><font color="#0000ff">只能有一个 </font></b>生产者或消费者可以访问缓冲区;</li></ul>
我们对问题分析可以得出:<br><br><ul><li>任何时刻只能有一个线程操作缓冲区,说明操作缓冲区是临界代码,<b><font color="#0000ff">需要互斥</font></b>;</li><li>缓冲区空时,消费者必须等待生产者生成数据;缓冲区满时,生产者必须等待消费者取出数据。说明生产者和消费者 <b><font color="#0000ff">需要同步</font></b>。</li></ul>
那么我们需要三个信号量,分别是:<br><br><ul><li>互斥信号量 mutex:用于互斥访问缓冲区,初始化值为 1;</li><li>资源信号量 fullBuffers:用于消费者询问缓冲区是否有数据,有数据则读取数据,初始化值为 0(表明缓冲区一开始为空);</li><li>资源信号量 emptyBuffers:用于生产者询问缓冲区是否有空位,有空位则生成数据,初始化值为 n (缓冲区大小);</li></ul>
具体的实现代码:<br>
如果消费者线程一开始执行 P(fullBuffers),由于信号量 fullBuffers 初始值为 0,<br>则此时 fullBuffers 的值从 0 变为 -1,说明缓冲区里没有数据,消费者只能等待。<br>
接着,轮到生产者执行 P(emptyBuffers),表示减少 1 个空槽,如果当前没有其他生产者线程在临界区执行代码,那么该生产者线程就可以把数据放到<br>缓冲区,放完后,执行 V(fullBuffers) ,信号量 fullBuffers 从 -1 变成 0,表明有「消费者」线程正在阻塞等待数据,于是阻塞等待的消费者线程会被唤醒。
经典同步问题
哲学家就餐问题
当初我在校招的时候,面试官也问过「哲学家就餐」这道题目,我当时听的一脸懵逼,<br>无论面试官怎么讲述这个问题,我也始终没听懂,就莫名其妙的说这个问题会「死锁」。
当然,我这回答槽透了,所以当场 game over,残酷又悲惨故事,就不多说了,反正当时菜就是菜。<br>
先来看看哲学家就餐的问题描述:<br><br><ul><li>5 个老大哥哲学家,闲着没事做,围绕着一张圆桌吃面;</li><li>巧就巧在,这个桌子只有 5 支叉子,每两个哲学家之间放一支叉子;</li><li>哲学家围在一起先思考,思考中途饿了就会想进餐;</li><li><b><font color="#0000ff">奇葩的是,这些哲学家要两支叉子才愿意吃面,也就是需要拿到左右两边的叉子才进餐;</font></b></li><li><b><font color="#0000ff">吃完后,会把两支叉子放回原处,继续思考;</font></b></li></ul>
那么问题来了,如何保证哲学家们的动作有序进行,而不会出现有人永远拿不到叉子呢?<br>
<b><font color="#1b5e20">方案一</font></b><br>
我们用信号量的方式,也就是 PV 操作来尝试解决它,代码如下:<br>
上面的程序,好似很自然。拿起叉子用 P 操作,代表有叉子就直接用,没有叉子时就等待其他哲学家放回叉子。<br>
不过,这种解法存在一个极端的问题:<b><font color="#0000ff">假设五位哲学家同时拿起左边的叉子,桌面上就没有叉子了, 这样就没有人能够<br>拿到他们右边的叉子,也就说每一位哲学家都会在 P(fork[(i + 1) % N ]) 这条语句阻塞了,很明显这发生了死锁的现象</font></b>。
<b><font color="#1b5e20">方案二</font></b><br>
既然「方案一」会发生同时竞争左边叉子导致死锁的现象,那么我们就在拿叉子前,加个互斥信号量,代码如下:
上面程序中的互斥信号量的作用就在于,<b><font color="#0000ff">只要有一个哲学家进入了「临界区」,也就是准备要<br>拿叉子时,其他哲学家都不能动,只有这位哲学家用完叉子了,才能轮到下一个哲学家进餐。</font></b>
方案二虽然能让哲学家们按顺序吃饭,但是每次进餐只能有一位哲学家,而桌面上是有 5 把叉子,<br>按道理是能可以有两个哲学家同时进餐的,所以从效率角度上,这不是最好的解决方案。
<b><font color="#1b5e20">方案三</font></b>
那既然方案二使用互斥信号量,会导致只能允许一个哲学家就餐,那么我们就不用它。
另外,方案一的问题在于,会出现所有哲学家同时拿左边刀叉的可能性,那我们就避免哲学家<br>可以同时拿左边的刀叉,采用分支结构,根据哲学家的编号的不同,而采取不同的动作。
<b><font color="#0000ff">即让偶数编号的哲学家「先拿左边的叉子后拿右边的叉子」,奇数编号的哲学家「先拿右边的叉子后拿左边的叉子」。</font></b>
上面的程序,在 P 操作时,根据哲学家的编号不同,拿起左右两边叉子的顺序不同。另外,V 操作是不需要分支的,因为 V 操作是不会阻塞的。
方案三即不会出现死锁,也可以两人同时进餐。<br>
<b><font color="#0000ff">方案四</font></b><br>
在这里再提出另外一种可行的解决方案,我们 <b><font color="#0000ff">用一个数组 state 来记录每一位哲学家的三个状态,<br>分别是在进餐状态、思考状态、饥饿状态(正在试图拿叉子)。</font></b>
那么,<b><font color="#0000ff">一个哲学家只有在两个邻居都没有进餐时,才可以进入进餐状态</font></b>。
第 i 个哲学家的左邻右舍,则由宏 LEFT 和 RIGHT 定义:<br><br><ul><li>LEFT : ( i + 5 - 1 ) % 5</li><li>RIGHT : ( i + 1 ) % 5</li></ul>
比如 i 为 2,则 LEFT 为 1,RIGHT 为 3。
具体代码实现如下:<br>
上面的程序使用了一个信号量数组,每个信号量对应一位哲学家,这样在所需的叉子被占用时,想进餐的哲学家就被阻塞。
注意,每个进程/线程将 smart_person 函数作为主代码运行,而其他 take_forks、put_forks 和 test 只是普通的函数,而非单独的进程/线程。
方案四同样不会出现死锁,也可以两人同时进餐。<br>
读者 - 写者问题
前面的「哲学家进餐问题」对于互斥访问有限的竞争问题(如 I/O 设备)一类的建模过程十分有用。
另外,还有个著名的问题是「读者-写者」,它为数据库访问建立了一个模型。<br>
读者只会读取数据,不会修改数据,而写者即可以读也可以修改数据。<br>
读者-写者的问题描述:<br><br><ul><li>「读-读」允许:同一时刻,允许多个读者同时读</li><li>「读-写」互斥:没有写者时读者才能读,没有读者时写者才能写</li><li>「写-写」互斥:没有其他写者时,写者才能写</li></ul>
接下来,提出几个解决方案来分析分析。
<b><font color="#1b5e20">方案一</font></b><br>
使用信号量的方式来尝试解决:<br><br><ul><li>信号量 wMutex:控制写操作的互斥信号量,初始值为 1 ;</li><li>读者计数 rCount:正在进行读操作的读者个数,初始化为 0;</li><li>信号量 rCountMutex:控制对 rCount 读者计数器的互斥修改,初始值为 1;</li></ul>
接下来看看代码的实现:<br>
上面的这种实现,是读者优先的策略,因为只要有读者正在读的状态,后来的读者都可以直接进入,如果读者持续不断进入,则写者会处于饥饿状态。
<b><font color="#1b5e20">方案二</font></b><br>
那既然有读者优先策略,自然也有写者优先策略:<br><br><ul><li>只要有写者准备要写入,写者应尽快执行写操作,后来的读者就必须阻塞;</li><li>如果有写者持续不断写入,则读者就处于饥饿;</li></ul>
在方案一的基础上新增如下变量:<br><br><ul><li>信号量 rMutex:控制读者进入的互斥信号量,初始值为 1;</li><li>信号量 wDataMutex:控制写者写操作的互斥信号量,初始值为 1;</li><li>写者计数 wCount:记录写者数量,初始值为 0;</li><li>信号量 wCountMutex:控制 wCount 互斥修改,初始值为 1;</li></ul>
具体实现如下代码:<br>
注意,这里 rMutex 的作用,开始有多个读者读数据,它们全部进入读者队列,此时来了一个写者,执行了 P(rMutex) 之后,<br>后续的读者由于阻塞在 rMutex 上,都不能再进入读者队列,而写者到来,则可以全部进入写者队列,因此保证了写者优先。
同时,第一个写者执行了 P(rMutex) 之后,也不能马上开始写,必须等到所有<br>进入读者队列的读者都执行完读操作,通过 V(wDataMutex) 唤醒写者的写操作。
<b><font color="#1b5e20">方案三</font></b><br>
既然读者优先策略和写者优先策略都会造成饥饿的现象,那么我们就来实现一下公平策略。
公平策略:<br><br><ul><li>优先级相同;</li><li>写者、读者互斥访问;</li><li>只能一个写者访问临界区;</li><li>可以有多个读者同时访问临界资源;</li></ul>
具体代码实现:<br>
看完代码不知你是否有这样的疑问,为什么加了一个信号量 flag,就实现了公平竞争?<br>
对比方案一的读者优先策略,可以发现,读者优先中只要后续有读者到达,读者就可以进入读者队列, 而写者必须等待,直到没有读者到达。
没有读者到达会导致读者队列为空,即 rCount==0,此时写者才可以进入临界区执行写操作。
而这里 flag 的作用就是阻止读者的这种特殊权限(特殊权限是只要读者到达,就可以进入读者队列)。
比如:开始来了一些读者读数据,它们全部进入读者队列,此时来了一个写者,执行 P(falg) 操作,使得<br>后续到来的读者都阻塞在 flag 上,不能进入读者队列,这会使得读者队列逐渐为空,即 rCount 减为 0。
这个写者也不能立马开始写(因为此时读者队列不为空),会阻塞在信号量 wDataMutex 上,读者队列中的<br>读者全部读取结束后,最后一个读者进程执行 V(wDataMutex),唤醒刚才的写者,写者则继续开始进行写操作。<br>
怎么避免死锁?
死锁的概念
介绍
在多线程编程中,我们为了防止多线程竞争共享资源而导致数据错乱,都会在操作共享资源之前加上互斥锁,<br>只有成功获得到锁的线程,才能操作共享资源,获取不到锁的线程就只能等待,直到锁被释放。
那么,当两个线程为了保护两个不同的共享资源而使用了两个互斥锁,那么这两个互斥锁应用不当的时候,可能会造成 <b><font color="#0000ff">两个<br>线程都在等待对方释放锁</font></b>,在没有外力的作用下,这些线程会一直相互等待,就没办法继续运行,这种情况就是发生了 <b><font color="#0000ff">死锁</font></b>。
举个例子,小林拿了小美房间的钥匙,而小林在自己的房间里,小美拿了小林房间的钥匙,而小美也在自己的房间里。<br>如果小林要从自己的房间里出去,必须拿到小美手中的钥匙,但是小美要出去,又必须拿到小林手中的钥匙,这就形成了死锁。
死锁只有同时满足以下四个条件才会发生:<br><br><ul><li>互斥条件;</li><li>持有并等待条件;</li><li>不可剥夺条件;</li><li>环路等待条件;</li></ul>
互斥条件
互斥条件是指 <b><font color="#0000ff">多个线程不能同时使用同一个资源</font></b>。<br>
比如下图,如果线程 A 已经持有的资源,不能再同时被线程 B 持有,如果线程 B <br>请求获取线程 A 已经占用的资源,那线程 B 只能等待,直到线程 A 释放了资源。
持有并等待条件
持有并等待条件是指,当线程 A 已经持有了资源 1,又想申请资源 2,而资源 2 已经被线程 C 持有了,<br>所以线程 A 就会处于等待状态,但是 <b><font color="#0000ff">线程 A 在等待资源 2 的同时并不会释放自己已经持有的资源 1</font></b>。
不可剥夺条件
不可剥夺条件是指,当线程已经持有了资源 ,<b><font color="#0000ff">在自己使用完之前不能被其他线程获取</font></b>,<br>线程 B 如果也想使用此资源,则只能在线程 A 使用完并释放后才能获取。
环路等待条件
环路等待条件指的是,在死锁发生的时候,<b><font color="#0000ff">两个线程获取资源的顺序构成了环形链</font></b>。
比如,线程 A 已经持有资源 2,而想请求资源 1, 线程 B 已经获取了资源 1,<br>而想请求资源 2,这就形成资源请求等待的环形图。<br>
模拟死锁问题的产生
Talk is cheap. Show me the code. 下面,我们用代码来模拟死锁问题的产生。
首先,我们先创建 2 个线程,分别为线程 A 和 线程 B,然后有两个互斥锁,分别是 mutex_A 和 mutex_B,代码如下:
接下来,我们看下线程 A 函数做了什么:<br><br><ul><li>先获取互斥锁 A,然后睡眠 1 秒;</li><li>再获取互斥锁 B,然后释放互斥锁 B;</li><li>最后释放互斥锁 A;</li></ul>
接下来,我们看下线程 B 函数做了什么:<br><br><ul><li>先获取互斥锁 B,然后睡眠 1 秒;</li><li>再获取互斥锁 A,然后释放互斥锁 A;</li><li>最后释放互斥锁 B;</li></ul>
然后,我们运行这个程序,运行结果如下:<br>
可以看到线程 B 在等待互斥锁 A 的释放,线程 A 在等待互斥锁 B 的释放,双方都在等待对方资源的释放,很明显,产生了死锁问题。<br>
利用工具排查死锁问题
如果你想排查你的 Java 程序是否死锁,则可以使用 jstack 工具,它是 jdk 自带的线程堆栈分析工具。
由于小林的死锁代码例子是 C 写的,在 Linux 下,我们可以使用 pstack + gdb 工具来定位死锁问题。
pstack 命令可以显示每个线程的栈跟踪信息(函数调用过程),它的使用方式也很简单,只需要 pstack <pid> 就可以了。<br>
那么,在定位死锁问题时,我们可以多次执行 pstack 命令查看线程的函数调用过程,多次对比结果,<br>确认哪几个线程一直没有变化,且是因为在等待锁,那么大概率是由于死锁问题导致的。
我用 pstack 输出了我前面模拟死锁问题的进程的所有线程的情况,我多次执行命令后,其结果都一样,如下:
可以看到,Thread 2 和 Thread 3 一直阻塞获取锁(pthread_mutex_lock)的过程,<br>而且 pstack 多次输出信息都没有变化,那么可能大概率发生了死锁。
但是,还不能够确认这两个线程是在互相等待对方的锁的释放,因为我们看不到它们是等在哪个锁对象,于是我们可以使用 gdb 工具进一步确认。
整个 gdb 调试过程,如下:
我来解释下,上面的调试过程:<br><br><ul><li>通过 info thread 打印了所有的线程信息,可以看到有 3 个线程,一个是主线程(LWP 87746),<br>另外两个都是我们自己创建的线程(LWP 87747 和 87748);</li><li>通过 thread 2,将切换到第 2 个线程(LWP 87748);</li><li>通过 bt,打印线程的调用栈信息,可以看到有 threadB_proc 函数,说明这个是线程 B 函数,也就说 LWP 87748 是线程 B;</li><li>通过 frame 3,打印调用栈中的第三个帧的信息,可以看到线程 B 函数,在获取互斥锁 A 的时候阻塞了;</li><li>通过 p mutex_A,打印互斥锁 A 对象信息,可以看到它被 LWP 为 87747(线程 A) 的线程持有着;</li><li>通过 p mutex_B,打印互斥锁 B 对象信息,可以看到他被 LWP 为 87748 (线程 B) 的线程持有着;</li></ul>
因为线程 B 在等待线程 A 所持有的 mutex_A, 而同时线程 A 又在等待线程 B 所拥有的 mutex_B, 所以可以断定该程序发生了死锁。<br>
避免死锁问题的发生
前面我们提到,产生死锁的四个必要条件是:互斥条件、持有并等待条件、不可剥夺条件、环路等待条件。
那么避免死锁问题就只需要破环其中一个条件就可以,最常见的并且可行的就是 <b><font color="#0000ff">使用资源有序分配法,来破环环路等待条件</font></b>。
那什么是资源有序分配法呢?
线程 A 和 线程 B 获取资源的顺序要一样,当线程 A 是先尝试获取资源 A,然后尝试获取资源 B 的时候,线程 B 同样<br>也是先尝试获取资源 A,然后尝试获取资源 B。也就是说,线程 A 和 线程 B 总是以相同的顺序申请自己想要的资源。
我们使用资源有序分配法的方式来修改前面发生死锁的代码,我们可以不改动线程 A 的代码。
我们先要清楚线程 A 获取资源的顺序,它是先获取互斥锁 A,然后获取互斥锁 B。
所以我们只需将线程 B 改成以相同顺序的获取资源,就可以打破死锁了。
线程 B 函数改进后的代码如下:
执行结果如下,可以看,没有发生死锁。<br>
总结
简单来说,死锁问题的产生是由两个或者以上线程并行执行的时候,争夺资源而互相等待造成的。<br>
死锁只有同时满足互斥、持有并等待、不可剥夺、环路等待这四个条件的时候才会发生。<br>
所以要避免死锁问题,就是要破坏其中一个条件即可,最常用的方法就是使用资源有序分配法来破坏环路等待条件。<br>
什么是悲观锁、乐观锁?
介绍
生活中用到的锁,用途都比较简单粗暴,上锁基本是为了防止外人进来、电动车被偷等等。<br>
但生活中也不是没有 BUG 的,比如加锁的电动车在「广西 - 窃·格瓦拉」面前,锁就是形同虚设,只要他愿意,<br>他就可以轻轻松松地把你电动车给「顺走」,不然打工怎么会是他这辈子不可能的事情呢?
那在编程世界里,「锁」更是五花八门,多种多样,每种锁的加锁开销以及应用场景也可能会不同。
如何用好锁,也是程序员的基本素养之一了。
高并发的场景下,如果选对了合适的锁,则会大大提高系统的性能,否则性能会降低。
所以,知道各种锁的开销,以及应用场景是很有必要的。<br>
多线程访问共享资源的时候,避免不了资源竞争而导致数据错乱的问题,所以我们通常为了解决这一问题,都会在访问共享资源之前加锁。
最常用的就是互斥锁,当然还有很多种不同的锁,比如自旋锁、读写锁、乐观锁等,不同种类的锁自然适用于不同的场景。
如果选择了错误的锁,那么在一些高并发的场景下,可能会降低系统的性能,这样用户体验就会非常差了。
所以,为了选择合适的锁,我们不仅需要清楚知道加锁的成本开销有多大,还需要分析<br>业务场景中访问的共享资源的方式,再来还要考虑并发访问共享资源时的冲突概率。
对症下药,才能减少锁对高并发性能的影响。<br>
那接下来,针对不同的应用场景,谈一谈「<b><font color="#0000ff">互斥锁、自旋锁、读写锁、乐观锁、悲观锁</font></b>」的选择和使用。<br>
互斥锁与自旋锁
最底层的两种就是会「互斥锁和自旋锁」,有很多高级的锁都是基于它们实现的,<br>你可以认为它们是各种锁的地基,所以我们必须清楚它俩之间的区别和应用。
加锁的目的就是保证共享资源在任意时间里,只有一个线程访问,这样就可以避免多线程导致共享数据错乱的问题。
当已经有一个线程加锁后,其他线程加锁则就会失败,互斥锁和自旋锁对于加锁失败后的处理方式是不一样的:<br><br><ul><li>互斥锁加锁失败后,线程会释放 CPU ,给其他线程;</li><li>自旋锁加锁失败后,线程会忙等待,直到它拿到锁;</li></ul>
互斥锁是一种「独占锁」,比如当线程 A 加锁成功后,此时互斥锁已经被线程 A 独占了,只要线程 A 没有释放手中的锁,<br>线程 B 加锁就会失败,于是就会释放 CPU 让给其他线程,<b><font color="#0000ff">既然线程 B 释放掉了 CPU,自然线程 B 加锁的代码就会被阻塞</font></b>。
<b><font color="#0000ff">对于互斥锁加锁失败而阻塞的现象,是由操作系统内核实现的</font></b>。当加锁失败时,内核会将线程置为「睡眠」状态,<br>等到锁被释放后,内核会在合适的时机唤醒线程,当这个线程成功获取到锁后,于是就可以继续执行。如下图:
所以,互斥锁加锁失败时,会从用户态陷入到内核态,让内核帮我们切换线程,虽然简化了使用锁的难度,但是存在一定的性能开销成本。
那这个开销成本是什么呢?会有 <b><font color="#0000ff">两次线程上下文切换的成本</font></b>:<br><br><ul><li>当线程加锁失败时,内核会把线程的状态从「运行」状态设置为「睡眠」状态,然后把 CPU 切换给其他线程运行;</li><li>接着,当锁被释放时,之前「睡眠」状态的线程会变为「就绪」状态,然后内核会在合适的时间,把 CPU 切换给该线程运行。</li></ul>
线程的上下文切换的是什么?当两个线程是属于同一个进程,<b><font color="#0000ff">因为虚拟内存是共享的,所以在切换时,<br>虚拟内存这些资源就保持不动,只需要切换线程的私有数据、寄存器等不共享的数据。</font></b>
上下切换的耗时有大佬统计过,大概在几十纳秒到几微秒之间,如果你锁住的代码<br>执行时间比较短,那可能上下文切换的时间都比你锁住的代码执行时间还要长。
所以,<b><font color="#0000ff">如果你能确定被锁住的代码执行时间很短,就不应该用互斥锁,而应该选用自旋锁,否则使用互斥锁</font></b>。
自旋锁是通过 CPU 提供的 <b><font color="#0000ff">CAS 函数(Compare And Swap)</font></b>,在「<b><font color="#0000ff">用户态</font></b>」完成加锁和解锁操作,<br>不会主动产生线程上下文切换,所以相比互斥锁来说,会快一些,开销也小一些。
一般加锁的过程,包含两个步骤:<br><br><ul><li>第一步,查看锁的状态,如果锁是空闲的,则执行第二步;</li><li>第二步,将锁设置为当前线程持有;</li></ul>
CAS 函数就把这两个步骤合并成一条硬件级指令,形成 <b><font color="#0000ff">原子指令</font></b>,这样就保证了<br>这两个步骤是不可分割的,要么一次性执行完两个步骤,要么两个步骤都不执行。
比如,设锁为变量 lock,整数 0 表示锁是空闲状态,整数 pid 表示线程 ID,那么 <br>CAS(lock, 0, pid) 就表示自旋锁的加锁操作,CAS(lock, pid, 0) 则表示解锁操作。
使用自旋锁的时候,当发生多线程竞争锁的情况,加锁失败的线程会「忙等待」,直到它拿到锁。这里的「忙等待」可以<br>用 while 循环等待实现,不过最好是使用 CPU 提供的 PAUSE 指令来实现「忙等待」,因为可以减少循环等待时的耗电量。
自旋锁是最比较简单的一种锁,一直自旋,利用 CPU 周期,直到锁可用。<b><font color="#0000ff">需要注意,在单核 CPU 上,需要抢占式的调度器(即<br>不断通过时钟中断一个线程,运行其他线程)。否则,自旋锁在单 CPU 上无法使用,因为一个自旋的线程永远不会放弃 CPU。</font></b>
自旋锁开销少,在多核系统下一般不会主动产生线程切换,适合异步、协程等在用户态切换请求的编程方式,但如果被锁住的代码执行时间过长,<br>自旋的线程会长时间占用 CPU 资源,所以自旋的时间和被锁住的代码执行的时间是成「正比」的关系,我们需要清楚的知道这一点。
自旋锁与互斥锁使用层面比较相似,但实现层面上完全不同:<b><font color="#0000ff">当加锁失败时,互斥锁用「线程切换」来应对,自旋锁则用「忙等待」来应对</font></b>。
它俩是锁的最基本处理方式,更高级的锁都会选择其中一个来实现,比如读写锁既可以选择互斥锁实现,也可以基于自旋锁实现。<br>
读写锁
读写锁从字面意思我们也可以知道,它由「读锁」和「写锁」两部分构成,如果只读取共享资源用「读锁」加锁,如果要修改共享资源则用「写锁」加锁。<br>
所以,<b><font color="#0000ff">读写锁适用于能明确区分读操作和写操作的场景</font></b>。<br>
读写锁的工作原理是:<br><br><ul><li>当「写锁」没有被线程持有时,多个线程能够并发地持有读锁,这大大提高了共享资源的访问效率,<br>因为「读锁」是用于读取共享资源的场景,所以多个线程同时持有读锁也不会破坏共享资源的数据。</li><li>但是,一旦「写锁」被线程持有后,读线程的获取读锁的操作会被阻塞,而且其他写线程的获取写锁的操作也会被阻塞。</li></ul>
所以说,写锁是独占锁,因为任何时刻只能有一个线程持有写锁,类似互斥锁和自旋锁,而读锁是共享锁,因为读锁可以被多个线程同时持有。
知道了读写锁的工作原理后,我们可以发现,<b><font color="#0000ff">读写锁在读多写少的场景,能发挥出优势。</font></b>
另外,根据实现的不同,读写锁可以分为「读优先锁」和「写优先锁」。<br>
「<b><font color="#0000ff">读优先锁</font></b>」期望的是,读锁能被更多的线程持有,以便提高读线程的并发性,它的工作方式是:当读线程 A 先持有了读锁,写线程 B 在获取写锁的时候,<br>会被阻塞,并且在阻塞过程中,后续来的读线程 C 仍然可以成功获取读锁,最后直到读线程 A 和 C 释放读锁后,写线程 B 才可以成功获取写锁。如下图:
而「<b><font color="#0000ff">写优先锁</font></b>」是优先服务写线程,其工作方式是:当读线程 A 先持有了读锁,写线程 B 在获取写锁的时候,会被阻塞,并且在阻塞过程中,后续来的读线程 C 获取读锁时会失败,于是读线程 C 将被阻塞在获取读锁的操作,这样只要读线程 A 释放读锁后,写线程 B 就可以成功获取写锁。如下图:
读优先锁对于读线程并发性更好,但也不是没有问题。我们试想一下,如果一直有读<br>线程获取读锁,那么写线程将永远获取不到写锁,这就造成了写线程「饥饿」的现象。
写优先锁可以保证写线程不会饿死,但是如果一直有写线程获取写锁,读线程也会被「饿死」。
既然不管优先读锁还是写锁,对方可能会出现饿死问题,那么我们就不偏袒任何一方,搞个「公平读写锁」。
公平读写锁比较简单的一种方式是:<b><font color="#0000ff">用队列把获取锁的线程排队,不管是写线程还是读线程<br>都按照先进先出的原则加锁即可,这样读线程仍然可以并发,也不会出现「饥饿」的现象</font></b>。
互斥锁和自旋锁都是最基本的锁,读写锁可以根据场景来选择这两种锁其中的一个进行实现。<br>
乐观锁与悲观锁
前面提到的互斥锁、自旋锁、读写锁,都是属于悲观锁。
悲观锁做事比较悲观,它认为 <b><font color="#0000ff">多线程同时修改共享资源的概率比较高,于是很容易出现冲突,所以访问共享资源前,先要上锁</font></b>。
那相反的,如果多线程同时修改共享资源的概率比较低,就可以采用乐观锁。
乐观锁做事比较乐观,它假定冲突的概率很低,它的工作方式是:<b><font color="#0000ff">先修改完共享资源,再验证这段时间内有没有发生<br>冲突,如果没有其他线程在修改资源,那么操作完成,如果发现有其他线程已经修改过这个资源,就放弃本次操作</font></b>。
放弃后如何重试,这跟业务场景息息相关,虽然重试的成本很高,但是冲突的概率足够低的话,还是可以接受的。
可见,乐观锁的心态是,不管三七二十一,先改了资源再说。另外,你会发现 <b><font color="#0000ff">乐观锁全程并没有加锁,所以它也叫无锁编程</font></b>。
这里举一个场景例子:在线文档。
我们都知道在线文档可以同时多人编辑的,如果使用了悲观锁,那么只要有一个用户<br>正在编辑文档,此时其他用户就无法打开相同的文档了,这用户体验当然不好了。
那实现多人同时编辑,实际上是用了乐观锁,它允许多个用户打开同一个文档进行编辑,编辑完提交之后才验证修改的内容是否有冲突。
怎么样才算发生冲突?这里举个例子,比如用户 A 先在浏览器编辑文档,之后用户 B 在浏览器也打开了相同的文档进行编辑,但是<br>用户 B 比用户 A 提交早,这一过程用户 A 是不知道的,当 A 提交修改完的内容时,那么 A 和 B 之间并行修改的地方就会发生冲突。
服务端要怎么验证是否冲突了呢?通常方案如下:<br><br><ul><li>由于发生冲突的概率比较低,所以先让用户编辑文档,但是浏览器在下载文档时会记录下服务端返回的文档 <b><font color="#0000ff">版本号</font></b>;</li><li>当用户提交修改时,发给服务端的请求会带上原始文档版本号,服务器收到后将它与当前版本号进行比较,<br>如果版本号不一致则提交失败,如果版本号一致则修改成功,然后服务端版本号更新到最新的版本号。</li></ul>
实际上,我们常见的 SVN 和 Git 也是用了乐观锁的思想,先让用户编辑代码,然后提交的时候,<br>通过版本号来判断是否产生了冲突,发生了冲突的地方,需要我们自己修改后,再重新提交。
乐观锁虽然去除了加锁解锁的操作,但是一旦发生冲突,重试的成本非常高,<br>所以 <b><font color="#0000ff">只有在冲突概率非常低,且加锁成本非常高的场景时,才考虑使用乐观锁</font></b>。
注意:<br><br><ul><li><b style="font-size: inherit;"><font color="#0000ff">CAS 是乐观锁</font></b><span style="font-size: inherit;">,因为它是一种无锁算法,如果当前资源是无锁状态,则会让当前线程获取该锁。</span></li><li><span style="font-size: inherit;">而 <b><font color="#0000ff">基于 CAS 实现的自旋锁是悲观锁</font></b>,因为自旋锁基于 CAS 加了while 或者睡眠 CPU 的操作而<br></span><span style="font-size: inherit;">产生自旋的效果,加锁失败会忙等待直到拿到锁,是要需要事先拿到锁才能修改数据的。</span></li></ul>
总结
开发过程中,最常见的就是互斥锁的了,互斥锁加锁失败时,会用「线程切换」来应对,<br>当加锁失败的线程再次加锁成功后的这一过程,会有两次线程上下文切换的成本,性能损耗比较大。
如果我们明确知道被锁住的代码的执行时间很短,那我们应该选择开销比较小的自旋锁,因为自旋锁加锁失败时,并不会主动<br>产生线程切换,而是一直忙等待,直到获取到锁,那么如果被锁住的代码执行时间很短,那这个忙等待的时间相对应也很短。
如果能区分读操作和写操作的场景,那读写锁就更合适了,它允许多个读线程可以同时持有读锁,提高了读的并发性。根据偏袒读方还是写方,可以分<br>为读优先锁和写优先锁,读优先锁并发性很强,但是写线程会被饿死,而写优先锁会优先服务写线程,读线程也可能会被饿死,那为了避免饥饿的问题,<br>于是就有了公平读写锁,它是用队列把请求锁的线程排队,并保证先入先出的原则来对线程加锁,这样便保证了某种线程不会被饿死,通用性也更好点。
互斥锁和自旋锁都是最基本的锁,读写锁可以根据场景来选择这两种锁其中的一个进行实现。
乐观锁是先修改同步资源,再验证有没有发生冲突。<br><br>悲观锁是修改共享数据前,都要先加锁,防止竞争。
互斥锁、自旋锁、读写锁都属于悲观锁,悲观锁认为并发访问共享资源时,冲突概率可能非常高,所以在访问共享资源前,都需要先加锁。
相反的,如果并发访问共享资源时,冲突概率非常低的话,就可以使用乐观锁,它的工作方式是,在访问共享资源时,不用先加锁,修改完共享资源后,<br>再验证这段时间内有没有发生冲突,如果没有其他线程在修改资源,那么操作完成,如果发现有其他线程已经修改过这个资源,就放弃本次操作。
但是,一旦冲突概率上升,就不适合使用乐观锁了,因为它解决冲突的重试成本非常高。
<ul><li><b style="font-size: inherit;"><font color="#0000ff">CAS 是乐观锁</font></b><span style="font-size: inherit;">,因为它是一种无锁算法,如果当前资源是无锁状态,则会让当前线程获取该锁。</span></li><li><span style="font-size: inherit;">而 <b><font color="#0000ff">基于 CAS 实现的自旋锁是悲观锁</font></b>,因为自旋锁基于 CAS 加了while 或者睡眠 CPU 的操作而<br></span><span style="font-size: inherit;">产生自旋的效果,加锁失败会忙等待直到拿到锁,是要需要事先拿到锁才能修改数据的。</span></li></ul>
不管使用的哪种锁,我们的加锁的代码范围应该尽可能的小,也就是加锁的粒度要小,这样执行速度会比较快。再来,使用上了合适的锁,就会快上加快了。
一个进程最多可以创建多少个线程?
介绍
<b><font color="#0000ff">一个进程最多可以创建多少个线程?</font></b>这个问题跟两个东西有关系:<br><br><ul><li><b><font color="#0000ff">进程的虚拟内存空间上限</font></b>,因为创建一个线程,操作系统需要为其分配一个栈空间,<br>如果线程数量越多,所需的栈空间就要越大,那么虚拟内存就会占用的越多。</li><li><b><font color="#0000ff">系统参数限制</font></b>,虽然 Linux 并没有内核参数来控制单个进程创建的最大线程个数,<br>但是有系统级别的参数来控制整个系统的最大线程个数。</li></ul>
我们先看看,在进程里创建一个线程需要消耗多少虚拟内存大小?
我们可以执行 ulimit -a 这条命令,查看进程创建线程时默认分配的栈空间大小,比如我这台服务器默认分配给线程的栈空间大小为 8M。
32 位操作系统中创建线程
在前面我们知道,在 <b><font color="#0000ff">32 位</font></b> Linux 系统里,一个进程的虚拟空间是 4G,内核分走了1G,<b><font color="#0000ff">留给用户用的只有 3G</font></b>。
那么假设创建一个线程需要占用 10M 虚拟内存,总共有 3G 虚拟内存可以使用。于是我们可以算出,最多可以创建差不多 300 个(3G/10M)左右的线程。
如果你想自己做个实验,你可以找台 32 位的 Linux 系统运行下面这个代码:<br>
由于我手上没有 32 位的系统,我这里贴一个网上别人做的测试结果:<br>
如果想使得进程创建上千个线程,那么我们可以调整创建线程时分配的栈空间大小,比如调整为 512k:
64 位操作系统中创建线程
说完 32 位系统的情况,我们来看看 64 位系统里,一个进程能创建多少线程呢?
我的测试服务器的配置:<br><br><ul><li>64 位系统;</li><li>2G 物理内存;</li><li>单核 CPU。</li></ul>
64 位系统意味着用户空间的虚拟内存最大值是 128T,这个数值是很大的,如果按创建一个线程需占用 <br>10M 栈空间的情况来算,那么理论上可以创建 128T/10M 个线程,也就是 1000多万个线程,有点魔幻!
所以按 64 位系统的虚拟内存大小,理论上可以创建无数个线程。<br>
事实上,肯定创建不了那么多线程,除了虚拟内存的限制,还有系统的限制。
比如下面这三个内核参数的大小,都会影响创建线程的上限:<br><br><ul><li><b><i><font color="#ff00ff">/proc/sys/kernel/threads-max</font></i></b>,表示系统支持的最大线程数,默认值是 14553;</li><li><b><i><font color="#ff00ff">/proc/sys/kernel/pid_max</font></i></b>,表示系统全局的 PID 号数值的限制,每一个进程或线程都有 ID,<br>ID 的值超过这个数,进程或线程就会创建失败,默认值是 32768;</li><li><b><font color="#ff00ff"><i>/proc/sys/vm/max_map_count</i></font></b>,表示限制一个进程可以拥有的VMA(虚拟内存区域)的数量,<br>具体什么意思我也没搞清楚,反正如果它的值很小,也会导致创建线程失败,默认值是 65530。</li></ul>
那接下针对我的测试服务器的配置,看下一个进程最多能创建多少个线程呢?
我在这台服务器跑了前面的程序,其结果如下:
可以看到,创建了 14374 个线程后,就无法在创建了,而且报错是因为资源的限制。<br>
前面我提到的 threads-max 内核参数,它是限制系统里最大线程数,默认值是 14553。
我们可以运行那个测试线程数的程序后,看下当前系统的线程数是多少,可以通过 top -H 查看。
左上角的 Threads 的数量显示是 14553,与 threads-max 内核参数的值相同,所以我们可以认为是因为这个参数导致无法继续创建线程。
那么,我们可以把 threads-max 参数设置成 99999:
设置完 threads-max 参数后,我们重新跑测试线程数的程序,运行后结果如下图:
可以看到,当进程创建了 32326 个线程后,就无法继续创建里,且报错是无法继续申请内存。
此时的上限个数很接近 pid_max 内核参数的默认值(32768),那么我们可以尝试将这个参数设置为 99999:<br>
设置完 pid_max 参数后,继续跑测试线程数的程序,运行后结果创建线程的个数还是一样卡在了 32768 了。
当时我也挺疑惑的,明明 pid_max 已经调整大后,为什么线程个数还是上不去呢?
后面经过查阅资料发现,max_map_count 这个内核参数也是需要调大的,但是它的数值与<br>最大线程数之间有什么关系,我也不太明白,只是知道它的值是会限制创建线程个数的上限。
然后,我把 max_map_count 内核参数也设置成后 99999:<br>
继续跑测试线程数的程序,结果如下图:
当创建差不多 5 万个线程后,我的服务器就卡住不动了,CPU 都已经被占满了,毕竟这个是单核 CPU,所以现在是 CPU 的瓶颈了。
我只有这台服务器,如果你们有性能更强的服务器来测试的话,有兴趣的小伙伴可以去测试下。
接下来,我们换个思路测试下,把创建线程时分配的栈空间调大,比如调大为 100M,在大就会创建线程失败。
设置完后,跑测试线程的程序,其结果如下:
总共创建了 26390 个线程,然后就无法继续创建了,而且该进程的虚拟内存空间已经高达 25T,要知道这台服务器的物理内存才 2G。
为什么物理内存只有 2G,进程的虚拟内存却可以使用 25T 呢?
因为虚拟内存并不是全部都映射到物理内存的,程序是有局部性的特性,也就是某一个时间只会执行部分代码,所以只需要映射这部分程序就好。
你可以从上面那个 top 的截图看到,虽然进程虚拟空间很大,但是物理内存(RES)只有使用了 400 多M。
总结
32 位系统,用户态的虚拟空间只有 3G,如果创建线程时分配的栈空间是 10M,那么一个进程最多只能创建 300 个左右的线程。
64 位系统,用户态的虚拟空间大到有 128T,理论上不会受虚拟内存大小的限制,而会受系统的参数或性能限制。<br>
线程崩溃了,进程也会崩溃吗?
介绍
之前分享的文章中提到线程的一个缺点:
很多同学就好奇,<b><font color="#0000ff">为什么 C/C++ 语言里,线程崩溃后,进程也会崩溃,<br>而 Java 语言里却不会呢?为什么线程崩溃崩溃不会导致 JVM 崩溃?<br></font></b>
线程崩溃,进程一定会崩溃吗?
一般来说如果线程是因为非法访问内存引起的崩溃,那么进程肯定会崩溃,为什么系统要让进程崩溃呢,这主要是因为在进程中,<br><b><font color="#0000ff">各个线程的地址空间是共享的</font></b>,既然是共享,那么 <b><font color="#0000ff">某个线程对地址的非法访问就会导致内存的不确定性</font></b>,进而可能会影响到其他<br>线程,这种操作是危险的,操作系统会认为这很可能导致一系列严重的后果,于是干脆让整个进程崩溃。
线程共享代码段,数据段,地址空间,文件非法访问内存有以下几种情况,我们以 C 语言举例来看看。
1、针对只读内存写入数据<br>
2、访问了进程没有权限访问的地址空间(比如内核空间)<br>
在 32 位虚拟地址空间中,p 指向的是内核空间,显然不具有写入权限,所以上述赋值操作会导致崩溃
3、访问了不存在的内存,比如:<br>
以上错误都是访问内存时的错误,所以统一会报 Segment Fault 错误(即段错误),这些都会导致进程崩溃<br>
进程是如何崩溃的 - 信号机制简介
那么线程崩溃后,进程是如何崩溃的呢,这背后的机制到底是怎样的,答案是 <b><font color="#0000ff">信号</font></b>。<br>
大家想想要干掉一个正在运行的进程是不是经常用 kill -9 pid 这样的命令,这里的 kill 其实就是给指定 pid 发送终止信号的意思,其中的 9 就是信号。
其实信号有很多类型的,在 Linux 中可以通过 kill -l查看所有可用的信号:<br>
当然了发 kill 信号必须具有一定的权限,否则任意进程都可以通过发信号来终止其他进程,那显然是不合理的,<br>实际上 kill 执行的是系统调用,将控制权转移给了内核(操作系统),由内核来给指定的进程发送信号。
那么发个信号进程怎么就崩溃了呢,这背后的原理到底是怎样的?
其背后的机制如下<br><br><ol><li>CPU 执行正常的进程指令</li><li>调用 kill 系统调用向进程发送信号</li><li><span style="font-size: inherit;">进程收到操作系统发的信号,CPU 暂停当前程序运行,并将控制权转交给操作系统</span></li><li><span style="font-size: inherit;">调用 kill 系统调用向进程发送信号(假设为 11,即 SIGSEGV,一般非法访问内存报的都是这个错误)</span></li><li><b style="font-size: inherit;"><font color="#0000ff">操作系统根据情况执行相应的信号处理程序(函数),一般执行完信号处理程序逻辑后会让进程退出</font></b></li></ol>
注意上面的第五步,如果进程没有注册自己的信号处理函数,那么操作系统会执行默认的信号处理程序(一般最后会让进程退出),<br>但如果注册了,则会执行自己的信号处理函数,这样的话就给了进程一个垂死挣扎的机会,它收到 kill 信号后,可以调用 exit() 来<br>退出,<b><font color="#0000ff">但也可以使用 sigsetjmp,siglongjmp 这两个函数来恢复进程的执行。</font></b>
如代码所示:注册信号处理函数后,当收到 SIGSEGV 信号后,先执行相关的逻辑再退出<br>
另外当进程接收信号之后也可以不定义自己的信号处理函数,而是选择忽略信号,如下:<br>
也就是说虽然给进程发送了 kill 信号,但如果进程自己定义了信号处理函数或者无视信号就有机会逃出生天,<br>当然了 kill -9 命令例外,不管进程是否定义了信号处理函数,都会马上被干掉。
说到这大家是否想起了一道经典面试题:<b><font color="#0000ff">如何让正在运行的 Java 工程的优雅停机?</font></b>
通过上面的介绍大家不难发现,其实是 JVM 自己定义了信号处理函数,这样当发送 kill pid 命令(默认<br>会传 15 也就是 SIGTERM)后,JVM 就可以在信号处理函数中执行一些资源清理之后再调用 exit 退出。
这种场景显然不能用 kill -9,不然一下把进程干掉了资源就来不及清除了。<br>
为什么线程崩溃不会导致 JVM 进程崩溃?
现在我们再来看看开头这个问题,相信你多少会心中有数,想想看在 Java 中有哪些是常见的由于非法访问内存而产生的 Exception 或 error 呢,<br>常见的是大家熟悉的 StackoverflowError 或者 NPE(NullPointerException),NPE 我们都了解,属于是访问了不存在的内存。
但为什么栈溢出(Stackoverflow)也属于非法访问内存呢,这得简单聊一下进程的虚拟空间,也就是前面提到的共享地址空间。
现代操作系统为了保护进程之间不受影响,所以使用了虚拟地址空间来隔离进程,进程的<br>寻址都是针对虚拟地址,每个进程的虚拟空间都是一样的,而线程会共用进程的地址空间。<br>
以 32 位虚拟空间,进程的虚拟空间分布如下:<br>
那么 stackoverflow 是怎么发生的呢?<br>
进程每调用一个函数,都会分配一个栈桢,然后在栈桢里会分配函数里定义的各种局部变量。
假设现在调用了一个无限递归的函数,那就会持续分配栈帧,但 stack 的大小是有限的(Linux 中默认为 8 M,可以通过 ulimit -a 查看),<br>如果无限递归很快栈就会分配完了,此时再调用函数试图分配超出栈的大小内存,就会发生段错误,也就是 stackoverflowError。
好了,现在我们知道了 StackoverflowError 怎么产生的。
那问题来了,既然 StackoverflowError 或者 NPE 都属于非法访问内存, JVM 为什么不会崩溃呢?
有了上一节的铺垫,相信你不难回答,其实就是因为 <b><font color="#0000ff">JVM 自定义了自己的信号处理函数,拦截了 SIGSEGV 信号,针对这两者不让它们崩溃</font></b>。
怎么证明这个推测呢,我们来看下 JVM 的源码来一探究竟。<br>
openJDK 源码解析
HotSpot 虚拟机目前使用范围最广的 Java 虚拟机,据 R 大所述, Oracle JDK 与 <br>OpenJDK 里的 JVM 都是 HotSpot VM,从源码层面说,两者基本上是同一个东西。
OpenJDK 是开源的,所以我们主要研究下 Java 8 的 OpenJDK 即可。
我们只要研究 Linux 下的 JVM,为了便于说明,也方便大家查阅,我把其中关于信号处理的关键流程整理了下(忽略其中的次要代码)。<br>
可以看到,在启动 JVM 的时候,也设置了信号处理函数,收到 SIGSEGV,SIGPIPE 等信号后<br>最终会调用 JVM_handle_linux_signal 这个自定义信号处理函数,再来看下这个函数的主要逻辑。
从以上代码我们可以知道以下信息:<br><br><ul><li>发生 stackoverflow 还有空指针错误,<b><font color="#0000ff">确实都发送了 SIGSEGV,只是虚拟机不选择退出,而是自己内部作了额外的处理,其实<br>是恢复了线程的执行,并抛出 StackoverflowError 和 NPE</font></b>,这就是为什么 JVM 不会崩溃且我们能捕获这两个错误/异常的原因</li><li>如果针对 SIGSEGV 等信号,在以上的函数中 JVM 没有做额外的处理,那么最终会走到 report_and_die 这个方法,<br>这个方法主要做的事情是生成 hs_err_pid_xxx.log crash 文件(记录了一些堆栈信息或错误),然后退出</li></ul>
至此我相信大家明白了为什么发生了 StackoverflowError 和 NPE 这两个非法访问内存的错误,JVM 却没有崩溃。<br>
<b><font color="#0000ff">原因其实就是虚拟机内部定义了信号处理函数,而在信号处理函数中对这两者做了额外的处理以让 JVM 不崩溃,<br>另一方面也可以看出如果 JVM 不对信号做额外的处理,最后会自己退出并产生 crash 文件 hs_err_pid_xxx.log<br>(可以通过 -XX:ErrorFile=/var/log/hs_err.log 这样的方式指定),这个文件记录了虚拟机崩溃的重要原因。</font></b>
所以也可以说,虚拟机是否崩溃只要看它是否会产生此崩溃日志文件。<br>
总结
正常情况下,操作系统为了保证系统安全,所以针对非法内存访问会发送一个 SIGSEGV 信号,<br>而操作系统一般会调用默认的信号处理函数(一般会让相关的进程崩溃)。
但如果进程觉得"罪不致死",那么它也可以选择自定义一个信号处理函数,<br>这样的话它就可以做一些自定义的逻辑,比如记录 crash 信息等有意义的事。
回过头来看为什么虚拟机会针对 StackoverflowError 和 NullPointerException 做额外处理让线程恢复呢,针对 stackoverflow <br>其实它采用了一种栈回溯的方法保证线程可以一直执行下去,而捕获空指针错误主要是这个错误实在太普遍了。
为了这一个很常见的错误而让 JVM 崩溃那线上的 JVM 要宕机多少次,所以出于工程健壮性的考虑,<br>与其直接让 JVM 崩溃倒不如让线程起死回生,并且将这两个错误/异常抛给用户来处理。
调度算法
进程调度算法
介绍
进程调度算法也称 CPU 调度算法,毕竟进程是由 CPU 调度的。
当 CPU 空闲时,操作系统就选择内存中的某个「就绪状态」的进程,并给其分配 CPU。
什么时候会发生 CPU 调度呢?通常有以下情况:<br><br><ol><li><span style="font-size: inherit;">当进程从运行状态转到等待状态;</span></li><li><span style="font-size: inherit;">当进程从运行状态转到就绪状态;</span></li><li><span style="font-size: inherit;">当进程从等待状态转到就绪状态;</span></li><li><span style="font-size: inherit;">当进程从运行状态转到终止状态;</span></li></ol>
其中发生在 1 和 4 两种情况下的调度称为「非抢占式调度」,2 和 3 两种情况下发生的调度称为「抢占式调度」。
非抢占式的意思就是,当进程正在运行时,它就会一直运行,直到该进程完成或发生某个事件而被阻塞时,才会把 CPU 让给其他进程。<br>
而抢占式调度,顾名思义就是进程正在运行的时,可以被打断,使其把 CPU 让给其他进程。<br>那抢占的原则一般有三种,分别是时间片原则、优先权原则、短作业优先原则。<br>
你可能会好奇为什么第 3 种情况也会发生 CPU 调度呢?假设有一个进程是处于等待状态的,但是它的优先级<br>比较高,如果该进程等待的事件发生了,它就会转到就绪状态,一旦它转到就绪状态,如果我们的调度算法是<br>以优先级来进行调度的,那么它就会立马抢占正在运行的进程,所以这个时候就会发生 CPU 调度。
那第 2 种状态通常是时间片到的情况,因为时间片到了就会发生中断,于是就会抢占正在运行的进程,从而占用 CPU。
调度算法影响的是等待时间(进程在就绪队列中等待调度的时间总和),而不能影响进程真在使用 CPU 的时间和 I/O 时间。
接下来,说说常见的调度算法:<br><br><ul><li>先来先服务调度算法</li><li>最短作业优先调度算法</li><li>高响应比优先调度算法</li><li>时间片轮转调度算法</li><li>最高优先级调度算法</li><li>多级反馈队列调度算法</li></ul>
<b><font color="#1b5e20">1、先来先服务调度算法</font></b>
最简单的一个调度算法,就是非抢占式的<b><font color="#0000ff"> 先来先服务(First Come First Serve, FCFS)</font></b>算法了。
顾名思义,先来先到,<b><font color="#0000ff">每次从就绪队列选择最先进入队列的进程,然后一直运行,<br>直到进程退出或被阻塞,才会继续从队列中选择第一个进程接着运行</font></b>。
这似乎很公平,但是当一个长作业先运行了,那么后面的短作业等待的时间就会很长,不利于短作业。
FCFS 对长作业有利,适用于 CPU 繁忙型作业的系统,而不适用于 I/O 繁忙型作业的系统。<br>
<b><font color="#1b5e20">2、最短作业优先调度算法</font></b>
<b><font color="#0000ff">最短作业优先(Shortest Job First, SJF)</font></b>调度算法同样也是顾名思义,它会 <b><font color="#0000ff">优先选择<br>运行时间最短的进程来运行</font></b>,这有助于提高系统的吞吐量。
这显然对长作业不利,很容易造成一种极端现象。
比如,一个长作业在就绪队列等待运行,而这个就绪队列有非常多的短作业,那么<br>就会使得长作业不断的往后推,周转时间变长,致使长作业长期不会被运行。
<b><font color="#1b5e20">3、高响应比优先调度算法</font></b>
前面的「先来先服务调度算法」和「最短作业优先调度算法」都没有很好的权衡短作业和长作业。
那么,<b><font color="#0000ff">高响应比优先 (Highest Response Ratio Next, HRRN)</font></b>调度算法主要是权衡了短作业和长作业。
<b><font color="#0000ff">每次进行进程调度时,先计算「响应比优先级」,然后把「响应比优先级」最高的进程投入运行</font></b>,<br>「响应比优先级」的计算公式:
从上面的公式,可以发现:<br><br><ul><li>如果两个进程的「等待时间」相同时,「要求的服务时间」越短,「响应比」就越高,这样短作业的进程容易被选中运行;</li><li>如果两个进程「要求的服务时间」相同时,「等待时间」越长,「响应比」就越高,这就兼顾到了长作业进程,因为进程<br>的响应比可以随时间等待的增加而提高,当其等待时间足够长时,其响应比便可以升到很高,从而获得运行的机会;</li></ul>
怎么才能知道一个进程要求服务的时间?这不是不可预知的吗?<br><br>对的,这是不可预估的。所以,<b><font color="#0000ff">高响应比优先调度算法是「理想型」的调度算法,现实中是实现不了的</font></b>。
<b><font color="#1b5e20">4、时间片轮转调度算法</font></b>
最古老、最简单、最公平且使用最广的算法就是 <b><font color="#0000ff">时间片轮转(Round Robin, RR)</font></b>调度算法。
<b><font color="#0000ff">每个进程被分配一个时间段,称为时间片(Quantum),即允许该进程在该时间段中运行。<br></font></b><br><ul><li>如果时间片用完,进程还在运行,那么将会把此进程从 CPU 释放出来,并把 CPU 分配给另外一个进程;</li><li>如果该进程在时间片结束前阻塞或结束,则 CPU 立即进行切换;</li></ul>
另外,时间片的长度就是一个很关键的点:<br><br><ul><li>如果时间片设得太短会导致过多的进程上下文切换,降低了 CPU 效率;</li><li>如果设得太长又可能引起对短作业进程的响应时间变长。</li></ul>
一般来说,时间片设为 20ms~50ms 通常是一个比较合理的折中值。<br>
<b><font color="#1b5e20">5、最高优先级调度算法</font></b>
前面的「时间片轮转算法」做了个假设,即让所有的进程同等重要,也不偏袒谁,大家的运行时间都一样。
但是,对于多用户计算机系统就有不同的看法了,它们希望调度是有优先级的,即希望调度程序能 <b><font color="#0000ff">从就绪<br>队列中选择最高优先级的进程进行运行</font></b>,这称为 <b><font color="#0000ff">最高优先级(Highest Priority First,HPF)</font></b>调度算法。
进程的优先级可以分为,静态优先级和动态优先级:<br><br><ul><li>静态优先级:创建进程时候,就已经确定了优先级了,然后整个运行时间优先级都不会变化;</li><li>动态优先级:根据进程的动态变化调整优先级,比如如果进程运行时间增加,则降低其优先级,如果进程<br>等待时间(就绪队列的等待时间)增加,则升高其优先级,也就是 <b><font color="#0000ff">随着时间的推移增加等待进程的优先级</font></b>。</li></ul>
该算法也有两种处理优先级高的方法,非抢占式和抢占式:<br><br><ul><li>非抢占式:当就绪队列中出现优先级高的进程,运行完当前进程,再选择优先级高的进程。</li><li>抢占式:当就绪队列中出现优先级高的进程,当前进程挂起,调度优先级高的进程运行。</li></ul>
但是依然有缺点,可能会导致低优先级的进程永远不会运行。
<b><font color="#1b5e20">6、多级反馈队列调度算法</font></b>
<b><font color="#0000ff">多级反馈队列(Multilevel Feedback Queue)</font></b>调度算法是「时间片轮转算法」和「最高优先级算法」的综合和发展。
顾名思义:<br><br><ul><li>「多级」表示有多个队列,每个队列优先级从高到低,同时优先级越高时间片越短。</li><li>「反馈」表示如果有新的进程加入优先级高的队列时,立刻停止当前正在运行的进程,转而去运行优先级高的队列;</li></ul>
来看看,它是如何工作的:<br><br><ul><li>设置了多个队列,赋予每个队列不同的优先级,<b><font color="#0000ff">每个队列优先级从高到低,同时优先级越高时间片越短</font></b>;</li><li>新的进程会被放入到第一级队列的末尾,按先来先服务的原则排队等待被调度,如果在第一级队列规定<br>的时间片没运行完成,则将其转入到第二级队列的末尾,以此类推,直至完成;</li><li>当较高优先级的队列为空,才调度较低优先级的队列中的进程运行。如果进程运行时,有新进程进入<br>较高优先级的队列,则停止当前运行的进程并将其移入到原队列末尾,接着让较高优先级的进程运行;</li></ul>
可以发现:<br><br><ul><li><b><font color="#0000ff">对于短作业可能可以在第一级队列很快被处理完</font></b>。</li><li><b><font color="#0000ff">对于长作业,如果在第一级队列处理不完,可以移入下次队列<br>等待被执行,虽然等待的时间变长了,但是运行时间也更长了<br></font></b></li></ul><br>所以该算法很好的 <b><font color="#0000ff">兼顾了长短作业,同时有较好的响应时间</font></b>。
<b><font color="#1b5e20">看的迷迷糊糊?那我拿去银行办业务的例子来说明</font></b>
<b><font color="#0000ff">办理业务的客户相当于进程,银行窗口工作人员相当于 CPU。</font></b>
现在,假设这个银行只有一个窗口(单核 CPU ),那么工作人员一次只能处理一个业务。
那么最简单的处理方式,就是先来的先处理,后面来的就乖乖排队,这就是 <b><font color="#0000ff">先来先服务(FCFS)</font></b>调度算法。但是万一先来的<br>这位老哥是来贷款的,这一谈就好几个小时,一直占用着窗口,这样后面的人只能干等,或许后面的人只是想简单的取个钱,<br>几分钟就能搞定,却因为前面老哥办长业务而要等几个小时,你说气不气人?
有客户抱怨了,那我们就要改进,我们干脆优先给那些几分钟就能搞定的人办理业务,这就是 <b><font color="#0000ff">短作业优先(SJF)</font></b>调度算法。听起来不错,但是依然<br>还是有个极端情况,万一办理短业务的人非常的多,这会导致长业务的人一直得不到服务,万一这个长业务是个大客户,那不就捡了芝麻丢了西瓜。
那就公平起见,现在窗口工作人员规定,每个人我只处理 10 分钟。如果 10 分钟之内处理完,就马上换下一个人。<br>如果没处理完,依然换下一个人,但是客户自己得记住办理到哪个步骤了。这个也就是 <b><font color="#0000ff">时间片轮转(RR)</font></b>调度算法。<br>但是如果时间片设置过短,那么就会造成大量的上下文切换,增大了系统开销。如果时间片过长,相当于退化成 FCFS 算法了。
既然公平也可能存在问题,那银行就对客户分等级,分为普通客户、VIP 客户、SVIP 客户。只要高优先级的客户一来,就第一时间处理这个客户,<br>这就是<b><font color="#0000ff">最高优先级(HPF)</font></b>调度算法。但依然也会有极端的问题,万一当天来的全是高级客户,那普通客户不是没有被服务的机会,不把普通客户<br>当人是吗?那我们把优先级改成动态的,如果客户办理业务时间增加,则降低其优先级,如果客户等待时间增加,则升高其优先级。
那有没有兼顾到公平和效率的方式呢?这里介绍一种算法,考虑的还算充分的,<b><font color="#0000ff">多级反馈队列(MFQ)</font></b>调度算法,<br>它是时间片轮转算法和优先级算法的综合和发展。它的工作方式:<br><br><ul><li>银行设置了多个排队(就绪)队列,每个队列都有不同的优先级,各个队列优先级从高到低,<br>同时每个队列执行时间片的长度也不同,优先级越高的时间片越短。</li><li>新客户(进程)来了,先进入第一级队列的末尾,按先来先服务原则排队等待被叫号(运行)。如果时间片<br>用完客户的业务还没办理完成,则让客户进入到下一级队列的末尾,以此类推,直至客户业务办理完成。</li><li>当第一级队列没人排队时,就会叫号二级队列的客户。如果客户办理业务过程中,有新的客户加入到较高优先级的队列,<br>那么此时办理中的客户需要停止办理,回到原队列的末尾等待再次叫号,因为要把窗口让给刚进入较高优先级队列的客户。</li></ul>
可以发现,对于要办理短业务的客户来说,可以很快的轮到并解决。对于要办理长业务的客户,一下子解决不了,就可以放到下一个队列,<br>虽然等待的时间稍微变长了,但是轮到自己的办理时间也变长了,也可以接受,不会造成极端的现象,可以说是综合上面几种算法的优点。<br>
页面置换算法
介绍
在了解内存页面置换算法前,我们得先谈一下 <b><font color="#0000ff">缺页异常(缺页中断)</font></b>。
当 CPU 访问的页面不在物理内存时,便会产生一个缺页中断,请求操作系统将所缺页调入到物理内存。<br>那它与一般中断的主要区别在于:<br><br><ul><li>缺页中断在指令执行「期间」产生和处理中断信号,而一般中断在一条指令执行「完成」后检查和处理中断信号。</li><li>缺页中断返回到该指令的开始重新执行「该指令」,而一般中断返回回到该指令的「下一个指令」执行。</li></ul>
我们来看一下缺页中断的处理流程,如下图:<br><br><ol><li>在 CPU 里访问一条 Load M 指令,然后 CPU 会去找 M 所对应的页表项。</li><li>如果该页表项的状态位是「有效的」,那 CPU 就可以直接去访问物理内存了,如果状态位是「无效的」,<br>则 CPU 则会发送缺页中断请求。</li><li>操作系统收到了缺页中断,则会执行缺页中断处理函数,先会查找该页面在磁盘中的页面的位置。</li><li>找到磁盘中对应的页面后,需要把该页面换入到物理内存中,但是在换入前,需要在物理内存中找空闲页,<br>如果找到空闲页,就把页面换入到物理内存中。</li><li>页面从磁盘换入到物理内存完成后,则把页表项中的状态位修改为「有效的」。</li><li>最后,CPU 重新执行导致缺页异常的指令。</li></ol>
上面所说的过程,第 4 步是能在物理内存找到空闲页的情况,那如果找不到呢?<br>
找不到空闲页的话,就说明此时 <b><font color="#0000ff">内存已满</font></b>,这时候,就需要「<b><font color="#0000ff">页面置换算法</font></b>」选择一个物理页,如果该物理页有被修改过(脏页),<br>则把它换出到磁盘,然后把该被置换出去的页表项的状态改成「无效的」,最后把正在访问的页面装入到这个物理页中。
这里提一下,页表项通常有如下图的字段,那其中:<br><br><ul><li><b><font color="#0000ff">状态位</font></b>:用于表示该页是否有效,也就是说是否在物理内存中,供程序访问时参考。</li><li><b><font color="#0000ff" style="">访问字段</font></b>:用于记录该页在一段时间被访问的次数,供页面置换算法选择出页面时参考。</li><li><b><font color="#0000ff">修改位</font></b>:表示该页在调入内存后是否有被修改过,由于内存中的每一页都在磁盘上保留一份副本,因此,如果没有修改,在置换该页时就<br>不需要将该页写回到磁盘上,以减少系统的开销;如果已经被修改,则将该页重写到磁盘上,以保证磁盘中所保留的始终是最新的副本。</li><li><b><font color="#0000ff">硬盘地址</font></b>:用于指出该页在硬盘上的地址,通常是物理块号,供调入该页时使用。</li></ul>
这里我整理了虚拟内存的管理整个流程,你可以从下面这张图看到:
所以,页面置换算法的功能是,<b><font color="#0000ff">当出现缺页异常,需调入新页面而内存已满时,选择被置换<br>的物理页面</font></b>,也就是说选择一个物理页面换出到磁盘,然后把需要访问的页面换入到物理页。
那其算法目标则是,尽可能减少页面的换入换出的次数,常见的页面置换算法有如下几种:<br><br><ul><li>最佳页面置换算法(OPT)</li><li>先进先出置换算法(FIFO)</li><li>最近最久未使用的置换算法(LRU)</li><li>时钟页面置换算法(Lock)</li><li>最不常用置换算法(LFU)</li></ul>
最佳页面置换算法
最佳页面置换算法基本思路是,<b><font color="#0000ff">置换在「未来」最长时间不访问的页面</font></b>。
所以,该算法实现需要计算内存中每个逻辑页面的「下一次」访问时间,然后比较,选择未来最长时间不访问的页面。
我们举个例子,假设一开始有 3 个空闲的物理页,然后有请求的页面序列,那它的置换过程如下图:<br>
在这个请求的页面序列中,缺页共发生了 7 次(空闲页换入 3 次 + 最优页面置换 4 次),页面置换共发生了 4 次。
这很理想,但是实际系统中无法实现,因为程序访问页面时是动态的,我们是无法预知每个页面在「下一次」访问前的等待时间。
所以,最佳页面置换算法作用是为了衡量你的算法的效率,你的算法效率越接近该算法的效率,那么说明你的算法是高效的。<br>
先进先出置换算法
既然我们无法预知页面在下一次访问前所需的等待时间,那我们可以 <b><font color="#0000ff">选择在内存驻留时间很长的页面进行中置换</font></b>,<br>这个就是「先进先出置换」算法的思想。
还是以前面的请求的页面序列作为例子,假设使用先进先出置换算法,则过程如下图:<br>
在这个请求的页面序列中,缺页共发生了 10 次,页面置换共发生了 7 次,跟最佳页面置换算法比较起来,性能明显差了很多。<br>
最近最久未使用置换算法
最近最久未使用(LRU)的置换算法的基本思路是,发生缺页时,<b><font color="#0000ff">选择最长时间没有被访问的页面进行置换</font></b>,<br>也就是说,该算法假设已经很久没有使用的页面很有可能在未来较长的一段时间内仍然不会被使用。
这种算法近似最优置换算法,最优置换算法是通过「未来」的使用情况来推测要淘汰的页面,<br>而 LRU 则是通过「历史」的使用情况来推测要淘汰的页面。
还是以前面的请求的页面序列作为例子,假设使用最近最久未使用的置换算法,则过程如下图:
在这个请求的页面序列中,缺页共发生了 9 次,页面置换共发生了 6 次,跟先进先出置换算法比较起来,性能提高了一些。
虽然 LRU 在理论上是可以实现的,但代价很高。为了完全实现 LRU,需要在内存中维护一个所有页面的链表,<br>最近最多使用的页面在表头,最近最少使用的页面在表尾。
困难的是,在每次访问内存时都必须要更新「整个链表」。在链表中找到一个页面,删除它,然后把它移动到表头是一个非常费时的操作。
所以,LRU 虽然看上去不错,但是由于开销比较大,实际应用中比较少使用。<br>
时钟页面置换算法
那有没有一种即能优化置换的次数,也能方便实现的算法呢?
时钟页面置换算法就可以两者兼得,它跟 LRU 近似,又是对 FIFO 的一种改进。
该算法的思路是,把所有的页面都保存在一个类似钟面的「环形链表」中,一个表针指向最老的页面。
当发生缺页中断时,算法首先检查表针指向的页面:<br><br><ul><li>如果它的访问位位是 0 就淘汰该页面,并把新的页面插入这个位置,然后把表针前移一个位置;</li><li>如果访问位是 1 就清除访问位,并把表针前移一个位置,重复这个过程直到找到了一个访问位为 0 的页面为止;</li></ul>
我画了一副时钟页面置换算法的工作流程图,你可以在下方看到:<br>
了解了这个算法的工作方式,就明白为什么它被称为时钟(Clock)算法了。<br>
最不常用置换算法
最不常用(LFU)算法,这名字听起来很调皮,但是它的意思不是指这个算法不常用,<br>而是 <b><font color="#0000ff">当发生缺页中断时,选择「访问次数」最少的那个页面,并将其淘汰</font></b>。
它的实现方式是,对每个页面设置一个「访问计数器」,每当一个页面被访问时,<br>该页面的访问计数器就累加 1。在发生缺页中断时,淘汰计数器值最小的那个页面。
看起来很简单,每个页面加一个计数器就可以实现了,但是在操作系统中实现的时候,我们需要考虑效率和硬件成本的。
要增加一个计数器来实现,这个硬件成本是比较高的,另外如果要对这个计数器查找哪个页面访问次数最小,<br>查找链表本身,如果链表长度很大,是非常耗时的,效率不高。
但还有个问题,LFU 算法只考虑了频率问题,没考虑时间的问题,比如有些页面在过去时间里访问的频率很高,但是现在已经没有访问了,<br>而当前频繁访问的页面由于没有这些页面访问的次数高,在发生缺页中断时,就会可能会误伤当前刚开始频繁访问,但访问次数还不高的页面。
那这个问题的解决的办法还是有的,可以定期减少访问的次数,比如当发生时间中断时,把过去时间访问的页面<br>的访问次数除以 2,也就说,随着时间的流失,以前的高访问次数的页面会慢慢减少,相当于加大了被置换的概率。
磁盘调度算法
介绍
我们来看看磁盘的结构,如下图:<br>
常见的机械磁盘是上图左边的样子,中间圆的部分是磁盘的盘片,一般会有多个盘片,每个盘面都有自己的磁头。<br>右边的图就是一个盘片的结构,盘片中的每一层分为多个磁道,每个磁道分多个扇区,每个扇区是 512 字节。<br>那么,多个具有相同编号的磁道形成一个圆柱,称之为磁盘的柱面,如上图里中间的样子。
磁盘调度算法的目的很简单,就是为了提高磁盘的访问性能,一般是通过优化磁盘的访问请求顺序来做到的。
寻道的时间是磁盘访问最耗时的部分,如果请求顺序优化的得当,必然可以节省一些不必要的寻道时间,从而提高磁盘的访问性能。
假设有下面一个请求序列,每个数字代表磁道的位置:<br><br>98,183,37,122,14,124,65,67
初始磁头当前的位置是在第 53 磁道。<br>
接下来,分别对以上的序列,作为每个调度算法的例子,那常见的磁盘调度算法有:<br><br><ul><li>先来先服务算法</li><li>最短寻道时间优先算法</li><li>扫描算法</li><li>循环扫描算法</li><li>LOOK 与 C-LOOK 算法</li></ul>
先来先服务算法
先来先服务(First-Come,First-Served,FCFS),顾名思义,<b><font color="#0000ff">先到来的请求,先被服务</font></b>。
那按照这个序列的话:<br><br>98,183,37,122,14,124,65,67
那么,磁盘的写入顺序是从左到右,如下图:
先来先服务算法总共移动了 640 个磁道的距离,这么一看这种算法,比较简单粗暴,但是如果大量进程竞争<br>使用磁盘,请求访问的磁道可能会很分散,那先来先服务算法在性能上就会显得很差,因为寻道时间过长。
最短寻道时间优先算法
最短寻道时间优先(Shortest Seek First,SSF)算法的工作方式是,<b><font color="#0000ff">优先选择从当前磁头位置所需寻道时间最短的请求</font></b>。
还是以这个序列为例子:<br><br>98,183,37,122,14,124,65,67
那么,那么根据距离磁头( 53 位置)最近的请求的算法,具体的请求则会是下列从左到右的顺序:<br><br>65,67,37,14,98,122,124,183
磁头移动的总距离是 236 磁道,相比先来先服务性能提高了不少。
但这个算法可能存在某些请求的 <b><font color="#0000ff">饥饿</font></b>,因为本次例子我们是静态的序列,看不出问题,假设是一个动态的请求,如果后续来的请求都是<br>小于 183 磁道的,那么 183 磁道可能永远不会被响应,于是就产生了饥饿现象,这里 <b><font color="#0000ff">产生饥饿的原因是磁头在一小块区域来回移动</font></b>。<br>
扫描算法
最短寻道时间优先算法会产生饥饿的原因在于:磁头有可能再一个小区域内来回得移动。<br>
为了防止这个问题,可以规定:<b><font color="#0000ff">磁头在一个方向上移动,访问所有未完成的请求,<br>直到磁头到达该方向上的最后的磁道,才调换方向,这就是扫描(Scan)算法。</font></b>
这种算法也叫做电梯算法,比如电梯保持按一个方向移动,直到在那个方向上没有请求为止,然后改变方向。
还是以这个序列为例子,磁头的初始位置是 53:<br><br>98,183,37,122,14,124,65,67
那么,假设扫描调度算先朝磁道号减少的方向移动,具体请求则会是下列从左到右的顺序:<br><br>37,14,<i><u>0</u></i>,65,67,98,122,124,183
磁头先响应左边的请求,<b><font color="#0000ff">直到到达最左端( 0 磁道)后,才开始反向移动</font></b>,响应右边的请求。
扫描调度算法性能较好,不会产生饥饿现象,但是存在这样的问题,中间部分的磁道会比较占便宜,<br>中间部分相比其他部分响应的频率会比较多,也就是说每个磁道的响应频率存在差异。
循环扫描算法
扫描算法使得每个磁道响应的频率存在差异,那么要优化这个问题的话,可以总是按相同的方向进行扫描,使得每个磁道的响应频率基本一致。
循环扫描(Circular Scan, CSCAN )规定:只有磁头朝某个特定方向移动时,才处理磁道访问请求,而返回时直接快速移动至最靠边缘<br>的磁道,也就是复位磁头,这个过程是很快的,并且 <b><font color="#0000ff">返回中途不处理任何请求,该算法的特点,就是磁道只响应一个方向上的请求</font></b>。
还是以这个序列为例子,磁头的初始位置是 53:<br><br>98,183,37,122,14,124,65,67
那么,假设循环扫描调度算先朝磁道增加的方向移动,具体请求会是下列从左到右的顺序:<br><br>65,67,98,122,124,183,<i><u>199</u></i>,<i><u>0</u></i>,14,37
磁头先响应了右边的请求,直到碰到了最右端的磁道 199,就立即回到磁盘的开始处(磁道 0),<br>但这个<b><font color="#0000ff">返回的途中是不响应任何请求的</font></b>,直到到达最开始的磁道后,才继续顺序响应右边的请求。
循环扫描算法相比于扫描算法,对于各个位置磁道响应频率相对比较平均。<br>
LOOK 与 C-LOOK 算法
我们前面说到的扫描算法和循环扫描算法,都是磁头移动到磁盘「<b><font color="#0000ff">最始端或最末端</font></b>」才开始调换方向。
那这其实是可以优化的,优化的思路就是 <b><font color="#0000ff">磁头在移动到「最远的请求」位置,然后立即反向移动</font></b>。<br>
那针对 SCAN 算法的优化则叫 LOOK 算法,它的工作方式,磁头在每个方向上仅仅移动到最远的请求位置,<br>然后立即反向移动,而不需要移动到磁盘的最始端或最末端,<b><font color="#0000ff">反向移动的途中会响应请求</font></b>。
而针对 C-SCAN 算法的优化则叫 C-LOOK,它的工作方式,磁头在每个方向上仅仅移动到最远的请求<br>位置,然后立即反向移动,而不需要移动到磁盘的最始端或最末端,<b><font color="#0000ff">反向移动的途中不会响应请求</font></b>。
网络系统
什么是零拷贝?
介绍
磁盘可以说是计算机系统最慢的硬件之一,读写速度相差内存 10 倍以上,所以针对优化磁盘的技术非常的多,比如零拷贝、直接 I/O、<br>异步 I/O 等等,这些优化的目的就是为了提高系统的吞吐量,另外操作系统内核中的磁盘高速缓存区,可以有效的减少磁盘的访问次数。
这次,我们就以「文件传输」作为切入点,来分析 I/O 工作方式,以及如何优化传输文件的性能。<br>
为什么要有 DMA 技术?
在没有 DMA 技术前,I/O 的过程是这样的:<br><br><ul><li>CPU 发出对应的指令给磁盘控制器,然后返回;</li><li>磁盘控制器收到指令后,于是就开始准备数据,会把数据放入到磁盘控制器的内部缓冲区中,然后产生一个 <b><font color="#0000ff">中断</font></b>;</li><li>CPU 收到中断信号后,停下手头的工作,接着把磁盘控制器的缓冲区的数据一次一个字节地读进自己的寄存器,<br>然后再把寄存器里的数据写入到内存,而在数据传输的期间 CPU 是无法执行其他任务的。</li></ul>
为了方便你理解,我画了一副图:
可以看到,整个数据的传输过程,都要需要 CPU 亲自参与搬运数据的过程,而且这个过程,CPU 是不能做其他事情的。
简单的搬运几个字符数据那没问题,但是如果我们用千兆网卡或者硬盘传输大量数据的时候,都用 CPU 来搬运的话,肯定忙不过来。
计算机科学家们发现了事情的严重性后,于是就发明了 DMA 技术,也就是 <b><font color="#0000ff">直接内存访问(Direct Memory Access)</font></b> 技术。
什么是 DMA 技术?简单理解就是,<b><font color="#0000ff">在进行 I/O 设备和内存的数据传输的时候,数据搬运的工作全部<br>交给 DMA 控制器,而 CPU 不再参与任何与数据搬运相关的事情,这样 CPU 就可以去处理别的事务。</font></b>
那使用 DMA 控制器进行数据传输的过程究竟是什么样的呢?下面我们来具体看看:<br><br><ul><li>用户进程调用 read 方法,向操作系统发出 I/O 请求,请求读取数据到自己的内存缓冲区中,进程进入阻塞状态;</li><li>操作系统收到请求后,进一步 <b><font color="#0000ff">将 I/O 请求发送 DMA,然后让 CPU 执行其他任务</font></b>;</li><li>DMA 进一步将 I/O 请求发送给磁盘;</li><li>磁盘收到 DMA 的 I/O 请求,把数据从磁盘读取到磁盘控制器的缓冲区中,当磁盘控制器的缓冲区被读满后,<br>向 DMA 发起中断信号,告知自己缓冲区已满;</li><li><b><font color="#0000ff">DMA 收到磁盘的信号,将磁盘控制器缓冲区中的数据拷贝到内核缓冲区中,此时不占用 CPU,CPU 可以执行其他任务</font></b>;</li><li>当 DMA 读取了足够多的数据,就会发送中断信号给 CPU;</li><li>CPU 收到 DMA 的信号,知道数据已经准备好,于是将数据从内核拷贝到用户空间,系统调用返回;</li></ul>
可以看到, <b><font color="#0000ff">CPU 不再参与「将数据从磁盘控制器缓冲区搬运到内核空间」的工作,这部分工作全程由 DMA 完成</font></b>。<br>但是 CPU 在这个过程中也是必不可少的,因为传输什么数据,从哪里传输到哪里,都需要 CPU 来告诉 DMA 控制器。
早期 DMA 只存在在主板上,如今由于 I/O 设备越来越多,数据传输的需求也不尽相同,所以每个 I/O 设备里面都有自己的 DMA 控制器。<br>
传统的文件传输有多糟糕?
如果服务端要提供文件传输的功能,我们能想到的最简单的方式是:将磁盘上的文件读取出来,然后通过网络协议发送给客户端。
传统 I/O 的工作方式是,数据读取和写入是从用户空间到内核空间来回复制,而内核空间的数据是通过操作系统层面的 I/O 接口从磁盘读取或写入。<br>
代码通常如下,一般会需要两个系统调用:<br>
代码很简单,虽然就两行代码,但是这里面发生了不少的事情。<br>
首先,期间共发生了 <b><font color="#0000ff">4 次用户态与内核态的上下文切换</font></b>,因为发生了两次系统调用,一次是 read() ,一次是 write(),<br>每次系统调用都得先从用户态切换到内核态,等内核完成任务后,再从内核态切换回用户态。<br>
上下文切换到成本并不小,一次切换需要耗时几十纳秒到几微秒,虽然时间看上去很短,<br>但是在高并发的场景下,这类时间容易被累积和放大,从而影响系统的性能。
其次,还发生了 <b><font color="#0000ff">4 次数据拷贝</font></b>,其中两次是 DMA 的拷贝,另外两次则是通过 CPU 拷贝的,下面说一下这个过程:<br><br><ul><li>第一次拷贝,把磁盘上的数据拷贝到操作系统内核的缓冲区里,这个拷贝的过程是通过 DMA 搬运的。</li><li>第二次拷贝,把内核缓冲区的数据拷贝到用户的缓冲区里,于是我们应用程序就可以使用这部分数据了,这个拷贝到过程是由 CPU 完成的。</li><li>第三次拷贝,把刚才拷贝到用户的缓冲区里的数据,再拷贝到内核的 socket 的缓冲区里,这个过程依然还是由 CPU 搬运的。</li><li>第四次拷贝,把内核的 socket 缓冲区里的数据,拷贝到网卡的缓冲区里,这个过程又是由 DMA 搬运的。</li></ul>
我们回过头看这个文件传输的过程,我们只是搬运一份数据,结果却搬运了 4 次,过多的数据拷贝无疑会消耗 CPU 资源,大大降低了系统性能。<br>
这种简单又传统的文件传输方式,存在冗余的上文切换和数据拷贝,在高并发系统里是非常糟糕的,多了很多不必要的开销,会严重影响系统性能。
所以,<b><font color="#0000ff">要想提高文件传输的性能,就需要减少「用户态与内核态的上下文切换」和「内存拷贝」的次数</font></b>。<br>
如何优化文件传输的性能?
如何减少「用户态与内核态的上下文切换」的次数呢?<br>
读取磁盘数据的时候,之所以要发生上下文切换,这是因为用户空间没有权限操作磁盘或网卡,内核的权限最高,这些操作设备<br>的过程都需要交由操作系统内核来完成,所以一般要通过内核去完成某些任务的时候,就需要使用操作系统提供的系统调用函数。<br>
而一次系统调用必然会发生 2 次上下文切换:首先从用户态切换到内核态,当内核执行完任务后,再切换回用户态交由进程代码执行。
所以,要想减少上下文切换到次数,就要减少系统调用的次数。<br>
如何减少「数据拷贝」的次数?
在前面我们知道了,传统的文件传输方式会历经 4 次数据拷贝,而且这里面,「从内核的读缓冲区<br>拷贝到用户的缓冲区里,再从用户的缓冲区里拷贝到 socket 的缓冲区里」,这个过程是没有必要的。<br>
因为文件传输的应用场景中,在用户空间我们并不会对数据「再加工」,<br>所以数据实际上可以不用搬运到用户空间,因此 <b><font color="#0000ff">用户的缓冲区是没有必要存在的</font></b>。<br>
如何实现零拷贝?
零拷贝技术实现的方式通常有 2 种:<br><br><ul><li>mmap + write</li><li><span style="font-size: inherit;">sendfile</span></li></ul><br style="font-size: inherit;"><span style="font-size: inherit;">下面就谈一谈,它们是如何减少「上下文切换」和「数据拷贝」的次数。</span><br>
mmap + write
在前面我们知道,read() 系统调用的过程中会把内核缓冲区的数据拷贝到用户的缓冲区里,<br>于是为了减少这一步开销,我们可以用 mmap() 替换 read() 系统调用函数。
mmap() 系统调用函数会直接把内核缓冲区里的数据「<b><font color="#0000ff">映射</font></b>」到用户空间,这样,<b><font color="#0000ff">操作系统内核与用户空间就不需要再进行任何的数据拷贝操作</font></b>。<br><br><ul><li>应用进程调用了 mmap() 后,DMA 会把磁盘的数据拷贝到内核的缓冲区里。接着,应用进程跟操作系统内核「<b><font color="#0000ff">共享</font></b>」这个缓冲区;</li><li>应用进程再调用 write(),操作系统直接将内核缓冲区的数据拷贝到 socket 缓冲区中,这一切都发生在内核态,由 CPU 来搬运数据;</li><li>最后,把内核的 socket 缓冲区里的数据,拷贝到网卡的缓冲区里,这个过程是由 DMA 搬运的。</li></ul>
我们可以得知,通过使用 mmap() 来代替 read(), 可以减少一次数据拷贝的过程。<br>
但这还不是最理想的零拷贝,因为仍然需要通过 CPU 把内核缓冲区的数据拷贝到 socket 缓冲区里,<br>而且仍然需要 4 次上下文切换,因为系统调用还是 2 次。
sendfile
在 Linux 内核版本 2.1 中,提供了一个专门发送文件的系统调用函数 sendfile(),函数形式如下:<br>
它的前两个参数分别是目的端和源端的文件描述符,后面两个参数是源端的偏移量和复制数据的长度,返回值是实际复制数据的长度。
首先,它可以 <b><font color="#0000ff">替代前面的 read() 和 write() 这两个系统调用</font></b>,这样就可以减少一次系统调用,也就减少了 2 次上下文切换的开销。<br>
其次,该系统调用,可以直接把内核缓冲区里的数据拷贝到 socket 缓冲区里,不再拷贝到用户态。
这样就只有 2 次上下文切换,和 3 次数据拷贝。如下图:
但是这还不是真正的零拷贝技术,如果网卡支持 SG-DMA(The Scatter-Gather Direct Memory Access)技术<br>(和普通的 DMA 有所不同),我们可以进一步减少通过 CPU 把内核缓冲区里的数据拷贝到 socket 缓冲区的过程。
你可以在你的 Linux 系统通过下面这个命令,查看网卡是否支持 scatter-gather 特性:<br>
于是,从 Linux 内核 2.4 版本开始起,对于支持网卡支持 SG-DMA 技术的情况下, sendfile() 系统调用的过程发生了点变化,具体过程如下:<br><br><ul><li>第一步,通过 DMA 将磁盘上的数据拷贝到内核缓冲区里;</li><li>第二步,缓冲区描述符和数据长度传到 socket 缓冲区,这样网卡的 SG-DMA 控制器就可以直接将内核缓存中的数据拷贝<br>到网卡的缓冲区里,此过程不需要将数据从操作系统内核缓冲区拷贝到 socket 缓冲区中,这样就减少了一次数据拷贝;</li></ul>
所以,这个过程之中,只进行了 2 次数据拷贝,如下图:<br>
这就是所谓的 <b><font color="#0000ff">零拷贝(Zero-copy)技术,因为我们没有在内存层面去拷贝数据,<br>也就是说全程没有通过 CPU 来搬运数据,所有的数据都是通过 DMA 来进行传输</font></b>。<br>
零拷贝技术的文件传输方式相比传统文件传输的方式,减少了 2 次上下文切换和数据拷贝次数,<b><font color="#0000ff">只需要 2 次上下文切换<br>和数据拷贝次数,就可以完成文件的传输,而且 2 次的数据拷贝过程,都不需要通过 CPU,2 次都是由 DMA 来搬运</font></b>。<br>
所以,总体来看,<b><font color="#0000ff">零拷贝技术可以把文件传输的性能提高至少一倍以上</font></b>。
使用零拷贝技术的项目
事实上,Kafka 这个开源项目,就利用了「零拷贝」技术,从而大幅提升了 I/O 的吞吐率,这也是 Kafka 在处理海量数据为什么这么快的原因之一。<br>
如果你追溯 Kafka 文件传输的代码,你会发现,最终它调用了 Java NIO 库里的 transferTo 方法:<br>
如果 Linux 系统支持 sendfile() 系统调用,那么 transferTo() 实际上最后就会使用到 sendfile() 系统调用函数。<br>
曾经有大佬专门写过程序测试过,在同样的硬件条件下,传统文件传输和零拷拷贝文件传输的性能差异,<br>你可以看到下面这张测试数据图,使用了零拷贝能够缩短 65% 的时间,大幅度提升了机器传输数据的吞吐量。<br>
另外,Nginx 也支持零拷贝技术,一般默认是开启零拷贝技术,这样有利于提高文件传输的效率,是否开启零拷贝技术的配置如下:<br>
sendfile 配置的具体意思:<br><br><ul><li>设置为 on 表示,使用零拷贝技术来传输文件:sendfile ,这样只需要 2 次上下文切换,和 2 次数据拷贝。</li><li>设置为 off 表示,使用传统的文件传输技术:read + write,这时就需要 4 次上下文切换,和 4 次数据拷贝。</li></ul>
当然,要使用 sendfile,Linux 内核版本必须要 2.1 以上的版本。<br>
PageCache 有什么作用?
回顾前面说道文件传输过程,其中第一步都是先需要先把磁盘文件数据拷贝「内核缓冲区」里,<br>这个「内核缓冲区」实际上是 <b><font color="#0000ff">磁盘高速缓存(PageCache)</font></b>。<br>
由于零拷贝使用了 PageCache 技术,可以使得零拷贝进一步提升了性能,我们接下来看看 PageCache 是如何做到这一点的。
读写磁盘相比读写内存的速度慢太多了,所以我们应该想办法把「读写磁盘」替换成「读写内存」。<br>于是,我们会通过 DMA 把磁盘里的数据搬运到内存里,这样就可以用读内存替换读磁盘。<br>
但是,内存空间远比磁盘要小,内存注定只能拷贝磁盘里的一小部分数据。<br>
那问题来了,选择哪些磁盘数据拷贝到内存呢?
我们都知道程序运行的时候,具有「局部性」,所以通常,刚被访问的数据在短时间内再次被访问的概率<br>很高,于是我们可以用 <b><font color="#0000ff">PageCache 来缓存最近被访问的数据</font></b>,当空间不足时淘汰最久未被访问的缓存。<br>
所以,读磁盘数据的时候,优先在 PageCache 找,如果数据存在则可以直接返回;如果没有,则从磁盘中读取,然后缓存 PageCache 中。<br>
还有一点,读取磁盘数据的时候,需要找到数据所在的位置,但是对于机械磁盘来说,就是通过磁头旋转到数据所在的扇区,<br>再开始「顺序」读取数据,但是旋转磁头这个物理动作是非常耗时的,为了降低它的影响,<b><font color="#0000ff">PageCache 使用了「预读功能」</font></b>。<br>
比如,假设 read 方法每次只会读 32 KB 的字节,虽然 read 刚开始只会读 0 ~ 32 KB 的字节,但内核会把其后面的 32~64 KB 也读取<br>到 PageCache,这样后面读取 32~64 KB 的成本就很低,如果在 32~64 KB 淘汰出 PageCache 前,进程读取到它了,收益就非常大。<br>
所以,PageCache 的优点主要是两个:<br><br><ul><li>缓存最近被访问的数据;</li><li>预读功能;</li></ul>
这两个做法,将大大提高读写磁盘的性能。<br>
但是,<b><font color="#0000ff">在传输大文件(GB 级别的文件)的时候,PageCache 会不起作用,那就白白浪费 DMA <br>多做的一次数据拷贝,造成性能的降低,即使使用了 PageCache 的零拷贝也会损失性能。</font></b><br>
这是因为如果你有很多 GB 级别文件需要传输,每当用户访问这些大文件的时候,<br>内核就会把它们载入 PageCache 中,于是 PageCache 空间很快被这些大文件占满。<br>
另外,由于文件太大,可能某些部分的文件数据被再次访问的概率比较低,这样就会带来 2 个问题:<br><br><ul><li>PageCache 由于长时间被大文件占据,其他「热点」的小文件可能就无法充分使用到 PageCache,于是这样磁盘读写的性能就会下降了;</li><li>PageCache 中的大文件数据,由于没有享受到缓存带来的好处,但却耗费 DMA 多拷贝到 PageCache 一次;</li></ul>
所以,针对大文件的传输,不应该使用 PageCache,也就是说不应该使用零拷贝技术,因为可能由于 PageCache <br>被大文件占据,而导致「热点」小文件无法利用到 PageCache,这样在高并发的环境下,会带来严重的性能问题。
大文件传输用什么方式实现?
那针对大文件的传输,我们应该使用什么方式呢?
我们先来看看最初的例子,当调用 read 方法读取文件时,进程实际上会阻塞在 read 方法调用,因为要等待磁盘数据的返回,如下图:<br><br><ul><li>当调用 read 方法时,会阻塞着,此时内核会向磁盘发起 I/O 请求,磁盘收到请求后,便会寻址,<br>当磁盘数据准备好后,就会向内核发起 I/O 中断,告知内核磁盘数据已经准备好;</li><li>内核收到 I/O 中断后,就将数据从磁盘控制器缓冲区拷贝到 PageCache 里;</li><li>最后,内核再把 PageCache 中的数据拷贝到用户缓冲区,于是 read 调用就正常返回了。</li></ul>
对于阻塞的问题,可以用异步 I/O 来解决,它工作方式如下图,它把读操作分为两部分:<br><br><ul><li>前半部分,内核向磁盘发起读请求,但是可以 <b><font color="#0000ff">不等待数据就位就可以返回</font></b>,于是进程此时可以处理其他任务;</li><li>后半部分,当内核将磁盘中的数据拷贝到进程缓冲区后,进程将接收到内核的 <b><font color="#0000ff">通知</font></b>,再去处理数据;</li></ul>
而且,我们可以发现,异步 I/O 并没有涉及到 PageCache,所以使用异步 I/O 就意味着要绕开 PageCache。
绕开 PageCache 的 I/O 叫直接 I/O,使用 PageCache 的 I/O 则叫缓存 I/O。通常,对于磁盘,异步 I/O 只支持直接 I/O。
前面也提到,大文件的传输不应该使用 PageCache,因为可能由于 PageCache 被大文件占据,而导致「热点」小文件无法利用到 PageCache。
于是,<b><font color="#0000ff">在高并发的场景下,针对大文件的传输的方式,应该使用「异步 I/O + 直接 I/O」来替代零拷贝技术</font></b>。<br>
直接 I/O 应用场景常见的两种:<br><br><ul><li>应用程序已经实现了磁盘数据的缓存,那么可以不需要 PageCache 再次缓存,减少额外的性能损耗。<br>在 MySQL 数据库中,可以通过参数设置开启直接 I/O,默认是不开启;</li><li>传输大文件的时候,由于大文件难以命中 PageCache 缓存,而且会占满 PageCache 导致「热点」<br>文件无法充分利用缓存,从而增大了性能开销,因此,这时应该使用直接 I/O。</li></ul>
另外,由于直接 I/O 绕过了 PageCache,就无法享受内核的这两点的优化:<br><br><ul><li>内核的 I/O 调度算法会缓存尽可能多的 I/O 请求在 PageCache 中,最后「<b><font color="#0000ff">合并</font></b>」成一个<br>更大的 I/O 请求再发给磁盘,这样做是为了减少磁盘的寻址操作;</li><li>内核也会「<b><font color="#0000ff">预读</font></b>」后续的 I/O 请求放在 PageCache 中,一样是为了减少对磁盘的操作;</li></ul>
于是,传输大文件的时候,使用「异步 I/O + 直接 I/O」了,就可以无阻塞地读取文件了。
所以,传输文件的时候,我们要根据文件的大小来使用不同的方式:<br><br><ul><li>传输大文件的时候,使用「异步 I/O + 直接 I/O」;</li><li>传输小文件的时候,则使用「零拷贝技术」;</li></ul>
在 nginx 中,我们可以用如下配置,来根据文件的大小来使用不同的方式:<br>
当文件大小大于 directio 值后,使用「异步 I/O + 直接 I/O」,否则使用「零拷贝技术」。<br>
总结
早期 I/O 操作,内存与磁盘的数据传输的工作都是由 CPU 完成的,而此时 CPU 不能执行其他任务,会特别浪费 CPU 资源。
于是,为了解决这一问题,DMA 技术就出现了,每个 I/O 设备都有自己的 DMA 控制器,通过这个 DMA 控制器,CPU 只需要告诉 DMA 控制器,我们<br>要传输什么数据,从哪里来,到哪里去,就可以放心离开了。后续的实际数据传输工作,都会由 DMA 控制器来完成,CPU 不需要参与数据传输的工作。<br>
传统 IO 的工作方式,从硬盘读取数据,然后再通过网卡向外发送,我们需要进行 4 上下文切换,和 4 次数据拷贝,其中 2 次数据拷贝发生在<br>内存里的缓冲区和对应的硬件设备之间,这个是由 DMA 完成,另外 2 次则发生在内核态和用户态之间,这个数据搬移工作是由 CPU 完成的。<br>
为了提高文件传输的性能,于是就出现了零拷贝技术,它通过一次系统调用(sendfile 方法)合并了磁盘读取与<br>网络发送两个操作,降低了上下文切换次数。另外,拷贝数据都是发生在内核中的,天然就降低了数据拷贝的次数。<br>
Kafka 和 Nginx 都有实现零拷贝技术,这将大大提高文件传输的性能。
零拷贝技术是基于 PageCache 的,PageCache 会缓存最近访问的数据,提升了访问缓存数据的性能,同时,为了解决机械硬盘寻址慢<br>的问题,它还协助 I/O 调度算法实现了 IO 合并与预读,这也是顺序读比随机读性能好的原因。这些优势,进一步提升了零拷贝的性能。<br>
需要注意的是,零拷贝技术是不允许进程对文件内容作进一步的加工的,比如压缩数据再发送。
另外,当传输大文件时,不能使用零拷贝,因为可能由于 PageCache 被大文件占据,而导致「热点」小文件<br>无法利用到 PageCache,并且大文件的缓存命中率不高,这时就需要使用「异步 IO + 直接 IO 」的方式。<br>
在 Nginx 里,可以通过配置,设定一个文件大小阈值,针对大文件使用异步 IO 和直接 IO,而对小文件使用零拷贝。<br>
I/O 多路复用:select/poll/epoll
最基本的 Socket 模型
要想客户端和服务器能在网络中通信,那必须得使用 Socket 编程,它是进程间通信里比较特别的方式,特别之处在于它是可以跨主机间通信。
Socket 的中文名叫作插口,咋一看还挺迷惑的。事实上,双方要进行网络通信前,各自得创建一个 Socket,这相当于客户端和服务器都开了一个“口子”,双方读取和发送数据的时候,都通过这个“口子”。这样一看,是不是觉得很像弄了一根网线,一头插在客户端,一头插在服务端,然后进行通信。
创建 Socket 的时候,可以指定网络层使用的是 IPv4 还是 IPv6,传输层使用的是 TCP 还是 UDP。<br>
UDP 的 Socket 编程相对简单些,这里我们只介绍基于 TCP 的 Socket 编程。<br>
服务器的程序要先跑起来,然后等待客户端的连接和数据,我们先来看看服务端的 Socket 编程过程是怎样的。<br>
服务端首先调用 socket() 函数,创建网络协议为 IPv4,以及传输协议为 TCP 的 Socket ,<br>接着调用 bind() 函数,给这个 Socket 绑定一个 <b><font color="#0000ff">IP 地址和端口</font></b>,绑定这两个的目的是什么?<br><br><ul><li>绑定端口的目的:当内核收到 TCP 报文,通过 TCP 头里面的端口号,来找到我们的应用程序,然后把数据传递给我们。</li><li>绑定 IP 地址的目的:一台机器是可以有多个网卡的,每个网卡都有对应的 IP 地址,当绑定一个网卡时,内核在收到该网卡上的包,才会发给我们;</li></ul>
绑定完 IP 地址和端口后,就可以调用 listen() 函数进行监听,此时对应 TCP 状态图中的 listen,如果<br>我们要判定服务器中一个网络程序有没有启动,可以通过 netstat 命令查看对应的端口号是否有被监听。
服务端进入了监听状态后,通过调用 accept() 函数,来从内核获取客户端的连接,如果没有客户端连接,则会阻塞等待客户端连接的到来。<br>
那客户端是怎么发起连接的呢?客户端在创建好 Socket 后,调用 connect() 函数发起连接,<br>该函数的参数要指明服务端的 IP 地址和端口号,然后万众期待的 TCP 三次握手就开始了。<br>
在 TCP 连接的过程中,服务器的内核实际上为每个 Socket 维护了 <b><font color="#0000ff">两个队列</font></b>:<br><br><ul><li>一个是「还没完全建立」连接的队列,称为 <b><font color="#0000ff">TCP 半连接队列</font></b>,这个队列都是没有完成三次握手的连接,此时服务端处于 syn_rcvd 的状态;</li><li>一个是「已经建立」连接的队列,称为 <b><font color="#0000ff">TCP 全连接队列</font></b>,这个队列都是完成了三次握手的连接,此时服务端处于 established 状态;</li></ul>
当 TCP 全连接队列不为空后,服务端的 accept() 函数,就会从内核中的 TCP 全连接队列<br>里拿出一个已经完成连接的 Socket 返回应用程序,后续数据传输都用这个 Socket。<br>
注意,监听的 Socket 和真正用来传数据的 Socket 是两个:<br><br><ul><li>一个叫作 <b><font color="#0000ff">监听 Socket</font></b>;</li><li>一个叫作 <b><font color="#0000ff">已连接 Socket</font></b>;</li></ul>
连接建立后,客户端和服务端就开始相互传输数据了,双方都可以通过 read() 和 write() 函数来读写数据。<br>
至此, TCP 协议的 Socket 程序的调用过程就结束了,整个过程如下图:<br>
看到这,不知道你有没有觉得读写 Socket 的方式,好像读写文件一样。<br>
是的,基于 Linux 一切皆文件的理念,在内核中 Socket 也是以「文件」的形式存在的,也是有对应的文件描述符。<br>
如何服务更多的用户?
前面提到的 TCP Socket 调用流程是最简单、最基本的,它基本只能一对一通信,因为使用的是同步阻塞的方式,<br>当服务端在还没处理完一个客户端的网络 I/O 时,或者 读写操作发生阻塞时,其他客户端是无法与服务端连接的。
可如果我们服务器只能服务一个客户,那这样就太浪费资源了,于是我们要改进这个网络 I/O 模型,以支持更多的客户端。
在改进网络 I/O 模型前,我先来提一个问题,你知道服务器单机理论最大能连接多少个客户端?
相信你知道 TCP 连接是由四元组唯一确认的,这个四元组就是:<b><font color="#0000ff">本机 IP, 本机端口, 对端 IP, 对端端口</font></b>。<br>
服务器作为服务方,通常会在本地固定监听一个端口,等待客户端的连接。因此服务器的本地 IP 和端口是固定的,<br>于是对于服务端 TCP 连接的四元组只有对端 IP 和端口是会变化的,所以 <b><font color="#0000ff">最大 TCP 连接数 = 客户端 IP 数×客户端端口数</font></b>。
对于 IPv4,客户端的 IP 数最多为 2 的 32 次方,客户端的端口数最多为 2 的 16 次方,也就是 <b><font color="#0000ff">服务端单机最大 TCP 连接数约为 2 的 48 次方</font></b>。
这个理论值相当“丰满”,但是服务器肯定承载不了那么大的连接数,主要会受两个方面的限制:<br><br><ul><li><font color="#0000ff"><b>文件描述符</b></font>,Socket 实际上是一个文件,也就会对应一个文件描述符。在 Linux 下,单个进程打开的文件<br>描述符数是有限制的,没有经过修改的值一般都是 1024,不过我们可以通过 ulimit 增大文件描述符的数目;</li><li><b><font color="#0000ff">系统内存</font></b>,每个 TCP 连接在内核中都有对应的数据结构,意味着每个连接都是会占用一定内存的;</li></ul>
那如果服务器的内存只有 2 GB,网卡是千兆的,能支持并发 1 万请求吗?<br>
并发 1 万请求,也就是经典的 C10K 问题 ,C 是 Client 单词首字母缩写,C10K 就是单机同时处理 1 万个请求的问题。
从硬件资源角度看,对于 2GB 内存千兆网卡的服务器,如果每个请求处理占用不到 200KB 的内存和 100Kbit 的网络带宽就可以满足并发 1 万个请求。
不过,要想真正实现 C10K 的服务器,要考虑的地方在于服务器的网络 I/O 模型,效率低的模型,会加重系统开销,从而会离 C10K 的目标越来越远。
多进程模型
基于最原始的阻塞网络 I/O, 如果服务器要支持多个客户端,其中比较传统的方式,就是使用 <b><font color="#0000ff">多进程模型</font></b>,也就是为每个客户端分配一个进程来处理请求。
服务器的主进程负责监听客户的连接,一旦与客户端连接完成,accept() 函数就会返回一个「已连接 Socket」,这时就通过 fork() <br>函数创建一个子进程,实际上就把父进程所有相关的东西都 <b><font color="#0000ff">复制</font></b> 一份,包括文件描述符、内存地址空间、程序计数器、执行的代码等。
这两个进程刚复制完的时候,几乎一模一样。不过,会根据 <b><font color="#0000ff">返回值 </font></b>来区分是父进程还是子进程,<br>如果返回值是 0,则是子进程;如果返回值是其他的整数,就是父进程。
正因为子进程会 <b><font color="#0000ff">复制父进程的文件描述符</font></b>,于是就可以直接使用「已连接 Socket 」和客户端通信了,<br>
可以发现,子进程不需要关心「监听 Socket」,只需要关心「已连接 Socket」;父进程则相反,<br>将客户服务交给子进程来处理,因此父进程不需要关心「已连接 Socket」,只需要关心「监听 Socket」。<br>
下面这张图描述了从连接请求到连接建立,父进程创建生子进程为客户服务。<br>
另外,当「子进程」退出时,实际上内核里还会保留该进程的一些信息,也是会占用内存的,<br>如果不做好“回收”工作,就会变成僵尸进程,随着僵尸进程越多,会慢慢耗尽我们的系统资源。<br>
因此,父进程要“善后”好自己的孩子,怎么善后呢?那么有两种方式可以在子进程退出后回收资源,分别是调用 wait() 和 waitpid() 函数。<br>
这种用多个进程来应付多个客户端的方式,在应对 100 个客户端还是可行的,但是当客户端数量高达一万时,肯定扛不住的,<br>因为每产生一个进程,必会占据一定的系统资源,而且进程间上下文切换的“包袱”是很重的,性能会大打折扣。
进程的上下文切换不仅包含了虚拟内存、栈、全局变量等用户空间的资源,还包括了内核堆栈、寄存器等内核空间的资源。<br>
多线程模型
既然进程间上下文切换的“包袱”很重,那我们就搞个比较轻量级的模型来应对多用户的请求 —— <b><font color="#0000ff">多线程模型</font></b>。<br>
线程是运行在进程中的一个“逻辑流”,单进程中可以运行多个线程,同进程里的线程可以共享进程的部分资源,<br>比如文件描述符列表、进程空间、代码、全局数据、堆、共享库等,这些共享些资源在上下文切换时不需要切换,<br>而只需要切换线程的私有数据、寄存器等不共享的数据,因此同一个进程下的线程上下文切换的开销要比进程小得多。
当服务器与客户端 TCP 完成连接后,通过 pthread_create() 函数创建线程,然后将「已连接 Socket」<br>的文件描述符传递给线程函数,接着在线程里和客户端进行通信,从而达到并发处理的目的。<br>
如果每来一个连接就创建一个线程,线程运行完后,还得操作系统还得销毁线程,虽说<br>线程切换的上写文开销不大,但是如果频繁创建和销毁线程,系统开销也是不小的。
那么,我们可以使用 <b><font color="#0000ff">线程池</font></b> 的方式来避免线程的频繁创建和销毁,所谓的线程池,就是提前创建若干个线程,这样当由新连接<br>建立时,将这个已连接的 Socket 放入到一个队列里,然后线程池里的线程负责从队列中取出「已连接 Socket 」进行处理。<br>
需要注意的是,这个队列是全局的,每个线程都会操作,为了避免多线程竞争,线程在操作这个队列前要加锁。
上面基于进程或者线程模型的,其实还是有问题的。新到来一个 TCP 连接,就需要分配一个进程或者线程,那么如果<br>要达到 C10K,意味着要一台机器维护 1 万个连接,相当于要维护 1 万个进程/线程,操作系统就算死扛也是扛不住的。<br>
I/O 多路复用
既然为每个请求分配一个进程/线程的方式不合适,那有没有可能只使用一个进程来维护多个 Socket 呢?答案是有的,那就是 <b><font color="#0000ff">I/O 多路复用 </font></b>技术。
一个进程虽然任一时刻只能处理一个请求,但是处理每个请求的事件时,耗时控制在 1 毫秒以内,这样 1 秒内就可以处理上千个请求,<br>把时间拉长来看,多个请求复用了一个进程,这就是多路复用,这种思想很类似一个 CPU 并发多个进程,所以也叫做时分多路复用。
我们熟悉的 select/poll/epoll 内核提供给用户态的多路复用系统调用,<b><font color="#0000ff">进程可以通过一个系统调用函数从内核中获取多个事件</font></b>。
select/poll/epoll 是如何获取网络事件的呢?在获取事件时,先把所有连接(文件描述符)<br>传给内核,再由内核返回产生了事件的连接,然后在用户态中再处理这些连接对应的请求即可。
select/poll/epoll 这是三个多路复用接口,都能实现 C10K 吗?接下来,我们分别说说它们。<br>
select/poll
select 实现多路复用的方式是,将已连接的 Socket 都放到一个 <b><font color="#0000ff">文件描述符集合</font></b>,然后调用 select 函数将文件描述符集合 <b><font color="#0000ff">拷贝 </font></b>到内核里,<br>让内核来检查是否有网络事件产生,检查的方式很粗暴,就是通过 <b><font color="#0000ff">遍历</font></b> 文件描述符集合的方式,当检查到有事件产生后,将此 Socket 标记<br>为可读或可写, 接着再把整个文件描述符集合 <b><font color="#0000ff">拷贝回 </font></b>用户态里,然后用户态还需要再通过 <b><font color="#0000ff">遍历 </font></b>的方法找到可读或可写的 Socket,然后再对其处理。
所以,对于 select 这种方式,需要进行 <b><font color="#0000ff">2 次「遍历」文件描述符集合</font></b>,一次是在内核态里,一个次是在用户态里 ,<br>而且还会发生 <b><font color="#0000ff">2 次「拷贝」文件描述符集合</font></b>,先从用户空间传入内核空间,由内核修改后,再传出到用户空间中。
<b><font color="#0000ff">select 使用固定长度的 BitsMap 表示文件描述符集合</font></b>,而且所支持的文件描述符的个数是有限制的,<br>在 Linux 系统中,由内核中的 FD_SETSIZE 限制, 默认最大值为 1024,只能监听 0~1023 的文件描述符。
<b><font color="#0000ff">poll </font></b>不再用 BitsMap 来存储所关注的文件描述符,取而代之 <b><font color="#0000ff">用动态数组</font></b>,以链表形式来组织,<br>突破了 select 的文件描述符个数限制,当然还会受到系统文件描述符限制。
但是 poll 和 select 并没有太大的本质区别,<b><font color="#0000ff">都是使用「线性结构」存储进程关注的 Socket 集合,因此都需要遍历文件描述符集合来找到可读或可写<br>的 Socket,时间复杂度为 O(n),而且也需要在用户态与内核态之间拷贝文件描述符集合</font></b>,这种方式随着并发数上来,性能的损耗会呈指数级增长。<br>
epoll
介绍
先复习下 epoll 的用法。如下的代码中,先用 epoll_create 创建一个 epoll 对象 epfd,<br>再通过 epoll_ctl 将需要监视的 socket 添加到 epfd 中,最后调用 epoll_wait 等待数据。
epoll 通过两个方面,很好解决了 select/poll 的问题。<br>
<i><font color="#ff00ff">第一点 </font></i>:epoll 在内核里使用 <b><font color="#0000ff">红黑树来跟踪进程所有待检测的文件描述字</font></b>,把需要监控的 socket 通过 epoll_ctl() 函数加入内核中<br>的红黑树里,红黑树是个高效的数据结构,增删改一般时间复杂度是 O(logn)。而 select/poll 内核里没有类似 epoll 红黑树这种<br>保存所有待检测的 socket 的数据结构,所以 select/poll 每次操作时都传入整个 socket 集合给内核,而 epoll 因为在内核维护了<br>红黑树,可以保存所有待检测的 socket ,所以只需要传入一个待检测的 socket,减少了内核和用户空间大量的数据拷贝和内存分配。<br>
<i><font color="#ff00ff">第二点</font></i> :epoll 使用 <b><font color="#0000ff">事件驱动 </font></b>的机制,内核里 <b><font color="#0000ff">维护了一个链表来记录就绪事件</font></b>,当某个 socket 有事件发生时,<br>通过 <b><font color="#0000ff">回调函数,</font></b>内核会将其加入到这个就绪事件列表中,当用户调用 epoll_wait() 函数时,只会返回有事件发生<br>的文件描述符的个数,不需要像 select/poll 那样轮询扫描整个 socket 集合,大大提高了检测的效率。<br>
从下图你可以看到 epoll 相关的接口作用:<br>
epoll 的方式即使监听的 Socket 数量越多的时候,效率不会大幅度降低,能够同时监听的 Socket 的数目也<br>非常的多了,上限就为系统定义的进程打开的最大文件描述符个数。因而,<b><font color="#0000ff">epoll 被称为解决 C10K 问题的利器</font></b>。
插个题外话,网上文章不少说,epoll_wait 返回时,对于就绪的事件,epoll 使用的是<br>共享内存的方式,即用户态和内核态都指向了就绪链表,所以就避免了内存拷贝消耗。<br>
这是错的!看过 epoll 内核源码的都知道,压根就没有使用共享内存这个玩意。你可以从下面这份代码看到, <br>epoll_wait 实现的内核代码中调用了 __put_user 函数,这个函数就是将数据从内核拷贝到用户空间。
边缘触发和水平触发
epoll 支持两种事件触发模式,分别是 <b><font color="#0000ff">边缘触发(</font></b><i><font color="#ff00ff">edge-triggered,ET</font></i><b><font color="#0000ff">)和水平触发(</font></b><i><font color="#ff00ff">level-triggered,LT</font></i><b><font color="#0000ff">)</font></b>。<br>
这两个术语还挺抽象的,其实它们的区别还是很好理解的。<br><br><ul><li>使用边缘触发模式时,当被监控的 Socket 描述符上有可读事件发生时,<b><font color="#0000ff">服务器端只会从 epoll_wait 中苏醒一次</font></b>,即使<br>进程没有调用 read 函数从内核读取数据,也依然只苏醒一次,因此我们程序要保证一次性将内核缓冲区的数据读取完;</li><li>使用水平触发模式时,当被监控的 Socket 上有可读事件发生时,<b><font color="#0000ff">服务器端不断地从 epoll_wait 中苏醒,<br>直到内核缓冲区数据被 read 函数读完才结束</font></b>,目的是告诉我们有数据需要读取;</li></ul>
举个例子,你的快递被放到了一个快递箱里,如果快递箱只会通过短信通知你一次,即使你一直没有去取,它也不会再发送第二条短信提醒你,这个<br>方式就是边缘触发;如果快递箱发现你的快递没有被取出,它就会不停地发短信通知你,直到你取出了快递,它才消停,这个就是水平触发的方式。
这就是两者的区别:<br><br><ul><li>水平触发的意思是只要满足事件的条件,比如内核中有数据需要读,就一直不断地把这个事件传递给用户;</li><li>而边缘触发的意思是只有第一次满足条件的时候才触发,之后就不会再传递同样的事件了。</li></ul>
如果使用水平触发模式,当内核通知文件描述符可读写时,接下来还可以继续去检测它的状态,<br>看它是否依然可读或可写。所以在收到通知后,没必要一次执行尽可能多的读写操作。<br>
如果使用边缘触发模式,I/O 事件发生时只会通知一次,而且我们不知道到底能读写多少数据,所以在收到通知后应尽可能地读写数据,<br>以免错失读写的机会。因此,我们会 <b><font color="#0000ff">循环</font></b> 从文件描述符读写数据,那么如果文件描述符是阻塞的,没有数据可读写时,进程会阻塞在<br>读写函数那里,程序就没办法继续往下执行。所以,<b><font color="#0000ff">边缘触发模式一般和非阻塞 I/O 搭配使用</font></b>,程序会一直执行 I/O 操作,直到系统<br>调用(如 read 和 write)返回错误,错误类型为 EAGAIN 或 EWOULDBLOCK。
一般来说,边缘触发的效率比水平触发的效率要高,因为边缘触发可以减少 epoll_wait <br>的系统调用次数,系统调用也是有一定的开销的的,毕竟也存在上下文的切换。
select/poll 只有水平触发模式,epoll 默认的触发模式是水平触发,但是可以根据应用场景设置为边缘触发模式。<br>
另外,使用 I/O 多路复用时,最好搭配非阻塞 I/O 一起使用,Linux 手册关于 select 的内容中有如下说明:<br>
我谷歌翻译的结果:<br>
简单点理解,就是 <b><font color="#0000ff">多路复用 API 返回的事件并不一定可读写的</font></b>,如果使用阻塞 I/O, 那么在<br>调用 read/write 时则会发生程序阻塞,因此最好搭配非阻塞 I/O,以便应对极少数的特殊情况。<br>
总结
最基础的 TCP 的 Socket 编程,它是阻塞 I/O 模型,基本上只能一对一通信,那为了服务更多的客户端,我们需要改进网络 I/O 模型。
比较传统的方式是使用多进程/线程模型,每来一个客户端连接,就分配一个进程/线程,然后后续的读写都在对应的进程/线程,这种方式<br>处理 100 个客户端没问题,但是当客户端增大到 10000 个时,10000 个进程/线程的调度、上下文切换以及它们占用的内存,都会成为瓶颈。<br>
为了解决上面这个问题,就出现了 I/O 的多路复用,可以只在一个进程里处理多个文件的 I/O,<br>Linux 下有三种提供 I/O 多路复用的 API,分别是:select、poll、epoll。<br>
select 和 poll 并没有本质区别,它们内部都是使用「线性结构」来存储进程关注的 Socket 集合。<br>
在使用的时候,首先需要把关注的 Socket 集合通过 select/poll 系统调用从用户态拷贝到内核态,然后由内核检测事件,<br>当有网络事件产生时,内核需要遍历进程关注 Socket 集合,找到对应的 Socket,并设置其状态为可读/可写,然后把<br>整个 Socket 集合从内核态拷贝到用户态,用户态还要继续遍历整个 Socket 集合找到可读/可写的 Socket,然后对其处理。<br>
很明显发现,select 和 poll 的缺陷在于,当客户端越多,也就是 Socket 集合越大,Socket 集合的遍历和拷贝会带来很大的开销,因此也很难应对 C10K。
epoll 是解决 C10K 问题的利器,通过两个方面解决了 select/poll 的问题。<br><br><ul><li>epoll 在内核里使用「红黑树」来关注进程所有待检测的 Socket,红黑树是个高效的数据结构,增删改一般时间复杂度是 O(logn),通过<br>对这棵黑红树的管理,不需要像 select/poll 在每次操作时都传入整个 Socket 集合,减少了内核和用户空间大量的数据拷贝和内存分配。</li><li>epoll 使用事件驱动的机制,内核里维护了一个「链表」来记录就绪事件,只将有事件发生的 Socket 集合传递给应用程序,<br>不需要像 select/poll 那样轮询扫描整个集合(包含有和无事件的 Socket ),大大提高了检测的效率。</li></ul>
而且,epoll 支持边缘触发和水平触发的方式,而 select/poll 只支持水平触发,一般而言,边缘触发的方式会比水平触发的效率高。<br>
高性能网络模式:Reactor 和 Proactor
演进
如果要让服务器服务多个客户端,那么最直接的方式就是为每一条连接创建线程。<br>
其实创建进程也是可以的,原理是一样的,进程和线程的区别在于线程比较轻量级些,<br>线程的创建和线程间切换的成本要小些,为了描述简述,后面都以线程为例。<br>
处理完业务逻辑后,随着连接关闭后线程也同样要销毁了,但是这样不停地创建和销毁线程,不仅会带来<br>性能开销,也会造成浪费资源,而且如果要连接几万条连接,创建几万个线程去应对也是不现实的。
要这么解决这个问题呢?我们可以使用「资源复用」的方式。<br>
也就是不用再为每个连接创建线程,而是创建一个「<b><font color="#0000ff">线程池</font></b>」,将连接分配给线程,然后一个线程可以处理多个连接的业务。<br>
不过,这样又引来一个新的问题,线程怎样才能高效地处理多个连接的业务?<br>
当一个连接对应一个线程时,线程一般采用「read -> 业务处理 -> send」的处理流程,如果当前连接没有数据可读,<br>那么线程会阻塞在 read 操作上( socket 默认情况是阻塞 I/O),不过这种阻塞方式并不影响其他线程。<br>
但是引入了线程池,那么一个线程要处理多个连接的业务,线程在处理某个连接的 read 操作时,<br>如果遇到没有数据可读,就会发生阻塞,那么线程就没办法继续处理其他连接的业务。<br>
要解决这一个问题,最简单的方式就是将 socket 改成非阻塞,然后线程不断地轮询调用 read 操作来判断是否有数据,这种方式虽然该能够解决阻塞的问题,但是解决的方式比较粗暴,因为轮询是要消耗 CPU 的,而且随着一个 线程处理的连接越多,轮询的效率就会越低。
上面的问题在于,<b><font color="#0000ff">线程并不知道当前连接是否有数据可读</font></b>,从而需要每次通过 read 去试探。<br>
那有没有办法在只有当连接上有数据的时候,线程才去发起读请求呢?答案是有的,实现这一技术的就是 I/O 多路复用。<br>
I/O 多路复用技术会用一个系统调用函数来监听我们所有关心的连接,也就说可以在一个监控线程里面监控很多的连接。<br>
我们熟悉的 select/poll/epoll 就是内核提供给用户态的多路复用系统调用,线程可以通过一个系统调用函数从内核中获取多个事件。<br>
select/poll/epoll 是如何获取网络事件的呢?
在获取事件时,先把我们要关心的连接传给内核,再由内核检测:<br><br><ul><li>如果没有事件发生,线程只需阻塞在这个系统调用,而无需像前面的线程池方案那样轮训调用 read 操作来判断是否有数据。</li><li>如果有事件发生,内核会返回产生了事件的连接,线程就会从阻塞状态返回,然后在用户态中再处理这些连接对应的业务即可。</li></ul>
当下开源软件能做到网络高性能的原因就是 I/O 多路复用吗?<br>
是的,基本是基于 I/O 多路复用,用过 I/O 多路复用接口写网络程序的同学,肯定知道是面向过程的方式写代码的,这样的开发的效率不高。<br>
于是,大佬们基于面向对象的思想,对 I/O 多路复用作了一层封装,让使用者不用考虑底层网络 API 的细节,只需要关注应用代码的编写。<br>
大佬们还为这种模式取了个让人第一时间难以理解的名字:<b><font color="#0000ff">Reactor 模式</font></b>。<br>
Reactor 翻译过来的意思是「反应堆」,可能大家会联想到物理学里的核反应堆,实际上并不是的这个意思。<br>
这里的反应指的是「<b><font color="#0000ff">对事件反应</font></b>」,也就是 <b><font color="#0000ff">来了一个事件,Reactor 就有相对应的反应/响应</font></b>。<br>
事实上,Reactor 模式也叫 Dispatcher 模式,我觉得这个名字更贴合该模式的含义,即 <br><b><font color="#0000ff">I/O 多路复用监听事件,收到事件后,根据事件类型分配(Dispatch)给某个进程 / 线程</font></b>。
Reactor 模式主要由 Reactor 和处理资源池这两个核心部分组成,它俩负责的事情如下:<br><br><ul><li>Reactor 负责监听和分发事件,事件类型包含连接事件、读写事件;</li><li>处理资源池负责处理事件,如 read -> 业务逻辑 -> send;</li></ul>
Reactor 模式是灵活多变的,可以应对不同的业务场景,灵活在于:<br><br><ul><li>Reactor 的数量可以只有一个,也可以有多个;</li><li>处理资源池可以是单个进程 / 线程,也可以是多个进程 /线程;</li></ul>
将上面的两个因素排列组设一下,理论上就可以有 4 种方案选择:<br><br><ul><li>单 Reactor 单进程 / 线程;</li><li>单 Reactor 多进程 / 线程;</li><li>多 Reactor 单进程 / 线程;</li><li>多 Reactor 多进程 / 线程;</li></ul>
其中,「多 Reactor 单进程 / 线程」实现方案相比「单 Reactor 单进程 / 线程」方案,<br>不仅复杂而且也没有性能优势,因此实际中并没有应用。
剩下的 3 个方案都是比较经典的,且都有应用在实际的项目中:<br><br><ul><li>单 Reactor 单进程 / 线程;</li><li>单 Reactor 多线程 / 进程;</li><li>多 Reactor 多进程 / 线程;</li></ul>
方案具体使用进程还是线程,要看使用的编程语言以及平台有关:<br><br><ul><li>Java 语言一般使用线程,比如 Netty;</li><li>C 语言使用进程和线程都可以,例如 Nginx 使用的是进程,Memcache 使用的是线程。</li></ul>
接下来,分别介绍这三个经典的 Reactor 方案。<br>
Reactor
单 Reactor 单进程 / 线程<br>
一般来说,C 语言实现的是「<b><font color="#0000ff">单 Reactor 单进程</font></b>」的方案,因为 C 语编写完的程序,运行后就是一个独立的进程,不需要在进程中再创建线程。
而 Java 语言实现的是「<b><font color="#0000ff">单 Reactor 单线程</font></b>」的方案,因为 Java 程序是跑在 Java 虚拟机<br>这个进程上面的,虚拟机中有很多线程,我们写的 Java 程序只是其中的一个线程而已。
我们来看看「单 Reactor 单进程」的方案示意图:
可以看到进程里有 <b><font color="#0000ff">Reactor、Acceptor、Handler</font></b> 这三个对象:<br><br><ul><li>Reactor 对象的作用是监听和分发事件;</li><li>Acceptor 对象的作用是获取连接;</li><li>Handler 对象的作用是处理业务;</li></ul>
对象里的 select、accept、read、send 是系统调用函数,dispatch 和 「业务处理」是需要完成的操作,其中 dispatch 是分发事件操作。<br>
接下来,介绍下「单 Reactor 单进程」这个方案:<br><br><ul><li>Reactor 对象通过 select (IO 多路复用接口) 监听事件,收到事件后通过 dispatch <br>进行分发,具体分发给 Acceptor 对象还是 Handler 对象,还要看收到的事件类型;</li><li>如果是连接建立的事件,则交由 Acceptor 对象进行处理,Acceptor 对象会通过 <br>accept 方法 获取连接,并创建一个 Handler 对象来处理后续的响应事件;</li><li>如果不是连接建立事件, 则交由当前连接对应的 Handler 对象来进行响应;</li><li>Handler 对象通过 read -> 业务处理 -> send 的流程来完成完整的业务流程。</li></ul>
单 Reactor 单进程的方案因为全部工作都在同一个进程内完成,所以实现起来比较简单,不需要考虑进程间通信,也不用担心多进程竞争。
但是,这种方案存在 2 个缺点:<br><br><ul><li>第一个缺点,因为只有一个进程,<b><font color="#0000ff">无法充分利用 多核 CPU 的性能</font></b>;</li><li>第二个缺点,Handler 对象在业务处理时,整个进程是无法处理其他<br>连接的事件的,<b><font color="#0000ff">如果业务处理耗时比较长,那么就造成响应的延迟</font></b>;</li></ul>
所以,单 Reactor 单进程的方案 <b><font color="#0000ff">不适用计算机密集型的场景,只适用于业务处理非常快速的场景</font></b>。
Redis 是由 C 语言实现的,在 Redis 6.0 版本之前采用的正是「单 Reactor 单进程」的方案,因为 Redis 业务处理<br>主要是在内存中完成,操作的速度是很快的,性能瓶颈不在 CPU 上,所以 Redis 对于命令的处理是单进程的方案。<br>
单 Reactor 多线程 / 多进程<br>
如果要克服「单 Reactor 单线程 / 进程」方案的缺点,那么就需要引入多线程 / 多进程,这样就产生了 <b><font color="#0000ff">单 Reactor 多线程 / 多进程 </font></b>的方案。
闻其名不如看其图,先来看看「单 Reactor 多线程」方案的示意图如下:<br>
<span style="font-size: inherit;">详细说一下这个方案:</span><br><br><ul><li><span style="font-size: inherit;">Reactor 对象通过 select (IO 多路复用接口) 监听事件,收到事件后通过 dispatch 进行分发,<br>具体分发给 Acceptor 对象还是 Handler 对象,还要看收到的事件类型;</span></li><li><span style="font-size: inherit;">如果是连接建立的事件,则交由 Acceptor 对象进行处理,Acceptor 对象会通过 accept 方法 <br>获取连接,并创建一个 Handler 对象来处理后续的响应事件;</span></li><li><span style="font-size: inherit;">如果不是连接建立事件, 则交由当前连接对应的 Handler 对象来进行响应;<br><br></span></li></ul><span style="font-size: inherit;">上面的三个步骤和单 Reactor 单线程方案是一样的,接下来的步骤就开始不一样了:<br></span><br><ul><li><span style="font-size: inherit;"><b><font color="#0000ff">Handler 对象不再负责业务处理</font></b>,只负责数据的接收和发送,Handler 对象通过 read 读取<br>到数据后,会将数据发给子线程里的 Processor 对象进行业务处理;</span></li><li><span style="font-size: inherit;">子线程里的 Processor 对象就进行业务处理,处理完后,将结果发给主线程中的 Handler 对象,<br>接着由 Handler 通过 send 方法将响应结果发送给 client;</span></li></ul>
单 Reator 多线程的方案优势在于 <b><font color="#0000ff">能够充分利用多核 CPU 的性能</font></b>,那既然引入多线程,那么自然就带来了多线程竞争资源的问题。<br>
例如,子线程完成业务处理后,要把结果传递给主线程的 Handler 进行发送,这里涉及共享数据的竞争。<br>
要避免多线程由于竞争共享资源而导致数据错乱的问题,就需要在操作共享资源前加上互斥锁,以保证任意<br>时间里只有一个线程在操作共享资源,待该线程操作完释放互斥锁后,其他线程才有机会操作共享数据。<br>
聊完单 Reactor 多线程的方案,接着来看看单 Reactor 多进程的方案。<br>
事实上,单 Reactor 多进程相比单 Reactor 多线程实现起来很麻烦,主要因为要考虑<br>子进程 <-> 父进程的双向通信,并且父进程还得知道子进程要将数据发送给哪个客户端。<br>
而 <b><font color="#0000ff">多线程间可以共享数据</font></b>,虽然要额外 <b><font color="#0000ff">考虑并发问题</font></b>,但是这远比进程间通信的复杂度低得多,<br>因此实际应用中也看不到单 Reactor 多进程的模式。<br>
另外,「单 Reactor」的模式还有个问题,<b><font color="#0000ff">因为一个 Reactor 对象承担所有事件的监听和<br>响应,而且只在主线程中运行,在面对瞬间高并发的场景时,容易成为性能的瓶颈的地方。</font></b><br>
多 Reactor 多进程 / 线程<br>
要解决「单 Reactor」的问题,就是将「单 Reactor」实现成「多 Reactor」,这样就产生了第<b><font color="#0000ff"> 多 Reactor 多进程 / 线程 </font></b>的方案。<br>
老规矩,闻其名不如看其图。多 Reactor 多进程 / 线程方案的示意图如下(以线程为例):<br>
方案详细说明如下:<br><br><ul><li>主线程中的 MainReactor 对象通过 select 监控连接建立事件,收到事件后通过 Acceptor <br>对象中的 accept 获取连接,将新的连接分配给某个子线程;</li><li>子线程中的 SubReactor 对象将 MainReactor 对象分配的连接加入 select 继续进行监听,<br>并创建一个 Handler 用于处理连接的响应事件。</li><li>如果有新的事件发生时,SubReactor 对象会调用当前连接对应的 Handler 对象来进行响应。</li><li>Handler 对象通过 read -> 业务处理 -> send 的流程来完成完整的业务流程。</li></ul>
多 Reactor 多线程的方案虽然看起来复杂的,但是实际实现时比单 Reactor 多线程的方案要简单的多,原因如下:<br><br><ul><li>主线程和子线程分工明确,主线程只负责接收新连接,子线程负责完成后续的业务处理。</li><li>主线程和子线程的交互很简单,主线程只需要把新连接传给子线程,子线程无须返回数据,<br>直接就可以在子线程将处理结果发送给客户端。</li></ul>
大名鼎鼎的两个开源软件 Netty 和 Memcache 都采用了「多 Reactor 多线程」的方案。<br>
采用了「多 Reactor 多进程」方案的开源软件是 Nginx,不过方案与标准的多 Reactor 多进程有些差异。<br>
具体差异表现在主进程中仅仅用来初始化 socket,并没有创建 mainReactor 来 accept 连接,而是由子进程的 Reactor 来 accept 连接,通过锁来<br>控制一次只有一个子进程进行 accept(防止出现惊群现象),子进程 accept 新连接后就放到自己的 Reactor 进行处理,不会再分配给其他子进程。<br>
Proactor
前面提到的 Reactor 是非阻塞同步网络模式,而 <b><font color="#0000ff">Proactor 是异步网络模式</font></b>。<br>
这里先给大家复习下阻塞、非阻塞、同步、异步 I/O 的概念。
先来看看阻塞 I/O,当用户程序执行 read ,线程会被阻塞,一直等到内核数据准备好,<br>并把数据从内核缓冲区拷贝到应用程序的缓冲区中,当拷贝过程完成,read 才会返回。<br>
注意,<b><font color="#0000ff">阻塞等待的是「内核数据准备好」和「数据从内核态拷贝到用户态」这两个过程</font></b>。过程如下图:<br>
知道了阻塞 I/O ,来看看 <b><font color="#0000ff">非阻塞 I/O</font></b>,非阻塞的 read 请求在数据未准备好的情况下立即返回,可以继续往下执行,此时<br>应用程序不断轮询内核,直到数据准备好,内核将数据拷贝到应用程序缓冲区,read 调用才可以获取到结果。过程如下图:
注意,<b><font color="#0000ff">这里最后一次 read 调用,获取数据的过程,是一个同步的过程,是需要等待的过程。<br>这里的同步指的是内核态的数据拷贝到用户程序的缓存区这个过程。</font></b>
举个例子,如果 socket 设置了 O_NONBLOCK 标志,那么就表示使用的是非阻塞 I/O 的方式访问,而不做任何设置的话,默认是阻塞 I/O。<br>
因此,无论 read 和 send 是阻塞 I/O,还是非阻塞 I/O 都是同步调用。因为在 read 调用时,内核将数据从内核空间拷贝到用户空间的<br>过程都是需要等待的,也就是说这个过程是同步的,如果内核实现的拷贝效率不高,read 调用就会在这个同步过程中等待比较长的时间。<br>
而真正的 <b><font color="#0000ff">异步 I/O</font></b> 是「内核数据准备好」和「数据从内核态拷贝到用户态」<b><font color="#0000ff">这两个过程都不用等待</font></b>。<br>
当我们发起 aio_read (异步 I/O) 之后,就立即返回,内核自动将数据从内核空间拷贝到用户空间,这个拷贝过程<br>同样是异步的,内核自动完成的,和前面的同步操作不一样,应用程序并不需要主动发起拷贝动作。过程如下图:<br>
举个你去饭堂吃饭的例子,你好比应用程序,饭堂好比操作系统。<br>
阻塞 I/O 好比,你去饭堂吃饭,但是饭堂的菜还没做好,然后你就一直在那里等啊等,等了好长一段时间终于等到饭堂阿姨把菜端了<br>出来(数据准备的过程),但是你还得继续等阿姨把菜(内核空间)打到你的饭盒里(用户空间),经历完这两个过程,你才可以离开。<br>
非阻塞 I/O 好比,你去了饭堂,问阿姨菜做好了没有,阿姨告诉你没,你就离开了,过几十分钟,<br>你又来饭堂问阿姨,阿姨说做好了,于是阿姨帮你把菜打到你的饭盒里,这个过程你是得等待的。<br>
异步 I/O 好比,你让饭堂阿姨将菜做好并把菜打到饭盒里后,把饭盒送到你面前,整个过程你都不需要任何等待。
很明显,异步 I/O 比同步 I/O 性能更好,因为异步 I/O 在「内核数据准备好」和「数据从内核空间拷贝到用户空间」这两个过程都不用等待。<br>
子主题
现在我们再来理解 Reactor 和 Proactor 的区别,就比较清晰了。<br><br><ul><li><b><font color="#0000ff">Reactor 是非阻塞同步网络模式,感知的是就绪可读写事件</font></b>。在每次感知到有事件发生(比如可读就绪事件)后,<br>就需要应用进程主动调用 read 方法来完成数据的读取,也就是要应用进程主动将 socket 接收缓存中的数据读到<br>应用进程内存中,这个过程是同步的,读取完数据后应用进程才能处理数据。</li><li><b><font color="#0000ff">Proactor 是异步网络模式, 感知的是已完成的读写事件</font></b>。在发起异步读写请求时,需要传入数据缓冲区的地址<br>(用来存放结果数据)等信息,这样系统内核才可以自动帮我们把数据的读写工作完成,这里的读写工作全程由<br>操作系统来做,并不需要像 Reactor 那样还需要应用进程主动发起 read/write 来读写数据,操作系统完成读写<br>工作后,就会通知应用进程直接处理数据。</li></ul>
因此,<b><font color="#0000ff">Reactor 可以理解为「来了事件操作系统通知应用进程,让应用进程来处理」</font></b>,而 <b><font color="#0000ff">Proactor 可以理解为「来了事件操作系统来处理,处理完再通知<br>应用进程」</font></b>。这里的「事件」就是有新连接、有数据可读、有数据可写的这些 I/O 事件这里的「处理」包含从驱动读取到内核以及从内核读取到用户空间。<br>
举个实际生活中的例子,Reactor 模式就是快递员在楼下,给你打电话告诉你快递到你家小区了,<br>你需要自己下楼来拿快递。而在 Proactor 模式下,快递员直接将快递送到你家门口,然后通知你。<br>
无论是 Reactor,还是 Proactor,都是一种基于「事件分发」的网络编程模式,区别在于 <br><b><font color="#0000ff">Reactor 模式是基于「待完成」的 I/O 事件,而 Proactor 模式则是基于「已完成」的 I/O 事件</font></b>。
接下来,一起看看 Proactor 模式的示意图:<br>
介绍一下 Proactor 模式的工作流程:<br><br><ul><li>Proactor Initiator 负责创建 Proactor 和 Handler 对象,并将 Proactor <br>和 Handler 都通过 Asynchronous Operation Processor 注册到内核;</li><li>Asynchronous Operation Processor 负责处理注册请求,并处理 I/O 操作;</li><li>Asynchronous Operation Processor 完成 I/O 操作后通知 Proactor;</li><li>Proactor 根据不同的事件类型回调不同的 Handler 进行业务处理;</li><li>Handler 完成业务处理;</li></ul>
可惜的是,在 Linux 下的异步 I/O 是不完善的, aio 系列函数是由 POSIX 定义的异步操作接口,不是真正的操作系统级别支持的,而是在用户空间模拟出来的异步,并且仅仅支持基于本地文件的 aio 异步操作,网络编程中的 socket 是不支持的,这也使得基于 Linux 的高性能网络程序都是使用 Reactor 方案。<br>
而 Windows 里实现了一套完整的支持 socket 的异步编程接口,这套接口就是 IOCP,是由操作系统级别实现的<br>异步 I/O,真正意义上异步 I/O,因此在 Windows 里实现高性能网络程序可以使用效率更高的 Proactor 方案。<br>
总结
常见的 Reactor 实现方案有三种。<br>
第一种方案单 Reactor 单进程 / 线程,不用考虑进程间通信以及数据同步的问题,因此实现起来比较简单,这种<br>方案的缺陷在于无法充分利用多核 CPU,而且处理业务逻辑的时间不能太长,否则会延迟响应,所以不适用于<br>计算机密集型的场景,适用于业务处理快速的场景,比如 Redis(6.0之前 ) 采用的是单 Reactor 单进程的方案。<br>
第二种方案单 Reactor 多线程,通过多线程的方式解决了方案一的缺陷,但它离高并发还差一点距离,差在只有一个 Reactor <br>对象来承担所有事件的监听和响应,而且只在主线程中运行,在面对瞬间高并发的场景时,容易成为性能的瓶颈的地方。<br>
第三种方案多 Reactor 多进程 / 线程,通过多个 Reactor 来解决了方案二的缺陷,主 Reactor 只负责监听事件,响应事件的工作交给<br>了从 Reactor,Netty 和 Memcache 都采用了「多 Reactor 多线程」的方案,Nginx 则采用了类似于 「多 Reactor 多进程」的方案。<br>
Reactor 可以理解为「来了事件操作系统通知应用进程,让应用进程来处理」,<br>而 Proactor 可以理解为「来了事件操作系统来处理,处理完再通知应用进程」。<br>
因此,真正的大杀器还是 Proactor,它是采用异步 I/O 实现的异步网络模型,感知的是已完成<br>的读写事件,而不需要像 Reactor 感知到事件后,还需要调用 read 来从内核中获取数据。<br>
不过,无论是 Reactor,还是 Proactor,都是一种基于「事件分发」的网络编程模式,区别在于 <br>Reactor 模式是基于「待完成」的 I/O 事件,而 Proactor 模式则是基于「已完成」的 I/O 事件。<br>
什么是一致性哈希?
如何分配请求?
大多数网站背后肯定不是只有一台服务器提供服务,因为单机的并发量和数据量都是有限的,所以都会用多台服务器构成集群来对外提供服务。
但是问题来了,现在有那么多个节点(后面统称服务器为节点),要如何分配客户端的请求呢?<br>
其实这个问题就是「负载均衡问题」。解决负载均衡问题的算法很多,不同的负载均衡算法,对应的就是不同的分配策略,适应的业务场景也不同。
最简单的方式,引入一个中间的负载均衡层,让它将外界的请求「轮流」的转发给内部的集群。<br>比如集群有 3 个节点,外界请求有 3 个,那么每个节点都会处理 1 个请求,达到了分配请求的目的。<br>
考虑到每个节点的硬件配置有所区别,我们可以引入权重值,将硬件配置更好的节点的权重值设高,然后根据各个<br>节点的权重值,按照一定比重分配在不同的节点上,让硬件配置更好的节点承担更多的请求,这种算法叫做加权轮询。<br>
加权轮询算法使用场景是建立在每个节点存储的数据都是相同的前提。所以,每次读数据的请求,访问任意一个节点都能得到结果。<br>
但是,加权轮询算法是无法应对「分布式系统(数据分片的系统)」的,因为分布式系统中,每个节点存储的数据是不同的。<br>
当我们想提高系统的容量,就会将数据水平切分到不同的节点来存储,也就是将数据分布到了不同的节点。比如一个分布式 <br>KV 缓存系统,某个 key 应该到哪个或者哪些节点上获得,应该是确定的,不是说任意访问一个节点都可以得到缓存结果的。<br>
因此,我们要想一个能应对分布式系统的负载均衡算法。<br>
使用哈希算法有什么问题?
有的同学可能很快就想到了:<b><font color="#0000ff">哈希算法</font></b>。因为对同一个关键字进行哈希计算,每次计算都是<br>相同的值,这样就可以将某个 key 确定到一个节点了,可以满足分布式系统的负载均衡需求。<br>
哈希算法最简单的做法就是进行取模运算,比如分布式系统中有 3 个节点,基于 hash(key) % 3 公式对数据进行了映射。<br>
如果客户端要获取指定 key 的数据,通过下面的公式可以定位节点:<br>hash(key) % 3<br>
如果经过上面这个公式计算后得到的值是 0,就说明该 key 需要去第一个节点获取。<br>
但是有一个很致命的问题,<b><font color="#0000ff">如果节点数量发生了变化,也就是在对系统做扩容或者缩容时,<br>必须迁移改变了映射关系的数据</font></b>,否则会出现查询不到数据的问题。<br>
举个例子,假设我们有一个由 A、B、C 三个节点组成分布式 KV 缓存系统,<br>基于计算公式 hash(key) % 3 将数据进行了映射,每个节点存储了不同的数据:<br>
现在有 3 个查询 key 的请求,分别查询 key-01,key-02,key-03 的数据,这三个 key 分别经过 hash() 函数<br>计算后的值为 hash( key-01) = 6、hash( key-02) = 7、hash(key-03) = 8,然后再对这些值进行取模运算。<br>
通过这样的哈希算法,每个 key 都可以定位到对应的节点。<br>
当 3 个节点不能满足业务需求了,这时我们增加了一个节点,节点的数量从 3 变化为 4,<br>意味取模哈希函数中基数的变化,这样会导致大部分映射关系改变,如下图:<br>
比如,之前的 hash(key-01) % 3 = 0,就变成了 hash(key-01) % 4 = 2,查询 key-01 数据时,<br>寻址到了节点 C,而 key-01 的数据是存储在节点 A 上的,不是在节点 C,所以会查询不到数据。<br>
同样的道理,如果我们对分布式系统进行缩容,比如移除一个节点,也会因为取模哈希函数中基数的变化,可能出现查询不到数据的问题。<br>
要解决这个问题的办法,就需要我们进行 <b><font color="#0000ff">迁移数据</font></b>,比如节点的数量从 3 变化为 4 时,要基于新的计算公式 hash(key) % 4 ,重新对数据和节点做映射。<br>
假设总数据条数为 M,哈希算法在面对节点数量变化时,<b><font color="#0000ff">最坏情况下所有数据都需要迁移,所以它的数据迁移规模是 O(M),这样数据的迁移成本太高了</font></b>。<br>
所以,我们应该要重新想一个新的算法,来避免分布式系统在扩容或者缩容时,发生过多的数据迁移。<br>
使用一致性哈希算法有什么问题?
一致性哈希算法就很好地解决了分布式系统在扩容或者缩容时,发生过多的数据迁移的问题。
一致哈希算法也用了取模运算,但与哈希算法不同的是,哈希算法是对节点的数量进行取模运算,<br>而 <b><font color="#0000ff">一致哈希算法是对 2^32 进行取模运算,是一个固定的值</font></b>。<br>
我们可以把一致哈希算法是对 2^32 进行取模运算的结果值组织成一个圆环,就像钟表一样,钟表的圆可以理解<br>成由 60 个点组成的圆,而此处我们把这个圆想象成由 2^32 个点组成的圆,这个圆环被称为哈希环,如下图:<br>
<span style="font-size: inherit;">一致性哈希要进行两步哈希:</span><br><br><ul><li><span style="font-size: inherit;">第一步:对存储节点进行哈希计算,也就是对存储节点做哈希映射,比如根据节点的 IP 地址进行哈希;</span></li><li><span style="font-size: inherit;">第二步:当对数据进行存储或访问时,对数据进行哈希映射;</span></li></ul>
所以,<b><font color="#0000ff">一致性哈希是指将「存储节点」和「数据」都映射到一个首尾相连的哈希环上</font></b>。<br>
问题来了,对「数据」进行哈希映射得到一个结果要怎么找到存储该数据的节点呢?<br>
答案是,映射的结果值往 <b><font color="#0000ff">顺时针的方向的找到第一个节点</font></b>,就是存储该数据的节点。<br>
举个例子,有 3 个节点经过哈希计算,映射到了如下图的位置:<br>
接着,对要查询的 key-01 进行哈希计算,确定此 key-01 映射在哈希环的位置,<br>然后从这个位置往顺时针的方向找到第一个节点,就是存储该 key-01 数据的节点。<br>
比如,下图中的 key-01 映射的位置,往顺时针的方向找到第一个节点就是节点 A。<br>
所以,当需要对指定 key 的值进行读写的时候,要通过下面 2 步进行寻址:<br><br><ul><li>首先,对 key 进行哈希计算,确定此 key 在环上的位置;</li><li>然后,从这个位置沿着顺时针方向走,遇到的第一节点就是存储 key 的节点。</li></ul>
知道了一致哈希寻址的方式,我们来看看,如果增加一个节点或者减少一个节点会发生大量的数据迁移吗?<br>
假设节点数量从 3 增加到了 4,新的节点 D 经过哈希计算后映射到了下图中的位置:<br>
你可以看到,key-01、key-03 都不受影响,只有 key-02 需要被迁移节点 D。<br>
假设节点数量从 3 减少到了 2,比如将节点 A 移除:<br>
你可以看到,key-02 和 key-03 不会受到影响,只有 key-01 需要被迁移节点 B。<br>
哈希环使用前后对比:<br><br><ul><li>没有使用哈希环之前,扩容设备的时候,由于设备数量变更了,比如节点的数量<br>从 3 变化为 4 时,要基于新的计算公式 hash(key) % 4 ,重新对数据和节点做映射。</li></ul><ul><li>使用了哈希环后,它的计算公式是 hash(key) % 2^32,<b><font color="#0000ff">这个计算公式并不会随着设备<br>数量变化而变化</font></b>。如果节点数量变化,仅影响该节点在哈希环上顺时针相邻的后继节点,<br>其它数据也不会受到影响,所以相对没有使用哈希环之前减少了数据迁移量。</li></ul>
因此,<b><font color="#0000ff">在一致哈希算法中,如果增加或者移除一个节点,仅影响该节点在哈希环上顺时针相邻的后继节点,其它数据也不会受到影响</font></b>。<br>
上面这些图中 3 个节点映射在哈希环还是比较分散的,所以看起来请求都会「均衡」到每个节点。<br>
但是 <b><font color="#0000ff">一致性哈希算法并不保证节点能够在哈希环上分布均匀</font></b>,这样就会带来一个问题,会有大量的请求集中在一个节点上。<br>
比如,下图中 3 个节点的映射位置都在哈希环的右半边:<br>
这时候有一半以上的数据的寻址都会找节点 A,也就是访问请求主要集中的节点 A 上,这肯定不行的呀,说好的负载均衡呢,这种情况一点都不均衡。<br>
另外,在这种节点分布不均匀的情况下,进行容灾与扩容时,哈希环上的相邻节点容易受到过大影响,容易发生 <b><font color="#0000ff">雪崩式的连锁反应</font></b>。<br>
比如,上图中如果节点 A 被移除了,当节点 A 宕机后,根据一致性哈希算法的规则,其上数据应该全部迁移到相邻的节点 B 上,这样,节点 <br>B 的数据量、访问量都会迅速增加很多倍,一旦新增的压力超过了节点 B 的处理能力上限,就会导致节点 B 崩溃,进而形成雪崩式的连锁反应。<br>
所以,<b><font color="#0000ff">一致性哈希算法虽然减少了数据迁移量,但是存在节点分布不均匀的问题,可能会引起雪崩式的连锁反应</font></b>。<br>
如何通过虚拟节点提高均衡度?
要想解决节点能在哈希环上分配不均匀的问题,就是要有大量的节点,节点数越多,哈希环上的节点分布的就越均匀。<br>
但问题是,实际中我们没有那么多节点。所以这个时候我们就加入 <b><font color="#0000ff">虚拟节点</font></b>,也就是对一个真实节点做多个副本。<br>
具体做法是,<b><font color="#0000ff">不再将真实节点映射到哈希环上,而是将虚拟节点映射到哈希环上,并将虚拟节点映射到实际节点,所以这里有「两层」映射关系</font></b>。<br>
比如对每个节点分别设置 3 个虚拟节点:<br><br><ul><li>对节点 A 加上编号来作为虚拟节点:A-01、A-02、A-03</li><li>对节点 B 加上编号来作为虚拟节点:B-01、B-02、B-03</li><li>对节点 C 加上编号来作为虚拟节点:C-01、C-02、C-03</li></ul>
引入虚拟节点后,原本哈希环上只有 3 个节点的情况,就会变成有 9 个虚拟节点映射到哈希环上,哈希环上的节点数量多了 3 倍。<br>
你可以看到,<b><font color="#0000ff">节点数量多了后,节点在哈希环上的分布就相对均匀了</font></b>。这时候,如果有访问请求寻址到「A-01」<br>这个虚拟节点,接着再通过「A-01」虚拟节点找到真实节点 A,这样请求就能访问到真实节点 A 了。<br>
上面为了方便你理解,每个真实节点仅包含 3 个虚拟节点,这样能起到的均衡效果其实很有限。而在实际的工程中,<br>虚拟节点的数量会大很多,比如 Nginx 的一致性哈希算法,每个权重为 1 的真实节点就含有160 个虚拟节点。<br>
另外,虚拟节点除了会提高节点的均衡度,还会提高系统的稳定性。<b><font color="#0000ff">当节点变化时,会有不同的节点共同分担系统的变化,因此稳定性更高</font></b>。<br>
比如,当某个节点被移除时,对应该节点的多个虚拟节点均会移除,而 <b><font color="#0000ff">这些虚拟节点按顺时针方向的<br>下一个虚拟节点,可能会对应不同的真实节点</font></b>,即这些不同的真实节点共同分担了节点变化导致的压力。<br>比如上面示例中 A-01 的下一个节点是 B-01,但 A-02 的下一个节点可能是 C-02 或者 B-03。
而且,有了虚拟节点后,还可以 <b><font color="#0000ff">为硬件配置更好的节点增加权重</font></b>,比如对权重更高的节点增加更多的虚拟机节点即可。<br>
因此,<b><font color="#0000ff">带虚拟节点的一致性哈希方法不仅适合硬件配置不同的节点的场景,而且适合节点规模会发生变化的场景</font></b>。<br>
一致性哈希或虚拟节点会发生哈希冲突吗?
哈希冲突是指如果多个节点同时哈希到了一个地方,要怎么解决?
如果是这个问题的话,说实话 <b><font color="#0000ff">一致性哈希发生哈希冲突概率是极小的</font></b>,因为是对2^32取模,而2^32是一个<br>非常大的数,意味着能建立2^32个的映射关系,现实中根本不存在节点数量超过这个数量级的分布式系统。<br>
假设真的发生了多个节点同时哈希到了一个地方,那么我觉得处理方式应该是 <b><font color="#0000ff">报错</font></b>,也就是发生哈希冲突的节点<br>不允许加入进来了,能发生这种情况,说明分布式系统的节点规模非常庞大了。如果真的想设计达到这种规模的<br>分布式系统的话,不如将对2^32取模,改成对2^64取模更简单,这样再次降低了哈希冲突的概率。<br>
如果一定要解决一致性哈希的哈希冲突问题的话,「<b><font color="#0000ff">再哈希</font></b>」这种方式也是可以的,这种方法是同时<br>构造多个不同的哈希函数,当第一种哈希函数发生冲突,就使用第二种哈希函数,直到冲突不再产生。<br>
总结
不同的负载均衡算法适用的业务场景也不同的。<br>
轮询这类的策略只能适用与每个节点的数据都是相同的场景,访问任意节点都能请求到数据。但是不适用分布式系统,<br>因为分布式系统意味着数据水平切分到了不同的节点上,访问数据的时候,一定要寻址存储该数据的节点。<br>
哈希算法虽然能建立数据和节点的映射关系,但是每次在节点数量发生变化的时候,<br>最坏情况下所有数据都需要迁移,这样太麻烦了,所以不适用节点数量变化的场景。<br>
为了减少迁移的数据量,就出现了一致性哈希算法。<br>
一致性哈希是指将「存储节点」和「数据」都映射到一个首尾相连的哈希环上,如果增加或者<br>移除一个节点,仅影响该节点在哈希环上顺时针相邻的后继节点,其它数据也不会受到影响。<br>
但是一致性哈希算法不能够均匀的分布节点,会出现大量请求都集中在一个节点的情况,在这种情况下进行容灾与扩容时,容易出现雪崩的连锁反应。<br>
为了解决一致性哈希算法不能够均匀的分布节点的问题,就需要引入虚拟节点,对一个真实节点做多个副本。不再将真实<br>节点映射到哈希环上,而是将虚拟节点映射到哈希环上,并将虚拟节点映射到实际节点,所以这里有「两层」映射关系。<br>
引入虚拟节点后,可以会提高节点的均衡度,还会提高系统的稳定性。所以,带虚拟节点的<br>一致性哈希方法不仅适合硬件配置不同的节点的场景,而且适合节点规模会发生变化的场景。<br>
文件系统
设备管理
评论
0 条评论
下一页