- 使用一组系统进行对同时发生的事情进行模拟
- 分解我们使用的模型,增强内部状态的演化,就是更为 模块化
- 能够支持多个进程的硬件支持
在多内核处理器普及的今天,硬件支持已经渐渐不是并发编程的难题了。但是并发编程的复杂性让然没有因为这个原因而降低难度。首先我们要承认正确的运用并行编程是有利的,能提升我们程序的运行效率和对硬件的利用率。
但是由于并发系统的时间的不确定性,两个同时运行并有所依赖的进程,我们并不能确定什么时候某个能运行完,而一个又不能对另一个的结果进行无限的等待。还有就是资源获取的问题,两个并行的程序如何对资源进行管理,比如第三章开始的那个例子,从银行取钱,如果无法控制程序对资源的有效管理就可能造成两个人同时使用一个账户同时取钱,都能取出来的情况出现。
这一节会谈及和并发相关的内容,对于有编程理论经验的同学,这并不是什么复杂的理论内容,其中涉及到的时序控制、锁和信号量等等的知识都是能在各种 OS 相关的课程和书中了解到的知识。
从抽象的角度来看时间就像是加在事件上的一种顺序,一件事情发生比另一件事情发生的早,只是事件顺序的相对关系,这个过程听起来能够非常原子的控制,但是本身事件还会消耗时间。这就引出了并发带来的一些问题,之前也已经提到了,来自于对相同资源的控制问题,和操作的顺序问题,解决这个问题我们就是在解决程序的 正确性 和 健壮性 的问题,通常我们可以这么去理解程序的正确性:
- 运行的表现要和不进行并发程序的状态是一样的
对正确性的保证其实就是在做和 并发控制 相关的工作,其实质就是对并行操作进行一定的控制,书中谈到的很多策略其实在做开发中都是经常见到的:
禁止所有共享资源的并行操作
其实典型就是加了个 锁 ,但是问题也比较明显,书中的反面 Demo 明显是一个锁粒度设定非常大的例子,这也是这种方案的一个比较突出的缺陷,并不是所有的时间都需要禁止并行进行。很多操作并非互相干扰的,比如非常常见的 读写分离 锁就是这样,我们很多时候对读操作和写操作的要求不同不能一概而论。
允许不互相干扰的并发
书中提到了另一种控制方式,是对一种想法的一种改进,这时候我们允许对很多的不互相干扰的并发执行,但是对结果的要求仅仅期望与某种顺序的运行方式相同,这样会有另一个方面的问题,并发结果有很多种,我们没办法对其结果进行预测。
串行控制器
串行化控制器的思路就是,程序可以并行执行,但是其中也有时序性的要求的部分,这部分无法并行执行程序的部分就靠一个控制器,将所有的执行过程通过一个集合控制起来,同一个时间段只会有一个过程在执行。最简单的应用我们可以借助共享变量去理解,同一个时间段可能有很多个进程在请求同一个资源,但是 同时 只能有一个进程能够获得这个资源,其余的将在等待队列中等待:
通过对程序的分组的方式来禁止不正当的并发行为,并且可通过程序控制将某个方法设置为 串行化 的方法。
我们引入一个内部方法 make-serializer
去提供这个是过程串行化的功能,make-serializer
接受一个过程作为参数返回同样行为的过程,参数与原过程保持一样,但保证其执行被串行化,我们可以继续使用之前的 make-account
的例子:
可以看出我们引入了内部过程,通过对每个向外暴露的方法包装一层方法。
多重资源的复杂性
我们考虑完单一资源的串行化操作之后来看一下涉及到多重资源的程序,比如刚才对 account 的操作,如果我们提供一个过程能够交换两个账户的金额(通过计算差值分别赋值给两个账户):
(define (exchange account1 account2)
(let ((difference (- (account1 'balance) (account2 'balance))))
((account1 'withdraw) difference)
((account2 'deposit) difference)))
但是这就涉及到了另外一个问题,这个的操作确实能在对两个账户进行使用的时候保持并发正确,但是如果我们同时运行两个这个程序分别交换 account1
和 account2
以及 account2
和 account3
的时候,这时候情况就变得复杂了,account 的操作能保证串行化,但是 的程序还没有保证串行化。
这时候我们就要改变 exchange
的串行租的策略,我们要是能够使用两个 account 的用户的串行组就能让 exchange
过程也并行正确,这里我们提取出了对应方法,把串行租从账户模块中暴露出来,这样就能在想加锁的时候用上锁了:
我们可以看到我们在程序内部没有给操作的过程加锁,而是把锁暴露在了外面,这样我们对操作的定义可能需要一定的修改:
(define (deposit account amount)
(let ((s (account 'serializer))
(d (account 'deposit)))
((s d) amount)))
我们的 exchange
的定义也可以用提取出来的串行控制器进行重构了:
分别把两个串行控制器提取出来了,并且确信要拿到两道锁的 exchange 程序来操作两个账户。
而且在看我们对 serializer 的使用上来看,明显和上个原因相同以外,我们暴露的是对象所在的串行组,想想串行组是用来管理资源的,这个东西都暴露在了模块的外部,无论是使用还是管理起来都是特别不方便还容易出现危险。
串行控制器的实现
我们刚才在谈及串行控制器的实质上面,我们可以把它想象成一把锁和一个请求锁的等待队列,在书中的模拟中是使用了 互斥量(mutex) 这个更为细粒度的抽象数据实现的。
互斥元是一个可以被获取、在使用之后被释放的数据抽象(类比锁实现),如果某个互斥元已经被获取,想获取该互斥元的其他操作需要等到该互斥元被释放(任何时候只有一个进程能拿到),来看 make-serializer
的实现过程:
(define (make-serializer)
; 创建互斥元
(let ((mutex (make-mutex)))
(define (serialized-p .args)
; 获取互斥元
(mutex 'acquire)
; 执行 p 过程
(let ((val (apply p args)))
; 释放互斥元
(mutex 'release)
val))
serialized-p)))
这就是我们之前生成串行控制器的过程,以一个过程作为参数,我们先对获取互斥元再执行完过程 p
然后再释放我们的互斥元。我们还需要一个生成互斥元的过程:
注意到互斥元的管理本身也是依赖一个变量来操作的,其中的 test-and-set!
检查参数的 car 值,如果参数的 car 值为假就在返回值前将其设为真值。
这个方法是生成互斥元的核心,这个操作需要以 原子操作 的方式执行,具体实现可能是一个特殊的硬件指令或者是系统提供的一个专门的过程。在单处理器程序中系统通过轮转时间片的方式为每一个可执行程序安排一段运行时间,多处理器机器中我们就必须通过硬件支持的专门指令。
死锁
在资源抢占和申请的过程中,我们会遇到更为严重的问题 —— 死锁。我们刚才对 serialized-exchange
的修改之后,通过增加一条锁,已经能让我们在交换账户的时候控制好时序性了,但是我们还可能遇到资源互相抢占的问题比如文中的例子:
对于赋值和状态的讨论是本章贯穿的内容,在本节中我们进一步揭示了复制的引入本质上是是对时间的依赖,赋值改变了变量的状态,从而改变了依赖于这些变量的表达式的计算。