笔记整理自拉钩教育
17 | 进程和线程:进程的开销比线程大在了哪里?
不知你在面试中是否遇到过这样的问题,题目很短,看似简单,但在回答时又感觉有点吃力?比如下面这两个问题:
进程内部都有哪些数据?
为什么创建进程的成本很高?
这样的问题确实不好回答,除非你真正理解了进程和线程的原理,否则很容易掉入面试大坑。本讲,我将带你一起探究问题背后的原理,围绕面试题展开理论与实践知识的学习。通过本讲的学习,希望你可以真正理解进程和线程原理,从容应对面试。
进程和线程
进程(Process),顾名思义就是正在执行的应用程序,是软件的执行副本。而线程是轻量级的进程。
进程是分配资源的基础单位。而线程很长一段时间被称作轻量级进程(Light Weighted Process),是程序执行的基本单位。
在计算机刚刚诞生的年代,程序员拿着一个写好程序的闪存卡,插到机器里,然后电能推动芯片计算,芯片每次从闪存卡中读出一条指令,执行后接着读取下一条指令。闪存中的所有指令执行结束后,计算机就关机。
早期的 ENIAC
一开始,这种单任务的模型,在那个时代叫作作业(Job),当时计算机的设计就是希望可以多处理作业。图形界面出现后,人们开始利用计算机进行办公、购物、聊天、打游戏等,因此一台机器正在执行的程序会被随时切来切去。于是人们想到,设计进程和线程来解决这个问题。
每一种应用,比如游戏,执行后是一个进程。但是游戏内部需要图形渲染、需要网络、需要响应用户操作,这些行为不可以互相阻塞,必须同时进行,这样就设计成线程。
资源分配问题
设计进程和线程,操作系统需要思考分配资源。最重要的 3 种资源是:计算资源(CPU)、内存资源和文件资源。早期的 OS 设计中没有线程,3 种资源都分配给进程,多个进程通过分时技术交替执行,进程之间通过管道技术等进行通信。
但是这样做的话,设计者们发现用户(程序员),一个应用往往需要开多个进程,因为应用总是有很多必须要并行做的事情。并行并不是说绝对的同时,而是说需要让这些事情看上去是同时进行的——比如图形渲染和响应用户输入。于是设计者们想到了,进程下面,需要一种程序的执行单位,仅仅被分配 CPU 资源,这就是线程。
轻量级进程
线程设计出来后,因为只被分配了计算资源(CPU),因此被称为轻量级进程。被分配的方式,就是由操作系统调度线程。操作系统创建一个进程后,进程的入口程序被分配到了一个主线程执行,这样看上去操作系统是在调度进程,其实是调度进程中的线程。
这种被操作系统直接调度的线程,我们也成为内核级线程。另外,有的程序语言或者应用,用户(程序员)自己还实现了线程。相当于操作系统调度主线程,主线程的程序用算法实现子线程,这种情况我们称为用户级线程。Linux 的 PThread API 就是用户级线程,KThread API 则是内核级线程。
分时和调度
因为通常机器中 CPU 核心数量少(从几个到几十个)、进程&线程数量很多(从几十到几百甚至更多),你可以类比为发动机少,而机器多,因此进程们在操作系统中只能排着队一个个执行。每个进程在执行时都会获得操作系统分配的一个时间片段,如果超出这个时间,就会轮到下一个进程(线程)执行。再强调一下,现代操作系统都是直接调度线程,不会调度进程。
分配时间片段
如下图所示,进程 1 需要 2 个时间片段,进程 2 只有 1 个时间片段,进程 3 需要 3 个时间片段。因此当进程 1 执行到一半时,会先挂起,然后进程 2 开始执行;进程 2 一次可以执行完,然后进程 3 开始执行,不过进程 3 一次执行不完,在执行了 1 个时间片段后,进程 1 开始执行;就这样如此周而复始。这个就是分时技术。
下面这张图更加直观一些,进程 P1 先执行一个时间片段,然后进程 P2 开始执行一个时间片段, 然后进程 P3,然后进程 P4……
注意,上面的两张图是以进程为单位演示,如果换成线程,操作系统依旧是这么处理。
进程和线程的状态
一个进程(线程)运行的过程,会经历以下 3 个状态:
进程(线程)创建后,就开始排队,此时它会处在“就绪”(Ready)状态;
当轮到该进程(线程)执行时,会变成“运行”(Running)状态;
当一个进程(线程)将操作系统分配的时间片段用完后,会回到“就绪”(Ready)状态。
我这里一直用进程(线程)是因为旧的操作系统调度进程,没有线程;现代操作系统调度线程。
有时候一个进程(线程)会等待磁盘读取数据,或者等待打印机响应,此时进程自己会进入“阻塞”(Block)状态。
因为这时计算机的响应不能马上给出来,而是需要等待磁盘、打印机处理完成后,通过中断通知 CPU,然后 CPU 再执行一小段中断控制程序,将控制权转给操作系统,操作系统再将原来阻塞的进程(线程)置为“就绪”(Ready)状态重新排队。
而且,一旦一个进程(线程)进入阻塞状态,这个进程(线程)此时就没有事情做了,但又不能让它重新排队(因为需要等待中断),所以进程(线程)中需要增加一个“阻塞”(Block)状态。
注意,因为一个处于“就绪”(Ready)的进程(线程)还在排队,所以进程(线程)内的程序无法执行,也就是不会触发读取磁盘数据的操作,这时,“就绪”(Ready)状态无法变成阻塞的状态,因此下图中没有从就绪到阻塞的箭头。
而处于“阻塞”(Block)状态的进程(线程)如果收到磁盘读取完的数据,它又需要重新排队,所以它也不能直接回到“运行”(Running)状态,因此下图中没有从阻塞态到运行态的箭头。
进程和线程的设计
接下来我们思考几个核心的设计约束:
- 进程和线程在内存中如何表示?需要哪些字段?
- 进程代表的是一个个应用,需要彼此隔离,这个隔离方案如何设计?
- 操作系统调度线程,线程间不断切换,这种情况如何实现?
- 需要支持多 CPU 核心的环境,针对这种情况如何设计?
接下来我们来讨论下这4个问题。
进程和线程的表示
可以这样设计,在内存中设计两张表,一张是进程表、一张是线程表。
进程表记录进程在内存中的存放位置、PID 是多少、当前是什么状态、内存分配了多大、属于哪个用户等,这就有了进程表。如果没有这张表,进程就会丢失,操作系统不知道自己有哪些进程。这张表可以考虑直接放到内核中。
细分的话,进程表需要这几类信息。
-
描述信息:这部分是描述进程的唯一识别号,也就是 PID,包括进程的名称、所属的用户等。
-
资源信息:这部分用于记录进程拥有的资源,比如进程和虚拟内存如何映射、拥有哪些文件、在使用哪些 I/O 设备等,当然 I/O 设备也是文件。
-
内存布局:操作系统也约定了进程如何使用内存。如下图所示,描述了一个进程大致内存分成几个区域,以及每个区域用来做什么。 每个区域我们叫作一个段。
操作系统还需要一张表来管理线程,这就是线程表。线程也需要 ID, 可以叫作 ThreadID。然后线程需要记录自己的执行状态(阻塞、运行、就绪)、优先级、程序计数器以及所有寄存器的值等等。线程需要记录程序计数器和寄存器的值,是因为多个线程需要共用一个 CPU,线程经常会来回切换,因此需要在内存中保存寄存器和 PC 指针的值。
用户级线程和内核级线程存在映射关系,因此可以考虑在内核中维护一张内核级线程的表,包括上面说的字段。
如果考虑到这种映射关系,比如 n-m 的多对多映射,可以将线程信息还是存在进程中,每次执行的时候才使用内核级线程。相当于内核中有个线程池,等待用户空间去使用。每次用户级线程把程序计数器等传递过去,执行结束后,内核线程不销毁,等待下一个任务。这里其实有很多灵活的实现,总体来说,创建进程开销大、成本高;创建线程开销小,成本低。
隔离方案
操作系统中运行了大量进程,为了不让它们互相干扰,可以考虑为它们分配彼此完全隔离的内存区域,即便进程内部程序读取了相同地址,而实际的物理地址也不会相同。这就好比 A 小区的 10 号楼 808 和 B 小区的 10 号楼 808 不是一套房子,这种方法叫作地址空间,我们将在“21 讲”的页表部分讨论“地址空间”的详细内容。
所以在正常情况下进程 A 无法访问进程 B 的内存,除非进程 A 找到了某个操作系统的漏洞,恶意操作了进程 B 的内存,或者利用我们在“21 讲”讲到的“进程间通信”的手段。
对于一个进程的多个线程来说,可以考虑共享进程分配到的内存资源,这样线程就只需要被分配执行资源。
进程(线程)切换
进程(线程)在操作系统中是不断切换的,现代操作系统中只有线程的切换。 每次切换需要先保存当前寄存器的值的内存,注意 PC 指针也是一种寄存器。当恢复执行的时候,就需要从内存中读出所有的寄存器,恢复之前的状态,然后执行。
上面讲到的内容,我们可以概括为以下 5 个步骤:
- 当操作系统发现一个进程(线程)需要被切换的时候,直接控制 PC 指针跳转是非常危险的事情,所以操作系统需要发送一个“中断”信号给 CPU,停下正在执行的进程(线程)。
- 当 CPU 收到中断信号后,正在执行的进程(线程)会立即停止。注意,因为进程(线程)马上被停止,它还来不及保存自己的状态,所以后续操作系统必须完成这件事情。
- 操作系统接管中断后,趁寄存器数据还没有被破坏,必须马上执行一小段非常底层的程序(通常是汇编编写),帮助寄存器保存之前进程(线程)的状态。
- 操作系统保存好进程状态后,执行调度程序,决定下一个要被执行的进程(线程)。
- 最后,操作系统执行下一个进程(线程)。
当然,一个进程(线程)被选择执行后,它会继续完成之前被中断时的任务,这需要操作系统来执行一小段底层的程序帮助进程(线程)恢复状态。
一种可能的算法就是通过栈这种数据结构。进程(线程)中断后,操作系统负责压栈关键数据(比如寄存器)。恢复执行时,操作系统负责出栈和恢复寄存器的值。
多核处理
在多核系统中我们上面所讲的设计原则依然成立,只不过动力变多了,可以并行执行的进程(线程)。通常情况下,CPU 有几个核,就可以并行执行几个进程(线程)。这里强调一个概念,我们通常说的并发,英文是 concurrent,指的在一段时间内几个任务看上去在同时执行(不要求多核);而并行,英文是 parallel,任务必须绝对的同时执行(要求多核)。
比如一个 4 核的 CPU 就好像拥有 4 条流水线,可以并行执行 4 个任务。一个进程的多个线程执行过程则会产生竞争条件,这块我们会在“19 讲”锁和信号量部分给你介绍。因为操作系统提供了保存、恢复进程状态的能力,使得进程(线程)也可以在多个核心之间切换。
创建进程(线程)的 API
用户想要创建一个进程,最直接的方法就是从命令行执行一个程序,或者双击打开一个应用。但对于程序员而言,显然需要更好的设计。
站在设计者的角度,你可以这样思考:首先,应该有 API 打开应用,比如可以通过函数打开某个应用;另一方面,如果程序员希望执行完一段代价昂贵的初始化过程后,将当前程序的状态复制好几份,变成一个个单独执行的进程,那么操作系统提供了 fork 指令。
也就是说,每次 fork 会多创造一个克隆的进程,这个克隆的进程,所有状态都和原来的进程一样,但是会有自己的地址空间。如果要创造 2 个克隆进程,就要 fork 两次。
你可能会问:那如果我就是想启动一个新的程序呢?
我在上文说过:操作系统提供了启动新程序的 API。
你可能还会问:如果我就是想用一个新进程执行一小段程序,比如说每次服务端收到客户端的请求时,我都想用一个进程去处理这个请求。
如果是这种情况,我建议你不要单独启动进程,而是使用线程。因为进程的创建成本实在太高了,因此不建议用来做这样的事情:要创建条目、要分配内存,特别是还要在内存中形成一个个段,分成不同的区域。所以通常,我们更倾向于多创建线程。
不同程序语言会自己提供创建线程的 API,比如 Java 有 Thread 类;go 有 go-routine(注意不是协程,是线程)。
总结
本讲我们学习了进程和线程的基本概念。了解了操作系统如何调度进程(线程)和分时算法的基本概念,然后了解进程(线程)的 3 种基本状态。线程也被称作轻量级进程,由操作系统直接调度的,是内核级线程。我们还学习了线程切换保存、恢复状态的过程。
我们发现进程和线程是操作系统为了分配资源设计的两个概念,进程承接存储资源,线程承接计算资源。而进程包含线程,这样就可以做到进程间内存隔离。这是一个非常巧妙的设计,概念清晰,思路明确,你以后做架构的时候可以多参考这样的设计。 如果只有进程,或者只有线程,都不能如此简单的解决我们遇到的问题。
那么通过这节课的学习,你现在可以来回答本节关联的面试题目:进程的开销比线程大在了哪里?
【解析】 Linux 中创建一个进程自然会创建一个线程,也就是主线程。创建进程需要为进程划分出一块完整的内存空间,有大量的初始化操作,比如要把内存分段(堆栈、正文区等)。创建线程则简单得多,只需要确定 PC 指针和寄存器的值,并且给线程分配一个栈用于执行程序,同一个进程的多个线程间可以复用堆栈。因此,创建进程比创建线程慢,而且进程的内存开销更大。
思考题
最后我再给你出一道思考题。考虑下面的程序:
fork()
fork()
fork()
print("Hello World\n")
请问这个程序执行后, 输出结果 Hello World 会被打印几次?
18 | 锁、信号量和分布式锁:如何控制同一时间只有 2 个线程运行?
锁是一个面试的热门话题,有乐观锁、悲观锁、重入锁、公平锁、分布式锁。有很多和锁相关的数据结构,比如说阻塞队列。还有一些关联的一些工具,比如说 Semaphore、Monitor 等。这些知识点可以关联很多的面试题目,比如:
- 锁是如何实现的?
- 如何控制同一时间只有 2 个线程运行?
- 如何实现分布式锁?
面试官通过这类题目考查你的这部分知识,就知道你对并发的理解是停留在表面,还是可以深入原理,去设计高并发的数据结构。这一讲我将帮你把锁类问题一网打尽。
原子操作
要想弄清楚锁,就要弄清楚锁的实现,实现锁需要底层提供的原子操作,因此我们先来学习下原子操作。
原子操作就是操作不可分。在多线程环境,一个原子操作的执行过程无法被中断。那么你可以思考下,具体原子操作的一个示例。
比如i++就不是一个原子操作,因为它是 3 个原子操作组合而成的:
- 读取 i 的值;
- 计算 i+1;
- 写入新的值。
像这样的操作,在多线程 + 多核环境会造成竞争条件。
竞争条件
竞争条件就是说多个线程对一个资源(内存地址)的读写存在竞争,在这种条件下,最后这个资源的值不可预测,而是取决于竞争时具体的执行顺序。
举个例子,比如两个线程并发执行i++。那么可以有下面这个操作顺序,假设执行前i=0:
虽然上面的程序执行了两次i++,但最终i的值为 1。
i++这段程序访问了共享资源,也就是变量i,这种访问共享资源的程序片段我们称为临界区。在临界区,程序片段会访问共享资源,造成竞争条件,也就是共享资源的值最终取决于程序执行的时序,因此这个值不是确定的。
竞争条件是一件非常糟糕的事情,你可以把上面的程序想象成两个自动提款机。如果用户同时操作两个自动提款机,用户的余额就可能会被算错。
解决竞争条件
解决竞争条件有很多方案,一种方案就是不要让程序同时进入临界区,这个方案叫作互斥。还有一些方案旨在避免竞争条件,比如 ThreadLocal、 cas 指令以及 “19 讲”中我们要学习的乐观锁。
避免临界区
不让程序同时进入临界区这个方案比较简单,核心就是我们给每个线程一个变量i,比如利用 ThreadLocal,这样线程之间就不存在竞争关系了。这样做优点很明显,缺点就是并不是所有的情况都允许你这样做。有一些资源是需要共享的,比如一个聊天室,如果每次用户请求都有一个单独的线程在处理,不可能为每个请求(线程)都维护一份聊天记录。
cas 指令
另一个方案是利用 CPU 的指令,让i++成为一个原子操作。 很多 CPU 都提供 Compare And Swap 指令。这个指令的作用是更新一个内存地址的值,比如把i更新为i+1,但是这个指令明确要求使用者必须确定知道内存地址中的值是多少。比如一个线程想把i从100更新到101,线程必须明确地知道现在i是 100,否则就会更新失败。
cas 可以用下面这个函数表示:
cas(&oldValue, expectedValue, targetValue)
这里我用的是伪代码,用&符号代表这里取内存地址。注意 cas 是 CPU 提供的原子操作。因此上面的比较和设置值的过程,是原子的,也就是不可分。
比如想用 cas 更新i的值,而且知道i是 100,想更新成101。那么就可以这样做:
cas(&i, 100, 101)
如果在这个过程中,有其他线程把i更新为101,这次调用会返回 false,否则返回 true。
所以i++程序可以等价的修改为:
// i++等价程序
cas(&i, i, i+1)
上面的程序执行时,其实是 3 条指令:
读取i
计算i+1
cas操作:比较期望值i和i的真实值的值是否相等,如果是,更新目标值
假设i=0,考虑两个线程分别执行一次这个程序,尝试构造竞争条件:
你可以看到通过这种方式,cas 解决了一部分问题,找到了竞争条件,并返回了 false。但是还是无法计算出正确的结果。因为最后一次 cas 失败了。
如果要完全解决可以考虑这样去实现:
while(!cas(&i, i, i+1)){
// 什么都不做
}
如果 cas 返回 false,那么会尝试再读一次 i 的值,直到 cas 成功。
tas 指令
还有一个方案是 tas 指令,有的 CPU 没有提供 cas(大部分服务器是提供的),提供一种 Test-And-Set 指令(tas)。tas 指令的目标是设置一个内存地址的值为 1,它的工作原理和 cas 相似。首先比较内存地址的数据和 1 的值,如果内存地址是 0,那么把这个地址置 1。如果是 1,那么失败。
所以你可以把 tas 看作一个特殊版的cas,可以这样来理解:
tas(&lock) {
return cas(&lock, 0, 1)
}
锁
锁(lock),目标是实现抢占(preempt)。就是只让给定数量的线程进入临界区。锁可以用tas或者cas来实现。
举个例子:如果希望同时只能有一个线程执行i++,伪代码可以这么写:
enter();
i++;
leave();
可以考虑用cas实现enter和leave函数,代码如下:
int lock = 0;
enter(){
while( !cas(&lock, 0, 1) ) {
// 什么也不做
}
}
leave(){
lock = 0;
}
多个线程竞争一个整数的 lock 变量,0 代表目前没有线程进入临界区,1 代表目前有线程进入临界区。利用cas原子指令我们可以对临界区进行管理。如果一个线程利用 cas 将 lock 设置为 1,那么另一个线程就会一直执行cas操作,直到锁被释放。
语言级锁的实现
上面解决竞争条件的时候,我们用到了锁。 相比 cas,锁是一种简单直观的模型。总体来说,cas 更底层,用 cas 解决问题优化空间更大。但是用锁解决问题,代码更容易写——进入临界区之前 lock,出去就 unlock。 从上面这段代码可以看出,为了定义锁,我们需要用到一个整型。如果实现得好,可以考虑这个整数由语言级定义。
比如考虑让用户传递一个变量过去:
int lock = 0;
enter(&lock);
//临界区代码
leave(&lock);
自旋锁
上面我们已经用过自旋锁了,这是之前的代码:
enter(){
while( !cas(&lock, 0, 1) ) {
// 什么也不做
}
}
这段代码不断在 CPU 中执行指令,直到锁被其他线程释放。这种情况线程不会主动释放资源,我们称为自旋锁。自旋锁的优点就是不会主动发生 Context Switch,也就是线程切换,因为线程切换比较消耗时间。自旋锁缺点也非常明显,比较消耗 CPU 资源。如果自旋锁一直拿不到锁,会一直执行。
wait 操作
你可以考虑实现一个 wait 操作,主动触发 Context Switch。这样就解决了 CPU 消耗的问题。但是触发 Context Switch 也是比较消耗成本的事情,那么有没有更好的方法呢?
enter(){
while( !cas(&lock, 0, 1) ) {
// sleep(1000ms);
wait();
}
}
你可以看下上面的代码,这里有一个更好的方法:就是 cas 失败后,马上调用sleep方法让线程休眠一段时间。但是这样,可能会出现锁已经好了,但是还需要多休眠一小段时间的情况,影响计算效率。
另一个方案,就是用wait方法,等待一个信号——直到另一个线程调用notify方法,通知这个线程结束休眠。但是这种情况——wait 和 notify 的模型要如何实现呢?
生产者消费者模型
一个合理的实现就是生产者消费者模型。 wait 是一个生产者,将当前线程挂到一个等待队列上,并休眠。notify 是一个消费者,从等待队列中取出一个线程,并重新排队。
如果使用这个模型,那么我们之前简单用enter和leave来封装加锁和解锁的模式,就需要变化。我们需要把enter leave wait notify的逻辑都封装起来,不让用户感知到它们的存在。
比如 Java 语言,Java 为每个对象增加了一个 Object Header 区域,里面一个锁的位(bit),锁并不需要一个 32 位整数,一个 bit 足够。下面的代码用户使用 synchronized 关键字让临界区访问互斥。
synchronized(obj){// enter
// 临界区代码
} // leave
synchronized 关键字的内部实现,用到了封装好的底层代码——Monitor 对象。每个 Java 对象都关联了一个 Monitor 对象。Monitor 封装了对锁的操作,比如 enter、leave 的调用,这样简化了 Java 程序员的心智负担,你只需要调用 synchronized 关键字。
另外,Monitor 实现了生产者、消费者模型。
- 如果一个线程拿到锁,那么这个线程继续执行;
- 如果一个线程竞争锁失败,Montior 就调用 wait 方法触发生产者的逻辑,把线程加入等待集合;
- 如果一个线程执行完成,Monitor 就调用一次 notify 方法恢复一个等待的线程。 这样,Monitor 除了提供了互斥,还提供了线程间的通信,避免了使用自旋锁,还简化了程序设计。
信号量
接下来介绍一个叫作信号量的方法,你可以把它看作是互斥的一个广义版。我们考虑一种更加广义的锁,这里请你思考如何同时允许 N 个线程进入临界区呢?
我们先考虑实现一个基础的版本,用一个整数变量lock来记录进入临界区线程的数量。
int lock = 0;
enter(){
while(lock++ > 2) { }
}
leave(){
lock--;
}
上面的代码具有一定的欺骗性,没有考虑到竞争条件,执行的时候会出问题,可能会有超过2个线程同时进入临界区。
下面优化一下,作为一个考虑了竞争条件的版本:
up(&lock){
while(!cas(&lock, lock, lock+1)) { }
}
down(&lock){
while(!cas(&lock, lock, lock - 1) || lock == 0){}
}
为了简化模型,我们重新设计了两个原子操作up和down。up将lock增 1,down将lock减 1。当 lock 为 0 时,如果还在down那么会自旋。考虑用多个线程同时执行下面这段程序:
int lock = 2;
down(&lock);
// 临界区
up(&lock);
如果只有一个线程在临界区,那么lock等于 1,第 2 个线程还可以进入。 如果两个线程在临界区,第 3 个线程尝试down的时候,会陷入自旋锁。当然我们也可以用其他方式来替代自旋锁,比如让线程休眠。
当lock初始值为 1 的时候,这个模型就是实现互斥(mutex)。如果 lock 大于 1,那么就是同时允许多个线程进入临界区。这种方法,我们称为信号量(semaphore)。
信号量实现生产者消费者模型
信号量可以用来实现生产者消费者模型。下面我们通过一段代码实现生产者消费者:
int empty = N; // 当前空位置数量
int mutex = 1; // 锁
int full = 0; // 当前的等待的线程数
wait(){
down(&empty);
down(&mutex);
insert();
up(&mutex);
up(&full);
}
notify(){
down(&full);
down(&mutex);
remove();
up(&mutex);
up(&empty)
}
insert(){
wait_queue.add(currentThread);
yield();
}
remove(){
thread = wait_queue.dequeue();
thread.resume();
}
代码中 wait 是生产者,notify 是消费者。 每次wait操作减少一个空位置数量,empty-1;增加一个等待的线程,full+1。每次notify操作增加一个空位置,empty+1,减少一个等待线程,full-1。
insert和remove方法是互斥的操作,需要用另一个 mutex 锁来保证。insert方法将当前线程加入等待队列,并且调用 yield 方法,交出当前线程的控制权,当前线程休眠。remove方法从等待队列中取出一个线程,并且调用resume进行恢复。以上, 就构成了一个简单的生产者消费者模型。
死锁问题
另外就是在并行的时候,如果两个线程互相等待对方获得的锁,就会发生死锁。你可以把死锁理解成一个环状的依赖关系。比如:
int lock1 = 0;
int lock2 = 0;
// 线程1
enter(&lock1);
enter(&lock2);
leave(&lock1);
leave(&lock2);
// 线程2
enter(&lock2);
enter(&lock1);
leave(&lock1);
leave(&lock2)
上面的程序,如果是按照下面这个顺序执行,就会死锁:
线程1: enter(&lock1);
线程2: enter(&lock2);
线程1: enter(&lock2)
线程2: enter(&lock1)
上面程序线程 1 获得了lock1,线程 2 获得了lock2。接下来线程 1 尝试获得lock2,线程 2 尝试获得lock1,于是两个线程都陷入了等待。这个等待永远都不会结束,我们称之为死锁。
关于死锁如何解决,我们会在“21 | 哲学家就餐问题:什么情况下会触发饥饿和死锁?”讨论。这里我先讲一种最简单的解决方案,你可以尝试让两个线程对锁的操作顺序相同,这样就可以避免死锁问题。
分布式环境的锁
最后,我们留一点时间给分布式锁。我们之前讨论了非常多的实现,是基于多个线程访问临界区。现在要考虑一个更庞大的模型,我们有 100 个容器,每一个里面有一个为用户减少积分的服务。
简化下模型,假设积分存在 Redis 中。当然数据库中也有,但是我们只考虑 Redis。使用 Redis,我们目标是给数据库减负。
假设这个接口可以看作 3 个原子操作:
- 从 Redis 读出当前库存;
- 计算库存 -1;
- 更新 Redis 库存。
和i++类似,很明显,当用户并发的访问这个接口,是会发生竞争条件的。 因为程序已经不是在同一台机器上执行了,解决方案就是分布式锁。实现锁,我们需要原子操作。
在单机多线程并发的场景下,原子操作由 CPU 指令提供,比如 cas 和 tas 指令。那么在分布式环境下,原子操作由谁提供呢?
有很多工具都可以提供分布式的原子操作,比如 Redis 的 setnx 指令,Zookeeper 的节点操作等等。作为操作系统课程,这部分我不再做进一步的讲解。这里是从多线程的处理方式,引出分布式的处理方式,通过两个类比,帮助你提高。如果你感兴趣,可以自己查阅更多的分布式锁的资料。
总结
那么通过这节课的学习,你现在可以尝试来回答本讲关联的面试题目:如何控制同一时间只有 2 个线程运行?
【解析】 同时控制两个线程进入临界区,一种方式可以考虑用信号量(semaphore)。
另一种方式是考虑生产者、消费者模型。想要进入临界区的线程先在一个等待队列中等待,然后由消费者每次消费两个。这种实现方式,类似于实现一个线程池,所以也可以考虑实现一个 ThreadPool 类,然后再实现一个调度器类,最后实现一个每次选择两个线程执行的调度算法。
思考题
最后我再给你出一道需要查资料的思考题:如果考虑到 CPU 缓存的存在,会对上面我们讨论的算法有什么影响?
19 | 乐观锁、区块链:除了上锁还有哪些并发控制方法?
这一讲我带来的面试题是:除了上锁还有哪些并发控制方法?
上面这道面试题是在“有哪些并发控制方法?”这个问题的基础上加了一个限制条件。
在我面试候选人的过程中,“上锁”是我听到过回答频次最多的答案,也就是说大多数程序员都可以想到这个并发控制方法。因此,是否能回答出上锁以外的方法,是检验程序员能力的一个分水岭,其实锁以外还有大量优秀的方法。
你掌握的方法越多,那么在解决实际问题的时候,思路就越多。即使你没有做过高并发场景的设计,但是如果脑海中有大量优秀的方法可以使用,那么公司也会考虑培养你,将高并发场景交给你去解决。今天我们就以这道面试题为引,一起探讨下“锁以外的并发控制方法”。
悲观锁/乐观锁
说到并发场景,设计系统的目的往往是达到同步(Synchronized)的状态,同步就是大家最终对数据的理解达成了一致。
同步的一种方式,就是让临界区互斥。 这种方式,每次只有一个线程可以进入临界区。比如多个人修改一篇文章,这意味着必须等一个人编辑完,另一个人才能编辑。但是从实际问题出发,如果多个人编辑的不是文章的同一部分,是可以同时编辑的。因此,让临界区互斥的方法(对临界区上锁),具有强烈的排他性,对修改持保守态度,我们称为悲观锁(Pressimistic Lock)。
通常意义上,我们说上锁,就是悲观锁,比如说 MySQL 的表锁、行锁、Java 的锁,本质是互斥(mutex)。
和悲观锁(PressimisticLock)持相反意见的,是乐观锁(Optimistic Lock)。你每天都用的,基于乐观锁的应用就是版本控制工具 Git。Git 允许大家一起编辑,将结果先存在本地,然后都可以向远程仓库提交,如果没有版本冲突,就可以提交上去。这就是一种典型的乐观锁的场景,或者称为基于版本控制的场景。
Git 的类比
比如现在代码仓库的版本是 100。Bob 和 Alice 把版本 100 拷贝到本地,Bob 在本地写到了 106 版本,Alice 在本地写到 108 版本。那么如果 Alice 先提交,代码仓库的版本就到了 108。 Bob 再提交的时候,发现版本已经不是 100 了,就需要把最新的代码 fetch 到本地,然后合并冲突,再尝试提交一个更新的版本,比如 110。
这种方式非常类似cas指令的形式,就是每次更新的发起方,需要明确地知道想从多少版本更新到多少版本。以 Git 为例,可以写出cas的伪代码:
cas(&version, 100, 108); // 成功
cas(&version, 100, 106); // 失败,因为version是108
上面代码第二次cas操作时因为版本变了,更新失败,这就是一个乐观锁——Alice 和 Bob 可以同时写,先更新的人被采纳,后更新的人负责解决冲突。
购物车的类比
再举个例子,比如说要实现一个购物车。用户可能在移动端、PC 端之间切换,比如他用一会手机累了,然后换成用电脑,当他用电脑累了,再换回手机。
在移动端和 PC 端,用户都在操作购物车。 比如在移动端上,用户增加了商品 A;然后用户打开 PC 端,增加了商品 B;然后用户又换回了移动端,想增加商品 C。
这种时候,如果用悲观锁,用户登录移动端后,一种方案就是把 PC 端下线——当然这个方案显然不合理。 合理的方案是给购物车一个版本号,假设是 MySQL 表,那么购物车表中就会多一个版本字段。这样当用户操作购物车的时候,检查一下当前购物车的版本号是不是最新的,如果是最新的,那么就正常操作。如果不是最新的,就提示用户购物车在其他地方已被更新,需要刷新。
去中心化方案:区块链的类比
继续类比,我们可以思考一个更加有趣的方案。在传统的架构中,我们之所以害怕并发,是因为中心化。比如说 DNS 系统,如果全球所有的 DNS 查询都执行一个集群,这个吞吐量是非常恐怖的,因此 DNS 系统用了一个分级缓存的策略。
但是交易数据分布的时候,比如下单、支付、修改库存,如果用分布式处理,就牵扯到分布式锁(分布式事务)。那么,有没有一个去中心化的方案,让业务不需要集中处理呢?比如说双 11 期间你在淘宝上买东西,可不可以直接和商家下单,而不用通过淘宝的中心系统呢?——如果可以,这也就相当于实现了同步,或者说去掉了高并发的同步。
解决最基本的信用问题
考虑购买所有的网购产品,下单不再走中心化的平台。比如阿里、拼多多、 京东、抖音……这些平台用户都不走平台的中心系统下单,而是用户直接和商家签订合同。这个技术现在已经实现了,叫作电子合同。
举例:Alice(A)向苹果店 B 购买了一个 iPhone。那么双方签订电子合同,合同内容 C 是:
from=A, to=B, price=10000, signature=alice的签名
from=B, to=A, object=iphone, signature=苹果店的签名
上面两条记录,第 1 条是说 A 同意给 B 转 10000 块钱;第 2 条记录说,B 同意给 A 一个 iPhone。如果 A 收了 iPhone 不给 B 打款,B 可以拿着这个电子合同去法院告 A。因为用 A 的签名,可以确定是 Alice 签署了这份协议。同理,如果苹果店不给 Alice iPhone,Alice 可以去法院告苹果店,因为 Alice 可以用苹果店的签名证明合同是真的。
解决货币和库存的问题
有了上面的例子,最基本的信用问题解决了。接下来,你可能会问,Alice 怎么证明自己有足够的钱买 iPhone?苹果店怎么证明有足够的 iPhone?
比如在某个对公开放的节点中,记录了:
account=alice, money=10000
account=bob, iphone=100
…… 以及很多其他的数据
我们假设这里的钱可能是 Alice 用某种手段放进来的。或者我们再简化这个模型,比如全世界所有人的钱,都在这个系统里,这样我们就不用关心钱从哪里来这个问题了。如果是比特币,钱是需要挖矿的。
如图,这个结构也叫作区块链。每个 Block 下面可以存一些数据,每个 Block 知道上一个节点是谁。每个 Block 有上一个节点的摘要签名。也就是说,如果 Block 10 是 Block 11 的上一个节点,那么 Block 11 会知道 Block 10 的存在,且用 Block 11 中 Block 10 的摘要签名,可以证明 Block 10 的数据没有被篡改过。
区块链构成了一个基于历史版本的事实链,前一个版本是后一个版本的历史。Alice 的钱和苹果店的 iPhone 数量,包括全世界所有人的钱,都在这些 Block 里。
购买转账的过程
下面请你思考,Alice 购买了 iPhone,需要提交两条新数据到上面的区块链。
from=A, to=B, price=10000, signature=alice的签名
from=B, to=A, object=iphone, signature=苹果店的签名
那么我们可以在末端节点上再增加一个区块,代表这次交易,如下图:
比如,Alice 先在本地完成这件事情,本地的区块链就会像上图那样。 假设有一个中心化的服务器,专门接收这些区块数据,Alice 接下来就可以把数据提交到中心化的服务器,苹果店从中心化服务器上看到这条信息,认为交易被 Alice 执行了,就准备发货。
如果世界上有很多人同时在这个末端节点上写新的 Block。那么可以考虑由一个可信任的中心服务帮助合并新增的区块数据。就好像多个人同时编辑了一篇文章,发生了冲突,那就可以考虑由一个人整合大家需要修改和新增的内容,避免同时操作产生混乱。
解决欺诈问题
正常情况下,所有记录都可以直接合并。但是比如Alice在一家店购买了 1 个 iPhone,在另外一家店购买了 2 个 iPhone,这个时候 Alice 的钱就不够付款了。 或者说 Alice 想用 20000 块买 3 个 iPhone,她还想骗一个。
那么 Alice 最终就需要写这样的记录:
from=A, to=B, price=10000, signature=alice的签名
from=B, to=A, object=iphone, signature=一个苹果店的签名
from=A, to=B1, price=20000, signature=alice的签名
from=B1, to=A, object=iphonex2, signature=另一个苹果店的签名
无论 Alice 以什么顺序写入这些记录,她的钱都是不够的,因为她只有 20000 的余额。 这样简单地就解决了欺诈问题。
如果 Alice 想要修改自己的余额,那么 Alice 怎么做呢?
Alice 需要新增一个末端的节点,比如她在末端节点上将自己的余额修改为 999999。那么 Alice 的余额,就和之前 Block 中记录的冲突了。简单一查,就知道 Alice 在欺诈。如果 Alice 想要修改之前的某个节点的数据,这个节点的摘要签名就会发生变化了, 那么后面所有的节点就失效了。
比如 Alice 修改了 Block 9 的数据,并把整个区块链拷贝给 Bob。Bob 通过验证签名,就知道 Alice 在骗人。如果 Alice 修改了所有 Block 9 以后的 Block,相当于修改了完整的一个链条,且修改了所有的签名。Bob 只需要核对其中几个版本和其他人,或者和中心服务的签名的区别就知道 Alice 在欺诈。
刚才有一个设计,就是有一个中心平台供 Bob 下载。如果中心平台修改了数据。那么 Bob 会马上发现存在本地的和自己相关的数据与中心平台不一致。这样 Bob 就会联合其他用户一起抵制中心平台。
所以结论是,区块链一旦写入就不能修改,这样可以防止很多欺诈行为。
解决并发问题
假设全球有几十亿人都在下单。那么每次下单,需要创建新的一个 Block。这种情况,会导致最后面的 Block,开很多分支。
这个时候你会发现,这里有同步问题对不对? 最傻的方案就是用锁解决,比如用一个集中式的办法,去接收所有的请求,这样就又回到中心化的设计。
还有一个高明的办法,就是允许商家开分支。 用户和苹果店订合同,苹果店独立做一个分支,把用户的合同连起来。
这样苹果店自己先维护自己的 Block-Chain,等待合适的时机,再去合并到主分支上。 如果有合同合并不进去,比如余额不足,那再作废这个合同(不发货了)。
这里请你思考这样一种处理方式:如果全世界每天有 1000 亿笔订单要处理,那么可以先拆分成 100 个区域,每个区域是 10W 家店。这样最终每家店的平均并发量在 10000 单。 然后可以考虑每过多长时间,比如 10s,进行一次逐级合并。
这样,整体每个节点的压力就不是很大了。
总结
在这一讲,我们主要学习了一些比锁更加有趣的处理方式, 其实还有很多方式,你可以去思考。并发问题也不仅仅是要解决并发问题,并发还伴随着一致性、可用性、欺诈及吞吐量等。一名优秀的架构师是需要储备多个维度的知识,所以还是我常常跟你强调的,知识在于积累,绝非朝夕之功。
另外,我想告诉你的是,其实大厂并不是只招收处理过并发场景的工程师。作为一名资深面试官,我愿意给任何人机会,前提是你的方案打动了我。而设计方案的能力,是可以学习的。你要多思考,多查资料,多整理总结,这样久而久之,就有公司愿意让你做架构了。
那么通过这节课的学习,你现在可以尝试来回答本节关联的面试题目:除了上锁还有哪些并发控制方法?
【解析】 这个问题比较发散,这一讲我们介绍了基于乐观锁的版本控制,还介绍了区块链技术。另外还有一个名词,并不属于操作系统课程范畴,我也简单给你介绍下。处理并发还可以考虑 Lock-Free 数据结构。比如 Lock-Free 队列,是基于 cas 指令实现的,允许多个线程使用这个队列。再比如 ThreadLocal,让每个线程访问不同的资源,旨在用空间换时间,也是避免锁的一种方案。
思考题
最后我再给你出一道需要查资料的思考题:举例各 2 个悲观锁和乐观锁的应用场景?
20 | 线程的调度:线程调度都有哪些方法?
这一讲我带来的面试题目是:线程调度都有哪些方法?
所谓调度,是一个制定计划的过程,放在线程调度背景下,就是操作系统如何决定未来执行哪些线程?
这类型的题目考察的并不是一个死的概念,面试官会通过你的回答考量你对知识进行加工和理解的能力。这有点类似于设计技术方案,要对知识进行系统化、结构化地思考和分类。就这道题目而言,可以抓两条主线,第一条是形形色色调度场景怎么来的?第二条是每个调度算法是如何工作的?
先到先服务
早期的操作系统是一个个处理作业(Job),比如很多保险业务,每处理一个称为一个作业(Job)。处理作业最容易想到的就是先到先服务(First Come First Service,FCFS),也就是先到的作业先被计算,后到的作业,排队进行。
这里需要用到一个叫作队列的数据结构,具有先入先出(First In First Out,FIFO)性质。先进入队列的作业,先处理,因此从公平性来说,这个算法非常朴素。另外,一个作业完全完成才会进入下一个作业,作业之间不会发生切换,从吞吐量上说,是最优的——因为没有额外开销。
但是这样对于等待作业的用户来说,是有问题的。比如一笔需要用时 1 天的作业 ,如果等待了 10 分钟,用户是可以接受的;一个用时 10 分钟的作业,用户等待一天就要投诉了。 因此如果用时 1 天的作业先到,用时 10 分钟的任务后到,应该优先处理用时少的,也就是短作业优先(Shortest Job First,SJF)。
短作业优先
通常会同时考虑到来顺序和作业预估时间的长短,比如下面的到来顺序和预估时间:
这样就会优先考虑第一个到来预估时间为 3 分钟的任务。 我们还可以从另外一个角度来审视短作业优先的优势,就是平均等待时间。
平均等待时间 = 总等待时间/任务数
上面例子中,如果按照 3,3,10 的顺序处理,平均等待时间是:(0 + 3 + 6) / 3 = 3 分钟。 如果按照 10,3,3 的顺序来处理,就是( 0+10+13 )/ 3 = 7.66 分钟。
平均等待时间和用户满意度是成反比的,等待时间越长,用户越不满意,因此在大多数情况下,应该优先处理用时少的,从而降低平均等待时长。
采用 FCFS 和 SJF 后,还有一些问题没有解决。
- 紧急任务如何插队?比如老板安排的任务。
- 等待太久的任务如何插队?比如用户等太久可能会投诉。
- 先执行的大任务导致后面来的小任务没有执行如何处理?比如先处理了一个 1 天才能完成的任务,工作半天后才发现预估时间 1 分钟的任务也到来了。
为了解决上面的问题,我们设计了两种方案, 一种是优先级队列(PriorityQueue),另一种是抢占(Preemption)。
优先级队列(PriorityQueue)
刚才提到老板安排的任务需要紧急插队,那么下一个作业是不是应该安排给老板?毫无疑问肯定是这样!那么如何控制这种优先级顺序呢?一种方法是用优先级队列。优先级队列可以给队列中每个元素一个优先级,优先级越高的任务就会被先执行。
优先级队列的一种实现方法就是用到了堆(Heap)这种数据结构,更最简单的实现方法,就是每次扫描一遍整个队列找到优先级最高的任务。也就是说,堆(Heap)可以帮助你在 O(1) 的时间复杂度内查找到最大优先级的元素。
比如老板的任务,就给一个更高的优先级。 而对于普通任务,可以在等待时间(W) 和预估执行时间(P) 中,找一个数学关系来描述。比如:优先级 = W/P。W 越大,或者 P 越小,就越排在前面。 当然还可以有很多其他的数学方法,利用对数计算,或者某种特别的分段函数。
这样,关于紧急任务如何插队?等待太久的任务如何插队?这两个问题我们都解决了,接下来我们来看先执行的大任务导致后面来的小任务没有执行的情况如何处理?
抢占
为了解决这个问题,我们需要用到抢占(Preemption)。
抢占就是把执行能力分时,分成时间片段。 让每个任务都执行一个时间片段。如果在时间片段内,任务完成,那么就调度下一个任务。如果任务没有执行完成,则中断任务,让任务重新排队,调度下一个任务。
拥有了抢占的能力,再结合之前我们提到的优先级队列能力,这就构成了一个基本的线程调度模型。线程相对于操作系统是排队到来的,操作系统为每个到来的线程分配一个优先级,然后把它们放入一个优先级队列中,优先级最高的线程下一个执行。
每个线程执行一个时间片段,然后每次执行完一个线程就执行一段调度程序。
图中用红色代表调度程序,其他颜色代表被调度线程的时间片段。调度程序可以考虑实现为一个单线程模型,这样不需要考虑竞争条件。
上面这个模型已经是一个非常优秀的方案了,但是还有一些问题可以进一步处理得更好。
- 如果一个线程优先级非常高,其实没必要再抢占,因为无论如何调度,下一个时间片段还是给它。那么这种情况如何实现?
- 如果希望实现最短作业优先的抢占,就必须知道每个线程的执行时间,而这个时间是不可预估的,那么这种情况又应该如何处理?
为了解决上面两个问题,我们可以考虑引入多级队列模型。
多级队列模型
多级队列,就是多个队列执行调度。 我们先考虑最简单的两级模型,如图:
上图中设计了两个优先级不同的队列,从下到上优先级上升,上层队列调度紧急任务,下层队列调度普通任务。只要上层队列有任务,下层队列就会让出执行权限。
- 低优先级队列可以考虑抢占 + 优先级队列的方式实现,这样每次执行一个时间片段就可以判断一下高优先级的队列中是否有任务。
- 高优先级队列可以考虑用非抢占(每个任务执行完才执行下一个)+ 优先级队列实现,这样紧急任务优先级有个区分。如果遇到十万火急的情况,就可以优先处理这个任务。
上面这个模型虽然解决了任务间的优先级问题,但是还是没有解决短任务先行的问题。可以考虑再增加一些队列,让级别更多。比如下图这个模型:
紧急任务仍然走高优队列,非抢占执行。普通任务先放到优先级仅次于高优任务的队列中,并且只分配很小的时间片;如果没有执行完成,说明任务不是很短,就将任务下调一层。下面一层,最低优先级的队列中时间片很大,长任务就有更大的时间片可以用。通过这种方式,短任务会在更高优先级的队列中执行完成,长任务优先级会下调,也就类似实现了最短作业优先的问题。
实际操作中,可以有 n 层,一层层把大任务筛选出来。 最长的任务,放到最闲的时间去执行。要知道,大部分时间 CPU 不是满负荷的。
总结
那么通过这一讲的学习,你现在可以尝试来回答本节关联的面试题目:线程调度都有哪些方法?
【解析】 回答这个问题你要把握主线,千万不要教科书般的回答:任务调度分成抢占和非抢占的,抢占的可以轮流执行,也可以用优先级队列执行;非抢占可以先到先服务,也可以最短任务优先。
上面这种回答可以用来过普通的程序员岗位,但是面试官其实更希望听到你的见解,这是初中级开发人员与高级开发人员之间的差异。
比如你告诉面试官:非抢占的先到先服务的模型是最朴素的,公平性和吞吐量可以保证。但是因为希望减少用户的平均等待时间,操作系统往往需要实现抢占。操作系统实现抢占,仍然希望有优先级,希望有最短任务优先。
但是这里有个困难,操作系统无法预判每个任务的预估执行时间,就需要使用分级队列。最高优先级的任务可以考虑非抢占的优先级队列。 其他任务放到分级队列模型中执行,从最高优先级时间片段最小向最低优先级时间片段最大逐渐沉淀。这样就同时保证了小任务先行和高优任务最先执行。
以上的回答,并不是一种简单的概括,还包含了你对问题的理解和认知。在面试时,正确性并不是唯一的考量指标,面试官更看重候选人的思维能力。这也是为什么很多人面试问题都答上来了,仍然没有拿到 offer 的原因。如果面试目标是正确性,为什么不让你开卷考试呢? 上维基百科看不是更正确吗?
思考题
最后我再给你出一道需要查资料的思考题:用你最熟悉的语言模拟分级队列调度的模型
21 | 哲学家就餐问题:什么情况下会触发饥饿和死锁?
这一讲给你带来的面试题目是:什么情况下会触发饥饿和死锁?
读题可知,这道题目在提问“场景”,从表面来看,解题思路是列举几个例子。但是在回答这类面试题前你一定要想一想面试官在考察什么,往往在题目中看到“什么情况下”时,其实考察的是你总结和概括信息的能力。
关于上面这道题目,如果你只回答一个场景,而没有输出概括性的总结内容,就很容易被面试官认为对知识理解不到位,因而挂掉面试。另外,提问死锁和饥饿还有一个更深层的意思,就是考察你在实战中对并发控制算法的理解,是否具备设计并发算法来解决死锁问题并且兼顾性能(并发量)的思维和能力。
要学习这部分知识有一个非常不错的模型,就是哲学家就餐问题。1965 年,计算机科学家 Dijkstra 为了帮助学生更好地学习并发编程设计的一道练习题,后来逐渐成为大家广泛讨论的问题。
哲学家就餐问题
问题描述如下:有 5 个哲学家,围着一个圆桌就餐。圆桌上有 5 份意大利面和 5 份叉子。哲学家比较笨,他们必须拿到左手和右手的 2 个叉子才能吃面。哲学不饿的时候就在思考,饿了就去吃面,吃面的必须前提是拿到 2 个叉子,吃完面哲学家就去思考。
假设每个哲学家用一个线程实现,求一种并发控制的算法,让哲学家们按部就班地思考和吃面。当然我这里做了一些改动,比如 Dijkstra 那个年代线程还没有普及,最早的题目每个哲学家是一个进程。
问题的抽象
接下来请你继续思考,我们对问题进行一些抽象,比如哲学是一个数组,编号 0~4。我这里用 Java 语言给你演示,哲学家是一个类,代码如下:
static class Philosopher implements Runnable {
private static Philosopher[] philosophers;
static {
philosophers = new Philosopher[5];
}
}
这里考虑叉子也使用编号 0~4,代码如下:
private static Integer[] forks;
private static Philosopher[] philosophers;
static {
for(int i = 0; i < 5; i++) {
philosophers[i] = new Philosopher(i);
forks[i] = -1;
}
}
forks[i]的值等于 x,相当于编号为i的叉子被编号为 x 的哲学家拿起;如果等于-1,那么叉子目前放在桌子上。
我们经常需要描述左、右的关系,为了方便计算,可以设计 1 个帮助函数(helper functions),帮助我们根据一个编号,计算它左边的编号。
private static int LEFT(int i) {
return i == 0 ? 4 : i-1;
}
假设和哲学家编号一致的叉子在右边,这样如果要判断编号为id哲学家是否可以吃面,需要这样做:
if(forks[LEFT(id)] == id && forks[id] == id) {
// 可以吃面
}
然后定义一个_take函数拿起编号为i叉子; 再设计一个_put方法放下叉子:
void _take(int i) throws InterruptedException {
Thread.sleep(10);
forks[i] = id;
}
void _put(int i){
if(forks[i] == id)
forks[i] = -1;
}
_take函数之所以会等待 10ms,是因为哲学家就餐问题的实际意义,是 I/O 处理的场景,拿起叉子好比读取磁盘,需要有一等的时间开销,这样思考才有意义。
然后是对think和eat两个方法的抽象。首先我封装了一个枚举类型,描述哲学家的状态,代码如下:
enum PHIS {
THINKING,
HUNGRY,
EATING
}
然后实现think方法,think方法不需要并发控制,但是这里用Thread.sleep模拟实际思考需要的开销,代码如下:
void think() throws InterruptedException {
System.out.println(String.format("Philosopher %d thinking...", id));
Thread.sleep((long) Math.floor(Math.random()*1000));
this.state = PHIS.HUNGRY;
最后是eat方法:
void eat() throws InterruptedException {
synchronized (forks) {
if(forks[LEFT(id)] == id && forks[id] == id) {
this.state = PHIS.EATING;
} else {
return;
}
}
Thread.sleep((long) Math.floor(Math.random()*1000));
}
eat方法依赖于forks对象的锁,相当于eat方法这里会同步——因为这里有读取临界区操作做。Thread.sleep依然用于描述eat方法的时间开销。sleep方法没有放到synchronized内是因为在并发控制时,应该尽量较少锁的范围,这样可以增加更大的并发量。
以上,我们对问题进行了一个基本的抽象。接下来请你思考在什么情况会发生死锁?
死锁(DeadLock)和活锁(LiveLock)
首先,可以思考一种最简单的解法,每个哲学家用一个while循环表示,代码如下:
while(true){
think();
_take(LEFT(id));
_take(id);
eat();
_put(LEFT(id));
_put(id);
}
void _take(id){
while(forks[id] != -1) { Thread.yield(); }
Thread.sleep(10); // 模拟I/O用时
}
_take可以考虑阻塞,直到哲学家得到叉子。上面程序我们还没有进行并发控制,会发生竞争条件。 顺着这个思路,就可以想到加入并发控制,代码如下:
while(true){
think();
synchronized(fork[LEFT(id)]) {
_take(LEFT(id));
synchronized(fork[id]) {
_take(id);
}
}
eat();
synchronized(fork[LEFT(id)]) {
_put(LEFT(id));
synchronized(fork[id]) {
_put(id);
}
}
}
上面的并发控制,会发生死锁问题,大家可以思考这样一个时序,如果 5 个哲学家都同时通过synchronized(fork[LEFT(id)]),有可能会出现下面的情况:
-
第 0 个哲学家获得叉子 4,接下来请求叉子 0;
-
第 1 个哲学家获得叉子 0,接下来请求叉子 1;
-
第 2 个哲学家获得叉子 1,接下来请求叉子 2;
-
第 3 个哲学家获得叉子 2,接下来请求叉子 3;
-
第 4 个哲学家获得叉子 3,接下来请求叉子 4。
为了帮助你理解,这里我画了一幅图。
如上图所示,可以看到这是一种循环依赖的关系,在这种情况下所有哲学家都获得了一个叉子,并且在等待下一个叉子。这种等待永远不会结束,因为没有哲学家愿意放弃自己拿起的叉子。
以上这种情况称为 死锁(Deadlock),这是一种饥饿(Starvation) 的形式。从概念上说,死锁是线程间互相等待资源,但是没有一个线程可以进行下一步操作。饥饿就是因为某种原因导致线程得不到需要的资源,无法继续工作。死锁是饥饿的一种形式,因为循环等待无法得到资源。哲学家就餐问题,会形成一种环状的死锁(循环依赖), 因此非常具有代表性。
死锁有 4 个基本条件。
- 资源存在互斥逻辑:每次只有一个线程可以抢占到资源。这里是哲学家抢占叉子。
- 持有等待:这里哲学家会一直等待拿到叉子。
- 禁止抢占:如果拿不到资源一直会处于等待状态,而不会释放已经拥有的资源。
- 循环等待:这里哲学家们会循环等待彼此的叉子。
刚才提到死锁也是一种饥饿(Starvation)的形式,饥饿比较简单,就是线程长期拿不到需要的资源,无法进行下一步操作。
要解决死锁的问题,可以考虑哲学家拿起 1 个叉子后,如果迟迟没有等到下一个叉子,就放弃这次操作。比如 Java 的 Lock Interface 中,提供的tryLock方法,就可以实现定时获取:
var lock = new ReentrantLock();
lock.tryLock(5, TimeUnit.SECONDS);
Java 提供的这个能力是拿不到锁,就报异常,并可以依据这个能力开发释放已获得资源的能力。
但是这样,我们会碰到一个叫作活锁(LiveLock)的问题。LiveLock 也是一种饥饿。可能在某个时刻,所有哲学及都拿起了左手的叉子,然后发现右手的叉子拿不到,就放下了左手的叉子——如此周而复始,这就是一种活锁。所有线程都在工作,但是没有线程能够进一步——解决问题。
在实际工作场景下,LiveLock 可以靠概率解决,因为同时拿起,又同时放下这种情况不会很多。实际工作场景很多系统,确实依赖于这个问题不频发。但是,优秀的设计者不能把系统设计依托在一个有概率风险的操作上,因此我们需要继续往深一层思考。
解决方案
其实解决上述问题有很多的方案,最简单、最直观的方法如下:
while(true){
synchronized(someLock) {
think();
_take(LEFT(id));
_take(id);
eat();
_put(LEFT(id));
_put(id);
}
}
上面这段程序同时只允许一个哲学家使用所有资源,我们用synchronized构造了一种排队的逻辑。而哲学家,每次必须拿起所有的叉子,吃完,再到下一哲学家。 这样并发度是 1,同时最多有一个线程在执行。 这样的方式可以完成任务,但是性能太差。
另一种方法是规定拿起过程必须同时拿起,放下过程也同时放下,代码如下:
while(true){
think();
synchronized(someLock) {
_takeForks();
}
eat();
synchronized(someLock) {
_puts();
}
}
void _takeForks(){
if( forks[LEFT(id)] == -1 && forks[id] == -1 ) {
forks[LEFT(id)] = id;
forks[id] = id;
}
}
void _puts(){
if(forks[LEFT(id)] == id)
forks[LEFT(id)] = -1;
if(forks[id] == id)
forks[id] = -1;
}
上面这段程序,think函数没有并发控制,一个哲学家要么拿起两个叉子,要么不拿起,这样并发度最高为 2(最多有两个线程同时执行)。而且,这个算法中只有一个锁,因此不存在死锁和饥饿问题。
到这里,我们已经对这个问题有了一个初步的方案,那么如何进一步优化呢?
思考和最终方案
整个问题复杂度的核心在于哲学家拿起叉子是有成本的。好比线程读取磁盘,需要消耗时间。哲学家的思考,是独立的。好比读取了磁盘数据,进行计算。那么有没有办法允许 5 个哲学家都同时去拿叉子呢?这样并发度是最高的。
经过初步思考,马上会发现这里有环状依赖, 会出现死锁。 原因就是如果 5 个哲学家同时拿叉子,那就意味着有的哲学家必须要放弃叉子。但是如果不放下会出现什么情况呢?
假设当一个哲学家发现自己拿不到两个叉子的时候,他去和另一个哲学家沟通把自己的叉子给对方。这样就相当于,有一个转让方法。相比于磁盘 I/O,转让内存中的数据成本就低的多了。 我们假设有这样一个转让的方法,代码如下:
void _transfer(int fork, int philosopher) {
forks[fork] = philosopher;
dirty[fork] = false;
}
这个方法相当于把叉子转让给另一个哲学家,这里你先不用管上面代码中的 dirty,后文中会讲到。而获取叉子的过程,我们可以进行调整,代码如下:
void take(int i) throws InterruptedException {
synchronized (forks[i]) {
if(forks[i] == -1) {
_take(id);
} else {
Philosopher other = philosophers[forks[i]];
if(other.state != PHIS.EATING && dirty[i]) {
other._transfer(i, forks[i]);
}
}
}
}
void _take(int i) throws InterruptedException {
Thread.sleep(10);
forks[i] = id;
}
这里我们把每个叉子看作一个锁,有多少个叉子,就有多少个锁,相当于同时可以拿起 5 个叉子(并发度是 5)。如果当前没有人拿起叉子,那么可以自己拿起。 如果叉子属于其他哲学家,就需要判断对方的状态。只要对方不在EATING,就可以考虑转让叉子。
最后是对 LiveLock 的思考,为了避免叉子在两个哲学家之间来回转让,我们为每个叉子增加了一个dirty属性。一开始叉子的dirty是true,每次转让后,哲学家会把自己的叉子擦干净给另一个哲学家。转让的前置条件是叉子是dirty的,所以叉子在两个哲学家之间只会转让一次。
通过上面算法,我们就可以避免死锁、饥饿以及提高读取数据(获取叉子)的并发度。最后完整的程序如下,给你做参考:
package test;
import java.util.Arrays;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.locks.ReentrantLock;
import java.util.concurrent.locks.StampedLock;
public class DiningPhilosophers {
enum PHIS {
THINKING,
HUNGRY,
EATING
}
static class Philosopher implements Runnable {
private static Philosopher[] philosophers;
private static Integer[] forks;
private static boolean[] dirty;
private PHIS state = PHIS.THINKING;
static {
philosophers = new Philosopher[5];
forks = new Integer[5];
dirty = new boolean[5];
for(int i = 0; i < 5; i++) {
philosophers[i] = new Philosopher(i);
forks[i] = -1;
dirty[i] = true;
}
}
private static int LEFT(int i) {
return i == 0 ? 4 : i-1;
}
public Philosopher(int id) {
this.id = id;
}
private int id;
void think() throws InterruptedException {
System.out.println(String.format("Philosopher %d thinking...", id));
Thread.sleep((long) Math.floor(Math.random()*1000));
this.state = PHIS.HUNGRY;
}
System.out.println(Arrays.toString(forks));
//System.out.println(Arrays.toString(dirty));
if(forks[LEFT(id)] == id && forks[id] == id) {
this.state = PHIS.EATING;
} else {
return;
}
}
System.out.println(String.format("Philosopher %d eating...", id));
Thread.sleep((long) Math.floor(Math.random()*1000));
synchronized (forks) {
dirty[LEFT(id)] = true;
dirty[id] = true;
}
var lock = new ReentrantLock();
lock.tryLock(5, TimeUnit.SECONDS);
state = PHIS.THINKING;
}
void _take(int i) throws InterruptedException {
Thread.sleep(10);
forks[i] = id;
}
void _transfer(int fork, int philosopher) {
forks[fork] = philosopher;
dirty[fork] = false;
}
void _putdown(int i) throws InterruptedException {
Thread.sleep(10);
forks[i] = -1;
}
void take(int i) throws InterruptedException {
synchronized (forks[i]) {
if(forks[i] == -1) {
_take(id);
} else {
Philosopher other = philosophers[forks[i]];
if(other.state != PHIS.EATING && dirty[i]) {
other._transfer(i, forks[i]);
}
}
}
}
void takeForks() throws InterruptedException {
take(LEFT(id));
take(id);
}
@Override
public void run() {
try {
while(true) {
think();
while (state == PHIS.HUNGRY) {
takeForks();
System.out.println("here--" + Math.random());
eat();
}
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
public static void main(String[] args) {
for(int i = 0; i < 5; i++) {
new Thread(new Philosopher(i)).start();
}
}
}
总结
那么通过这节课的学习,你现在可以尝试来回答本节关联的面试题目:什么情况下会触发饥饿和死锁?
【解析】 线程需要资源没有拿到,无法进行下一步,就是饥饿。死锁(Deadlock)和活锁(Livelock)都是饥饿的一种形式。 非抢占的系统中,互斥的资源获取,形成循环依赖就会产生死锁。死锁发生后,如果利用抢占解决,导致资源频繁被转让,有一定概率触发活锁。死锁、活锁,都可以通过设计并发控制算法解决,比如哲学家就餐问题。
思考题
最后我再给你出一道需要查资料的思考题:如果哲学家就餐问题拿起叉子、放下叉子,只需要微小的时间,主要时间开销集中在 think 需要计算资源(CPU 资源)上,那么使用什么模型比较合适?
22 | 进程间通信: 进程间通信都有哪些方法?
这节课带给你的面试题目是:进程间通信都有哪些方法?
在上一讲中,我们提到过,凡是面试官问“什么情况下”的时候,面试官实际想听的是你经过理解,整理得到的认知。回答应该是概括的、简要的。而不是真的去列举每一种 case。
另外,面试官考察进程间通信,有一个非常重要的意义——进程间通信是架构复杂系统的基石。复杂系统往往是分成各种子系统、子模块、微服务等等,按照 Unix 的设计哲学,系统的每个部分应该是稳定、独立、简单有效,而且强大的。系统本身各个模块就像人的器官,可以协同工作。而这个协同的枢纽,就是我们今天的主题——进程间通信。
什么是进程间通信?
进程间通信(Intermediate Process Communication,IPC)。所谓通信就是交换数据。所以,狭义地说,就是操作系统创建的进程们之间在交换数据。 我们今天不仅讨论狭义的通信,还要讨论 IPC 更广泛的意义——程序间的通信。 程序可以是进程,可以是线程,可以是一个进程的两个部分(进程自己发送给自己),也可以是分布式的——总之,今天讨论的是广义的交换数据。
管道
之前我们在“07 | 进程、重定向和管道指令:xargs 指令的作用是?”中讲解过管道和命名管道。 管道提供了一种非常重要的能力,就是组织计算。进程不用知道有管道存在,因此管道的设计是非侵入的。程序员可以先着重在程序本身的设计,只需要预留响应管道的接口,就可以利用管道的能力。比如用shell执行MySQL语句,可能会这样:
进程1 | 进程2 | 进程3 | mysql -u... -p | 爬虫进程
我们可以由进程 1、进程 2、进程 3 计算出 MySQL 需要的语句,然后直接通过管道执行。MySQL经过计算将结果传给一个爬虫进程,爬虫就开始工作。MySQL并不是设计用于管道,爬虫进程也不是设计专门用于管道,只是程序员恰巧发现可以这样用,完美地解决了自己的问题,比如:用管道构建一个微型爬虫然后把结果入库。
我们还学过一个词叫作命名管道。命名管道并没有改变管道的用法。相比匿名管道,命名管道提供了更多的编程手段。比如:
进程1 > namedpipe
进程2 > namedpipe
上面的程序将两个进程的临时结果都同时重定向到 namedpipe,相当于把内容合并了再找机会处理。再比如说,你的进程要不断查询本地的 MySQL,也可以考虑用命名管道将查询传递给 MySQL,再用另一个命名管道传递回来。这样可以省去和 localhost 建立 TCP 3 次握手的时间。 当然,现在数据库都是远程的了,这里只是一个例子。
管道的核心是不侵入、灵活,不会增加程序设计负担,又能组织复杂的计算过程。
本地内存共享
同一个进程的多个线程本身是共享进程内存的。 这种情况不需要特别考虑共享内存。如果是跨进程的线程(或者理解为跨进程的程序),可以考虑使用共享内存。内存共享是现代操作系统提供的能力, Unix 系操作系统,包括 Linux 中有 POSIX 内存共享库——shmem。(如果你感兴趣可以参考网页中的内容,这里不做太深入地分析。)Linux 内存共享库的实现原理是以虚拟文件系统的形式,从内存中划分出一块区域,供两个进程共同使用。看上去是文件,实际操作是内存。
共享内存的方式,速度很快,但是程序不是很好写,因为这是一种侵入式的开发,也就是说你需要为此撰写大量的程序。比如如果修改共享内存中的值,需要调用 API。如果考虑并发控制,还要处理同步问题等。因此,只要不是高性能场景,进程间通信通常不考虑共享内存的方式。
本地消息/队列
内存共享不太好用,因此本地消息有两种常见的方法。一种是用消息队列——现代操作系统都会提供类似的能力。Unix 系可以使用 POSIX 标准的 mqueue。另一种方式,就是直接用网络请求,比如 TCP/IP 协议,也包括建立在这之上的更多的通信协议(这些我们在下文中的“远程调用”部分详细讲解)。
本质上,这些都是收/发消息的模式。进程将需要传递的数据封装成格式确定的消息,这对写程序非常有帮助。程序员可以根据消息类型,分门别类响应消息;也可以根据消息内容,触发特殊的逻辑操作。在消息体量庞大的情况下,也可以构造生产者队列和消费者队列,用并发技术进行处理。
远程调用
远程调用(Remote Procedure Call,RPC)是一种通过本地程序调用来封装远程服务请求的方法。
程序员调用 RPC 的时候,程序看上去是在调用一个本地的方法,或者执行一个本地的任务,但是后面会有一个服务程序(通常称为 stub),将这种本地调用转换成远程网络请求。 同理,服务端接到请求后,也会有一个服务端程序(stub),将请求转换为一个真实的服务端方法调用。
你可以观察上面这张图,表示客户端和服务端通信的过程,一共是 10 个步骤,分别是:
- 客户端调用函数(方法);
- stub 将函数调用封装为请求;
- 客户端 socket 发送请求,服务端 socket 接收请求;
- 服务端 stub 处理请求,将请求还原为函数调用;
- 执行服务端方法;
- 返回结果传给 stub;
- stub 将返回结果封装为返回数据;
- 服务端 socket 发送返回数据,客户端 socket 接收返回数据;
- 客户端 socket 将数据传递给客户端 stub;
- 客户端 stub 把返回数据转义成函数返回值。
RPC 调用过程有很多约定, 比如函数参数格式、返回结果格式、异常如何处理。还有很多细粒度的问题,比如处理 TCP 粘包、处理网络异常、I/O 模式选型——其中有很多和网络相关的知识比较复杂,你可以参考我将在拉勾教育上线的《计算机网络》专栏。
上面这些问题比较棘手,因此在实战中通常的做法是使用框架。比如 Thrift 框架(Facebook 开源)、Dubbo 框架(阿里开源)、grpc(Google 开源)。这些 RPC 框架通常支持多种语言,这需要一个接口定义语言支持在多个语言间定义接口(IDL)。
RPC 调用的方式比较适合微服务环境的开发,当然 RPC 通常需要专业团队的框架以支持高并发、低延迟的场景。不过,硬要说 RPC 有额外转化数据的开销(主要是序列化),也没错,但这不是 RPC 的主要缺点。RPC 真正的缺陷是增加了系统间的耦合。当系统主动调用另一个系统的方法时,就意味着在增加两个系统的耦合。长期增加 RPC 调用,会让系统的边界逐渐腐化。这才是使用 RPC 时真正需要注意的东西。
消息队列
既然 RPC 会增加耦合,那么怎么办呢——可以考虑事件。事件不会增加耦合,如果一个系统订阅了另一个系统的事件,那么将来无论谁提供同类型的事件,自己都可以正常工作。系统依赖的不是另一个系统,而是某种事件。如果哪天另一个系统不存在了,只要事件由其他系统提供,系统仍然可以正常运转。
实现事件可以用消息队列。具体这块架构技术我不再展开,你如果感兴趣可以课下去研究 Domain Drive Design 这个方向的知识。
另一个用到消息队列的场景是纯粹大量数据的传输。 比如日志的传输,中间可能还会有收集、清洗、筛选、监控的节点,这就构成了一个庞大的分布式计算网络。
总的来说,消息队列是一种耦合度更低,更加灵活的模型。但是对系统设计者的要求也会更高,对系统本身的架构也会有一定的要求。具体场景的消息队列有 Kafka,主打处理 feed;RabbitMQ、ActiveMQ、 RocketMQ 等主打分布式应用间通信(应用解耦)。
总结
那么通过这节课的学习,你现在可以尝试来回答本节关联的面试题目:进程间通信都有哪些方法?
**【解析】 **你可以从单机和分布式角度给面试管阐述。
如果考虑单机模型,有管道、内存共享、消息队列。这三个模型中,内存共享程序最难写,但是性能最高。管道程序最好写,有标准接口。消息队列程序也比较好写,比如用发布/订阅模式实现具体的程序。
如果考虑分布式模型,就有远程调用、消息队列和网络请求。直接发送网络请求程序不好写,不如直接用实现好的 RPC 调用框架。RPC 框架会增加系统的耦合,可以考虑 消息队列,以及发布订阅事件的模式,这样可以减少系统间的耦合。
思考题
最后我再给你出一道需要查资料的思考题:还有哪些我没有讲到的进程间通信方法?
23 | 分析服务的特性:我的服务应该开多少个进程、多少个线程?
在平时工作中,你应该经常会遇到自己设计的服务即将上线,这就需要从整体评估各项指标,比如应该开多少个容器、需要多少 CPU 呢?另一方面,应该开多少个线程、多少个进程呢?——如果结合服务特性、目标并发量、目标吞吐量、用户可以承受的延迟等分析,又应该如何调整各种参数?
资源分配多了,CPU、内存等资源会产生资源闲置浪费。资源给少了,则服务不能正常工作,甚至雪崩。因此这里就产生了一个性价比问题——这一讲,就以“我的服务应该开多少个进程、多少个线程”为引,我们一起讨论如何更好地利用系统的资源。
计算密集型和 I/O 密集型
通常我们会遇到两种任务,一种是计算、一种是 I/O。
计算,就是利用 CPU 处理算数运算。比如深度神经网络(Deep Neural Networks),需要大量的计算来计算神经元的激活和传播。再比如,根据营销规则计算订单价格,虽然每一个订单只需要少量的计算,但是在并发高的时候,所有订单累计加起来就需要大量计算。如果一个应用的主要开销在计算上,我们称为计算密集型。
再看看 I/O 密集型,I/O 本质是对设备的读写。读取键盘的输入是 I/O,读取磁盘(SSD)的数据是 I/O。通常 CPU 在设备 I/O 的过程中会去做其他的事情,当 I/O 完成,设备会给 CPU 一个中断,告诉 CPU 响应 I/O 的结果。比如说从硬盘读取数据完成了,那么硬盘给 CPU 一个中断。如果操作对 I/O 的依赖强,比如频繁的文件操作(写日志、读写数据库等),可以看作I/O 密集型。
你可能会有一个疑问,读取硬盘数据到内存中这个过程,CPU 需不需要一个个字节处理?
通常是不用的,因为在今天的计算机中有一个叫作 Direct Memory Access(DMA)的模块,这个模块允许硬件设备直接通过 DMA 写内存,而不需要通过 CPU(占用 CPU 资源)。
很多情况下我们没法使用 DMA,比如说你想把一个数组拷贝到另一个数组内,执行的 memcpy 函数内部实现就是一个个 byte 拷贝,这种情况也是一种CPU 密集的操作。
可见,区分是计算密集型还是 I/O 密集型这件事比较复杂。按说查询数据库是一件 I/O 密集型的事情,但是如果存储设备足够好,比如用了最好的固态硬盘阵列,I/O 速度很快,反而瓶颈会在计算上(对缓存的搜索耗时成为主要部分)。因此,需要一些可衡量指标,来帮助我们确认应用的特性。
衡量 CPU 的工作情况的指标
我们先来看一下 CPU 关联的指标。如下图所示:CPU 有 2 种状态,忙碌和空闲。此外,CPU 的时间还有一种被偷走的情况。
忙碌就是 CPU 在执行有意义的程序,空闲就是 CPU 在执行让 CPU 空闲(空转)的指令。通常让 CPU 空转的指令能耗更低,因此让 CPU 闲置时,我们会使用特别的指令,最终效果和让 CPU 计算是一样的,都可以把 CPU 执行时间填满,只不过这类型指令能耗低一些而已。除了忙碌和空闲,CPU 的时间有可能被宿主偷走,比如一台宿主机器上有 10 个虚拟机,宿主可以偷走给任何一台虚拟机的时间。
如上图所示,CPU 忙碌有 3 种情况:
- 执行用户空间程序;
- 执行内核空间程序;
- 执行中断程序。
CPU 空闲有 2 种情况。
- CPU 无事可做,执行空闲指令(注意,不能让 CPU 停止工作,而是执行能耗更低的空闲指令)。
- CPU 因为需要等待 I/O 而空闲,比如在等待磁盘回传数据的中断,这种我们称为 I/O Wait。
下图是我们执行 top 指令看到目前机器状态的快照,接下来我们仔细研究一下这些指标的含义:
如上图所示,你可以细看下 %CPU(s) 开头那一行(第 3 行):
- us(user),即用户空间 CPU 使用占比。
- sy(system),即内核空间 CPU 使用占比。
- ni(nice),nice 是 Unix 系操作系统控制进程优先级用的。-19 是最高优先级, 20 是最低优先级。这里代表了调整过优先级的进程的 CPU 使用占比。
- id(idle),闲置的 CPU 占比。
- wa(I/O Wait),I/O Wait 闲置的 CPU 占比。
- hi(hardware interrupts),响应硬件中断 CPU 使用占比。
- si(software interrrupts),响应软件中断 CPU 使用占比。
- st(stolen),如果当前机器是虚拟机,这个指标代表了宿主偷走的 CPU 时间占比。对于一个宿主多个虚拟机的情况,宿主可以偷走任何一台虚拟机的 CPU 时间。
上面我们用 top 看的是一个平均情况,如果想看所有 CPU 的情况可以 top 之后,按一下1键。结果如下图所示:
当然,对性能而言,CPU 数量也是一个重要因素。可以看到我这台虚拟机一共有 16 个核心。
负载指标
上面的指标非常多,在排查问题的时候,需要综合分析。其实还有一些更简单的指标,比如上图中 top 指令返回有一项叫作load average——平均负载。 负载可以理解成某个时刻正在排队执行的进程数除以 CPU 核数。平均负载需要多次采样求平均值。 如果这个值大于1,说明 CPU 相当忙碌。因此如果你想发现问题,可以先检查这个指标。
具体来说,如果平均负载很高,CPU 的 I/O Wait 也很高, 那么就说明 CPU 因为需要大量等待 I/O 无法处理完成工作。产生这个现象的原因可能是:线上服务器打日志太频繁,读写数据库、网络太频繁。你可以考虑进行批量读写优化。
到这里,你可能会有一个疑问:为什么批量更快呢?我们知道一次写入 1M 的数据,就比写一百万次一个 byte 快。因为前者可以充分利用 CPU 的缓存、复用发起写操作程序的连接和缓冲区等。
如果想看更多load average,你可以看/proc/loadavg文件。
通信量(Traffic)
如果怀疑瓶颈发生在网络层面,或者想知道当前网络状况。可以查看/proc/net/dev,下图是在我的虚拟机上的查询结果:
我们来一起看一下上图中的指标。表头分成了 3 段:
- Interface(网络接口),可以理解成网卡
- Receive:接收的数据
- Transmit:发送的数据
然后再来看具体的一些参数:
- byte 是字节数
- package 是封包数
- erros 是错误数
- drop 是主动丢弃的封包,比如说时间窗口超时了
- fifo: FIFO 缓冲区错误(如果想了解更多可以关注我即将推出的《计算机网络》专栏)
- frame: 底层网络发生了帧错误,代表数据出错了
如果你怀疑自己系统的网络有故障,可以查一下通信量部分的参数,相信会有一定的收获。
衡量磁盘工作情况
有时候 I/O 太频繁导致磁盘负载成为瓶颈,这个时候可以用iotop指令看一下磁盘的情况,如图所示:
上图中是磁盘当前的读写速度以及排行较靠前的进程情况。
另外,如果磁盘空间不足,可以用df指令:
其实 df 是按照挂载的文件系统计算空间。图中每一个条目都是一个文件系统。有的文件系统直接挂在了一个磁盘上,比如图中的/dev/sda5挂在了/上,因此这样可以看到各个磁盘的使用情况。
如果想知道更细粒度的磁盘 I/O 情况,可以查看/proc/diskstats文件。 这里有 20 多个指标我就不细讲了,如果你将来怀疑自己系统的 I/O 有问题,可以查看这个文件,并阅读相关手册。
监控平台
Linux 中有很多指令可以查看服务器当前的状态,有 CPU、I/O、通信、Nginx 等维度。如果去记忆每个指令自己搭建监控平台,会非常复杂。这里你可以用市面上别人写好的开源系统帮助你收集这些资料。 比如 Taobao System Activity Report(tsar)就是一款非常好用的工具。它集成了大量诸如上面我们使用的工具,并且帮助你定时收集服务器情况,还能记录成日志。你可以用 logstash 等工具,及时将日志收集到监控、分析服务中,比如用 ELK 技术栈。
决定进程/线程数量
最后我们讲讲如何决定线程、进程数量。 上面观察指标是我们必须做的一件事情,通过观察上面的指标,可以对我们开发的应用有一个基本的认识。
下面请你思考一个问题:如果线程或进程数量 = CPU 核数,是不是一个好的选择?
有的应用不提供线程,比如 PHP 和 Node.js。
Node.js 内部有一个事件循环模型,这个模型可以理解成协程(Coroutine),相当于大量的协程复用一个进程,可以达到比线程池更高的效率(减少了线程切换)。PHP 模型相对则差得多。Java 是一个多线程的模型,线程和内核线程对应比 1:1;Go 有轻量级线程,多个轻量级线程复用一个内核级线程。
以 Node.js 为例,如果现在是 8 个核心,那么开 8 个 Node 进程,是不是就是最有效利用 CPU 的方案呢? 乍一看——8 个核、8 个进程,每个进程都可以使用 1 个核,CPU 利用率很高——其实不然。 你不要忘记,CPU 中会有一部分闲置时间是 I/O Wait,这个时候 CPU 什么也不做,主要时间用于等待 I/O。
假设我们应用执行的期间只用 50% CPU 的执行时间,其他 50% 是 I/O Wait。那么 1 个 CPU 同时就可以执行两个进程/线程。
我们考虑一个更一般的模型,如果你的应用平均 I/O 时间占比是 P,假设现在内存中有 n 个这样的线程,那么 CPU 的利用率是多少呢?
假设我们观察到一个应用 (进程),I/O 时间占比是 P,那么可以认为这个进程等待 I/O 的概率是 P。那么如果有 n 个这样的线程,n 个线程都在等待 I/O 的概率是Pn。而满负荷下,CPU 的利用率就是 CPU 不能空转——也就是不能所有进程都在等待 I/O。因此 CPU 利用率 = 1 -Pn。
理论上,如果 P = 50%,两个这样的进程可以达到满负荷。 但是从实际出发,何时运行线程是一个分时的调度行为,实际的 CPU 利用率还要看开了多少个这样的线程,如果是 2 个,那么还是会有一部分闲置资源。
因此在实际工作中,开的线程、进程数往往是超过 CPU 核数的。你可能会问,具体是多少最好呢?——这里没有具体的算法,要以实际情况为准。比如:你先以 CPU 核数 3 倍的线程数开始,然后进行模拟真实线上压力的测试,分析压测的结果。
如果发现整个过程中,瓶颈在 CPU,比如load average很高,那么可以考虑优化 I/O Wait,让 CPU 有更多时间计算。
当然,如果 I/O Wait 优化不动了,算法都最优了,就是磁盘读写速度很高达到瓶颈,可以考虑延迟写、延迟读等等技术,或者优化减少读写。
如果发现 idle 很高,CPU 大面积闲置,就可以考虑增加线程。
总结
那么通过这节课的学习,你现在可以尝试来回答本节关联的面试题目:我的服务应该开多少个进程、多少个线程?
【解析】 计算密集型一般接近核数,如果负载很高,建议留一个内核专门给操作系统。I/O 密集型一般都会开大于核数的线程和进程。 但是无论哪种模型,都需要实地压测,以压测结果分析为准;另一方面,还需要做好监控,观察服务在不同并发场景的情况,避免资源耗尽。
然后具体语言的特性也要考虑,Node.js 每个进程内部实现了大量类似协程的执行单元,因此 Node.js 即便在 I/O 密集型场景下也可以考虑长期使用核数 -1 的进程模型。而 Java 是多线程模型,线程池通常要大于核数才能充分利用 CPU 资源。
所以核心就一句,眼见为实,上线前要进行压力测试。
思考题
最后我再给你出一道需要查资料的思考题:如果磁盘坏了,通常会是怎样的情况?
练习题详解(四)
今天我会带你把《模块四:进程和多线程》中涉及的课后练习题,逐一讲解,并给出每个课时练习题的解题思路和答案。
练习题详解
17 | 进程和线程:进程的开销比线程大在了哪里?
【问题】考虑下面的程序:
fork()
fork()
fork()
print("Hello World\n")
请问这个程序执行后, 输出结果 Hello World 会被打印几次?
【解析】 这道题目考察大家对 fork 能力的理解。
fork 的含义是复制一份当前进程的全部状态。第 1 个 fork 执行 1 次产生 1 个额外的进程。 第 2 个 fork,执行 2 次,产生 2 个额外的进程。第 3 个 fork 执行 4 次,产生 4 个额外的进程。所以执行 print 的进程一共是 8 个。
18 | 锁、信号量和分布式锁:如何控制同一时间只有 2 个线程运行?
【问题】如果考虑到 CPU 缓存的存在,会对上面我们讨论的算法有什么影响?
【解析】 这是一道需要大家查一些资料的题目。这里涉及一个叫作内存一致性模型的概念。具体就是说,在同一时刻,多线程之间,对内存中某个地址的数据认知是否一致(简单理解,就是多个线程读取同一个内存地址能不能读到一致的值)。
对某个地址,和任意时刻,如果所有线程读取值,得到的结果都一样,是一种强一致性,我们称为线性一致性(Sequencial Consistency),含义就是所有线程对这个地址中数据的历史达成了一致,历史没有分差,有一条大家都能认可的主线,因此称为线性一致。 如果只有部分时刻所有线程的理解是一致的,那么称为弱一致性(Weak Consistency)。
那么为什么会有内存不一致问题呢? 这就是因为 CPU 缓存的存在。
如上图所示:假设一开始 A=0,B=0。两个不在同一个 CPU 核心执行的 Thread1、Thread2 分别执行上图中的简单程序。在 CPU 架构中,Thread1,Thread2 在不同核心,因此它们的 L1\L2 缓存不共用, L3 缓存共享。
在这种情况下,如果 Thread1 发生了写入 A=1,这个时候会按照 L1,L2,L3 的顺序写入缓存,最后写内存。而对于 Thread2 而言,在 Thread1 刚刚发生写入时,如果去读取 A 的值,就需要去内存中读,这个时候 A=1 可能还没有写入内存。但是对于线程 1 来说,它只要发生了写入 A=1,就可以从 L1 缓存中读取到这次写入。所以在线程 1 写入 A=1 的瞬间,线程 1 线程 2 无法对 A 的值达成一致,造成内存不一致。这个结果会导致 print 出来的 A 和 B 结果不确定,可能是 0 也可能是 1,取决于具体线程执行的时机。
考虑一个锁变量,和 cas 上锁操作,代码如下:
int lock = 0
void lock() {
while(!cas(&lock, 0, 1)){
// CPU降低功耗的指令
}
}
上述程序构成了一个简单的自旋锁(spin-lock)。如果考虑到内存一致性模型,线程 1 通过 cas 操作将 lock 从 0 置 1。这个操作会先发生在线程所在 CPU 的 L1 缓存中。cas 函数通过底层 CPU 指令保证了原子性,cas 执行完成之前,线程 2 的 cas 无法执行。当线程 1 开始临界区的时候,假设这个时候线程 2 开始执行,尝试获取锁。如果这个过程切换非常短暂,线程 2 可能会从内存中读取 lock 的值(而这个值可能还没有写入,还在 Thread 所在 CPU 的 L1、L2 中),线程 2 可能也会通过 cas 拿到锁。两个线程同时进入了临界区,造成竞争条件。
这个时候,就需要强制让线程 2的读取指令一定等到写入指令彻底完成之后再执行,避免使用 CPU 缓存。Java 提供了一个 volatile 关键字实现这个能力,只需要这样:
volatile int lock = 0;
就可以避免从读取不到对lock的写入问题。
19 | 乐观锁、区块链:除了上锁还有哪些并发控制方法?
【问题】举例各 2 个悲观锁和乐观锁的应用场景?
【解析】 乐观锁、悲观锁都能够实现避免竞争条件,实现数据的一致性。 比如减少库存的操作,无论是乐观锁、还是悲观锁都能保证最后库存算对(一致性)。 但是对于并发减库存的各方来说,体验是不一样的。悲观锁要求各方排队等待。 乐观锁,希望各方先各自进步。所以进步耗时较长,合并耗时较短的应用,比较适合乐观锁。 比如协同创作(写文章、视频编辑、写程序等),协同编辑(比如共同点餐、修改购物车、共同编辑商品、分布式配置管理等),非常适合乐观锁,因为这些操作需要较长的时间进步(比如写文章要思考、配置管理可能会连续修改多个配置)。乐观锁可以让多个协同方不急于合并自己的版本,可以先 focus 在进步上。
相反,悲观锁适用在进步耗时较短的场景,比如锁库存刚好是进步(一次库存计算)耗时少的场景。这种场景使用乐观锁,不但没有足够的收益,同时还会导致各个等待方(线程、客户端等)频繁读取库存——而且还会面临缓存一致性的问题(类比内存一致性问题)。这种进步耗时短,频繁同步的场景,可以考虑用悲观锁。类似的还有银行的交易,订单修改状态等。
再比如抢购逻辑,就不适合乐观锁。抢购逻辑使用乐观锁会导致大量线程频繁读取缓存确认版本(类似 cas 自旋锁),这种情况下,不如用队列(悲观锁实现)。
综上:有一个误区就是悲观锁对冲突持有悲观态度,所以性能低;乐观锁,对冲突持有乐观态度,鼓励线程进步,因此性能高。 这个不能一概而论,要看具体的场景。最后补充一下,悲观锁性能最高的一种实现就是阻塞队列,你可以参考 Java 的 7 种继承于 BlockingQueue 阻塞队列类型。
20 | 线程的调度:线程调度都有哪些方法?
【问题】用你最熟悉的语言模拟分级队列调度的模型?
【解析】 我用 Java 实现了一个简单的 yield 框架。 没有到协程的级别,但是也初具规模。考虑到协程实现需要更复杂一些,所以我用 PriorityQueue 来放高优任务;然后我用 LinkedList 来作为放普通任务的队列。Java 语言中的add和remove方法刚好构成了入队和出队操作。
private PriorityQueue<Task> urgents;
private ArrayList<LinkedList<Task>> multLevelQueues;
我实现了一个submit方法用于提交任务,代码如下:
var scheduler = new MultiLevelScheduler();
scheduler.submit((IYieldFunction yield) -> {
System.out.println("Urgent");
}, 10);
普通任务我的程序中默认是 3 级队列。提交的任务优先级小于 100 的会放入紧急队列。每个任务就是一个简单的函数。我构造了一个 next() 方法用于决定下一个执行的任务,代码如下:
private Task next(){
if(this.urgents.size() > 0) {
return this.urgents.remove();
} else {
for(int i = 0; i < this.level; i++) {
var queue = this.multLevelQueues.get(i);
if(queue.size() > 0) {
return queue.remove();
}
}
}
return null;
}
先判断高优队列,然后再逐级看普通队列。
执行的程序就是递归调用 runNext() 方法,代码如下:
private void runNext(){
var nextTask = this.next();
if(nextTask == null) {return;}
if(nextTask.isYield()) {
return;
}
nextTask.run(() -> {
// yiled 内容……省略
});
this.runNext();
}
上面程序中,如果当前任务在yield状态,那么终止当前程序。yield相当于函数调用,从yield函数调用中返回相当于继续执行。yield相当于任务主动让出执行时间。使用yield模式不需要线程切换,可以最大程度利用单核效率。
最后是yield的实现,nextTask.run 后面的匿名函数就是yield方法,它像一个调度程序一样,先简单保存当前的状态,然后将当前任务放到对应的位置(重新入队,或者移动到下一级队列)。如果当前任务是高优任务,yield程序会直接返回,因为高优任务没有必要yield,代码如下:
nextTask.run(() -> {
if(nextTask.level == -1) {
// high-priority forbid yield
return;
}
nextTask.setYield(true);
if(nextTask.level < this.level - 1) {
multLevelQueues.get(nextTask.level + 1).add(nextTask);
nextTask.setLevel(nextTask.level + 1);
} else {
multLevelQueues.get(nextTask.level).add(nextTask);
}
this.runNext();
});
下面是完成的程序,你可以在自己的 IDE 中尝试。
package test;
import java.util.ArrayList;
import java.util.LinkedList;
import java.util.PriorityQueue;
import java.util.concurrent.locks.LockSupport;
import java.util.function.Function;
public class MultiLevelScheduler {
/**
* High-priority
*/
private PriorityQueue<Task> urgents;
private ArrayList<LinkedList<Task>> multLevelQueues;
/**
* Levels of Scheduler
*/
private int level = 3;
public MultiLevelScheduler(){
this.init();
}
public MultiLevelScheduler(int level) {
this.level = level;
this.init();
}
private void init(){
urgents = new PriorityQueue<>();
multLevelQueues = new ArrayList<>();
for(int i = 0; i < this.level; i++) {
multLevelQueues.add(new LinkedList<Task>());
}
}
@FunctionalInterface
interface IYieldFunction {
void yield();
}
@FunctionalInterface
interface ITask{
void run(IYieldFunction yieldFunction);
}
class Task implements Comparable<Task>{
int level = -1;
ITask task;
int priority;
private boolean yield;
public Task(ITask task, int priority) {
this.task = task;
this.priority = priority;
}
@Override
public int compareTo(Task o) {
return this.priority - o.priority;
}
public int getLevel() {
return level;
}
public void setLevel(int level) {
this.level = level;
}
public void run(IYieldFunction f) {
this.task.run(f);
}
public void setYield(boolean yield) {
this.yield = yield;
}
public boolean isYield() {
return yield;
}
}
public void submit(ITask itask, int priority) {
var task = new Task(itask, priority);
if(priority >= 100) {
this.multLevelQueues.get(0).add(task);
task.setLevel(0);
} else {
this.urgents.add(task);
}
}
public void submit(ITask t) {
this.submit(t, 100);
}
private Task next(){
if(this.urgents.size() > 0) {
return this.urgents.remove();
} else {
for(int i = 0; i < this.level; i++) {
var queue = this.multLevelQueues.get(i);
if(queue.size() > 0) {
return queue.remove();
}
}
}
return null;
}
private void runNext(){
var nextTask = this.next();
if(nextTask == null) {return;}
if(nextTask.isYield()) {
return;
}
nextTask.run(() -> {
if(nextTask.level == -1) {
// high-priority forbid yield
return;
}
nextTask.setYield(true);
if(nextTask.level < this.level - 1) {
multLevelQueues.get(nextTask.level + 1).add(nextTask);
nextTask.setLevel(nextTask.level + 1);
} else {
multLevelQueues.get(nextTask.level).add(nextTask);
}
this.runNext();
});
this.runNext();
}
public void start() throws InterruptedException {
this.runNext();
}
public static void main(String[] argv) throws InterruptedException {
var scheduler = new MultiLevelScheduler();
scheduler.submit((IYieldFunction yield) -> {
System.out.println("Urgent");
}, 10);
scheduler.submit((IYieldFunction yield) -> {
System.out.println("Most Urgent");
}, 0);
scheduler.submit((IYieldFunction yield) -> {
System.out.println("A1");
yield.yield();
System.out.println("A2");
});
scheduler.submit((IYieldFunction yield) -> {
System.out.println("B");
});
scheduler.submit((IYieldFunction f) -> {
System.out.println("C");
});
scheduler.start();
}
}
最后是执行结果如下:
Most Urgent
Urgent
A1
B
C
A2
Process finished with exit code 0
我们看到结果中任务 1 发生了yield在打印 A2 之前交出了控制权导致任务 B,C 先被执行。如果你想在 yield 出上增加定时的功能,可以考虑 yield 发生后将任务移出队列,并在定时结束后重新插入回来。
21 | 哲学家就餐问题:什么情况下会触发饥饿和死锁?
【问题】如果哲学家就餐问题拿起叉子、放下叉子,只需要微小的时间,主要时间开销集中在 think 需要计算资源(CPU 资源)上,那么使用什么模型比较合适?
【解析】 哲学家就餐问题最多允许两组哲学家就餐,如果开销集中在计算上,那么只要同时有两组哲学家可以进入临界区即可。不考虑 I/O 成本,问题就很简化了,也失去了讨论的意义。比如简单要求哲学家们同时拿起左右手的叉子的做法就可以达到 2 组哲学家同时进餐。
22 | 进程间通信: 进程间通信都有哪些方法?
【问题】还有哪些我没有讲到的进程间通信方法?
【解析】 我看到有同学提到了 Android 系统的 OpenBinder 机制——允许不同进程的线程间调用(类似 RPC)。底层是 Linux 的文件系统和内核对 Binder 的驱动。
我还有没讲到的进程间的通信方法,比如说:
使用数据库
使用普通文件
还有一种是信号,一个进程可以通过操作系统提供的信号。举个例子,假如想给某个进程(pid=9999)发送一个 USR1 信号,那么可以用:
kill -s USR1 9999
进程 9999 可以通过写程序接收这个信号。 上述过程除了用kill指令外,还可以调用操作系统 API 完成。
23 | 分析服务的特性:我的服务应该开多少个进程、多少个线程?
【问题】如果磁盘坏了,通常会是怎样的情况?
【解析】 磁盘如果彻底坏了,服务器可能执行程序报错,无法写入,甚至死机。这些情况非常容易发现。而比较不容易观察的是坏道,坏道是磁盘上某个小区域数据无法读写了。有可能是硬损坏,就是物理损坏了,相当于永久损坏。也有可能是软损坏,比如数据写乱了。导致磁盘坏道的原因很多,比如电压不稳、灰尘、磁盘质量等问题。
磁盘损坏之前,往往还伴随性能整体的下降;坏道也会导致读写错误。所以在出现问题前,通常是可以在监控系统中观察到服务器性能指标变化的。比如 CPU 使用量上升,I/O Wait 增多,相同并发量下响应速度变慢等。
如果在工作中你怀疑磁盘坏了,可以用下面这个命令检查坏道:
sudo badblocks -v /dev/sda5
我的机器上是 /dev/sda5,你可以用df命令查看自己的文件系统。
总结
这个模块我们完整的学习了进程和多线程,讨论了多线程中最底层,最重要的若干问题,比如原子操作、锁、调度等。如果你还想深入学习,可以在课下去学习这几块知识。
- 一个是同步队列,这是实战中非常重要的一类并发数据结构,能够帮助你解决生产者消费者问题。
- 另一个是无锁设计,目的是提高程序的并发能力,尽可能地让更多的线程获得进步。
- 最后一块就是分布式领域,当你熟悉了操作系统知识后,分布式领域的知识能够给到你更多的场景和启发。