Q: 什么是进程?

A:

进程其实是一个比较抽象的概念,它是用来描述多道程序设计系统中的一个工作单元。单纯的给进程下一个定义是没有任何意义的。比如现在所谓的标准答案:进程是操作系统中运行的程序。对于进程,我们更多的要理解它是一个「复合体」,它是一系列活动的组合。它是一个逻辑上的概念,并不是一个现实世界中具体的事物。这一点和 k8s 中的 pod很像。所以,我更倾向于将进程理解为操作系统中的一个复杂且基本的工作单元。

Q: 子进程被创建之后和父进程是如何隔离的?

A:

通常情况下,在 Linux 系统当中,一旦子进程被创建,那么子进程和父进程就会分别占有两块独立的地址空间。相互之间是隔离的,并且可以通过一些方式来进行通信或者共享某些资源。但是,在之后操作系统发展的过程当中,对于父子进程的创建过程可能会有一些优化,而不仅仅是粗暴的将父进程地址空间中所有的东西都 copy 一份给子进程。这里也是有一个比较重要的机制:COW(写时复制机制)。

Q: Linux 中的进程和 Windows 中有哪些不同?

A:

Linux 系统中的进程是有严格的「父子关系」的,并且所有的进程会以树形的层次结构组织起来。其中祖先进程可认为是 Init,它是进程树中的根。而 Windows 中的进程,无论父子,都是靠一个叫做「句柄」的概念对一个进程进行标识的,并且这个句柄是可以传递的。所以在 Windows 中,进程间没有严格的父子关系。

Q: 什么是线程?

A:

线程是轻量级的进程。进程由操作系统来管理而线程由进程来管理。不同进程之间的地址空间是隔离的,但是不同线程之间的地址空间是共享的。一般来说,一个进程通常会有一个主线程,进程负责向内核申请线程运行所需要的资源和环境,而线程才是真正执行程序的单位。

Q: 有了进程为什么还需要线程?

A:

从程序性能的角度来说,很多程序在一个进程中都会做很多任务。这些任务可以大致的被划分为两类,一类是 I/O, 一类是计算。I/O 通常消耗的时间会比较长,对于只有主线程的进程来说,它会一直处于等待状态,内核分配给他的 CPU 时间片也会被白白的消耗。计算类的任务则会直接消耗 CPU 资源,最大限度的利用了已分配的时间片。所以,如果一个程序中同时包含这两类任务的话,计算类的任务很可能被 I/O 类的任务阻塞,最终导致整个程序的效率下降。因为线程是存在于进程的地址空间中的,如果可以在进程地址空间中创建多个线程,并且让这些线程重叠执行,分别去运行不同类型的任务,就可以在一定的 CPU 时间片内,将程序的效率尽可能的提高。通过上面的一些思考,我们甚至可以延伸出另外一个问题:多线程技术一定会对我们的程序产生积极的影响么?其实也不尽然。如果我们的程序中既包含大量的 I/O 操作,也包含大量的计算操作,那么多线程技术是可以提升我们程序的效率的。因为此时由于多个线程重叠的进行,最大限度的利用了 CPU 的时间片。如果我们的程序基本都是计算类的任务,很少有 I/O 操作,那么多线程的引入可能不会对提升程序的效率有太大的帮助。因为即使线程间的切换消耗再小,还是有 CPU 时间片上面的损耗的。同样,这个问题的思考方式还可以延伸到:多进程技术一定会对我们的程序有积极的影响么?

从资源共享的角度来说,不同进程间的地址是不同的,所以它们在共享一些资源的时候就会比较麻烦,可能需要借助第三方的东西,比如文件。然而对于同一个进程中的不同的线程来说,这种内存上的隔离是不存在的,它们可以很方便的去共享一些资源。看到这里你可能会说,在地址空间不隔离的条件下,多个线程对同一个资源可能会出现竞争的想象。对于这个问题,我们要明确两点:首先,线程间共享资源的初衷是让多个线程合作,而不是让它们竞争。其次,如果不可避免的发生了竞争,也可以通过一些互斥的机制来解决。

最后还要提及一点的就是,大多数操作系统对于多线程的实现都是在「用户态」下,且线程中维护的必要信息会较进程少很多。这就造成了线程是比进程更轻量级的。如果不可避免的发生频繁和切换操作,那么很明显线程在这种场景下会更具优势。

Q: 进程和线程之间的关系是什么?

A:

进程更倾向于从操作系统申请资源,并对这些资源进行统一的管理,提供一个良好的运行环境。线程则更注重利用已经分配好的资源运行程序。也就是说,实际上在 CPU 上调度执行的并不是进程而是线程。

Q: 如何实现线程?

A:

实现线程有两种思路:在用户态实现 or 在内核态实现。

当我们想在用户态实现「线程」的时候,就意味着「线程」或者说是「多线程」对于内核来讲应该是透明的。内核与具有单个控制线程的主进程还是按照原来的模式运行(进程模型)。所以,我们很自然的就能够想到,在用户态下需要一系列「过程」的集合来实现和线程有关的操作以及「多线程」技术。这个「过程」的集合可以被称作为是一种 Runtime 系统。

用户态 Runtime 系统的数和进程数成正比。每一个进程中都有一个 Runtime 去管理进程中的多个线程。它负责线程的创建,销毁。同时也要负责维护一张「线程表」,用于保存进程内部线程的运行状态。更重要的是,这个 Runtime 系统需要借助「线程表」进行线程间的切换,因为同一时刻只有一个线程可以获得 CPU 的时间片。其实,这样看起来,Runtime 运行的方式很像一个有限状态机。它将进程内的线程的状态保存至「线程表」中,当一个线程被调度到 CPU 上执行的时候,Runtime 就需要在线程表中读取和这个线程有关的信息;当一个线程要被调度离开 CPU 的时候,同样需要 Runtime 将此时的状态保存到线程表中,以便下一次复原运行的上下文环境。如果再类比一下进程和操作系统内核的关系就可以得知,用轻量级的进程来描述线程,真的是再合适不过了。

对于线程间的切换来说,它和进程间的切换的实现有所不同。进程间的切换,要不就是依靠中断机制,强行将进程从 CPU 上拿下来;要不就是等到该进程的 CPU 时间片被消耗完,调度系统切换新的进程上来。由于我们现在是在用户态实现线程,操作系统内核无法干预线程的相关操作。所以,我们需要在 Runtime 中实现一个过程,这个过程在调用之后可以主动的将 CPU 时间片让给其他处于就绪态的线程。这也就是 POSIX 线程标准中定义的 Pthread_yield 所要实现的功能。

在用户态实现 Runtime 的好处其实很明显:1. 之前看起来比较复杂的操作,如线程间的切换,都是在用户态下完成的,不需要内核的参与,所以肯定要比内核实现的版本效率要高 2. 由于这个 Runtime 是我们自己来实现的,所以它的可定制性是非常强的。我们甚至可以开发出自己的一套「线程调度策略」来保证我们的程序效率最大化。

在用户态实现 Runtime 的坏处其实都可以归结到一个问题上:阻塞,它既包括线程之间的阻塞也包括线程所在进程的阻塞。线程间的阻塞是指:当一个线程想要进行一些阻塞操作的时候,比如从键盘读取输入信息。如果让这个阻塞操作进行了且它所需要的条件一直没有被满足,那么该进程中其他可运行的线程在这一个 CPU 时间片上就没有机会再被运行了。这其实是不符合「多线程」技术发明的初衷的。进程的阻塞是指:当一个线程执行了一些阻塞系统调用的时候,不仅仅是其他的线程没有运行的机会了,整个进程都会因为进入阻塞态而被调离 CPU。这是一个非常严重的事情。而触发这种问题的 Case 也很常见:缺页中断(线程所需要的数据或者代码没有在内存页中找到而是在硬盘中)。此外,由于多线程间只能够通过主动调用 Pthread_yield 过程来实现切换操作,如果你的代码写的有 bug 的话,其他的线程就会处于「饥饿」或者「饿死」的状态。

内核态实现的 Runtime 系统的数量不再随着进程数的变化而变化。事实上,如果真的把线程拿到内核态来实现的话,线程和进程基本就没什么区别了。线程会和进程一样,在内核中有一个线程表,用来维护线程的运行情况。通过对比之前在用户态实现线程的缺点可以知道,如果将所有阻塞线程的调用全都以系统调用的形式来实现的话,线程间的切换就统一由内核来进行管理,它会选取一个合适的线程继续使用剩余的 CPU 时间片。这种阻塞调用既包括线程之间的阻塞也包括阻塞的系统调用。

虽然说,内核态实现 Runtime 开销比较大的问题是不可避免的。但是仍然可以做出一些优化,比如在线程的销毁操作上,如果一个线程需要被销毁,内核可以不进行真正的销毁操作,而是打上一个空闲线程的标记,并且由它统一管理。这样如果有线程创建需求的时候,有可以直接复用之前已经分配的资源。

很显然,内核态实现 Runtime 也是有很多缺点的。其中最被大家诟病的就是「开销」变大了。这个开销不仅仅是指时间上面的,还包括空间上面的。如:线程的数量一般都是要比进程多的,所以线程表的规模的增长速度会远远大于进程表。当规模大起来之后如何保证一个高效的读取和写入操作呢?毕竟引入线程和多线程相关概念的初衷是在合适的场景下能够提升程序的效率而不是拉低。

既然两者各有优劣,那么根据操作系统的一贯思想,就是最大化的将这两个方案的优点结合起来,产出一个普适性更强的方案:调度程序激活机制。它借助了用户态 Runtime 系统的优势:高效的进行线程间的切换。同时,在用户态下模拟「内核」线程的功能,防止因线程使用阻塞的系统调用而发生进程的切换。

调度程序激活机制启用后,内核会为每一个进程分配一个或多个的虚拟 CPU,用户态 Runtime 系统可以将线程分配到这些虚拟的 CPU 上。虚拟 CPU 代表这个进程可以使用的 CPU 核心数。当一个线程被同进程的另外一个线程所阻塞,它会被用户态 Runtime 系统处理,并调度新的进程运行。此时,不会发生用户态和内核态的切换,对于内核来说,这些操作都是透明的。当一个线程被进程之外的因素阻塞住时(阻塞的系统调用,缺页终端),内核会感知到这个问题,它会通知用户态 Runtime 系统,需要重新调度一个就绪的线程运行。而当阻塞的事件被完成的时候,内核也会将这个事件通知给用户态 Runtime 系统,让它自己来决定,下一步应该调度哪个线程运行。

http://o6sfmikvw.bkt.clouddn.com/E5F7EC18-D476-44B5-844B-90E7F62E7F04.png

这种内核调用用户态 Runtime 系统的机制被称作为「上行调用」。在CPU中用户空间为上层,内核为下层层,常规调用应该是上层调用下层,下层不应该调用上层,上行调用就是指内核调用用户空间的 Runtime 系统。

Q: 如何实现进程间的通信?

A:

两个进程或者线程间如果要进行通信,可能涉及到以下三个问题:

  1. 如何传递信息?
  2. 如何防止「竞争」?
  3. 如何保证进/线程间执行的顺序?

Q: 如何防止「竞争」?

A:

我们首先来看下如何在进程间通信过程中避免「竞争」的问题。「竞争」通常出现在两个进程或者线程同时要访问/修改一个共享资源的时候,除了同时读取可能还不会有问题之外,比如一读一写,两个都是写这种组合是肯定会引起资源的「竞争」的。这种「同时操作共享资源」的结果,完全取决于两个竞争者之间执行的时序。但是我们都清楚,在编程的世界中,是不能够对「时序」做任何假设的,因为它的随机性较大,很多「竞争」问题之所以难以 track,就是因为它出现的几率不固定,很难定位。

那现在问题就变成了:如何在使用共享资源的时候,同一时间只允许一个进程对其操作。这种行为有一个比较统一和抽象的称呼:「互斥」。而产生「竞争」问题的地方或者说要进行「互斥」改造的地方,我们统一把它成为「临界区」。「临界区」是一段操作共享资源的代码,如果能保证同一时间只有一个进程进入「临界区」,那么「竞争」的问题也就随之解决了。

第一种可实现互斥机制的技术是:屏蔽中断。这应该是最暴力的一种方式了,而且更多的是对于进程间「竞争」问题的解决方案。当一个进程进入临界区后,可以屏蔽所有的中断。此时, CPU 不会再切换进程,已经处在临界区的进程也不会受到影响,它可以放心的操作「共享资源」。但是这种方案的缺点也很明显:1. 进程一旦运行异常很可能从临界区中退不出来,一直不能切换其他进程 2. 在多 CPU 的情形下,除非把所有 CPU 都 disable 掉,否则还是有其他的 CPU 可以调度运行与其「竞争」的进程。

第二种是在软件层面的方案: 锁变量。进临界区之前查看是否可以获得锁,离开临界区之后释放锁。「获得」和「释放」的操作通过修改某一个变量实现。但是,这其实并没有什么卵用,在 CPU 可以对进程进行任意切换的前提下,锁变量就变成了另外一个「共享资源」。如:在 A 进程进入临界区获得锁之后想更改锁状态时发生了进程间切换,那么 B 进程此时仍然可以获得锁进入临界区,最终的结果就是,A 和 B 都认为自己拿到了这个锁,都进入了临界区。究其原因,还是因为没有保证进程之间对于共享资源操作的顺序性。

第三种是利用「忙等待」的原理:利用一个全局变量实现两个进程间的同步,保证执行顺序。如,设置一个共享变量 turn,初始值为0。A 进程将通过一个 while 循环检查turn,当其值为0的时候进入临界区,出临界区的时候将其改为1。B 进程将通过一个 while 循环检查 turn,当其值为1的时候进入临界区,出临界区的时候将其改为0。利用这种机制就实现了两个进程严格的「同步」,轮换进入临界区。但是,这种实现有一个Edege Case 没有考虑到,如果两个进程执行的速度相差过大,就会导致速度较快的进程在离开临界区之后,一直在等待速度较慢的进程先进入临界区。此时,速度较快的进程在 CPU 时间片内执行一个死循环,浪费了 CPU 资源,而且最终执行的效率看起来已经完全取决于速度较慢的进程到底有多慢。

第四种则是在「忙等待」的基础上进行了一些改进,由原来的不断访问锁变量,查看是否可以进入临界区的方式变为:当访问锁变量不能进入临界区的时候就进入睡眠状态,将自身阻塞在临界区外。直到从临界区出来的进程唤醒它。这种方式的本质是实现了一对「同步原语」:Sleep/Wake。不过它的缺陷和使用锁变量是类似的:使用同步原语之前还是需要去访问一个共享变量来决定是否执行 Sleep or Wake。如果在「访问共享变量」和「执行 Sleep 原语」之间发生了进程间的切换,那么很有可能在另外一个进程从临界区出来之后执行 Wake,但是之前被切换的进程因没有执行 Sleep,并没有收到这个信号,从而导致了 Wake 消息丢失的问题。等到下一次它重新获得 CPU 时间片的时候,会将自己Sleep,最终它将无法进入临界区。

讨论到现在为止,其实有一部分问题都已经解决了,比如:如何实现互斥,如何避免忙等待给系统带来的消耗。唯一一个还没有办法解决的其实就是对共享变量的「互斥访问」问题。而互斥访问这个东西,基本上在用户态下是不太可能做到的,因为内核才是大 Boss,他想把你调离 CPU 那你就是没机会再进行下去了。所以,借鉴上面第一种方案的思路,我们需要借助「屏蔽中断」这一特性来实现共享变量的「互斥访问」。这就必须要提及「信号量」的概念了。

Q: 什么是信号量?

A:

信号量是一个新的名字,它本质上其实就是我们之前所说的在睡眠和唤醒进程前访问的共享变量。之所以改了一个新的名字,是因为信号量附加的相关操作是通过系统调用+屏蔽中断来实现的,它可以保证将之前讨论的一些可能发生「竞争」的操作实现为一个原子操作。

信号量是一个整型的变量,它的取值范围为[0, +无穷],在当前的场景下,它的数值代表了还有多少进程处于睡眠状态中并等待被唤醒。它以系统调用的方式实现了两个操作:Up, Down。Up 操作为唤醒操作,Down 操作即为 Sleep 操作。它的工作机制大致是这样的:

对于 Down 操作来说,它在操作一个信号量之前会检查它是否大于0,如果大于0,则对信号量进行-1,然后进行剩余的操作;如果等于0,那么将进程睡眠,但是此时 Down 操作还并没有结束,因为它还没有将被它阻塞的进程顺利的送出去。检查,修改,以及后续的操作(睡眠 or 继续)三者是作为一个原子性的操作来处理的。

对于 Up 操作来说,由于信号量的取值范围是到正无穷的,所以它在对信号量进行+1操作之前是不需要检查相应的值的。信号量一旦执行了Up 操作,就说明此时可以唤醒一个睡眠的进程执行了,而睡眠的进程在被唤醒的时候会使用 Down 操作对信号量-1,这样一增一减也就平衡了。所以,增加信号量的值,唤醒一个进程,两个步骤若被实现为一个原子操作,即可称作是一个 Up 操作。

上面所说的,基本上是依靠信号量实现了「互斥」的功能,从而可以解决进程之间的「竞争」问题。实际上,由于信号量是一个整型变量,它的取值范围比较大,所以可以利用它的计数功能实现进程间的「同步」,从而保证多进程的执行顺序。所以,对于生产者和消费者模型来说,如何在保证互斥的同时又保证了两者的执行顺序,信号量的使用起到了至关重要的作用。

信号量的出现极大的丰富了我们处理「互斥」和「同步」问题的方式。若你想解决「竞争」,则可以使用一个仅有「解锁,锁住」两种语义的信号量,我们通常称他为「互斥量」。「互斥量」的作用仅限于避免「临界区」同时多有个进程或者线程进入。若你想解决「同步问题」,则可以使用一个仅有「计数」语义的信号量。「计数量」的作用是在具有依赖关系的两个进程或线程中传递「计数信号」,当「计数量」的值未达到某个进程运行条件时,该进程就会被阻塞,反之会顺利的进行。

Q: 「互斥」+ 「条件」的另一种实现方式是什么?

A:

了解到目前为止,我们大致可以归纳出操作系统在处理「互斥」和「同步」的问题上究竟依赖的是什么思想了:

  1. 互斥:通过某种实现,锁住临界区,防止临界区内同一时间被多个进线程访问
  2. 同步:通过某种实现,在进线程间建立一种「通知」机制,可以按照一定的条件「睡眠」和「唤醒」某个进线程。「通知」机制的运行需要互斥的保护

其实,实现「同步」一种比较简单的方式就是利用我们上面所说的信号量,而且这个信号量还有存储信号的功能,不会怕信号丢失。同步和互斥一般都是在一起使用的:互斥用于锁住临界区,同步用于保证执行顺序。但是在运用他们的时候,请一定要掌握好它们之间语义的差别:互斥作用于进程已经可以执行但是执行过程中受阻,同步作用于进程是否可以执行。这么说可能比较迷惑,我们来看下面一个例子(利用信号量解决生产者、消费者问题):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
m_mux // 互斥信号量
m_num_empty // 缓冲区空闲位置个数
m_num_full // 缓冲区有数据的位置个数
buf // 缓冲区

void producer(){
	 down(m_num_empty) // 缓冲区是否已满,可以生产消息
	 down(m_mux) // 是否可进入临界区
   buf.append(1) // 生产消息
   up(m_mux) // 离开临界区
	 up(m_num_full) // 增加有数据的位置个数
} 

void comsumer(){
	 down(m_num_full) // 缓冲区是否还有数据,可以消费
	 down(m_mux) // 是否可进入临界区
   buf.append(1) // 生产消息
   up(m_mux) // 离开临界区
	 up(m_num_empty) // 增加空闲的位置个数
} 

以目前的状态,上面这个例子运行起来是没有什么问题的。但是如果我们把同步和互斥的语义搞错,粗心一点将down 操作全部颠倒顺序就可能会发生死锁:当 m_mux 在减小 m_num_empty 信号前就被-1且缓冲区已经满了的时候,生产者将会被阻塞,但是此时生产者已经没有机会在释放互斥量了。而消费者会因为被互斥量阻塞的原因无法进入临界区消费,从而不能对m_num_empty信号量执行 up 操作环境生产者。这是一个标准的死锁的例子,它像我们说明了一个事实:在使用同步和互斥的时候要注意对信号量的操作顺序,否则会引起灾难。

Q: 什么是管程?什么是条件变量?

A:

管程是一种高级的同步原语,它是编程语言的组成部分。管程是「互斥」和「同步」的结合体。其中「互斥」部分仍然由「互斥量」实现,但是它由编译器进行操作。使用管程的人只需要将临界区的代码注入到一个管程中,而不用关注它是怎么实现的。而「同步」的部分,则是通过一个叫做「条件变量」的东西来实现的。条件变量是一种功能单一的信号量。它只负责实现「同步」。条件变量通常还伴随着一个过程的集合,其中它的 Wait 操作的实现是比较有意思的:Wait 操作在发现当前进程因某些条件不满足不能继续执行下去的时候,除了将当前的进程阻塞,还会将另外一个合适的进程调入到管程中来。并且在执行完 Wait 操作之前,不会被任何中断打断,从而引起竞争,因为管程帮我们做了「互斥」的保护。

其实笔者对于「条件变量」和「信号量」的区别也不是特别清楚,总觉得条件变量是信号量的一个 Special Case。因为条件变量能做的东西,信号量也一样可以做。所以,在这篇文章中,我们姑且就按照这样来理解。

Q: 通过信号量实现的「互斥」和「同步」在哪些场景下会有缺陷呢?

A:

使用信号量实现「互斥」和「同步」有一个比较大的限制:通过将共享变量放在共享内存,并且通过 TSL 等指令来保护这些变量的操作,以避免竞争。但是,当在一个由多个操作系统组成的分布式系统中,每个 CPU 都有自己的私有内存,且还可以通过网络互连,那么信号量的保护机制就将失效了。看起来,信号量并不能解决处于不同机器之间的进程的通信问题。

所以,操作系统又实现了两个新的原语:Send && Receive。 通过字面的意思就可以看出,这是一种通过消息传递的方式来实现「互斥」和「同步」的。共享变量在通信双方的机器上都需要被「互斥」机制保护,这一点在单机上实现起来应该是比较简单的。发送进程可以调用 Send 原语发送信息,而接受进程可以调用 Receive 源于来接受消息。至于「阻塞」和「唤醒」则可以通过网络在双端传输共享变量来实现:如生产者进程在启动时调用 Receive 等待消费者向他传递缓冲区的空闲情况,因为它还不知道现在的情况是否可以传递消息,所以会进入阻塞状态。消费者在启动后,先调用 Send 将缓冲区的空闲情况发送给生产者,然后再调用 Receive 等待接受消息。生产者收到消息后会查看缓冲区的情况,如果确认可以发送,则调用 Send 向消费者发送消息。 在执行了一个循环之后,整个通信流程就变成了以下几个步骤重复执行:

  1. 生产者发送完消息被阻塞
  2. 消费者接受并消费消息
  3. 消费者发送缓冲区空闲情况
  4. 消费者等待消息被阻塞
  5. 生产者接受到和缓冲区空闲情况有关的消息
  6. 生产者生产消息

Q: 如果需要「同步」机制的不是一个进程而是一个进程组,我们需要怎么办呢?

A:

信号量,管程都是针对于两个进程间通讯所遇到的问题的解决方案。但是,当同步机制作用于进程组的时候,问题似乎更加抽象了。如,现在一共有八个进程为一组。这里的同步是指,无论执行的速率,只有等到进程组内所有的进程都执行完了某一阶段逻辑,它们才能够继续向下运行。

所以,操作系统专门为进程组的同步创建了一个原语:屏障(barrier)。它也是通过一个系统调用来实现的,且最终操作的肯定还是一个进程组内多个进程共享的变量。以上面的进程组为例来描述一下屏障的工作机制:进程组中的每一个进程在执行完它自己的第一阶段逻辑之后都会调用 barrier 原语,此时,如果该进程不是进程组中最后一个执行完毕的进程,那么它会被挂起,直到最后一个进程执行完且调用了 barrier 原语之后,所有的进程才会被释放去执行第二阶段的逻辑。