UNIX网络编程卷一 学习笔记 第二十六章 线程

在传统UNIX模式中,当一个进程需要另一个实体完成某事时,它就fork一个子进程,并让子进程去执行处理,Unix上大多网络服务器程序就是这么写的。

这种范式多年来一直用得很好,但fork调用存在一些问题:
1.fork调用代价大。fork函数要把父进程的内存映像复制到子进程,并在子进程中复制所有描述符,如此等等。当今实现使用写时复制技术,用以避免在子进程在真正需要自己的副本前就把父进程的数据空间复制到子进程。但即使有这样的优化措施,fork调用仍是昂贵的。

2.fork函数返回后父子进程之间信息的传递需要进程间通信(IPC)机制。调用fork前父进程向尚未存在的子进程传递信息很容易,因为子进程将获得父进程数据空间和所有描述符的一个副本,但从子进程往父进程返回信息比较费力。

线程有助于解决这两个问题,线程有时称为轻权进程(lightweight process,轻量级进程),线程的创建可能比进程的创建快10~100倍。

同一进程内所有线程共享相同的全局内存,这使得线程之间易于共享信息,但伴随这种简易性而来的却是同步问题。

同一进程内所有线程除了全局变量外还共享:
1.进程指令。

2.大多数数据。

3.打开的文件(即描述符)。

4.信号处理函数和信号处置。

5.当前工作目录。

6.用户ID和组ID。

但每个线程有各自的:
1.线程ID。

2.寄存器集合,包括程序计数器和栈指针。

3.栈(用于存放局部变量和函数的返回地址)。

4.errno。

5.信号掩码。

6.优先级。

信号处理函数可被类比作某种线程,即在传统的UNIX模型中,我们有主执行流(也称主控制流,即一个线程)和某个信号处理函数(另一个线程)。如果主执行流正在更改某个链表时发生一个信号,而该信号的处理函数也试图更改该链表,则后果通常是灾难性的,主执行流和信号处理函数共享同样的全局变量,但它们有各自的栈。

我们本章讲解POSIX线程,也称为Pthread,POSIX线程作为POSIX.1c标准的一部分在1995年得到标准化,大多UNIX版本将来会支持这类线程。所有Pthread函数都以pthread_打头。

当一个程序由exec函数启动执行时,称为初始线程或主线程的单个线程就创建了,其余线程通过pthread_create函数创建:
在这里插入图片描述
一个进程内的每个线程都由一个线程ID标识,其数据类型为pthread_t(往往是unsigned int),如果新线程成功创建,其ID就通过tid参数指针返回。

每个线程都有许多属性:优先级、初始栈大小、是否应成为一个守护线程等。我们可以在创建线程时通过初始化一个取代默认设置的pthread_attr_t变量指定这些属性,如果attr参数为空指针,则采用默认设置。

创建一个线程时func参数是由该线程执行的函数,arg参数是该函数的参数,该线程通过调用这个函数开始执行,然后或者显式地终止(调用pthread_exit),或者隐式终止(让该函数返回)。func参数指定的函数只接受1个参数arg,如果我们需要给该函数传递多个参数,就要把这些参数打包成一个结构,然后把这个结构的地址作为单个参数传递给这个初始函数。

func参数指向的函数接受一个void *指针参数,同时还返回一个void *指针,这使得我们可以把一个指针(可指向任何我们期望的内容)传递给线程,又允许线程返回一个指针(同样指向任何我们期望的内容)。

通常Pthread函数的返回值为0表示成功,非0表示出错。与套接字函数及大多数系统调用出错时返回-1并设置errno为某个正值所不同的是,Pthread函数出错时返回正值错误指示,如pthread_create函数因线程数目超过某个系统限制而不能创建新线程时返回EAGAIN。Pthread函数不设置errno。Pthread函数成功时返回0,失败时返回非0不成问题,因为sys/errno.h头文件中所有Exxx值都是正值,0值从来不被赋予任何Exxx。

我们可通过调用pthread_join等待一个给定线程终止,对比线程与UNIX进程,pthread_create函数类似于fork函数,pthread_join函数类似于waitpid函数。
在这里插入图片描述
调用pthread_join时,我们必须指定要等待线程的tid。Pthread没有办法等待任一线程终止(类似指定进程ID为-1调用waitpid)。

如果status参数指针非空,则来自所等待线程的返回值将存入status参数指向的位置。

每个线程都由一个在所属进程内标识自身的ID,线程ID由pthread_create函数返回,每个线程也可调用pthread_self获取自身线程ID:
在这里插入图片描述
对比线程和UNIX进程,pthread_self函数类似于getpid函数。

一个线程要么是可汇合的(joinable,默认值),要么是脱离的(detached)。当一个可汇合的线程终止时,它的线程ID和退出状态将留存到另一个线程对它调用pthread_join。脱离的线程就像守护进程(创建守护进程的进程一般创建完就退出了,因此一般守护进程(daemon)的父进程会是init进程(pid为1)),当它终止时,所有相关资源都被释放,我们不能等待它终止。如果线程A需要知道线程B什么时候终止,最好将线程B保持默认可汇合。

pthread_detach函数把指定线程转变为脱离状态:
在这里插入图片描述
pthread_detach函数通常由想让自己脱离的线程调用:

pthread_detach(pthread_self());

让一个线程终止的方法之一是调用pthread_exit:
在这里插入图片描述
如果调用pthread_exit的线程未曾脱离,它的线程ID和退出状态将一直留存到调用进程内的某个其他线程对它调用pthread_join。

参数status不能指向调用线程的本地对象,因为线程终止时这样的对象也会消失。

让一个线程终止的其他方法:
1.启动线程的函数(pthread_create函数的第三个参数)返回,该函数的返回值是void指针,即相应线程的终止状态。

2.如果进程的main函数返回,或任何线程调用了exit,整个进程就终止,包括其中的线程。

我们把TCP回射客户程序中的str_cli函数改写为使用线程的:
UNIX网络编程卷一 学习笔记 第二十六章 线程_第1张图片
以下是TCP回射客户程序中的str_cli函数的使用线程的版本:

// unpthread.h头文件中包含unp.h头文件,以及POSIX的pthread.h头文件
// 然后定义了我们为pthread_XXX函数编写的包裹函数的函数原型,这些包裹函数都以Pthread_打头
#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;

    // 我们即将创建的线程需要str_cli的两个参数,为简单起见,我们此处将其保存到外部变量中
    // 另外的方法是将这两个值放到一个结构中,然后把指向这个结构的指针作为参数传递给我们将要创建的线程
    sockfd = sockfd_arg;    /* copy arguments to externals */
    fp = fp_arg;

    // 创建线程,新线程id返回到tid变量中,新线程会执行copyto函数,没有参数传递给该线程
    Pthread_create(&tid, NULL, copyto, NULL);

    // 主线程调用readline和fputs,把从套接字读入的文本行复制到标准输出
    while (Readline(sockfd, recvline, MAXLINE) > 0) {
        Fputs(recvline, stdout);
    }
    // 当str_cli函数返回时,main函数会调用exit终止进程,进程内所有线程也随之被终止
    // 通常,copyto线程在从标准输入读到EOF时已经先于main函数的exit调用而终止
    // 但如果服务器过早终止,尚未读入EOF的copyto线程就会由main函数调用exit来终止
}

void *copyto(void *arg) {
    char sendline[MAXLINE];

    // 该线程只是把读自标准输入的文本行复制到套接字
    while (Fgets(sendline, MAXLINE, fp) != NULL) {
        Writen(sockfd, sendline, strlen(sendline));
    }

    // 当在标准输入上读到EOF时,它通过调用shutdown从套接字送出FIN
    Shutdown(sockfd, SHUT_WR);    /* EOF on stdin, send FIN */

    // 从启动该线程的函数return来终止该线程
    return NULL;    /* return (i.e., thread terminates) when EOF on stdin,i.e.的意思是即,也就是说 */
}

之前的回射客户程序中,当期待服务器回射文本行却收到EOF时,客户会显示server terminated prematurely,我们可以把以上线程版本也改为输出此消息,这个消息应该在主线程收到EOF,但另一个线程却还在运行时显示,一个简单的方法是声明名为done且初始化为0的外部变量,线程copyto在返回前把该变量设为1,主线程收到EOF后检查该变量,如果其值为0就显示此出错消息,由于设置该变量的线程只有1个,就没有同步的必要。

在第十六章中,我们对str_cli函数的各种版本进行了性能测量:
UNIX网络编程卷一 学习笔记 第二十六章 线程_第2张图片
我们看到,线程版本略快于fork版本,但仍慢于非阻塞式IO版本,但对比非阻塞式IO版本的复杂性和线程版本的简单性,我们仍推荐使用线程版本,而不是非阻塞式IO版本。

重新编写TCP回射服务器程序,改为每个客户使用一个线程,我们同样适用自己的tcp_listen函数使该程序与协议无关:

#include "unpthread.h"

static void *doit(void *);    /* each thread executes this function */

int main(int argc, char **argv) {
    int listenfd, connfd;
    pthread_t tid;
    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], argv[2], &addrlen);
    } else {
        err_quit("usage: tcpserv01 [  ] ");
    }

    cliaddr = Malloc(addrlen);

    for (; ; ) {
        len = addrlen;
        connfd = Accept(listenfd, cliaddr, &len);
        // 我们传递给doit函数的唯一参数是已连接套接字描述符connfd
        // 此处我们把整数描述符的类型强制转换成void指针,ANSI C不保证这能成功
        // 只有在整数大小大小等于指针大小的系统上,这种类型强制转换才能成功,所幸大多UNIX实现都是这样
        Pthread_create(&tid, NULL, &doit, (void *)connfd);
        // 主线程不像fork版本那样关闭已连接套接字,因为同一进程内所有线程共享描述符
        // 如果主线程调用close,就会终止相应连接
        // 创建新线程不影响已打开文件描述符的引用计数,这一点不同于fork
    }
}

static void *doit(void *arg) {
    // 先让自身脱离,因为主线程没有理由等待它创建的每个线程
    Pthread_detach(pthread_self());
    str_echo((int)arg);    /* same function as before */
    // 处理完后,close已连接套接字,因为本线程和主线程共享所有描述符
    // 如果此处不调用close,则此时客户发送了FIN,且服务器对该FIN发了ACK,此时连接处于半关闭状态
    // 服务器在半关闭状态下还可以发送数据,但此处我们没有发送,客户会一直处于FIN_WAIT_2状态
    // 源自Berkeley的实现在客户端保持FIN_WAIT_2状态超过11分钟后会超时断连,服务器最终可能会耗尽描述符
    // 对于使用fork函数的情形,子进程就不必close已连接套接字,因为子进程终止时,所有打开描述符都被关闭
    Close((int)arg);    /* done with connected socket */
    return NULL;
}

以上程序把整数变量connfd类型强制转换为void指针,这一点不保证在所有系统上都能成功。注意此处我们不能把connfd的地址传给新线程,即以下代码是错误的:

int main(int argc, char **argv) {
    int listenfd, connfd;
    ...
    
    for (; ; ) {
        len = addrlen;
        connfd = Accept(listenfd, cliaddr, &len);
        
        Pthread_create(&tid, NULL, &doit, &connfd);
    }
}

static void *doit(void *arg) {
    int connfd;
    
    connfd = *((int *)arg);
    Pthread_detach(pthread_self());
    str_echo(connfd);    /* same function as before */
    Close(connfd);    /* done with connected socket */
    return NULL;
}

以上程序从ANSI C角度看是可接受的,ANSI C保证我们能把一个整数指针类型强制转换为void指针,然后把这个void指针类型强制转换回原来的整数指针,但问题出在这个整数指针指在什么上。

主线程中只有一个整数变量connfd,每次调用accept该变量都会被覆写以一个新的已连接套接字描述符,因此可能发生以下情况:
1.accept函数返回,主线程把返回值(如5)存入connfd后调用pthread_create,pthread_create函数的最后一个参数是指向connfd的指针而非connfd的内容。

2.Pthread函数库创建一个线程,并准备调度doit函数启动执行。

3.另一个连接就绪且主线程在新创建的线程开始运行前再次运行,accept函数返回,主线程把返回值(如6)存入connfd后调用pthread_create。

尽管主线程一共创建了2个线程,但它们操作的都是存放在connfd中的值(上例中为6)。问题出在多个线程不是同步地访问一个共享变量。在直接将connfd转换为void指针值传递给pthread_create函数的例子中,是不存在该问题的,按照C向被调用函数传递整数值的方式(即把该值的一个副本压入被调用函数的栈中),这个解决方法是可行的。

以下是解决以上传参问题更好的方法:

#include "unpthread.h"

static void *doit(void *);    /* each thread executes this function */

int main(int argc, char **argv) {
    int listenfd, *iptr;
    pthread_t tid;
    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], argv[2], &addrlen);
    } else {
        err_quit("usage: tcpserv01 [  ] ");
    }

    cliaddr = Malloc(addrlen);

    for (; ; ) {
        len = addrlen;
        // 每当调用accept前,先调用malloc分配一个整数变量的内存空间
        // 其中存放待accept函数返回的已连接描述符,这使得每个线程都由各自的已连接描述符副本
        iptr = Malloc(sizeof(int));
        *iptr = Accept(listenfd, cliaddr, &len);
        Pthread_create(&tid, NULL, &doit, iptr);
    }
}

static void *doit(void *arg) {
    int connfd;

    // 获取已连接描述符的值,然后释放内存空间
    connfd = *((int *)arg);
    free(arg);

    Pthread_detach(pthread_self());
    str_echo(connfd);    /* same function as before */
    Close(connfd);    /* done with connected socket */
    return NULL;
}

以上函数中,malloc和free函数历史上是不可重入的,在处于这两个函数之一的内部处理期间,从某个信号处理函数中调用这两个函数之一有可能导致灾难性后果,因为这两个函数操纵相同的静态数据结构。但我们现在可以安全地调用它们,因为POSIX要求以上两函数是线程安全的,这通常是库函数内做了一些对我们透明的某种同步机制。

POSIX.1要求由POSIX.1和ANSI C标准定义的所有函数都是线程安全的,但除了以下函数:
UNIX网络编程卷一 学习笔记 第二十六章 线程_第3张图片
但POSIX未对网络编程API函数的线程安全性作出规定,上表最后5行来源于Unix 98。在第十一章中讨论过gethostbyname和gethostbyaddr的不可重入性质,当时提到有些厂家定义了这两个函数以_r结尾的线程安全版本,但这些线程安全函数没有标准可循,应避免使用。

从上图可知,很多函数定义了一个名字以_r结尾的新函数作为其线程安全的版本。而ctermid和tmpnam函数的线程安全条件是调用者为返回结果预先分配空间,并把指向该空间的指针作为参数传递给函数。

把一个程序转换成使用线程的版本时,有时会碰到因有些函数使用静态变量而引起的编程错误,和许多线程相关的编程错误相同,这个错误也会引起非确定性的结果。在无需考虑重入的环境下编写使用静态变量的函数无可非议,但当同一进程内的不同线程(信号处理函数也视为线程,但信号处理函数运行时,主控制流是暂停的)几乎同时调用这样的函数时就可能发生问题,因为这些函数使用的静态变量没有为不同线程保存各自的值。第三章中的readline函数就是非线程安全的:

#include "unp.h"

static int read_cnt;
static char *read_ptr;
static char read_buf[MAXLINE];

// 每次最多读MAXLINE个字节,每次返回一个字节
static ssize_t my_read(int fd, char *ptr) {
    if (read_cnt <= 0) {
    again:
        if ((read_cnt = read(fd, read_buf, sizeof(read_buf))) < 0) {
            if (errno == EINTR) {
                goto again;
            }
            return -1;
        } else if (read_cnt == 0) {
            return 0;
        }
        read_ptr = read_buf;
    }
    
    read_cnt--;
    *ptr = *read_ptr++;
    return 1;
}

// readline函数本身唯一的变化是用my_read函数取代read函数
ssize_t readline(int fd, void *vptr, size_t maxlen) {
    ssize_t n, rc;
    char c, *ptr;
    
    ptr = vptr;
    for (n = 1; n < maxlen; ++n) {
        if ((rc = my_read(fd, &c)) == 1) {
            *ptr++ = c;
            if (c == '\n') {
                break;    /* new line is stored, like fgets() */
            }
        } else if (rc == 0) {
            *ptr = 0;
            return n - 1;    /* EOF, n - 1 bytes were read */
        } else {
            return -1;
        }
    }
    
    *ptr = 0;    /* null terminate like fgets() */
    return n;
}

// 此函数可以展露内部缓冲区的状态,便于调用者查看在当前文本行后是否收到了新数据
ssize_t readlinebuf(void **vptrptr) {
    if (read_cnt) {
        *vptrptr = read_ptr;
    }
    return read_cnt;
}

以上程序中的my_read函数使用3个静态变量,这些静态变量是为了增加性能而增设的,以上编程错误是在将现有的函数转换成在线程环境运行时经常碰到的问题,有多个解决方法:
1.使用线程特定数据。此方法并不简单,且将原函数转换成了只能在支持线程的系统上工作的函数,但本方法的优点是调用顺序无需变动,所有变动都体现在库函数中而非调用这些函数的应用中。

2.改变调用顺序,由调用者把readline函数的所有参数封装在一个结构中,并在该结构中存放以上程序中的3个静态变量,下图给出了新的结构和新的函数原型:
UNIX网络编程卷一 学习笔记 第二十六章 线程_第4张图片
上图中新函数在支持线程和不支持线程的系统上都可使用,但调用readline的所有应用都需要修改调用方式。

3.改变接口结构,避免使用静态变量,这样函数就变成了线程安全的。对于上例,我们使用my_read函数是为了获得性能提升,我们可使用较老版本的readline(把my_read函数替换回read函数,每次使用read函数读1字节),但老版本极为低效,这个方法可能行不通。

使用线程特定数据是使现有函数变为线程安全的一个常用技巧。在介绍操纵线程特定数据的Pthread函数前,先说明一下线程特定数据这个概念本身和一个可能的实现,因为看起来这些函数很复杂,但实际并非如此。

线程特定数据相关的Pthread函数看起来复杂,部分复杂性源于许多关于线程使用的教材都把对线程特定数据的讲解写得读起来像是在描述Pthread标准本身,把键值对和键作为不透明对象来讨论(即忽略内部实现,只讲表现形式)。我们以索引和指针来描述线程特性数据,因为一般会实现把一个小整数索引用作键,与索引关联的值只是一个指向由线程malloc的某个内存区的指针。

每个系统支持有限数量的线程特定数据元素,POSIX要求每个进程的这个限制不小于128,后面的例子就采用128这个限制。系统(可能是线程函数库)为每个进程维护一个我们称之为Key的结构组成的结构数组:
UNIX网络编程卷一 学习笔记 第二十六章 线程_第5张图片
Key结构中的标志字段指示这个数组元素是否正在使用,所有的标志都会初始化为不在使用。当线程调用pthread_key_create创建一个新线程特定元素时,系统搜索Key结构数组找出第一个不在使用的元素,该元素的索引(0~127)我们称为键,返回给调用线程的正是这个索引。

除了Key结构数组外,系统还在进程内维护了每个线程的多条信息,这些特定于线程的信息我们称为Pthread结构,其部分内容是我们称之为pkey数组的一个128个指针元素的数组:
UNIX网络编程卷一 学习笔记 第二十六章 线程_第6张图片
每个线程的pkey数组的所有元素都被初始化为空指针,这128个指针与进程内的128个可能的键逐一关联,即这128个指针是键对应的该线程中的值。

当我们调用pthread_key_create创建一个键时,系统告诉我们这个键(即索引),每个线程可以随后为该键存储一个值(指针),而这个指针通常又是每个线程通过调用malloc获得的。线程特定数据中易于混淆的地方之一是:该指针是键值对中的值,但真正的线程特定数据却是该指针指向的任何内容。

我们现在查看一个如何使用线程特定数据的例子,以上述readline函数为例:
1.一个进程被启动,多个线程被创建。

2.其中一个线程(线程0)是首个调用readline函数的线程,在readline函数中,会调用pthread_key_create,系统会在Key数组中找到第一个未用元素,并把它的索引(0~127)返回给调用者,我们假设找到的索引为1。

我们将使用pthread_once函数确保pthread_key_create函数只被第一个调用readline的线程所调用。

3.readline函数调用pthread_getspecific获取本线程的pkey[1]值,返回值是空指针,readline函数于是调用malloc分配内存,用于为本线程保存特定于线程的信息。readline函数初始化完该内存,并调用pthread_setspecific把对应所创建键的线程特定数据指针pkey[1]设置为指向刚分配的内存区:
UNIX网络编程卷一 学习笔记 第二十六章 线程_第7张图片
上图我们指出,Pthread结构是系统(可能是线程函数库)维护的,而我们malloc的真正线程特性数据是由我们的函数维护的。pthread_setspecific函数所做的只是在Pthread结构中键对应的指针指向readline函数分配的内存区,类似地,pthread_getspecific函数所做的只是返回键对应的指针。

4.另一个线程(线程n)调用readline,此时也许线程0仍在执行readline函数。

readline调用pthread_once试图初始化它的线程特定数据所用的键,但初始化函数已被线程0调用过,因此就不再被调用。

5.线程n在readline函数中调用pthread_getspecific获取线程n的pkey[1]值,返回值是一个空指针,线程n于是像线程0那样先调用malloc,再调用pthread_setspecific,以初始化线程n的键1所对应的线程特定数据:
UNIX网络编程卷一 学习笔记 第二十六章 线程_第8张图片
6.线程n继续在readline函数中执行,使用和修改它自己的线程特定数据。

当一个线程终止时,如上例中调用readline的线程终止时,readline函数已经分配了一个需要释放的内存区,这正是Key结构中的析构函数指针的用处,一个线程调用pthread_key_create创建某个线程特定数据时,所指定的函数之一就是指向某个析构函数的指针,当线程终止时,系统将扫描该线程的pkey数组,为每个非空的pkey指针调用相应的析构函数。

处理线程特定数据时通常先调用pthread_once和pthread_key_create:
UNIX网络编程卷一 学习笔记 第二十六章 线程_第9张图片
当使用线程特定数据的函数被调用时,pthread_once函数会被该函数调用,pthread_once函数通过onceptr参数指向的变量,确保init参数所指的函数在进程范围内只被调用一次。

在进程范围内对于一个给定键,pthread_key_create函数只被调用一次,所创建的键通过keyptr指针参数返回,如果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) {
    // 创建一个线程特定数据的键存放在rl_key中
    pthread_key_create(&rl_key, readline_destructor);
}

ssize_t readline( ... ) {
    ...
    // 每次调用readline时,都会调用pthread_once
    // pthread_once函数使用onceptr参数指向的值(变量rl_once)来确保readline_once函数只被调用一次
    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 values pointed to by ptr */
}

函数pthread_getspecific和pthread_setspecific分别用于获取和存放某个键关联的值,该值就是Pthread结构中的指针,该指针具体指向取决于应用,通常它指向一个动态分配的缓冲区:
UNIX网络编程卷一 学习笔记 第二十六章 线程_第10张图片
pthread_key_create函数的参数是一个指向某个键的指针(因为该函数会在其中存放由系统赋予该键的值),而以上两个get和set函数的参数是键本身。

以下是使用线程特定数据的readline函数:

#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_key_create(&rl_key, readline_destructor);
}

// Rline结构含有原readline函数中导致问题的3个static变量
typedef struct {
    int rl_cnt;    /* initialize to 0 */
    char *rl_bufptr;    /* initialize to rl_buf */
    char rl_buf[MAXLINE];
} Rline;

// 第一个参数是预先为本线程分配的Rline结构的指针
static ssize_t my_read(Rline *tsd, int fd, char *ptr) {
    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) {
    size_t n, rc;
    char c, *ptr;
    Rline *tsd;

    // 本进程内第一个调用readline的线程通过调用pthread_once创建线程特定数据对应的键
    Pthread_once(&rl_once, readline_once);
    // 获取特定于本线程的Rline结构指针,如果这次是本线程首次调用readline,返回值将是空指针
    if ((tsd = pthread_getspecific(rl_key)) == NULL) {
        tsd = Calloc(1, sizeof(Rline));    /* init to 0 */
        // 为本线程存储这个指向本线程特定数据的指针
        // 下次本线程调用readline时,pthread_getspecific函数将返回刚存储的指针
        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) {
            *ptr = 0;
            return n - 1;    /* EOF, n - 1 bytes read */
        } else {
            return -1;    /* error, errno set by read() */
        }
    }
    *ptr = 0;
    return n;
}

将第十六章中的web客户程序重新编写成使用线程的版本,而非原来的使用非阻塞connect函数的版本。改用线程后,我们可以让套接字停留在默认的阻塞模式,因为我们为每个连接都创建了一个线程,每个线程可以阻塞在它的connect调用中,内核(也可能是线程函数库)会在其阻塞时转而运行另外某个就绪的线程:

#include "unpthread.h"
// 除了通常的pthread.h头文件外,我们还包含thread.h头文件,因为我们除了使用Pthread线程外,还使用Solaris线程
#include     /* Solaris threads */

#define MAXFILES 20
#define SERV "80"    /* port number or service name */

// 我们在file结构中增加了一个f_tid成员来存放线程ID
// 我们在线程版本中不再使用select函数,因此不再需要描述符集和最大描述符值变量maxfd
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[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 *);
// home_page函数没有改变
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>   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]);

    nlefttoread = nlefttoconn = nfiles;
    nconn = 0;
    while (nlefttoread > 0) {
        // 如果还有文件没有创建线程开始读,且最大连接数量没有满,我们就创建一个线程去读该文件
        while (nconn < maxnconn && 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;
            // 每个新线程执行的函数是do_get_read,传递给它的参数是file结构的指针
            Pthread_create(&tid, NULL, &do_get_read, &file[i]);
            file[i].f_tid = tid;
            ++nconn;
            --nlefttoconn;
        }

        // 通过指定第一个参数为0调用Solaris线程函数thr_join,等待任何一个线程终止
        // Pthread没有提供等待任一线程终止的手段,pthread_join函数要求我们显式指定我们要等待的线程ID
        // Pthread解决本问题的方法较复杂,需要使用条件变量供即将终止的线程通知主线程自身何时终止
        // 我们给出的Solaris线程函数thr_join难以移植到所有环境,但我们还是使用它
        // 因为我们不希望引入条件变量和互斥锁而搞复杂对它的讨论
        // 还好我们可以在Solaris环境下混合使用Pthread线程和Solaris线程
        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 = (struct file *)vptr;

    // 建立TCP连接,套接字fd默认是阻塞式套接字,因此线程将阻塞在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);

    // 给服务器发送一个HTTP GET命令,其中会设置本文件的F_READING标志
    write_get_cmd(fptr);    /* write() the GET command */

    /* Read server's reply */
    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 */
}

作者Stevens曾在Usenet上抱怨pthread_join函数不能等待任一线程终止,一些参与过Pthread标准工作的人员为这个设计决策辩解说,在进程模型中存在父子关系,因此wait或waitpid函数具备等待任一子进程的能力是有意义的,而线程没有类似父子进程的层次关系,调用pthread_join等待终止的线程不一定是调用线程创建的,他们还补充说,如果有人需要等待任一线程,也可使用条件变量实现它(并不简单)。无论他们如何争辩,作者仍认为pthread_join函数的设计存在瑕疵。

对于以上程序,主循环在某个线程终止后,在主循环中递减nconn和nlefttoread,我们也能把这两个递减操作放在do_get_read函数中,让每个线程在即将终止前递减这两个计数器,但这么做是一个并发编程错误。

把计数器递减代码放在每个线程均执行的函数中的问题在于那两个变量是全局的,而不是特定于线程的。如果一个线程在递减某个变量的中途被挂起,而另一个线程执行并递减同一个变量,就可能导致错误。举例来说,假设C编译器将递减运算符转换成3条机器指令:从内存装载到寄存器、递减寄存器、从寄存器存储到内存,考虑如下可能的情形:
1.线程A运行,把nconn的值3装载到一个寄存器。

2.系统把运行线程从A切换到B,A的寄存器被保存,B的寄存器被恢复。

3.线程B执行--nconn相对应的3条指令,把新值2存储到nconn。

4.一段时间后,系统把运行线程从B切换回A,A的寄存器被恢复,A继续执行,把寄存器中的值从3减为2,再把2存储到nconn。

最终的结果本该为1,实际却为2,运行结果错误。

这种类型的并发编程错误很难被发现,原因如下:
1.这些编程错误很少导致错误的发生,但它们毕竟是错误,在持续运行中错误总会发生(墨菲定律)。

2.这些编程错误导致的运行差错难以再现(需要像以上描述的那样,两个线程同时修改同一共享数据,且修改数据到一半切换其他线程同时修改此数据)。

3.某些系统上递减运算符的硬件指令可能是原子的,即在这些系统中存在可递减内存中某个整数的单条硬件指令来替换我们上例中说的3指令序列,且这条指令的执行期间硬件不能被中断,但我们不能保证所有系统都是如此,因此可能会发生在一个系统上出问题,在另一个系统上却不出问题的现象。

我们称线程编程为并发编程或并行编程,因为多个线程可以并发地(或并行地)运行且访问相同变量。虽然我们上例讨论的是单CPU情形,但如果线程A和线程B同时运行在某多处理系统的不同CPU上,问题还是会存在。对于多进程编程,fork后父子进程除描述符外不共享任何东西,因此不会碰到这些并发编程问题,但在进程之间的共享内存区中,还是会碰到同类问题。

我们可以使用线程轻易复现以上问题,以下程序会创建两个线程,每个线程会递增同一个全局变量5000次:

#include "unpthread.h"

#define NLOOP 5000

int counter;    /* incremented by 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 counter NLOOP times.
     * The value of the counter should increse monotonically.
     */
    for (i = 0; i < NLOOP; ++i) {
        // 为强化出错的可能性,我们先将counter的当前值保存在val中,然后再显示它的新值,再存储这个新值
        val = counter;
        printf("%d: %d\n", pthread_self(), val + 1);
        counter = val + 1;
    }
    return NULL;
}

运行以上程序:
UNIX网络编程卷一 学习笔记 第二十六章 线程_第11张图片
注意系统首次从线程4切换到线程5时发生的错误,此时两个线程中存储到counter中的值都是518,这种错误在10000行输出中出现了多次。

如果我们运行以上程序多次,每次运行的结果应该都不同于前一次运行,如果我们把程序的输出重定向到磁盘文件,有时候就不会发生运行差错,因为不打印时程序运行地更快,线程间切换的机会也更少。试验中运行差错出现得最多的情形是:交互地运行该程序,把程序的输出写到慢速终端上,同时使用Unix的script程序把整个交互过程的输出保存到一个文件中。

以上讨论的多个线程更改一个共享变量的问题是最简单的问题,我们可以用一个互斥锁保护这个共享变量,访问该变量的前提是持有该互斥锁,按照Pthread,互斥锁是类型为pthread_mutex_t的变量,我们使用以下函数为一个互斥锁上锁和解锁:
UNIX网络编程卷一 学习笔记 第二十六章 线程_第12张图片
如果试图对已被另一线程锁住的互斥锁加锁,本线程将被阻塞,直到该互斥锁被解锁。

如果某个互斥锁变量是静态分配(内存分配发生在编译和链接时)的,我们必须把它初始化为常值PTHREAD_MUTEX_INITIALIZER。如果我们在共享内存区中分配一个互斥锁,那么必须通过调用pthread_mutex_init函数在运行时把它初始化。

有些系统(如Solaris)把PTHREAD_MUTEX_INITIALIZER定义为0,因而可以忽略初始化步骤,因为静态分配的变量会被自动初始化为0,但不是所有系统都可以忽略初始化步骤,因为其他系统(如Digital Unix)把初始化常值定义为非0。

以下程序使用单个互斥锁保护由两个线程共同访问的计数器:

#include "unpthread.h"

#define NLOOP 5000

int counter;    /* incremented by 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;

    /* 
     * Each thread fetches, prints, and increments the counter NLOOP times.
     * The value of the counter should increase monotonically.
     */
    for (i = 0; i < NLOOP; ++i) {
        // 线程在操纵counter变量前必须锁住该互斥量
        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;
}

使用互斥锁的开销有多大呢,我们把以上程序和不加互斥量版本的程序各运行50000次,并把输出定向到/dev/null,然后测量时间,结果使用互斥量的版本的CPU时间多10%,互斥锁上锁开销并不大。

互斥锁适用于防止同时访问某个共享变量,我们还需要另外某种在等待某个条件发生期间能让我们进入睡眠的东西,例如以上web客户程序中,我们想把Solaris的thr_join函数替换为pthread_join函数,但在知道某个线程已经终止前,我们不能调用这个Pthread函数,我们首先声明一个已终止线程的全局变量计数器,并使用一个互斥锁保护它:

int ndone;    /* number of terminated threads */
pthread_mutex_t ndone_mutex = PTHREAD_MUTEX_INITIALIZER;

我们接着要求每个线程在即将终止前使用互斥锁递增这个计数器:

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,这种方法称为轮询,它很浪费CPU时间。

我们需要一个让主循环进入睡眠,直到某个线程通知它有事可做才醒来的方法,条件变量结合互斥锁能提供这个功能。互斥锁提供互斥机制,条件变量提供信号机制。

按照Pthread,条件变量是类型为pthread_cond_t的变量,以下两函数使用条件变量:
UNIX网络编程卷一 学习笔记 第二十六章 线程_第13张图片
第二个函数名字中的signal一词并不指Unix的SIGxxx信号。

我们举例说明这些函数,回到我们的Web客户程序的例子,我们现在给计数器ndone同时关联一个条件变量和一个互斥锁:

int ndone;
pthread_mutex_t ndone_mutex = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t ndone_cond = PTHREAD_COND_INITIALIZER;

通过在持有该互斥量期间递增该计数器并发送信号到该条件变量,一个线程通知主循环自身即将终止:

Pthread_mutex_lock(&ndone_mutex);
++ndone;
Pthread_cond_signal(&ndone_cond);
Pthread_mutex_unlock(&ndone_mutex);

主循环阻塞在pthread_cond_wait调用中,等待某个即将终止的线程发送信号到与ndone关联的条件变量:

while (nlefttoread > 0) {
    while (nconn < maxnconn && nlefttoconn > 0) {
        /* find a file to read */
        ...
    }
    /* Wait for one 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 terminated thread */
            ...
        }
    }
    Pthread_mutex_unlock(&ndone_mutex);
}

主循环仍然只是在持有互斥锁期间检查ndone变量,然后,如果发现无事可做,就调用pthread_cond_wait,该函数把调用线程投入睡眠并释放调用线程持有的互斥锁,此外,当调用线程后来从pthread_cond_wait函数返回时(其他某线程发送信号到与ndone关联的条件变量后),该线程再次持有该互斥量。

为什么每个条件变量都要关联一个互斥锁呢,因为条件同时是线程之间共享的某个变量的值,允许不同线程设置和测试该变量要求有一个与该变量关联的互斥锁,例如,如果上例中没有使用互斥锁,则主循环将这样测试变量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,这样信号就丢失了,造成主循环永远阻塞在pthread_cond_wait调用中,等待永远不再发生的某事再次出现。

因此要求pthread_cond_wait函数被调用时其所关联的互斥锁必须是上锁的,该函数作为单个原子操作解锁该互斥量并把调用线程投入睡眠也是一样的原因,如果该函数不解锁该互斥锁且不在返回时再给它上锁,调用线程就需要自己做这些操作,测试变量ndone的代码将变为:

/* 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的值。

pthread_cond_signal函数通常唤醒在相应条件变量上的单个线程,有时一个线程直到自己应唤醒多个线程,此时它可以调用pthread_cond_broadcast唤醒等在相应条件变量上的所有线程:
UNIX网络编程卷一 学习笔记 第二十六章 线程_第14张图片
pthread_cond_timedwait函数允许线程设置一个阻塞时间限制,abstime参数是一个timespec结构,指定该函数必须返回时刻的系统时间,即到这个时间如果相应条件变量尚未收到信号也会返回,如果发生这样的超时,会返回ETIME错误。

abstime参数是一个绝对时间,而非时间增量,即abstime参数是函数应该返回时刻的系统时间,即从1970年1月1日UTC时间以来的秒数和纳秒数。这一点不同于select和pselect函数,它们指定的是从调用时刻开始到函数应该返回时刻的秒数和微秒数(对于pselect函数为纳秒数)。我们通常这样获取abstime参数值:调用gettimeofday获取当前时间的timeval结构值,再把它复制到一个timespec结构中,再加上期望的时间限制,如:

struct timeval tv;
struct timespec ts;

if (gettimeofday(&tv, NULL) < 0) {
    err_sys("gettimeofday error");
}
ts.tv_sec = tv.tv_sec + 5;    /* 5 seconds in future */
ts.tv_nsec = tv.tv_usec * 1000;    /* microsec to nanosec */

pthread_cond_timedwait(..., &ts);

使用绝对时间取代增量时间的优点是,如果该函数过早返回(可能是捕获了某信号),那么不必改动timespec结构参数的内容就能再次调用该函数,缺点是首次调用该函数前需要调用gettimeofday。

POSIX规范定义了一个名为clock_gettime的函数,它把当前时间返回为一个timespec结构。

我们现在重新编写以上web客户程序,把其中的Solaris函数thr_join改换为pthread_join函数,这样我们需要明确指定等待哪一个线程。我们还需要使用条件变量。

全局变量的唯一变动是增加一个新标志和一个条件变量:

#define F_JOINED 8    /* main has pthread_join'ed */

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并通知主循环:

    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 */
}

大多数变动发生在主循环中,以下是新版本的主循环:

    while (nlefttoread > 0) {
        while (nconn < maxnconn && 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 thread 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);
                
                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);
}

假设一个服务器同时服务100个客户,如果使用fork函数,会使用101个描述符,其中1个是监听套接字描述符,其余100个是已连接套接字描述符,这101个进程中每个只打开着一个描述符。如果是使用线程,则是单个进程中有101个描述符,每个线程各处理其中一个。

你可能感兴趣的:(UNIX网络编程卷一(第三版),unix,网络,学习)