CSAPP:第12章 并发编程
文章目录
- CSAPP:第12章 并发编程
- 12.1 基于进程的并发编程(Process-based)
- 12.1.1 基于进程的并发服务器
- 12.1.2 进程的优劣
- 12.2 基于IO多路复用的并发编程(Event-based)
- 12.2.1 基 于 I /O 多路复用的并发事件驱动服务器
- 12.2.2 I/O 多路复用技术的优劣
- 12.3 基于线程的并发编程(Thread-based)
- 12.3.1 线程执行模型
- 12.3.2 Posix 线程
- 12.3.3 创建线程
- 12.3.4 终止线程
- 12.3.5 回收已终止线程的资源
- 12.3.6 分离线程
- 12.3.7 初始化线程
- 12.3.8 基于线程的并发服务器
- 12.4 多线程程序中的共享变量
- 12.4.1 线程内存模型
- 12.4.2 将变量映射到内存
- 12.4.3 共享变量
- 12.5 用信号量同步线程
- 12.5.1 进度图
- 12.5.2 信号量
- 12.5.3 使用信号量来实现互斥
- 12.5.4 利用信号量来调度共享资源
- 12.5.5 综合:基于预线程化的并发服务器
- 12.6 使用线程提高并行性
- 12.7 其他并发问题
- 12.7.1 线程安全
- 12.7.2 可重入性
- 12.7.3 在线程化的程序中使用已存在的库函数
- 12.7.4 竞争
- 12.7.5 死锁
12.1 基于进程的并发编程(Process-based)
12.1.1 基于进程的并发服务器
-
构造并发程序最简单的方法就是用进程(fork、exec 和 waitpid)
-
#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:handlerendint 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:echofunClose(connfd); /* Child closes connection with client */ //line:conc:echoserverp:childcloseexit(0); /* Child exits */}Close(connfd); /* Parent closes connected socket (important!) */ //line:conc:echoserverp:parentclose} }
-
基于上面的例子:
- 我们可以看到父进程链接请求获得监听描述符(connfd),然后fork一个一摸一样的子进程,不再监听(但有之前已经得到的监听描述符),开始处理请求(数据传输就被转移到客户端1和子进程1之间了,见下图右侧)
- 父进程需要关闭监听描述符(因为父、子进程中的已连接描述符都指向同一个文件表表项,使得该文件表表项的引用数为2,只有当引用数为0时,才会终止和客户端的连接)
- 通常服务器会运行很长的时间,所以我们必须要包括一个 SIGCHLD 处理程序,来回收僵死(zombie)子进程的资源(第 4 9 行)。
-
接下来重复即可
-
12.1.2 进程的优劣
- 进程有独立的地址空间既是优点也是缺点- 不会覆盖另一个进程的地址空间- 独立的地址空间使得进程**共享状态信息变得更加困难**(必须用显式的 IPC(进程间通信)机制)- **慢**,因为进程控制和 IPC 的开销很高
12.2 基于IO多路复用的并发编程(Event-based)
-
假设要求编写一个 echo 服务器,它也能对用户从标准输人键人的交互命令做出响应在这种情况下,服务器必须响应两个互相独立的 I/O 事件:
- 1)网络客户端发起连接请
- 2)用户在键盘上键人命令行
-
一个解决办法就是 I/O 多路复用(I/O multiplexing)技术。
- 基本的思路就是使用 select 函数,要求内核挂起进程,只有在一个或多个 I/O 事件发生后,才将控制返回给应用程序
-
#include <sys/select.h> int select(int n, fd_set *fdset, NULL, NULL, NULL);FD_ZERO(fd_set *fdset); //清空fdset中的所有位 FD_CLR(int fd, fd_set *fdset); //清除fdset中的描述符fd FD_SET(int fd, fd_set *fdset); //开启fdset中的描述符fd FD_ISSET(int fd, fd_set *fdset); //判断fdset中的描述符fd是否开启
- select 函数处理类型为 fd_set 的集合,也叫做描述符集合。
- 它使用一个大小为n的位向量[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-dAsx6XPv-1613836967317)(https://www.zhihu.com/equation?tex=b_%7Bn-1%7D%2Cb_%7Bn-2%7D%2C…%2Cb_0)] 来标记我们关注的描述符集合,比如我们这里的
listenfd
为3
,而标准输入STDIN_FILENO
为0
,则我们可以用FD_ZERO
和FD_SET
函数来定义一个大小为4的位向量 [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-rmvu9Te6-1613836967318)(https://www.zhihu.com/equation?tex=1001)] 来表示我们关注的位向量。将该描述符集合fdset
传入select
函数,它就知道要关注哪些描述符,会先将进程挂起,如果能从描述符0
或3
读取一个字节而不会阻塞,select
函数就修改传入的fdset
,用来表示哪个描述符准备好了,我们可以通过FD_ISSET
函数来进行判断,从而执行对应的函数。- 1001是如何赋值的(赋值过程):可以看书上给的例子
- 1、打开监听
Open_listenfd(axgv[i])
,并使用FD_ZERO创建一个空的读集合 - 2、使用FD_SET将标准输入(stdin),监听符(listenfd),设置为1
- 3、我们就用 FD_ISSET 宏指令来确定哪个描述符准备好可以读,或者监听符准备好了(25、27),然后执行相应逻辑
- 1001是如何赋值的(赋值过程):可以看书上给的例子
- **注意:**由于
select
函数会修改传入的fdset
参数,所以每次调用select
函数都要更新fdset
。
- 它使用一个大小为n的位向量[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-dAsx6XPv-1613836967317)(https://www.zhihu.com/equation?tex=b_%7Bn-1%7D%2Cb_%7Bn-2%7D%2C…%2Cb_0)] 来标记我们关注的描述符集合,比如我们这里的
- select 函数处理类型为 fd_set 的集合,也叫做描述符集合。
12.2.1 基 于 I /O 多路复用的并发事件驱动服务器
- I/O 多路复用可以用做并发事件驱动(event-driven)程序的基础,在事件驱动程序中,某些事件会导致流向前推进。
- 一般的思路是将逻辑流模型化为状态机(state machine):
- =状态(
state
)+输入时间(input event
)+转移(transition
) - 每个转移是将一个(输入状态、输入时间)对 映射到 一个输出状态
- 自循环(
self-loop
)是同一输入和输出状态之间的转移。
- =状态(
- 状态机池的数据结构:
- 这里将所有客户端的已连接描述符保存在
clientfd
数组中,并且保存了对应的缓冲区clientrio
数组。
- main函数:
- 服务器获得监听描述符后,会先对池子进行初始化(27)
- - maxi表示有多少已链接客户端(init的时候为-1),描述符数组初始化为-1 - 使用`maxfd`来保存最大的描述符,这里一开始只有一个`listenfd`,所以就初始化为`listenfd` - 设置好`read_set`(10、11行),使得`select`函数能判断`listenfd`描述符是否准备好。
- main的第30、31行基本一致(只是改成pool的调用)
- add_client 函数添加一个新的客户端到活动客户端池中。
- 在 clientfd数组中找到一个空槽位后,服务器将这个已连接描述符添加到数组中,并初始化相应的RIO 读缓冲区,这样一来我们就能够对这个描述符调用 rio_readlineb(第 8~9 行)。
- maxi 变量(第 17~18行)记录的是到 clientfd 数组的最大索引,这样 check_clients 函数就无需搜索整个数组了。
- check_clients 函数回送来自每个准备好的已连接描述符的一个文本行
- 在该函数中,会对发送数据的客户端进行读取,并将发送了
EOF
的客户端进行关闭,并设置p->read_set
以及将其从池子中删除
- 所以,总结就是:
- 1、使用
select
函数一次性检查多个描述符是否准备好可读了 - 2、通过
FD_ISSET
函数来判断是哪个描述符准备好了,再对其进行读取 - 3、每个准备好的客户端都只读取一行,然后又调用
select
等待新的准备好的描述符,避免了一个客户端长时间占用服务器,使得别的客户端无法使用
- 1、使用
12.2.2 I/O 多路复用技术的优劣
- 优点:
- 它比基于进程的设计给了程序员更多的对程序行为的控制。
- 一个基于 I/O 多路复用的事件驱动服务器是运行在单一进程上下文中的,因此每个逻辑流都能访问该进程的全部地址空间。
- 缺点:
- 一个明显的缺点就是编码复杂。
- 不能充分利用多核处理器。
12.3 基于线程的并发编程(Thread-based)
- 线程基本概念:
- 线程(thread)就是运行在进程上下文中的逻辑流。
- 线程由内核自动调度。
- 每个线程都有它自己的线程上下文(thread context),包括一个唯一的整数线程ID (Thread ID, TID)、栈、栈指针、程序计数器、通用目的寄存器和条件码。
12.3.1 线程执行模型
- 多线程的执行模型在某些方面和多进程的执行模型是相似的。
- 线程的上下文切换要比进程的上下文切换快得多。
- 另一个不同就是线程不像进程那样,不是按照严格的父子层次来组织的。
- 和一个进程相关的线程组成一个对等(线程)池,独立于其他线程创建的线程。
- 对等(线程)池概念的主要影响是,一个线程可以杀死它的任何对等线程,或者等待它的任意对等线程终止。
- 每个对等线程都能读写相同的共享数据。
- 主线程和其他线程的区别仅在于它总是进程中第一个运行的线程。
12.3.2 Posix 线程
- Posix线程(pthread)是在 C 程序中处理线程的一个标准接口。
12.3.3 创建线程
- - `pthread_create` 函数创建一个新的线程 - 带着一个输人变量 `arg` - 在新线程的上下文中运行线程例程 f - 能用 `attr` 参数来改变新创建线程的默认属性
- 新线程可以通过调用
pthread_self()
获得自己的TID
12.3.4 终止线程
- 一个线程是以下列方式之一来终止的:
- 当顶层的线程例程返回时,线程会隐式地终止。
- 通过调用
pthread_exit
函数,线程会显式地终止。如果主线程调用pthread_exit
,他会等待其他所有对等线程终止,然后终止主线程和整个进程,返回值为thread_return。 - 当某个对等线程调用
exit
函数时,会终止进程以及所有与该进程相关的线程。 - 可以通过
pthread_cancel
发送线程终止请求给tid
线程
12.3.5 回收已终止线程的资源
- 线程通过调用 pthreadjoin 函数等待其他线程终止。
- - `pthread_join` 函数会阻塞,直到线程 tid 终止,将线程例程返回的通用(void* )指针赋值为 threac_return 指向的位置,然后回收已终止线程占用的所有内存资源。
12.3.6 分离线程
- 在任何一个时间点上,线程是可结合的(joinable)或者是分离的(detached)。
- 一个可结合的线程能够被其他线程收回和杀死。在被其他线程回收之前,它的内存资源(例如栈)是不释放的。
- 一个分离的线程是不能被其他线程回收或杀死的。它的内存资源在它终止时由系统自动释放。
- - `pthread_detach` 函数分离可结合线程 tid。线程能够通过以 `pthread_self ( )`为参`pthread_detach` 调用来分离它们自己。 - 当每个请求都需要一个线程单独处理时,每个对等线程都应该在它开始处理请求之前分离它自身,这样就能在它终止后回收它的内存资源。
12.3.7 初始化线程
- pthread_once 函数允许初始化与线程例程相关的状态
- 各个线程例程中调用
pthread_once
函数,此时会通过once_control
参数来控制只执行一次初始化函数init_routine
(只有第一次会执行)。
12.3.8 基于线程的并发服务器
- 下图为基于线程的并发服务器:
- 这个模式和基于进程的有些像,但有些不同的地方(已用箭头指出),但上述传递可能存在竞争(race)问题
- 对于这个问题,在21行为connfdp动态分配内存快,用以接受Accept返回的描述符,因此,在没有free(32行)之前,描述符所在的空间(进程的堆)一直是以分配状态,于是就避免了竞争。
- 31行的分离用于避免内存泄漏
12.4 多线程程序中的共享变量
- 为了理解 C 程序中的一个变量是否是共享的,有一些基本的问题要解答:
-
- 线程的基础内存模型是什么?
-
- 根据这个模型,变量实例是如何映射到内存的?
-
- 最后,有多少线程引用这些实例?
-
12.4.1 线程内存模型
- 一组并发线程运行在一个进程的上下文中。
- 每个线程都有它自己独立的线程上下文,包括线程 ID、找、栈指针、程序计数器、条件码和通用目的寄存器值。
- 每个线程和其他线程一起共享进程上下文的剩余部分。
- 这包括整个用户虚拟地址空间,它是由只读文本(代码)、读/写数据、堆以及所有的共享库代码和数据区域组成的。
- 线程也共享相同的打开文件的集合。
- 线程之间不能访问对方的寄存器,但可以共享进程虚拟内存中的任意位置(假如是同一个进程下的话)。
- 如果一个线程以某种方式得到一个指向其他线程栈的指针,那么它就可以读写这个栈的任何部分。
12.4.2 将变量映射到内存
- 全局变量:
- 全局变量是定义在函数之外的变量。
- 在运行时,虚拟内存的读/写区域只包含每个全局变量的一个实例,任何线程都可以引用。
- 本地自动变量(局部变量):
- 定义函数内部的没有static 属性的变量
- 它保存在线程的栈中。
- 本地静态变量:
- 定义在函数内部并有 static 属性的变量
- 和全局变量一样,虚拟内存的读/写区域只包含在程序中声明的每个本地静态变量的一个实例。
12.4.3 共享变量
- 我们说一个变量 是共享的 ,当且仅当它的一个实例被一个以上的线程引用。
- 如:cnt
- 某些本地自动变量也可以被共享:如msgs通过ptr
12.5 用信号量同步线程
- 共享变量是十分方便,但是它们也引人了同步错误(synchronization error)的可能性。
- 书上太长了,个人理解是:
- 有线程AB,共享变量c,由于修改c不是原子操作,故可分为readc和writec,则存在下列顺序:
- A readc->B readc->A writec-> B writec
- A的修改被B覆盖了,则存在同步问题。
- 书上太长了,个人理解是:
12.5.1 进度图
- 进度图(progress graph)将 n 个并发线程的执行模型化为一条n 维笛卡儿空间中的轨迹线。
- 以二维为例(俩线程)下标表示哪个线程,于是下面的序列可以画出右侧轨迹线
- 安全区与不安全区(书中的定义):在进度图中,两个临界区的交集形成的状态空间区域称为不安全区(unsafe region )。
- (我的定义),若:L1S2不可以同时进行(或者叫互斥访问),则该点属于不安全区,所有这种点连起来就是不安全区
- 当轨迹穿过不安全区的时候就是不安全的,下图给出安全区和安全/不安全轨迹线的示例:
- 以二维为例(俩线程)下标表示哪个线程,于是下面的序列可以画出右侧轨迹线
12.5.2 信号量
- 也就是我们常见的PV操作(操作系统(汤晓丹版)中第二章进程会有这个概念)
- PV的定义:
- P(s):如果 s 是非零的,那么 P 将 s 减 1,并且立即返回。如果 s 为零,那么就挂起这个线程,直到 s 变为非零,而一个 V 操作会重启这个线程。在重启之后,P 操作将 s 减 1,并将控制返回给调用者。
- V(s):V 操作将 s 加 1。如果有任何线程阻塞在 P 操作等待 s 变成非零,那么 V 操作会重启这些线程中的一个,然后该线程将 s 减 1,完成它的 P 操作。
- 这玩意好像和我们学过的PV有些不一样——s的值,根据上面的定义s一定是>=0,而汤版的是可以<0,不过这个大同小异。
- Posix 标准定义了许多操作信号量的函数。
12.5.3 使用信号量来实现互斥
- 基本思想是将每个共享变量(或者一组相关的共享变量)与一个信号量s联系起来,然后用 P(s)和 V(s)操作将相应的临界区包围起来。
- 以这种方式来保护共享变量的信号量叫做二元信号量(binary semaphore)(也叫互斥锁(mutex)), 因为它(s)的值总是 0 或者 1。
12.5.4 利用信号量来调度共享资源
- 两个进度调度问题:生产者-消费者问题,读者-写者问题,实际上还有许多,诸如哲学家问题,抽烟问题等等,但下文主要以此二者为主。
- 生产者消费者问题:
- 读者-写者问题:
- 有两种读者-写者问题
- 1、读者权限高于写者——只要有读者,就不能写
- 2、写者权限高于读者——写者到达后读者必须等待
- 1、读者权限高于写者——只要有读者,就不能写
- 有两种读者-写者问题
- 生产者消费者问题:
12.5.5 综合:基于预线程化的并发服务器
- 我们之前实现的基于线程的并发服务器,为每个客户端都生成一个线程进行处理,其实十分耗费资源。我们可以基于生产者-消费者问题来构建一个**预线程化(Prethreading)**的并发服务器,它将主线程看成是生产者,不断接收客户端发送的连接请求,并将已连接描述符放在缓冲区中,而提前创建的一系列线程就是消费者,不断从缓冲区中取出已连接描述符与客户端通信
- 这就是线程池,类似的还有JDBC的数据库连接池等,其主要思想就是,预先建立多个线程,放在线程池中,当来服务请求了,直接用池中的线程处理,用完放回即可(不销毁,也避免了资源浪费)
- - 第24行首先对缓冲区进行初始化,然后生成了`NTHREADS`个消费者线程,在线程例程中,会通过`pthread_detach`函数来分离消费者线程,然后死循环通过`sbuf_remove`函数来从缓冲区中获得已连接描述符`connfd`,当`pthread_create`函数返回时,这些消费者线程就开始运行了,此时由于`sbuf->items`为0,所以这些消费者线程会被`P(&sbuf->items)`挂起(39行取描述符的时候)。 - _然后在生产者线程中通过`accept`函数来获得已连接描述符`connfd`,并通过`sbuf_insert`函数将其插入到缓冲区中,此时`sbuf->items`就不为0了,那些被挂起的消费者线程就会一个个被`V(&sbuf->items)`重启,然后将获得的`connfd`传入`echo_cnt`函数来与客户端通信。这里的对等线程的数目是我们一开始创建的那些线程,而不是等到获得`connfd`时才创建,所以称为预线程化,可以自己控制对等线程的数目。 - - 这个`echo_cnt`函数会通过全局变量`byte_cnt`来统计服务器总共从客户端获得的字节数,由于不同的消费者线程都会调用`echo_cnt`函数,所以会有多个线程来访问`byte_cnt`共享变量,所以我们对`byte_cnt`的访问需要使用互斥锁。而我们这里将互斥锁和`byte_cnt`的初始化过程放在了子线程中,通过`pthread_once`函数来保证该初始化函数`init_echo_cnt`函数只被执行一次。
12.6 使用线程提高并行性
- 各种实验证明线程可以提高并行性(相对进程),并给出量化数据
12.7 其他并发问题
12.7.1 线程安全
- 一个函数被称为线程安全的(thread-safe), 当且仅当被多个并发线程反复地调用时,它会一直产生正确的结果。
- 我们能够定义出四个(不相交的)线程不安全函数类 :
- 1、不保护共享变量的函数。
- 当我们没有对共享变量进行保护,而它的操作也是非原子的时,就可能会造成错误
- 解决:PV
- 2、保持跨越多个调用的状态的函数。
- 一个伪随机数生成器是这类线程不安全函数的简单例子。在单个线程程序中,我们可以通过
srand
函数定义相同的随机种子来得到可重复的随机数序列,但是在多个线程中调用rand
函数时,由于rand
函数中依赖于共享的前一个状态next_seed
,此时next_seed
可能会被其他线程修改而无法获得相同的随机数序列。 - 解决:重写使得它不再使用任何 static数据,而是依靠调用者在参数中传递状态信息。
- 可重入版:
- 一个伪随机数生成器是这类线程不安全函数的简单例子。在单个线程程序中,我们可以通过
- 3、返回指向静态变量的指针的函数
- 静态变量保存在数据区,在进程中只有一个实例,是所有线程共享的,当你返回一个静态变量的指针时,是指向一个内存地址,而在别的线程中可能也会通过该静态变量对该内存地址的内容进行修改。
- 解决:PV/重写,传递保存地址即可
- 4、调用线程不安全函数的函数。
- 如果该线程不安全函数属于第二类,就要修改函数的源码,如果是第一类或第三类,可以直接通过互斥锁的方式保证函数时线程安全的
- 1、不保护共享变量的函数。
12.7.2 可重入性
- 可重入函数(reentrant function), 其特点在于它们具有这样一种属性:当它们被多个线程调用时,不会引用任何共享数据。
- 可重入函数集合是线程安全函数的一个真子集
- **显式可重入的(explicitly reentrant):**如果所有的函数参数都是传值传递的(即没有指针),并且所有的数据引用都是本地的自动栈变量(即没有引用静态或全局变量)。 也就是说,无论它是被如何调用的,都可以断言它是可重人的。
- **隐式可重入的(implicitly reentrant):**允许显式可重人函数中一些参数是引用传递的(即允许它们传递指针),也就是说,如果调用线程小心地传递指向非共享数据的指针,那么它是可重人的。
12.7.3 在线程化的程序中使用已存在的库函数
- Linux 系统提供大多数线程不安全函数的可重人版本。可重人版本的名字总是以 “_r” 后缀结尾。例如,asctime 的可重人版本就叫做 asctime_r。
12.7.4 竞争
- 当一个程序的正确性依赖于一个线程要在另一个线程到达y点之前到达它的控制流中的 x点时,就会发生竞争(race)。
- 通常发生竞争是因为程序员假定线程将按照某种特殊的轨迹线穿过执行状态空间,而忘记了另一条准则规定:
- 多线程的程序必须对任何可行的轨迹线都正确工作。
- —个具有竞争的程序:
- 运行这个程序会有不同的结果:
- 为了消除竞争,我们可以动态地为每个整数 ID 分配一个独立的块,并且传递给线程例程一个指向这个块的指针(上文提到过)
12.7.5 死锁
- 信号量引入了一种潜在的令人厌恶的运行时错误,叫做死锁(deadlock), 它指的是一组线程被阻塞了,等待一个永远也不会为真的条件。
- 关于死锁,个人记忆很深的是死锁四条件:互斥、请求与保持、循环等待、不剥夺
- 当轨迹进入图中所示的**死锁区(Deadlock)**时,线程1占用互斥锁
s
,线程2占用互斥锁t
,而轨迹要从死锁区出来,要么线程1占用互斥锁s
,要么线程2占用互斥锁t
,这两个都是不可能为真的条件,所以就陷入了死锁状态。所以轨迹可以进入死锁区,但是无法从死锁区出来。
- **互斥锁加锁顺序规则:**给定所有互斥操作的一个全序,如果每个线程都是以一种顺序获得互斥锁并以相反的顺序 放,那么这个程序就是无死锁的。
- 例如,我们可以通过这样的方法来上图中的死锁问题:在每个线程中先对 s加锁,然后再对 t 加锁(解锁顺序无所谓)。
- 例如,我们可以通过这样的方法来上图中的死锁问题:在每个线程中先对 s加锁,然后再对 t 加锁(解锁顺序无所谓)。
本文链接:https://my.lmcjl.com/post/8339.html
展开阅读全文
4 评论