介绍
我们将介绍为单个运行进程提供的新抽象:线程(thread)。经典观点是一个程序只有一个执行序列(一个程序计数器,用来存放要执行的指令),但多线程(multi-threaded)程序会有多个执行序列(多个程序计数器,每个都用于取指令和执行)。换一个角度来看,每个线程类似于独立的进程,只有一点区别:它们共享地址空间,从而能够访问相同的数据。
因此,单个线程的状态与进程状态非常类似。线程有一个程序计数器,记录程序从哪里获取指令。每个线程有自己的一组用于计算的寄存器。所以,如果有两个线程运行在一个处理器上,从一个线程切换到另一个线程时,必定发生上下文切换(context switch)。线程之间的上下文切换类似于进程间的上下文切换。对于进程,我们将状态保存到进程控制块(ProcessControl Block,PCB)。现在,我们需要一个或多个线程控制块(Thread Control Block,TCB),保存每个线程的状态。但是,与进程相比,线程之间的上下文切换有一点主要区别:地址空间保持不变(即不需要切换当前使用的页表)。
线程和进程之间的另一个主要区别在于栈。在简单的传统进程地址空间模型(我们现在可以称之为单线程(single-threaded)进程)中,只有一个栈,通常位于地址空间的底部(见图左)。
然而,在多线程的进程中,每个线程独立运行,可以调用各种例程来完成正在执行的任何工作。此时地址空间中不只有一个栈,而是每个线程都有一个栈。假设有一个多线程的进程,它有两个线程,结果地址空间看起来不同(见图右)。
以前,堆和栈可以互不影响地增长,直到空间耗尽,多个栈就没有这么简单了。幸运的是,通常栈不会很大(除了大量使用递归的程序)。
实例:线程创建
假设我们想运行一个程序,它创建两个线程,每个线程都做一些独立的工作:分别打印“A”或“B”。代码如图所示。
主程序创建了两个线程,分别执行函数mythread(),但是传入不同的参数。一旦线程创建,可能会立即运行,或者处于就绪状态,等待执行。创建了两个线程后,主程序调用pthread_join(),等待特定线程完成。
1 #include <stdio.h>
2 #include <assert.h>
3 #include <pthread.h>
4
5 void *mythread(void *arg) {
6 printf("%s\n", (char *) arg);
7 return NULL;
8 }
9
10 int
11 main(int argc, char *argv[]) {
12 pthread_t p1, p2;
13 int rc;
14 printf("main: begin\n");
15 rc = pthread_create(&p1, NULL, mythread, "A"); assert(rc == 0);
16 rc = pthread_create(&p2, NULL, mythread, "B"); assert(rc == 0);
17 // join waits for the threads to finish
18 rc = pthread_join(p1, NULL); assert(rc == 0);
19 rc = pthread_join(p2, NULL); assert(rc == 0);
20 printf("main: end\n");
21 return 0;
22 }
此时,系统中存在有三个线程。代码每次的执行结果都有可能与上次不同,有很多可能的顺序,这取决于调度程序决定在给定时刻运行哪个线程。
线程创建有点像进行函数调用。然而,并不是首先执行函数然后返回给调用者,而是为被调用的例程创建一个新的执行线程,它可以独立于调用者运行,可能在从创建者返回之前运行,但也许会晚得多。
共享数据的问题
设想一个简单的例子,有两个线程希望更新全局共享变量。代码如图所示。
1 #include <stdio.h>
2 #include <pthread.h>
3 #include "mythreads.h"
4
5 static volatile int counter = 0;
6
7 //
8 // mythread()
9 //
10 // Simply adds 1 to counter repeatedly, in a loop
11 // No, this is not how you would add 10,000,000 to
12 // a counter, but it shows the problem nicely.
13 //
14 void *
15 mythread(void *arg)
16 {
17 printf("%s: begin\n", (char *) arg);
18 int i;
19 for (i = 0; i < 1e7; i++) {
20 counter = counter + 1;
21 }
22 printf("%s: done\n", (char *) arg);
23 return NULL;
24 }
25
26 //
27 // main()
28 //
29 // Just launches two threads (pthread_create)
30 // and then waits for them (pthread_join)
31 //
32 int
33 main(int argc, char *argv[])
34 {
35 pthread_t p1, p2;
36 printf("main: begin (counter = %d)\n", counter);
37 Pthread_create(&p1, NULL, mythread, "A");
38 Pthread_create(&p2, NULL, mythread, "B");
39
40 // join waits for the threads to finish
41 Pthread_join(p1, NULL);
42 Pthread_join(p2, NULL);
43 printf("main: done with both (counter = %d)\n", counter);
44 return 0;
45 }
这段代码的意图很简单:两个线程分别将共享变量的计数器加一,并在循环中执行1000万次。因此,预期的最终结果是20000000。
遗憾的是,即使是在单处理器上运行这段代码,也不一定能获得预期结果。有时会这样:
prompt> ./main
main: begin (counter = 0)
A: begin
B: begin
A: done
B: done
main: done with both (counter = 19345221)
而且,每次运行不但会产生错误的结果,甚至结果都不尽相同。那么不禁要问了:为什么会出现这种情况?
核心:不可控的调度
为了理解为什么会发生这种情况,我们必须了解编译器为更新计数器生成的代码序列。在这个例子中,我们只是想给counter加上一个数字。因此,做这件事的代码序列可能看起来像这样:
mov 0x8049a1c, %eax
add $0x1, %eax
mov %eax, 0x8049a1c
这个例子假定,变量counter位于地址0x8049a1c。在这3条指令中,先用mov指令从内存地址处取出值,放入eax。然后,给eax寄存器的值加1。最后,eax的值被存回内存中相同的地址。
设想线程1进入这个代码区域,它将counter的值(假设它这时是50)加载到寄存器eax中。然后它向寄存器加1,因此eax = 51。现在,一件不幸的事情发生了:时钟中断发生。操作系统将当前正在运行的线程(它的程序计数器、寄存器,包括eax等)的状态保存到线程的TCB。
然后线程2被调度运行,并进入同一段代码。它也执行了第一条指令,获取计数器的值并将其放入eax中。此时counter的值仍为50,因此eax = 50。线程2继续执行接下来的两条指令,将eax递增1(此时eax = 51),然后将eax的内容保存到counter中。因此,全局变量counter现在的值是51。
最后,又发生一次上下文切换,线程1恢复运行。它已经执行过mov和add指令,现在准备执行最后一条mov指令。回忆一下,现在eax=51。最后的mov指令执行,将值保存到内存,counter再次被设置为51。
发生的情况是:增加counter的代码被执行两次,初始值为50,但是结果为51。
这里展示的情况称为竞态条件(race condition):结果取决于代码的时间执行。由于执行过程中发生的上下文切换,我们得到了错误的结果。事实上,可能每次都会得到不同的结果。
由于执行这段代码的多个线程可能导致竞争状态,因此我们将此段代码称为临界区(criticalsection)。临界区是访问共享变量(或更一般地说,共享资源)的代码片段,一定不能由多个线程同时执行。
我们真正想要的代码就是所谓的互斥(mutual exclusion)。这个属性保证了如果一个线程在临界区内执行,其他线程将被阻止进入临界区。
原子执行
解决这个问题的一种途径是拥有更强大的指令,只需一步就能完成要做的事,从而消除不合时宜的中断的可能性。比如,如果有一条超级指令原子地支持对内存变量的自增操作,上面的程序就可以得到正确的结果。
但在一般情况下,不会有这样的指令。因此,我们要做的是要求硬件提供一些有用的指令,然后在这些指令上构建一个通用的集合,即所谓的同步原语(synchronization primitive)。通过使用这些硬件同步原语,加上操作系统的一些帮助,我们将能够构建多线程代码,以同步和受控的方式访问临界区,从而可靠地产生正确的结果。
等待另一个线程
事实证明,线程之间还有另一种常见的交互,即一个线程在继续之前必须等待另一个线程完成某些操作。例如,当进程执行磁盘I/O并进入睡眠状态时,会产生这种交互。当I/O完成时,该进程需要从睡眠中唤醒,以便继续进行。
因此,我们不仅要研究如何构建同步原语来支持原子性,还要研究支持在多线程程序中常见的睡眠/唤醒交互的机制。
线程API
创建线程
编写多线程程序的第一步就是创建新线程,在POSIX中如下:
#include <pthread.h>
int pthread_create(pthread_t * thread, const pthread_attr_t *attr, void *(*start_routine)(void*), void *arg);
该函数有4个参数:thread、attr、start_routine和arg。第一个参数thread是指向pthread_t结构类型的指针,我们将利用这个结构与该线程交互,因此需要将它传入pthread_create(),以便将它初始化。
第二个参数attr用于指定该线程可能具有的任何属性。一些例子包括设置栈大小,或关于该线程调度优先级的信息。每个属性通过单独调用pthread_attr_init()来初始化,在大多数情况下,默认值就行。在这个例子中,我们只需传入NULL。
第三个参数最复杂,但它实际上只是问:这个线程应该在哪个函数中运行?在C中,我们把它称为一个函数指针(function pointer),这个指针告诉我们需要以下内容:一个函数名称(start_routine),它被传入一个类型为void *的参数,并且它返回一个void *类型的值(即一个void指针)。
注:在C中,将void指针作为函数的参数,允许我们传入任何类型的参数,将它作为返回值,允许函数返回任何类型的结果。
最后,第四个参数arg就是要传递给线程开始执行的函数的参数。
下面是创建线程的一个程序实例:
1 #include <pthread.h>
2
3 typedef struct myarg_t {
4 int a;
5 int b;
6 } myarg_t;
7
8 void *mythread(void *arg) {
9 myarg_t *m = (myarg_t *) arg;
10 printf("%d %d\n", m->a, m->b);
11 return NULL;
12 }
13
14 int
15 main(int argc, char *argv[]) {
16 pthread_t p;
17 int rc;
18
19 myarg_t args;
20 args.a = 10;
21 args.b = 20;
22 rc = pthread_create(&p, NULL, mythread, &args);
23 ...
24 }
等待线程完成
如果想等待线程完成,你必须调用函数pthread_join()。该函数有两个参数,第一个是pthread_t类型,用于指定要等待的线程。这个变量是由线程创建函数初始化的(当你将一个指针作为参数传递给pthread_create()时),如果你保留了它,就可以用它来等待该线程终止。
第二个参数是一个指针,指向你希望得到的返回值。函数可以返回任何东西,所以它被定义为返回一个指向void的指针。因为pthread_join()函数改变了传入参数的值,所以你需要传入一个指向该值的指针,而不只是该值本身。
下面是一个程序实例:
1 #include <stdio.h>
2 #include <pthread.h>
3 #include <assert.h>
4 #include <stdlib.h>
5
6 typedef struct myarg_t {
7 int a;
8 int b;
9 } myarg_t;
10
11 typedef struct myret_t {
12 int x;
13 int y;
14 } myret_t;
15
16 void *mythread(void *arg) {
17 myarg_t *m = (myarg_t *) arg;
18 printf("%d %d\n", m->a, m->b);
19 myret_t *r = Malloc(sizeof(myret_t));
20 r->x = 1;
21 r->y = 2;
22 return (void *) r;
23 }
24
25 int
26 main(int argc, char *argv[]) {
27 int rc;
28 pthread_t p;
29 myret_t *m;
30
31 myarg_t args;
32 args.a = 10;
33 args.b = 20;
34 Pthread_create(&p, NULL, mythread, &args);
35 Pthread_join(p, (void **) &m);
36 printf("returned %d %d\n", m->x, m->y);
37 return 0;
38 }
锁
除了线程创建和join之外,POSIX线程库提供的最有用的函数集,可能是通过锁(lock)来提供互斥进入临界区的那些函数。这方面最基本的一对函数是:
int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);
如果在调用pthread_mutex_lock()时没有其他线程持有锁,线程将获取该锁并进入临界区。如果另一个线程确实持有该锁,那么尝试获取该锁的线程将不会从该调用返回,直到获得该锁(意味着持有该锁的线程通过解锁调用释放该锁)。在给定的时间内,许多线程可能会卡住,在获取锁的函数内部等待。然而,只有获得锁的线程才应该调用解锁。
不过还有几点需要注意。首先,在使用这些函数之前,必须确保所有的锁被正确地初始化,以保证它们具有正确的值,在锁和解锁被调用时按照需要工作。
对于POSIX线程,有两种方法来初始化锁。一种方法是使用宏PTHREAD_MUTEX_INITIALIZER,这样做会将锁设置为默认值。另一种是调用pthread_mutex_init(),此函数的第一个参数是锁本身的地址,而第二个参数是一组可选属性,传入NULL就是使用默认值。无论哪种方式都有效,但我们通常使用后者。
注:当用完锁时,还应该相应地调用pthread_mutex_destroy()来释放资源。
其次,在调用获取锁和释放锁时还需要有检查错误代码。就像UNIX系统中调用的任何库函数一样,这些函数也可能会失败!如果你的代码没有正确地检查错误代码,失败将会静静地发生,可能会允许多个线程进入临界区。因此我们至少要使用包装的函数,对函数成功加上断言。
获取锁和释放锁函数不是pthread与锁进行交互的仅有的函数。还有两个可能会用到的函数:
int pthread_mutex_trylock(pthread_mutex_t *mutex);
int pthread_mutex_timedlock(pthread_mutex_t *mutex, struct timespec *abs_timeout);
这两个函数用于获取锁。如果锁已被占用,则trylock函数将失败;timedlock函数会在超时或获取锁后返回,以先发生者为准。因此,将超时时间设置为0的timedlock将退化为trylock。通常应避免使用这两个函数,但有些情况下,比如死锁时,避免卡在获取锁的函数中会很有用。
条件变量
所有线程库还有一个主要组件,就是存在一个条件变量(condition variable)。当线程之间必须发生某种信号时,如果一个线程在等待另一个线程继续执行某些操作,条件变量就很有用。相关函数主要有如下两个:
int pthread_cond_wait(pthread_cond_t *cond, pthread_mutex_t *mutex);
int pthread_cond_signal(pthread_cond_t *cond);
要使用条件变量,必须另外有一个与此条件相关的锁。在调用上述任何一个函数时,应该持有这个锁。
第一个函数pthread_cond_wait()使调用线程进入休眠状态,因此等待其他线程发出信号,通常当程序中的某些内容发生变化时,唤醒现在正在休眠的线程。典型的用法如下所示:
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
Pthread_mutex_lock(&lock);
while (ready == 0)
Pthread_cond_wait(&cond, &lock);
Pthread_mutex_unlock(&lock);
在这段代码中,在初始化相关的锁和条件变量之后,一个线程检查变量ready是否已经被设置为零以外的值。如果没有,那么线程只是简单地调用等待函数以便休眠,直到其他线程唤醒它。唤醒线程的代码运行在另外某个线程中,像下面这样:
Pthread_mutex_lock(&lock);
ready = 1;
Pthread_cond_signal(&cond);
Pthread_mutex_unlock(&lock);
关于这段代码有一些注意事项。首先,在发出信号时(以及修改全局变量ready时),始终确保持有锁。这确保我们不会在代码中意外引入竞态条件。
其次,你可能会注意到等待调用将锁作为其第二个参数,而信号调用仅需要一个条件。造成这种差异的原因在于,等待调用除了使调用线程进入睡眠状态外,还会让调用者睡眠时释放锁。想象一下,如果不是这样:其他线程如何获得锁并将其唤醒?但是,在被唤醒之后返回之前,pthread_cond_wait()会重新获取该锁,从而确保等待线程在等待序列开始时获取锁与结束时释放锁之间运行的任何时间,它持有锁。
最后一点需要注意:等待线程在while循环中重新检查条件,而不是简单的if语句。通常使用while循环是一件简单而安全的事情,虽然它重新检查了这种情况(可能会增加一点开销),但有一些pthread实现可能会错误地唤醒等待的线程。在这种情况下,没有重新检查,等待的线程会继续认为条件已经改变。因此,将唤醒视为某种事物可能已经发生变化的暗示,而不是绝对的事实,这样更安全。
一些参考建议
当构建多线程程序时,这里有一些简单而重要的建议:
- 保持简洁。线程之间的锁和信号的代码应该尽可能简洁,复杂的线程交互容易产生缺陷。
- 让线程交互减到最少。每次交互都应该想清楚,并用验证过的、正确的方法来实现。
- 初始化锁和条件变量。未初始化的代码有时工作正常,有时失败,可能会产生奇怪的结果。
- 检查返回值。任何C和UNIX的程序,都应该检查返回值,这里也是一样。
- 注意传给线程的参数和返回值。举例来说,如果传递在栈上分配的变量的引用,就会出错。
- 每个线程都有自己的栈。线程局部变量应该是线程私有的,其他线程不应该访问。线程之间共享数据,值要在堆或者其他全局可访问的位置。
- 线程之间总是通过条件变量发送信号。切记不要用标记变量来同步。
- 多查手册。尤其是Linux的pthread手册,有更多的细节、更丰富的内容。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。