操作系统并发底层原理
经典的 i++ 问题
我们来看看下边这段 Java 代码:
public class ThreadDemo {
private static int i = 0;
static class IncrTask implements Runnable {
CountDownLatch start;
CountDownLatch end;
public IncrTask(CountDownLatch start, CountDownLatch end) {
this.start = start;
this.end = end;
}
@Override
public void run() {
try {
start.await();
for (int j = 0; j < 100; j++) {
i++;
}
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
end.countDown();
}
}
}
public static void main(String[] args) throws InterruptedException {
CountDownLatch start = new CountDownLatch(1);
CountDownLatch end = new CountDownLatch(100);
for (int i = 0; i < 100; i++) {
Thread t = new Thread(new IncrTask(start, end));
t.start();
}
start.countDown();
end.await();
System.out.println("result is :" + j);
}
}
代码模拟了 100 个线程同时对 i 变量执行 100 次 i++ 的操作,但是最终的执行结果并非是 10000,而且大概率会是一个小于 10000 的数字。通过这个实验我们可以得出一个结论,那就是:i++ 语句并不是一条原子性的指令,它存在着线程安全问题。
在 Java 层面来看,i++ 指令似乎已经无法再细分了,但是如果我们更深入一些,从操作系统层面来看 i++ 的话,你会发现,其实它的下层实现,并不是简单的自增操作而已。
为了更深入了解 i++ 在计算机底层发生了什么变化,我用 C++ 写了个简单的案例,通过 gcc.godbolt.org/ 平台,将 C++ 代码直接转换为了汇编指令(采用Java语言写还需要先转换为class字节码再到汇编,所以不如直接用 C++ 更方便)。
具体的汇编指令如下:
i:
.zero 4
incr():
push rbp
mov rbp, rsp
mov eax, DWORD PTR i[rip]
add eax, 1
mov DWORD PTR i[rip], eax
nop
pop rbp
ret
关于这段汇编指令,我们可以发现 i++ 的核心部分由三条指令组成:
mov eax, DWORD PTR i[rip]
add eax, 1
mov DWORD PTR i[rip], eax
这些指令的大概意思是:将 i 变量挪到 CPU 的一个寄存器中,然后进行自增,接着将 i 移出寄存器放回原先的内存地址中。
看到这里,可能有一些朋友感到疑惑了:为什么执行这些指令时 , 可能会有线程安全问题?
要理解这个问题,我们得先知道一些计算机的基础知识,比如,什么是寄存器,寄存器和CPU之间有什么联系,等等。所以接下来我们需要先来回顾一下这些基础知识。
程序和CPU之间的协作关系
我们都知道,计算机的运作通常都离不开CPU的扶持,一旦离开了CPU,程序就无法运行(ps:下边主要围绕着以intel旗下x86架构的CPU)。
如果你有拆过自己的电脑,或者在网上查阅过CPU资料,应该会见过 CPU,就是那个长满了各种引线脚的小方块,下边我将其进行了些许抽象,绘制如下图所示:
当然,这张图只是将 CPU 的外表模型进行简化,如果将其深入拆解开来,其内部还存在好几个重要的组成部分,下边我将它内部的构造进行核心拆解,主要有以下几个模块,如下图所示:
CPU的几大模块
- 寄存器
寄存器是CPU中最需要软件工程师们关心的部分了,因为寄存器中主要存储了从内存中加载的数据(寄存器的运行速度比内存要快好多个级别,准确说是从内存中将数据加载到L1,L2,L3缓存,再到寄存器中),而我们所编写程序在计算机底层转换为汇编语言之后,主要的操控对象其实就是寄存器。例如常见的mov和add指令:
mov eax, DWORD PTR i[rip]
add eax, 1
mov DWORD PTR i[rip], eax
其实“寄存器”一词还只是一个大的类目,它的下边还包含了许多种小的子类别,
我按照存储的数据将它们划分为了两大类 : 存储内存地址类寄存器,存储非内存地址寄存器。
通常单个 CPU 内部就存有成百个寄存器,面对这么多的寄存器如果要逐个了解的话就会让人感到棘手,所以我将它们按照各自的职责划分为了两大类别从而方便大家理解,它们主要有两大类别:
- 存储内存地址类寄存器
程序计数器,基址寄存器,变址寄存器。
- 存储非内存地址寄存器
累加寄存器,通用寄存器,标志寄存器。
下边我用了一张图来展示它们,如下所示:
咦,这六种寄存器的职责分别是什么?别急,后边我会通过一段Java代码案例和绘图的方式,将它们的作用串通起来,方便大家理解。
- 控制器
控制器主要是起到一个辅助的功能,它可以帮助CPU做一些指令读取,结果写回等功能,同时它也能根据汇编指令的结果去操控一些计算机的硬件设备。
- 运算器
运算器是CPU内部最核心的用于做计算的模块,我们编写的程序在经过多步编译之后最终传达给到CPU的会是一段0和1组成的指令代码,这些指令在控制器的帮助下会将需要计算的数据放入到寄存器中,让运算器去计算。
- 时钟
主要是用于记录每次CPU计算的耗时,它的运算单位为ghz,1ghz = 10亿次/秒,通常ghz越高,表示CPU的运算效率越高。
寄存器和程序之间的关系
在了解了CPU的主要组成之后,我们还需要了解它们之间是如何协调运行程序,这样才能更加透彻地去理解并发编程中存在哪些问题需要工程师们注意。下边我们通过一个例子看一下。 这里有一段简单的Java程序:
public class CountDemo {
public static void compareTest(int a, int b, int[] arr) {
int t1 = countSum(a);
int t2 = countSum(b);
if (t1 > t2) {
arr[0] = 1;
} else {
arr[1] = 1;
}
}
//1~a的求和计算
public static int countSum(int a) {
int sum = 0;
for (int i = 1; i <= a; i++) {
sum += i;
}
return a;
}
public static void main(String[] args) {
int a[] = {-1,-1};
compareTest(1, 2, a); // ----- code_1
}
}
这份程序在IDE工具开发完毕后,实际上会被保存在磁盘当中。
接着如果要运行程序的话,可以输入一段 Java 指令去运行它,如:javac 和 java 指令。接下来磁盘中的程序会被读取到内存当中,并且进行相关的编译。期间会涉及到多次编译,会先在 jvm 层变成 class 字节码,然后再转换为汇编指令,最后才是到机器码(这也正是将Java代码转换为汇编指令才能认清它背后原理的原因了)。
这些机器码存放的地址会被放到一种叫做程序计数器的寄存器中,之后控制器会到根据程序计数器的地址去读取相关的机器指令,并且将指令读取给运算器进行计算。当运行结束之后,程序计数器的地址就会刷新,让控制器去加载新内存地址的指令给到运算器。
ps:有些资料上喜欢将程序计数器和寄存器分成两个部分来说明,但实质上程序计数器本身就是寄存器的一类,因此我个人感觉没有必要将其分开。
在CountDemo这段代码中虽然涉及到了一个求和计算,比较计算,函数调用三个功能,但是在实际运作过程中却牵涉到了文章前边所提到的六种寄存器。
先来看程序计数器在代码执行过程中的变化,这里我将各个代码块执行过程中的细节点用图绘制了出来,图中的code_2部分是重复调用countSum函数,所以没有绘制箭头走向,读者们可以根据code_1的指令调用去推理 :
首先 main 函数执行,程序计数器的地址会更新为 main 函数的入口位置,让控制器去加载其指令地址开始执行。接着在准备调用 compareTest 函数的时候,会有一条 call指令,将当前的程序计数器地址变更为子函数的入口地址,同理,在 comparetTest 函数内部调用 countSum 函数也是会发送 call指令。当子函数执行结束后,便会执行一条 return指令,返回到原先执行代码位置的下一条指令位置(call指令和return指令在函数调用的过程中是经常会使用到的)。
我们深入分析 countSum 内部,会发现它包含了求和累积的计算,这里边涉及到了累加寄存器和标志寄存器的使用。累加寄存器这个很好理解,就是将sum的数值在累加寄存器中不断更新。而标志寄存器其实是用于了判断是否满足跳出循环的逻辑。
我们在 for 循环代码中所编写的 i<=a; 这个逻辑,而计算机底层会通过做差的方式来判断是否满足该条件,也就是变成了:a - i >= 0; 的判断(这里有些数学中的不等式基础运算的味道~)。而通过 a-i 计算出来的结果会被记录在标志寄存器中的某些个位上,例如下图所示:
我们可以将标志寄存器理解为是一个巨大的 bit 数组,不同位置上的 bit 值表示不同的含义,当需要将计算结果记录为负数的话,只需要将 bit[0] 更新为 1 即可。
接下来我们来看看变址寄存器和基址寄存器,这两个寄存器主要是在数组进行定位元素的过程中会有所使用。为了方便理解,我将这部分用一张图来带大家认识:
CPU 在对数组这类数据结构的内部元素进行定位的时候会通过基址寄存器的位置 + 变址寄存器的数值进行查询,变址寄存器就有点类似于是数组的索引下标,通过一个相对偏差的数值去对具体位置的定位。
通用寄存器这块其实比较好理解 , 大家可以将它理解为用于专门存储一些临时变量的公共部分,例如一些临时定义的数字值,对它进行深入了解的意义并不是很大,大家知道有这么一个东西就可以了。
再看i++问题
好了,现在我们终于把计算机底层是如何运作程序的流程给搞清楚了,现在让我们回过头来重新从 CPU的角度去认识下 i++ 指令背后的秘密。
i++ 的核心部分有三条指令组成:
mov eax, DWORD PTR i[rip]
add eax, 1
mov DWORD PTR i[rip], eax
mov 指令会先将 i 变量加载到 eax 累加寄存器中,然后在寄存器中做加 1 操作,接着才是将加 1 之后的结果放回到i变量原先的内存地址中,整体流程如下图所示:
但是当有多个线程同时使用i++指令的时候就可能会出现如下图的冲突:
不同线程在执行的时候,各自的 eax 累加寄存器中的数值不相同,从而导致最终i被两次更新,但值却不是 2。
而这类现象就是我们常说的线程安全问题了。如果需要解决这类问题,其实只需要保证每个线程在执行i++ 指令的时候都是一个原子操作即可,例如通过加入一道屏障指令,如下图所示:
通过加入一到屏障指令可以保证数据在被多个线程访问的过程中一次只能有一个线程操作它,而且下一个线程会处于等待状态。
通过这段简单的 i++ 指令的运算原理,让我们可以发现,如今的高级语言已经将一些系统底层的细节步骤进行了封装 , 如果对于它背后的机制没有专门研究的话,在一些复杂场景中很可能会产生一些意想不到的情况。所以,我们得能够深入系统底层,理解CPU的知识,从CPU的角度出发来看并发问题。
我们通过对 CPU 的底层认识,了解了CPU在运行程序过程中的一些细节,同时也看到了并发执行程序的时候可能会存在的问题,那么计算机底层是否有什么方式去避免这些问题呢?其实发明操作系统的人很早就已经给出了一套解决方案,其中最为人们熟知的就是管程和信号量了。
临界区
在开始介绍信号量和管程之前,我们需要先有一定的铺垫,先来看下边这么一段代码案例:
public class IncrDemo {
static int i=0;
public void incr(){
i++;
}
}
这段程序非常简单,就是对 i 进行自增的操作,在单线程下这个程序执行是正常的,但是多线程的场景下就可能会出现数据错乱的问题了。当多个线程同时执行 incr 方法的时候,对于 i 的自增操作就是一个对于临界区的访问操作。
临界区是操作系统底层的一项专业术语,通常用于描述共享资源块。当某一时刻同时有多个进程或者线程访问同一临界区的时候,每次只能允许有一个线程访问成功,其他线程则需要进入等待状态。为了实现这种效果,操作系统需要确保对于临界区的资源每次访问都只能有一个请求抵达,于是乎便有了信号量这个概念的出现。
信号量
什么是信号量
信号量其实是一种设计思想,它的本质可以理解为是一个整形的数字(sem),对于这个数字的访问,在具体实现上只提供两个原子操作,分别是:
-
P():如果执行sem-1之前,sem已经0,则进入等待状态,否则就只是正常扣减操作。
-
V():如果执行sem+1之前,sem已经0,则执行完sem+1之后同时会唤醒一个等待的P,否则就只是正常的加1操作。
信号量的操作之所以具备原子性,这是和它的底层实现有关。操作系统的底层提供了一对原语来对其进行操作,所谓的原语其实是一种非常特别的程序段,这类程序要么一气呵成,要么不可被中断。
看到这里,没有了解过信号量的同学可能会有些疑惑,这是个啥玩意儿?别急,我们通过一张图来认识下。
下图中模拟了当一个请求访问到某块临界区的时候,触发到预先在程序中设定好的P操作,触发sem-1操作,从而使得sem=0。
接下来,当有多个请求抵达临界区的时候,它们都会陆续触发到P操作,但是此时 sem 值已经变为 0 了,于是乎后续的请求就会被放置到一条等待队列中。
随着之前在临界区的访问处理结束之后,就会触发一次 V 操作,将 sem 进行加 1,然后会从等待队列的队头通知一个之前的处于等待状态的请求,让其进入临界区,接下来的请求请以此类推。
怎么样,是不是感觉这种设计思路在高级语言中似曾相识。其实很多高级语言的设计都是来源自操作系统当中。
可能会有部分同学有疑问,这无非就是对一个数值的加或者减嘛,为什么要叫做P和V操作呢?关于这块我之前在学习的时候也有疑惑,后来才得知,发明信号量机制的工程师是荷兰人,P 和 V 分表都是荷兰用语,代表的意思是V verhoog 增加,P prolaag 减少。
信号量在不同场景中的应用
上边的例子中,我们已经对信号量的基本模型有了一定的了解,下边我们来通过几个案例了解下信号量在不同场景中的使用。
- 实现多进程任务执行的先后顺序
A 进程的 method_3 需要在 B 进程执行 method_2 之后才运行,那么这个时候运用信号量进行实现的话就会非常简单,基本思路如下图所示:
在执行 method1 和 method2 之前,先将 sem 设置为 0,于是当 A 进程在想执行 method_3 之前,因为遇到了 sem=0 的情况,于是在调用P的时候处于堵塞状态。而此时需要借助 B 进程去发起 V 操作,才能让 A 线程继续执行,这样就能保证 A 进程每次执行 method_3 之前都是有 B 进程执行过一次 method_2 操作。
- 实现多进程间任务的前驱关系
进程 P1 中存在函数 S1,进程 P2 中存在函数 S2,进程 P3 中存在函数 S3,后续的进程 P4、P5、P6以此类推。现在需要确保各个进程中函数的执行顺序为 S1->S2->S3,S4->S5,S6,其中 S3 和 S4 之间没有优先级关系,S5,S6之间也是没有优先级关系,这种情况下其实也是可以借助信号量机制去进行实现。
- 通过信号量来实现访问次数限制
对于某项资源访问,它只能同时允许有 3 个进程读取,当进程数量超过 3 个,则需要进入等待。这类场景非常好理解,只需要将 sem 设置为 3 即可。
在出现了信号量机制之后,虽然能够解决一部分简单的并发问题,但是在使用方式上依然是存在一些缺陷,于是部分聪明的程序员对信号量做了一层可读性更强的代码封装,这个封装的结果就是我们下边要介绍的管程。
管程
管程 英文名为monitor,翻译过来就是指监视器的意思,对于管程的理解,大致可以认为是基于信号量的基础做了一层封装,专门用于访问一些共享变量的函数,让调用方使用起来更加简单和清晰,它的作用是一次只允许有一个线程访问临界区,如果同时有多个访问,则将多余的访问挂起。
这里需要注意的是,信号量的应用主要是基于操作系统层面中,而管程的提出 , 则是用在了语言的场景中,它主要是专门针对语言中的并发场景而设计的,从而简化了一些语言层面的实现逻辑。
例如Java内部对于并发模块的实现 wait()、notify()、notifyAll() 这些函数就是采用了管程技术来控制的。
看到这里,可能依然会有些同学对于管程的运作原理感到模糊,下边我们一起探讨管程的内部组成和运作原理,相信看完之后,你便会有所解惑。
管程的组成
-
一把锁
-
0或者多个条件变量
一把锁: 管程为了保证对于共享资源的访问一次只能有一个进程,所以引入了锁的机制,没有抢到锁的进程则需要进入到锁的等待队列进行等待。
0或者多个条件变量: 当进程获取到了锁的请求进入到临界区之后,有可能还需要做多个条件的判断,如果没有满足其中的某一条条件则需要进入到条件队列(条件队列位于临界区外)中,若后续条件满足,则由其他进程进行唤醒。
因此它的结构可以用下图来描述:
现在我们对管程的执行逻辑有了一个初步的认识,再来看看指令级别该如何去设计和实现它。 上边我们说了,管程的内部需要有一把锁,因此对应的锁可以设计一套API去进行定义:
Lock::Acquire() //等待锁可用,然后抢占
Lock::Release() //释放锁,唤醒等待队列中的线程
Wait() //释放当前锁,进入睡眠状态,
Signal() //当某个条件满足的时候,就会唤醒等待队列中线程
关于不同条件的等待队列部分,我将其核心的逻辑做了些许抽象,实现如下:
//设计一个条件类
Class Condition {
//记录有多少个线程等待该条件
int numWaiting=0;
//存储等待该条件的队列
WaitQueue q;
}
//当有线程调用wait函数的时候,该线程会被挂起在该条件队列中
Condition::Wait(lock){
numWaiting++;
//将当前线程放入到条件队列中
add currentThread t to q;
//释放锁
release(lock);
//调度一个处于就绪状态的线程执行,这里相当于发生了线程的上下文切换
schedule();
//重新获取锁
require(lock);
}
//从等待队列中移除一个处于挂起状态的线程t,让其继续执行
Condition:Signal(){
if(numWaiting >0){
//从条件队列中移除线程
Remove one thread t from q;
//唤醒一个处于睡眠状态的进程,如果此时没有线程处于条件队列中,这里就会堵塞。
wakeup(t);
numWaiting---;
}
}
从上边的这段简化版代码中,我只是基于对管程的理解,设计了一个简单的基本伪代码,大致思路为:每次进行抢锁的时候都只能运行一个线程成功,如果抢夺到锁的线程希望主动释放锁,那么就需要主动调用 wait 方法。如果希望唤醒那些调用了 wait 方法的线程,就需要调用 signal 方法。
不过这里有个细节需要注意下:为什么wait操作中 , 要先执行release 再执行require操作?
这是因为进入睡眠状态前,线程必须要将它所拥有的锁进行释放,否则会一直处于死锁状态。当线程被唤醒后,程序又会重新回到 schedule 代码后边一条指令进行执行,于是这个时候就有需要重新尝试获取锁。
上边我们介绍了如何给予代码实现去设计一个基本的管程模型,但你发现没?当 Condition 调用了 signal() 函数的时候,会唤醒条件队列中的一个挂起线程,那么就会产生同时有两个线程访问到了临界区的共享资源,一个是当前触发 signal 函数的线程,一个是刚从挂起状态被唤醒的线程,这种情况下该如何处理呢?
Hansen, Hoare, Mesa模型
其实上边所说的问题在很早之前就已经有程序员们注意到了,当时为了解决这两种问题,业界提出了三种管程的模型,它们分别是Hansen模型,Hoare模型,Mesa模型,为了方便下边描述,我们暂时称呼被挂起的线程为 A,调用 signal 的线程为 B。
- Hoare 模型
这种模型的核心关注点是急迫性的,它会让A立即执行,而当前调用signal方法的B处于睡眠状态,在等待的A执行之后才继续让B执行。 因此在这种模型下去设计管程,就需要程序员养成一个将 signal 操作放到函数最后一步才去执行的习惯。
- Hansen 模型
这类模型不强调立马释放cpu的占有权利,会等当前B完全执行完毕之后,才允许让被唤醒的A继续执行。 在这种模型中,程序员可以不用担心 signal 操作之后是否会有等待的情况发生,更加灵活。
这两种模型其实各有各的考虑出发点,Hoare的表现更为急迫性,而 Hansen 则是更加考虑灵活性,并不能直接评判两类模型谁更好,只不过在具体实现层面来说,Hansen 模型要比 Hoare 模型更加容易一些。
关于两类模型的执行原理图如下所示:
这两类模型都有个特点,就是线程被重新唤醒之后就能继续执行,因为它们被唤醒之后都是可以直接存在于临界区,只不过它们都只允许单个线程访问临界区中的资源。但是还有一种模型和这个不太一样,那就是 MESA 模型。
- Mesa模型
在 Mesa 模型中,B 在执行了 signal 之后,不需要担心 A 的任何事情,B 可以继续正常执行。而 A 会被重新放入到等待队列中去参与抢占的行为,这一点和 Hansen、Hoare 是不一样的,被重新唤醒的线程会被移出临界区。
Mesa 模型也是 Java 语言所采用的管程模型,在 JDK 的 synchronized 中,就是基于 Mesa 管程模型去进行了封装,对外主要提供了 wait、notify、notifyAll三个函数,关于它们的进一步深入了解,我会在后续的章节中带大家专门深入探索。
管程模型实现生产者消费者
有了上边基本的设计思路,我们来看看如何将上边所设计的管程的 API 应用于生产者消费者模型中,下边我给出了些自己的设计思路:
//设计了一个buffer对象,内部管理了一把Lock和两个Condition条件以及共享资源count和q
Class Buffer {
Lock lock;
int count=0;
Queue q;
Condition notFull,Condition notEmpty;
}
//往队列放入元素
Buffer::Deposit(c){
//获取锁,进入临界区
lock -> Acquire();
//当队列的体积满足某个条件的时候便将当前线程刮起到等待队列中
//此时count==n的意思是当队列的容积达到了上限1,这个就是当前的需要被判断是否满足的条件
while(count == 1){
//条件满足,挂起,同时底层会放弃lock
notFull.Wait(&lock);
}
//这里可能是条件未满足,或者是线程重新从睡眠状态被唤醒的情况。将c放入到队列中
add c to the q;
//队列个数加1
count++;
notEmpty.Signal();
//释放锁
lock -> Release();
}
Buffer::Remove(){
//获取锁,进入临界区
lock -> Acquire();
while(count==0){
//当队列中没有元素了,此时消费线程就需要被挂起到等待队列中,同时底层会放弃lock
notEmpty.Wait(&lock);
}
//这里可能是条件未满足,或者是线程重新从睡眠状态被唤醒的情况。从q中继续提取元素处理
remove c from the q;
count--;
//消费完毕后,通知生产者往q中放入下一个元素
notFull.Signal();
//释放锁
lock -> Release();
}
在互联网应用中,我们通常会遇到一些利用多线程或者多进程的场景,例如:
- 支付下单之后触发一次邮件发送操作;
- 定期备份日志、定期备份数据库;
- 分布式任务计算;
- Tomcat 内部采用多线程,上百个客户端访问同一个 Web 应用,Tomcat 接入后,就把后续的处理扔给一个新的线程来处理,这个新的线程最后调用我们的 servlet 程序,比如 doGet 或者 doPost 方法。
这些都是在利用线程或者进程技术来提升计算速度,完成一些复杂的业务场景。可以看出,合理地利用多线程或者多进程可以帮助我们对当前程序进行优化,但如果运用地不恰当,也有可能会“翻车”,例如:
- 单台机器部署了太多的应用,导致内存溢出;
- 在配置线程池的时候没有关注CPU的核心数,导致线程池的效率无法合理利用。
要想避免此类问题,我们需要能够深入到操作系统层面,深入理解进程和线程。
怎么理解进程和线程?
首先说说我自己对于进程和线程两个概念的理解,进程是CPU的一个基本资源分配单位,线程是任务执行的基本单元。
其实在早期的操作系统中,是没有“线程”概念的,大部分都是直接以“进程”作为任务执行的基本单元,那进程在运行过程中所产生的数据都被存在哪里呢?这里我们就需要了解下 PCB 这个概念了。
PCB 其实是一种描述进程的数据结构,它是专门用于存储每个进程在运行过程中所产生和需要的内存数据。
我们在前边的章节中介绍了寄存器的运作原理,当 CPU 发生时间片中断的时候,进程会发生上下文切换,此时需要将老进程执行过程中所使用的寄存器都存储到 PCB 当中,等后续再次恢复上下文的时候使用,整体情况如下图所示:
在 PCB 的内部存在以下信息:进程的创建者标识,进程在执行程序的时候需要用到的寄存器信息 、 栈指针 、 进程运行中所产生的内存数据。
在操作系统底层,可能会同时运作着许多个进程,每个进程的数据之所以能做到互相独立,关键点之一就是有了 PCB 的存在。面对如此之多的进程,操作系统又是如何存放它们的呢?
由于每个进程都有各自的状态,有些可能是出于睡眠状态,有些可能是出于阻塞状态,还有的会处于空闲或者是正在执行状态,为了能够实现对它们的分类管理以及快速定位,操作系统设计了一个索引链表对它进行管理,其结构如下图所示:
操作系统底层按照不同的进程状态设计了不同的队列,每个队列的首部都会有一个指针管理,这些队列在内存中采用链表的方式去组织,合理地利用了内存的内存空间。
其实每个进程都是存在于互相独立的内存空间中,就如同下图所示:
在采用多进程协作开发的过程中,程序员们发现这种设计存在一定的弊端,因为在进程在进行相互协作的过程中如果需要进行通信的话,其成本是比较大的,需要分配额外的内存资源,建立 PCB,回收资源等,其切换的流程图如下所示:
那么,既然进程的切换开销比较大,能否设计一种技术去降低这种切换和互相通信所带来的巨大开销?于是人们开始提出一种设想,渐渐地,一种轻量级的进程技术--线程便诞生了。
线程:更小的“进程”
线程其实可以看作是一个更小的“进程”,来看下边这张图,你或许就会对线程有更加深刻的认识了。下边这张图解释了同一个进程内部运行的两个线程的内存布局。
当发生线程上下文切换的时候,正在执行的线程会将运行程序时所产生的信息保存在一个叫做 TCB 的地方。这个 TCB 可以类比为 PCB 的迷你版本,也就是上图中粉色块部分。
各个 TCB 中都有着专属的程序计数器 、 寄存器 、 堆栈记录,它们各自的属性都是独立开来的,但是它们实际上都是在占用着进程所提供的资源,这一点我们称之为资源共享。同一个进程内的线程具有资源共享性,也就意味着同一个进程中的变量会被多个线程并发访问,这也正是并发编程中经常会被程序员们所提及的线程安全问题。
了解到这里,你应该对线程和进程的基本内部组成有了一定的了解了吧,正因为它们的内部组成不同,上下文切换的开销不同,所以在许多并发编程领域中,会优先采用多线程的思路去实现。
相信大家在实际工作中,或多或少都有接触过并发任务执行的情况,在多线程执行场景中,各个函数的调用通常都是轮流切换的状态,就如同下图所示:
A,B,C 三个线程的任务都是交替执行的,也就意味着,任一线程的任务可能会在执行到一半的时候暂停一段时间,然后等下一次执行的时候才恢复过来。
来看看下边这个案例你可能就对多任务执行的逻辑更加清晰了:
public class CpuTaskDemo {
public void printChar(String word){
System.out.print(word);
}
public static void main(String[] args) {
CpuTaskDemo c = new CpuTaskDemo();
Thread taskA = new Thread(new Runnable() {
@Override
public void run() {
for (int i=0;i<100;i++){
c.printChar("A");
}
}
});
taskA.start();
Thread taskB = new Thread(new Runnable() {
@Override
public void run() {
for (int i=0;i<100;i++){
c.printChar("B");
}
}
});
taskB.start();
Thread taskC = new Thread(new Runnable() {
@Override
public void run() {
for (int i=0;i<100;i++){
c.printChar("C");
}
}
});
taskC.start();
Thread taskD = new Thread(new Runnable() {
@Override
public void run() {
for (int i=0;i<100;i++){
c.printChar("D");
}
}
});
taskD.start();
}
}
这个案例中,我们通过构建了 4 个线程,分别输出 A,B,C,D 字符,但是通过实际的运行结果却发现,实际打印出来的内容并没有固定的先后顺序:
为什么B线程会出现执行到一半又暂停,后边又再次执行的情况呢?为什么各个线程的执行任务会同时进行呢?
其实这一切都和操作系统的多任务调度机制有关。
多任务调度的本质,就是让CPU从就绪线程队列中寻找一个合适的线程/进程,然后将它作为下一个需要执行的任务单元。那么该如何选择下一个合适的进程/线程呢?这就涉及到了 CPU 的调度机制。
CPU的调度机制
CPU的调度机制主要划分为两种类型:
- 抢占式调度: 交给操作系统的内核来决定中断哪些任务。
- 非抢占式调度: 必须要等运行中的任务完全运行结束后,才可以切换为下一个任务,任务在运行过程中不允许被终止。
另外当任务被中断的时候,大多数都是由内核态去操作的,这一系列的行为对于用户态是无感知的。在 Linux 操作系统中,大多数的场景都是采用了抢占式调度。
当然操作系统的多任务调度也不是遇见啥就干啥的,没有任何策略的,它也有自己的评估机制。
CPU 在执行调度决策的时候主要会从以下几个点去衡量:
- CPU 的整体繁忙状态所占时间比例。
- 整个系统的吞吐量高低。
- 单个任务执行的周期时长。
- 单个任务的等待周期时长。
- 提交请求到响应所花费的整体时长。
通常在 IO 拷贝类型的任务中,更多会关注数据的吞吐量是否足够高,而在一些桌面应用程序中,则会更多关注响应速度是否足够快的要点。
有的任务随着执行时间越久,其任务的等级会上升或下降,例如一些 CPU 计算型任务,计算多的进程对于时间片消耗较高,一般会被放在低级别的队列中,而一些耗时较短的任务可以直接放在高级别的队列中。
在 CPU 里有一个叫 TLB 的角色 ,它主要用于缓存虚拟内存到物理内存映射关系的页表,如果进程发生上下文切换,会导致该部分缓冲的上一个进程的页表映射关系成为脏页,此时 CPU 会刷新整个 TLB 为新进程的页表,这个过程也是多进程切换中最为消耗性能的环节。
因此现在的大多数应用程序基本都是通过多线程模型的运作方式来提升整体性能,但是在一些大数据领域多进程模式依然是主流。