第十二章 并发编程
如果逻辑控制流在时间上重叠,那么就称它们是并发的。注意:核心是在时间上重叠。
操作系统内核运行多个应用程序采用了并发机制,但并发不止用于内核,也用于应用程序中。应用级并发的一些应用场合:
- 访问慢速 I/O 设备。当一个用户等待来自慢速 I/O 设备(比如磁盘)的数据到达时,内核会运行其他进程。
- 与人交互。每次用户请求某种操作时(比如通过点击鼠标),一个独立的并发逻辑流被创建来执行这个操作。
- 通过推迟工作以降低延迟。
- 服务多个网络客户端。一个并发服务器为每个客户端创建一个单独的逻辑流。
- 在多核机器上进行并行计算。被划分称并发流的应用程序通常在多个机器上比单处理器机器上快很多,因为这些流会并行执行,而不是交错执行。
使用应用级并发的应用程序称为并发程序。OS 提供了三种基本的构造并发程序的方法:
- 进程。在这种形式下,每个逻辑控制流都是一个进程,由内核来调度和维护。因为进程有独立的虚拟地址空间,想要和其他流通信,控制流必须使用显式的进程间通信(IPC)机制。
- **I/O 多路复用。**在这种形式下,应用程序在一个进程的上下文中显式地调用它们自己的逻辑流。逻辑流被模型化为状态机,数据到达文件描述符后,主程序显式地从一个状态转换到另一个状态。因为程序是一个单独的进程,所以所有的流都共享同一个地址空间。
- **线程。**线程是运行在一个单一进程上下文中的逻辑流,由内核进行调度。线程像进程流一样由内核进行调度,像 I/O 多路复用一样共享同一个虚拟地址空间。
12.1 基于进程的并发编程
构造并发程序最简单的方法就是用进程,使用 fork, exec, waitpid 等函数。
一个构造并发服务器的自然方法就是在父进程中接受客户端连接请求,然后创建一个新的子进程来为每个客户端提供服务。
上图中,服务器正在监听一个监听描述符(listenfd3)上的连接请求。然后服务器接受了客户端 1 的连接请求,返回给客户端 1 一个已连接描述符(connfd4)。这之后服务器要进行如下操作:
- 派生一个子进程,这个子进程获得服务器描述符表的完整副本。
- 子进程关闭它的副本中的监听描述符 listenfd3。
- 父进程关闭它的已连接描述符 connfd4 的副本。
因为父、子进程中的已连接描述符 connfd4 指向同一个文件表表项,所以父进程必须关闭它的 connfd 的副本,否则将永远不会释放 connfd4 的文件表条目,导致内存泄漏,进而耗光内存,系统崩溃。
12.1.1 基于进程的并发服务器
'一个基于进程的并发 echo 服务器'
#include "csapp.h"
void echo(int connfd);
void sigchld_handler(int sig)
{
while(waitpid(-1, 0, WNOHANG) > 0) ;
return;
}
12.1.2 进程的优劣
优点:父子进程间共享状态信息:共享文件表,但是不共享用户地址空间。这样避免了一个进程覆盖另外一个进程的虚拟内存。
缺点:独立的地址空间使得进程共享状态信息变得更加困难,需要使用进程间通信(IPC)机制。
12.2 基于I/O多路复用的并发编程
I/O多路复用技术,基本思想就是使用 select 函数,要求内核挂起进程,只有在一个或多个 I/O 事件发生后,才将控制返回给应用程序。
12.2.1 基于I/O多路复用的并发事件驱动服务器
IO多路复用可以用到并发事件驱动程序的基础,在事件驱动程序中某些事件会导致流向前推进,一般的思路是将逻辑流模型转化为状态机,不严格的说一个状态机就是一组状态、输入时间和转移。服务器使用IO多路复用借助select函数检测输入事件的发生,当每个已连接描述符准备好可读时,服务器就为相应的状态机执行转移,在这里就是从描述符读和写回一个文本行。
12.2.2 I/O多路复用技术的优劣
事件驱动设计的优点:
(1)它比基于进程的设计给了程序员更多对程序行为的控制。例如,我们可以编写一个事件驱动的并发服务器,为某些客户端提供它们需要的服务,而对于基于进程的并发服务器来说是很困难的;
(2)一个基于IO多路复用的事件驱动服务器试运行在单一进程上下文中的,因此每个逻辑流都能访问该进程的全部地址空间,这使得流之间共享数据变得很容易;
(3)一个与作为单个进程运行相关的优点是,你可以利用熟悉的调试工具例如GDB,来调试你的并发服务器,就像对顺序程序那样
(4)比基于进程的设计要高效的多,因为他们不需要进程上下文切换来调度新的流。
事件驱动设计的缺点:
(1)编码复杂,上面的事件驱动并发echo服务器需要的代码比基于进程的服务器多三倍,随着并发粒度的减小,复杂性还会上升,这里的粒度是指每个逻辑流每个时间片执行的指令数量;
(2)不能充分利用多核处理器。
12.3 基于线程的并发编程
上面介绍了两种创建并发逻辑流的方法。
接下来介绍第三种方法:基于线程。它是两种方法的混合。
线程就是运行在进程上下文中的逻辑流。
现代操作系统允许我们编写一个进程里同时运行多个线程的程序。
线程由内核调度。
每个线程都有自己的线程上下文。
这个上下文包括:整数线程ID、栈、栈指针、程序计数器、通用目的寄存器和条件码。
所有运行在一个进程里的线程共享该进程的整个虚拟地址空间。
12.3.1 线程执行模型
每个进程开始生命周期时都是单一线程,这个线程成为主线程。
在某个时刻,主线程创建一个对等线程(peer thread)。
从这个时刻开始,两个线程并发地运行。
最后,因为主线程执行一个慢速系统调用,或者它被系统的间隔计时器中断,控制就会通过上下文切换传递到对等线程。
对等线程会执行一段时间,然后控制传递回主线程。以此类推。
线程与进程的不同之处
1)线程的上下文切换比进程的上下文切换要快得多,因为线程的上下文要比进程的上下文小得多;
2)线程不像进程那样,不是严格按照父子层次来组织的。和一个进程相关的线程组成一个对等线程池(pool),独立于其他进程创建的线程。
3)主线程和其他线程的区别仅在于它总是进程中第一个运行的线程。
4)对等线程池概念的主要影响是,一个线程可以杀死它的任何对等线程,或者等待它的任意对等线程终止。
5)另外,每个对等线程都能读写相同的共享数据。
12.3.2 Posix线程
Posix线程是在C程序中处理线程的一个标准接口。
Pthreads 定义大约60个函数,允许程序创建、杀死和回收线程,与对等线程安全地共享数据,还可以通知对等线程系统状态的变化。
12.3.3 创建线程
创建线程:pthread_create
12.3.4 终止线程
终止线程:pthread_exit
12.3.5 回收已终止线程的资源
回收已终止线程的资源:pthread_join 等待其他线程终止。
12.3.6 分离线程
分离线程:pthread_detach 可结合的线程是能够被其他线程回收其资源并杀死的,分离的线程是不能被其他线程回收或杀死的。
12.3.7 初始化线程
初始化线程:pthread_once
12.4 多线程程序中的共享变量
线程很有吸引力的一个方面就是多个线程很容易共享相同的程序变量。
但是这种共享也是棘手的。我们必须对所谓的共享以及它是如何工作的有很清楚的了解。
为了理解C程序中的一个变量是否是共享的,有一些基本的问题要解答:
1)线程的基础存储器模型是什么?
2)根据这个模型,变量实例是如何映射到存储器的?
3)最后,有多少线程引用这些实例?一个变量是共享的,当且仅当多个线程引用这个变量的某个实例。
12.4.1 线程内存模型
一组并发线程运行在一个进程的上下文中。
每个线程都有它自己独立的线程上下文,包括线程ID、栈、栈指针、程序计数器、条件码、通用目的寄存器值。
每个线程和其他线程一起共享进程上下文的剩余部分。
这个剩余部分包括整个用户虚拟地址空间,它是由只读文本(代码)、读/写数据、堆以及所有的共享库代码和数据区域组成的。
线程也共享同样的打开文件的集合。
寄存器是从不共享的,而虚拟存储器总是共享的。
12.4.2 将变量映射到内存
线程化的C程序中变量根据它们的存储类型被映射到虚拟存储器:
全局变量
全局变量是定义在函数之外的变量。
在运行时,虚拟存储器的读/写区域只包含每个全局变量的一个实例,任何线程都可以引用。
本地自助变量
本地自动变量就是定义在函数内部但是没有static属性的变量。
在运行时,每个线程的栈都包含它自己的所有本地自动变量的实例。
本地静态变量
定义在函数内部并有static属性的变量。
和全局变量一样,虚拟存储器的读/写去也只包含在程序中声明的每个本地静态变量的一个实例。
12.4.3 共享变量
我们说一个变量是共享的,是指当且仅当它的一个实例被一个以上的线程引用时。
12.5 用信号量同步线程
共享变量的使用是十分方便的,但是它们也引入了同步错误的可能性。
12.5.1 进度图
进度图将n个并发线程的执行模型化为一条n维笛卡尔空间中的轨迹线。
我们希望确保每个线程在执行它的临界区中的指令时,拥有对共享变量的互斥访问。通常这种现象称为互斥。
两个临界区的交集形成的状态空间称为不安全区。
有些轨迹如果穿越了不安全区,那么这种行为就是不安全的。
12.5.2 信号量
信号量是一种特殊类型的变量,用于解决同步不同执行线程问题。
信号量s是具有非负整数值的全局变量,只能由两种特殊的操作来处理,这两种操作称为P和V:
P,如果s是非零,则P将s减1;
V,将s加1
P和V确保了一个正在运行的程序绝不可能进入这样一种状态,也就是一个正确初始化的信号量有一个负值。
这个属性称为信号不变性,为控制并发程序的轨迹线提供了强有力的工具。
Posix标准定义了许多操作信号量的函数。
12.5.3 使用信号量来实现互斥
信号量是一种特殊的共享变量。
它提供了一种很方便的方法来确保对共享变量的互斥访问。
基本思想是将每个共享变量与一个信号量联系起来。
然后用P和V操作将相应的临界区包围起来。
以这种方式来保护共享变量的信号量叫做二元信号量,因为它的值总是0或1。
以提供互斥为目的的二元信号量常常也称为互斥锁。
在一个互斥锁上执行P操作称为对互斥锁加锁。类似地,执行V操作称为对互斥锁解锁。
对互斥锁加锁,但是没有对互斥锁解锁的线程称为占用这个互斥锁。
一个被用作一组可用资源的计数器的信号量称为计数信号量。
P和V操作创建的禁止区使得在任何时间点上,在被包围的临界区中,不可能有多个线程在执行指令。
换句话说,信号量操作确保了对临界区的互斥访问。就像不可能同时有两个人在上一个茅坑一样。
最终的目的,无论是在单处理器还是多处理器上运行程序,都要同步你对共享变量的访问。
12.5.4 利用信号量来调度共享资源
除了提供互斥之外,信号量还有一个重要的作用是,调度对共享资源的访问。
在这种场景中,一个线程用信号量来通知另一个线程,程序状态中的某个条件已经成真了。
以下有两个经典而有用的例子:
1、生产者-消费者问题
生产者和消费者线程共享一个有n个槽的有限缓冲区。
生产者线程反复地生成新的项目(item),并把它们插入到缓冲区中。
消费者线程不断地从缓冲区中取出这些项目,然后消费它们。
因为插入和取出项目都涉及更新共享变量,所以我们必须保证对缓冲区的访问的都是互斥的。
但是仅仅保证互斥还是不够的,我们还需要调度对缓冲区的访问。
如果缓冲区是满的(没有空的槽位),那么生产者必须等待到有空的槽位可用为止。
如果缓冲区是空的(没有可取用的槽位),那么消费者必须等待直到有一个项目变为可用为止。
2、读者-写者问题
读者-写者问题是互斥问题的一个概括。
一组并发地线程要访问一个共享对象。有些线程只读对象,有些线程只修改对象。
修改对象的线程叫做写者。
只读对象的线程叫做读者。
写者必须拥有对对象的独占的访问,而读者可以和无限多个其他的读者共享对象。
读者-写者问题有几个变种:
1)读者优先,要求不要让读者等待,除非已经把使用对象的权限赋予了一个写者。换句话说,读者不会因为有一个写者在等待而等待。
2)写者优先,要求一旦一个写者准备好可以写,它就会尽可能地完成它的写操作。在一个写者后到达的读者必须等待。
第一个变种会引发一个问题就是饥饿。如果有读者不断地到达,写者就可能无限期地等待。
12.5.5 综合:基于预线程化的并发服务器
12.6 使用线程提高并行性
对于并行性的利用越来越重要。
许多现代处理器都有多核,并发程序通常在这样的机器上运行得更快。
Web服务器、浏览器,数据库服务器等应用中,并行性也变得越来越有用。
所有程序的集合都可以划分成不相交的顺序程序的集合和并发程序的集合。
写顺序程序只有一条逻辑流。
写并发程序有多条并发流。
并行程序是一个运行在多个处理器上的并发程序。
因此,并行程序的集合是并发程序集合的真子集。
并行程序有一些衡量程序性能的指标。称为加速比和效率。
效率是对并行化造成的开销的衡量。高效率的程序在同步和通信上花费的时间更短。
强扩展 弱扩展;
12.7 其他并发问题
12.7.1 线程安全
定义出四类线程不安全的函数(不相交):
1)不保护共享变量的函数
2)保持跨越多个调用的状态的函数
伪随机数是这类线程不安全函数的简单例子;
3)返回指向静态变量的指针的函数
4) 调用线程不安全函数的函数
12.7.2 可重入性
可重入函数是线程安全函数的真子集。
可重入函数的特点是不会引用任何共享数据。
12.7.3 在线程化的程序中使用已存在的库函数
Unix系统提供大多数线程不安全函数的可重入版本。
可重入版本的名字总是以“_r”后缀结尾的。
12.7.1 线程安全
定义出四类线程不安全的函数(不相交):
1)不保护共享变量的函数
2)保持跨越多个调用的状态的函数
伪随机数是这类线程不安全函数的简单例子;
3)返回指向静态变量的指针的函数
4) 调用线程不安全函数的函数
12.7.2 可重入性
[外链图片转存中…(img-EU6dXkDp-1722409759013)]
可重入函数是线程安全函数的真子集。
可重入函数的特点是不会引用任何共享数据。
12.7.3 在线程化的程序中使用已存在的库函数
Unix系统提供大多数线程不安全函数的可重入版本。
可重入版本的名字总是以“_r”后缀结尾的。
[外链图片转存中…(img-yLOCuwG3-1722409759013)]