chapter13_并发编程

  1. 并发

    1. 如果逻辑控制流在时间上重叠,那么它们就是并发

    2. 并发既可以发生在内核,也可以发生在应用程序

  2. 应用级并行的作用

    1. 在多处理器上并行计算

    2. 访问慢速IO设备

    3. 与人交互

    4. 通过推迟工作以减少执行时间

    5. 服务多个网络客户端

  3. 构造并发程序的3种方法

    1. 进程

      (1) 由内核来调度和维护

      (2) 每个进程有独立的虚拟地址空间,因此需要IPC机制

    2. IO多路复用

      (1) 一个单一进程

      (2) 应用程序负责调度自己的逻辑流——状态机

    3. 线程

      (1) 由内核进行调度

      (2) 单一进程

      线程可以视为进程和IO多路复用两种方式的结合体

  4. 基于进程的并发编程

    1. 示例

       int main(int argc, char **argv) {
      
           int listenfd, connfd, port;
           socklen_t clientlen=sizeof(struct sockaddr_in);
           struct sockaddr_in clientaddr;
      
           if (argc != 2) {
               fprintf(stderr, "usage: %s <port>\n", argv[0]);
               exit(0);
           }
           port = atoi(argv[1]);
      
           Signal(SIGCHLD, sigchld_handler);
           listenfd = Open_listenfd(port);
      
           while (1) {
               connfd = Accept(listenfd, (SA *) &clientaddr, &clientlen);
      
               // 子进程
               if (Fork() == 0) { 
                   Close(listenfd); 
                   
                   echo(connfd);    
                   
                   Close(connfd);   
       
                   exit(0);         
               }
      
               // 父进程
               Close(connfd); 
           }
       }
      
    2. 说明

      (1) 57行

       Signal(SIGCHLD, sigchld_handler);
      

      SIGCHLD信号是在一个进程终止或者停止时,将SIGCHLD信号发送给其父进程,这个信号的处理函数sigchld_handler是:

       void sigchld_handler(int sig) {
      
           while (waitpid(-1, 0, WNOHANG) > 0);    
           return;
       }
      

      第一个参数-1代表等待父进程的所有子进程,WNOHANG代表若pid指定的子进程没有结束,则waitpid()函数返回0,不予以等待;若结束,则返回该子进程的ID

      这个方法用来回收僵尸子进程的资源,由于SIGCHLD处理程序执行时,SIGCHLD信号阻塞,而Unix信号不排队,所以SIGCHLD处理程序要准备好回收多个僵尸子进程的资源

      (2) 整体思路是每次到达一个客户端连接,就fork子进程。由于子进程不需要listenfd,所以要close(65行)

       Close(listenfd); 
      

      而父进程中不需要connfd,所以也要close(75行)

       Close(connfd); 
      

      (3) Socket中的文件表表项是引用计数,所以直到父子进程的connfd都关闭了,到客户端的连接才终止

    3. 使用开子进程方式做并发服务器的优缺点

      (1) 优点

      1° 共享文件表,不共享地址空间,不用担心存储器地址空间被覆盖

      (2) 缺点

      1° 独立的地址空间让进程间信息共享变得困难

      2° 进程控制和IPC开销比较高,更加耗时

    4. IPC方式

      (1) waitpid

      (2) Unix 信号

      (3) Socket

      (4) 管道

      (5) FIFO

      (6) 系统V共享存储器

      (7) 系统V信号

  5. 基于IO多路复用的并发编程

    1. 场景

      又要响应用户键盘输入,又要等待网络客户端连接,不能用read或者accept傻等

    2. IO多路复用

      (1) 基本思路:使用select函数,要求内核挂起进程,只有当一个或多个IO事件发生后,才将控制返回给应用程序,之前保持阻塞

      (2) select 函数的基本用法

       #include <unistd.h>
       #include <sys/types.h>
      
       int select(int n, fd_set* fdset, NULL, NULL, NULL);
      
       fdset:描述符集合
       n:集合中的最大描述符+1
       返回值:准备好的描述符数量
       作用:等待一组描述符准备好读
      

      操作fd_set的宏

       FDZERO(fd_set* fdset);
       FDCLR(int fd, fd_set* fdset);
       FD_SET(int fd, fd_set* fdset);
       FD_ISSET(int fd, fd_set* fdset);
      
    3. 简单示例

       int main(int argc, char **argv) {
      
           int listenfd, connfd, port;
           socklen_t clientlen = sizeof(struct sockaddr_in);
           struct sockaddr_in clientaddr;
           fd_set read_set, ready_set;
      
           if (argc != 2) {
               fprintf(stderr, "usage: %s <port>\n", argv[0]);
               exit(0);
           }
      
           port = atoi(argv[1]);
           listenfd = Open_listenfd(port);  
      
           // 设置要读的描述符集合
           FD_ZERO(&read_set);              
           FD_SET(STDIN_FILENO, &read_set); 
           FD_SET(listenfd, &read_set);     
      
           while (1) {
               ready_set = read_set;
      
               Select(listenfd+1, &ready_set, NULL, NULL, NULL); 
      
               // 如果标准输入fd可读,处理之
               if (FD_ISSET(STDIN_FILENO, &ready_set)) {
                   command(); 
               }
       
               // 如果网络Socket fd可读,处理之
               if (FD_ISSET(listenfd, &ready_set)) { 
                   connfd = Accept(listenfd, (SA *)&clientaddr, &clientlen);
       
                   echo(connfd); 
                   
                   Close(connfd);
               }
           }
       }
      

      select函数的副作用是改变了fdset参数的状态,因此需要一个read_set保存好初始值,然后每次while循环开始时reset ready_set

      这个示例的问题是:标准输入很容易检测到EOF,Socket的EOF必须靠client主动终止进程,这导致了client不终止进程,一次while循环就结束不了

    4. 一个基于IO多路复用的并发事件驱动服务器

      (1) 基本思路

      把listenfd和connfds分离开,用一个pool管理所有client的connfd

      select函数返回时,先判断listenfd,决定要不要加入新client,这个很快就会返回,不会阻塞while循环;

      然后找到pool中准备好可读的connfd,每次只读一行就返回,所以也不会阻塞;如果读到了EOF,那么把这个client的connfd回收

      (2) 代码

       void init_pool(int listenfd, pool *p);
       void add_client(int connfd, pool *p);
       void check_clients(pool *p);
      
       /* counts total bytes received by server */
       int byte_cnt = 0; 
      
       int main(int argc, char **argv) {
      
           int listenfd, connfd, port; 
           socklen_t clientlen = sizeof(struct sockaddr_in);
           struct sockaddr_in clientaddr;
           static pool pool; 
      
           if (argc != 2) {
               fprintf(stderr, "usage: %s <port>\n", argv[0]);
               exit(0);
           }
      
           port = atoi(argv[1]);
           listenfd = Open_listenfd(port);
      
           init_pool(listenfd, &pool); 
      
           while (1) {
      
               /* Wait for listening/connected descriptor(s) to become ready */
               pool.ready_set = pool.read_set;
               pool.nready = Select(pool.maxfd+1, &pool.ready_set, NULL, NULL, NULL);
      
               /* If listening descriptor ready, add new client to pool */
               if (FD_ISSET(listenfd, &pool.ready_set)) { 
                   connfd = Accept(listenfd, (SA *)&clientaddr, &clientlen); 
       
                   add_client(connfd, &pool); 
               }
      
               /* Echo a text line from each ready connected descriptor */ 
               check_clients(&pool); 
           }
       }
      
       void init_pool(int listenfd, pool *p) {
      
           /* Initially, there are no connected descriptors */
           int i;
      
           p->maxi = -1;                   
      
           for (i=0; i< FD_SETSIZE; i++) {
               p->clientfd[i] = -1;        
           }
      
           /* Initially, listenfd is only member of select read set */
           p->maxfd = listenfd;  
      
           FD_ZERO(&p->read_set);
           FD_SET(listenfd, &p->read_set); 
       }
      
       void add_client(int connfd, pool *p) {
      
           int i;
           p->nready--;
      
           /* Find an available slot */
           for (i = 0; i < FD_SETSIZE; i++) {
               if (p->clientfd[i] < 0) { 
                   /* Add connected descriptor to the pool */            
                   p->clientfd[i] = connfd;       
                   Rio_readinitb(&p->clientrio[i], connfd); 
      
                   /* Add the descriptor to descriptor set */
                   FD_SET(connfd, &p->read_set); 
      
                   /* Update max descriptor and pool highwater mark */
                   if (connfd > p->maxfd) {
                       p->maxfd = connfd;
                   }
      
                   if (i > p->maxi) {
                       p->maxi = i;  
                   }
                   break;
               }
           }
      
           /* Couldn't find an empty slot */
           if (i == FD_SETSIZE) {
               app_error("add_client error: Too many clients");
           }
       }
      
       void check_clients(pool *p) {
      
           int i, connfd, n;
           char buf[MAXLINE]; 
           rio_t rio;
      
           for (i = 0; (i <= p->maxi) && (p->nready > 0); i++) {
      
               connfd = p->clientfd[i];
               rio = p->clientrio[i];
      
               /* If the descriptor is ready, echo a text line from it */
               if ((connfd > 0) && 
                   (FD_ISSET(connfd, &p->ready_set))) { 
      
                   p->nready--;
       
                   if ((n = Rio_readlineb(&rio, buf, MAXLINE)) != 0) {
      
                       byte_cnt += n; 
                       printf("Server received %d (%d total) bytes on fd %d\n", n, byte_cnt, connfd);
           
                       Rio_writen(connfd, buf, n);
      
                   } else {  /* EOF detected, remove descriptor from pool */
      
                       Close(connfd); 
                       FD_CLR(connfd, &p->read_set); 
                       p->clientfd[i] = -1;       
                   }
               }
           }
       }
      
    5. IO多路复用技术的优劣

      (1) 优点

      1° 程序员对程序的控制,比进程方式更多

      2° 单一进程上下文,共享数据容易

      3° 不需要进程上下文切换,效率高

      (2) 缺点

      1° 编码复杂,当并发粒度减小时,复杂度进一步上升

  6. 线程

    1. 线程上下文

      (1) 每个线程唯一的ID(TID)

      (2) 栈

      (3) 栈指针

      (4) 程序计数器

      (5) 通用目的寄存器

      (6) 条件码

    2. 线程上下文比进程上下文小得多 —> 线程切换比进程切换开销小

      原因:进程上下文是线程上下文的超集,包括了整个用户虚拟地址空间,它是由只读文本(代码)、读写数据、堆、所有的共享库代码和数据区域过程,另外线程也共享同样的打开文件集合

      而且,同一个进程的多个线程共享同一块虚拟地址空间,切换时发生缺页中断的概率小

    3. 线程不按照父子关系一样组织(进程有父子进程的概念)

      和一个进程相关的线程组成一个对等线程池 —> 一个线程可以杀死它的任何对等线程,也可以等待它的任意对等线程终止

    4. Posix线程 —— Pthreads

      (1) C程序中处理线程的一个标准接口

      (2) 线程例程

      线程的代码和本地数据被封装进一个线程例程中,每一个线程例程都以一个通用指针作为输入,并返回一个通用指针

      示例

       void* mythread(void* vargp) {
      
           ...
       }
      
  7. Posix线程的几种操作

    1. 创建线程

       #include <pthread.h>
      
       typedef void *(func)(void*);
      
       int pthread_create(pthread_t* tid, pthread_attr_t* attr, func* f, void* arg);
      

      创建一个新线程,带着输入变量arg, 运行线程例程f,将线程id写入tid参数

    2. 获取调用者线程id

       #include <pthread.h>
      
       pthread_t pthread_self(void);
      
    3. 终止线程

      一共有4种方式

      (1) 顶层的线程例程返回时,线程会隐式终止

      (2) 调用 pthread_exit 函数,线程会显式终止

       #include <pthread.h>
      
       int pthread_exit(void* pthread_return);
      

      (3) 某个对象线程调用 Unix exit 函数,会终止进程以及其中的所有线程

      (4) 另一个对等线程调用 pthread_cancel 终止当前线程

       #include <pthread.h>
      
       int pthread_cancel(pthread_t tid);
      
    4. 回收已终止线程的资源

       #include <pthread.h>
      
       int pthread_join(pthread_t tid, void** thread_return);
      

      阻塞直到tid线程终止,然后回收已终止线程的存储器资源

      和Unix wait不同,pthread_join只能等待一个线程

    5. 分离线程

      线程可以分为可结合分离

      可结合的线程,可以被其它对等线程杀死并回收资源,否则自己不释放资源

      分离的线程,不能被其它线程杀死和回收,存储器资源在它终止时由系统自动释放

       #include <pthread.h>
      
       int pthread_detach(pthread_t tid);
      

      注:一个线程可以先通过pthread_self获取调用者tid,然后在适当的时候分离自己

    6. 初始化线程

      用于动态初始化多个线程共享的全局变量

       #include <pthread.h>
      
       pthread_once_t once_control = PTHREAD_ONCE_INIT;
      
       int pthread_once(pthread_once_t* once_control, void (*init_routine)(void));
      
  8. 基于线程的并发服务器

     #include "csapp.h"
    
     void echo(int connfd);
     void* thread(void *vargp);
    
     int main(int argc, char **argv) {
    
         int listenfd, *connfdp, port;
         socklen_t clientlen=sizeof(struct sockaddr_in);
         struct sockaddr_in clientaddr;
         pthread_t tid; 
    
         if (argc != 2) {
             fprintf(stderr, "usage: %s <port>\n", argv[0]);
             exit(0);
         }
    
         port = atoi(argv[1]);
         listenfd = Open_listenfd(port);
    
         while (1) {
             connfdp = Malloc(sizeof(int)); 
             *connfdp = Accept(listenfd, (SA *) &clientaddr, &clientlen); 
             Pthread_create(&tid, NULL, thread, connfdp);
         }
     }
    
     void *thread(void *vargp) {  
    
         int connfd = *((int *)vargp);
         
         Pthread_detach(pthread_self()); 
         
         Free(vargp);                    
    
         echo(connfd);
         Close(connfd);
    
         return NULL;
     }
    

    这里有几个注意的地方

    1. 500行和501行

       connfdp = Malloc(sizeof(int)); 
       *connfdp = Accept(listenfd, (SA *) &clientaddr, &clientlen); 
      

      使用了动态分配,这样才能保证处理线程能得到正确的地址和正确的值

    2. 508行在线程入口处获取 connfd 以后,512行立刻把它free掉,防止存储器泄漏

    3. 510行线程自己把自己detach,这样主线程就无需wait每个线程结束,线程资源的回收交给系统去做

       Pthread_detach(pthread_self()); 
      
  9. 多线程程序中的共享变量

    1. 寄存器从不共享虚拟存储器总是共享

    2. 栈区域通常被它们相应的线程独立访问,但是如果一个线程一不小心得到了一个其他线程的栈的指针,也是可以访问的,例如通过全局变量的方式(示例中的ptr指针一不小心就指向了主线程栈中的msgs变量,然后其他线程靠全局指针ptr就可以访问到了)

    3. 示例

       #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;
       }
      
    4. C程序中的3种变量映射到存储器的方式

      (1) 全局变量

      定义在函数之外的变量

      虚拟存储器的读/写区域只包含每个全局变量的一个实例

      例如示例中的ptr

      (2) 本地自动变量

      定义在函数内部,无static修饰

      每个线程的栈都包含它自己的所有本地自动变量的实例,多线程下不只一份

      例如示例中的myid

      (3) 本地静态变量

      定义在函数内部,用static修饰

      虚拟存储器的读/写区域只包含每个本地静态变量的一个实例

    5. 共享变量

      一个变量的实例,被一个以上的线程引用

      全局变量、本地自动变量和本地静态变量3种,都有可能成为共享变量,在C语言中即便是本地自动变量也不稳

  10. 信号量同步线程

    1. 定义

      具有非负整数值的全局变量s,只有两种特殊的操作P和V

      P(s):如果s非零,将s减1,并立即返回;如果s为零,则挂起进程,直到s变为非零,并且该进程被一个V操作重启

      V(s):将s加1,如果有任何进程阻塞在P操作等待s变为非零,V操作会重启这些进程中的1个

    2. (1) P中的测试s的值和减1操作不可分割

      (2) V中的加1操作也不可分割

      —> 加载、加1、存储信号量的过程中没有中断

    3. 初始值为1的信号量 —> 二进制信号量 —> 提供互斥访问

    4. Posix信号量

       #include <semaphore.h>
      
       // 1 初始化信号量s
       int sem_init(sem_t* sem, 0, unsigned int value);
      
       // 2 P(s)
       int sem_wait(sem_t* s);
      
       // 3 V(s)
       int sem_post(sem_t* s);
      
    5. 使用信号量构造生产者-消费者

      (1) 先定义一个结构体

       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;
      

      这里面有三个信号量,mutex用来控制数据访问,slots用来记录空余槽的数量,items用来记录buffer中的item数量

      (2) 两个带阻塞的基本操作:存和取

       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 */
       }
      
       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;
       }
      
  11. 基于预线程化的并发服务器

    1. 基本思想

      (1) 线程池,提前把线程开好

      (2) 生产者-消费者模式,主线程不断往buffer里塞connfd,然后各个线程从buffer里面消费connfd

    2. 示例

       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, port;
           socklen_t clientlen=sizeof(struct sockaddr_in);
           struct sockaddr_in clientaddr;
           pthread_t tid; 
      
           if (argc != 2) {
               fprintf(stderr, "usage: %s <port>\n", argv[0]);
               exit(0);
           }
      
           port = atoi(argv[1]);
           sbuf_init(&sbuf, SBUFSIZE); //line:conc:pre:initsbuf
           listenfd = Open_listenfd(port);
      
           for (i = 0; i < NTHREADS; i++)  
               Pthread_create(&tid, NULL, thread, NULL);               
      
           while (1) { 
               connfd = Accept(listenfd, (SA *) &clientaddr, &clientlen);
      
               sbuf_insert(&sbuf, connfd); /* Insert connfd in buffer */
           }
       }
      
       void *thread(void *vargp) {  
      
           Pthread_detach(pthread_self()); 
      
           while (1) { 
               int connfd = sbuf_remove(&sbuf); 
      
               echo_cnt(connfd);                /* Service client */
               
               Close(connfd);
           }
       }
      
  12. 其他并发性问题

    1. 线程安全

      四类线程不安全的函数

      (1) 不保护共享变量的函数

      解决方法:在读写共享变量前后加上互斥量的PV操作

       P(&mutex);
       ...
       V(&mutex);
      

      (2) 保持跨越多个调用状态的函数

      例如下面这个,next变量是全局变量,不断被更新,但是没有保护

       #include <stdio.h>
       #include <stdlib.h>
      
       unsigned int next = 1;
      
       int rand(void) {
           next = next*1103515245 + 12345;
           return (unsigned int)(next/65536) % 32768;
       }
      
       void srand(unsigned int seed) {
           next = seed;
       } 
      

      解决方法:重写

      (3) 返回指向静态变量指针的函数

      示例:

       int* example() {
      
           static int value = 3;
      
           return &value;
       }
      

      解决方法lock-and-copy技术(加锁-拷贝,装饰器模式)

      示例

       struct hostent* gethostbyname_ts(char* hostname){
      
           struct hostent* sharedp, *unsharedp;
           unsharedp = malloc(sizeof(struct hostent));
      
           P(&mutex);
      
           sharedp = gethostbyname(hostname);  //这是个库函数,我们改不了
           *unsharedp = sharedp;
      
           V(&mutex);
      
           return unsharedp;
       }
      

      (4) 调用线程不安全函数的函数

    2. 可重入性

      (1) 定义:不会引用共享数据的函数

      (2) 可重入函数一定是线程安全函数,线程安全函数未必是可重入函数

      (3) 显式可重入

      函数参数按值传递,数据引用都是本地的自动栈变量

      (4) 隐式可重入

      一些函数参数是引用传递,但是调用线程传递的是指向非共享数据的指针

    3. 对于库函数中的线程不安全函数,最好的办法就是用lock-and-copy技术

    4. 死锁

      避免死锁 —> 互斥锁加锁顺序规则

      对于程序中每对互斥锁(s, t),每个既包含s也包含t的线程都按相同的顺序同时对它们加锁,那么这个程序就无死锁

发布了391 篇原创文章 · 获赞 7 · 访问量 1万+

猜你喜欢

转载自blog.csdn.net/captxb/article/details/103549814