线程
概述
线程是CPU使用的基本单元,它由线程ID,程序计数器,寄存器集合和栈组成。它与属于同一进程的其他线程共享代码段,数据段和其他操作系统资源,如文件和信号。一个传统重量级(heavyweight)的进程只有单个控制线程。如果进程又多个控制线程,那么它能同时做多个任务。
下图说明了单线程和多线程进程的差别。
优点
多线程编程具有如下4个优点
响应度高:如果一个交互程序采用多线程,那么即使其部分阻塞或执行较冗长的操作,该程序仍然继续执行,从而增加了对用户的响应程度。
资源共享:线程默认共享它们所需进程的内存和资源。代码和数据共享的优点是它能允许一个应用程序在同一地址空间有多个不同的活动线程。
进程:进程创建所需要的内存和资源的分配比较昂贵。由于线程能共享它们所属进程的资源,所以创建和切换线程会更为经济。
多处理器体系结构的利用:多线程的优点之一是能充分使用多处理器体系结构,以便每个进程能并行运行在不同的处理器上。不管有多少CPU,单线程进程只能运行在一个CPU上。在多CPU上使用多线程加强了并发功能。
多线程模式
有两种不同方法来提供线程支持:用户层的用户线程或内核层的内核线程。用户线程受内核支持,而无须内核管理;而内核线程由操作系统直接支持和管理。用户线程和内核线程之间必然存在一种关系。
多对一模型
多对一模型将许多用户线程映射到一个内核线程。线程管理是由线程库在用户空间进行的,因而效率比较高。但是如果一个线程执行了阻塞系统调用,那么整个进程会阻塞。而且,因为任一时刻只有一个线程能访问内核,多个线程不能并行运行在多处理器上。Green thread(Solaris所应用的线程库)就使用了这种模型,GUN可移植线程(GUN Portable Threads)
一对一模型
一对一模型将每个用户线程映射到内核线程。该模型在一个线程执行阻塞系统调用时,能允许另一个线程继续执行,所以它提供了比多对一更好的并发功能;它也允许多个线程能并行地运行在多处理器系统上。这种模型的唯一的缺点是每创建一个用户线程就需要创建一个相应的内核线程。由于创建内核线程的开销会影响应用程序的性能,所以这种模型的绝大多数实现限制了系统所支持的线程数量。Linux和Windows操作系统实现了一对一模型。
多对多模型
多对多模型多路复用了许多用户线程到同样数量或更小数量的内核线程上。虽然多对一模型允许开发人员创建任意多的用户线程,但是因为内核只能一次调度一个线程,所以并没有增加并发性。一对一模型提供了更大的并发性,但是开发人员必须小心,不要再应用程序内创建太多的线程。多对多模型没有这两者的缺点:开发人员可创建任意多的用户线程,并且相应内核线程能在多处理器系统上并发执行。而且,当一个线程执行阻塞系统调用时,内核能调度另一个线程来执行。
一个流行的多对多模型的变种仍然多路复用了许多用户线程到同样数量或更小数量的内核线程上,但也允许将一个用户线程绑定到某个内核线程上。这个变种有时称为二级模型。
线程库
线程库(thread library)为程序员提供创建和管理线程的API。主要有两种方法来实现线程库。第一种方法是在用户空间中提供一个没有内核的库,此库的所有代码和数据结构都存在于用户空间中。调用库中的一个函数只是导致了用户空间中的一个本地函数调用,而不是系统调用。第二种方法是执行一个由操作系统直接支持的内核级的库。此时,库的代码和数据结构存在于内核空间中。调用库中的一个API函数通常会导致对内核的系统调用。
目前使用的三种主要的线程库是:
(1)POSIX pthread: pthread作为POSIX标准的扩展,可以提供用户级或内核级的库。
(2)Win32: Win32线程库适用于Windows操作系统的内核级线程库。
(3)Java: Java线程API允许线程在Java程序中直接创建和管理。然而由于大多数JVM实例运行在宿主操作系统之上,Java线程API通常采用宿主系统上的线程库来实现。这意味着在Windows系统上,Java线程通常用Win32 API实现,而在UNIX和Linux系统中采用Pthread。
Linux线程实例
Linux系统中有一整套和线程相关的函数库,其中大部分以pthread_作为前缀。
#include <pthread.h>
int pthread_create(pthread_t *thread, pthread_attr_r *attr,
void *(*start_routine)(void *),void *arg)
thread用于表示新产生线程的标识符
attr用于设置新产生线程的属性,如果则设为NULL
start_routine是新线程的函数指针
arg用于指定新线程运行所需的参数
#include <pthread.h>
void pthread_exit(void *retval)
int pthread_join(pthread_t th, void **thread_return)
retval指针不能指向局部变量
pthread_join函数相当于进程管理中的wait函数
th参数用于描述线程的标识符,由create函数获取
多线程问题
系统调用fork()和exec()
如果程序中的一个线程调用fork(),那么新进程会复制所有线程,还是新进程只有单个线程?有的UNIX系统有两种形式的fork(),一种复制所有线程,另一种只复制调用了系统调用fork()的线程。
系统调用exec()的工作方式与进程的方式通常相同,即如果一个线程调用了系统调用exec(),那么exec()参数所指定的程序会替换整个进程,包括所有线程。
fork()的两种形式的使用与应用程序有关,如果调用fork()之后立即调用exec(),那么没有必要复制所有线程,因为exec()参数所指定的程序会替换整个进程。在这种情况下,只复制调用线程比较适当。不过,如果在fork()之后另一个进程并不调用exec(),那么另一个进程就应复制所有线程。
取消
线程取消(thread cancellation)是在线程完成之前来终止线程的任务。例如,如果多个线程并发执行来搜索数据库,并且一个线程已经得到了结果,那么其他线程就可以被取消。要取消的线程通常称为目标线程。目标线程的取消可在如下两种情况下发生:
- 异步取消(asynchronous cancellation): 一个线程立即终止目标线程。
- 延迟取消(deferred cancellation): 目标线程不断检查它是否应终止,这允许目标线程有机会以有序方式来终止自己。
如果资源已分配给要取消的线程或取消的线程正在更新与其线程所共享的数据,那么取消就会有困难。对于异步取消尤其麻烦。操作系统回收取消线程的系统资源,但是通常并不回收所有资源。因此,异步取消线程并不会使所需的系统资源空闲。相反采用延迟取消时,一个线程指示目标线程要被取消,不过,只有当目标线程检查一个标志以确定它是否应该取消时才会发生取消。这允许一个线程检查它是否是在安全的点被取消,Pthread称这些点位取消点(cancellation point)。
信号处理
信号在UNIX中用来通知进程某个特定事件已发生了。根据需要通知信号的来源和事件的理由,信号可以同步或异步接收。不管信号是同步或异步的,所有信号具有同样模式:
- 信号是由特定事件的发送所产生的。
- 产生的信号要发送到进程。
- 一旦发送,信号必须加以处理。
同步信号的例子包括非法访问内存或被0所除。在这种情况下,如果运行程序执行这些动作,那么就产生信号。同步信号发送到执行操作而产生信号的同一进程(这就是为什么被认为是同步的原因)。
当一个信号由运行进程之外的事件产生,那么进程就异步接收这一信号。通常,异步信号被发送到另一个进程。
每个信号可能由两种可能的处理程序中的一种来处理:
(1):默认信号处理程序。
(2):用户定义的信号处理程序
每个信号都有一个默认信号处理程序(default signal handler),当处理信号时是在内核中运行的。这种默认动作可以用用户定义的信号处理程序来改写。信号可按不同的方式处理。有的信号可以简单地忽略(如改变窗口大小),其他的(如非法内存访问)可能要通过终止程序来处理。
单线程程序的信号处理比较直接,信号总是发送给进程。不过,对于多线程程序,发送信号就比较复杂,因为进程可能有多个线程。信号的发送通常会有如下选择:
- 发送信号到信号所应用的线程。
- 发送信号到进程内的每个线程。
- 发送信号到进程内的某些固定线程
- 规定一个特定线程以接收进程的所有信号
发送信号的方法依赖于产生信号的类型。例如,同步信号需要发送到产生这一信号的线程,而不是进程的其他线程。不过对于异步信号,情况就不是那么清楚了。有的异步信号如终止进程的信号应该发送到所有线程。
线程池
线程池的主要思想是在进程开始时创建一定数量的线程,并放入到池中等待工作。当服务器收到请求时,它会唤醒池中的一个线程(如果有可以用的线程),并将要处理的请求传递给它。一旦线程完成了服务,它会返回到池中再等待工作。如果池中没有可用的线程,那么服务器会一直等待直到有空线程为止。
线程池具有如下主要优点:
(1):通常用现有线程处理请求要比等待创建新的线程要快。
(2):线程池限制了在任何时候可用线程的数量。这对哪些不能支持大量并线程的系统非常重要。
线程池中的线程数量由系统CPU的数量,物理内存的大小和并发客户请求的期望值等因素决定。
线程特定数据
同属一个进程的线程共享进程数据。这种数据共享提供了多线程编程的一种优势。不过,在有些时候每个线程可能需要一定数据的自己的副本。这种数据为线程特定数据(thread-specific data)。例如,对于事物处理系统,可能需要通过独立线程以处理请求。而且,每个每个事务都有一个唯一标识符。
调度程序激活
多线程编程的一个重要的问题是内核与线程库之间的通信问题。许多实现多对多模型或二级模型的系统在用户和内核线程之间设置一种中间数据结构。这种数据结构(通常是轻量级进程(LWP))。对于用户线程库,LWP表现为一种应用程序可以调度用户线程来运行的虚拟处理器。每个LWP与内核线程相连,改内核线程被操作系统调度到物理处理器上运行。如果内核线程阻塞(如在等待一个I/O操作结束),LWP也阻塞。在这个关系链的顶端,与LWP相连的用户线程也阻塞。
为了高效地运行,应用程序可能需要一定数量的LWP。考虑一个CPU约束的运行在单处理器上的应用程序。此时,一次只能运行一个线程,所以只要一个LWP就够了,但一个I/O请求密集的应用程序可能需要多个LWP来执行。通常,每个并发阻塞系统调用需要一个LWP。
一种解决用户线程库与内核通信的方法被称为调度器激活(scheduler activation)。它按如下方式工作:内核提供一组虚拟处理器(LWP)给应用程序,应用程序可调度用户线程到一个可用的虚拟处理器上。进一步说,内核必须告知与应用程序有关的特定事件。这个过程被称为upcall。upcall由具有upcall处理句柄的线程处理,upcall处理句柄必须在虚拟处理器上运行。当一个应用线程将要阻塞时,事件引发一个upcall。在这个例子中,内核向应用程序发出一个upcall,通知它线程阻塞并标识特殊的线程。然后内核分配一个新的虚拟处理器给应用程序,应用程序在这个新的虚拟处理器上运行upcall处理程序,它保存阻塞线程状态和放弃阻塞线程运行的虚拟处理器。然后upcall调度另一个合适再新的虚拟处理器上运行的线程,当阻塞线程事件等待发生时,内核向线程库发出另一个upcall,来通知它先前阻塞的线程出现在可以运行了。此事件的upcall处理程序也需要一个虚拟处理器,内核可能分配一个新的虚拟处理器或先占一个用户线程并在其虚拟处理器上运行upcall处理程序。在使非阻塞线程可以运行后,应用程序调度符合条件的线程来在一个适当的虚拟处理器上运行。
小结
线程是进程内的控制流。多线程进程在同一地址空间内包括多个不同的控制流。多线程的优点包括对用户响应的改进,进程内的资源共享,经济和利用多处理器体系结构的能力。
用户线程对程序员来说是可见的,而对内核来说却是未知的。操作系统支持和管理内核线程。通常,用户线程跟内核线程相比,创建和管理要更快,因为它不需要内核干预。有三种不同模型将用户和内核线程关联起来:多对一模型将许多用户线程映射到一个内核线程;一对一模型将每个用户线程映射到一个相应的内核线程;多对多模型将多个用户线程在同样(或更少)数量的内核线程之间切换。