引言
使用多个控制线程(或者简单地说就是线程)在单进程环境中执行多个任务。一个进程中的所有线程都可以访问该进程的组成部分,如文件描述符和内存。
不管什么情况下,只要单个资源需要在多个用户间共享,就必须处理一致性问题。本章的最后将讨论目前可用的同步机制,防止多个线程在共享资源时出现不一致的问题。
线程的概念
典型的UNIX进程可以看成只有一个控制线程,一个进程在某一时刻只能做一件事情。有了多个控制线程以后,在程序设计时就可以把进程设计成在某一时刻能够做不止一件事,每个线程处理各自独立的任务。这种方法有很多好处。
- 通过为每种事件类型分配单独的处理线程,可以简化处理异步事件的代码。每个线程在进行事件处理时可以采用同步编程模式,同步编程模式要比异步编程模式简单得多。
- 多个进程必须使用操作系统提供的复杂机制才能实现内存和文件描述符的共享,而多个线程自动地可以访问相同的存储地址空间和文件描述符。
- 有些问题可以分解从而提高整个程序的吞吐量。在只有一个控制线程的情况下,一个单线程进程要完成多个任务,只需要把这些任务串行化。但有多个控制线程时,相互独立的任务的处理就可以交叉进行,此时只需要为每个任务分配一个单独的线程。当然只有在两个任务的处理过程互不依赖的情况下,两个任务才可以交叉执行。
- 交互的程序同样可以通过使用多线程来改善响应时间,多线程可以把程序中处理用户输入输出的部分与其他部分分开。
即使多线程程序在串行化任务时不得不阻塞,由于某些线程在阻塞的时候还有另外一些线程可以运行,所以多线程程序在单处理器上运行还是可以改善响应时间和吞吐量。
每个线程都包含有表示执行环境所必需的信息,其中包含进程中标识线程的线程ID,一组寄存器值、栈、调度优先级和策略、信号屏蔽字、errno变量以及线程私有数据。一个进程的所有信息对该进程的所有线程都是共享的,包括可执行程序的代码、程序的全局内存和堆内存、栈以及文件描述符。
线程标识
就像每个进程有一个进程ID一样,每个线程也有一个线程ID,进程ID在整个系统中是唯一的,但线程ID不同,线程ID只有在它所属的进程上下文中才有意义。
线程ID是用pthread_t数据类型来表示,必须使用一个函数来对两个线程ID进行比较。
|
|
线程可以通过调用pthread_self函数获得自身的线程ID。
|
|
线程创建
在传统的UNIX进程模型中,每个进程只有一个控制线程。从概念上来讲,这与基于线程的模型中每个进程只包含一个线程是相同的。在POSIX线程(pthread)的情况下,程序开始运行时,他也是以单进程中的单个控制线程启动的。在创建多个控制线程以前,程序的行为与传统的进程并没有什么区别。新增的线程可以通过调用pthread_create函数创建。
|
|
参数说明:
- tidp:新创建的线程ID会被设置成tidp指向的内存单元。
- attr:用于定制各种不同的线程属性,如果设置为NULL,则创建一个具有默认属性的线程。
- start_rtn:新创建的线程从start_rtn函数的地址开始运行,该函数只有一个void类型的指针参数arg,如果start_rtn需要多个参数,可以将参数放入一个结构中,然后将结构的地址作为arg传入。
线程创建时并不能保证哪个线程会先运行:是新创建的线程,还是调用线程。新创建的线程可以访问进程的地址空间,并且继承调用线程的浮点环境和信号屏蔽字,但是该线程的挂起信号集会被清除。
注意:pthread函数在调用失败后通常会返回错误码,它们并不像其他的POSIX函数一样设置errno。每个线程都提供errno的副本,这只是为了与使用errno的现有函数兼容。在线程中,从函数中返回错误码更为清晰整洁,不需要依赖那些随着函数执行不断变化的全局状态。这样可以把错误的范围限制在引起出错的函数中。
示例
创建一个线程,打印进程ID、新线程的线程ID以及初始化线程的线程ID。
|
|
|
|
在编译中要加 -lpthread参数,因为pthread库不是linux默认的库。
这个示例有两个特别之处。
第一个特别之处在于,主线程需要休眠,如果主线程不休眠,它就可能会退出,这样新线程还没有机会运行,整个进程就可能已经终止了。这种行为依赖于操作系统中的线程实现和调度算法。
第二个特别之处在于新线程是通过调用pthread_self函数获取自己的线程ID的,而不是从共享内存中读出的,或者从线程的启动例程中以参数的形式接收到的。在这个例子中,主线程把新线程ID存放在ntid中,但是新建的线程并不能安全地使用它,如果新线程在主线程调用pthread_create返回之前就运行了,那么新线程有可能看到的是未经初始化的ntid的内容。这个内容并不是正确的线程ID。
线程终止
如果进程中的任意线程调用了exit、_Exit、_exit,那么整个进程就会终止。与此相类似,如果默认的动作是终止进程,那么,发送到线程的信号就会终止整个进程。
单个线程可以通过3种方式退出,因此可以在不终止整个进程的情况下,停止它的控制流。
- 线程可以简单地从启动例程中返回,返回值是线程的退出码。
- 线程可以被同一进程中的其他线程取消。
- 线程调用pthread_exit。
|
|
rval_ptr参数是一个无类型的指针,与传给启动例程的单个参数类似。进程中的其他线程也可以通过调用pthread_join函数访问到这个指针上。
|
|
调用线程将一直阻塞,直到指定的线程调用pthread_exit、从启动例程中返回或者被取消。如果线程简单地从它的启动例程返回,rval_ptr就包含返回码。如果线程被取消,由rval_ptr指定的内存单元被设置为PTHREAD_CANCELED。
可以通过调用pthread_join自动把线程置于分离状态,这样资源就可以恢复。如果线程已经处于分离状态,pthread_join调用就会失败,返回EINVAL,尽管这种行为是与具体实现相关的。
如果对线程的返回值并不敢兴趣,那么可以把rval_ptr设置为NULL。在这种情况下,调用pthread_join函数可以等待指定的线程终止,但并不获取线程的终止状态。
示例
获取已终止线程的退出码。
|
|
|
|
可以看出,当一个线程通过调用pthread_exit退出或简单地从启动例程中返回时,进程中的其他线程可以通过调用pthread_join函数获得该进程的退出状态。
pthread_create和pthread_exit函数的无类型指针参数可以传递的值不止一个,这个指针可以传递包含复杂信息的结构的地址,但是注意,这个结构体所使用的内存在调用者完成调用以后必须仍然是有效的。
示例:
程序给出了用自动变量(分配在栈上)作为pthread_exit的参数时出现的问题。
|
|
|
|
解决办法,可以使用全局结构(全局变量,static修饰),或者用malloc函数分配结构。
取消线程
线程可以通过调用pthread_cancel函数来请求取消同一进程中的其他线程。
|
|
在默认情况下,pthread_cancel函数会使得由tid标识的线程的行为表现为如同调用了参数为PTHREAD_CANCELED的pthread_exit函数,但是,线程可以选择忽略取消或者控制如何被取消。注意,pthread_cancel并不等待线程终止,它仅仅提出请求。
线程可以安排它退出时需要调用的函数,这与进程在退出时可以用atexit函数安排退出是类似的。这样的函数称为线程清理处理程序(thread cleanup handler)。一个线程可以建立多个清理处理程序。处理程序记录在栈中,也就是说,它们的执行顺序与它们的注册时相反。
|
|
当线程执行以下动作时,清理函数rtn是由pthread_cleanup_push函数调度的,调用时只有一个参数arg:
- 调用pthread_exit时;
- 响应取消请求时;
- 用非零execute参数调用pthread_cleanup_pop时。
如果execute参数设置为0,清理函数将不被调用。不管发生上述哪种情况,pthread_cleanup_pop都将删除上次pthread_cleanup_push调用建立的清理处理程序。
这些函数有一个限制,由于它们可以实现为宏,所以必须在与线程相同的作用域中以匹配对的形式使用。
示例
|
|
|
|
从输入结果可以看到,两个线程都正确地启动和退出了,但是只有第二个线程的清理处理程序被调用了。因此,如果线程是通过从它的启动例程中返回而终止的话,它的清理处理程序就不会被调用,还要注意清理处理程序是按照与它们安装时相反的顺序被调用的。
现在,让我们了解一下线程函数和进程函数之间的相似之处,下图总结了这些相似的函数。
进程原语 | 线程原语 | 描述 |
---|---|---|
fork | pthread_create | 创建新的控制流 |
exit | pthread_exit | 从现在的控制流中退出 |
waitpid | pthread_join | 从控制流中得到退出状态 |
atexit | pthread_cancel_push | 注册在调用控制流时调用的函数 |
getpid | pthread_self | 获取控制流ID |
abort | pthread_cancel | 请求控制流的非正常退出 |
在默认情况下,线程的终止状态会保存直到对该线程调用pthread_join。如果线程已经被分离,线程的底层存储资源可以在线程终止时立即被收回。在线程分离后,我们不能用pthread_join函数等待它的终止状态,因为对分离状态的线程调用pthread_join会产生未定义行为。可以调用pthread_detach分离线程。
|
|