CSAPP学习笔记——Chapter12 并行编程

CSAPP学习笔记——Chapter12 并行编程

并发编程有着其独特的魅力,之前接触cuda编程的时候,感受到一些,没想到书里还有相关的内容。今天我们主要围绕进程,I/O多路复用,线程三种并发的方式,介绍并发编程的相关概念。并最终拓展chapter11讲中的echo服务器,使其能够处理多个客户端的连接请求。

基于进程的并发编程

一个构造并发服务器的最自然方法就是,在父进程中接受客户端的连接请求,然后创建一个新的子进程来为每个新客户端提供服务。

一个典型的过程如下:

image-20240127204038188

image-20240127204054713

基于进程的并发echo服务器

/* 
 * echoserverp.c - A concurrent echo server based on processes
 */
/* $begin echoserverpmain */
#include "csapp.h"
void echo(int connfd);

void sigchld_handler(int sig) //line:conc:echoserverp:handlerstart
{
    while (waitpid(-1, 0, WNOHANG) > 0)
	;
    return;
} //line:conc:echoserverp:handlerend

int main(int argc, char **argv) 
{
    int listenfd, connfd;
    socklen_t clientlen;
    struct sockaddr_storage clientaddr;

    if (argc != 2) {
	fprintf(stderr, "usage: %s <port>\n", argv[0]);
	exit(0);
    }

    Signal(SIGCHLD, sigchld_handler);
    listenfd = Open_listenfd(argv[1]);
    while (1) {
	clientlen = sizeof(struct sockaddr_storage); 
	connfd = Accept(listenfd, (SA *) &clientaddr, &clientlen);
	if (Fork() == 0) { 
	    Close(listenfd); /* Child closes its listening socket */
	    echo(connfd);    /* Child services client */ //line:conc:echoserverp:echofun
	    Close(connfd);   /* Child closes connection with client */ //line:conc:echoserverp:childclose
	    exit(0);         /* Child exits */
	}
	Close(connfd); /* Parent closes connected socket (important!) */ //line:conc:echoserverp:parentclose
    }
}
/* $end echoserverpmain */

代码的结构并不复杂,需要注意的是资源释放的问题:

  • 首先是调用sigchld_handler函数回收子进程,ChatGPT总结的很好,我直接引用他的回答。

    sigchld_handler函数是一个信号处理器,专门用来处理SIGCHLD信号。在基于进程的并发服务器中,父进程通常通过fork系统调用创建子进程来处理客户端请求。每个子进程处理完它的客户端请求后就会终止。当子进程终止时,它会向其父进程发送SIGCHLD信号。

    如果父进程不处理SIGCHLD信号,终止的子进程会变成僵尸进程(Zombie),占用系统资源。僵尸进程是已经结束但其进程描述符仍然存在,以便父进程读取子进程的退出状态的进程。如果僵尸进程过多,可能会耗尽系统资源。

    函数功能

    sigchld_handler函数通过调用waitpid函数来回收结束子进程的资源,防止僵尸进程的产生。它的工作流程如下:

    • waitpid(-1, 0, WNOHANG):调用waitpid来等待任何子进程结束。这里的-1表示等待任何子进程,0waitpid的选项,WNOHANG是一个非阻塞标志,它告诉waitpid如果没有已终止的子进程就立即返回,而不是阻塞等待。
    • while循环:通过循环调用waitpid来确保回收所有已终止的子进程。如果waitpid返回0,表示当前没有更多已终止的子进程可以回收,循环就会结束。

    为什么需要这个函数

    在并发服务器中,父进程需要持续运行以接受新的客户端连接。父进程不会主动等待子进程结束(因为这会阻塞父进程,导致无法接受新的连接)。因此,需要一种机制在子进程结束时通知父进程。SIGCHLD信号正是这种机制,它在子进程结束时被发送给父进程。通过为SIGCHLD信号设置一个处理器(如sigchld_handler),父进程可以在不阻塞的情况下回收子进程资源,避免僵尸进程的产生。

    设置信号处理器

    在主函数中通过调用Signal(SIGCHLD, sigchld_handler);设置了sigchld_handler为SIGCHLD信号的处理器,这样每当有子进程结束时,sigchld_handler就会被调用,从而回收子进程资源。这是确保并发服务器稳定运行的重要机制之一。

  1. 其次是父子进程各自关闭其connfd,因为父子进程各自都拥有其状态独立的副本,所以关闭时需要都关闭。代码第28,30,33行。

image-20240127205831314

基于I/O多路复用的并发编程

基于I/O多路复用的并发编程是一种在单个线程或进程中同时处理多个I/O操作的技术。它利用了操作系统提供的I/O多路复用系统调用(如selectpollepoll(Linux特有)等)来实现。这些系统调用允许程序监视多个文件描述符(FDs),以便检查它们是否有I/O操作可进行(如读取、写入或异常条件)。

工作原理

  • 监视文件描述符集合:程序通过指定一个或多个文件描述符集合给I/O多路复用系统调用,请求操作系统监视这些文件描述符。
  • 阻塞等待:程序执行I/O多路复用调用后,会阻塞在该调用上,直到一个或多个文件描述符准备好进行I/O操作,或者发生超时。
  • 事件通知:一旦有文件描述符准备好进行I/O操作,系统调用返回,程序就可以知道哪些文件描述符可以进行非阻塞I/O操作,并据此执行读取、写入或其他操作。

优点

  1. 效率和扩展性:能够在单个进程/线程中高效处理多个客户端连接,避免了为每个连接创建新进程/线程的开销,从而提高了系统的扩展性。
  2. 资源利用率:减少了因多线程/进程带来的上下文切换和内存使用,使得资源利用率更高。
  3. 简化设计:对于某些应用场景,使用I/O多路复用可以简化程序的设计,因为它只需要处理单个事件循环。
  4. 实时性:通过即时响应就绪的I/O事件,可以提高程序对网络事件的响应速度。

缺点

  1. 编程复杂性:相比于多线程/进程模型,基于I/O多路复用的程序通常逻辑更复杂,编写和维护难度更大。
  2. 调试困难:由于所有操作都在单个线程/进程中进行,调试和故障排除可能比多线程/进程模型更加困难。
  3. 性能瓶颈:所有的I/O操作都在单个线程/进程中处理,可能会成为性能瓶颈,尤其是在高负载时。
  4. 系统调用差异:不同的操作系统提供的I/O多路复用调用可能有所不同(如selectpollepoll),需要针对不同环境做适配。

应用场景

基于I/O多路复用的并发编程适用于I/O密集型且并发连接数较高的应用场景,如网络服务器、数据库系统等。在这些场景下,I/O多路复用能够帮助应用以较低的资源消耗高效地处理大量并发I/O操作。然而,对于计算密集型任务,多线程/进程模型可能更加适合,因为它们能够更好地利用多核CPU的计算能力。

基于线程的并发编程

线程,它是上面两种方法的混合,结合了二者的优点。

线程(thread)就是运行在进程上下文中的逻辑流。在本书里迄今为止,程序都是由每个进程中一个线程组成的。但是现代系统也允许我们编写一个进程里同时运行多个线程的程序。线程由内核自动调度。每个线程都有它自己的线程上下文(thread context),包括一个唯一的整数线程ID(Thread ID,TID)、栈、栈指针、程序计数器、通用目的寄存器和条件码。所有的运行在一个进程里的线程共享该进程的整个虚拟地址空间。

image-20240127210116337

一个demo:

/* 
 * hello.c - Pthreads "hello, world" program 
 */
/* $begin hello */
#include "csapp.h"
void *thread(void *vargp);                    //line:conc:hello:prototype

int main()                                    //line:conc:hello:main
{
    pthread_t tid;                            //line:conc:hello:tid
    Pthread_create(&tid, NULL, thread, NULL); //line:conc:hello:create
    Pthread_join(tid, NULL);                  //line:conc:hello:join
    exit(0);                                  //line:conc:hello:exit
}

void *thread(void *vargp) /* thread routine */  //line:conc:hello:beginthread
{
    printf("Hello, world!\n");                 
    return NULL;                               //line:conc:hello:return
}                                              //line:conc:hello:endthread
/* $end hello */

《深入理解计算机系统》P692-694介绍了几个线程编程时的函数。

基于线程的echo并发服务器

/* 
 * echoservert.c - A concurrent echo server using threads
 */
/* $begin echoservertmain */
#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 <port>\n", argv[0]);
	exit(0);
    }
    listenfd = Open_listenfd(argv[1]);

    while (1) {
        clientlen=sizeof(struct sockaddr_storage);
        connfdp = Malloc(sizeof(int)); //line:conc:echoservert:beginmalloc
        *connfdp = Accept(listenfd, (SA *) &clientaddr, &clientlen); 						//line:conc:echoservert:endmalloc
        Pthread_create(&tid, NULL, thread, connfdp);
    }
}

/* Thread routine */
void *thread(void *vargp) 
{  
    int connfd = *((int *)vargp);
    Pthread_detach(pthread_self()); //line:conc:echoservert:detach
    Free(vargp);                    //line:conc:echoservert:free
    echo(connfd);
    Close(connfd);
    return NULL;
}
/* $end echoservertmain */

里面的一个小细节是在传递已连接描述符时,传递的是一个指针。然后再让线程引用这个指针,并把它赋给一个局部变量。

但是,如果我再传递已连接描述符时,采用的是这个版本,就有bug:

首先使用一个int接受connfd,

int connfdp; 
while (1) {
    clientlen=sizeof(struct sockaddr_storage);
    connfdp = Accept(listenfd, (SA *) &clientaddr, &clientlen); 				
    Pthread_create(&tid, NULL, thread, &connfdp);// 传的是这个int的地址
 }

然后再让线程间接引用这个地址,并把它赋给一个局部变量,

void *thread(void *vargp) 
{  
    int connfd = *((int *)vargp);
    Pthread_detach(pthread_self()); //line:conc:echoservert:detach
    Free(vargp);                    //line:conc:echoservert:free
    echo(connfd);
    Close(connfd);
    return NULL;
}

这个版本和上一个版本的唯一区别在于while循环内传递已连接描述符的方式,但是这个版本是有bug的,因为如果当子进程还未来得及赋值int connfd = *((int *)vargp);时,如果主函数就接受了下一个连接,那么此时子进程内vargp指向内容就是下一个连接的已连接描述符了。

image-20240127214035540

版本二只有一个地方保存connfd,如果子线程内还没有来得及读取里面的值,主线程就填入了新的值的话,会导致子线程保存了错误的已连接描述符,因此使用版本一的代码是正确的。

预线程化

之后书里介绍了线程的内存模型,大致是局部变量是自己私有的,但是全局变量和static变量是共享的。如果多个进程对全局变量进行读写,可能会引发一些错误,并举了一个例子。之后介绍了使用进度图和信号量来调度共享资源;再往后就是操作系统中的生产者消费者模型登场!

image-20240127215658391

因为插入和取出项目都涉及更新共享变量,所以我们必须保证对缓冲区的访问是互斥的。但是只保证互斥访问是不够的,我们还需要调度对缓冲区的访问。如果缓冲区是满的没有空的槽位),那么生产者必须等待直到有一个槽位变为可用。与之相似,如果缓冲区是空的(没有可取用的项目),那么消费者必须等待直到有一个项目变为可用。

生产者-消费者的相互作用在现实系统中是很普遍的。例如,在一个多媒体系统中生产者编码视频帧,而消费者解码并在屏幕上呈现出来。缓冲区的目的是为了减少视频流的抖动,而这种抖动是由各个帧的编码和解码时与数据相关的差异引起的。缓冲区为生产者提供了一个槽位池,而为消费者提供一个已编码的帧池。另一个常见的示例是图形用户接口设计。生产者检测到鼠标和键盘事件,并将它们插入到缓冲区中。消费者以某种基于优先级的方式从缓冲区取出这些事件,并显示在屏幕上。

生产者消费者模型

书里定义了一个SBUF包,实现了生产者消费者模型,非常的经典。

#ifndef __SBUF_H__
#define __SBUF_H__

#include "csapp.h"

/* $begin sbuft */
typedef struct {
    int *buf;          /* Buffer array */         
    int n;             /* Maximum number of slots */
    int front;         /* buf[(front+1)%n] is first item */
    int rear;          /* buf[rear%n] is last item */
    sem_t mutex;       /* Protects accesses to buf */
    sem_t slots;       /* Counts available slots */
    sem_t items;       /* Counts available items */
} sbuf_t;
/* $end sbuft */

void sbuf_init(sbuf_t *sp, int n);
void sbuf_deinit(sbuf_t *sp);
void sbuf_insert(sbuf_t *sp, int item);
int sbuf_remove(sbuf_t *sp);

#endif /* __SBUF_H__ */
/* $begin sbufc */
#include "csapp.h"
#include "sbuf.h"

/* Create an empty, bounded, shared FIFO buffer with n slots */
/* $begin sbuf_init */
void sbuf_init(sbuf_t *sp, int n)
{
    sp->buf = Calloc(n, sizeof(int)); 
    sp->n = n;                       /* Buffer holds max of n items */
    sp->front = sp->rear = 0;        /* Empty buffer iff front == rear */
    Sem_init(&sp->mutex, 0, 1);      /* Binary semaphore for locking */
    Sem_init(&sp->slots, 0, n);      /* Initially, buf has n empty slots */
    Sem_init(&sp->items, 0, 0);      /* Initially, buf has zero data items */
}
/* $end sbuf_init */

/* Clean up buffer sp */
/* $begin sbuf_deinit */
void sbuf_deinit(sbuf_t *sp)
{
    Free(sp->buf);
}
/* $end sbuf_deinit */

/* Insert item onto the rear of shared buffer sp */
/* $begin sbuf_insert */
void sbuf_insert(sbuf_t *sp, int item)
{
    P(&sp->slots);                          /* Wait for available slot */
    P(&sp->mutex);                          /* Lock the buffer */
    sp->buf[(++sp->rear)%(sp->n)] = item;   /* Insert the item */
    V(&sp->mutex);                          /* Unlock the buffer */
    V(&sp->items);                          /* Announce available item */
}
/* $end sbuf_insert */

/* Remove and return the first item from buffer sp */
/* $begin sbuf_remove */
int sbuf_remove(sbuf_t *sp)
{
    int item;
    P(&sp->items);                          /* Wait for available item */
    P(&sp->mutex);                          /* Lock the buffer */
    item = sp->buf[(++sp->front)%(sp->n)];  /* Remove the item */
    V(&sp->mutex);                          /* Unlock the buffer */
    V(&sp->slots);                          /* Announce available slot */
    return item;
}
/* $end sbuf_remove */
/* $end sbufc */

综合

image-20240128094326033

基于SBUF包实现一个预线程化的并发echo服务器,我补充了源码的注释“

/* 
 * echoservert_pre.c - A prethreaded concurrent echo server
 */
/* $begin echoservertpremain */
#include "csapp.h"
#include "sbuf.h"
#define NTHREADS  4
#define SBUFSIZE  16

void echo_cnt(int connfd){
    /* 
 * A thread-safe version of echo that counts the total number
 * of bytes received from clients.
 */
/* $begin echo_cnt */
#include "csapp.h"

static int byte_cnt;  /* Byte counter */
static sem_t mutex;   /* and the mutex that protects it */

static void init_echo_cnt(void)
{
    Sem_init(&mutex, 0, 1);
    byte_cnt = 0;
}

void echo_cnt(int connfd);
void *thread(void *vargp);

sbuf_t sbuf; /* Shared buffer of connected descriptors */

int main(int argc, char **argv) 
{
    int i, listenfd, connfd;
    socklen_t clientlen;
    struct sockaddr_storage clientaddr;
    pthread_t tid; 

    if (argc != 2) {
	fprintf(stderr, "usage: %s <port>\n", argv[0]);
	exit(0);
    }
    listenfd = Open_listenfd(argv[1]);

    sbuf_init(&sbuf, SBUFSIZE); //line:conc:pre:initsbuf  初始化SUBF
    for (i = 0; i < NTHREADS; i++)  /* Create worker threads */ 		      		
		Pthread_create(&tid, NULL, thread, NULL);
    //这里创建了NTHREADS个进程,进程开始执行,并阻塞在thread()函数的sbuf_remove()中,因为结合这个函数的源码我们可以知道在等待一个connfd。

    while (1) { 
        clientlen = sizeof(struct sockaddr_storage);
        connfd = Accept(listenfd, (SA *) &clientaddr, &clientlen);
        sbuf_insert(&sbuf, connfd); /* Insert connfd in buffer */
        //插入一个connfd,当插入完成之后,某个线程就可以拿到这个connfd了。
    }
}

void *thread(void *vargp) 
{  
    Pthread_detach(pthread_self()); 
    while (1) { 
	int connfd = sbuf_remove(&sbuf); /* Remove connfd from buffer */ 			         //line:conc:pre:removeconnfd
	echo_cnt(connfd);                /* Service client */
	Close(connfd); // 执行完不要忘记关闭连接
    }
}
/* $end echoservertpremain */
    
    
static int byte_cnt;  /* Byte counter 全局变量统计连接的总进程数*/ 
static sem_t mutex;   /* and the mutex that protects it */

static void init_echo_cnt(void)
{
    Sem_init(&mutex, 0, 1);
    byte_cnt = 0;
}
 /* $begin echo_cnt */
void echo_cnt(int connfd) 
{
    int n; 
    char buf[MAXLINE]; 
    rio_t rio;
    static pthread_once_t once = PTHREAD_ONCE_INIT;
    Pthread_once(&once, init_echo_cnt); //line:conc:pre:pthreadonce
    //上面执行初始化函数,这个函数只有第一个被调用的进程才会运行,之后的就什么都不做。
    Rio_readinitb(&rio, connfd);        //line:conc:pre:rioinitb
    while((n = Rio_readlineb(&rio, buf, MAXLINE)) != 0) {
        P(&mutex);
        byte_cnt += n; //line:conc:pre:cntaccess1
        printf("server received %d (%d total) bytes on fd %d\n", 
               n, byte_cnt, connfd); //line:conc:pre:cntaccess2
        V(&mutex);
        Rio_writen(connfd, buf, n);
    }
}
/* $end echo_cnt */

再往后作者介绍了分析线程并行性能的方法,这里我就不展开介绍了,在书的P710页。

其他并发问题

线程安全

当用线程编写程序时,必须小心地编写那些具有称为线程安全性(thread safety)属性的函数。一个函数被称为线程安全的(thread-safe),当且仅当被多个并发线程反复地调用时,它会一直产生正确的结果。如果一个函数不是线程安全的,我们就说它是线程不安全的(thread-unsafe)。

定义了四个线程不安全类:

  1. 使用静态或全局变量存储数据的函数

这类函数在内部使用静态存储区域(如静态变量或全局变量)来保存数据,因此在多个线程中共享。如果多个线程同时访问或修改这些数据,就可能导致数据不一致或竞争条件。例如:

  • strtok函数用来分割字符串,它内部使用了静态变量来记录当前的位置,因此在多线程环境中是不安全的。
  • 标准I/O函数(如printfscanf)在内部使用全局数据结构来管理缓冲区,如果不加锁,也可能是线程不安全的。
  1. 返回指向静态或内部数据结构的指针的函数

这类函数返回一个指向内部静态数据结构的指针,这意味着返回的数据在多次调用之间是共享的。如果多个线程访问这些数据,可能会互相覆盖对方的结果。例如:

  • localtimegmtime函数返回指向静态内部结构的指针,该结构包含了转换后的时间。在多线程环境下,一个线程对时间的修改可能会影响到其他线程看到的时间。
  • gethostbyname函数返回指向内部静态数据结构的指针,该结构包含了主机的信息。在多线程中使用可能会导致数据冲突。
  1. 保持跨越多个调用的状态的函数

    一个伪随机数生成器是这类线程不安全函数的简单例子。请参考图 12-37 中的伪随机数生成器程序包。rand 函数是线程不安全的因为当前调用的结果依赖于前次调用的中间结果。当调用 srand 为 rand 设置了一个种子后,我们从一个单线程中反复地调用 rand,能够预期得到一个可重复的随机数字序列然而,如果多线程调用 rand 函数,这种假设就不再成立了。

    #include <stdio.h>
    
    /* $begin rand */
    unsigned next_seed = 1;
    
    /* rand - return pseudorandom integer in the range 0..32767 */
    unsigned rand(void)
    {
        next_seed = next_seed*1103515245 + 12543;
        return (unsigned)(next_seed>>16) % 32768;
    }
    
    /* srand - set the initial seed for rand() */
    void srand(unsigned new_seed)
    {
        next_seed = new_seed;
    } 
    /* $end rand */
    
    int main()
    {
        srand(100);
        printf("%d\n", rand());
        printf("%d\n", rand());
        printf("%d\n", rand());
        return 0;
    }
    
  2. 调用线程不安全函数的函数

    image-20240128101245377

竞争

竞争在基于线程的echo并发服务器介绍过,就不再继续展开了,主要就是由于线程和线程之间,线程和主线程之间的的竞争可能会导致某些变量没有被正确使用,这个往往十分隐晦,需要注意。

死锁

信号量引入了一种潜在的令人厌恶的运行时错误,叫做死锁(deadlock),它指的是一组线程被阻塞了,等待一个永远也不会为真的条件。进度图对于理解死锁是一个无价的工具。例如,图 12-44 展示了一对用两个信号量来实现互斥的线程的进程图。从这幅图中,我们能够得到一些关于死锁的重要知识:

image-20240128103947322

  • 程序员使用 P和V 操作顺序不当,以至于两个信号量的禁止区域重叠。如果某个执行轨迹线碰巧到达了死锁状态 d,那么就不可能有进一步的进展了,因为重叠的禁止区域阻塞了每个合法方向上的进展。换句话说,程序死锁是因为每个线程都在等待其他线程执行一个根不可能发生的 V 操作。

  • 重叠的禁止区域引起了一组称为死锁区域(deadlock region)的状态。如果一个轨迹线碰巧到达了一个死锁域中的状态,那么死锁就是不可避免的了。轨迹线可以进入死锁区域,但是它们不可能离开。

  • 死锁是一个相当困难的问题,因为它不总是可预测的。一些幸运的执行轨迹线将绕开死锁区域,而其他的将会陷入这个区域。图 12-44 展示了每种情况的一个示例。对于程序员来说,这其中隐含的着实令人惊慌。你可以运行一个程序 1000 次不出任何问题,但是下一次它就死锁了。或者程序在一台机器上可能运行得很好,但是在另外的机器上就会死锁。最糟糕的是,错误常常是不可重复的,因为不同的执行有不同的轨迹线。

程序死锁有很多原因,要避免死锁一般而言是很困难的。然而,当使用二元信号量来实现互斥时,如图 12-44 所示,你可以应用下面的简单而有效的规则来避免死锁:
互斥锁加锁顺序规则,给定所有互斥操作的一个全序,如果每个线程都是以一种顺序获得互斥锁并以相反的顺序释放,那么这个程序就是无死锁的。

image-20240128103143549

其实这里的话翻译一下就是:假设有两个信号量s,t,如果线程1的加锁顺序是s,t线程2的加锁顺序是t,s,那么这个程序就很有可能陷入互相等待的情况。如图12-44。解决办法也很简单,就是保证线程的信号量顺序是一样的,要么都先申请s,再申请t,要么都先申请t,再申请s。

总结

本篇博文介绍了并发编程的相关知识,基于进程的并发,它的缺点主要是进程之间通信的成本过高;基于I/O多路复用的并发,主要缺点是编写困难;而基于线程的并发是最常用的,先是用其扩展了echo服务器,使其能够同时处理多个连接请求,然后再结合生产者消费者模型进行了进一步的拓展。最后介绍了有关线程安全的一些概念。

posted @ 2024-01-28 10:46  CuriosityWang  阅读(145)  评论(0编辑  收藏  举报