第12章--并发编程

到目前为止,我们主要将并发看做是一种操作系统内核用来运行多个应用程序的机制。但是,并发不仅仅局限于内核。它也可以在应用程序中扮演重要角色。例如,我们已经看到 Linux信号处理程序如何允许应用响应异步事件,例如用户键入 Ctrl+C,或者程序访问虚拟内存的一个未定义的区域。应用级并发在其他情况下也是很有用的:

  • 访问慢速 I/ 设备。
  • 与人交互。
  • 通过推迟工作以降低延迟。
  • 服务多个网络客户端。
  • 在多核机器上进行并行计算。

使用应用级并发的应用程序称为并发程序。现代操作系统提供了三种基本的构造并发程序的方法:

  • 进程。用这种方法,每个逻辑控制流都是一个进程,由内核来调度和维护。因为进程有独立的虚拟地址空间,想要和其他流通信,控制流必须使用某种显式的进程间通信(interprocess communication,IPC)机制。
  • I/0 多路复用。在这种形式的并发编程中,应用程序在一个进程的上下文中显式地调度它们自己的逻辑流。逻辑流被模型化为状态机,数据到达文件描述符后,主程序显式地从一个状态转换到另一个状态。因为程序是一个单独的进程,所以所有的流都共享同一个地址空间。
  • 线程。线程是运行在一个单一进程上下文中的逻辑流,由内核进行调度。你可以把线程看成是其他两种方式的混合体,像进程流一样由内核进行调度,而像 I/O 多路复用流一样共享同一个虚拟地址空间。

一、基于进程的并发编程

构造并发程序最简单的方法就是用进程,使用那些大家都很熟悉的函数,像 fork,exec和 waitpid。例如,一个构造并发服务器的自然方法就是,在父进程中接受客户端连接请求,然后创建一个新的子进程来为每个新客户端提供服务。

为了了解这是如何工作的,假设我们有两个客户端和一个服务器,服务器正在监听-个监听描述符(比如指述符 3)上的连接请求。现在假设服务器接受了客户端 1的连接请求,并返回一个已连接描述符(比如指述符 ),如图 12-1 所示。在接受连接请求之后,服务器派生一个子进程,这个子进程获得服务器描述符表的完整副本。子进程关闭它的副本中的监听描述符 3,而父进程关闭它的已连接描述符 4 的副本,因为不再需要这些描述符了这就得到了图 12-2 中的状态,其中子进程正忙于为客户端提供服务。

因为父、子进程中的已连接描述符都指向同一个文件表表项,所以父进程关闭它的已连接描述符的副本是至关重要的。否则,将永不会释放已连接描述符 4 的文件表条目,而且由此引起的内存泄漏将最终消耗光可用的内存,使系统崩溃。

现在,假设在父进程为客户端1创建了子进程之后,它接受一个新的客户端2 的连接请求,并返回一个新的已连接描述符(比如描述符 5),如图 12-3 所示。然后,父进程又派生另个子进程,这个子进程用已连接描述符 5 为它的客户端提供服务,如图 12-4 所示。此时父进程正在等待下一个连接请求,而两个子进程正在并发地为它们各自的客户端提供服务。

1. 基于进程的并发服务器

下图展示了一个基于进程的并发 echo 服务器的代码。关于这个服务器,有几点重要内容需要说明:

  • 首先,通常服务器会运行很长的时间,所以我们必须要包括一个 SIGCHLD 处理程序,来回收僵死(zombie)子进程的资源(第 4~9 行)。因为当 SIGCHLD处理程序执行时,SIGCHLD信号是阻塞的,而 Linux 信号是不排队的,所以 SIGCHLD处理程序必须准备好回收多个僵死子进程的资源。
  • 其次,父子进程必须关闭它们各自的 connfd(分别为第 33 行和第 30 行)副本。就像我们已经提到过的,这对父进程而言尤为重要,它必须关闭它的已连接描述符,以避免内存泄漏。
  • 最后,因为套接字的文件表表项中的引用计数,直到父子进程的 connfd 都关闭了到客户端的连接才会终止。

在这里插入图片描述

2. 进程的优劣

对于在父、子进程间共享状态信息,进程有一个非常清晰的模型:共享文件表,但是不共享用户地址空间。进程有独立的地址空间既是优点也是缺点。这样一来,一个进程不可能不小心覆盖另一个进程的虚拟内存,这就消除了许多令人迷惑的错误一-这是一个明显的优点。

另一方面,独立的地址空间使得进程共享状态信息变得更加困难。为了共享信息,它们必须使用显式的 IPC(进程间通信)机制。基于进程的设计的另一个缺点是,它们往往比较慢,因为进程控制和 IPC 的开销很高。

二、基于 I/O 多路复用的并发编程

假设要求你编写一个 echo 服务器,它也能对用户从标准输入键入的交命令做出响应。在这种情况下,服务器必须响应两个互相独立的 I/0 事件:1)网络客户端发起连接请求,2)用户在键盘上键入命令行。我们先等待哪个事件呢?没有哪个选择是理想的。如果在 accept 中等待一个连接请求,我们就不能响应输入的命令。类似地,如果在 read 中等待一个输人命令,我们就不能响应任何连接请求。

针对这种困境的一个解决办法就是 I/0多路复用(I/O multiplexing)技术。基本的思路就是使用 select 函数,要求内核挂起进程,只有在一个或多个 I/0 事件发生后,才将控制返回给应用程序。

select 是一个复杂的函数,有许多不同的使用场景。我们将只讨论第一种场景:等待一组描述符准备好读。

#include <sys/select.h>

int select(int n, fd_set *fdset, NULL, NULL, NULL); //返回已准备好的描述符的非零的个数,若出错则为-1。
FD_ZERO(fd_set *fdset);/* Clear all bits in fdset */
FD_CLR(int fd,fd set *fdset);/* Clear bit fd in fdset */
FD_SET(int fd,fd_set *fdset);/* Turn on bit fd in fdset */
FD_ISSET(int fd,fd_set *fdset);/* Is bit fd in fdset on? */

针对我们的目的,select 函数有两个输入:一个称为读集合的描述符集合(fdset)和该读集合的基数(n)(实际上是任何描述符集合的最大基数)。select 函数会一直阻塞直到读集合中至少有一个描述符准备好可以读。当且仅当一个从该描述符读取一个字节的请求不会阻塞时,描述符及就表示准备好可以读了。select 有一个副作用,它修改参数fdset 指向的 fd set,指明读集合的一个子集,称为准备好集合(ready set),这个集合是由读集合中准备好可以读了的描述符组成的。该函数返回的值指明了准备好集合的基数。注意,由于这个副作用,我们必须在每次调用 select 时都更新读集合。

1. 基于 I/O 多路复用的并发事件驱动服务器

I/O多路复用可以用做并发事件驱动(event-driven)程序的基础,在事件驱动程序中某些事件会导致流向前推进。一般的思路是将逻辑流模型化为状态机。不严格地说,一个状态机(state machine)就是一组状态(state)、输入事件(input event)和转移(transition),其中转移是将状态和输入事件映射到状态。每个转移是将一个(输入状态,输入事件)对映射到一个输出状态。自循环(self-loop)是同一输人和输出状态之间的转移。通常把状态机画成有向图,其中节点表示状态,有向弧表示转移,而弧上的标号表示输入事件。一个状态机从某种初始状态开始执行。每个输人事件都会引发一个从当前状态到下一状态的转移。

对于每个新的客户端k,基于 I/O 多路复用的并发服务器会创建一个新的状态机Sk,并将它和已连接描述符 dk联系起来。如图 12-7 所示,每个状态机s都有一个状态(“等待描述符 dk准备好可读”)、一个输人事件(“描述符 dk准备好可以读了”)和一个转移(“从描述符 dk读一个文本行”)。

服务器使用 I/O 多路复用,借助 select 函数检测输入事件的发生。当每个已连接描述符准备好可读时,服务器就为相应的状态机执行转移,在这里就是从描述符读和写回一个文本行。

2. I/O 多路复用技术的优劣

事件驱动设计的一个优点是,它比基于进程的设计给了程序员更多的对程序行为的控制。例如,我们可以设想编写一个事件驱动的并发服务器,为某些客户端提供它们需要的服务,而这对于基于进程的并发服务器来说,是很困难的。

另一个优点是,一个基于 I/0 多路复用的事件驱动服务器是运行在单一进程上下文中的,因此每个逻辑流都能访问该进程的全部地址空间。这使得在流之间共享数据变得很容易。一个与作为单个进程运行相关的优点是,你可以利用熟悉的调试工具,例如 GDB,来调试你的并发服务器,就像对顺序程序那样。最后,事件驱动设计常常比基于进程的设计要高效得多,因为它们不需要进程上下文切换来调度新的流。

事件驱动设计一个明显的缺点就是编码复杂。我们的事件驱动的并发 echo 服务器需要的代码比基于进程的服务器多三倍,并且很不幸,随着并发粒度的减小,复杂性还会上升。这里的粒度是指每个逻辑流每个时间片执行的指令数量。

三、基于线程的并发编程

线程(thread)就是运行在进程上下文中的逻辑流。在本书里迄今为止,程序都是由每个进程中一个线程组成的。但是现代系统也允许我们编写一个进程里同时运行多个线程的程序。线程由内核自动调度。每个线程都有它自己的线程上下文(thread context),包括一个唯一的整数线程 ID、栈、栈指针、程序计数器、通用目的寄存器和条件码。所有的运行在一个进程里的线程共享该进程的整个虚拟地址空间。

基于线程的逻辑流结合了基于进程和基于I/O 多路复用的流的特性。同进程一样,线程由内核自动调度,并且内核通过一个整数 ID来识别线程。同基于 I/0 多路复用的流-样,多个线程运行在单一进程的上下文中,因此共享这个进程虚拟地址空间的所有内容,包括它的代码、数据、堆、共享库和打开的文件。

1. 线程执行模型

多线程的执行模型在某些方面和多进程的执行模型是相似的。每个进程开始生命周期时都是单一线程,这个线程称为主线程(main thread)。在某一时刻,主线程创建一个对等线程(peer thread),从这个时间点开始,两个线程就并发地运行。最后,因为主线程执行一个慢速系统调用,例如 read 或者sleep,或者因为被系统的间隔计时器中断,控制就会通过上下文切换传递到对等线程。对等线程会执行一段时间,然后控制传递回主线程,依次类推。

在一些重要的方面,线程执行是不同于进程的。因为一个线程的上下文要比一个进程的上下文小得多,线程的上下文切换要比进程的上下文切换快得多。另一个不同就是线程不像进程那样,不是按照严格的父子层次来组织的。和一个进程相关的线程组成一个对等(线程)池,独立于其他线程创建的线程。主线程和其他线程的区别仅在于它总是进程中第个运行的线程。对等(线程)池概念的主要影响是,一个线程可以杀死它的任何对等线程,或者等待它的任意对等线程终止。另外,每个对等线程都能读写相同的共享数据。

2. 创建线程

线程通过调用 pthread create 函数来创建其他线程。pthread create 函数创建一个新的线程,并带着一个输人变量 arg,在新线程的上下文中运行线程例程 f。能用 attr 参数来改变新创建线程的默认属性。当pthread create返回时,参数 tid 包含新创建线程的ID。新线程可以通过调用pthread self 函数来获得它自己的线程 ID。

#include <pthread.h>
typedef void *(func)(void *);

int pthread_create(pthread_t *tid, pthread_attr_t *attr, func *f, void *arg); //若成功则返回 0,若出错则为非零。
pthread_t pthread_self(void); //返回调用者的线程 ID。

3. 终止线程

一个线程是以下列方式之一来终止的:

  • 当顶层的线程例程返回时,线程会隐式地终止。
  • 通过调用 pthread_exit 函数,线程会显式地终止。如果主线程调用 pthread_exit,它会等待所有其他对等线程终止,然后再终止主线程和整个进程,返回值为thread return。
  • 某个对等线程调用 Linux的 exit 函数,该函数终止进程以及所有与该进程相关的线程。
  • 另一个对等线程通过以当前线程 ID 作为参数调用 pthread cancel 函数来终止当前线程。
#include <pthread.h>

void pthread_exit(void *thread_return);
int pthread_cancel(pthread_t tid); //若成功则返回 0,若出错则为非零。

4. 回收已终止线程的资源

线程通过调用 pthread_join 函数等待其他线程终止。pthread_join 函数会阻塞,直到线程 tid终止,将线程例程返回的通用(void*)指针赋值为 thread_return 指向的位置,然后回收已终止线程占用的所有内存资源。注意,和 Linux的 wait 函数不同,pthread_join 函数能等待一个指定的线程终止。没有办法让 pthread_wait 等待任意一个线程终止。这使得代码更加复杂,因为它迫使我们去使用其他一些不那么直观的机制来检测进程的终止。

#include <pthread.h>

int pthread_join(pthread_t tid,void **thread_return); //若成功则返回 0,若出错则为非零。

5. 分离线程

在任何一个时间点上,线程是可结合的(joinable)或者是分离的(detached)。一个可结合的线程能够被其他线程收回和杀死。在被其他线程回收之前,它的内存资源(例如栈)是不释放的。相反,一个分离的线程是不能被其他线程回收或杀死的。它的内存资源在它终止时由系统自动释放。
默认情况下,线程被创建成可结合的。为了避免内存泄漏,每个可结合线程都应该要么被其他线程显式地收回,要么通过调用 pthread detach 函数被分离。pthread_detach 函数分离可结合线程 tid。线程能够通过以 pthread_self()为参数的 pthread_detach 调用来分离它们自己。

#include <pthread.h>

int pthread_detach(pthread_t tid); //若成功则返回 0,若出错则为非零。

6. 初始化线程

pthread_once函数允许你初始化与线程例程相关的状态。once_control变量是一个全局或者静态变量,总是被初始化为 PTHREAD_ONCEINIT。当你第一次用参数 once_control 调用 pthread_once 时,它调用 init_routine,这是一个没有输入参数、也不返回什么的函数。接下来的以 once_control 为参数的 pthread_once 调用不做任何事情。无论何时,当你需要动态初始化多个线程共享的全局变量时,pthread_once 函数是很有用的。

#include <pthread.h>

pthread_once_t once_control = PTHREAD_ONCE_INIT
int pthread_once(pthread_once_t *once_controlvoid (*init_routine)(void)); //总是返回0。

7. 基于线程的并发服务器

下图展示了基于线程的并发 echo 服务器的代码。整体结构类似于基于进程的设计。主线程不断地等待连接请求,然后创建一个对等线程处理该请求。

在这里插入图片描述

四、多线程程序中的共享变量

从程序员的角度来看,线程很有吸引力的一个方面是多个线程很容易共享相同的程序变量。然而,这种共享也是很棘手的。为了编写正确的多线程程序,我们必须对所谓的共享以及它是如何工作的有很清楚的了解。

为了理解 C程序中的一个变量是否是共享的,有一些基本的问题要解答:1)线程的基础内存模型是什么? 2)根据这个模型,变量实例是如何映射到内存的? 3)最后,有多少线程引用这些实例?一个变量是共享的,当且仅当多个线程引用这个变量的某个实例。

1. 线程内存模型

一组并发线程运行在一个进程的上下文中。每个线程都有它自己独立的线程上下文包括线程ID、栈、栈指针、程序计数器、条件码和通用目的寄存器值。每个线程和其他线程一起共享进程上下文的剩余部分。这包括整个用户虚拟地址空间,它是由只读文本(代码)、读/写数据、堆以及所有的共享库代码和数据区域组成的。线程也共享相同的打开文件的集合。

从实际操作的角度来说,让一个线程去读或写另一个线程的寄存器值是不可能的。另一方面,任何线程都可以访问共享虚拟内存的任意位置。如果某个线程修改了一个内存位置,那么其他每个线程最终都能在它读这个位置时发现这个变化。因此,寄存器是从不共享的,而虚拟内存总是共享的。

各自独立的线程栈的内存模型不是那么整齐清楚的。这些栈被保存在虚拟地址空间的栈区域中,并且通常是被相应的线程独立地访问的。我们说通常而不是总是,是因为不后的线程栈是不对其他线程设防的。所以,如果一个线程以某种方式得到一个指向其他线砖栈的指针,那么它就可以读写这个栈的任何部分。

2. 将变量映射到内存

多线程的 C程序中变量根据它们的存储类型被映射到虚拟内存:

  • 全局变量。全局变量是定义在函数之外的变量。在运行时,虚拟内存的读/写区域只包含每个全局变量的一个实例,任何线程都可以引用。
  • 本地自动变量。本地自动变量就是定义在函数内部但是没有 static 属性的变量。在运行时,每个线程的栈都包含它自己的所有本地自动变量的实例。即使多个线程执行同一个线程例程时也是如此。
  • 本地静态变量。本地静态变量是定义在函数内部并有 static 属性的变量。和全局变量一样,虚拟内存的读/写区域只包含在程序中声明的每个本地静态变量的一个实例。

五、用信号量同步线程

共享变量是十分方便,但是它们也引入了同步错误(synchronization error)的可能性。一般而言,你没有办法预测操作系统是否将为你的线程选择一个正确的顺序。

1. 进度图

进度图(progress graph)将 n 个并发线程的执行模型化为一条n 维卡儿空间中的轨迹线。每条轴k对应于线程k的进度。每个点代表线程k已经完成了指令I 这一状态。图的原点对应于没有任何线程完成一条指令的初始状态。

进度图将指令执行模型化为从一种状态到另一种状态的转换(transition)。转换被表示为一条从一点到相邻点的有向边。合法的转换是向右(线程 1中的一条指令完成)或者向上(线程2中的一条指令完成)的。两条指令不能在同一时刻完成一对角线转换是不允许的。程序决不会反向运行,所以向下或者向左移动的转换也是不合法的。

对于线程i,操作共享变量 cnt 内容的指令(Li,Ui,Si)构成了一个(关于共享变量cnt 的)临界区(critical section),这个临界区不应该和其他进程的临界区交替执行。换句话说,我们想要确保每个线程在执行它的临界区中的指令时,拥有对共享变量的互斥的访问(mutually exclusive access)。通常这种现象称为互斥(mutualexclusion)。

在进度图中,两个临界区的交集形成的状态空间区域称为不安全区(unsafe region)。图 12-21 展示了变量 cnt 的不安全区。注意,不安全区和与它交界的状态相毗邻,但并不包括这些状态。例如,状态(H1,H2)和(S1,U2)毗邻不安全区,但是它们并不是不安全区的一部分。绕开不安全区的轨迹线叫做安全轨迹线(safe trajectory)。相反,接触到任何不安全区的轨迹线就叫做不安全轨迹线(unsafe trajectory)。图 12-21 给出了示例程序badcnt.c 的状态空间中的安全和不安全轨迹线。上面的轨迹线绕开了不安全区域的左边和上边,所以是安全的。下面的轨迹线穿越不安全区,因此是不安全的。

在这里插入图片描述

2. 信号量

Edsger Dikstra,并发编程领域的先锋人物,提出了一种经典的解决同步不同执行线程问题的方法,这种方法是基于一种叫做信号量(semaphore)的特殊类型变量的。信号量是具有非负整数值的全局变量,只能由两种特殊的操作来处理,这两种操作称为 P和V:

  • P(s):如果s是非零的,那么P将 减1,并且立即返回。如果s为零,那么就持起这个线程,直到s变为非零,而一个V操作会重启这个线程。在重启之后,P 操
    作将s减 1,并将控制返回给调用者。
  • V(s):V操作将s加1。如果有任何线程阻塞在 P操作等待s变成非零,那么V操作会重启这些线程中的一个,然后该线程将s减 1,完成它的 P 操作。

P 中的测试和减 1操作是不可分割的,也就是说,一旦预测信号量 s 变为非零,就会将减 1,不能有中断。V 中的加 1 操作也是不可分割的,也就是加载、加 1和存储信号量的过程中没有中断。注意,V 的定义中没有定义等待线程被重启动的顺序。唯一的要求是V 必须只能重启一个正在等待的线程。因此,当有多个线程在等待同一个信号量时,你不能预测 V操作要重启哪一个线程。

Posix标准定义了许多操作信号量的函数。

#include <semaphore.h>

int sem_init(sem_t *sem,0,unsigned int value);/* P(s) */
int sem_wait(sem_t *s);/* V(s) */
int sem_post(sem_t *s); //返回:若成功则为0,若出错则为-1。

3. 使用信号量来实现互斥

信号量提供了一种很方便的方法来确保对共享变量的互斥访问。基本思想是将每个共享变量(或者一组相关的共享变量)与一个信号量 s(初始为 1)联系起来,然后用 P(s)和 V(s)操作将相应的临界区包围起来。

以这种方式来保护共享变量的信号量叫做二元信号量(binary semaphore),因为它的值总是0或者 1。以提供斥为目的的二元信号量常常也称为互斥锁(mutex)。在一个互斥锁上执行 P 操作称为对互斥锁加锁。类似地,执行 V 操作称为对互斥锁解锁。对一个瓦斥锁加了锁但是还没有解锁的线程称为占用这个瓦斥锁。一个被用作一组可用资源的计数器的信号量被称为计数信号量。

4. 利用信号量来调度共享资源

除了提供互斥之外,信号量的另一个重要作用是调度对共享资源的访问。在这种场景中,一个线程用信号量操作来通知另一个线程,程序状态中的某个条件已经为真了。两个经典而有用的例子是生产者-消费者和读者-写者问题。

1)生产者-消费者问题

生产者和消费者线程共享一个有 n 个槽的有限缓冲区。生产者线程反复地生成新的项目(item),并把它们插入到缓冲区中。消费者线程不断地从缓冲区中取出这些项目,然后消费(使用)它们。也可能有多个生产者和消费者的变种。

因为插入和取出项目都涉及更新共享变量,所以我们必须保证对缓冲区的访问是互斥的。但是只保证互斥访问是不够的,我们还需要调度对缓冲区的访问。如果缓冲区是满的(没有空的槽位),那么生产者必须等待直到有一个槽位变为可用。与之相似,如果缓冲区是空的(没有可取用的项目),那么消费者必须等待直到有一个项目变为可用。

生产者-消费者的相互作用在现实系统中是很普遍的。例如,在一个多媒体系统中,生产者编码视频帧,而消费者解码并在屏幕上呈现出来。缓冲区的目的是为了减少视频流的抖动,而这种抖动是由各个帧的编码和解码时与数据相关的差异引起的。缓冲区为生产者提供了一个槽位池,而为消费者提供一个已编码的帧池。另一个常见的示例是图形用户接口设计。生产者检测到鼠标和键盘事件,并将它们插入到缓冲区中。消费者以某种基于
优先级的方式从缓冲区取出这些事件,并显示在屏幕上。

2)读者-写者问题

读者-写者问题是互斥问题的一个概括。一组并发的线程要访问一个共享对象,例如一个主存中的数据结构,或者一个磁盘上的数据库。有些线程只读对象,而其他的线程只修改对象。修改对象的线程叫做写者。只读对象的线程叫做读者。写者必须拥有对对象的独占的访问,而读者可以和无限多个其他的读者共享对象。一般来说,有无限多个并发的读者和写者。

读者-写者交互在现实系统中很常见。例如,一个在线航空预定系统中,允许有无限多个客户同时查看座位分配,但是正在预订座位的客户必须拥有对数据库的独占的访问。再来看另一个例子,在一个多线程缓存 Web 代理中,无限多个线程可以从共享页面缓存中取出已有的页面,但是任何向缓存中写人一个新页面的线程必须拥有独占的访问。

六、使用线程提高并行性

到目前为止,在对并发的研究中,我们都假设并发线程是在单处理器系统上执行的。然而,大多数现代机器具有多核处理器。并发程序通常在这样的机器上运行得更快,因为操作系统内核在多个核上并行地调度这些并发线程,而不是在单个核上顺序地调度。在像繁忙的 Web 服务器、数据库服务器和大型科学计算代码这样的应用中利用这样的并行性是至关重要的,而且在像 Web 浏览器、电子表格处理程序和文档处理程序这样的主流应用中,并行性也变得越来越有用。

并行程序的详细处理超出了本书讲述的范围,但是研究一个非常简单的示例程序能够帮助你理解并行编程的一些重要的方面。例如,考虑我们如何并行地对一列整数 0,…,n-1求和。当然,对于这个特殊的问题,有闭合形式表达式的解答(译者注:即有现成的公式来计算它,即和等于 n(n-1)/2),但是尽管如此,它是一个简洁和易于理解的示例,能让我们对并行程序做一些有趣的说明。

我们在一个四核系统上,对一个大小为 n=2”的序列运行 psum- mutex,测量它的运行时间(以秒为单位),作为线程数的函数,得到的结果难懂又令人奇怪。

程序单线程顺序运行时非常慢,几乎比多线程并行运行时慢了一个数量级。不仅如此,使用的核数越多,性能越差。造成性能差的原因是相对于内存更新操作的开销,同步操作(P 和V)代价太大。这突显了并行编程的一项重要教训:同步开销巨大,要尽可能避免。如果无可避免,必须要用尽可能多的有用计算弥补这个开销。在我们的例子中,一种避免同步的方法是让每个对等线程在一个私有变量中计算它自己的部分和,这个私有变量不与其他任何线程共享,如图 12-33 所示。主线程(图中未显示)定义一个全局数组 psum,每个对等线程把它的部分和累积在 psum[i]中。因为小心地给了每个对等线程一个不同的内存位置来更新,所以不需要用互斥锁来保护这些更新。唯一需要同步的地方是主线程必须等待所有的子线程完成。在对等线程结束后,主线程把psum向量的元素加起来,得到最终的结果。

在四核系统上运行 psum-array 时,我们看到它比 psum-mutex 运行得快好几个数量级。

在第 5 章中,我们学习到了如何使用局部变量来消除不必要的内存引用。图 12-34 展示了如何应用这项原则,让每个对等线程把它的部分和累积在一个局部变量而不是全局变量中。当在四核机器上运行 psum-local 时,得到一组新的递减的运行时间。

在这里插入图片描述

七、其他并发问题

1. 线程安全

当用线程编写程序时,必须小心地编写那些具有称为线程安全性(thread safety)属性的函数。一个函数被称为线程安全的(thread-safe),当且仅当被多个并发线程反复地调用时,它会一直产生正确的结果。如果一个函数不是线程安全的,我们就说它是线程不安全的(thread-unsafe)。

我们能够定义出四个(不相交的)线程不安全函数类:

  • 不保护共享变量的函数。我们在图 12-16 的 thread 函数中就已经遇到了这样的问题,该函数对一个未受保护的全局计数器变量加 1。将这类线程不安全函数变成线程安全的,相对而言比较容易:利用像 P 和V操作这样的同步操作来保护共享的变量。这个方法的优点是在调用程序中不需要做任何修改。缺点是同步操作将减慢程序的执行时间。
  • 保持跨越多个调用的状态的函数。一个伪随机数生成器是这类线程不安全函数的简单例子。请参考图 12-37 中的伪随机数生成器程序包。rand 函数是线程不安全的,因为当前调用的结果依赖于前次调用的中间结果。当调用 srand 为 rand 设置了一个种子后,我们从一个单线程中反复地调用 rand,能够预期得到一个可重复的随机数字序列然而,如果多线程调用 rand 函数,这种假设就不再成立了。
  • 返回指向静态变量的指针的函数。某些函数,例如 ctime 和 gethostbyname,将计算结果放在一个 static 变量中,然后返回一个指向这个变量的指针。如果我们从并发线程中调用这些函数,那么将可能发生灾难,因为正在被一个线程使用的结果会被另一个线程悄悄地覆盖了。
  • 调用线程不安全函数的函数。如果函数调用线程不安全函数 g,那么就是线程不安全的吗?不一定。如果 g 是第 2 类函数,即依赖于跨越多次调用的状态,那么f也是线程不安全的,而且除了重写 g 以外,没有什么办法。然而,如果 g 是第 1类或者第3类函数,那么只要你用一个互斥锁保护调用位置和任何得到的共享数据,广仍然可能是线程安全的。

2. 可重入性

有一类重要的线程安全函数,叫做可重入函数(reentrant function),其特点在于它们具有这样一种属性:当它们被多个线程调用时,不会引用任何共享数据。所有函数的集合被划分成不相交的线程安全和线程不安全函数集合。可重入函数集合是线程安全函数的一个真子集。

3. 竞争

当一个程序的正确性依赖于一个线程要在另一个线程到达y点之前到达它的控制流中的点时,就会发生竞争(race)。通常发生竞争是因为程序员假定线程将按照某种特殊的轨迹线穿过执行状态空间,而忘记了另一条准则规定:多线程的程序必须对任何可行的轨迹线都正确工作。

4. 死锁

信号量引入了一种潜在的令人厌恶的运行时错误,叫做死锁(deadlock),它指的是一组线程被阻塞了,等待一个永远也不会为真的条件。进度图对于理解死锁是一个无价的工具。例如,图 12-44 展示了一对用两个信号量来实现互斥的线程的进程图。从这幅图中我们能够得到一些关于死锁的重要知识:

  • 程序员使用 P 和V操作顺序不当,以至于两个信号量的禁止区域重叠。如果某个执行轨迹线碰巧到达了死锁状态 d,那么就不可能有进一步的进展了,因为重叠的禁止区域阻塞了每个合法方向上的进展。换句话说,程序死锁是因为每个线程都在等待其他线程执行一个根不可能发生的 V 操作。
  • 重叠的禁止区域引起了一组称为死锁区域(deadlock region)的状态。如果一个轨迹线碰巧到达了一个死锁区域中的状态,那么死锁就是不可避免的了。轨迹线可以进入死锁区域,但是它们不可能离开。
  • 死锁是一个相当困难的问题,因为它不总是可预测的。一些幸运的执行轨迹线将绕开死锁区域,而其他的将会陷入这个区域。

在这里插入图片描述

程序死锁有很多原因,要避免死锁一般而言是很困难的。然而,当使用二元信号量来实现互斥时,如图 12-44 所示,你可以应用下面的简单而有效的规则来避免死锁: 给定所有互斥操作的一个全序,如果每个线程都是以一种顺序获得互斥锁并以相反的顺序释放,那么这个程序就是无死锁的。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值