真正要做的事情,对神明都不要讲。一定如此,万般如此。
引入赋值之后,必须承认时间在所用的计算模型中的位置。在引入赋值之前,所有程序都没有时间问题,任何具有某个值的表达式,将总是具有这个值。
引入赋值之后带来的问题,以 3.1.1 节中模拟从银行账户提款并返回最后余额为例:
(withdraw 25)
75
(withdraw 25)
50
连续对同一个表达式求值,却产生了不同的值。这种行为的出现就是因为:赋值语句的执行描绘出有关值变化的时刻,对一个表达式的求值结果不但依赖于该表达式本身,还依赖于求值发生咋这些时刻之前还是之后。
现实世界里的对象并不是一次一个地顺序变化,与此相反,它们总是发并地活动,所有东西一起活动。
注:可以通过将模型组织为一些具有相互分离的局部状态对象,使做出的程序更加模块化。
将计算模型划分为一些能各自独立地并发演化的部分
事件顺序的非确定性,可能对并发系统的设计提出了严重的问题。举例证明,假定由 Peter 和 Paul 进行取款被实现为两个独立的进程,它们共享同一个变量 balance,这两个计算进程都由如下过程描述:
(define (withdraw amount)
(if (>= balance amount)
(begin (set! balance (- balance amount))
balance)
"Insufficent funds"))
上述表达式包含三个步骤:
如果 Peter 和 Paul 在提款过程中并发执行这一语句,那么这两次提款在访问 balance 和将它设置为新值的动作就可能交错。
下图的时序图勾画了一个事件顺序,其中的 balance 在开始时是 100,Peter 取走了 10,Paul 取走了 25,然后 balance 最后的值却是 75。
上述实例表现出的一般性现象是:几个进程有可能共享同一个状态变量。使事情变得更加复杂的原因,就是多个进程有可能同时试图去操作这种共享状态。
在写那些使用 set! 的程序时必须小心,因为一个计算的结果依赖于其中的各个赋值发生的顺序。**对于并发进程,对于赋值就需要特别小心,因为无法控制其他进程所做赋值的出现顺序。**如果几个修改可能并发出现,必须采用某些方式,以设法保证系统的行为是正确的。
对于并发的一种可能限制方式是规定,修改任意共享状态变量的两个操作都不允许同时发生。
注:这是一个非常严厉的要求,以分布式银行系统举例,这就要求系统设计者保证同时出现的只能有一个交易,这样做可能过于低效,也太保守了。
对于并发的另一种不那么严厉的限制方式是,保证并发系统产生的结果与各个进程按照某种方式顺序运行产生出的结果完全一样。这一要求中包含两个重要方面:
注:参考 raft 算法,节点间数据的同步
对于并发程序的正确执行,还可以提出一些更弱的要求。一个模拟扩缩过程的程序可以由一大批进程组成,每个进程代表空间中很小的一点体积,它们并发地更新自己的值。这里的每个进程都反复将自己的值更新为自己的原值和相邻进程的值的平均值。无论有关的操作按什么顺序执行,这种算法都能收敛到正确的解,因此也就不需要对共享变量的并发使用提出任何限制了。
串行化:使进程可以并发地执行,但是其中也有一些过程不能并发地执行。
串行化的例子:创建一些不同的过程集合,并且保证在每个时刻,在任何一个串行化集合里至多只有一个过程的执行。如果某个集合里有过程正在执行,而另一进程企图执行这个集合里的任何过程时,它就必须等待到前一过程的执行结束。
为了使上述「对共享变量的串行访问」机制更加具象化,假定扩充本书示例中所用的 Scheme 语言,加入一个称为 parallel-execute 的过程:
(parallel-execute
这里的的每个
必须是一个无参过程,parallel-execute 为每个
创建一个独立的过程,该进程将应用
以下面的例子为例:
(define x 10)
(parallel-execute (lambda () (set! x (* x x)))
(lambda () (set! x (+ x 1))))
假设 P1 要把 x 设置为 x * x,P2 要把 x 设置为 + x 1,在上述的例子执行过之后,x 将具有以下 5 种值之一:
101:P1 将 x 设置为 100,而后 P2 将 x 的值增加到 101
121:P2 将 x 的值增加到 11,而后 P1 将 x 设置为 x * x
110:P2 将 x 从 10 修改为 11 的动作出现在 P1 两次访问 x 的值之间,这两次访问是为了求值表达式 (* x x)
11: P2 访问 x,而后 P1 将 x 设置为 100,而后 P2 又设置 x
100:P1 访问 x (两次),而后 P2 将 x 设置为 11,而后 P1 又设置 x
可以用串行化的过程给此处的并发性增加一些限制,通过串行化组实现这种限制,假设构造串行化组的方式为调用 make-serializer。一个串行化组以一个过程为参数,它返回的串行化过程具有与原过程一样的行为方式。
(define x 10)
(define s (make-serializer))
(parallel-execute (s (lambda () (set! x (* x x))))
(s (lambda () (set! x (+ x 1)))))
通过此种方式只可能产生 x 的两种可能性 101 和 121,其他的几种可能性都被清除了,因为 P1 和 P2 的执行不会交错进行。
如果只存在一个共享资源(例如一个银行账户),串行化的使用问题是相对比较简单的。但是如果存在多项共享资源,并发程序设计就可能变得非常难以把握。
为了展示可能出现的一种困难,假设操作为交换两个账户的余额。
(define (exchange account1 account2)
(let ((difference (- (account1 `balance)
(account2 `balance))))
((account1 `withdraw) difference)
((account2 `deposit) difference))
假定 Peter 和 Paul 都能访问 a1 、a2 和 a3 账户,在 Peter 要交换 a1 和 a2 时,正好 Paul 也并发地要求交换 a1 和 a3。
如果出现 Peter 算出 a1 和 a2 的余额差值,但是 Paul 却可能在 Peter 完成交换之前改变了 a1 的余额。
注:为了得到正确的行为,就必须重新设计 exchange 过程,让它在完成整个交换期间锁住对于账户的任何其他访问。
可以用一种更基本的称为「互斥元(mutex)」的同步机制来实现串行化。互斥元是一种对象,假定它提供两个操作:
一旦某个互斥元被获取,对于这个互斥元的任何其他获取操作都必须等到该互斥元被释放后进行操作。在实际实现使用中,给定一个过程 P,串行化组将返回一个过程,该过程获取相应互斥元,之后运行 P,最后释放互斥元,此种操作即可保证串行化性质。
在账户交互问题里还存在一个麻烦,假设 Peter 企图去交互账户 a1 和 a2,同时 Paul 并发地企图去交互 a2 和 a1。
此种情况下,就会进入「死锁」状态。
注:对于多种共享资源的并发访问的系统里,总是存在着死锁的危险。
避免死锁的一种方式,是首先给每个账户确定一个唯一的标识编号,使每个进程总是首先设法进入保护具有较低标识编号的账户过程。
注:此次笔者理解就是锁的粒度变的更大了,可以同时将 a1 和 a2 的账户锁定,在操作期。
在并发系统的程序设计中,关于以下两个问题,已经有清晰的描述:
但是,从一种更基本的观点来看,「共享状态」究竟意味着什么,这件事常常并不清楚。
「共享变量」的各个方面问题也出现在大型的分布式系统里。例如,设想一个分布式的银行系统,其中的各个分支银行维护着银行余额的局部值,并且周期性地将这些值与其他分支所维护的值相互比较。在这样的系统里,「账户余额」的值可能是不确定的。
假设 Peter 在他与 Paul 公用的一个账户里存入了一些钱,什么时候才能说账户的余额已经改变了:
如果 Paul 从另一分支银行访问这个账户,如何在这一银行系统里对这种行为的「正确性」确定合理的约束?
在此处,能考虑的可能就是保持 Peter 和 Paul 的各自行为,以及保证刚刚完成同步时刻的账户「状态」正确性。
**这里的基本现象是不同进程之间的同步,建立起共享状态,或迫使进程之间通信所产生的事件按照某种特定的顺序运行 。**从本质上看,在并发控制中,任何时间概念都必然与通信有内在的密切联系。
注:在处理时间和状态时,在计算模型领域所遭遇的复杂性,事实上,可能就是物理世界中最基本的复杂性的一种反映。
本来想要把第三章啃完的,但是这本书,看过的都知道有多晦涩难懂,那就慢一点吧,「走的慢一点,走的稳一点,走的久一点」
注:你是什么时候发现时间竟然不知不觉的已经从指缝中流走的?
在突然间意识到自己好像没有了刚刚工作时的那种拼搏向上的感觉时。