写点什么

解读《深入理解计算机系统 (CSAPP)》第 12 章并发编程

  • 2022 年 7 月 16 日
  • 本文字数:5345 字

    阅读完需:约 18 分钟

前言:📫 作者简介:小明java问道之路,专注于研究计算机底层,就职于金融公司后端高级工程师,擅长交易领域的高安全/可用/并发/性能的设计和架构📫 

🏆 Java 领域优质创作者、阿里云专家博主、华为云享专家🏆

🔥 如果此文还不错的话,还请👍关注点赞收藏三连支持👍一下博主哦

本文导读

如果逻辑控制流在时间上重叠,那么就称它们是并发(concurrent)的。并发可以看做是一种操作系统内核用来运行多个应用程序的机制,并发不局限于内核。

应用级并发的一些应用场合:(1)访问慢速 I/O 设备。当一个用户等待来自慢速 I/O 设备(比如磁盘)的数据到达时,内核会运行其他进程;(2)与人交互。每次用户请求某种操作时(比如通过点击鼠标),一个独立的并发逻辑流被创建来执行这个操作;(3)通过推迟工作以降低延迟;(4)服务多个网络客户端。一个并发服务器为每个客户端创建一个单独的逻辑流;(5)在多核机器上进行并行计算。被划分称并发流的应用程序通常在多个机器上比单处理器机器上快很多,因为这些流会并行执行,而不是交错执行。

现代操作系统提供了三种基本的构造并发程序的方法:(1)进程。在这种形式下,每个逻辑控制流都是一个进程,由内核来调度和维护。因为进程有独立的虚拟地址空间,想要和其他流通信,控制流必须使用显式的进程间通信(IPC)机制。(2)I/O 多路复用。在这种形式下,应用程序在一个进程的上下文中显式地调用它们自己的逻辑流。逻辑流被模型化为状态机,数据到达文件描述符后,主程序显式地从一个状态转换到另一个状态。因为程序是一个单独的进程,所以所有的流都共享同一个地址空间。(3)线程是运行在一个单一进程上下文中的逻辑流,由内核进行调度。线程像进程流一样由内核进行调度,像 I/O 多路复用一样共享同一个虚拟地址空间。


重点解读:

一、基于进程的并发编程

构造并发程序最简单的方法就是用进程,使用 fork, exec, waitpid 等函数。

优点:父子进程间共享状态信息:共享文件表,但是不共享用户地址空间。这样避免了一个进程覆盖另外一个进程的虚拟内存。缺点:独立的地址空间使得进程共享状态信息变得更加困难,需要使用进程间通信(IPC)机制。

二、基于 I/O 多路复用的并发编程

I/O 多路复用技术,基本思想就是使用 select 函数,要求内核挂起进程,只有在一个或多个 I/O 事件发生后,才将控制返回给应用程序。

IO 多路复用可以用到并发事件驱动程序的基础,在事件驱动程序中某些事件会导致流向前推进,一般的思路是将逻辑流模型转化为状态机,不严格的说一个状态机就是一组状态、输入时间和转移。服务器使用 IO 多路复用借助 select 函数检测输入事件的发生,当每个已连接描述符准备好可读时,服务器就为相应的状态机执行转移,在这里就是从描述符读和写回一个文本行。

事件驱动设计的优点:(1)它比基于进程的设计给了程序员更多对程序行为的控制。例如,我们可以编写一个事件驱动的并发服务器,为某些客户端提供它们需要的服务,而对于基于进程的并发服务器来说是很困难的;(2)一个基于 IO 多路复用的事件驱动服务器试运行在单一进程上下文中的,因此每个逻辑流都能访问该进程的全部地址空间,这使得流之间共享数据变得很容易;(3)一个与作为单个进程运行相关的优点是,你可以利用熟悉的调试工具例如 GDB,来调试你的并发服务器,就像对顺序程序那样(4)比基于进程的设计要高效的多,因为他们不需要进程上下文切换来调度新的流。

事件驱动设计的缺点:(1)编码复杂,上面的事件驱动并发 echo 服务器需要的代码比基于进程的服务器多三倍,随着并发粒度的减小,复杂性还会上升,这里的粒度是指每个逻辑流每个时间片执行的指令数量;(2)不能充分利用多核处理器。

三、基于线程的并发编程

到目前为止,前面看到了两种创建并发逻辑流的方法,第一种方法中,每个流使用了单独的进程,内核会自动调度每个进程,而每个进程有自己的私有地址空间,使流之间共享数据很困难。第二种方法中,创建自己的逻辑流,并利用 IO 多路复用来显式地调度流。因为只有一个进程,所有的流共享整个地址空间,本节介绍第三种方法,基于线程,它是之前地两种方法的混合。

线程是运行在进程上下文中的逻辑流,在之前程序都是由每个进程中一个线程组成的,现代操作系统允许我们编写一个进程里运行多个线程的程序,线程由内核自动调度。每个线程都有自己的线程上下文,包括一个唯一的整数线程 ID,栈、栈指针、程序计数器、通用目的寄存器和条件码,所有运行在一个进程里的线程共享该进程的整个虚拟地址空间。

基于线程的逻辑流结合了基于进程和基于 IO 多路复用的流的特征,同进程一样,线程由内核自动调度,并且内核通过一个整数 ID 来识别线程同基于 IO 多路复用的流一样,多个线程运行在单一进程的上下文中,共享这个进程虚拟地址空间的所有内容,包括它的代码、数据、堆、共享库和打开的文件。

每个进程开始生命周期时都是单一线程,这个线程被称为主线程,在某一时刻,主线程创建一个对等线程,从这个时间点开始,两个线程就并发的运行,最后主线程执行一个慢速系统调用,例如 read 或者 sleep,或者因为被系统的间隔计时器中断,控制就会通过上下文切换传递到对等线程,对等线程会执行一段时间,然后控制传回主线程,依次类推。

多线程的执行模型在某些方面和多进程的执行模型是相似的。

​线程执行在一些方面和进程是不同的,因为一个线程的上下文比一个进程的上下文小得多,线程的上下文切换要比进程快得多。另一个不同的是线程不像进程那样,不是按照严格的父子层次来组织的,和一个进程相关的线程组成一个对等(线程)池,独立于其他线程创建的线程。主线程和其他线程的区别仅在于它总是进程中第一个运行的线程。对等(线程)池的概念主要影响的是一个线程可以杀死它的任何对等线程或者等待它的任意对等线程终止。另外每个对等线程都能读写相同的共享数据。

四、多线程程序中的共享变量

线程存储器模型:一组并发线程运行在一个进程的上下文中。每个线程都有自己独立的线程上下文,包括线程 ID、栈、栈指针、程序计数器、条件码和通用目的寄存器值。每个线程和其他线程一起共享进程上下文剩余部分,其中包括整个用户虚拟地址空间,它是由只读文本(代码)、读写数据、堆以及所有的共享库代码和数据区域组成的,线程也共享相同的打开文件集合。

从实际操作角度来说,让一个线程去读写另一个线程的寄存器值是不可能的,另一方面,任何线程都可以访问共享虚拟内存的任意位置,如果某个线程修改了一个内存位置,那么其他每个线程最终都能在它读这个位置时发现这个变化,因此寄存器是不共享的,而虚拟内存总是共享的。

各自独立的线程栈的内存模型不是那么整齐清楚,这些栈被保存在虚拟地址空间的栈区域中,并且通常是被相应的线程独立的访问的,我们说的通常不是总是,因为不同的线程栈是不对其他线程设防的,所以,如果一个线程以某一种方式得到一个指向其他线程栈的指针,那么它就可以读写这个栈的任何部分。

多线程的 C 程序中变量根据它们的存储类型被映射到虚拟内存。(1)全局变量。全局变量是定义在函数之外的变量,在运行时,虚拟内存的读写区域只包含每个全局变量的一个实例,任何线程都可以引用。(2)本地自动变量。本地自动变量就是定义在函数内部但是没有 static 属性的变量。在运行时,每个线程的栈都包含它自己的所有本地自动变量的实例,即使多个线程执行同一线程例程也是如此。(3)本地静态变量。本地静态变量,是定义在函数内部并有 static 属性的变量。和全局变量一样,虚拟内存的读写区域只包含在程序中声明的每个本地静态变量的一个实例。例如,即使每个对等线程都声明了 cnt,在运行时虚拟内存的读写区域也只有一个 cnt 的实例,每个对等线程都读写这个实例。

五、用信号量同步线程

共享变量虽然很方便但是会引入同步错误的问题,对于我们而言是没有办法预测操作系统是否将为你的线程选择一个正确的顺序,我们需要借助一种叫作进度图的方法来阐明这些正确或者不正确的指令顺序。

信号量提供了一种很方便的方法来确保对共享变量的互斥访问,基本思想是,将每个共享变量与一个信号量 s 联系起来,然后用 P(s)和 V(s)操作将相应的临界区包围起来。

除了提供互斥之外,信号量的另一个作用是调度对共享资源的访问,在这种场景中,一个线程用信号量操作来通知另一线程,程序中某个条件已经为真了,两个经典而有用的例子就是生产者-消费者和读者-写者问题。

生产者-消费者问题:

读者-写者问题:读者-写者问题是互斥问题的一个概括,一组并发的线程要访问一个共享对象,例如,一个主存中的数据结构或者一个磁盘上的数据库,有的线程只读对象,而其他的线程只修改对象。修改对象的叫作写者,只读对象的线程叫作读者。写者必须拥有对对象的独占的访问,而读者可以和无线个其他读者共享对象。一般来说,有无线多个并发的读者和写者。

读写者问题有几个变种,分别基于读者和写者的优先级。第一类读者优先,要求不要让读者等待,除非已经把使用对象的权限赋予了一个写者,换言之,读者不会因为有一个写者在等待而等待。第二类写者优先,要求一旦一个写者准备好可以写,它就会尽可能尽快完成它的写操作。同第一类问题不同,在一个写者后到达的读者必须等待,即使这个写者也在等待。

六、使用线程提高并行性

并发(concurrency)和并行(parallellism)是:解释一:并行是指两个或者多个事件在同一时刻发生;而并发是指两个或多个事件在同一时间间隔发生。解释二:并行是在不同实体上的多个事件,并发是在同一实体上的多个事件。解释三:并行是在多台处理器上同时处理多个任务。如 hadoop 分布式集群,并发是在一台处理器上“同时”处理多个任务。

下图是顺序、并发和并行程序之间的几何关系,所有程序的集合能够被划分成不相交的顺序集合和并发程序的集合。并行程序是一个运行在多个处理器上的并发程序。

七、并发问题

线程安全:一个函数被称为线程安全的,当且仅当被多个并发线程反复调用时,它会一直产生正确的结果。如果一个函数不是线程安全的,我们就说它是线程不安全的。四个(不相交)线程不安全函数(类):(1)不保护共享变量的函数;(2)保持跨越多个调用状态的函数;(3)返回指向静态变量的指针的函数;(4)调用线程不安全函数的函数。

可重入性:有一类重要的线程安全函数,叫作可重入线程安全函数,其特点是,当它们被多个线程调用时,不会引入任何共享数据。尽管线程安全和可重入有时会被当做同义词但是还是有清晰地技术差别。可重入函数集合是线程安全函数的一个真子集。

竞争:当一个程序的正确性依赖于一个线程要在另一个线程到达 y 点之前到达它的控制流中的 x 点时,就会发生竞争。通常发生竞争是因为程序员假定线程将按照某种特殊的轨迹线穿过执行状态空间,而忘记另一条准则规定:多线程的程序必须对任何可行的轨迹线都正确工作。

死锁:信号量引入了一个潜在的运行错误,叫作死锁。它指的是一组线程被阻塞了,等待一个永远不会为真的条件。进度图对于理解死锁是一个很好的工具。下图展示了一对用两个信号量实现互斥的线程的进度图(它是指一组线程被阻塞了,等待一个永远也不会为真的条件

​程序员使用 P 和 V 操作顺序不当,以至于两个信号量的禁止区域重叠,如果某个执行轨迹线碰巧到达了死锁状态 d,那么就不可能有进一步的进展了,因为重叠的禁止区域阻塞了每个合法向上的进展,换句话说,程序死锁是因为每个线程都在等待其他线程执行一个根本不可能发生的 V 操作。重叠的禁止区域引起了一组称为死锁区域的状态,如果一个轨迹线碰巧到达了一个死锁区域的状态,那么死锁就不能避免了,轨迹线可以进入死锁区域,但不可能离开。避免死锁:明确互斥锁加锁的顺序规则,给所有的互斥操作的一个全序,如果每个线程都是以一种顺序获得互斥锁并以相反的顺序释放,那么这个程序就是无锁。

小结

一个并发程序是由在时间上重叠的一组逻辑流组成的。在这一章中,我们学习了三种不同的构建并发程序的机制:进程、IO 多路复用和线程。1、进程是由内核自动调度的,而且因为它们有各自独立的虚拟地址空间,所以要实现共享数据,必须要有显式的 IPC 机制。2、事件驱动程序创建它们自己的并发逻辑流,这些逻辑流被模型化状态机,用 IO 多路复用来显式的调度这些流,因为程序运行在单一进程中,所以在流之间共享数据速度很快而且很容易。线程是这些方法的混合,同基于进程的流一样,线程也是由内核自动调度的,同基于 IO 多路复用的流一样,线程是运行在单一进程的上下文中的,因此可以快速方便的共享数据。3、无论哪种并发机制,同步对共享数据的并发访问都是比较困难的,提出信号量的 P 和 V 操作就是为了帮助解决这个问题。4、信号量操作可以用来提供对共享数据的互斥访问,也对比如生产者-消费者程序中有限缓冲区和读写者系统中的共享对象这样的资源访问进行调度。5、并发也引入了其他一些困难的问题,被线程调用的函数必须具有一种称为线程安全的属性。6、我们定义了四种不同种类的线程不安全函数,以及一些将它们变为线程安全的建议。可重入函数是线程安全函数的一个真子集,它不访问任何共享数据。可重入函数比不可重入函数更高效,因为它们不涉及任何同步操作。竞争和死锁是并发程序中出现的另一些困难问题,当程序员错误的假设逻辑流如何调度时,就会发生竞争,当一个流等待一个永远不会发生的事件时,就会产生死锁。

发布于: 2022 年 07 月 16 日阅读数: 35
用户头像

物有本末,事有终始。知所先后,则近道矣 2020.03.20 加入

🏆CSDNJava领域优质创作者/阿里云专家博主/华为云享专家 📫就职某大型金融互联网公司后端高级工程师 👍专注于研究计算机底层/Java/架构/设计模式/算法

评论

发布
暂无评论
解读《深入理解计算机系统(CSAPP)》第12章并发编程_Java_小明Java问道之路_InfoQ写作社区