本章将介绍为单个运行进程提供的新抽象:线程(thread)。经典观点是一个程序只有一个执行点(一个程序计数器,用来存放要执行的指令),但多线程(multi-threaded)程序会有多个执行点(多个程序计数器,每个都用于取指令和执行) 。换一个角度来看,每个线程类似于独立的进程,只有一点区别:它们共享地址空间,从而能够访问相同的数据。
26.1 实例:线程创建
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include "common.h"
#include "common_threads.h"
void *mythread(void *arg) {
printf("%s\n", (char *) arg);
return NULL;
}
int main(int argc, char *argv[]) {
if (argc != 1) {
fprintf(stderr, "usage: main\n");
exit(1);
}
pthread_t p1, p2;
printf("main: begin\n");
Pthread_create(&p1, NULL, mythread, "A");
Pthread_create(&p2, NULL, mythread, "B");
Pthread_join(p1, NULL);
Pthread_join(p2, NULL);
printf("main: end\n");
return 0;
}
26.2 为什么更糟糕:共享数据
设想一个简单的例子,其中两个线程希望更新全局共享变量。
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include "common.h"
#include "common_threads.h"
int max;
volatile int counter = 0;
void *mythread(void *arg) {
char *letter = arg;
int i;
printf("%s: begin [addr of i: %p]\n", letter, &i);
for (i = 0; i < max; i++) {
counter = counter + 1;
}
printf("%s: done\n", letter);
return NULL;
}
int main(int argc, char *argv[]) {
if (argc != 2) {
fprintf(stderr, "usage: main-first <loopcount>\n");
exit(1);
}
max = atoi(argv[1]);
pthread_t p1, p2;
printf("main: begin [counter = %d] [%x]\n", counter,
(unsigned int) &counter);
Pthread_create(&p1, NULL, mythread, "A");
Pthread_create(&p2, NULL, mythread, "B");
Pthread_join(p1, NULL);
Pthread_join(p2, NULL);
printf("main: done\n [counter: %d]\n [should: %d]\n",
counter, max*2);
return 0;
}
26.3 核心问题:不可控的调度
mov 0x8049a1c, %eax
add $0x1, %eax
mov %eax, 0x8049a1c
,变量 counter 位于地址 0x8049a1c。在这 3 条指令中,先用 x86 的 mov指令,从内存地址处取出值,放入 eax。然后,给 eax 寄存器的值加 1(0x1)。最后,eax的值被存回内存中相同的地址。 设想我们的两个线程之一(线程 1)进入这个代码区域,并且因此将要增加一个计数器。
它将 counter 的值(假设它这时是 50)加载到它的寄存器 eax 中。因此,线程 1 的 eax = 50。然后它向寄存器加 1,因此 eax = 51。现在,一件不幸的事情发生了:时钟中断发生。因此,操作系统将当前正在运行的线程(它的程序计数器、寄存器,包括 eax 等)的状态保存到线程的 TCB。
现在更糟的事发生了:线程 2 被选中运行,并进入同一段代码。它也执行了第一条指令,获取计数器的值并将其放入其 eax 中 [请记住:运行时每个线程都有自己的专用寄存器。上下文切换代码将寄存器虚拟化(virtualized),保存并恢复它们的值]。此时 counter 的值仍为 50,因此线程 2 的 eax = 50。假设线程 2 执行接下来的两条指令,将 eax 递增 1(因此 eax= 51),然后将 eax 的内容保存到 counter(地址 0x8049a1c)中。因此,全局变量 counter 现在的值是 51。
最后,又发生一次上下文切换,线程 1 恢复运行。还记得它已经执行过 mov 和 add 指令,现在准备执行最后一条 mov 指令。回忆一下,eax=51。因此,最后的 mov 指令执行,将值保存到内存,counter 再次被设置为 51。
26.4 原子性愿望
- 临界区(critical section)是访问共享资源的一段代码,资源通常是一个变量或数据结构。
- 竞态条件(race condition)出现在多个执行线程大致同时进入临界区时,它们都试图更新共享的数据结构,导致了令人惊讶的(也许是不希望的)结果。
- 不确定性(indeterminate)程序由一个或多个竞态条件组成,程序的输出因运行而异,具体取决于哪些线程在何时运行。这导致结果不是确定的(deterministic) ,而我们通常期望计算机系统给出确定的结果。
- 为了避免这些问题,线程应该使用某种互斥(mutual exclusion)原语。这样做可以保证只有一个线程进入临界区,从而避免出现竞态,并产生确定的程序输出。
26.5 还有一个问题:等待另一个线程
还有另一种常见的交互,即一个线程在继续之前必须等待另一个线程完成某些操作。例如,当进程执行磁盘 I/O 并进入睡眠状态时,会产生这种交互。当 I/O 完成时,该进程需要从睡眠中唤醒,以便继续进行。
|