本章将介绍为单个运行进程提供的新抽象:线程(thread)。
经典观点是一个程序只有一个执行点(一个程序计数器,用来存放要执行的指令),但多线程(multi-threaded)程序会有多个执行点(多个程序计数器,每个都用于取指令和执行)。换一个角度来看,每个线程类似于独立的进程,只有一点区别:它们共享地址空间,从而能够访问相同的数据。
单个线程的状态与进程状态非常类似。
- 线程有一个
程序计数器(PC),记录程序从哪里获取指令。 - 每个线程有自己的一组
用于计算的寄存器。所以,如果有两个线程运行在一个处理器上,从运行一个线程(T1)切换到另一个线程(T2)时,必定发生上下文切换(context switch)。线程之间的上下文切换类似于进程间的上下文切换。 - 我们需要一个或多个
线程控制块(Thread Control Block,TCB),保存每个线程的状态。 - 与进程相比,线程之间的上下文切换有一点主要区别:地址空间保持不变(即不需要切换当前使用的页表)。
线程和进程之间的另一个主要区别在于栈。在简单的传统进程地址空间模型 [我们现在可以称之为单线程(single-threaded)进程] 中,只有一个栈,通常位于地址空间的底部。
在多线程的进程中,
- 每个线程
独立运行,当然可以调用各种例程来完成正在执行的任何工作。 每个线程都有一个栈。在图 中,可以看到两个栈跨越了进程的地址空间。因此,所有位于栈上的变量、参数、返回值和其他放在栈上的东西,将被放置在有时称为线程本地(thread-local)存储的地方,即相关线程的栈。

实例:线程创建
#include <stdio.h>
#include <assert.h>
#include <pthread.h>
void*mythread(void*arg) {
printf("%s\n", (char*) arg);
return NULL;
}
int main(int argc,char*argv[]) {
pthread_t p1, p2;
int rc;
printf("main: begin\n");
rc= pthread_create(&p1, NULL, mythread, "A"); assert(rc== 0);
rc= pthread_create(&p2, NULL, mythread, "B"); assert(rc== 0);
// join waits for the threads to finish
rc= pthread_join(p1, NULL); assert(rc== 0);
rc= pthread_join(p2, NULL); assert(rc== 0);
printf("main: end\n");
return 0;
}
但请注意,这种排序不是唯一可能的顺序。实际上,给定一系列指令,有很多可能的顺序,这取决于调度程序决定在给定时刻运行哪个线程。例如,创建一个线程后,它可能会立即运行


如你所见,线程创建有点像进行函数调用。然而,并不是首先执行函数然后返回给调用者,而是为被调用的例程创建一个新的执行线程,它可以独立于调用者运行,可能在从创建者返回之前运行,但也许会晚得多。(不确定性) 从这个例子中也可以看到,线程让生活变得复杂:已经很难说出什么时候会运行了! 没有并发,计算机也很难理解。遗憾的是,有了并发,情况变得更糟,而且糟糕得多。
为什么更糟糕:共享数据
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"); //Pthread_create为封装好的函数
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 万(107)次。因此,预期的最终结果是:20000000。 我们现在编译并运行该程序,观察它的行为。有时候,一切如我们预期的那样:
prompt> gcc -o main main.c -Wall -pthread
prompt> ./main
main: begin (counter = 0)
A: begin
B: begin
A: done
B: done
main: done with both (counter = 20000000)遗憾的是,即使是在单处理器上运行这段代码,也不一定能获得预期结果。有时会这样:
prompt> ./main
main: begin (counter = 0)
A: begin
B: begin
A: done
B: done
main: done with both (counter = 19345221)让我们再试一次,看看我们是否疯了。毕竟,计算机不是应该产生确定的(deterministic) 结果,像教授讲的那样?!也许教授一直在骗你?(大口地吸气)
prompt> ./main
main: begin (counter = 0)
A: begin
B: begin
A: done
B: done
main: done with both (counter = 19221041)每次运行不但会产生错误,而且得到不同的结果!有一个大问题:为什么会发生这种情况?
原子性愿望
我们要做的是要求硬件提供一些有用的指令,可以在这些指令上构建一个通用的集合,即所谓的同步原语(synchronization primitive)。通过使用这些硬件同步原语,加上操作系统的一些帮助,将能够构建多线程代码,以同步和受控的方式访问临界区,从而可靠地产生正确的结果—— 尽管有并发执行的挑战。
临界区(critical section)是访问共享资源的一段代码,资源通常是一个变量或数据结构。竞态条件(race condition)出现在多个执行线程大致同时进入临界区时,它们都试图更新共享 的数据结构,导致了令人惊讶的(也许是不希望的)结果。不确定性(indeterminate)程序由一个或多个竞态条件组成,程序的输出因运行而异,具体取 决于哪些线程在何时运行。这导致结果不是确定的(deterministic),而我们通常期望计算机系统给出确定的结果。- 为了避免这些问题,线程应该使用某种
互斥(mutual exclusion)原语。这样做可以保证只有一 个线程进入临界区,从而避免出现竞态,并产生确定的程序输出。