四、基于线程的并发编程
1、线程的简介
所有的流共享整个地址空间。线程是这两种方法的混合。
(2)线程(thread)就是运行在进程上下文中的逻辑流。线程由内核自动调度。每个线程都有它自己的线程上下文(thread contxt),包括唯一的整数线程ID(Thread ID,TID)、栈、栈指针、程序计数器、通用目的的寄存器和条件码。所有的运行在一个进程里的线程共享该进程的整个虚拟地址空间。
(3)基于线程的逻辑流结合了基于进程和基于I/O多路复用的流的特性。同进程一样,线程由内核自动调度,并且内核通过一个整数ID来识别线程。同基于I/O多路复用的流一样,多个线程运行在单一进程的上下文中,因此共享这个进程虚拟地址空间的所有内容,包括它的代码、数据、堆、共享库和打开的文件。
2、线程执行模型
2、线程执行模型
多线程执行模型在某些方面个多进程的执行模式是相似的。如上图。每个进程开始生命周期时都是单一线程,这个线程称为主线程(main thread)。在某一时刻,主线程创建了一个对等线程(peer thread),从这个时间点开始,两个线程就并发地运行。最后,因为主线程执行一个慢速系统调用,例如read或者sleep,或者因为被系统的时间间隔计时器中断,控制就会通过上下文切换传递到对等线程。对等线程会执行一段时间,然后控制传递回主线程,一次类推。在一些重要的方面,线程执行是不同于进程的。因为一个线程的上下文要比一个进程的上下文小很多,线程的上下文切换要比进程的上下文切换快得多。另一个不同就是线程不像进程那样,不是按照严格的父子进程来组织的。和一个进程相关的线程组成一个对等(线程)池,独立于其他线程创建的线程。主线程和其他线程的区别仅在于它总是进程中第一个运行的线程。对等(线程)池概念的主要影响是,一个线程可以杀死它的任何对等线程,或者等待它的任意对等线程终止。另外,每个对等线程都能读写相同的共享数据。
3、Posix 线程
Posix 线程(Pthreads)是在C程序中处理线程的一个标准接口。它最早出现在1995年,而且在所有的Linux系统上都可用。Pthread定义了大约60个函数,允许程序创建、杀死和回收线程,与对等线程安全地共享数据,还可以通知对等线程系统状态的变化。(POSIX表示可移植操作系统接口(Portable Operating System Interface of UNIX,缩写为 POSIX ),POSIX标准定义了操作系统应该为应用程序提供的接口标准)
// #include “csapp.h” woid *thread(void *vargp); int main(int argc, char **argv) { 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; } //
(1)上面展示了一个简单的Pthreads程序。主程序创建一个对等线程,然后等待它的终止。对等线程输出“Hello, world!\n”
并且终止。当主线程检测到对等线程终止后,它就通过调用exit终止该进程。线程的代码和本地数据被封装在一个线程例程(threa routine)中。
(2)正如函数void *thread(void *vargp);所示,每个线程例程都以一个通用指针作为输入,并返回一个通用指针。如果想传递多个参数给线程例程,应该将参数放到一个结构体中,并传递一个指向该结构的指针。相似地,如果想要线程例程返回多个参数,可以返回一个结构体的指着。
(3)pthread_t tid;主线程声明了一个本地变量tid,可以用来存放对等线程的ID。
(4)Pthread_create(&tid, NULL, thread, NULL); 主线程调用此函数创建一个新的对等线程。函数调用返回后,主线程和新的对等线程同时运行。
(5)Pthread_join(tid, NULL); 调用此函数,主线程等待对等线程终止。
(6)exit(0);调用此函数,终止当时运行在这个进程中的所有线程。
4、主要API的介绍
(1)创建线程pthread_create
// #include <pthread.h> typedef void *(func)(void *); int pthread_create(pthread_t *tid, pthread_attr_t *attr, func *f, void *arg); 返回:成功返回0,出错返回非0 //pthread_create函数创建一个新的线程,并带着一个输入变量arg,在新线程的上下文中运行线程。能用attr参数来改变
新创建线程的默认属性。在本书示例中,总是用一个为NULL的attr参数来调用pthread_self函数来获得它自己的线程ID。
// #include <pthread.h> pthread_t pthread_self(void); 返回调用者的线程ID。 //
(2)终止线程pthread_exit
一个线程终止的方式如下:
A、当顶层的线程例程返回时,线程会隐式终止。
B、通过调用pthread_exit函数,线程会显式地终止。如果主线程调用pthread_exit,它会等待所有其他对等线程终止,返回值为thread_return.
// #include <pthread.h> void pthread_exit(void *thread_return); //
A、某个对等线程调用Linux的exit函数,该函数终止进程以及所有与进程相关的线程。
B、另一个对等线程通过以当前线程ID作为参数调用pthread_cancel函数来终止当前线程。
// #include <pthread.h> int pthread_cancel(pthread_t tid); //
返回:成功返回0,出错返回非0
(3)回收已终止线程的资源pthread_join
线程通过调用pthread_join函数等待其他线程终止。
// #include <pthread.h> int pthread_join(pthread_t tid, void **thread_return); 返回:成功返回0,出错返回非0 //
pthread_join函数会阻塞,直到线程tid终止,将线程例程返回的通用(void*)指针赋值为thread_return指向的位置,然后回收已终止线程占用的所有的内存资源。
注意,和Linux的wait函数不同,pthread_join函数只能等待一个指定的线程终止。没有办法让pthread_wait等待任意一个线程终止。这使得代码变得复杂,这是一个不合理的机制。
(4)分离线程pthread_detach
在任何一个时间点上,线程是可结合的(joinable)或者是分离的(detache)。一个可结合的线程能够被其他线程收回和杀死。在被其他线程回收之前,它的内存资源(如栈)是不释放的。相反,一个分离的线程是不能被其他线程回收或杀死的。它的内存资源在它终止时由系统自动释放。默认情况下,线程被创建成可结合的。为了避免内存泄漏,每个可结合线程都应该要么被其他线程显式地收回,要么通过调用pthread_detach函数被分离。
// #include <pthread.h> int pthread_detach(pthread_t tid); 返回:成功返回0,出错返回非0 //
pthread_detach函数分离可结合线程tid.线程能够通过以pthread_self()为参数的pthead_detach调用来分离它们自己。在实际应用程序中,有很好的理由要使用分离的线程。例如,一个高性能Web服务器可能在每次收到web浏览器的连接请求时都创建一个新的对等线程。因为每个连接都是由一个单独的线程独立处理的,所以对于服务器而言,就没有必要显式地等待每个对等线程终止。此情况下,每个对等线程都应该在它开始处理请求之前分离它自身,这样就能在它终止后回收它的内存资源了。
(5)初始化线程pthread_once
pthread_once函数允许你初始化与线程例程相关的状态。
// #include <pthread.h> pthread_once_t once_control = PTHREAD_ONCE_INIT; int pthread_once(pthread_once_t *once_control, void (*init_routine)(void)); 返回:总是0 //
once_control变量是一个全局或者静态变量,总是被初始化为PTHREAD_ONCE_INIT。当你第一次用参数once_conteol调用pthread_once时,它调用init_routine,这是一个没有输入参数、也不返回什么的函数。接下来的以once_control为参数的pthread_once调用不做任何事情。无论何时,当你需要动态初始化多个线程贡献的全局变量时,pthread_once函数是很有用的。
5、基于线程的并发服务器
(1)代码
// #include "csapp.h" void echo(int connfd); void *thread(void *vargp); int main(int argc, char **argv) { int listenfd, *connfdp socklen_t clientlen; struct sockaddr_storage clientaddr; pthread_t tid; if (argc != 2) { fprintf(stderr, "usage: %s <pott>\n", argv[0]); exit(0); } listenfd = open_listenfd(argv[1]); while (1) { clientlen = sizeof(struct sockaddr_storage); connfd = malloc(sizeof(int)); *connfdp = accept(listenfd, (SA *) &clientaddr, &clientlen); pthread_create(&tid, NULL, thread, connfdp); } } /* Thread routine */ void *thread(void *vargp) { int connfd = *((int *)vargp); pthread_detach(pthread_self()); free(vargp); echo(connfd); close(connfd); return NULL; } //
(2)分析
上面是基于线程的并发echo服务器的代码。整体结构类似于基于进程的设计。主线程不断地等待连接请求,然后创建一个对等线程处理该请求。
A、pthread_create函数调用时,通过下面的语句实现将已连接描述符传递给对等线程(传递一个指向这个描述符的指针)
// connfd = accept(listenfd, (SA *) &clientaddr, &clientlen); pthread_create(&tid, NULL, thread, &connfd); //
B、让对等线程间接引用这个指针,并将它赋值给一个局部变量,如下
// void *thread(void *vargp) { int connfd = *((int *)vargp); . . . } //
C、然而,这样会出错,因为它在对等线程的赋值语句和主线程的accept语句间引入了竞争(race)。如果赋值语句在下一个accept之前完成,那么对等线程中的局部变量connfd就得到正确的描述符值。然而,如果赋值语句是在accept之后才完成,那么对等线程在同一个描述符上执行输入和输出。为了避免这种潜在的致命竞争,我们必须将accept返回的每个已连接描述符分配到它自己的动态分配的内存块,具体语句如下:
// connfd = malloc(sizeof(int)); *connfdp = accept(listenfd, (SA *) &clientaddr, &clientlen); //
D、为了避免线程例程中内存泄漏。在不显式收回线程下,必须分离每个线程,使得它在终止时它的内存资源能够被收回。
pthread_detach(pthread_self());
E、必须小心释放主线程分配的内存块。
free(vargp);
五、多线程程序中的共享变量
1、介绍
程序中的变量是否共享,需要做如下判断:(1)线程的基础内存模型是什么?
(2)根据这个模型,变量示例是如何映射到内存的?
(3)有多少线程引用这些实例?
一个变量是共享的,当且仅当多个线程引用这个变量的某个实例。
2、线程内存模型
(1)一组并发线程运行在一个进程的上下文中。每个线程都有它自己独立的线程上下文,包括线程ID、栈、栈指针、程序计数器、条件码和通用目的的寄存器值。每个线程和其他线程一起共享进程上下文的剩余部分。这包括整个用户虚拟地址空间,它是由只读文本(代码)、读/写数据、堆以及所有的共享库代码和数据区域组成的。线程也共享相同的打开文件的集合。
(2)从实际操作来说,让一个线程去读写另一个线程的寄存器值是不可能的。另一方面,任何线程都可以访问共享虚拟内存的任意位置。
(3)各自独立的线程栈的内存模型不是那么整齐清楚的。这些栈被保存在虚拟地址空间的栈区域中,并且通常是被相应的线程独立地访问。这里说的是通常而不是总是,是因为不同的线程栈是不对其他线程设防的。所以,如果一个线程以某种方式得到一个指向其他线程栈的指针,那么它就可以读写这个栈的任何部分。示例中printf("[%d]: %s (cnt=%d)\n", myid, ptr[myid], ++cnt);对等线程直接通过全局变量ptr间接引用主线程的栈的内容。
// #include "csapp.h" #define N 2 void *thread(void *vargp); char **ptr; /* global variable */ int main() { int i; pthread_t tid; char *msgs[N] = { "Hello from foo", "Hello from bar" }; ptr = msgs; for (i = 0; i < N; i++) pthread_create(&tid, NULL, thread, (void *)i); pthread_exit(NULL); } void *thread(void *vargp) { int myid = (int)vargp; static int cnt = 0; printf("[%d]: %s(cnt = %d)\n", myid, ptr[myid], ++cnt); return NULL; } //
3、将变量映射到内存
多线程的C程序中变量根据它们的存储类型被映射到虚拟内存:
(1)全局变量。全局变量定义在函数之外,在运行时,虚拟内存的读/写区域只包含每个全局变量的一个实例,在任何线程都可以引用。如上述代码中的ptr.当一个变量只有一个实例时,我们只用变量名来表示这个实例。
(2)本地自动变量。本地自动变量就是定义在函数内部但是没有static属性的变量。在运行时,每个线程的栈都包含它自己的所有本地自动变量的实例。即使多个线程在执行同一个线程例程时也是如此。
(3)本地静态变量。本地静态变量是定义在函数内部并有static属性的变量。和全局变量一样,虚拟内存的读/写区域只包含在程序中声明的每个本地静态变量的一个实例。例如上面代码thread函数中的static int cnt = 0;在运行时,虚拟内存的续/写区域中也只有一个cnt的实例。每个对等线程都读/写这个实例。
4、共享变量
一个变量共享,当且仅当它的一个实例被一个以上的线程引用。如上面代码中变量cnt是共享的,因为它只有一个运行实例,并且这个实例被两个对等线程引用。变量myid不是共享的,因为它的两个实例中每一个只被一个线程引用。
致谢
1、《深入理解计算机系统》[第3版],作者 Randal E.Bryant, David R.O`Hallaron 译者 龚奕利 贺莲