-
并发
-
如果逻辑控制流在时间上重叠,那么它们就是并发
-
并发既可以发生在内核,也可以发生在应用程序
-
-
应用级并行的作用
-
在多处理器上并行计算
-
访问慢速IO设备
-
与人交互
-
通过推迟工作以减少执行时间
-
服务多个网络客户端
-
-
构造并发程序的3种方法
-
进程
(1) 由内核来调度和维护
(2) 每个进程有独立的虚拟地址空间,因此需要IPC机制
-
IO多路复用
(1) 一个单一进程
(2) 应用程序负责调度自己的逻辑流——状态机
-
线程
(1) 由内核进行调度
(2) 单一进程
线程可以视为进程和IO多路复用两种方式的结合体
-
-
基于进程的并发编程
-
示例
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); } }
-
说明
(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都关闭了,到客户端的连接才终止
-
使用开子进程方式做并发服务器的优缺点
(1) 优点
1° 共享文件表,不共享地址空间,不用担心存储器地址空间被覆盖
(2) 缺点
1° 独立的地址空间让进程间信息共享变得困难
2° 进程控制和IPC开销比较高,更加耗时
-
IPC方式
(1) waitpid
(2) Unix 信号
(3) Socket
(4) 管道
(5) FIFO
(6) 系统V共享存储器
(7) 系统V信号
-
-
基于IO多路复用的并发编程
-
场景
又要响应用户键盘输入,又要等待网络客户端连接,不能用read或者accept傻等
-
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);
-
简单示例
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循环就结束不了
-
一个基于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; } } } }
-
IO多路复用技术的优劣
(1) 优点
1° 程序员对程序的控制,比进程方式更多
2° 单一进程上下文,共享数据容易
3° 不需要进程上下文切换,效率高
(2) 缺点
1° 编码复杂,当并发粒度减小时,复杂度进一步上升
-
-
线程
-
线程上下文
(1) 每个线程唯一的ID(TID)
(2) 栈
(3) 栈指针
(4) 程序计数器
(5) 通用目的寄存器
(6) 条件码
-
线程上下文比进程上下文小得多 —> 线程切换比进程切换开销小
原因:进程上下文是线程上下文的超集,包括了整个用户虚拟地址空间,它是由只读文本(代码)、读写数据、堆、所有的共享库代码和数据区域过程,另外线程也共享同样的打开文件集合
而且,同一个进程的多个线程共享同一块虚拟地址空间,切换时发生缺页中断的概率小
-
线程不按照父子关系一样组织(进程有父子进程的概念)
和一个进程相关的线程组成一个对等线程池 —> 一个线程可以杀死它的任何对等线程,也可以等待它的任意对等线程终止
-
Posix线程 —— Pthreads
(1) C程序中处理线程的一个标准接口
(2) 线程例程
线程的代码和本地数据被封装进一个线程例程中,每一个线程例程都以一个通用指针作为输入,并返回一个通用指针
示例
void* mythread(void* vargp) { ... }
-
-
Posix线程的几种操作
-
创建线程
#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参数
-
获取调用者线程id
#include <pthread.h> pthread_t pthread_self(void);
-
终止线程
一共有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);
-
回收已终止线程的资源
#include <pthread.h> int pthread_join(pthread_t tid, void** thread_return);
阻塞直到tid线程终止,然后回收已终止线程的存储器资源
和Unix wait不同,pthread_join只能等待一个线程
-
分离线程
线程可以分为可结合和分离的
可结合的线程,可以被其它对等线程杀死并回收资源,否则自己不释放资源;
分离的线程,不能被其它线程杀死和回收,存储器资源在它终止时由系统自动释放
#include <pthread.h> int pthread_detach(pthread_t tid);
注:一个线程可以先通过pthread_self获取调用者tid,然后在适当的时候分离自己
-
初始化线程
用于动态初始化多个线程共享的全局变量
#include <pthread.h> pthread_once_t once_control = PTHREAD_ONCE_INIT; int pthread_once(pthread_once_t* once_control, void (*init_routine)(void));
-
-
基于线程的并发服务器
#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; }
这里有几个注意的地方
-
500行和501行
connfdp = Malloc(sizeof(int)); *connfdp = Accept(listenfd, (SA *) &clientaddr, &clientlen);
使用了动态分配,这样才能保证处理线程能得到正确的地址和正确的值
-
508行在线程入口处获取 connfd 以后,512行立刻把它free掉,防止存储器泄漏
-
510行线程自己把自己detach,这样主线程就无需wait每个线程结束,线程资源的回收交给系统去做
Pthread_detach(pthread_self());
-
-
多线程程序中的共享变量
-
寄存器从不共享,虚拟存储器总是共享
-
栈区域通常被它们相应的线程独立访问,但是如果一个线程一不小心得到了一个其他线程的栈的指针,也是可以访问的,例如通过全局变量的方式(示例中的ptr指针一不小心就指向了主线程栈中的msgs变量,然后其他线程靠全局指针ptr就可以访问到了)
-
示例
#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; }
-
C程序中的3种变量映射到存储器的方式
(1) 全局变量
定义在函数之外的变量
虚拟存储器的读/写区域只包含每个全局变量的一个实例
例如示例中的ptr
(2) 本地自动变量
定义在函数内部,无static修饰
每个线程的栈都包含它自己的所有本地自动变量的实例,多线程下不只一份
例如示例中的myid
(3) 本地静态变量
定义在函数内部,用static修饰
虚拟存储器的读/写区域只包含每个本地静态变量的一个实例
-
共享变量
一个变量的实例,被一个以上的线程引用
全局变量、本地自动变量和本地静态变量3种,都有可能成为共享变量,在C语言中即便是本地自动变量也不稳
-
-
用信号量同步线程
-
定义
具有非负整数值的全局变量s,只有两种特殊的操作P和V
P(s):如果s非零,将s减1,并立即返回;如果s为零,则挂起进程,直到s变为非零,并且该进程被一个V操作重启
V(s):将s加1,如果有任何进程阻塞在P操作等待s变为非零,V操作会重启这些进程中的1个
-
(1) P中的测试s的值和减1操作不可分割
(2) V中的加1操作也不可分割
—> 加载、加1、存储信号量的过程中没有中断
-
初始值为1的信号量 —> 二进制信号量 —> 提供互斥访问
-
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);
-
使用信号量构造生产者-消费者
(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; }
-
-
基于预线程化的并发服务器
-
基本思想
(1) 线程池,提前把线程开好
(2) 生产者-消费者模式,主线程不断往buffer里塞connfd,然后各个线程从buffer里面消费connfd
-
示例
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); } }
-
-
其他并发性问题
-
线程安全
四类线程不安全的函数
(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) 调用线程不安全函数的函数
-
可重入性
(1) 定义:不会引用共享数据的函数
(2) 可重入函数一定是线程安全函数,线程安全函数未必是可重入函数
(3) 显式可重入
函数参数按值传递,数据引用都是本地的自动栈变量
(4) 隐式可重入
一些函数参数是引用传递,但是调用线程传递的是指向非共享数据的指针
-
对于库函数中的线程不安全函数,最好的办法就是用lock-and-copy技术
-
死锁
避免死锁 —> 互斥锁加锁顺序规则:
对于程序中每对互斥锁(s, t),每个既包含s也包含t的线程都按相同的顺序同时对它们加锁,那么这个程序就无死锁
-
chapter13_并发编程
猜你喜欢
转载自blog.csdn.net/captxb/article/details/103549814
今日推荐
周排行