并发编程不彻底指北(二)

本文发于公众号“百川海的小记”,一个小菜鸟的自留地,欢迎关注讨论。另外本文的用图,部分修改自极客时间专栏,侵删java


写在前面c++

本篇是《并发编程不彻底指北》第二篇,第一篇中讨论了并发、并发问题与并发问题的缘由,欢迎阅读留言讨论。连接:并发编程不彻底指北(一)算法


################正题#################编程



进程/线程的协做关系并发

接下来这一部分聊聊进程/线程间的协做关系问题。post

在聊进程线程协做关系前,首先要确认一点,就是两个进程线程间存在临界资源。这里先说明一下这个临界资源怎么定义。学习过操做系统原理的同窗都应该记得:“进程是系统资源分配的最小单位”,为何这里会连带上线程呢?性能

由于这里定义的临界资源,起码是Java编程上的“资源”的概念,应该是从逻辑上来理解的。毕竟Java程序启动就是一个独立的进程,后面的全部数据交互都只是线程间的交互。好比,咱们说“建立线程会给线程分配一个独立的虚拟机栈空间”,这里的“分配”就不是对于操做系统来讲,而是基于JVM来讲的。从操做系统的角度来讲,这个过程其实并无任何系统资源分配动做发生,这些操做都只是进程内部的运做,从进程的视角都是逻辑分配而已,可是这并不妨碍咱们理解资源分配的这一个概念。这是对进程与线程的资源分配概念的一点说明。学习

说回临界资源,咱们说临界资源是对多个进程/线程开放访问的资源。在没有附加限制的条件下,开放意味着全部的进程/线程能够在任意时刻对资源进行访问,访问的形式能够是输入或者输出。若是两个进程/线程之间不存在临界资源,那么它们之间的运做从逻辑上是彻底独立的,不管如何颠倒执行顺序,都不对结果有任何影响,这能够理解为资源隔离,这一点应该比较好明白的。优化

而若是两个进程/线程间出现了临界资源,它们之间就存在相互关系。关系主要分为两种:互斥与同步this

互斥在以前讨论并发问题的内容中讨论得比较多了,一句到底,互斥的意义在于保护临界资源,保护临界资源在同一时间节点只被有限的线程进行操做。而同步,就是进程/线程间的相互等待的表现。通常来讲,互斥和同步都是有条件的:互斥的条件控制了互斥发生的必要性;同步的条件则做为唤醒条件出现,由于等待都是须要唤醒的,若是没有唤醒条件,那么等待的唤醒就不能发生,或者说发生变得不可控。

互斥与同步的出现每每是同时的,二者是相互依赖的,而又都依赖于临界资源的存在的。由于这里有一个逻辑:

  • 出现了临界资源,因此要保护临界资源的访问,因而造成了互斥条件;

  • 出现了互斥,就意味着某些进程/线程须要发生等待

  • 进程/线程的等待须要被唤醒,长睡不起是不符合合法程序原则的;

  • 须要唤醒,就须要有唤醒的条件,这个唤醒的条件也就是等待的条件,或者说是等待的限度;

  • 等待条件的出现,意味着同步关系的造成。

可见,互斥与同步是相互的,辩证的,不然是不能造成有效控制的。


信号量模型

那么如何良好地控制程序的互斥与同步呢?前辈大牛们已经总结出来了一些可靠的套路,称为并发模型。并发模型是并发问题得以解决的核心,由于控制好互斥与同步能够有效地避免并发问题,而并发模型又能够可靠地控制互斥与同步,因此将具体问题直接往合适的模型上面套,解决的编程方案就呼之欲出了。

比较通用的模型,这里介绍两个,第一个是信号量模型。

这里是信号量模型的一个示意图,咱们结合这个图来看。


这个黑框,咱们定义为一个或一组临界资源具备互斥性的范围,黑框的边界便是临界资源访问的边界。咱们将整个黑框看做一个对象,内部定义一个计数器,通常称为信号量,一个等待队列,对外定义了三个操做,分别是Init,P操做与V操做。不少资料都不将Init初始化这个动做视做为一个对外的操做,信号量模型的重点主要在于PV操做。

下面简要说明这个模型是怎么运做的,结合下面的伪代码:

Sempaphore {
  Number c = Number(n)
  BlockingQueue q P() {
    c--
    if(c<0)
      block()
  }
  
  V() {
    c++
    if(c<=0)
      wakeupOne()
  }
}
复制代码

  1. Init初始化,先将信号量的值初始化为临界资源的数量

  2. P操做是一个原语,所以也被普遍地称为P原语。原语的逻辑是这样的:首先将信号量-1,而后判断信号量是否小于0。若是为真,则P原语结束,意味着该进程/线程获取到临界资源的访问权限;若是为假,则进入进程/线程进入等待队列,并进入阻塞状态。

  3. V操做也是一个原语,所以被称为V原语,也和P原语合称PV原语。它的逻辑是:首先将信号量+1,而后判断信号量是否小于等于0。若是为真,则选择唤醒等待队列的一个等待进程/线程;若是为假,不作动做。以后V原语随即结束,同时意味着执行原语的进程/线程放弃了临界资源的访问权限,访问流程结束。

  4. 须要注意,阻塞不是终止,从阻塞队列中被唤醒的进程/线程,还处于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模型的示意图如图


和信号量模型相似,这个黑框也是一个临界范围,黑框边界也是外部访问的边界。整个模型的定义分为几个部分:

  • 临界资源(也就是图中的共享资源)

  • 若干个外部方法

  • 若干个条件变量

  • 与条件变量一一对应的条件等待阻塞队列

  • 还有一个入口等待队列

下面结合伪代码说明这个模型是怎么运做的:

  1. 线程在进入临界区以前,先进入入口等待队列。一个线程须要先从入口等待队列出队,才能申请进入临界区

  2. 临界区只容许一个线程进入,所以经过修改互斥标识的方式阻塞其余线程进入

  3. 而后线程依次检查模型中定义的各个条件变量,一旦发现不知足某个条件变量,则进入该条件变量对应条件等待队列,放弃临界区的独占并进入blocking阻塞状态,也就是对特定条件变量的条件等待状态。一旦线程进入等待状态,在入口等待队列中的其余线程就能够再次尝试申请临界区的访问

  4. 若是线程进入临界区后,对全部条件变量都彻底知足,则能够对临界资源实施访问操做

  5. 资源操做完毕以后,再次依次断定各个条件变量是否知足,若是条件变量成立,则唤醒对应条件等待队列中的线程。从条件等待队列中被唤醒的线程,只能从新进入入口等待队列开始新一轮的排队

  6. 唤醒操做结束以后,线程准备离开临界区,修改互斥标识,放弃临界区独占。

相比于信号量模型,管程模型主要的优势是它容许了多个条件变量与条件队列定义。这个变化让等待条件再也不受到临界资源的限制,能够更加灵活自由,还能够知足多个不一样的临界资源相互做用的复合状况。可是管程模型再也不自然地容许多个线程同时进入临界区,这既有好处,也有坏处。

上面提到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唤醒对象不肯定性,可能发生如下事件:

  1. 线程2被唤醒

  2. 线程2在执行一轮空转之后,从新进入阻塞状态(由于线程2是须要占用的是资源B,而非资源A)

  3. 再次触发notify

  4. 线程2被唤醒,重复1……

以上循环形成的空转流程可能屡次重复,这个空转的运算,既浪费了CPU资源,也下降了真正有执行资源的线程1的及时性,甚至有可能形成特定的线程出现饥饿问题。

关于活跃性问题,咱们在下文再去进一步讨论。


本节总结

这一节主要从进程/线程的协做关系提及,讨论协做关系的内在逻辑关系。而后介绍两种并发协做模型:信号量模型与管程模型,其中管程模型又着重已举例的方式说明了MESA管程模型的运做。最后,补充介绍了一下几个管程模型的区别和MESA模型的一些要点


(未完待续)……

相关文章
相关标签/搜索