第12章 并发编程(深入理解计算机系统)
现在操作系统提供了三种基本的构造并发程序的方法:
- 进程。每个逻辑控制流都是一个进程,由内核来调度和维护。因为进程有独立的虚拟地址空间,想要和其他流通信,控制流必须使用某种显示的进程间通信(interprocess communication,IPC)机制。
- I/O多路复用。应用程序在一个进程的上下文中显式地调度他们自己的逻辑流。逻辑流被模型化为状态机,数据到达文件描述符之后,主程序显式地从一个状态转换到另一个状态。因为程序是一个单独的进程,所以所有的流都共享同一个地址空间。
- 线程。线程是运行在一个单一进程上下文中的逻辑流,由内核进行调度。可以把线程看作其他两种方式的混合体,像进程一样由内核进行调度,而像I/O多路复用流一样共享同一个虚拟地址空间。
12.1 基于进程的并发编程
构造并发程序最简单的方法就是用进程,使用那些大家都很熟悉的函数,像fork、exec、和waitpid。例如,一个构造并发服务器的自然方法就是,在父进程中接受客户端连接请求,然后创建一个新的子进程来为每个新客户端提供服务。
在父、子进程之间共享状态信息,进程有一个非常清晰的模型:共享文件表,但是不共享用户地址空间。进程有独立的地址空间既是优点也是缺点。一个进程不可能不小心覆盖另一个进程的虚拟存储器,这就消除了许多令人迷惑的错误。另一方面,独立的地址空间使得进程共享状态信息变得更加困难。为了共享信息,它们必须使用显式的IPC(进程间通信)机制。基于进程的设计的另一个缺点是,它们往往比较慢,因为进程控制和IPC的开销很高。
属于Unix IPC通常指的是所有允许进程和同一台主机上的其他进程进程通信的技术。其中包括管道、先进先出(FIFO)、系统V共享存储器,以及系统V信号量(semaphore)。
12.2 基于I/O多路复用的并发编程
加入要求你编写一个echo服务器,它也能对用户从标准输入键入的交互命令做出响应。在这种情况下,服务器必须响应两个相互独立的I/O事件:1)网络客户端发送连接请求,2)用户在键盘上键入命令行。如果在accept中等待一个连接请求,就不能响应输入的命令。如果再read中等待一个用户输入,就不能响应任何连接请求。
一个解决版本就是I/O多路复用(I/O multiplexing)技术。基本思想是使用select函数,要求内核挂起进程,只有在一个或多个I/O事件发生后,才将控制返回给应用程序。
它比基于进程的设计给了程序员更多的对程序行为的控制;另一个优点是,一个基于I/O多路复用的时间驱动服务器是运行在单一进程上下文中的,因此每个逻辑流都能访问该进程的全部地址空间,使得流之间共享数据变得很容易。最后,事件驱动设计常常比基于进程的设计要高效得多,因为它们不需要进程上下文切换来调度新的流。
事件驱动设计的一个明显的缺点就是编码复杂。随着并发粒度的减少,复杂性还会上升。另一个重大缺点是它们不能充分利用多核处理器。
12.3 基于线程的并发编程
线程(thread)就是运行在进程上下文中的逻辑流。线程由内核自动调度,并通过一个整数ID来识别线程。每个线程都有它自己的线程上下文(thread context),包括一个唯一的整数线程ID(Thread ID,TID)、栈、栈指针、程序计数器、通用目的寄存器和条件码。所有的运行在一个进程中的线程共享该进程的整个虚拟地址空间。
12.3.1 线程执行模型
每个进程开始生命周期时都是单一线程,这个线程称为主线程(main thread)。在某一时刻,主线程创建一个对等线程(peer thread),两个线程开始并发地运行。最后,因主线程执行一个慢速系统调用,如read或sleep,或者因为它被系统的间隔计时器中断,控制就会通过上下文切换传递到对等线程。对等线程执行一段时间,然后控制传递回主线程……。
和一个进程相关的线程组成一个对等(线程)池(pool),独立于其他线程创建的线程。主线程和其他线程的区别仅在于它总是进程中第一个运行的线程。对等池概念的主要影响是,一个线程可以杀死它的任何对等线程,或者等待它的任何对等线程终止。每个对等线程都能读写相同的共享数据。
12.3.2 Posix线程
Posix线程(Pthreads)是在C程序中处理线程的一个标准接口。Pthreads大约定义了60个函数,允许程序创建、杀死和回收线程,与对等线程安全地共享数据,还可以通知对等线程系统状态的变化。
#include "csapp.h" void *thread(void *vargp); int main() { pthread_t tid; pthread_create(&tid, NULL, thread, NULL); pthread_join(tid, NULL); exit(0); } void *thread(void *vargp) /*Thread Routine*/ { printf("hello, world!\n"); return NULL; }
12.3.3 创建线程
线程通过调用pthread_create函数来创建其他线程。
#include <pthread.h> typedef void *(func)(void *); //返回:若成功返回0,否则为非零 int pthread_create(pthread_t *tid, pthread_attr_t *attr, func *f, void *arg);
pthread_create函数创建一个新的线程,并带着一个输入变量arg,在线程的上下文中运行线程例程f。可以用attr参数来改变新创建线程的默认属性。当pthread_create返回时,参数tid包含新创建线程的ID。新线程可以通过调用pthread_self函数来获取它自己的线程ID。
#include <pthread.h>
//返回:返回调用者的线程ID pthread_t pthread_self(void);
12.3.4 终止线程
一个线程是以以下方式之一来终止的:
- 当顶层的线程例程返回时,线程会隐式地终止。
- 通过调用pthread_exit函数,线程会显示地终止。如果主线程调用pthread_exit,它会等所有其他对等线程终止,然后再终止主线程和整个进程,返回值thread_return。
#include <pthread.h>
//返回:若成功返回0,否则为非零
void pthread_exit(void *thread_return);
- 某个对等线程调用Unix的exit函数,该函数终止进程以及所有与该进程相关的线程。
- 另一个对等线程通过以当前线程ID座位参数调用pthread_cancle函数来终止当前线程。
#include <pthread.h>
//返回:若成功返回0,否则为非零 int pthread_cancle(pthread_t tid);
12.3.5 回收已终止线程的资源
线程通过调用pthread_join函数等待其他线程终止。
#include <pthread.h> //返回:若成功返回0,否则为非零 int pthread_join(pthread_t tid, void **thread_return);
pthread_join函数会阻塞,直到线程tid终止,将线程例程返回的(void*)指针赋值为thread_return指向的位置,然后回收已终止线程占用的所有存储器资源。
和Unix的wait函数不同,pthread_join函数只能等待一个指定的线程终止,没有办法让pthread_join等待任意一个线程终止。
12.3.6 分离线程
在任何一个时间点上,线程是可结合的(joinable)或者是分离的(detached)。一个可结合的线程能够被其他线程收回其资源和杀死。在被其他线程回收之前,它的存储器资源(例如栈)是没有被释放的。相反,一个分离的线程是不能被其他线程回收或杀死的。它的存储器资源在它终止时由系统自动释放。
默认情况下, 线程被创建成可结合的。为了避免内存泄漏,每个可结合的线程都应该要么被其他线程显示地收回,要么通过调用pthread_detach函数被分离。
pthread_detach函数分离可结合线程tid。线程能够通过以pthread_self()为参数的pthread_detach调用来分离它们自己。
#include <pthread.h> //返回:若成功返回0,否则为非零 int pthread_detach(pthread_t tid);
12.3.7 初始化线程
pthread_once函数允许你初始化与线程例程相关的状态,
#include <pthread.h> pthread_once_t once_control = PTHREAD_ONCE_INIT; //总是返回0 int pthread_once(pthread_once_t *once_control, void (*init_routine)(void));
once_control变量是一个全局或者静态变量,总是被初始化为PTHREAD_ONCE_INIT。当第一个用参数once_control调用pthread_once时,它调用init_routine,这是一个没有输入参数,也不返回什么的函数。接下来的以once_control为参数的pthread_once调用不做任何事情。当需要动态初始化多个线程共享的全局变量时,pthread_once函数是很有用的。
12.4 多线程程序中的共享变量
12.4.1 线程存储器模型
一组并发线程运行在一个进程的上下文中。每个线程都有它自己独立的线程上下文,包括线程ID、栈、栈指针、程序计数器、条件码和通用目的寄存器值。每个线程和其他线程一起共享进程上下文的剩余部分。包括整个用户虚拟地址空间,它是由只读文本(代码)、读/写数据、堆以及所有的共享库代码和数据区域组成的。线程也共享同样的打开文件的集合。
从实际操作角度来讲,让一个线程去读或写另一个线程的寄存器值是不可能的。另一方面,任何线程都可以访问共享虚拟存储器的任意位置。如果某个线程修改了一个存储器位置,那么其他每个线程最终都能在它读这个位置时发现这个变化。因此,寄存器是从不共享的,而虚拟存储器总是共享的。
各自独立的线程栈的存储器模型不是那么整齐清楚的。这些栈被保存在虚拟存储器地址空间的栈区域中,并且通常是被相应的线程独立访问的。但不同的线程栈是不对其他线程设防的。所以,如果一个线程以某种方式得到一个指向其他线程栈的指针,那么它就可以读写这个栈的任务部分。
12.4.2 将变量映射到存储器
线程化的C程序中变量根据它们的存储类型被映射到虚拟存储器:
- 全局变量。全局变量时定义在函数之外的变量。在运行时,虚拟存储器的读/写区域只包含每个全局变量的一个实例,任何线程都可以引用。
- 本地自动变量。定义在函数内部但没有static属性的变量。在运行时,每个线程的栈都包含它自己的所有本地自动变量的实例。即使当多个线程执行同一个线程例程时也是如此。
- 本地静态变量。定义在函数内部并有static属性的变量。和全局变量一样,虚拟存储器的读/写区域只包含在程序中声明的每个本地静态变量的一个实例。
12.4.3 共享变量
一个变量v是共享的,当且仅当它的一个实例被一个以上的线程引用,如全局变量和本地静态变量。
12.5 用信号量同步线程
共享变量是十分方便的,但它们也引入了同步错误(synchronization error)的可能性。
12.5.1 进度图
进度图(progress graph)将n个并发线程的执行模型化为一条n维笛卡尔空间中的轨迹线。每条轴k对应于线程k的进度。图的原点对应于没有任何线程完成一条指令的初始状态。进度图将指令模型化为从一种状态到另一种状态的转换(transition)。合法的转换是向右或者向上的,两条指令不能在同一时刻完成,即对角转换是不允许的。程序绝不会反向运行,所有向下或向右的转换也是不合法的。
对于线程i,操作共享变量n内容的指令构成了一个(关于共享变量n的)临界区(critical section),这个临界区不应该和其他线程的临界区交替执行。换句话说,我们想要每个线程在执行它的临界区中的指令时,拥有对共享变量的互斥的访问。通常这种现象称为互斥(mutual exclusion)。
在进度图中,两个临界区的交集形成的状态空间区域称为不安全区(unsafe region)。为了保证程序正确执行,我们必须以某种方式同步线程,使他们总有一条安全轨迹线,一个经典的方法是基于信号量的思想。
12.5.2 信号量
Edsger Dijkstra,并发编程领域的先锋人物。提出了一种经典的解决同步不同执行线程问题的方法,这种方法是基于一种叫做信号量(semaphore)的特殊类型的变量的。信号量s是具有非负整数值的全局变量,只能由两种特殊的操作来处理,这两种操作称为P和V。
- P(s):如果s是非零的,那么P将s减1,并且立即返回。如果s为零,而一个V操作会重启这个线程。在重启后,P将s减1,并将控制返回给调用者。
- V(s):V操作将s加1。如果有任何线程阻塞在P操作等待s变成非零,那么V操作会重启这些线程中的一个,然后该线程将s减1,完成它的P操作。
P的测试和减1操作是不可分割的,也就是说,一旦预测信号量s变为非零,将会将s减1,不能有中断。V中的加1操作也不是不可分割的,也就是加载、加1和存储信号量的过程中没有中断。注意,V的定义中没有定义等待线程被重新启动的顺序。
P和V的定义确保了以一个正在运行的程序绝不可能进入这样一个状态,也就是一个正确初始化了的信号量有一个负值。这个属性称为信号量不变性(semaphore invariant)。
Posix标准定义了很多操作信号量的函数。
#include <semaphore.h> int sem_init(sem_t *sem, 0, unsigned int value); int sem_wait(sem_t *sem); /*P(s)*/ int sem_post(sem_t *sem); /*V(s)*/ //返回:若成功则为0,若出错则为-1
sem_init将信号量sem初始化为value。每个信号量在使用前必须初始化。针对我们的目的,中间的参数总是0。程序分别通过调用sem_wait和sem_post函数来执行P和V操作。为了简化,我们使用下面这些等价的P和V的包装函数:
#include "csapp.h" void P(sem_t *s); /*Warpper function for sem_wait*/ void V(sem_t *s); /*Warpper function for sem_post*/
12.5.3 使用信号量来实现互斥
信号量提供了一种很方便的方法来确保对共享变量的互斥访问。基本思想是将每个共享变量与一个信号量s(初始值为1)联系起来,然后用P(s)和V(s)操作将相应的临界区包围起来。以这种方式来保护共享变量的信号量叫做二元信号量(binary semaphore),因为它的值总是0或者1。以提供互斥为目的的二元信号量常常也称为互斥锁(mutex)。在一个互斥锁上执行P操作成为对互斥锁加锁,执行V操作称为对互斥锁解锁。对一个互斥锁加了锁但是还没解锁的线程称为占用这个互斥锁。
P和V操作的结果创建了一组状态, 叫做禁止区(forbidden region),其中s<0。因为信号量的不变性,没有实际可行的轨迹线能够包含禁止区中的状态。而且,因为禁止区完全包括了不安全区,所以没有实际可行的轨迹线能够接触不安全区中的任何部分。
12.5.4 利用信号量来调度共享资源
除了提供互斥之外,信号量的另一个重要作用是调度对共享资源的访问。在这种场景中,一个线程用信号量操作来通知另一个线程,程序状态中的某个条件已经为真了。两个经典而有用的例子是生产者-消费者和读者-写者问题。
1.生产者-消费者问题
生产者和消费者线程共享一个有n个槽的有限缓冲区,生产者线程反复地生成新的项目(item),并把他们插入到缓冲区中。消费者线程不断地从缓冲区中取出这些项目,然后消费它们。因为插入和取出项目都涉及更新共享变量,所以必须保证对缓冲区的访问是互斥的。同时还需要调度对缓冲区的访问。如果缓冲区是满,那么生产者必须等待知道有一个槽位可用。相似的,如果缓冲区为空,消费者必须等待直到有一个项目可用。
图12-23 生产者-消费者问题
我们开发一个简单的包叫做SBUF,用来构造生产者-消费者程序。SBUF操作类型为sbuf_t的有限缓冲区。项目存放在一个动态分配的n项整数数组(buf)中。front和rear索引值记录该数组中第一项和最后一项。三个信号量同步对缓冲区的访问。mutex信号量提供互斥的缓冲区访问,slots和items信号量分别记录空槽位和可用项目的数量。
typedef struct { int *buf; /* Buffer array */ int n; /* Maximum number of slots */ int front; /* buf[(front+1)%n] is first item */ int rear; /* buf[rear%n] is last item */ sem_t mutex; /* Protects accesses to buf */ sem_t slots; /* Counts available slots */ sem_t items; /* Counts available items */ } sbuf_t;
1 /* Create an empty, bounded, shared FIFO buffer with n slots */ 2 /* $begin sbuf_init */ 3 void sbuf_init(sbuf_t *sp, int n) 4 { 5 sp->buf = Calloc(n, sizeof(int)); 6 sp->n = n; /* Buffer holds max of n items */ 7 sp->front = sp->rear = 0; /* Empty buffer iff front == rear */ 8 Sem_init(&sp->mutex, 0, 1); /* Binary semaphore for locking */ 9 Sem_init(&sp->slots, 0, n); /* Initially, buf has n empty slots */ 10 Sem_init(&sp->items, 0, 0); /* Initially, buf has zero data items */ 11 } 12 /* $end sbuf_init */ 13 14 /* Clean up buffer sp */ 15 /* $begin sbuf_deinit */ 16 void sbuf_deinit(sbuf_t *sp) 17 { 18 Free(sp->buf); 19 } 20 /* $end sbuf_deinit */ 21 22 /* Insert item onto the rear of shared buffer sp */ 23 /* $begin sbuf_insert */ 24 void sbuf_insert(sbuf_t *sp, int item) 25 { 26 P(&sp->slots); /* Wait for available slot */ 27 P(&sp->mutex); /* Lock the buffer */ 28 sp->buf[(++sp->rear)%(sp->n)] = item; /* Insert the item */ 29 V(&sp->mutex); /* Unlock the buffer */ 30 V(&sp->items); /* Announce available item */ 31 } 32 /* $end sbuf_insert */ 33 34 /* Remove and return the first item from buffer sp */ 35 /* $begin sbuf_remove */ 36 int sbuf_remove(sbuf_t *sp) 37 { 38 int item; 39 P(&sp->items); /* Wait for available item */ 40 P(&sp->mutex); /* Lock the buffer */ 41 item = sp->buf[(++sp->front)%(sp->n)]; /* Remove the item */ 42 V(&sp->mutex); /* Unlock the buffer */ 43 V(&sp->slots); /* Announce available slot */ 44 return item; 45 } 46 /* $end sbuf_remove */
注意:30行和43行,通知有一个项目或者一个空间的槽位可用。
2.读者-写者问题
读者-写者问题是互斥问题的一个概括。一组并发的线程要访问一个共享对象,例如一个主存中的数据结构,或者一个磁盘上的数据库。
1 int readcnt; 2 sem_t mutex, w; 3 4 void reader(void) 5 { 6 while(1){ 7 P(&mutex); 8 readcnt++; 9 if(readcnt == 1) /*First in*/ 10 P(&w); 11 V(&mutex); 12 13 /*Critical section*/ 14 /*Reading happens*/ 15 16 P(&mutex); 17 readcnt--; 18 if(readcnt == 0); /*Last out*/ 19 V(&w); 20 P(&mutex); 21 } 22 } 23 24 void writer(void) 25 { 26 while(1){ 27 P(&w); 28 /*Critical section*/ 29 /*Writing happens*/ 30 V(&w); 31 } 32 }
12.7 其他并发问题
12.7.1 线程安全
一个函数被称为线程安全的(thread-safe),当且仅当被多个并发线程反复地调用时,它会一直产生正确的结果。我们能够定义出四个(不相交的)线程不安全函数类:
第1类:不保护共享变量的函数。例如对未保护的全局变量进行操作。将这类线程不安全函数变成线程安全的,相对而言比较容易:利用像P和V操作这样的同步操作来保护共享变量。优点是在调用程序中不用做任何修改。缺点是同步操作将减慢程序的执行时间。
第2类:保持跨越多个调用的状态的函数。请参考图12-35中的伪随机数生成器。rand函数是线程不安全的,因为当前调用的结果依赖于前次调用的中间结果。当调用srand为rand设置了一个种子后,单线程调用可以得到一个可重复的随机数字序列。而多线程调用就不能了。
1 /* $begin rand */ 2 unsigned int next = 1; 3 4 /* rand - return pseudo-random integer on 0..32767 */ 5 int rand(void) 6 { 7 next = next*1103515245 + 12345; 8 return (unsigned int)(next/65536) % 32768; 9 } 10 11 /* srand - set seed for rand() */ 12 void srand(unsigned int seed) 13 { 14 next = seed; 15 } 16 /* $end rand */
图12-35 一个线程不安全的伪随机数生成器
使得像rand这样的函数线程安全的唯一方式是重写它,使得它不再使用任何static数据,而是依靠调用者在参数中传递状态信息。缺点是,程序员还要被迫修改调用程序中的代码。在一个大的程序中,可能有很多的调用位置,将会非常麻烦,而且容易出错。
第3类:返回指向静态变量的指针的函数。某些函数将计算结果放在一个static变量中,然后返回一个指向该变量的指针。如果从并发线程中调用这些函数,可能导致正在被一个线程使用的记过被另外一个线程悄悄的覆盖了。
有两种方法处理这类线程不安全函数,一种是重写,是的调用者传递存放结果的变量的地址。这就消除了所有共享数据,但是要求程序员能够修改函数的源代码。
如果函数难以修改或不可能修改,另一种选择就是使用加锁-拷贝(lock-and-copy)技术。基本思想是将线程不安全的函数与互斥锁联系起来。在每一个调用位置对互斥锁加锁,调用线程不安全函数,将函数返回的结果拷贝到一个私有的存储器位置,然后对互斥锁解锁。图12-36给出了ctime的一个线程安全的版本,利用的就是加锁-拷贝技术。
1 /* $begin ctime_ts */ 2 char *ctime_ts(const time_t *timep, char *privatep) 3 { 4 char *sharedp; 5 6 P(&mutex); 7 sharedp = ctime(timep); 8 strcpy(privatep, sharedp); /* Copy string from shared to private */ 9 V(&mutex); 10 return privatep; 11 } 12 /* $end ctime_ts */
图12-36 C标准库函数ctime的线程安全的包装函数
第4类:调用线程不安全函数的函数。如果函数f调用线程不安全函数g,那么f就是线程不安全的吗?不一定。如果g是第2类函数,即依赖跨越多次调用的状态,那么g就是线程不安全的,除了重写g以外,没有什么办法。如果g是第1类或者第3类函数,那么只要用一个互斥锁保护调用位置和任何得到的共享数据,f仍然可能是线程安全的。图12-36就是通过使用加锁-拷贝技术编写了一个线程安全函数,它调用了一个线程不安全函数。
12.7.2 可重入性
有一类重要的线程安全函数,叫可重入函数(reentrant function),它们具有这样一种属性:当它们被多个线程调用时,不会引用任何共享数据。可重入函数是线程安全函数的一个真子集。
图12-37 可重入函数、线程安全函数和线程不安全函数之间的集合关系
可重入函数通常比不可重入函数的线程线程安全的函数高效一些,因为它们不需要同步操作。将第2类线程不安全函数转化为线程安全函数的唯一方法就是重写它,使之变为可重入的。图12-38展示了图12-35中rand函数的一个可重入版本,关键思想是我们用一个调用者传递进来的指针取代了静态的next变量。
1 /* $begin rand_r */ 2 /* rand_r - a reentrant pseudo-random integer on 0..32767 */ 3 int rand_r(unsigned int *nextp) 4 { 5 *nextp = *nextp * 1103515245 + 12345; 6 return (unsigned int)(*nextp / 65536) % 32768; 7 } 8 /* $end rand_r */
12.7.4 竞争
当一个程序的正确性依赖于一个线程要在另一个线程到达y之前达到它的控制流中的x点时,就会发生竞争(race)。通常是因为程序员假定线程将按照某种特殊的轨迹线穿过执行状态空间,而忘记了另一条准则规定:线程化的程序必须对任何可行的轨迹线都正确工作。
12.7.5 死锁
信号量引入了一种潜在的令人厌恶的运行时错误,叫做死锁(deadlock),它指的是一组线程被阻塞了,等待一个永远也不会为真的条件。进度图对于理解死锁是一个无价的工具。
- 程序员使用P和V操作顺序不当,以至于两个信号量的禁止区域重叠。如果某个执行轨迹线碰巧达到了死锁状态d,那么就不可能有进一步的进展了。因为重叠区域阻塞了每个合法方向上的进展。程序死锁是因为每个线程都在等待其他线程执行一个根本不可能发生的V操作。
- 重叠的禁止区域引擎了一组称为死锁区域(deadlock region)的状态。如果一个轨迹线碰巧达到了一个死锁区域中的状态,那么死锁就是不可避免的了。轨迹线可以进入死锁区域,但是它们不可能离开。
简单有效的避免死锁的规则:互斥锁加锁顺序规则:如果对于程序中每对互斥锁(s,t),给所有的锁分配一个全序,每个线程按照这个顺序来请求锁,并且按照逆序来释放,那么, 这个程序就是无死锁的。