进程的延伸——线程(上)
POSIX线程
为实现可移植的线程程序,IEEE在IEEE标准1003.1c中定义了线程的标准。它定义的线程包叫作
pthread。大部分UNIX系统都支持该标准。这个标准定义了超过60个函数调用。

所有pthread线程都含有一个标识符、一组寄存器(包括程序计数器)和一组存储在结构中的属性。这些属性包括堆栈大小、调度参数以及其他线程需要的项目。
前面四个线程调用我在上一篇已经提过了。我再提一下最后两个线程调用。
这两个线程调用是处理属性的。pthread_attr_init建立关联一个线程的属性结构并初始化成默认值。这些值(例如优先级)可以通过修改属性结构中的域值来改变。pthread_attr_destroy删除一个线程的属性结构,释放它占用的内存。它不会影响调用它的线程。这些线程会继续存在。
三种实现线程包的主要方式
1)在用户空间中实现线程
知识点拨:这种方法是把整个线程包放在用户空间中,内核对线程包一无所知。从内核的角度考虑,就是按正常的方式管理,即单线程进程。
优点:
1)用户级线程包可以在不支持线程的操作系统上实现。过去所有的操作系统都属于这个范围,即使现在也有一些操作系统还是不支持线程。
1.用函数库实现线程
后续自己手搓操作系统时可以尝试实现一下。
2.线程在一个运行时系统的上层运行,该运行时系统是一个管理线程的过程的集合。就是前面介绍的那些调用过程:pthread_create、pthread_exit、pthread_join...。一般会有更多的过程。
3.在用户空间中管理线程时,每个进程需要有其专用的线程表,用来跟踪该进程中的线程。它仅记录各个线程的属性,包括每个线程的程序计数器、堆栈指针、寄存器和状态等。该线程表由运行时系统管理。当一个线程转换到就绪状态或阻塞状态时,在该线程表中存放重新启动该线程所需的信息,与内核在进程表中存放进程的信息完全一样。
4.当某个线程做了一些会引起在本地阻塞的事情之后,例如等待进程中另一个线程完成某项工作,
它调用一个运行时系统的过程(就像进程层面的调度程序一样),这个过程检查该线程是否必须进入阻塞状态。如果是,它在线程表中保存该线程的寄存器(即它本身的),查看表中可运行的就绪线程,并把新线程的保存值重新装入机器的寄存器中。只要堆栈指针和程序计数器一被切换,新的线程就就又自动投入运行。
优点:
2)进行类似于这样的线程切换至少比陷入内核要快一个数量级(或许更多),这是使用用户级线程包的极大的优点。
3)允许每个进程有自己定制的调度算法。
4)用户级线程还具有较好的可扩展性。因为内核级线程(在内核中实现线程)需要一些固定表格空间和堆栈空间,如果内核线程的数量非常大,就会出现问题。
问题:
1)如何实现阻塞系统调用。
让某个线程实际进行阻塞系统调用会停止该进程中的所有线程,我们的目标是:允许每个线程使用阻塞调用,但是还要避免被阻塞的线程影响其他的线程。
思路一:系统调用全部改成非阻塞的,但这需要修改操作系统,不太现实。
思路二:如果某个调用会阻塞,就提前通知。
在某些UNIX版本中,有一个系统调用select可以允许调用者通知预期的read是否会阻塞。若有这个调用,那么库过程read就可以被新的操作替代,首先进行select调用,然后只有在安全的情形下(即不会阻塞)才进行read调用。如果read调用会被阻塞,有关的调用就不进行,代之以运行另一个线程。到了下次有关的运行系统取得控制权之后,就可以再次检查看看现在进行read调用是否安全。这个处理方法需要重写部分系统调用库,所以效率不高也不优雅,不过没有其他的可选方案了。
在系统调用周围从事检查的这类代码称为包装器(jacket或wrapper)。
与阻塞系统调用问题类似的是缺页中断问题,这里不做赘述。
2)如果一个线程开始运行,那么在该进程中的其他线程就不能运行。
在一个单独的进程内部,没有时钟中断(这是进程调度层面的),所以不可能用轮转调度的方式调度线程。
思路一:让运行时系统请求每秒一次的时钟信号(中断),但是这样对程序也是生硬和无序的。不可能总是高频率地发生周期性的时钟中断,即使可能,总的开销也很大。而且,线程可能也需要时钟中断,这就会扰乱运行时系统使用的时钟。
程序员通常在经常发生线程阻塞的应用中才希望使用多个线程。比如多线程Web服务器。而那些基本上是CPU密集型且极少有阻塞的应用程序并不会使用多线程。这是针对用户级线程的最大负面争论意见。
2)在内核中实现线程(需要操作系统支持线程的概念)
1.在内核中支持和管理线程不需要运行时系统。每个进程中没有线程表,在内核中有专门记录系统中所有线程的线程表。
2.当某个线程希望创建一个新线程或撤销一个已有线程时,它进行一个系统调用,这个系统调用通过对线程表的更新完成线程创建或撤销工作。
3.内核中线程表保存的信息和用户级是一样的。这些信息是传统内核所维护的每个单线程进程信息(即进程状态)的子集。另外,内核还维护了传统的进程表,以便跟踪进程的状态。
4.当一个线程阻塞时,内核根据其选择,可以运行同一个进程中的另一个线程(若有一个就绪线程)或者
运行另一个进程中的线程。而在用户级线程中,运行时系统始终运行自己进程中的线程,直到内核剥夺它的CPU(或者没有可运行的线程存在了)为止。
5.在内核中创建或撤销线程的代价比较大,某些系统采取回收线程的方式:当某个线程被撤销时,就把它标志为不可运行的,但是其内核数据结构没有受到影响。稍后,在必须创建一个新线程时,就重新启动某个旧线程,从而节省了一些开销。在用户级线程中线程回收也是可能的,但是由于其线程管理的代价很小,所以没有必要进行这项工作。
内核线程能够解决的问题:
内核线程不需要任何新的、非阻塞系统调用。如果其某个进程中的线程引起了页面故障,内核
可以很方便地检查该进程是否有任何其他可运行的线程,如果有,在等待所需要的页面从磁盘读入时,
就选择一个可运行的线程运行。
这样做的主要缺点是系统调用的代价比较大,所以如果线程的操作(创建、终止等)比较多,就会带来很大的开销。
依旧不能解决的问题(举例):
1)当一个多线程进程创建新的进程时,会发生什么?
新进程是拥有与原进程相同数量的线程,还是只有一个线程?在很多情况下,最好的选择取决于进程计划下一步做什么。如果它要调用exec来启动一个新的程序,或许一个线程是正确的选择;但是如果它继续执行,则最好复制所有的线程。
2)另一个话题是信号。信号是发给进程而不是线程的,至少在经典模型中是这样的。当一个信号到达时,应该由哪一个线程处理它?线程可以"注册"它们感兴趣的某些信号,因此当一个信号到达的时候,可把它交给需要它的线程。但是如果两个或更更多的线程注册了相同的信号,会发生什么?
3)混合实现
将上面两种线程实现的优点结合起来的一种方法是:使用内核级线程,然后将用户级线程与某些或者全部内核线程多路复用起来。
采用这种方法,内核只识别内核级线程,并对其进行调度。其中一些内核级线程会被多个用户级线程多路复用。
如同在没有多线程能力操作系统中某个进程中的用户级线程一样,可以创建、撤销和调度这些用户级线程。在这种模型中,每个内核级线程有一个可以轮流使用的用户级线程集合。