每一个学习操作系统的人都不可避免地要接触进程,线程(内核线程|用户线程),协程,纤程等概念。针对这些概念有很多经典的问题,诸如进程和线程的区别等等。一开始我觉得辨析这些概念似乎有纠结"茴"字有几种写法之嫌,对理解操作系统无益。但当我想要写一个自己的操作系统内核时,发现这些概念含混不清是我将真实操作系统中的技巧映射到自己代码的最大障碍。
1. 最经典:进程与线程的区别
简单来说,进程是操作系统分配资源的最小单位,线程是操作系统调度的最小单位。
早期的操作系统并没有线程,进程是操作系统运行程序的最小单位,但随着多处理器的普及,进程这一抽象显得很笨重:
- 创建进程需要独立的地址空间,载入数据和代码段,初始化堆栈。即使通过
fork()
创建进程也需要大量拷贝。 - 由于地址空间的隔离,不同进程之间通信要么以页为单位维护公共映射,要么采用信号量等开销较大的进程间通信机制。
因此,当有一些轻量级的并发任务时,线程的概念呼之欲出:拥有独立的执行流但共享地址空间。
2. 内核眼中的进程和线程
我们知道了线程是操作系统调度的最小单位,从状态机的视角来看一个线程持有的状态无非就是寄存器、栈、线程局部变量。可是当操作系统在两个进程之间切换时,不可避免地要涉及进程特有的状态(比如通知父进程子进程的状态改变等)。如果仅在切换线程时维护线程的上下文,似乎进程的抽象减弱了。
实际上,以Linux为例,它在创建线程时是通过clone()
这一系统调用。clone是用来创建进程的,不过在创建线程时会加上精细的控制选项使得新创建出来的进程和之前的进程拥有相同的pid,地址空间等等。
可以看出,操作系统内核并不像上面所说的泾渭分明地区分进程与线程。更进一步地,在操作系统眼中它调度的是任务,任务有进程/线程的全部信息,而内核并不知道一次调度是在同一进程的不同线程,还是涉及了不同进程切换。
3. 内核线程与用户态线程
我们似乎从程序和内核两个角度搞清楚了进程与线程的区别。但一个新概念的引入将会打乱上面的分析:内核线程和用户线程。
这两个线程最核心的区别就是调度器是内核还是在用户态。
很多教科书上会提到,操作系统真正能调度的其实是内核线程,用户线程只有通过内核线程才能访问操作系统的资源。而内核线程与用户线程的关联又可分为多对一、一对一、多对多...
在我们已经熟知的概念里,我们通过pthreads
创建的线程直接被操作系统调度而且也可以主动陷入内核啊,那他们到底是用户线程还是内核线程?
事实上,POSIX threads
只是一组公共接口,它创建的是用户态线程,不过实现因操作系统而异。这组接口并没有规定创建的线程采用哪种方式被调度,换句话说它也可以只在用户态发生调度(接口并没有规定)。只是大部分操作系统(Linux, BSD等)的实现都采用上图中的一对一模型,让我们产生了一种用户态线程也直接受操作系统内核调度的错觉。
以上图的多对一模型为例,我们可以看看pthreads的另一种实现方式:
- 有一个专门的调度线程
master thread
,它与唯一的内核线程相连 - 其它用户态线程的执行受master thread调度,当控制权交给master thread时,它可以通过线程信号终止其它线程的执行完成调度。
即使我们知道了Linux采用一对一模型,但还存在一个问题:到底什么是内核线程?它是区别于用户线程额外的实体还是用户线程陷入内核后就摇身一变成为了内核线程?
为了解释这个概念,需要创造一些新名词:
native threads
:在一对一模型中,用户线程陷入内核后变成native threadskernel threads
:操作系统创建的用于执行额外工作的线程,比如回收资源等等
区分开这两个名词,这个问题的答案已经很清楚了。用户态线程陷入内核后会发生栈切换等操作,实际上变成了内核中的线程,它会执行系统调用、被调度等等。但它和对应的用户态线程不会同时存在。
操作系统内核有时还需要自己创建一些线程执行额外的工作,这些线程我称之为kernel threads
,它们区别于操作系统上运行的用户线程,是可以并行执行也是完全不同的实体。
从操作系统调度的角度来说,它们都是内核线程(任务
),但从功能上来说有必要区分开。
最后还有一点边角问题,为什么说操作系统只调度内核线程?
实际上即使是用户线程,每次陷入内核后都会变成native threads,而操作系统调度的也都是陷入内核后的线程所以这么说也不足为怪了。
4. 协程,纤程和其它...
在现在主流操作系统采用一对一模型的情况下,协程纤程其实更像是前面所谓的用户态线程,它们的调度真正地发生在用户态。
调度发生在用户态,好处是更加轻量级,上下文较短。坏处自然是只能主动地申请调度了。
在这里不去展开这些名词的概念了。进程进化到线程,线程进化到协程、纤程,本质上都是针对特定workload的一种删减。