UNP Chapter 23 - 线程
23.1. 概述
在传统的UNIX模型中,当一个进程需要由另一个实体执行某件事时,该进程派生(fork)一个子进程,让子进程去进行处理。UNIX下的大多数网络服务器程序都是这么编写的,这在我们的并发服务程序例子中可以看出:父进程接收连接,派生子进程,子进程处理与客户的交互。
虽然这种模式很多年来使用的很好,但是fork有一些问题:
1. fork是昂贵的。内存映像要从父进程拷贝到子进程,所有描述字要在子进程中复制等等。目前的实现使用一种称做写时拷贝(copy-on-write)技术,可避免父进程数据空间向子进程的拷贝,除非子进程需要自己的拷贝。尽管有这种优化技术,fork仍然是昂贵的。
2. fork子进程后,需要用进程间通信(IPC)在父子进程之间传递信息。fork之前的信息容易传递,因为子进程一开始就有父进程数据空间及所有描述字的拷贝。但是从子进程返回信息给父进程需要做更多的工作。
线程有助于解决这两个问题。线程有时候称为轻权进程(lightweight process),因为线程比进程"轻权"。也就是说,创建线程要比创建进程块10~100倍。
一个进程中的所有线程共享相同的全局内存,这使得线程很容易共享信息,但是这种简易性也带来了同步(synchronization)问题。一个进程中的所有线程不仅共享全局变量,而且共享:
1. 进程指令
2. 大多数数据
3. 打开的文件(如描述字)
4. 信号处理程序和信号处置
5. 当前工作目录
6. 用户ID和组ID
但是每个线程有自己的:
1. 线程ID
2. 寄存器集合,包括程序计数器和栈指针
3. 栈(用于存放局部变量和返回地址)
4. errno
5. 信号掩码
6. 优先级
23.2. 基本线程函数:创建和终止
讲述5个基本线程函数,利用他们代替fork重新编写我们的TCP客户-服务器程序
当一个程序由exec启动时,会创建一个称为初始线程(initial thread)或主线程(main thread)的单个线程。额外线程则由pthread_create函数创建。
#include <pthread.h>
int pthread_create(pthread_t * tid, const pthread_attr_t * attr, void * (*func)(void *), void * arg);
//return: success 0, failed Exxx
一个进程中的每个线程都由一个线程ID(thread ID)标识,其数据类型是pthread_t(常常是unsigned int)。如果新的线程创建成功,其ID将通过tid指针返回。
每个线程都有很多属性(attribute):优先级,起始栈大小,是否应该是一个守护线程,得等。当创建线程时,我们可通过初始化一个pthread_attr_t变量说明这些属性以覆盖缺省值。我们通常使用缺省值,在这种情况下,我们将attr参数说明为空指针。
最后,当创建一个线程时,我们要说明一个它将执行的函数。线程以调用该函数开始,然后或者显示地终止(调用pthread_exit)或者隐式的终止(让该函数返回)。函数的地址由func参数指定,该函数的调用参数是一个指针arg。如果我们需要多个调用参数,我们必须将它们打包成一个结构,然后将其地址当作唯一的参数传递给起始函数。
注意func和arg的声明,func函数取一个通用指针(void *)参数,并返回一个通用指针(void *)。这就使得我们可以传递一个指针(指向任何我们想要指向的东西)给线程,由线程返回一个指针(同样地,指向任何我们想要指向的东西)。
Pthread函数的返回值有两种:成功时返回为0,出错时返回非0。它与套接口函数及大多数系统调用出错时返回-1,并置errno为正值不同,Pthread函数返回正值指示错误。例如,如果pthread_create因为超过了对系统线程数目的限制而不能创建新线程,将返回EAGAIN,Pthread函数不设置errno。成功时返回0,出错时返回非0的约定没有问题,因为所有的在<sys/errno.h>头文件中的Exxx值都大于0。 0值永远不会赋给任何一个Exxx名字。
我们可以调用pthread_join等待一个线程终止。把线程和UNIX进程相比,pthread_create类似于fork,pthread_join类似与waitpid。
#include <pthread.h>
int pthread_join(pthread_t tid, void * * status); //返回:成功为0,出错为正的Exxx值
我们必须指定要等待线程的tid。很可惜,我们没有办法等待任意一个线程结束(类似于waitpid的进程ID参数为-1的情况)。我们在讨论图23.14时还将涉及这个问题。如果,status指针非空,线程的返回值(一个指向某个对象的指针)将存放在status指向的位置。
每个线程都有一个ID以在给定的进程内标识自己。线程ID由pthread_create返回,我们也看到了它在pthread_join中的使用。线程用pthread_self取得自己的线程ID。
#include <pthread.h>
pthread_t pthread_self(void); //返回:调用线程的线程ID
与UNIX进程相比,线程的pthread_self类似于getpid。
线程或者是可汇合的(joinable)或者是脱离的(detached)。当可汇合的线程终止时,其线程ID和退出状态将保留,直到另外一个线程调用pthread_join。脱离的线程则像守护进程:当它终止时,所有的资源都将释放,我们不能等待它终止。如果一个线程需要知道另一个线程什么时候终止,最好保留第二个线程的可汇合性。
pthread_detach函数将指定的线程变为脱离的。
#include <pthread.h>
int pthread_detach(pthread_t tid); //返回:成功为0,出错为正Exxx值
该函数通常被想脱离自己的线程调用,如: pthread_detach( pthread_self() );
终止线程的一种方法是调用pthread_exit
#include <pthread.h>
void pthread_exit(void * status);
如果线程未脱离,其线程ID和退出状态将一直保留到调用进程中的某个其他线程调用pthread_join。
指针status不能指向局部于调用线程的对象,因为线程终止时这些对象也消失。
有两种其他方法可使线程终止:
1. 启动线程的函数(pthread_create的第3个参数)返回。既然该函数必须说明为返回一个void指针,该返回值便是线程的终止状态。
2. 如果进程的main函数返回或者任何线程调用了exit,进程将终止,线程将随之终止。
23.3. 使用线程的str_cli函数
我们使用线程的第一个例子是用线程代替fork,重新编写图15.9中的str_cli函数。回想起来,我们提供了该函数的其他几个版本:最初使用停-等协议的图5.5,使用阻塞I/O和select函数的图6.13,以及从图15.3开始的使用非阻塞I/O的版本。图23.1示出我们线程版本的设计。
图23.2给出了使用线程的str_cli函数代码
#include "unpthread.h"
void * copyto(void *);
static int sockfd; /* global for both threads to access */
static FILE * fp;
void str_cli(FILE * fp_arg, int sockfd_arg)
{
char recvline[MAXLINE];
pthread_t tid;
sockfd = sockfd_arg; /* copy arguments to externals */
fp = fp_arg; /* 我们将要创建的线程需要的str_cli的两个参数:fp,输入文件的标准I/O FILE指针;和sockfd,连接到服务器的TCP套接口描述字。 */
/* 另外一个技巧是将这两个值放入一个结构中,然后将指向该结构的指针作为参数传给我们将要创建的线程 */
Pthread_create(&tid, NULL, copyto, NULL); /* 创建线程,新线程ID将存入tid。新线程将要执行的函数是copyto。没有参数传递给线程。 */
while(Readline(sockfd, recvline, MAXLINE) > 0) /* 主线程调用readline和fputs,把从套接口读入的每一行拷贝到标准输出。 */
Fputs(recvline, stdout);
} /* 当str_cli函数返回时,main函数调用exit终止,从而进程中的所有线程都被终止。通常情况下,另外一个线程在读到标准输入上的文件结束符时已经终止。但是为了预防服务器过早终止,我们调用exit终止另一个线程,这正是我们想要的效果 */
void * copyto(void * arg)
{ /* copyto线程:这个线程只将从标准输入读入的每一行拷贝到套接口。,当它在标准输入上读到文件结束符时,调用shutdown在套接口上发送FIN,之后线程返回。从该函数返回终止了线程。*/
char sendline[MAXLINE];
while(Fgets(sendline, MAXLINE, fp) != NULL)
Writen(sockfd, sendline, strlen(sendline));
Shutdown(sockfd, SHUT_WR); /* EOF on stdin, send FIN */
return(NULL);
/* return (i.e. thread terminates) when end-of-file on stdin */
}
23.4. 使用线程的TCP回射服务器程序
现在我们重新编写图5.2中的TCP回射服务器程序,我们采用一个客户对应一个线程而不是一个客户对应一个子进程。我们同样使用自己的tcp_listen函数使该程序与协议无关。图23.3给出的是服务器程序。
#include "unpthread.h"
static void * doit(void *); /* each thread executes this function */
int main(int argc, char * * argv)
{
int listenfd, connfd;
socklen_t addrlen, len;
struct sockaddr * cliaddr;
pthread_t tid;
if(argc == 2)
listenfd = Tcp_listen(NULL, argv[1], &addrlen);
else if (argc == 3)
listenfd = Tcp_listen(argv[1], argv[2], &addrlen);
else
err_quit("usage: tcpserv01[ <host> ] <service or port>");
cliaddr = Malloc(addrlen);
for( ; ; )
{
len = addrlen;
connfd = Accept(listenfd, cliaddr, &len);
Pthread_create(&tid, NULL, &doit, (void *) connfd); /* 当accept返回时,我们调用pthread_create而不是调用fork。我们传递给doit函数的唯一参数是已连接套接口描述字connfd */
}
}
static void * doit(void *arg)
{/* doit是线程执行的函数。线程脱离自己,因为主线程没有理由等待它创建的每个线程。 */
Pthread_detach(pthread_self());
str_echo( (int)arg ); /* same function as before */ /* 该函数返回时,我们必须close已连接套接口,因为本线程和主线程共享所有的描述字 */
Close( (int) arg ); /* we are done with connected socket */ /* 用fork时,子进程不必close已连接套接口,因为子进程终止后,其上所有打开的描述字都被关闭 */
return(NULL);
/* 另一值得注意的地方是主进程不关闭已连接套接口,而这是我们在调用fork的并发服务器程序中总要做的。原因是一个进程中的所有线程共享描述字,如果主线程调用close,连接将被终止。创建线程并不影响打开的描述字的引用个数,这个fork是不同的 */
}
我们将整数变量connfd强制成void指针并不能保证在所有系统上工作。要正确地处理这一点需要做额外的工作。
#include "unpthread.h"
static void * doit(void *); /* each thread executes this function */
int main(int argc, char * * argv)
{
int listenfd, * iptr;
socklen_t addrlen, len;
struct sockaddr * cliaddr;
if(argc == 2)
listenfd = Tcp_listen(NULL, argv[1], &addrlen);
else if (argc == 3)
listenfd = Tcp_listen(argv[1], arg[2], &addrlen);
else
err_quit("usage: tcpserv01 [ <host> ] <service or port>" );
cliaddr = Malloc(addrlen);
for( ; ; ) {
len = addrlen;
iptr = Malloc(sizeof(int));
/* 每当调用accept时,我们首先调用malloc给一个整型变量(已连接描述字)分配空间。这使得每个线程都有自己的已连接描述字拷贝 */
* iptr = Accept(listenfd, cliaddr, &len);
Pthread_create(NULL, NULL, &doit, iptr);
}
}
static void * doit(void * arg)
{
int connfd;
connfd = * ( (int *)arg );
free(arg); /* 线程获得已连接描述字的值,然后调用free释放内存空间*/
Pthread_detach(pthread_self());
str_echo(connfd); /* same function as before */
Close(connfd); /* we are done with connected socket */
return(NULL);
}
malloc和free函数历来是不可重入的。换句话说,从信号处理程序中调用这两个函数中的任何一个,同时主线程正在这两个函数中任何一个的处理过程中,那将会导致灾难性的后果,原因是这两个函数操纵相同的静态数据结构。 需要函数是线程安全的(thread-safe),通常通过在函数库中进行某种形式的同步来达到。
从该图我们看出,使一个函数线程安全的共同技巧是定义一个以_r结尾的新函数。只有当一个函数的调用者给结果分配空间,并将其指针作为参数传递给另外一个函数时,这两个函数才是线程安全的。
23.5. 线程特定数据
使用线程特定数据是使现成函数线程安全的常用技巧。在描述使用线程特定数据的Pthread函数之前,我们先描述概念和一个可能的实现,因为这些函数比它们实际看起来的要复杂得多。我们以下标和指针这两个术语刻画线程特定数据,因为在通常的实现中,键使用Key结构数组的小整数下标,以及键关联的值只是一个指向线程分配的存储区的指针。
每个系统支持有限数量的线程特定数据项。系统(很可能是线程库)为每个进程维护一个数据结构,我们称之为Key结构,如图23.7所示
Key结构中的标志指示这个数组元素是否正在使用,所有的标志初始化为"不在使用"。当一个线程调用pthread_key_create创建一个新线程特定数据项时,系统搜索其Key结构数组并找到第一个未被使用的项。其下标(0~127)称做键(key),这个下标返回给调用线程。我们很快谈到Key结构的另一个成员“析构函数指针”。
除了进程范围内的Key结构数组,系统还为进程的每个线程维护许多条信息。我们称之为Pthread结构,其中一部分是一个128个元素的指针数组,我们称之为pkey数组,图23.8展示了这些信息
pkey数组的所有项都被初始化为空指针。这128个指针是和进程的128个可能的键关联的值。
当我们用pthread_key_create创建一个键时,系统告诉我们它的键(下标)。每个线程随之能够给该键存储一个值(指针),而每个线程通常又是通过malloc得到这个指针的。在线程特定数据中,部分易混淆的地方是:指针是键-值对中的值,但线程特定数据却是该指针指向的任何东西。
现在我们完整地看一个如果使用线程特定数据的例子,这里假设我们的readline函数使用线程特定数据,跨越对该函数的连续调用维护每个线程的状态。我们很快将给出这个函数的代码,它是依循如下步骤修改原来的readline函数的结构。
1. 一个进程被启动,多个线程被创建。
2. 线程之一是第1个调用readline函数的线程,该函数再调用pthread_key_create。系统在图23.7的Key结构数组中找到第1个未用的项并返回其下标(0~127)给调用者。假设找到的下标是1。我们使用pthread_once函数来保证只有第一个调用readline的线程才调用pthread_key_create。
3. readline调用pthread_getspecific为该线程取得pkey[1]的值(图23.8中与键1对应的“指针”),返回值是一个空指针。于是readline调用malloc分配内存区域,该线程需要用它来跨越连续的readline调用保存本线程的线程特定的信息。readline初始化该内存区域,并且调用pthread_setspecific将线程特定数据指针(pkey[1])指向它刚刚分配的内存区域。我们假设调用线程是进程中的线程0,图23.9显示了这一过程。
在该图中,我们注意到Pthread结构是系统(可能是线程库)维护的,但是我们malloc的线程特定数据是由我们的函数(本例中是readline)维护的。pthread_setspecific所做的全部工作是将这个Pthread结构中指定键指针指向我们刚刚分配的内存区域。类似地,pthread_getspecific所做的全部工作是将这个指针返回给我们。
4. 另一个线程,假定是线程n,调用readline,这是线程0仍然在readline内执行。readline调用pthread_once为这个线程特定数据项初始化键,但因为该函数已经被调用,所以不再被调用。
5. readline调用pthread_getspecific为该线程取得pkey[1]指针,返回空指针。该线程于是调用malloc,像线程0一样为这个键(1)初始化线程特定数据。我们在图23.10中展示了这一点。
6. 线程n继续在readline中执行,使用和修改自己的线程特定数据。
我们没有解决的一个问题是:当一个线程终止时会发生什么情况?如果该线程调用过我们的readline函数,该函数所分配的内存区就需要释放。这正是图23.7中“析构函数指针”的用武之地。线程调用pthread_key_create创建线程特定数据时,该函数的参数之一是指向一个析构函数(destructor)的指针。当一个线程终止时,系统扫描该线程的pkey数组,调用与每个非空pkey指针相应的析构函数,“相应的析构函数”是指在图23.7的Key数组中存放的函数指针。这是线程终止时线程特定数据的释放方法。
通常用来处理线程特定数据的头两个函数是:pthread_once和pthread_key_create
#include <pthread.h>
int pthread_once(pthread_once_t * onceptr, void (*int)(void)); //return: success 0, error Exxx
int pthread_key_create(pthread_key_t * keyptr, void (*destructor)(void * value)); //return: success 0, error Exxx
通常每当一个使用线程特定数据的函数被调用时就要调用pthread_once,但pthread_once使用onceptr所指的变量来保证每个进程只调用一次init函数。
对于一个进程内的给定键,pthread_key_create只能被调用一次。键通过keyptr指针返回,如果相应destructor函数的参数不为空指针,而且线程为这个键存储了值,那么该线程终止时,destructor函数将被调用。
这两个函数的用法如下所示(不考虑错误返回):
pthread_key_t rl_key; pthread_once_t rl_once = PTHREAD_ONCE_INIT; void readline_destructor(void * ptr) { free(ptr); } void readline_once(void) { pthread_key_create(&rl_key, readline_destructor); } ssize_t readline(...) { ... pthread_once(&rl_once, readline_once); if( ( ptr = pthread_getspecific(rl_key) ) == NULL ) { ptr = Malloc(...); pthread_setspecific(rl_key, ptr); /* initialize memory pointed to by ptr */ } ... /* use the values pointed to by ptr */ }
每次readline被调用时,pthread_once都要被调用。这个函数使用onceptr参数指向的值(变量rl_once的内容)确保init函数只被调用一次。初始化函数readline_once为存放在rl_key中的键创建线程特定数据,readline的pthread_getspecific和pthread_setspecific将要用到该数据。
pthread_getspecific和pthread_setspecific函数用来获取和存放与一个键关联的值。该值就是我们在图23.8中称做“指针”的东西。该指针指向什么由应用程序决定,但它通常指向动态分配内存区域。
#include <pthread.h> void * pthread_getspecific(pthread_key_t key); //返回:指向线程特定数据的指针(可能为非空指针) int pthread_setspecific(pthread_key_t key, const void * value); //返回:成功时为0,出错时为正Exxx值
注意,pthread_key_create的参数是一个指向键的指针(因为该函数存放赋给键的值),而get和set函数的参数是键本身(很可能是我们前面讨论过的小整数下标)。
现在我们举一个使用线程特定数据的例子,我们把图3.17中的readline函数的优化版本转换为线程安全版本,而无需改变调用顺序。
图23.11是该函数的第1部分:pthread_key_t变量,pthread_once_t变量,readline_destructor函数,readline_once函数以及包含为每个线程维护的所有信息的Rline结构。
#include "unpthread.h" static pthread_key_t rl_key; static pthread_once_t rl_once = PTHREAD_ONCE_INIT; static void readline_destructor(void * ptr) { /* 我们的析构函数只释放为该线程分配的内存区域 */ free(ptr); } static void readline_once(void) { /* 我们将看到我们的一次性执行函数只被pthread_once调用一次,它只创建readline使用的键 */ Pthread_key_create(&rl_key, readline_destructor); } /* Rline结构包括三个变量,它们是图3.17声明为static从而引起问题的变量。Rline结构之一将由每个线程动态分配,然后由析构函数释放 */ typedef struct { int rl_cnt; /* initialize to 0 */ char * rl_bufptr; /* initialize to rl_buf */ char rl_buf[MAXLINE]; }Rline;
图23.12给出了实际的readline函数及其调用的my_read函数。该图是图3.17的修改版。
static ssize_t my_read(Rline * tsd, int fd, char *ptr) { /* 该函数的第一个参数是指向Rline结构的指针,该指针是为这个线程分配的(实际的线程特定数据) */ if(tsd->rl_cnt <= 0) { again: if( (tsd->rl_cnt = read(fd, tsd->rl_buf, MAXLINE) ) <0 ) { if(errno == EINTR) goto again; return(-1); } else if (tsd->rl_cnt == 0) return(0); tsd->rl_bufptr = tsd->rl_buf; } tsd->rl_cnt--; * ptr = * tsd->rl_bufptr++; return(1); } ssize_t readline(int fd, void * vptr, size_t maxlen) { int n, rc; char c, * ptr; Rline * tsd; Pthread_once(&rl_once, readline_once); /* 我们首先调用pthread_once,使得进程中第一个调用readline的线程调用pthread_once创建线程特定数据键 */ if( (tsd = pthread_getspecific(rl_key)) == NULL ) { /* pthread_getspecific为线程返回指向Rlien结构的指针。但是如果这是线程第一次调用readline,其返回值将是一个空指针。在这种情况下,我们分配一个Rline结构的空间,并且初始化其rl_cnt成员为0,然后我们调用pthread_setspecific为线程存储这个指针。下一次线程调用readline时,pthread_getspecific将返回这个刚存储的指针 */ tsd = Calloc(1, sizeof(Rline)); /* init to 0 */ Pthread_setspecific(rl_key, tsd); } ptr = vptr; for(n = 1; n < maxlen; n++) { if( (rc = my_read(tsd, fd, &c)) == 1 ) { * ptr ++ = c; if ( c == '\n') break; } else if (rc == 0) { if( n == 1 ) return(0); /* EOF, no data read */ else break; /* EOF, some data was read */ } else return(-1); /* error, errno set by read() */ } * ptr = 0; return(n); }
23.6. Web客户与同时连接
现在让我们再来看一看15.5节中Web客户程序的例子。我们要用线程代替非阻塞connect重新编码。使用线程后,套接口就可以停留在缺省的阻塞方式,但必须为每个连接创建一个线程。每个线程阻塞在connect调用上也没有关系,因为内核会运行一些就绪的其他线程。
/* 我们在包括通常的<pthread.h>之外还包括了<thread.h>,因为除了用到Pthreads,我们还需使用Solaris线程。*/ #include “unpthread.h" #include <thread.h> /* Solaris threads */ #define MAXLINE 20 #define SERV "80" /* port number or service name */ struct file { char * f_name; /* filename */ char * f_host; /* hostname or IP address */ int f_fd; /* descriptor */ int f_flags; /* F_xxx below */ pthread_t f_tid; /* thread ID */ /* 我们在file结构中增加了一个成员f_tid,即线程ID。其余代码和图15.15相似。在本线程版中,我们不使用select,所有不需要任何描述字集或变量maxfd */ } file[MAXFILES]; #define F_CONNECTING 1 /* connect() in progress */ #define F_READING 2 /* connect() complete; now reading */ #define F_DONE 4 /* all done */ #define GET_CMD "GET %s HTTP /1.0\r\n\r\n" int nconn, nfiles, nlefttoconn, nlefttoread; void * do_get_read(void *); void home_page(const char *, const char *); void write_get_cmd(struct file *); int main(int argc, char * * argv) { int i, n, maxnconn; pthread_t tid; struct file * fptr; if ( argc < 5 ) err_quit("usage: web < #conns > < IPaddr > <homepage> file1 ... "); maxnconn = atoi(argv[1]); nfiles = min(argc - 4, MAXFILES); for ( i = 0; i < nfiles; i ++) { file[i].f_name = argv[i + 4]; file[i].f_host = argv[2]; file[i].f_flags = 0; } printf("nfiles = %d\n", nfiles); home_page(argv[2], argv[3]); /* 被调用的home_page函数与图15.16相比没有改变 */ nlefttoread = nlefttoconn = nfiles; nconn = 0; while (nlefttoread > 0) { /* 如果允许我们创建另一个线程(nconn小于maxnconn),我们就创建一个线程。新线程执行的函数是do_get_read,函数参数是指向file结构的指针 */ while(conn < maxnconn && nlefttoconn > 0) { /* fine a file to read */ for( i = 0; i < nfiles; i++) if ( file[i].f_flags == 0 break; if ( i == nfiles ) err_quit("nlefttoconn = %d but nothing found", nlefttoconn); file[i].f_flags = F_CONNECTING; Pthread_create(&tid, NULL, &do_get_read, &file[i]); file[i].f_tid = tid; nconn++; nlefttoconn--; } /* 调用Solaris的线程函数thr_join,第一个参数为0表示我们等待任何一个线程终止。不幸的是Pthreads没有提供等待任一线程终止的方法;pthread_join函数只能让我们显示地说明希望等待的线程。我们将在23.9节中看到,Pthreads解决这一问题的方法要复杂的多:它要求我们使用一个条件变量,线程终止时可以通过它通知主线程 */ if( ( n = thr_join(0, &tid, (void * *) &fptr ) ) != 0 ) errno = n, err_sys("thr_join error"); nconn--; nlefttoread--; printf("thread id %d for %s done\n", tid, fptr->f_name); } exit(0); } void * do_get_read(void * vptr) { int fd, n; char line[MAXLINE]; struct file * fptr; fptr = (sturct file *) vptr; /* 由tcp_connect函数创建一个TCP套接口,建立一个连接。套接口是通常的阻塞方式套接口,所以线程在调用connect时会阻塞,直到连接建立。 */ fd = Tcp_connect(fptr->f_host, SERV); fptr->f_fd = fd; printf("do_get_read for %s, fd %d, thread %d\n", fptr->f_name, fd, fptr->f_tid); /* write_get_cmd构造HTTP GET命令并将它发送给服务器。我们不再给出这个函数的代码,它和图15.18的唯一区别是线程版本不调用FD_SET,不使用maxfd */ while_get_cmd(fptr); /* write() the GET command */ /* Read server's reply */ /* 服务器的应答随机被读取。当连接被服务器关闭时,设置F_DONE标志,函数返回,线程终止 */ for( ; ; ) { if( (n = Read(fd, line, MAXLINE) ) == 0 ) break; /* server closed connection */ printf("read %d bytes from %s\n", n, fptr->f_name); } printf("end-of-file on %s\n", fptr->f_name); Close(fd); fptr->f_flags = F_DONE; /* clears F_READING */ return(fptr); /* terminate thread */ }
23.7. 互斥锁
注意,在图23.14中,当一个线程结束时,主循环将nconn和nlefttoread分别减1,我们可以将这两个减1操作放到do_get_read函数中,以使每个线程在终止前的瞬间将这两个计数器减1.但这是一个微妙而意义重大的并发程序设计错误。
将减1计数器的代码放到每个线程均执行的函数中的问题是:这两个计数器是全局变量,而不是线程特有的。如果一个线程在减1变量的当中被挂起,另一个线程执行并减1同一个变量便会引起错误。例如,假设C编译器将减1操作符用3条机器指令来实现:从内存装载到寄存器,寄存器减1,从寄存器存入内存。考虑下述可能的情形:
1. 线程A运行,它将nconn的值(3)装入一个寄存器。
2. 系统将线程A切换到B。A’的寄存器被存储,B’的寄存器被恢复。
3. 线程B执行与C表达式nconn--相对应的三条指令存储新值2
4. 一段时间之后,系统将线程从B切回到A,A’的寄存器被恢复,A从原来离开的地方继续执行,即3条机器指令序列的第2条:寄存器中的值被减1,即从3变为2,并且2被存入nconn
最终的结果是nconn为2,而不是正确的1,这是错误的。
这类并行程序设计错误很难被发现,原因有很多: 首先,错误很少出现。然而这是一个错误,早晚会引起失败(墨菲定律Murphy’s Law);其次,错误很难重现,因为它依赖于许多非确定的事件时序。最后,在有些系统上,硬件指令可能是原子的,亦即存在一个硬件指令给内存中的整数减1(而不是我们前面假设的3条指令序列),而且这个指令在执行过程中不能被中断。但是不能保证所有系统都如此,所以代码在一个系统上工作,而在另一个系统上不工作。
我们称线程编程为并发编程(concurrent programming)或并行编程(parallel programming),因为多个线程可并发运行并访问相同的变量。虽然我们刚刚讨论的错误情形以单CPU系统为前提,但是如果线程A和线程B在多处理器系统的不同CPU上同时运行,潜在的错误同样存在。在通常的Unix编程中,我们没有遇到这种并发编程问题,因为用fork时,除了描述字外,父进程和子进程不共享任何东西。但是,当我们讨论进程间的共享内存时仍将遇到这类问题。
我们很容易用线程表现这个问题。图23.17是一个简单的程序,它创建两个线程,然后让每个线程执行5000次将一个全局变量加1的操作。为了增加问题出现的可能性,我们先取得counter的值,输出新值,然后存储新值。如果我们运行这个程序,可得到图23.16所示的输出
#include “unpthread.h” #define NLOOP 5000 int counter; /* this is incremented by the threads */ void * doit(void *); int main(int argc, char * * argv) { pthread_t tidA, tidB; Pthread_create(&tidA, NULL, &doit, NULL); Pthread_create(&tidB, NULL, &doit, NULL); /* wait for both threads to terminate */ Pthread_join(tidA, NULL); Pthread_join(tidB, NULL); exit(0); } void * doit(void * vptr) { int i, val; /* * Each thread fetches, prints, and increments the coounter NLOOP times. * The value of the counter should increase monotonically. */ for ( i = 0; i < NLOOP; i++) { val = counter; printf("%d: %d\n", pthread_self(), val + 1); counter = val + 1; } return(NULL); } /*图23.17 两个线程不正确地对全局变量进行加1操作 */
注意,第一次错误发生在系统从线程4切换到线程5的时候:值518被每个线程存储。这种错误在100000行输出中发生了许多次。
这种类型问题的不确定性在我们运行几次该程序后同样明显:每次运行的最终结果都和前一次运行的不同。如果我们将输出重定向到磁盘文件,有时就不出现错误,这是因为程序运行的快,在线程间切换的机会少。当我们交互式地运行程序,将输入写到(慢)终端上,同时用Unix script程序将输出保存到一个文件中时,错误发生的次数最多。
我们刚刚讨论的问题,即多个线程修改一个共享变量,是最简单的问题。解决方法是用一个互斥锁(mutex, 代表mutual exclusion)保护共享变量。只有我们持有该互斥锁才能访问该变量。在Pthreads中,互斥锁是类型为pthread_mutex_t的变量。我们用下面两个函数为互斥锁加锁和解锁。
#include <pthread.h> int pthread_mutex_lock(pthread_mutex_t * mptr); //return: success 0, failed Exxx int pthread_mutex_unlock(pthread_mutex_t * mptr); //return: success 0, failed Exxx
如果我们试图为一个已被其他线程锁住的互斥锁加锁,程序便会阻塞直到该互斥锁被解锁。
如果互斥锁变量是静态分配的,我们必须将它初始化为常值PTHREAD_MUTEX_INITIALIZER,我们将在27.8节中看到,如果在共享内存中分配一个互斥锁,我们必须在运行时调用pthread_mutex_init函数进行初始化。
图23.18是图23.17的修正版本,使用一个互斥锁在两个线程之间锁住计数器。
#include "unpthread.h" #define NLOOP 5000 int counter; /* this is incremented by the threads */ pthread_mutex_t counter_mutex = PTHREAD_MUTEX_INITIALIZER; void * doit(void *); int main(int argc, char * * argv) { pthread_t tidA, tidB; Pthread_create(&tidA, NULL, &doit, NULL); Pthread_create(&tidB, NULL, &doit, NULL); /* wait for both threads to terminate */ Pthread_join(tidA, NULL); Pthread_join(tidB, NULL); exit(0); } void * doit(void * vptr) { int i, val; for( i = 0; i < NLOOP; i++ ) { Pthread_mutex_lock(&counter_mutex); val = counter; printf("%d: %d\n", pthread_self(), val+1); counter = val + 1; Pthread_mutex_unlock(&counter_mutex); } return(NULL); }
23.8. 条件变量
互斥锁适于阻止对共享变量的同时访问,但是我们需要某种东西以使我们能够睡眠等待某种条件出现。让我们用一个例子说明这一点。回到23.6中Web客户程序的例子,用pthread_join代替Solaris的thr_join,但是我们在知道哪个线程终止之前无法调用这个Pthread函数。我们首先说明一个统计终止线程数目的全局变量,并且用一个互斥锁来保护。
int ndone /* number of terminated threads */ pthread_mutex_t ndone_mutex = PTHREAD_MUTEX_INITIALIZER;
我们要求每个线程终止时将该计数器加1,同时小心使用与之关联的互斥锁。
void * do_get_read(void * vptr) { ... Pthread_mutex_lock(&ndone_mutex); ndone++; Pthread_mutex_unlock(&ndone_mutex); return(fptr); /* terminate thread */ }
这么做一点没有问题,但我们怎样编写主循环?它需要持续地锁住该互斥锁并检查是否在任何线程终止。
while(nlefttoread > 0) { while(nconn < maxnconn && nlefttoconn > 0) { /* find a file to read */ ... } /* see if one of the threads is done */ Pthread_mutex_lock(&ndone_mutex); if(ndone > 0) { for( i = 0; i < nfiles; i++) { if(file[i].f_flags & F_DONE) { Pthread_join(file[i].f_tid, (void **)&fptr); /* update file[i] for terminated thread */ ... } } } Pthread_mutex_unlock(&ndone_mutex); }
虽然这种方法正确,但是它意味这主循环永远不进入睡眠,而要进行循环,每次循环都要检查ndone。这称做轮询(polling),会严重浪费CPU时间。
我们需要一中方法使得主循环进入睡眠,直到有一线程通知它某件事已就绪。条件变量(condition variable)加上互斥锁可以提供这种功能。互斥锁提供互斥机制,条件变量提供信号机制。
在Pthreads中,条件变量是一个pthread_cond_t类型的变量。条件变量使用下述两个函数:
#include <pthread.h> int pthread_cond_wait(pthread_cond_t * cptr, pthread_mutex_t * mptr); int pthread_cond_signal(pthread_cond_t * cptr); //return: success 0, failed Exxx
第二个函数的名字中“signal”一词不是指Unix的SIGxxx信号。
举例是解释这些函数的最容易的方法。回到我们的Web客户程序例子,现在我们给计数器ndone同时关联一个条件变量和一个互斥锁:
int ndone; pthread_mutex_t ndone_mutex = PTHREAD_MUTEX_INITIALIZER; pthread_cond_t ndone_cond = PTHREAD_COND_INITIALIZER;
线程在持有互斥锁的情况下通过增1计数器并且用条件变量发信号来通知主循环:
Pthread_mutex_lock(&ndone_mutex); ndone++; Pthread_cond_singal(&ndone_cond); Pthread_mutex_unlock(&ndone_mutex);
主循环阻塞在调用pthread_cond_wait上,以等待即将终止线程发送条件变量信号:
while(nletftoread > 0) { while(nconn < maxnconn && nlefttoconn > 0) { /* find a file to read */ ... } /* wait for aone of the threads to terminate */ Pthread_mutex_lock(&ndone_mutex); while(ndone == 0) Pthread_cond_wait(&ndone_cond, &ndone_mutex); for( i = 0; i < nfiles; i++) { if(file[i].f_flags & F_DONE) { Pthread_join(file[i].f_tid, (void **)&fptr); /* update file[i] for therminated thread */ ... } } Pthread_mutex_unlock(&ndone_mutex); }
注意,变量ndone仍然在持有互斥锁的情况下检查。然后,如果无事可做,pthread_cond_wait将被调用。这使调用线程进入睡眠并且释放持有的互斥锁。当线程从pthread_cond_wait返回时(在其他线程给它发送条件变量信号之后),它又重新持有该互斥锁。
为什么每个条件变量都要关联一个互斥锁?“条件”通常是线程间共享的某个变量的值,不同线程设置和测试该变量时要求由一个互斥锁控制。如果我们不用互斥锁,刚刚给出的例子代码中的主循环便会这样测试ndone:
/* wait for one of the threads to terminate */ while(ndone == 0) Pthread_cond_wait(&ndone_cond, &ndone_mutex);
这样就存在一种可能,在主循环测试ndone==0之后,调用pthread_cond_wait之前,最后一个线程给ndone加1,这样的话最后这个“信号”将丢失,因而主循环将永远阻塞在pthread_cond_wait上,等待永远不再发生的事情。
同样的原因,pthread_cond_wait必须在关联的互斥锁加锁的情况下调用,而且这个函数将解锁互斥锁和使调用线程进入睡眠作为一个单一的原子操作。要是该函数不解锁互斥锁,在返回时再给它加锁,线程将不得不自己为互斥锁解锁和加锁,程序代码将变为:
/* wait for one of the threads to terminate */ Pthread_mutex_lock(&ndone_mutex); while(ndone ==0) { Pthread_mutex_unlock(&ndone_mutex); Pthread_cond_wait(&ndone_cond, &ndone_mutex); Pthread_mutex_lock(&ndone_mutex); }
但是,同样存在最后一个线程在调用pthread_mutex_unlock和pthread_cond_wait之间终止,并给ndone值增1的可能性。
#include <pthread.h> int pthread_cond_broadcast(pthread_cond_t * cptr); int pthread_cond_timewait(pthread_cond_t * cptr, pthread_mutex_t * mptr, const struct timespec * abstime); //return: success 0, failed Exxx
通常地,pthread_cond_signal唤醒一个等待条件变量的线程。有时,线程知道多个线程应该被唤醒,在这种情况下,pthread_cond_broadcast将唤醒阻塞在该条件变量上的所有线程。
pthread_cond_timedwait允许一个线程设置阻塞时间的上限。abstime是一个timespec结构,指定函数必须返回的系统时间,即使条件变量信号还没有被发出。如果超时发生,则返回ETIME错误。
23.9. Web客户与并发连接(续)
现在重新编写图23.6中的Web客户程序。我们去掉Solaris的thr_join函数,用pthread_join调用代替。在该节中我们讨论过,我们必须精确地指定要等待的线程。为了做到这一点,我们将使用23.8节中描述的条件变量。
全局变量的唯一变化是增加了一个新标志和条件变量:
#define F_JOINED 8 /* main has pthread_joined */ int ndone; /* number of terminated threads */ pthread_mutex_t ndone_mutex = PTHREAD_MUTEX_INITIALIZER; pthread_cond_t ndone_cond = PTHREAD_COND_INITIALIZER;
do_get_read函数的唯一变化是给ndone增1,在线程终止之前通知主循环:
printf("end-of-file on %s\n", fptr->f_name); Close(fd); Pthread_mutex_lock(&ndone_mutex); fptr->f_flags = F_DONE; /* clears F_READING */ ndone++; Pthread_cond_signal(&ndone_cond); Pthread_mutex_unlock(&ndone_mutex); return(fptr); /* terminate thread */
大多数变化是在主循环中,图23.19给出的是主循环的新版本
while(nlefttoread > 0) { while(nconn < maxncconn && nlefttoconn > 0) { /* find a file to read */ for( i = 0; i < nfiles; i++) if(file[i].f_flags == 0) break; if( i == nfiles ) err_quit("nlefttoconn = %d but nothing found", nlefttoconn); file[i].f_flags = F_CONNECTING; Pthread_create(&tid, NULL, &do_get_read, &file[i]); file[i].f_tid = tid; nconn++; nlefttoconn--; } /* Wait for one of the threads to terminate */ Pthread_mutex_lock(&ndone_mutex); while(ndone == 0) /* 为了等待一个线程终止,我们等待ndone变为非0。测试必须在互斥锁上锁情况下进行,睡眠由pthread_cond_wait执行 */ Pthread_cond_wait(&ndone_cond, &ndone_mutex); for( i = 0; i < nfiles; i++) /*当一个线程终止时,我们扫描所有的file结构以找出相应的线程,调用pthread_join,然后设置新的F_JOINED标志 */ { if( file[i].f_flags & F_DONE ) { Pthread_join(file[i].f_fid, (void * * )&fptr); if(&file[i] != fptr) err_quit("file[i] != fptr"); fptr->f_flags = F_JOINED; /* clears F_DONE */ ndone--; nconn--; nlefttoread--; printf("thread %d for %s done\n", fptr->f_tid, fptr->f_name); } } Pthread_mutex_unlock(&ndone_mutex); } exit(0);
23.10. 小结