20135328陈都信息安全系统设计基础第十三周学习总结
第十三周(11.30-12.06):
一、学习目标
- 掌握三种并发的方式:进程、线程、I/O多路复用
- 掌握线程控制及相关系统调用
- 掌握线程同步互斥及相关系统调用
第 12 章 并发编程
逻辑控制流在时间上重叠,那么它们就是并发的。
并发(concurrency ) ,出现在计算机系统的许多不同层面上。
应用级并发是很有用的:
- 访问慢速I/O设备。当一个应用正在等待来自慢速 I/O 设备(例如磁盘)的数据到达时, 内核会运行其他进程,使 CPU保持繁忙。每个应用都可以按照类似的方式,通过交替执行 I/O 请求和其他有用的工作来使用并发。
- 与人交互。和计算机交互的人要求计算机有同时执行多个任务的能力。例如,他们在打印一个文档时,可能想要调整一个窗口的大小。现代视窗系统利用并发来提供这种能力。每次用户请求某种操作(比如通过单击鼠标)时,一个独立的并发逻辑流被创建来执行这个操作。
- 通过推迟工作以降低延迟。有时,应用程序能够通过推迟其他操作和并发地执行它们,利用并发来降低某些操作的延迟。比如,一个动态存储分配器可以通过推迟合并,把它放到一个运行在较低优先级上的并发"合并"流中,在有空闲的 CPU 周期时充分利用这些空闲 周期,从而降低单个 free 操作的延迟。
- 服务多个网络客户端。
- 在多核机器上进行并行计算。许多现代系统都配备有多核处理器,多核处理器中包含多个 CPU。被划分成并发流的应用程序通常在多核机器上比在单处理器机器上运行得快,因为这些流会并行执行,而不是交错执行。
使用应用级并发的应用程序称为并发程序 (concurrent program)。现代操作系统提供了三种基本的构造并发程序的方法:
进程。每个逻辑控制流都是一个进程,由内核来调度和维护。因为进程 有独立的虚拟地址空间,想要和其他流通信,控制流必须使用某种显式的进程间通信 (interprocess communication, IPC) 机制。
I/O 多路复用。在这种形式的并发编程中,应用程序在一个进程的上下文中显式地调度它们自己的逻辑流。逻辑流被模型化为状态机,数据到达文件描述符后,主程序显式地从一个状态转换到另一个状态。因为程序是一个单独的进程,所以所有的流都共享同一个地址空间。
线程。线程是运行在一个单一进程上下文中的逻辑流,由内核进行调度。是其他两种方式的混合体,像进程流一样由内核进行调度,而像I/O 多路复用流一样共享同一个虚拟地址空间。
12.1 基于进程的并发编程
构造并发程序最简单的方法就是用进程。
一个构造并发服务器的自然方法就是,在父进程中接受客户端连接请求,然后创建一个新的子进程来为每个新客户端提供服务。
服务器正在监昕一个监听描述符(比如描述符 3)上的连接请求。现在假设服务器接受了客户端 1 的连接请求, 并返回一个已连接描述符(比如描述符4)。
在接受连接请求之后,服务器派生一个子进程,这个子进程获得服务器描述符表的完整拷贝。子进程关闭它的拷贝端的连接请求中的监听描述符 3,而父进程关闭它的己连接描述符 4 的拷贝,因为不再需要这些描述符了。
其中子进程正忙于为客户端提供服务。因为父、子进程中的已连接描述符都指向同一个文件表表项,所以父进程关闭它的已连接描述符的拷贝是至关重要的。否则,将永远不会释放已连接描述符 4 的文件表条目,而且由此 引起的存储器泄漏将最终消耗尽可用的存储器,使系统崩溃。
第一步:服务器接受客户端的连接请求:
父进程为客户端 1 创建了子进程之后,它接受一个新的客户端 2 的连接请求, 并返回一个新的已连接描述符(比如描述符5),然后,父进程又派生另一个子进程,这个子进程用已连接描述符 5 为它的客户端提供服务。
此时,父进程正在等待下一个连接请求,而两个子进程正在并发地为它们各自的客户端提供服务。
第二步:服务器派生一个子进程为这个客户端服务:
第三步:服务器接受另一个连接请求:
12.1.1 基于进程的并发服务器
- 通常服务器会运行很长的时间,所以我们必须要包括一个 SIGCHLD 处理程序,来回收僵死 (zombie) 子进程的资源。因为当 SIGCHLD 处理程序执行时, SIGCHLD 信号是阻塞的,而 Unix 信号是不排队的,所以 SIGCHLD 处理程序必须准备好回收多个僵死子进程的资源。
- 父子进程必须关闭它们各自的 connfd 拷贝。这对父进程而言尤为重要,它必须关闭它的已连接描述 符,以避免存储器泄漏。
- 因为套接字的文件表表项中的引用计数,直到父子进程的 connfd 都关闭了,到客户端的连接才会终止。
第四步:服务器派生另一个子进程为新的客户端服务
基于进程的并发 echo 服务器.父进程派生一个子进程来处理每个新的连接请求:
12.1.2 关于进程的优劣
在父、子进程间共享状态信息,进程有一个非常清晰的模型 : 共享文件表,但是不共享用户地址空间。进程有独立的地址空间既是优点也是缺点。一个进程不可能不小心覆盖另一个进程的虚拟存储器,这就消除了许多令人迷惑的错误一一这是一个明显的优点。
另一方面,独立的地址空间使得进程共享状态信息变得更加困难。为了共享信息,它们必须使用显式的IPC(进程间通信)机制。基于进程的设计的另一个缺点是,它们往往比较慢,因为进程控制和 IPC 的开销很高。
12.2 基于 I/O 多路复用的并发编程
I/O 多路复用(I/O multiplexing) 技术。基本的思路就是使用 select 函数,要求内核挂起进程,只有在一个或多个I/O事件发生后,才将控制返回给应用程序
select函数:
select 函数处理类型为 fd_set 的集合,也叫做描述符集合。逻辑上,我们将描述符集合看成一个大小为 n 的位向量。
每个位 bk对应于描述符 k。当且仅当 bk= 1, 描述符 k才表明是描述符集合的一个元素。只允许你对描述符集合做三件事: 1) 分配它们, 2) 将一个此种类型的变量赋值给另一个变量, 3) 用 FD_ZERO、 FD_SET、 FD_CLR 和 FD_ISSET 宏指令来修改和检查它们。
select 函数有两个输人 : 一个称为读集合的描述符集合(fdset)和该 读集合的基数 (n) (实际上是任何描述符集合的最大基数). select 函数会一直阻塞,直到读集合中至少有一个描述符准备好可以读。当且仅当一个从该描述符读取一个字节的请求不会阻塞时,描述符 k就表示准备好可以读了。作为一个副作用, select 修改了参数 fdset 指向的 fd_set,指明读集合中一个称为准备好集合 (ready set) 的子集,这个集合是由读集合中准备好可以读了的描述符组成的。函数返回的值指明了准备好集合的基数。注意,由于这个副作用, 我们必须在每次调用 select 时都更新读集合。
使用 I/O 多路复用的 echo 服务器。服务器使用 select 等待监听描述符上的连接请求和标准输入上的命令:
不是调用 accept 函数来等待一个连接请求,而是调用 select 函数,这个函数会一直阻塞,直到监听描述符或者标准输入准备好可以读。
一旦 select 返回,我们就用 FD_ISSET 宏指令来判断哪个描述符准备好可以读了。
一旦它连接到某个客户端,就会连续回送输入行,直到客户端关闭这个连接中它的那一端。因此,如果你键入一个命令到标准输入,你将不会得到响应,直到服务器和客户端之间结束。一个 更好的方法是更细粒度的多路复用,服务器每次循环〈至多)回送一个文本行。
12.2.1 基于 I/O 多路复用的并发事件驱动服务器
I/O 多路复用可以用做并发事件驱动 (event-driven) 程序的基础,在事件驱动程序中,流是因为某种事件而前进的。
将逻辑流模型化为状态机。不严格地说,一个状态机 (state machine) 就是一组状态 (state)、输入事件(input event) 和转移他(transition),其中转移就是将状态和输入事件映射到状态。每个转移都将一个(输入状态,输入事件)对映射到一个输出状 态。自循环(self-loop) 是同一输入和输出状态之间的转移。节 点表示状态,有向弧表示转移,而弧上的标号表示输入事件。一个状态机从某种初始状态开始执行。每个输入事件都会引发一个从当前状态到下一状态的转移。
并发事件驱动 echo 服务器中逻辑流的状态机:
服务器使用I/O多路复用,借助 select 函数检测输入事件的发生。
服务器调用 select 函数来 检测两种不同类型的输人事件: a) 来自一个新客户端的连接请求到达, b) 一个己存在的客户 端的己连接描述符准备好可以读了。
基于I/O 多路复用的并发 echo 服务器。每次服务器迭代都回送来自每个准备好的描述符的文本行:
init_pool 函数初始化客户端池。 clientfd 数组表示已连接描述符的集合, 其中整数 -1 表示一个可用的槽位。初始时,已连接描述符集合是空的,而且监听描述符是 select 读集合中唯一的描述符。
init_pool: 初始化活动客户端池:
add_clieht函数添加一个新的客户端到活动客户端池中。在 clientfd 数组中找到一个空槽位后,服务器将这个已连接描述符添加到数组中,并初始化相应的RIO读缓冲区,这样一来我们就能够对这个描述符调用rio_readlineb。将这个已连接描述符添加到 select 读集合,并更新该池的一些全局属性。 maxfd 变量记录了 select 的最大文件描述符。 maxi 变量记录的 是到 clientfd数组的最大索引,这样 check_clients 函数就无需搜索整个数组了。
check_clients 函数回送来自每个准备好的已连接描述符的一个文本行。 如果成功地从描述符读取了一个文本行,那么我们就将该文本行回送到客户端
add_client: 向池中添加一个新的客户端连接:
check_clients: 为准备好的客户端连接服务:
select 函数检测到输入事件,而 add_client 函数创建 一个新的逻辑流(状态机). check_clients 函数通过回送输入行来执行状态转移,而且当客 户端完成文本行发送时,它还要删除这个状态机。
12.2.2 I/O 多路复用技术的优劣
事件驱动设计的一个优点是,它比基于进程的设计给了程序员更多的对程序行为的控制。
另一个优点是,一个基于 I/O 多路复用的事件驱动服务器是运行在单一进程上下文中的,因 此每个逻辑流都能访问该进程的全部地址空间。
事件驱动设计的一个明显的缺点就是编码复杂。我们的事件驱动的并发 echo 服务器需要的代码比基于进程的服务器多三倍。不幸的是,随着并发粒度的减小,复杂性还会上升。这里的粒度是指每个逻辑流每个时间片执行的指令数量。
12.3 基于线程的并发编程
线程(thread) 就是运行在进程上下文中的逻辑流。
每个线程都有它自己的线程上下文 (thread context),包括一个唯一的整数线程 (Thread ID, TID)、栈、栈指针、程序计数器、通用目的寄存器和条件码。所有的运行在一个进程里的线程共享该进程的整个虚拟地址空间。
基于线程的逻辑流结合了基于进程和基于 I/O 多路复用的流的特性。同进程一样,线程由内核自动调度,并且内核通过一个整数 ID 来识别线程。同基于 I/O 多路复用的流一样,多个线程 运行在单一进程的上下文中,因此共享这个进程虚拟地址空间的整个内容,包括它的代码、数据、堆、共享库和打开的文件。
12.3.1 线程执行模型
每个进程开始生命周期时都是单一线程,这个线程称为主线程 (main thread)。在某一时刻,主线程创建一个对等线程 (peer thread),从这个时间点开始,两个线程就并发地运行。最后,因 为主线程执行一个慢速系统调用。或者因为它被系统的间隔计时器中断, 控制就会通过上下文切换传递到对等线程。对等线程会执行一段时间,然后控制传递回主线程,依次类推。
因为一个线程的上下文要比一个进程的上下文小得多,线程的上下文切换要比 进程的上下文切换快得多。另一个不同就是线程不像进程那样,不是按照严格的父子层次来组织的。和一个进程相关的线程组成一个对等(线程)池 (pool),独立于其他线程创建的线程。主线程和其他线程的区别仅在于它总是进程中第一个运行的线程。对等 (线程)池概念的主要影响是,一个线程可 以杀死它的任何对等线程,或者等待它的任意对等线程终止。另外,每个对等线程都能读写相同的共享数据。
并发线程执行:
12.3.2 Posix 线程
Posix 线程 (Pthreads) 是在 C 程序中处理线程的一个标准接口。Pthreads 定义了大约 60 个函数,允许程序创建、杀死和回收线程,与对等线程安全地共享数据,还可以通知对等线程系统状态的变化。
线程的代码和本地数据被封装在一个线程例程(thread routine) 中。如果想传递多个参数给钱程例程,那么你应该将参数放 到一个结构中,并传递一个指向该结构的指针。想要线程例程返回多个参数,你可以返回一个指向一个结构的指针。
12.3.3 创建线程
线程通过调用 pthread create 函数来创建其他线程。
pthread_create 函数创建一个新的线程,并带着一个输入变量arg,在新线程的上下文中运行线程例程f。能用attr参数来改变新创建线程的默认属性。
当 pthread_create 返回时,参数 tid包含新创建线程的ID。新线程可以通过调用 pthread_self 函数来获得它自己的线程 ID.
12.3.4 终止线程
一个线程是以下列方式之一来终止的 :
-
当顶层的线程例程返回时,线程会隐式地终止。
-
通过调用 pthread_exit 函数,线程会显式地终止。如果主线程调用 pthread_exit , 它会等待所有其他对等线程终止,然后再终止主线程和整个进程,返回值为 thread_return。
-
某个对等线程调用 Unix 的 exit 函数,该函数终止进程以及所有与该进程相关的线程。
-
另一个对等线程通过以当前线程ID作为参数调用 pthread_cancle 函数来终止当前线程。
12.3.5 回收已终止线程的资源
线程通过调用 pthread_join 函数等待其他线程终止。
pthread_join 函数会阻塞,直到线程 tid 终止,将线程例程返回的 (void*) 指针赋值 为 thread_return 指向的位置,然后回收己终止线程占用的所有存储器资源。
pthread join 函数只能等待一个指定的线程终止。
12.3.6 分离线程
在任何一个时间点上,线程是可结合的 (joinable) 或者是分离的 (detached)。一个可结合的线程能够被其他线程收回其资源和杀死。在被其他线程回收之前,它的存储器资源(例如栈)是没有被释放的。相反,一个分离的线程是不能被其他线程回收或杀死的。它的存储器资源在它终止时由系统自动释放。
默认情况下,线程被创建成可结合的。为了避免存储器泄漏,每个可结合线程都应该要么被其他线程显式地收回,要么通过调用 pthread_detach 函数被分离。
pthread_detach 函数分离可结合线程 tid. 线程能够通过以 pthread_self ()为参数 的 pthread_detach 调用来分离它们自己。
12.3.7 初始化线程
pthread_once 函数允许你初始化与线程例程相关的状态。
once_control 变量是一个全局或者静态变量,总是被初始化为 PTHREAD_ONCE_INIT。 当你第一次用参数 once_control 调用 pthread_once 时, 它调用 init_routine,这是一个没有输入参数,也不返回什么的函数。
动态初始化多个线程共享的全局变量时, pthread_once 函数是很有用的。
12.3.8 一个基于线程的并发服务器
调用 pthread_ create 时,如何将已连接描述符传递给对等线程。最明显的方法就是传递一个指向这个描述符的指针。
对等线程间接引用这个指针,并将它赋值给一个局部变量。
这样可能会出错,因为它在对等线程的赋值语句和主线程的 accept 语句间引入了竞争 (race)。如果赋值语句在下一个 accept 之前完成,那么对等线程中的局部变量 connfd 就得到正确的描述符值。然而,如果赋值语句是在 accept 之后才完成的,那么对等线程中的 局部变量 connfd 就得到下一次连接的描述符值。那么不幸的结果就是,现在两个线程在同一 个描述符上执行输入和输出。为了避免这种潜在的致命竞争,我们必须将每个 accept 返回的 已连接描述符分配到它自己的动态分配的存储器块
是在线程例程中避免存储器泄漏。既不显式地收回线程,就必须分离 每个线程,使得在它终止时它的存储器资源能够被收回。更进一步,我们必须小心释放主线程分配的存储器块
12.4 多线程程序中的共享变量
12.4.1 线程存储器模型
一组并发线程运行在一个进程的上下文中。每个线程都有它自己独立的线程上下文,包括线程 ID、栈、栈指针、程序计数器、条件码和通用目的寄存器值。每个线程和其他线程一起共享进程上下文的剩余部分。这包括整个用户虚拟地址空间,它是由只读文本(代码)、读/写数据、堆以及所有的共享库代码和数据区域组成的。线程也共享同样的打开文件的集合。
让一个线程去读或写另一个线程的寄存器值是不可能的。另一方 面,任何线程都可以访问共享虚拟存储器的任意位置。
保存在虚拟地址空间的栈区域中,并且通常是被相应的线程独立地访问的。
12.4.2 将变量映射到存储器
线程化的 C 程序中变量根据它们的存储类型被映射到虚拟存储器:
- 全局变量。全局变量是定义在函数之外的变量。在运行时,虚拟存储器的读/写区域只包含每个全局变量的一个实例,任何线程都可以引用。
- 本地自动变量。本地自动变量就是定义在函数内部但是没有 static 属性的变量。在运行时,每个线程的栈都包含它自己的所有本地自动变量的实例。即使当多个线程执行同一个线程例程时也是如此。
- 本地静态变量。本地静态变量是定义在函数内部并有 static 属性的变量。和全局变量一样,虚拟存储器的读/写区域只包含在程序中声明的每个本地静态变量的一个实例。
12.4.3 共享变量
一个变量 v 是共亭的,当且仅当它的一个实例被一个以上的线程引用。
12.5 用信号量同步线程
同步错误。
将线程 i 的循环代码分解成五个部分:
- Hi:在循环头部的指令块
- Li:加载共享变量 cnt 到寄存器%eaxi 的指令,这里%eaxi 表示线程i 中的寄存器%eax的值。
- Ui:更新(增加) %eaxi 的指令。
- Si:将%eaxi 的更新值存回到共享变量 cnt 的指令。
- Ti:循环尾部的指令块。
注意头和尾只操作本地栈变量,而 Li、 Ui 和 Si操作共享计数器变量的内容。
一般而言, 你没有办法预测操作系统是否将为你的线程选择一个正确的顺 序。
进度图 (progress graph) 的方法来阐明这些正确 的和不正确的指令顺序的概念
12.5.1 进度图
进度图 (progress graph) 将 n 个并发线程的执行模型化为一条 n 维笛卡儿空间中的轨迹线。每条轴 k对应于线程 k 的进度。每个代表线程 k 已经完成了指令 Ik 这一状态。图的原点对应于没有任何线 程完成一条指令的初始状态。
进度图将指令执行模型化为从一种状态到另一种状态的转换(transition)。转换被表示为一条从一点到相邻点的有向边。合法的转换是向右(线程 1 中的一条指令完成〉或者向上(线程 2 中的一条指令完成)的。两条指令不能在同一时刻完成一一对角线转换是不允许的。程序决不会反向运行,所以向下或者向左移动的转换也是不合法的。
一个程序的执行历史被模型化为状态空间中的一条轨迹线。
绕开不安全区的轨迹线叫做安全轨迹线 (safe trajectory)。 相反,接触到任何不安全区的轨迹线就叫做不安全轨迹线 (unsafe trajectory)。
12.5.2 信号量
一种解决同步不同执行线程问题的方法,这种方法是基于一种叫做信号量 (semaphore) 的特殊类型变量的。信号量 s 是具有非负整数值的全 局变量,只能由两种特殊的操作来处理,这两种操作称为 P 和 V:
- P(s) :如果 s 是非零的,那么 P 将 s 减1,并且立即返回。如果 s 为零,那么就挂起这个线程, 直到 s变为非零,而一个 V操 作会重启这个线程。在重启之后, P 操作将 s 减1,并将控制 返回给调用者。
- V(s): V操作将s 加 1。如果有 任何线程阻塞在P 操作等待 s 变成非零,那么 V操作会重启 这些线程中的一个,然后该线程将s 减1,完成它的 P 操作。
P 中的测试和减 1 操作是不可分割的,也就是说,一旦预测信号量s 变为非零,就会将s 减 1,不能有中断。V中的加 1 操作也是不可分割的,也就是加载、加 1 和存储信号量的过程中没 有中断。注意, V的定义中没有定义等待线程被重新启动的顺序。唯一的要求是V必须只能重启一个正在等待的线程。因此,当有多个线程在等待同一个信号量时,你不能预测V操作要重启哪一个线程。
P 和 V的定义确保了一个正在运行的程序绝不可能进入这样一种状态,也就是一个正确初始化了的信号量有一个负值。这个属性称为信号量不变性 (semaphore invariant),为控制并发程序的轨迹线提供了强有力的工具
信号量的函数:
sem_init 函数将信号量 sem 初始化为 value. 每个信号量在使用前必须初始化。针对我 们的目的,中间的参数总是0。程序分别通过调用 sem_wait 和 sem_post 函数来执行 P 和 V 操作。
P 和 V的包装函数:
12.5.3 使用信号量来实现互斥
基本思想是将每个共享变量 (或者一组相关的共享变量)与一个信号量 s (初始为1)联系起来,然后用 P(s) 和V(s) 操作将 相应的临界区包围起来。
保护共享变量的信号量叫做二元信号量 (binary semaphore),因为它的值总是 0 或者 1 。以提供互斥为目的的二元信号量常常也称为互斥锁 (mutex)。在一个互斥锁上执行 P 操作称为对互斥锁加锁。类似地,执行 V操作称为对互斥锁解锁。对一个互斥锁加了锁但是还没有解锁的线程称为占用这个互斥锁。一个被用作一组可用资源的计数器的信号量称为计数信号量。
关键思想是这种 P 和 V操作的结合创建了一组状态, 叫做禁止区 (forbidden region),其中 s<O。因为信号量的不变性,没有实际可行的轨迹线能够包含禁止区中的状态。而且,因为禁止区完全包括了不安全区,所以没有实际可行的轨迹线能够接触不安全区的任何部分。因此,每条实际可行的轨迹线都是安全的,而且不管运行时指令顺序是怎样的,程序都会正确地增加计数器的值。
使用信号量来互斥。 s<O 的不可行状态定义了一个禁止区,禁止区完全包括了不安全 区,阻止了实际可行的轨迹线接触到不安全区:
由 P 和 V操作创建的禁止区使得在任何时间点上,在被包围的临 界区中,不可能有多个线程在执行指令。换句话说,信号量操作确保了对临界区的互斥访问。
12.5.4 利用信号量来调度共享资源
除了提供互斥之外,信号量的另一个重要作用是调度对共享资源的访问。
1. 生产者-消费者问题
生产者一消费者问题。生产者产生项目并把它们插入到一个有限的缓冲区中。消费者从缓冲区中取出这些项目,然后消费它们
因为插入和取出项目都涉及更新共享变量,所以我们必须保证对缓冲区的访问是互斥的。但是只保证互斥访问是不够的,我们还需要调度对缓冲区的访问。如果缓冲区是满的(没有空的槽位),那么生产者必须等待直到有一个槽位变为可用。与之相似,如果缓冲区是空的(没有可取用的项目),那么消费者必须等待直到有一个项目变为可用。
基于预线程化 (prethreading) 的有趣的并发服务器。 SBUP 操作类型为 sbuf_t 的有限缓冲区,项目存放在一个动态分配的 n 项整数数组 (buf) 中。 front 和 rear 索引值记录该数组中的第一项和最后一项。三个信号量同步对缓 冲区的访问。 mutex 信号量提供互斥的缓冲区访问。 slots 和 items 信号量分别记录空槽位和可用项目的数量。
sbuf_t: SBUF 包使用的有限缓冲区:
sbuf_init 函数为缓冲区分配堆存储器,设置 front 和 rear 表示一个空的缓冲区,并为三个信号量赋初始值。这个函数在调用其他三个函数中的任何一个之前调用一次。 sbuf_deinit函数是当应用程序使用完缓冲区时,释放缓冲区存储的。 sbuf_insert 函数等待一个可用的槽位,对互斥锁加锁,添加项目,对互斥锁解锁,然后宣布有一个新项目可用。 sbuf_remove 函数是与 sbuf_insert 函数对称的。在等待一个可用的缓冲区项目之后,对互斥锁加锁,从缓冲区的前面取出该项目,对互斥锁解锁,然后发信号通知一个新的槽位可供使用。
SBUF: 同步对有限缓冲区并发访问的包:
2. 读者-写者问题
有些线程只读对象,而其他的线程只修改对象。修改对象的线程叫做写者。只读对象的线程叫做读者。写者必须拥有对对象的独占的访问,而读者可以和无限多个其他的读者共享对象。一般来说,有无限多个并发的读者和写者。
第一类读者-写者问题,读者优先,要求不要让读者等待,除非已经把使用对象的权限赋予了一个写者。换句话说,读者不会因为有一个写者在等待而等待。第二类读者一写者问题,写者优先,要求一旦一个写者准备好可以写,它就会尽可能快地完成它的写操作。
对这两种读者-写者问题的正确解答可能导致饥饿 (starvation),饥饿就是一个线程无限期地阻塞,无法进展。
12.5.5 综合: 基于预线程化的并发服务器
预线程化的并发服务器的组织结构。一组现有的线程不断地取出和处理来自有限缓冲区的已连接描述符:
12.6 使用线程提高并行性
顺序、并发和并行程序集合之间的关系:
并行程序常常被写为每个核上只运行一个线程。
并行程序的加速比 (speedup) 通常定义为:
p 是处理器核的数量,凡是在 k个核上的运行时间。这个公式有时称为强扩展 (strong scaling)。当 T1 是程序顺序执行版本的执行时间时, Sp 称为绝对加速比.(absolute speedup)。当 T1 是程序并行版本在一个核上的执行时间时, Sp 称为相对加速比 (relative speedup)。绝对加速 比比相对加速比能更真实地衡量并行的好处。
效率 (efficiency ) ,定义为
通常表示为范围在 (0, 100] 之间的百分比。效率是对由于并行化造成的开销的衡量。具有高 效率的程序比效率低的程序在有用的工作上花费更多的时间,在同步和通信上花费更少的时间。
加速比还有另外一面,称为弱扩展 (weak scaling),在增加处理器数量的同时,增加问题的规模,这样随着处理器数量的增加,每个处理器执行的工作量保持不变。加速比和效率被表达为单位时间完成的工作总量。
12.7 其他并发问题
12.7.1 线程安全
当用线程编写程序时,我们必须小心地编写那些具有称为线程安全性(thread safety) 属性的画数。一个函数被称为线程安全的(thread-safe),当且仅当被多个并发线程反复地调用时,它会一直产生正确的结果。如果一个函数不是线程安全的,我们就说它是线程不安全的(thread-unsafe)。
四个(不相交的)线程不安全函数类:
-
第 1 类: 不保护共享变量的函数。thread 函数中对一个未受保护的全局计数器变量加 1. 将这类线程不安全函数变成线程安全的, 相对而言比较容易:利用像P和 V操作这样的同步操作来保护共享的变量。这个方法的优点是在调用程序中不需要做任何修改。缺点是同步操作将减慢程序的执行时间。
-
第 2 类:保持跨越多个调用的状态的函数。一个伪随机数生成器是这类线程不安全函数的简单例子。伪随机数生成器程序包. rand 函数是线程不安全的,因为当前调用的结果依赖于前次调用的中间结果。当调用 srand 为 rand 设置了一个种子后,我们从一个单线程中反复地调用 rand,能够预期得到一个可重复的随机数字序列。然而,如果多线程调用 rand 函数,这种假设就不再成立了。
使得像 rand这样的函数线程安全的唯一方式是重写它,使得它不再使用任何 static 数据,而是依靠调用者在参数中传递状态信息。这样做的缺点是,程序员现在还要被迫修改调用程序中的代码。在一个大的程序中,可能有成百上千个不同的调用位置,做这样的修改将是非常麻烦的,而且容易出错。 -
第 3 类:返回指向静态变量的指针的函数。某些函数,例如 ctime 和 gethostbyname,将计算结果放在一个 static 变量中,然后返回一个指向这个变量的指针。如果我们从并发线程中调用 这些函数,那么将可能发生灾难,因为正在被一个线程使用的结果会被另一个线程悄悄地覆盖了。有两种方法来处理这类线程不安全函数。一种选择是重写函数,使得调用者传递存放结果的变量的地址。这就消除了所有共享数据,但是它要求程序员能够修改函数的源代码。
-
第 4 类:调用线程不安全函数的函数。如果函数f调用线程不安全函数 g,那么f就是线程不安全的吗?不一定。如果 g是第 2 类函数,即依赖于跨越多次调用的状态,那么f也是线程不安全的,而且除了重写 g 以外,没有什么办法。然而,如果 g 是第 1 类或者第 3 类函数,那么只 要你用一个互斥锁保护调用位置和任何得到的共享数据,f仍然可能是线程安全的。
12.7.2 可重入性
可重入函数 (reentrant function),其特点在于它们具有这 样一种属性:当它们被多个线程调用时,不会引用任何共享数据。
可重入函数通常要比不可重人的线程安全的函数高效一些,因为它们不需要同步操作。更进一步来说,将第 2 类线程不安全函数转化为线 程安全函数的唯一方法就是重写它,使之变为可重入的。
可重入函数、线程安全函数和线程不安全函数之间的集合关系:
检查某个函数的代码并先验地断定它是可重入的。
如果所有的函数参数都是传值传递的(即没有指针),并且所有的数据引用都是本地的自动栈变量(即没有引用静态或全局变量),那么函数就是显式可重入的 (explicitly reentrant),也就是说,无论它是被如何调用的,我们都可以断言它是可重入的。
我们总是使用术语可重入的 (reenntrant) 既包括显式可重入函数也包括隐式可重入函数。然而,认识到可重入性有时既是调用者也是被调用者的属性,并不只是被调用者单独的属性是非常重要的。
12.7.3 在线程化的程序中使用已存在的库函数
大多数 Unix 函数,包括定义在标准 C 库中的函数(例如 malloc、 free、 realloc、 printf 和 scanf) 都是线程安全的,只有一小部分是例外。
常见的线程不安全的库函数:
除了 rand 和 strtok 以外,所有这些线程不安全函数都是第 3 类的,它们返回一个指向静态变量的指针。如果我们需要在一个线程化的程序中调用这些函数中的某一个,对调用者来说最不惹麻烦的方法是加锁-拷贝。然而,加锁-拷贝方法有许多缺点。首先,额外的同步降低了 程序的速度。其次,像 gethostbyname 这样的函数返回指向复杂结构的结构的指针,要拷贝整个结构层次,需要深层拷贝 (deep copy) 结构。再次,加锁-拷贝方法对像 rand 这样依赖 跨越调用的静态状态的第 2 类函数并不有效。 因此,Unix系统提供大多数线程不安全函数的可重人版本。可重入版本的名字总是以"_r" 后缀结尾。
12.7.4 竞争
当一个程序的正确性依赖于一个线程要在另一个线程到达y 点之前到达它的控制流中的 x 点时,就会发生竞争。
发生竞争是因为程序员假定线程将按照某种特殊的轨迹线穿过执行状态空间,而忘记了另一条准则规定:线程化的程序必须对任何可行的轨迹线都正确工作。
12.7.5 死锁
信号量引人了一种潜在的令人厌恶的运行时错误,叫做死锁(deadlock) ,它指的是一组线程被阻塞了,等待一个永远也不会为真的条件。
-
程序员使用 P 和 V操作顺序不当,以至于两个信号量的禁止区域重叠。如果某个执行轨迹 线碰巧到达了死锁状态 d,那么就不可能有进一步的进展了,因为重叠的禁止区域阻塞了 每个合法方向上的进展。换句话说,程序死锁是因为每个线程都在等待其他线程执行一个根本不可能发生的V操作。
-
重叠的禁止区域引起了一组称为死所区域(deadlock region )的状态。如果一个轨迹线碰巧到达了一个死锁区域中的状态,那么死锁就是不可避免的了。轨迹线可以进人死锁区域, 但是它们不可能离开。
-
死锁是一个相当困难的问题,因为它不总是可预测的。一些幸运的执行轨迹线将绕开死锁区域,而其他的将会陷入这个区域。
程序死锁有很多原因,要避免死锁一般而言是很困难的。然而,当使用二元信号量来实现互斥时,可以应用下面的简单而有效的规则来避免死锁:
互斥锁加锁顺序规则:如果对于程序中每对互斥锁 (s, t), 每个同时占用 s 和 t 的线程都按照相同的顺序对它们加锁,那么这个程序就是无死锁的。
有死锁程序的进度图:
无死锁程序的进度图:
12.8 小结
一个并发程序是由在时间上重叠的一组逻辑流组成的。
三种不同的构建并发程序的机制:进程、I/O 多路复用和线程。
进程是由内核自动调度的,而且因为它们有各自独立的虚拟地址空间,所以要实现共享数 据,必须要有显式的 IPC 机制。事件驱动程序创建它们自己的并发逻辑流,这些逻辑流被模型化为状态机,用I/O 多路复用来显式地调度这些流。因为程序运行在一个单一进程中,所以在流之间共享数据速度很快而且很容易。线程是这些方法的综合。同基于进程的流一样,线程也是由内核自动调度的。同基于 I/O 多路复用的流一样,线程是运行在一个单一进程的上下文中的,因 此可以快速而方便地共享数据。
无论哪种并发机制,同步对共享数据的并发访问都是一个困难的问题。提出对信号量的 P 和 V操作就是为了帮助解决这个问题。信号量操作可以用来提供对共享数据的互斥访问,也对诸如生产者一消费者程序中有限缓冲区和读者一写者系统中的共享对象这样的资源访问进行调度。
并发也引人了其他一些困难的问题。被线程调用的函数必须具有一种称为线程安全的属性。
可重入函数是线程安全函数的一个真子集,它不访问任何共享数据。可重入函数通常比不可重人函数更为有效,因为它们不需要任何同步原语。竞争和死锁是并发程序中出现的另一些困难的问题。当程序员错误地假设逻辑流该如何调度时,就会发生竞争。当一个流等待一个永远不会发生的事件时,就会产生死锁。
四类线程不安全的函数。