12.5 同步

在上一节中,我们看到两个线程同时执行的情况,但我们采用的在它们之间进行切换的方法是非常笨拙且没有效率的。幸运的是,专门有一组设计好的函数为我们提供了更好的控制线程执行和访问代码临界区域的方法。

我们将在本节学习两种基本的方法。一种是信号量,它的作用如同看守一段代码的看门人;另一种是互斥量,它的作用如同保护代码段的一个互斥设备。这两种方法很相似,事实上,它们可以互相通过对方来实现。但在实际应用中,对于一些情况,可能使用信号量或互斥量中的一个更符合问题的语义,并且效果更好。例如,如果想控制任一时刻只能有一个线程可以访问一些共享内存,使用互斥量就要自然得多。但在控制对一组相同对象的访问时——比如从5条可用的电话线中分配1条给某个线程的情况,就更适合使用计数信号量。具体选择哪种方法取决于个人偏好和相应的程序机制。

12.5.1 用信号量进行同步

有两组接口函数用于信号量。一组取自POSIX的实时扩展,用于线程。另一组被称为系统V信号量,常用于进程的同步(我们将在第14章介绍第二组接口函数)。这两组接口函数虽然很相近,但并不保证它们之间可以互换,而且它们使用的函数调用也各不相同。

荷兰计算机科学家Dijkstra首先提出了信号量的概念。信号量是一个特殊类型的变量,它可以被增加或减少,但对其的关键访问被保证是原子操作,即使在一个多线程程序中也是如此。这意味着如果一个程序中有两个(或更多)的线程试图改变一个信号量的值,系统将保证所有的操作都将依次进行。但如果是普通变量,来自同一程序中的不同线程的冲突操作所导致的结果将是不确定的。

在本节中,我们将介绍一种最简单的信号量——二进制信号量,它只有0和1两种取值。还有一种更通用的信号量——计数信号量,它可以有更大的取值范围。信号量一般常用来保护一段代码,使其每次只能被一个执行线程运行,要完成这个工作,就要使用二进制信号量。有时,我们希望可以允许有限数目的线程执行一段指定的代码,这就需要用到计数信号量。由于计数信号量并不常用,所以我们在这里不对它进行深入的介绍,实际上它仅仅是二进制信号量的一种逻辑扩展,两者实际调用的函数都一样。

信号量函数的名字都以sem开头,而不像大多数线程函数那样以pthread开头。线程中使用的基本信号量函数有4个,它们都非常的简单。

信号量通过sem_init函数创建,它的定义如下所示:

12.5 同步 - 图1

这个函数初始化由sem指向的信号量对象,设置它的共享选项(我们马上就会介绍到它),并给它一个初始的整数值。pshared参数控制信号量的类型,如果其值为0,就表示这个信号量是当前进程的局部信号量,否则,这个信号量就可以在多个进程之间共享。我们在这里只对不能在进程间共享的信号量感兴趣。在编写本书时,Linux还不支持这种共享,给pshared参数传递一个非零值将导致调用失败。

接下来的两个函数控制信号量的值,它们的定义如下所示:

12.5 同步 - 图2

这两个函数都以一个指针为参数,该指针指向的对象是由sem_init调用初始化的信号量。

sem_post函数的作用是以原子操作的方式给信号量的值加1。所谓原子操作是指,如果两个线程企图同时给一个信号量加1,它们之间不会互相干扰,而不像如果两个程序同时对同一个文件进行读取、增加、写入操作时可能会引起冲突。信号量的值总是会被正确地加2,因为有两个线程试图改变它。

sem_wait函数以原子操作的方式将信号量的值减1,但它会等待直到信号量有个非零值才会开始减法操作。因此,如果对值为2的信号量调用sem_wait,线程将继续执行,但信号量的值会减到1。如果对值为0的信号量调用sem_wait,这个函数就会等待,直到有其他线程增加了该信号量的值使其不再是0为止。如果两个线程同时在sem_wait调用上等待同一个信号量变为非零值,那么当该信号量被第三个线程增加1时,只有其中一个等待线程将开始对信号量减1,然后继续执行,另外一个线程还将继续等待。信号量的这种“在单个函数中就能原子化地进行测试和设置”的能力使其变得非常有价值。

还有另外一个信号量函数sem_trywait,它是sem_wait的非阻塞版本。我们不在这里对它做更多的介绍,更详细的资料可以参考它的手册页。

最后一个信号量函数是sem_destroy。这个函数的作用是,用完信号量后对它进行清理。它的定义如下:

12.5 同步 - 图3

与前几个函数一样,这个函数也以一个信号量指针为参数,并清理该信号量拥有的所有资源。如果企图清理的信号量正被一些线程等待,就会收到一个错误。

与大多数Linux函数一样,这些函数在成功时都返回0。

实 验 一个线程信号量

这个程序thread3.c也是基于thread1.c的。因为改动的地方比较多,所以我们将其完整代码列在下面。

12.5 同步 - 图4

12.5 同步 - 图5

第一个重要的改动是包含了头文件semaphore.h,它使我们可以访问信号量函数。然后,定义一个信号量和几个变量,并在创建新线程之前对信号量进行初始化。如下所示:

12.5 同步 - 图6

注意,我们将这个信号量的初始值设置为0。

在main函数中,启动新线程后,我们从键盘读取一些文本并把它们放到工作区work_area数组中,然后调用sem_post增加信号量的值。如下所示:

12.5 同步 - 图7

在新线程中,我们等待信号量,然后统计来自输入的字符个数。如下所示:

12.5 同步 - 图8

设置信号量的同时,我们等待着键盘的输入。当输入到达时,我们释放信号量,允许第二个线程在第一个线程再次读取键盘输入之前统计出输入字符的个数。

这两个线程共享同一个work_area数组。为了让示例代码更加简洁并容易理解,我们还省略了一些错误检查。例如,没有检查sem_wait函数的返回值。但在产品代码中,除非有特别充足的理由才省略错误检查,否则我们总是应该检查函数的返回值。

运行这个程序:

12.5 同步 - 图9

在线程程序中,时序错误查找起来总是特别困难,但这个程序似乎对快速的文本输入和悠闲的暂停都很适应。

实验解析

初始化信号量时,我们把它的值设置为0。这样,在线程函数启动时,sem_wait函数调用就会阻塞并等待信号量变为非零值。

在主线程中,我们等待直到有文本输入,然后调用sem_post增加信号量的值,这将立刻令另一个线程从sem_wait的等待中返回并开始执行。在统计完字符个数之后,它再次调用sem_wait并再次被阻塞,直到主线程再次调用sem_post增加信号量的值为止。

我们很容易忽略程序设计上的细微错误,而该错误会导致程序运行结果中的一些细微错误。我们将上面的程序稍加修改并另存为thread3a.c。它偶尔会将来自键盘的输入用事先准备好的文本自动替换掉。我们把main函数中的读数据循环修改为:

12.5 同步 - 图10

12.5 同步 - 图11

现在,如果输入FAST,程序就会调用sem_post使字符统计线程开始运行,同时立刻用其他数据更新work_area数组。程序运行情况如下所示:

12.5 同步 - 图12

问题在于,我们的程序依赖其接收文本输入的时间要足够长,这样另一个线程才有时间在主线程还未准备好给它更多的单词去统计之前统计出工作区中字符的个数。当我们试图连续快速地给它两组不同的单词去统计时(键盘输入的FAST和程序自动提供的Weeee…),第二个线程就没有时间去执行。但信号量已被增加了不止一次,所以字符统计线程就会反复统计字符数目并减少信号量的值,直到它再次变为0为止。

这个例子显示:在多线程程序中,我们需要对时序考虑得非常仔细。为了解决上面程序中的问题,我们可以再增加一个信号量,让主线程等到统计线程完成字符个数的统计后再继续执行,但更简单的一种方式是使用互斥量。

12.5.2 用互斥量进行同步

另一种用在多线程程序中的同步访问方法是使用互斥量。它允许程序员锁住某个对象,使得每次只能有一个线程访问它。为了控制对关键代码的访问,必须在进入这段代码之前锁住一个互斥量,然后在完成操作之后解锁它。

用于互斥量的基本函数和用于信号量的函数非常相似,它们的定义如下所示:

12.5 同步 - 图13

与其他函数一样,成功时返回0,失败时将返回错误代码,但这些函数并不设置errno,你必须对函数的返回代码进行检查。

与信号量类似,这些函数的参数都是一个先前声明过的对象的指针。对互斥量来说,这个对象的类型为pthread_mutex_t。pthread_mutex_init函数中的属性参数允许我们设置互斥量的属性,而属性控制着互斥量的行为。属性类型默认为fast,但它有一个小缺点:如果程序试图对一个已经加了锁的互斥量调用pthread_mutex_lock,程序就会被阻塞,而又因为拥有互斥量的这个线程正是现在被阻塞的线程,所以互斥量就永远也不会被解锁了,程序也就进入死锁状态。这个问题可以通过改变互斥量的属性来解决,我们可以让它检查这种情况并返回一个错误,或者让它递归的操作,给同一个线程加上多个锁,但必须注意在后面执行同等数量的解锁操作。

设置互斥量的属性超出了本书的讨论范围,所以我们将传递NULL给属性指针,从而使用其默认行为。与改变属性相关的更详细的资料可以参考pthread_mutex_init的手册页。

实 验 线程互斥量

这个程序也基于原先的thread1.c,但改动的地方比较多。这次,假设需要保护对一些关键变量的访问,我们用一个互斥量来保证任一时刻只能有一个线程访问它们。为了让示例代码容易阅读,我们省略了对互斥量加锁和解锁调用的返回值应该进行的一些错误检查。在软件代码中,对返回值的检查是必不可少的。下面是新程序thread4.c的代码:

12.5 同步 - 图14

12.5 同步 - 图15

实验解析

在程序的开始,我们声明了一个互斥量、工作区和一个变量time_to_exit。如下所示:

12.5 同步 - 图16

然后初始化互斥量,如下所示:

12.5 同步 - 图17

接下来启动新线程。下面是在线程函数中执行的代码:

12.5 同步 - 图18

新线程首先试图对互斥量加锁。如果它已经被锁住,这个调用将被阻塞直到它被释放为止。一旦获得访问权,我们就检查是否有申请退出程序的请求。如果有,就设置time_to_exit变量,再把工作区的第一个字符设置为\0,然后退出。

如果不想退出,就统计字符个数,然后把work_area数组中的第一个字符设置为null。我们用将第一个字符设置为null的方法通知读取输入的线程,我们已完成了字符统计。然后解锁互斥量并等待主线程继续运行。我们将周期性地尝试给互斥量加锁,如果加锁成功,就检查是否主线程又有字符送来要处理。如果还没有,就解锁互斥量继续等待;如果有,就统计字符个数并再次进入循环。

下面是主线程的代码:

12.5 同步 - 图19

12.5 同步 - 图20

这段代码和上面新线程中的很类似。我们首先给工作区加锁,读入文本到它里面,然后解锁以允许其他线程访问它并统计字符数目。我们周期性地对互斥量再加锁,检查字符数目是否已统计完(work_area[0]被设置为null)。如果还需要等待,就释放互斥量。如前所述,这种通过轮询来获得结果的方法通常并不是好的编程方式。在实际的编程中,我们应该尽可能用信号量来避免出现这种情况。这里的代码只是用作示例而已。