本文发于公众号“百川海的小记”,一个小菜鸟的自留地,欢迎关注讨论。另外本文的用图,部分修改自极客时间专栏,侵删
写在前面
本篇是《并发编程不完全指北》第二篇,第一篇中讨论了并发、并发问题与并发问题的原因,欢迎阅读留言讨论。链接:并发编程不完全指北(一)
################正题#################
进程/线程的协作关系
接下来这一部分聊聊进程/线程间的协作关系问题。
在聊进程线程协作关系前,首先要确认一点,就是两个进程线程间存在临界资源。这里先说明一下这个临界资源怎么定义。学习过操作系统原理的同学都应该记得:“进程是系统资源分配的最小单位”,为什么这里会连带上线程呢?
因为这里定义的临界资源,起码是Java编程上的“资源”的概念,应该是从逻辑上来理解的。毕竟Java程序启动就是一个独立的进程,后面的所有数据交互都只是线程间的交互。比如,我们说“创建线程会给线程分配一个独立的虚拟机栈空间”,这里的“分配”就不是对于操作系统来说,而是基于JVM来说的。从操作系统的角度来说,这个过程其实并没有任何系统资源分配动作发生,这些操作都只是进程内部的运作,从进程的视角都是逻辑分配而已,但是这并不妨碍我们理解资源分配的这一个概念。这是对进程与线程的资源分配概念的一点说明。
说回临界资源,我们说临界资源是对多个进程/线程开放访问的资源。在没有附加限制的条件下,开放意味着所有的进程/线程可以在任意时刻对资源进行访问,访问的形式可以是输入或者输出。如果两个进程/线程之间不存在临界资源,那么它们之间的运作从逻辑上是完全独立的,无论如何颠倒执行顺序,都不对结果有任何影响,这可以理解为资源隔离,这一点应该比较好明白的。
而如果两个进程/线程间出现了临界资源,它们之间就存在相互关系。关系主要分为两种:互斥与同步。
互斥在之前讨论并发问题的内容中讨论得比较多了,一句到底,互斥的意义在于保护临界资源,保护临界资源在同一时间节点只被有限的线程进行操作。而同步,就是进程/线程间的相互等待的表现。一般来说,互斥和同步都是有条件的:互斥的条件控制了互斥发生的必要性;同步的条件则作为唤醒条件出现,因为等待都是需要唤醒的,如果没有唤醒条件,那么等待的唤醒就不能发生,或者说发生变得不可控。
互斥与同步的出现往往是同时的,两者是相互依赖的,而又都依赖于临界资源的存在的。因为这里有一个逻辑:
出现了临界资源,所以要保护临界资源的访问,于是形成了互斥条件;
出现了互斥,就意味着某些进程/线程需要发生等待
进程/线程的等待需要被唤醒,长睡不起是不符合合法程序原则的;
需要唤醒,就需要有唤醒的条件,这个唤醒的条件也就是等待的条件,或者说是等待的限度;
等待条件的出现,意味着同步关系的形成。
可见,互斥与同步是相互的,辩证的,否则是不能形成有效控制的。
信号量模型
那么如何良好地控制程序的互斥与同步呢?前辈大牛们已经总结出来了一些可靠的套路,称为并发模型。并发模型是并发问题得以解决的核心,因为控制好互斥与同步可以有效地避免并发问题,而并发模型又可以可靠地控制互斥与同步,所以将具体问题直接往合适的模型上面套,解决的编程方案就呼之欲出了。
比较通用的模型,这里介绍两个,第一个是信号量模型。
这里是信号量模型的一个示意图,我们结合这个图来看。
这个黑框,我们定义为一个或一组临界资源具有互斥性的范围,黑框的边界即是临界资源访问的边界。我们将整个黑框看作一个对象,内部定义一个计数器,一般称为信号量,一个等待队列,对外定义了三个操作,分别是Init,P操作与V操作。很多资料都不将Init初始化这个动作视作为一个对外的操作,信号量模型的重点主要在于PV操作。
下面简要说明这个模型是怎么运作的,结合下面的伪代码:
Sempaphore {
Number c = Number(n)
BlockingQueue q
P() {
c--
if(c<0)
block()
}
V() {
c++
if(c<=0)
wakeupOne()
}
}
Init初始化,先将信号量的值初始化为临界资源的数量
P操作是一个原语,因此也被广泛地称为P原语。原语的逻辑是这样的:首先将信号量-1,然后判断信号量是否小于0。如果为真,则P原语结束,意味着该进程/线程获取到临界资源的访问权限;如果为假,则进入进程/线程进入等待队列,并进入阻塞状态。
V操作也是一个原语,因此被称为V原语,也和P原语合称PV原语。它的逻辑是:首先将信号量+1,然后判断信号量是否小于等于0。如果为真,则选择唤醒等待队列的一个等待进程/线程;如果为假,不做动作。之后V原语随即结束,同时意味着执行原语的进程/线程放弃了临界资源的访问权限,访问流程结束。
需要注意,阻塞不是终止,从阻塞队列中被唤醒的进程/线程,还处于P操作的执行过程中,被唤醒后依然需要依据后面的逻辑指令继续执行。当然,在P操作里面也没有其他后续指令,操作结束,可以直接进入临界区。
我们从这个模型里面分析一下互斥和同步的协作关系是怎么样体现的。
在信号量模型中,交互的标识就是信号量。每个进程/线程在进入临界区前,先执行P操作,然后进入临界区,最后执行V操作。
P()
// 临界区
function()
V()
信号量是可以大于1的,所以信号量模型互斥不是绝对的排他的,而是依据临界资源的量允许一定程度的共享的,这就是信号量的初始值应该与临界资源一致的原因,也是信号量模型的局限性之一。
信号量大于0,意味着有非占用的临界资源,不会发生任何阻塞;当信号量小于等于0,意味着临界资源均被占用,没有可用的资源,这时候就体现出互斥的特性,P操作会阻塞后续执行的任务,从而起到互斥的效果。而同步的条件,就是V操作中判断的“信号量小于等于0”,意义是存在至少一个正处于阻塞的进程/线程等待唤醒,只要满足同步条件,就实施一次同步唤醒。PV操作总是成对出现,P操作在进入临界区前体现互斥特性,V操作则在退出临界区时体现同步唤醒的特性。
管程模型
信号量模型的确有效地满足了进程/线程协作的需要,但是它的缺点也比较明显,归结起来主要有两点:
一是模型的阻塞同步依赖信号量,而信号量又强依赖临界资源的数量,这使得互斥与同步条件的设置强关联于临界资源,不够灵活;
二是模型中定义的阻塞队列只能是唯一的,这是模型的一个设定,毕竟模型也没有提供其他可用的变量了。
虽然从理论上来说,信号量模型可以通过扩展临时变量的定义和模型复合来解决实际的业务问题,但是在工程实现上依然比较复杂,于是大牛们提出了一种新的模型——管程模型。
管程的英语单词对应的是monitor,直译是“监视器”,“管程”的翻译形式是来源于操作系统原理中的说法,但是比起监视器,管程更能体现模型的本质,因此我也比较认同将这个模型翻译为管程模型,而不是监视器模型。在Java中,synchronized关键词就是典型的管程模型实现,JUC并发包的Lock使用也是管程模型的形式实现,可见Java语言的开发者从工程实践上,认可并且选择了管程模型。
管程模型在具体的工程实现上,又分了几种常见的形式,比如Hansen模型,Hoare模型,MESA模型。这几个模型大体差异不大,这里重点讨论在Java选择实现的MESA模型,另外两个模型的一些差异点,在聊过MESA模型之后适当补充即可。
MESA管程模型是这几个管程模型里面最后生的,它诞生于上世纪70年代后期,现在已经在工程上被广泛应用,久经考验。
MESA模型的示意图如图
和信号量模型类似,这个黑框也是一个临界范围,黑框边界也是外部访问的边界。整个模型的定义分为几个部分:
临界资源(也就是图中的共享资源)
若干个外部方法
若干个条件变量
与条件变量一一对应的条件等待阻塞队列
还有一个入口等待队列
下面结合伪代码说明这个模型是怎么运作的:
线程在进入临界区之前,先进入入口等待队列。一个线程需要先从入口等待队列出队,才能申请进入临界区
临界区只允许一个线程进入,因此通过修改互斥标识的方式阻塞其他线程进入
然后线程依次检查模型中定义的各个条件变量,一旦发现不满足某个条件变量,则进入该条件变量对应条件等待队列,放弃临界区的独占并进入blocking阻塞状态,也就是对特定条件变量的条件等待状态。一旦线程进入等待状态,在入口等待队列中的其他线程就可以再次尝试申请临界区的访问
如果线程进入临界区后,对所有条件变量都完全满足,则可以对临界资源实施访问操作
资源操作完毕之后,再次依次判定各个条件变量是否满足,如果条件变量成立,则唤醒对应条件等待队列中的线程。从条件等待队列中被唤醒的线程,只能重新进入入口等待队列开始新一轮的排队
唤醒操作结束之后,线程准备离开临界区,修改互斥标识,放弃临界区独占。
相比于信号量模型,管程模型主要的优点是它允许了多个条件变量与条件队列定义。这个变化让等待条件不再受到临界资源的限制,可以更加灵活自由,还可以满足多个不同的临界资源相互作用的复合情况。但是管程模型不再天然地允许多个线程同时进入临界区,这既有好处,也有坏处。
上面提到Java的synchronized关键字就是典型的MESA管程模型的应用,现在可以回过头来讨论这一个话题了。Java中的synchronized关键字,无论是修饰在什么位置,本质上都是依据一个特定的monitor作为互斥标识进行互斥操作的(编注:在1.6版本以前,synchronized关键字都是以此方式实现,在1.6以后,随着synchronized的优化,monitor主要用于重量级锁的实现),这个monitor取自于对象的内存定义:在JVM的对象头定义中,以1个字宽长度(编注:32位/64位JVM分别占用32/64 bit)存储Mark Word,在Mark Word中包含monitor对应的互斥量指针。下面是Java1.6后的Mark Word的内存基本接口示意表格,注意重量级锁的定义:
根据不同的修饰方式,实际上取用的monitor也不一样:
如果修饰静态方法或静态块上,依据的是class加载对象的monitor;
如果修饰在普通方法上,使用的是this,也就是对象本身的monitor;
如果指定特定对象进行修饰,使用的就是特定对象的monitor。
Synchronized是最简单的管程模型形式,它没有设定任何的条件变量,所以唯一的等待条件临界区中的线程离开临界区。这样在套用回到管程模型的流程,synchronized的实现机制就很好理解了。
下面再举一个稍微复杂的例子,也是一个非常经典的问题:生产者消费者问题。问题大家应该都知道,就不赘述了。下面是一个借阻塞队列定义的生产者消费者问题代码,入队相当于生产,出队相当于消费:
public class BlockedQueue<T>{
final Lock lock = new ReentrantLock();
// 条件变量:队列不满
final Condition notFull = lock.newCondition();
// 条件变量:队列不空
final Condition notEmpty = lock.newCondition();
final List<T> list = new ArrayList<>();
final int upperLimit;
public BlockedQueue(int upperLimit) {
this.upperLimit = upperLimit;
}
// 入队,生产
public void enq(T x) throws InterruptedException {
lock.lock();
try {
while (list.size() == upperLimit){
// 等待队列不满
notFull.await();
}
// 省略入队相关操作...
// 入队后, 通知可出队
notEmpty.signalAll();
}finally {
lock.unlock();
}
}
// 出队,消费
public void deq() throws InterruptedException {
lock.lock();
try {
while (list.size() == 0){
// 等待队列不空
notEmpty.await();
}
// 省略出队相关操作...
// 出队后,通知可入队
notFull.signalAll();
}finally {
lock.unlock();
}
}
}
在这里的代码中,用到了JUC并发包的一个典型用法,Lock & Condition。Lock的概念,就相当于管程模型中的互斥标识,而Condition的概念,就相当于管程模型中的条件变量,因此对于一个Lock,可以定义零个至多个Condition,正如模型中可以设置任意多个条件变量。这里对程序的条件变量简单解释一下:
当队列已满时,生产者不能再生产,通过条件变量notFull进行阻塞,notFull的意思是队列不满则允许操作;
当队列为空时,消费者不能再消费,通过条件变量notEmpty进行阻塞,notEmpty的意思是队列非空则允许操作。
这段代码也是遵循MESA管程模型的,还是几个步骤:
排他互斥;
循环检查与阻塞等待;
临界资源操作;
阻塞唤醒;
释放互斥。
这里的写法是用了一把锁,引申了两个条件变量,这里可以思考几个小问题:
这段代码是否可以只用一个条件变量实现呢?
用一个条件变量对性能和语义上有什么影响呢?
那现在的形式是不是最优呢?
如果不是最优的写法,那如何进一步优化呢?
并发问题的困难和有趣之处,大概也见于这些反反复复的问题上面了。
MESA管程模型的两个注意点
让我们抛开生产者消费者问题这个特定场景,回到MESA模型本身。这里还有两个值得注意的要点,其中第一个要点是,我们对于条件变量判断与阻塞的操作,必须使用循环,比如上面代码就用到while死循环。这里的while是不能被if替代,这是MESA模型特有的。因为在MESA模型中,当线程A唤醒条件等待队列中的线程B之后,线程A会继续运行,而线程B不会真正地马上开始执行,而是从条件等待队列转移到入口等待队列,继续排队。因此,当线程B恢复现场重新开始执行时,因为和被唤醒的时间中已经存在时间差,条件变量已经不一定符合了,因此必须重新进行条件变量判断,如果不符合条件变量,则要再次进入阻塞。
相较MESA的前辈Hansen模型和Hoare模型,入口等待队列是MESA特有的,它是为了解决唤醒线程A与被唤醒线程B之间执行顺序的争抢而设计的。而对于Hansen模型和Hoare模型,他们也给出了不同的策略:
Hansen模型在线程A唤醒线程B之后,A就马上结束,B紧接着执行,所以Hansen模型必须要求唤醒操作必须放在临界区的最后,增加了实现的限制性;
Hoare模型则选择在A唤醒B后,直接阻塞线程A,将访问权让渡给线程B,线程B马上开始执行,直到线程B执行结束以后再重新唤醒线程A,这种思路的代价是每次操作都要增加一次唤醒操作,拟制了效率;
而MESA的做法,就是设计了入口等待队列,作为线程B执行的缓冲,对执行效率和实现的灵活性做了平衡的选择。
关于MESA模型的第二个要点,是唤醒的方式。在MESA模型中,我们推荐使用全阻塞队列唤醒,而非单阻塞线程唤醒,对Java来说,也就是notifyAll()优于notify()。这是为何呢?原因在于活跃性的考虑:因为notify是根据具体实现的调度算法决定出队线程的,很有可能是一个随机的选择,也就是说,notify唤醒的是线程是不确定。
试着考虑下面的场景:一个条件变量同时控制着资源A和资源B,线程1和线程2分别因为资源A、B阻塞在条件等待队列中。此时资源A被释放,如果使用notify,则有且只有一个线程被唤醒。根据notify唤醒对象不确定性,可能发生以下事件:
线程2被唤醒
线程2在执行一轮空转以后,重新进入阻塞状态(因为线程2是需要占用的是资源B,而非资源A)
再次触发notify
线程2被唤醒,重复1……
以上循环造成的空转流程可能多次重复,这个空转的运算,既浪费了CPU资源,也降低了真正有执行资源的线程1的及时性,甚至有可能造成特定的线程出现饥饿问题。
关于活跃性问题,我们在下文再去进一步讨论。
本节总结
这一节主要从进程/线程的协作关系说起,讨论协作关系的内在逻辑关系。然后介绍两种并发协作模型:信号量模型与管程模型,其中管程模型又着重已举例的方式说明了MESA管程模型的运作。最后,补充介绍了一下几个管程模型的区别和MESA模型的一些要点
(未完待续)……