10.1 引言
![4064394-2f050bcd5a6be57b.png](https://i-blog.csdnimg.cn/blog_migrate/e27e960c25036799b2fe077160e3286e.png)
在前面的章节中讨论了进程,学习了UNIX进程的环境、进程间的 关系以及控制进程的不同方式。可以看到在相关的进程间可以存在一定 的共享。
本章将进一步深入理解进程,了解如何使用多个控制线程(或者简 单地说就是线程)在单进程环境中执行多个任务。一个进程中的所有线 程都可以访问该进程的组成部件,如文件描述符和内存。
不管在什么情况下,只要单个资源需要在多个用户间共享,就必须 处理一致性问题。本章的最后将讨论目前可用的同步机制,防止多个线 程在共享资源时出现不一致的问题。
11.2 线程概念
典型的UNIX进程可以看成只有一个控制线程:一个进程在某一时 刻只能做一件事情。有了多个控制线程以后,在程序设计时就可以把进 程设计成在某一时刻能够做不止一件事,每个线程处理各自独立的任 务。这种方法有很多好处。
•通过为每种事件类型分配单独的处理线程,可以简化处理异步事 件的代码。每个线程在进行事件处理时可以采用同步编程模式,同步编 程模式要比异步编程模式简单得多。
•多个进程必须使用操作系统提供的复杂机制才能实现内存和文件 描述符的共享,我们将在第 15 章和第 17 章中学习这方面的内容。而多 个线程自动地可以访问相同的存储地址空间和文件描述符。
•有些问题可以分解从而提高整个程序的吞吐量。在只有一个控制 线程的情况下,一个单线程进程要完成多个任务,只需要把这些任务串 行化。但有多个控制线程时,相互独立的任务的处理就可以交叉进行, 此时只需要为每个任务分配一个单独的线程。当然只有在两个任务的处 理过程互不依赖的情况下,两个任务才可以交叉执行。
•交互的程序同样可以通过使用多线程来改善响应时间,多线程可 以把程序中处理用户输入输出的部分与其他部分分开。
有些人把多线程的程序设计与多处理器或多核系统联系起来。但是 即使程序运行在单处理器上,也能得到多线程编程模型的好处。处理器 的数量并不影响程序结构,所以不管处理器的个数多少,程序都可以通 过使用线程得以简化。而且,即使多线程程序在串行化任务时不得不阻 塞,由于某些线程在阻塞的时候还有另外一些线程可以运行,所以多线程程序在单处理器上运行还是可以改善响应时间和吞吐量。
每个线程都包含有表示执行环境所必需的信息,其中包括进程中标
识线程的线程ID
、一组寄存器值
、栈
、调度优先级
和策略
、信号屏蔽字
、errno变量
,以及线程私有数据
一个进程的 所有信息对该进程的所有线程都是共享的,包括可执行程序的代码、程 序的全局内存和堆内存、栈以及文件描述符。
我们将要讨论的线程接口来自POSIX.1-2001。线程接口也称 为“pthread”或“POSIX线程”,原来在POSIX.1-2001中是一个可选功 能,但后来SUSv4把它们放入了基本功能。POSIX线程的功能测试宏是 _POSIX_THREADS。应用程序可以把这个宏用于#ifdef测试,从而在编 译时确定是否支持线程;也可以把_SC_THREADS常数用于调用sysconf 函数,进而在运行时确定是否支持线程。遵循SUSv4的系统定义符号 _POSIX_THREADS的值为200809L。
11.3线程标识
就像每个进程有一个进程ID一样,每个线程也有一个线程ID。进 程 ID在整个系统中是唯一的,但线程ID不同,线程ID只有在它所属的 进程上下文中才有意义。
回忆一下进程ID,它是用pid_t数据类型来表示的,是一个非负整 数。线程ID是用pthread_t数据类型来表示的,实现的时候可以用一个结 构来代表pthread_t数据类型,所以可移植的操作系统实现不能把它作为整数处理。因此必须使用一个函数来对两个线程ID进行比较。
#include <pthread.h>
int pthread_equal(pthread_t tid1, pthread_t tid2);
返回值:若相等,返回非0数值;否则,返回0
Linux 3.2.0使用无符号长整型表示pthread_t数据类型。Solaris 10把pthread_t数据类型表示为无符号整型。FreeBSD 8.0和Mac OS X 10.6.8用一个指向pthread结构的指针来表示pthread_t数据类型。
用结构表示pthread_t数据类型的后果是不能用一种可移植的方式打 印该数据类型的值。在程序调试过程中打印线程ID有时是非常有用的, 而在其他情况下通常不需要打印线程ID。最坏的情况是,有可能出现不 可移植的调试代码,当然这也算不上是很大的局限性。
线程可以通过调用pthread_self函数获得自身的线程ID。
#include<pthread.h>
pthread_t pthread_self(void);
返回值:调用线程的线程ID 当线程需要识别以线程ID作为标识的数据结构时,pthread_self函数可以与pthread_equal函数一起使用。例如,主线程可能把工作任务放在 一个队列中,用线程ID来控制每个工作线程处理哪些作业。
如图11-1所 示,主线程把新的作业放到一个工作队列中,由3个工作线程组成的线 程池从队列中移出作业。主线程不允许每个线程任意处理从队列顶端取 出的作业,而是由主线程控制作业的分配,主线程会在每个待处理作业 的结构中放置处理该作业的线程ID,每个工作线程只能移出标有自己线 程ID的作业。
![4064394-e30ba1a59bafb014.png](https://i-blog.csdnimg.cn/blog_migrate/27c8e33d84513f84c55f20d0fa3e5d46.png)
11.4 线程创建
在传统UNIX进程模型中,每个进程只有一个控制线程。从概念上 讲,这与基于线程的模型中每个进程只包含一个线程是相同的。在 POSIX线程(pthread)的情况下,程序开始运行时,它也是以单进程中 的单个控制线程启动的。在创建多个控制线程以前,程序的行为与传统 的进程并没有什么区别。新增的线程可以通过调用pthread_create函数创 建。
#include <pthread.h>
int pthread_create(pthread_t *restrict tidp,
const pthread_attr_t *restrict attr,
void *(*start_rtn)(void *), void *restrict arg);
返回值:若成功,返回0;否则,返回错误编号 当pthread_create成功返回时,新创建线程的线程ID会被设置成tidp
指向的内存单元。attr参数用于定制各种不同的线程属性。我们将在12.3 节中讨论线程属性,但现在我们把它置为NULL,创建一个具有默认属 性的线程。
新创建的线程从start_rtn函数的地址开始运行,该函数只有一个无 类型指针参数arg。如果需要向start_rtn函数传递的参数有一个以上,那么需要把这些参数放到一个结构中,然后把这个结构的地址作为arg参数 传入。
线程创建时并不能保证哪个线程会先运行:是新创建的线程,还是 调用线程。新创建的线程可以访问进程的地址空间,并且继承调用线程 的浮点环境和信号屏蔽字,但是该线程的挂起信号集会被清除。
注意,pthread 函数在调用失败时通常会返回错误码,它们并不像 其他的 POSIX 函数一样设置errno。每个线程都提供errno的副本,这只 是为了与使用errno的现有函数兼容。在线程中,从函数中返回错误码更 为清晰整洁,不需要依赖那些随着函数执行不断变化的全局状态,这样 可以把错误的范围限制在引起出错的函数中。
#include <pthread.h>
#include <iostream>
#include <memory>
#include <vector>
#include <unistd.h>
/* Function for start. */
void run_thread(int task1, int task2) {
printf("hello");
std::cout << "start Function for thread." << std::endl;
std::cout << "task1: " << task1 << std::endl;
std::cout << "task1: " << task2 << std::endl;
// std::cout << "this thread_id: " << pthread_self() << std::endl;
}
void * run_task(void* args) {
printf("hello this is thread");
run_thread(1, 2);
return ((void *)0);
}
pthread_t tid;
int main()
{
/* Pthread Id. */
pthread_t tid_1 = reinterpret_cast<pthread_t>(1);
pthread_t tid_2 = reinterpret_cast<pthread_t>(1);
if (pthread_equal(tid_1, tid_2)) {
std::cout << "equal pthread" << std::endl;
}
/* Get self Id. */
pthread_t my_tid = pthread_self();
std::cout << "self _Id: " << my_tid << std::endl;
void (*thread_task)(int , int ) = run_thread;
int arg_list[10] = {1, 2};
int *arg = arg_list;
printf("Test pthread\n");
/* Start Create Thread. */
int ret = pthread_create(&tid, NULL, run_task, NULL);
sleep(1);
/* thread queue. */
return 0;
}
![4064394-42022119e7e7b36f.png](https://i-blog.csdnimg.cn/blog_migrate/20fc59b77de1194e22a260cf652e688d.png)
这个实例有两个特别之处,需要处理主线程和新线程之间的竞争。 (我们将在这章后面的内容中学习如何更好地处理这种竞争。)第一个 特别之处在于,主线程需要休眠,如果主线程不休眠,它就可能会退 出,这样新线程还没有机会运行,整个进程可能就已经终止了。这种行 为特征依赖于操作系统中的线程实现和调度算法。
第二个特别之处在于新线程是通过调用pthread_self函数获取自己的 线程ID的,而不是从共享内存中读出的,或者从线程的启动例程中以参 数的形式接收到的。回忆 pthread_create函数,它会通过第一个参数
(tidp)返回新建线程的线程ID。在这个例子中,主线程把新线程ID存 放在 ntid 中,但是新建的线程并不能安全地使用它,如果新线程在主线 程调用pthread_create返回之前就运行了,那么新线程看到的是未经初始 化的ntid的内容,这个内容并不是正确的线程ID。
尽管Linux线程ID是用无符号长整型来表示的,但是它们看起来像
指针。
Linux 2.4和Linux 2.6在线程实现上是不同的。Linux 2.4中, LinuxThreads是用单独的进程实现每个线程的,这使得它很难与POSIX 线程的行为匹配。Linux 2.6中,对Linux内核和线程库进行了很大的修 改,采用了一个称为Native POSIX线程库(Native POSIX Thread Library,NPTL)的新线程实现。它支持单个进程中有多个线程的模 型,也更容易支持POSIX线程的语义。
11.5 线程终止
如果进程中的任意线程调用了 exit、_Exit 或者_exit,那么整个进程 就会终止。与此相类似,如果默认的动作是终止进程,那么,发送到线 程的信号就会终止整个进程,单个线程可以通过3种方式退出,因此可以在不终止整个进程的情 况下,停止它的控制流。
(1)线程可以简单地从启动例程中返回,返回值是线程的退出 码。
(2)线程可以被同一进程中的其他线程取消。
(3)线程调用pthread_exit。
#include <pthread.h>
void pthread_exit(void *rval_ptr);
rval_ptr 参数是一个无类型指针,与传给启动例程的单个参数类
似。进程中的其他线程也可以通过调用pthread_join函数访问到这个指 针。
#include <pthread.h>
int pthread_join(pthread_t thread, void **rval_ptr);
返回值:若成功,返回0;否则,返回错误编号
调用线程将一直阻塞,直到指定的线程调用pthread_exit、从启动例
程中返回或者被取消。如果线程简单地从它的启动例程返回,rval_ptr就 包含返回码。如果线程被取消,由rval_ptr指定的内存单元就设置为 PTHREAD_CANCELED。
可以通过调用pthread_join自动把线程置于分离状态(马上就会讨论 到),这样资源就可以恢复。如果线程已经处于分离状态,pthread_join 调用就会失败,返回EINVAL,尽管这种行为是与具体实现相关的。
如果对线程的返回值并不感兴趣,那么可以把rval_ptr设置为 NULL。在这种情况下,调用pthread_join函数可以等待指定的线程终 止,但并不获取线程的终止状态。
#include <iostream>
#include <vector>
#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
#include <pthread.h>
void *th_func(void *) {
std::cout << "After Running Thread task1" << std::endl;
return (void *)1;
}
void *th_task_2(void *) {
std::cout << "After Running Thread task2" << std::endl;
pthread_exit(NULL);
}
int main() {
void *rval_ptr;
pthread_t tid_test;
pthread_t tid_2;
int err;
std::cout << "Start Main Thread" << std::endl;
/* Thread 1. */
err = pthread_create(&tid_test, NULL, th_func, NULL);
if(err != 0) {
std::cout << err << "can't create thread 1" << std::endl;
}
printf("Thread1 exit code %ld\n", *static_cast<long *>(rval_ptr));
/* Thread 2. */
err= pthread_create(&tid_2, NULL, th_task_2, NULL);
if(err != 0) {
std::cout << err << "can't create thread 2" << std::endl;
}
err = pthread_join(tid_test, &rval_ptr);
/* Join Thread2. */
err = pthread_join(tid_2, &rval_ptr);
if(err != 0) {
std::cout << err << "can't join thread2" << std::endl;
}
printf("Thread2 exit code %ld\n", *static_cast<long*>(rval_ptr));
exit(0);
}
可以看到,当一个线程通过调用pthread_exit退出或者简单地从启动
例程中返回时,进程中的其他线程可以通过调用pthread_join函数获得该 线程的退出状态。
pthread_create和pthread_exit函数的无类型指针参数可以传递的值不 止一个,这个指针可以传递包含复杂信息的结构的地址,但是注意,这 个结构所使用的内存在调用者完成调用以后必须仍然是有效的。例如, 在调用线程的栈上分配了该结构,那么其他的线程在使用这个结构时内 存内容可能已经改变了。又如,线程在自己的栈上分配了一个结构,然 后把指向这个结构的指针传给pthread_exit,那么调用pthread_join的线程 试图使用该结构时,这个栈有可能已经被撤销,这块内存也已另作他 用。
虽然线程退出后,内存依然是完整的,但我们不能期望情况总是这
样的。从其他平台上的结果中可以看出,情况并不都是这样的。 线程可以通过调用pthread_cancel函数来请求取消同一进程中的其他线程。
#include <pthread.h>
int pthread_cancel(pthread_t tid);
返回值:若成功,返回0;否则,返回错误编号
返回值:若成功,返回0;否则,返回错误编号 在默认情况下,pthread_cancel 函数会使得由tid标识的线程的行为表 现为如同调用了参数为PTHREAD_ CANCELED 的pthread_exit 函数,但
是,线程可以选择忽略取消或者控制如何被取消。我们将在12.7节中详 细讨论。注意pthread_cancel并不等待线程终止,它仅仅提出请求。
线程可以安排它退出时需要调用的函数,这与进程在退出时可以用 atexit函数(见7.3节)安排退出是类似的。这样的函数称为线程清理处 理程序(thread cleanup handler)。一个线程可以建立多个清理处理程 序。处理程序记录在栈中,也就是说,它们的执行顺序与它们注册时相 反。
#include <pthread.h>
void pthread_cleanup_push(void (*rtn)(void *), void *arg); void pthread_cleanup_pop(int execute);
void *hello(void* temp) {
do something
pthread_cleanup_push(func, arg)
pthread_cleanup_pop(0);
return (void *)0;
}
现在,让我们了解一下线程函数和进程函数之间的相似之处。图 11-6总结了这些相似的函数。
![4064394-c19e2a3d59558da9.png](https://i-blog.csdnimg.cn/blog_migrate/8808b1da91030923c86fb47873ba0f41.png)
在默认情况下,线程的终止状态会保存直到对该线程调用 pthread_join。如果线程已经被分离,线程的底层存储资源可以在线程终 止时立即被收回。在线程被分离后,我们不能用pthread_join函数等待它 的终止状态,因为对分离状态的线程调用pthread_join会产生未定义行 为。可以调用pthread_detach分离线程。
#include <pthread.h>
int pthread_detach(pthread_t tid);
返回值:若成功,返回0;否则,返回错误编号 在下一章里,我们将学习通过修改传给pthread_create函数的线程属
性,创建一个已处于分离状态的线程。
11.7 线程同步
当多个控制线程共享相同的内存时,需要确保每个线程看到一致的 数据视图。如果每个线程使用的变量都是其他线程不会读取和修改的, 那么就不存在一致性问题。同样,如果变量是只读的,多个线程同时读 取该变量也不会有一致性问题。但是,当一个线程可以修改的变量,其 他线程也可以读取或者修改的时候,我们就需要对这些线程进行同步, 确保它们在访问变量的存储内容时不会访问到无效的值。
当一个线程修改变量时,其他线程在读取这个变量时可能会看到一 个不一致的值。在变量修改时间多于一个存储器访问周期的处理器结构 中,当存储器读与存储器写这两个周期交叉时,这种不一致就会出现。 当然,这种行为是与处理器体系结构相关的,但是可移植的程序并不能 对使用何种处理器体系结构做出任何假设。
图 11-7 描述了两个线程读写相同变量的假设例子。在这个例子 中,线程 A读取变量然后给这个变量赋予一个新的数值,但写操作需要 两个存储器周期。当线程B在这两个存储器写周期中间读取这个变量 时,它就会得到不一致的值。
![4064394-cb3e3dbbe6aedc1f.png](https://i-blog.csdnimg.cn/blog_migrate/cf59219d263cc2ebcef4a4dbb7c9a8ac.png)
![4064394-27839cf2b1370398.png](https://i-blog.csdnimg.cn/blog_migrate/304143d86d81f11f34c045eab7bbcc8f.png)
为了解决这个问题,线程不得不使用锁,同一时间只允许一个线程 访问该变量。图11-8描述了这种同步。如果线程B希望读取变量,它首 先要获取锁。同样,当线程A更新变量时,也需要获取同样的这把锁。 这样,线程B在线程A释放锁以前就不能读取变量。
两个或多个线程试图在同一时间修改同一变量时,也需要进行同 步。考虑变量增量操作的情况(图11-9),增量操作通常分解为以下3 步。
(1)从内存单元读入寄存器。
(2)在寄存器中对变量做增量操作。
(3)把新的值写回内存单元。
如果两个线程试图几乎在同一时间对同一个变量做增量操作而不进
行同步的话,结果就可能出现不一致,变量可能比原来增加了1,也有 可能比原来增加了2,具体增加了1还是2要取决于第二个线程开始操作 时获取的数值。如果第二个线程执行第1步要比第一个线程执行第3步要 早,第二个线程读到的值与第一个线程一样,为变量加1,然后写回 去,事实上没有实际的效果,总的来说变量只增加了1。
如果修改操作是原子操作,那么就不存在竞争。在前面的例子中, 如果增加1只需要一个存储器周期,那么就没有竞争存在。如果数据总 是以顺序一致出现的,就不需要额外的同步。当多个线程观察不到数据 的不一致时,那么操作就是顺序一致的。在现代计算机系统中,存储访 问需要多个总线周期,多处理器的总线周期通常在多个处理器上是交叉 的,所以我们并不能保证数据是顺序一致的。
![4064394-32aa87be4e9cb64f.png](https://i-blog.csdnimg.cn/blog_migrate/c15ee6060969bdb5f922efa9eb310bb1.png)
顺序一致环境中,可以把数据修改操作解释为运行线程的顺序操 作步骤。可以把这样的操作描述为“线程A对变量增加了1,然后线程B 对变量增加了1,所以变量的值就比原来的大2”,或者描述为“线程B 对变量增加了1,然后线程A对变量增加了1,所以变量的值就比原来的 大2”。这两个线程的任何操作顺序都不可能让变量出现除了上述值以 外的其他值。
除了计算机体系结构以外,程序使用变量的方式也会引起竞争,也 会导致不一致的情况发生。例如,我们可能对某个变量加 1,然后基于
这个值做出某种决定。因为这个增量操作步骤和这个决定步骤的组合并 非原子操作,所以就给不一致情况的出现提供了可能。
11.6互斥量
互斥变量是用pthread_mutex_t数据类型表示的。在使用互斥变量以 前,必须首先对它进行初始化,可以把它设置为常量 PTHREAD_MUTEX_INITIALIZER(只适用于静态分配的互斥量),也 可以通过调用pthread_mutex_init函数进行初始化。如果动态分配互斥量 (例如,通过调用malloc函数),在释放内存前需要调用,pthread_mutex_destroy。
#include <pthread.h>
int pthread_mutex_init(pthread_mutex_t *restrict mutex,
const pthread_mutexattr_t *restrict attr);
int pthread_mutex_destroy(pthread_mutex_t *mutex);
两个函数的返回值:若成功,返回0;否则,返回错误编号 要用默认的属性初始化互斥量,只需把attr设为NULL.
对互斥量进行加锁,需要调用 pthread_mutex_lock。如果互斥量已
经上锁,调用线程将阻塞直到互斥量被解锁。对互斥量解锁,需要调用 pthread_mutex_unlock。
#include <pthread.h>
int pthread_mutex_lock(pthread_mutex_t *mutex); int pthread_mutex_trylock(pthread_mutex_t *mutex); int pthread_mutex_unlock(pthread_mutex_t *mutex);
所有函数的返回值:若成功,返回0;否则,返回错误编号 如果线程不希望被阻塞,它可以使用pthread_mutex_trylock尝试对互 斥量进行加锁。如果调用 pthread_mutex_trylock 时互斥量处于未锁住状态,那pthread_mutex_trylock将锁住互斥量,不会出现阻塞直接返回 0,否则pthread_mutex_trylock 就会失败,不能锁住互斥量,返回 EBUSY。
保护某个数据结构的互斥量。当一个以上的线程 需要访问动态分配的对象时,我们可以在对象中嵌入引用计数,确保在 所有使用该对象的线程完成数据访问之前,该对象内存空间不会被释 放。
在对引用计数加 1、减 1、检查引用计数是否到达 0 这些操作之前 需要锁住互斥量。在foo_alloc 函数中将引用计数初始化为 1 时没必要加 锁,因为在这个操作之前分配线程是唯一引用该对象的线程。但是在这 之后如果要将该对象放到一个列表中,那么它就有可能被别的线程发 现,这时候需要首先对它加锁。
struct foo{
int f_count;
pthread_mutex_t f_mutex;
int f_id;
};
foo * foo_alloc(int id) {
foo *fp;
if((fp = (foo*)malloc(sizeof(sizeof(foo)))) != NULL) {
fp->f_count = 1;
fp->f_id = id;
if(pthread_mutex_init(&fp->f_mutex, NULL) != 0) {
free(fp);
return NULL;
}
}
return fp;
}
void foo_hold(foo *fp) {
pthread_mutex_lock(&fp->f_mutex);
fp->f_count++;
pthread_mutex_unlock(&fp->f_mutex);
}
void foo_release(foo *fp) {
pthread_mutex_lock(&fp->f_mutex);
if(--fp->f_count == 0) {
pthread_mutex_unlock(&fp->f_mutex);
pthread_mutex_destroy(&fp->f_mutex);
free(fp);
} else {
pthread_mutex_unlock(&fp->f_mutex);
}
}
11.6 避免死锁
如果线程试图对同一个互斥量加锁两次,那么它自身就会陷入死锁 状态,但是使用互斥量时,还有其他不太明显的方式也能产生死锁。例 如,程序中使用一个以上的互斥量时,如果允许一个线程一直占有第一 个互斥量,并且在试图锁住第二个互斥量时处于阻塞状态,但是拥有第 二个互斥量的线程也在试图锁住第一个互斥量。因为两个线程都在相互 请求另一个线程拥有的资源,所以这两个线程都无法向前运行,于是就 产生死锁。
可以通过仔细控制互斥量加锁的顺序来避免死锁的发生。例如,假 设需要对两个互斥量A和B同时加锁。如果所有线程总是在对互斥量B加 锁之前锁住互斥量A,那么使用这两个互斥量就不会产生死锁(当然在 其他的资源上仍可能出现死锁)。类似地,如果所有的线程总是在锁住 互斥量A之前锁住互斥量B,那么也不会发生死锁。可能出现的死锁只 会发生在一个线程试图锁住另一个线程以相反的顺序锁住的互斥量。
有时候,应用程序的结构使得对互斥量进行排序是很困难的。如果 涉及了太多的锁和数据结构,可用的函数并不能把它转换成简单的层 次,那么就需要采用另外的方法。在这种情况下,可以先释放占有的 锁,然后过一段时间再试。这种情况可以使用pthread_mutex_trylock接口 避免死锁。如果已经占有某些锁而且pthread_mutex_trylock接口返回成 功,那么就可以前进。但是,如果不能获取锁,可以先释放已经占有的 锁,做好清理工作,然后过一段时间再重新试。
11.6.3 函数pthread_mutex_timedlock
当线程试图获取一个已加锁的互斥量时,pthread_mutex_timedlock 互斥量原语允许绑定线程阻塞时间。pthread_mutex_timedlock函数与 pthread_mutex_lock是基本等价的,但是在达到超时时间值时, pthread_mutex_timedlock 不会对互斥量进行加锁,而是返回错误码ETIMEDOUT。
#include <pthread.h>
#include <time.h>
int pthread_mutex_timedlock(pthread_mutex_t
mutex,
const struct timespec *restrict tsptr);
返回值:若成功,返回0;否则,返回错误编号 超时指定愿意等待的绝对时间(与相对时间对比而言,指定在时间X之前可以阻塞等待,而不是说愿意阻塞Y秒)。这个超时时间是用 timespec结构来表示的,它用秒和纳秒来描述时间。
11.6.4 读写锁
读写锁(reader-writer lock)与互斥量类似,不过读写锁允许更高的 并行性。互斥量要么是锁住状态,要么就是不加锁状态,而且一次只有 一个线程可以对其加锁。读写锁可以有3种状态:读模式下加锁状态, 写模式下加锁状态,不加锁状态。一次只有一个线程可以占有写模式的 读写锁,但是多个线程可以同时占有读模式的读写锁。
当读写锁是写加锁状态时,在这个锁被解锁之前,所有试图对这个 锁加锁的线程都会被阻塞。当读写锁在读加锁状态时,所有试图以读模 式对它进行加锁的线程都可以得到访问权,但是任何希望以写模式对此 锁进行加锁的线程都会阻塞,直到所有的线程释放它们的读锁为止。虽 然各操作系统对读写锁的实现各不相同,但当读写锁处于读模式锁住的 状态,而这时有一个线程试图以写模式获取锁时,读写锁通常会阻塞随 后的读模式锁请求。这样可以避免读模式锁长期占用,而等待的写模式 锁请求一直得不到满足。
读写锁非常适合于对数据结构读的次数远大于写的情况。当读写锁 在写模式下时,它所保护的数据结构就可以被安全地修改,因为一次只 有一个线程可以在写模式下拥有这个锁。当读写锁在读模式下时,只要 线程先获取了读模式下的读写锁,该锁所保护的数据结构就可以被多个
获得读模式锁的线程读取。
读写锁也叫做共享互斥锁(shared-exclusive lock)。当读写锁是读
模式锁住时,就可以说成是以共享模式锁住的。当它是写模式锁住的时 候,就可以说成是以互斥模式锁住的。
与互斥量相比,读写锁在使用之前必须初始化,在释放它们底层的 内存之前必须销毁。
#include <pthread.h>
int pthread_rwlock_init(pthread_rwlock_t *restrict rwlock,
const pthread_rwlockattr_t *restrict attr); int pthread_rwlock_destroy(pthread_rwlock_t *rwlock);
两个函数的返回值:若成功,返回0;否则,返回错误编号 读写锁通过调用 pthread_rwlock_init 进行初始化。如果希望读写锁 有默认的属性,可以传一个null指针给attr,我们将在12.4.2节中讨论读写
锁的属性。
Single UNIX Specification在XSI扩展中定义了
PTHREAD_RWLOCK_INITIALIZER常量。如果默认属性就足够的话, 可以用它对静态分配的读写锁进行初始化。
在释放读写锁占用的内存之前,需要调用 pthread_rwlock_destroy 做 清理工作。如果pthread_rwlock_init为读写锁分配了资源, pthread_rwlock_destroy将释放这些资源。如果在调用 pthread_rwlock_destroy 之前就释放了读写锁占用的内存空间,那么分配 给这个锁的资源就会丢失。
要在读模式下锁定读写锁,需要调用pthread_rwlock_rdlock。要在写 模式下锁定读写锁,需要调用pthread_rwlock_wrlock。不管以何种方式 锁住读写锁,都可以调用pthread_rwlock_unlock进行解锁。
#include <pthread.h>
int pthread_rwlock_rdlock(pthread_rwlock_t *rwlock);
int pthread_rwlock_wrlock(pthread_rwlock_t *rwlock); int pthread_rwlock_unlock(pthread_rwlock_t *rwlock);
有函数的返回值:若成功,返回0;否则,返回错误编号 各种实现可能会对共享模式下可获取的读写锁的次数进行限制,所
以需要检查 pthread_rwlock_rdlock的返回值。即使pthread_rwlock_wrlock 和pthread_rwlock_unlock有错误返回,而且从技术上来讲,在调用函数 时应该总是检查错误返回,但是如果锁设计合理的话,就不需要检查它 们。错误返回值的定义只是针对不正确使用读写锁的情况(如未经初始 化的锁),或者试图获取已拥有的锁从而可能产生死锁的情况。但是需 要注意,有些特定的实现可能会定义另外的错误返回。
Single UNIX Specification还定义了读写锁原语的条件版本。
#include <pthread.h>
int pthread_rwlock_tryrdlock(pthread_rwlock_t *rwlock);
int pthread_rwlock_trywrlock(pthread_rwlock_t *rwlock);
两个函数的返回值:若成功,返回0;否则,返回错误编号 可以获取锁时,这两个函数返回0。否则,它们返回错误EBUSY。
这两个函数可以用于我们前面讨论的遵守某种锁层次但还不能完全避免 死锁的情况。
凡是需要向队列中增加作业或者从队列中删除作业 的时候,都采用了写模式来锁住队列的读写锁。不管何时搜索队列,都 需要获取读模式下的锁,允许所有的工作线程并发地搜索队列。在这种 情况下,只有在线程搜索作业的频率远远高于增加或删除作业时,使用 读写锁才可能改善性能。
工作线程只能从队列中读取与它们的线程 ID 匹配的作业。由于作 业结构同一时间只能由一个线程使用,所以不需要额外的加锁。
11.6.5 带有超时的读写锁
与互斥量一样,Single UNIX Specification提供了带有超时的读写锁 加锁函数,使应用程序在获取读写锁时避免陷入永久阻塞状态。这两个 函数是 pthread_rwlock_timedrdlock 和 pthread_rwlock_timedwrlock。
#include <pthread.h>
#include <time.h>
int pthread_rwlock_timedrdlock(pthread_rwlock_t *restrict
rwlock,
const struct timespec *restrict tsptr);
int pthread_rwlock_timedwrlock(pthread_rwlock_t *restrict
rwlock,
const struct timespec *restrict tsptr);
两个函数的返回值:若成功,返回0;否则,返回错误编号 这两个函数的行为与它们“不计时的”版本类似。tsptr参数指向
timespec结构,指定线程应该停止阻塞的时间。如果它们不能获取锁, 那么超时到期时,这两个函数将返回 ETIMEDOUT错误。与 pthread_mutex_timedlock函数类似,超时指定的是绝对时间,而不是相 对时间。
11.6.7 条件变量
条件变量是线程可用的另一种同步机制。条件变量给多个线程提供 了一个会合的场所。条件变量与互斥量一起使用时,允许线程以无竞争 的方式等待特定的条件发生。
条件本身是由互斥量保护的。线程在改变条件状态之前必须首先锁 住互斥量。其他线程在获得互斥量之前不会察觉到这种改变,因为互斥 量必须在锁定以后才能计算条件。
在使用条件变量之前,必须先对它进行初始化。由pthread_cond_t数 据类型表示的条件变量可以用两种方式进行初始化,可以把常量 PTHREAD_COND_INITIALIZER赋给静态分配的条件变量,但是如果 条件变量是动态分配的,则需要使用pthread_cond_init函数对它进行初始 化。
在释放条件变量底层的内存空间之前,可以使用 pthread_cond_destroy函数对条件变量进行反初始化(deinitialize)。
#include <pthread.h>
int pthread_cond_init(pthread_cond_t *restrict cond, const pthread_condattr_t *restrict attr);
int pthread_cond_destroy(pthread_cond_t *cond);
两个函数的返回值:若成功,返回0;否则,返回错误编号 除非需要创建一个具有非默认属性的条件变量,否则
pthread_cond_init函数的attr参数可以设置为NULL。我们将在12.4.3节中 讨论条件变量属性。
我们使用pthread_cond_wait等待条件变量变为真。如果在给定的时 间内条件不能满足,那么会生成一个返回错误码的变量。
#include <pthread.h>
int pthread_cond_wait(pthread_cond_t *restrict cond,
pthread_mutex_t *restrict mutex);
int pthread_cond_timedwait(pthread_cond_t *restrict cond,
pthread_mutex_t *restrict mutex,
const struct timespec *restrict tsptr);
两个函数的返回值:若成功,返回0;否则,返回错误编号 传递给pthread_cond_wait的互斥量对条件进行保护。调用者把锁住
的互斥量传给函数,函数然后自动把调用线程放到等待条件的线程列表 上,对互斥量解锁。这就关闭了条件检查和线程进入休眠状态等待条件 改变这两个操作之间的时间通道,这样线程就不会错过条件的任何变 化。pthread_cond_wait返回时,互斥量再次被锁住。
pthread_cond_timedwait函数的功能与pthread_cond_wait函数相似,只 是多了一个超时(tsptr)。超时值指定了我们愿意等待多长时间,它是 通过timespec结构指定的。
如图11-13所示,需要指定愿意等待多长时间,这个时间值是一个绝 对数而不是相对数。例如,假设愿意等待3分钟。那么,并不是把3分钟
转换成timespec结构,而是需要把当前时间加上3分钟再转换成timespec 结构。
可以使用clock_gettime函数(见6.10节)获取timespec结构表示的当 前时间。但是目前并不是所有的平台都支持这个函数,因此,也可以用 另一个函数 gettimeofday 获取timeval结构表示的当前时间,然后把这个 时间转换成timespec结构。
如果超时到期时条件还是没有出现,pthread_cond_timewait 将重新 获取互斥量,然后返回错误ETIMEDOUT。从pthread_cond_wait或者 pthread_cond_timedwait调用成功返回时,线程需要重新计算条件,因为 另一个线程可能已经在运行并改变了条件。
有两个函数可以用于通知线程条件已经满足。pthread_cond_signal函 数至少能唤醒一个等待该条件的线程,而pthread_cond_broadcast函数则 能唤醒等待该条件的所有线程。
POSIX 规范为了简化 pthread_cond_signal 的实现,允许它在实 现的时候唤醒一个以上的线程。
#include <pthread.h>
int pthread_cond_signal(pthread_cond_t *cond); int pthread_cond_broadcast(pthread_cond_t *cond);
两个函数的返回值:若成功,返回0;否则,返回错误编号 在调用pthread_cond_signal或者pthread_cond_broadcast时,我们说这
是在给线程或者条件发信号。必须注意,一定要在改变条件状态以后再 给线程发信号。
#include <iostream>
#include <pthread.h>
#include <vector>
#include <string>
struct msg {
msg *next;
std::string str;
};
msg *work_queue;
pthread_cond_t q_ready = PTHREAD_COND_INITIALIZER;
pthread_mutex_t q_lock = PTHREAD_MUTEX_INITIALIZER;
void handle_msg(void) {
msg *tmp;
while(true) {
pthread_mutex_lock(&q_lock);
while(work_queue == NULL) {
pthread_cond_wait(&q_ready, &q_lock);
}
tmp = work_queue;
work_queue = tmp->next;
pthread_mutex_unlock(&q_lock);
}
}
void enqueue_msg(msg * tmp) {
pthread_mutex_lock(&q_lock);
tmp->next = work_queue;
work_queue = tmp;
pthread_mutex_unlock(&q_lock);
pthread_cond_signal(&q_ready);
}
条件是工作队列的状态。我们用互斥量保护条件,在 while 循环中 判断条件。把消息放到工作队列时,需要占有互斥量,但在给等待线程 发信号时,不需要占有互斥量。只要线程在调用pthread_cond_signal之前 把消息从队列中拖出了,就可以在释放互斥量以后完成这部分工作。因 为我们是在 while 循环中检查条件,所以不存在这样的问题:线程醒 来,发现队列仍为空,然后返回继续等待。如果代码不能容忍这种竞 争,就需要在给线程发信号的时候占有互斥量。
11.6.7自旋锁
自旋锁与互斥量类似,但它不是通过休眠使进程阻塞,而是在获取 锁之前一直处于忙等(自旋)阻塞状态。自旋锁可用于以下情况:锁被 持有的时间短,而且线程并不希望在重新调度上花费太多的成本。
自旋锁通常作为底层原语用于实现其他类型的锁。根据它们所基于 的系统体系结构,可以通过使用测试并设置指令有效地实现。当然这里 说的有效也还是会导致CPU资源的浪费:当线程自旋等待锁变为可用 时,CPU不能做其他的事情。这也是自旋锁只能够被持有一小段时间的
原因。 当自旋锁用在非抢占式内核中时是非常有用的:除了提供互斥机制
以外,它们会阻塞中断,这样中断处理程序就不会让系统陷入死锁状 态,因为它需要获取已被加锁的自旋锁(把中断想成是另一种抢占)。 在这种类型的内核中,中断处理程序不能休眠,因此它们能用的同步原 语只能是自旋锁。
但是,在用户层,自旋锁并不是非常有用,除非运行在不允许抢占 的实时调度类中。运行在分时调度类中的用户层线程在两种情况下可以 被取消调度:当它们的时间片到期时,或者具有更高调度优先级的线程 就绪变成可运行时。在这些情况下,如果线程拥有自旋锁,它就会进入 休眠状态,阻塞在锁上的其他线程自旋的时间可能会比预期的时间更 长。
很多互斥量的实现非常高效,以至于应用程序采用互斥锁的性能与 曾经采用过自旋锁的性能基本是相同的。事实上,有些互斥量的实现在 试图获取互斥量的时候会自旋一小段时间,只有在自旋计数到达某一阈 值的时候才会休眠。这些因素,加上现代处理器的进步,使得上下文切 换越来越快,也使得自旋锁只在某些特定的情况下有用。
自旋锁的接口与互斥量的接口类似,这使得它可以比较容易地从一 个替换为另一个。可以用pthread_spin_init 函数对自旋锁进行初始化。用 pthread_spin_destroy 函数进行自旋锁的反初始化。
#include <pthread.h>
int pthread_spin_init(pthread_spinlock_t *lock, int pshared);
int pthread_spin_destroy(pthread_spinlock_t *lock);
两个函数的返回值:若成功,返回0;否则,返回错误编号 只有一个属性是自旋锁特有的,这个属性只在支持线程进程共享同
步(Thread Process-Shared Synchronization)选项(这个选项目前在SingleUNIX Specification中是强制的,见图2-5)的平台上才用得到。pshared 参数表示进程共享属性,表明自旋锁是如何获取的。如果它设为 PTHREAD_PROCESS_SHARED,则自旋锁能被可以访问锁底层内存的 线程所获取,即便那些线程属于不同的进程,情况也是如此。否则 pshared参数设为 PTHREAD_PROCESS_PRIVATE,自旋锁就只能被初 始化该锁的进程内部的线程所访问。
可以用pthread_spin_lock或pthread_spin_trylock对自旋锁进行加锁, 前者在获取锁之前一直自旋,后者如果不能获取锁,就立即返回EBUSY 错误。注意,pthread_spin_trylock不能自旋。不管以何种方式加锁,自 旋锁都可以调用pthread_spin_unlock函数解锁。
#include <pthread.h>
int pthread_spin_lock(pthread_spinlock_t *lock); int pthread_spin_trylock(pthread_spinlock_t *lock); int pthread_spin_unlock(pthread_spinlock_t *lock);
11.6.8 屏障
屏障(barrier)是用户协调多个线程并行工作的同步机制。屏障允 许每个线程等待,直到所有的合作线程都到达某一点,然后从该点继续 执行。我们已经看到一种屏障,pthread_join函数就是一种屏障,允许一 个线程等待,直到另一个线程退出。
但是屏障对象的概念更广,它们允许任意数量的线程等待,直到所 有的线程完成处理工作,而线程不需要退出。所有线程达到屏障后可以 接着工作。
可以使用 pthread_barrier_init 函数对屏障进行初始化,用 thread_barrier_destroy函数反初始化。
#include <pthread.h>
int pthread_barrier_init(pthread_barrier_t *restrict barrier,
const pthread_barrierattr_t *restrict attr,
unsigned int count);
int pthread_barrier_destroy(pthread_barrier_t *barrier);
初始化屏障时,可以使用count参数指定,在允许所有线程继续运
行之前,必须到达屏障的线程数目。使用attr参数指定屏障对象的属 性,我们会在下一章详细讨论。现在设置attr为NULL,用默认属性初始 化屏障。如果使用pthread_barrier_init函数为屏障分配资源,那么在反初 始化屏障时可以调用pthread_barrier_destroy函数释放相应的资源。
可以使用pthread_barrier_wait函数来表明,线程已完成工作,准备等 所有其他线程赶上来。
#include <pthread.h>
int pthread_barrier_wait(pthread_barrier_t *barrier);
返回值:若成功,返回0或者 PTHREAD_BARRIER_SERIAL_THREAD;否则,返回错误编号
调用pthread_barrier_wait的线程在屏障计数(调用pthread_barrier_init 时设定)未满足条件时,会进入休眠状态。如果该线程是最后一个调用 pthread_barrier_wait的线程,就满足了屏障计数,所有的线程都被唤醒。
对于一个任意线程,pthread_barrier_wait函数返回了 PTHREAD_BARRIER_SERIAL_THREAD。剩下的线程看到的返回值是 0。这使得一个线程可以作为主线程,它可以工作在其他所有线程已完 成的工作结果上。
一旦达到屏障计数值,而且线程处于非阻塞状态,屏障就可以被重 用。但是除非在调用了pthread_barrier_destroy函数之后,又调用了 pthread_barrier_init函数对计数用另外的数进行初始化,否则屏障计数不 会改变。