[操作系统] 三种实现线程的方式

作者 Shilei Tian 日期 2017-03-21
[操作系统] 三种实现线程的方式

提起线程模型,大家可能会立马想起来操作系统课程里面的三种经典线程模型:一对一模型、多对一模型和多对多模型。这个对应关系主要体现在内核线程和用户线程数量上的关系。在《Modern Operating Systems》里面讲到了线程实现的三种方法::用户线程、内核线程和混合模式,看了这个以后感觉还是比较明朗的。所以本文就从 Andrew S. Tanebaum(《Modern Operating Systems》的作者)的角度来看一下不同实现方式的优缺点。

实现在用户空间的线程

用户级的线程,顾名思义,就是线程这个概念完全是在用户级定义的,而在内核看来,它操作的还是进程。这种实现方式的一大优点是,可以让不支持线程概念的内核支持线程。

所有的实现方式都差不多,基本上是如下图所示:

我们可以注意到,在内核空间内只有一个进程表,这说明内核完全不知道线程这个概念。在每一个进程中都有一个运行时系统(run-time system)和一个线程表,有了这两个东西就可以实现线程的切换了,线程由运行时系统来进行管理。

我们从线程调度的角度来看一下这种实现方式,注意:我们这里不讨论调度算法。由于内核全然不知道线程的存在,因此进程调度器选择一个进程来运行,这样那个进程就获得了一定的运行时间(称为 quantum)。这时,进程内的运行时系统就选择一个线程来运行。如果 quantum 用完时该线程都没有交出使用权,运行在内核中的进程调度器会结束该进程的运行,选择下一个进程进行运行;如果线程执行完毕或者由于什么情况阻塞了,该进程还有运行时间,此时运行时系统会选择另外一个可以运行的线程来执行。这个切换的方式和进程比较相似,但是由于以下两个原因:

  1. 线程共享进程的内存空间,这种切换都只是在同一个进程内进行,因此不需要内存的分配。

  2. 不需要进行上下文切换到内核模式,全部都在用户空间内执行。

因此切换速度非常快,这是用户级线程的一大优点。另外一个优点,是允许不同的应用程序(进程)根据自己的需要指定线程调度算法,这使得线程的调度更加符合各自应用程序的需求。

但是这种完全是现在用户空间的线程有一个最主要的问题,就是如何来实现那些会阻塞的系统调用?比如,我们有一个线程要等待用户输入,如果直接让线程进行系统调用 read,由于在内核看来是一个进程在进行系统调用,因此它会阻塞整个进程,这样的话,该进程内部的所有线程都就被阻塞了。我们想要引入线程的初衷,就是能够让其中的一个阻塞了,其他的不受影响,因此这种实现方式肯定不行。一种解决思路是,将所有的系统调用都换成非阻塞式的,但是这样肯定也不现实。因为这需要重写所有的系统调用,还需要重写调用它们的应用程序。另一种解决方案是引入一种被称为 jacket 或者 wrapper 的技术,这种技术会将系统调用封装成一个库,当需要调用某个系统调用时,调用者会告诉库函数,我需要的是一种什么样的系统调用。比如拿刚才这个例子来讲,线程 A 会告诉库函数想要一个非阻塞式的系统调用 read,库函数会检测调用 read 是否会阻塞,如果是,线程 A 会被阻塞,运行时系统这时会选择其他线程运行。当重新调度回线程 A 时,就再次检测是否可行,重复上面的步骤。除了系统调用外,类似的问题还有缺页中断。

还需要考虑的一个问题是,由于我们的运行时系统也是运行在用户层的,不存在所谓的时钟中断这个功能,这样会使得,一旦运行时系统选择一个线程来运行,除非这个线程自己交出控制权,否则这个线程会一直运行下去。一个可能的解决方案是,运行时系统每隔一段时间请求一次中断,来使自己拥有控制权。这样做其实也不总是可行的,因为太频繁的请求中断会严重影响效率,还有有可能线程也会进行中断,这样会干扰运行时系统的中断的。

Kernel space threads

不过话说回来,这三种实现的方式与三种线程模型的关系是什么?这个我也不太清楚。