关于后端:并发编程基石管程

41次阅读

共计 12230 个字符,预计需要花费 31 分钟才能阅读完成。

大家好,我是易安!

如果有人问我学习并发并发编程,最外围的技术点是什么,我肯定会通知他,管程技术。Java 语言在 1.5 之前,提供的惟一的并发原语就是管程,而且 1.5 之后提供的 SDK 并发包,也是以管程技术为根底的。除此之外,C/C++、C# 等高级语言也都反对管程。

能够这么说,管程就是解决并发问题的基石。

什么是管程

不晓得你是否曾思考过这个问题:为什么 Java 在 1.5 之前仅仅提供了 synchronized 关键字及 wait()、notify()、notifyAll()这三个看似从天而降的办法?在刚接触 Java 的时候,我认为它会提供信号量这种编程原语,因为操作系统原理课程通知我,用信号量能解决所有并发问题,后果我发现不是。起初我找到了起因:Java 采纳的是管程技术,synchronized 关键字及 wait()、notify()、notifyAll()这三个办法都是管程的组成部分。而 管程和信号量是等价的,所谓等价指的是用管程可能实现信号量,也能用信号量实现管程。 然而管程更容易应用,所以 Java 抉择了管程。

管程,对应的英文是 Monitor,很多 Java 畛域的同学都喜爱将其翻译成“监视器”,这是直译。操作系统畛域个别都翻译成“管程”,这个是意译,而我本人也更偏向于应用“管程”。

所谓 管程,指的是治理共享变量以及对共享变量的操作过程,让他们反对并发。翻译为 Java 畛域的语言,就是治理类的成员变量和成员办法,让这个类是线程平安的。那管程是怎么管的呢?

MESA 模型

在管程的发展史上,先后呈现过三种不同的管程模型,别离是:Hasen 模型、Hoare 模型和 MESA 模型。其中,当初广泛应用的是 MESA 模型,并且 Java 管程的实现参考的也是 MESA 模型。所以明天咱们重点介绍一下 MESA 模型。

在并发编程畛域,有两大外围问题:一个是 互斥 ,即同一时刻只容许一个线程访问共享资源;另一个是 同步,即线程之间如何通信、合作。这两大问题,管程都是可能解决的。

咱们先来看看管程是如何解决 互斥 问题的。

管程解决互斥问题的思路很简略,就是将共享变量及其对共享变量的操作对立封装起来。如果咱们要实现一个线程平安的阻塞队列,一个最直观的想法就是:将线程不平安的队列封装起来,对外提供线程平安的操作方法,例如入队操作和出队操作。

利用管程,能够疾速实现这个直观的想法。在下图中,管程 X 将共享变量 queue 这个线程不平安的队列和相干的操作入队操作 enq()、出队操作 deq()都封装起来了;线程 A 和线程 B 如果想访问共享变量 queue,只能通过调用管程提供的 enq()、deq()办法来实现;enq()、deq()保障互斥性,只容许一个线程进入管程。

不知你有没有发现,管程模型和面向对象高度符合的。预计这也是 Java 抉择管程的起因吧。

那管程如何解决线程间的 同步 问题呢?

这个就比较复杂了,不过你能够借鉴一下咱们已经提到过的就医流程,它能够帮忙你疾速地了解这个问题。为进一步便于你了解,在上面,我展现了一幅 MESA 管程模型示意图,它详细描述了 MESA 模型的次要组成部分。

在管程模型里,共享变量和对共享变量的操作是被封装起来的,图中最外层的框就代表封装的意思。框的下面只有一个入口,并且在入口旁边还有一个入口期待队列。当多个线程同时试图进入管程外部时,只容许一个线程进入,其余线程则在入口期待队列中期待。这个过程相似就医流程的分诊,只容许一个患者就诊,其余患者都在门口期待。

管程里还引入了条件变量的概念,而且 每个条件变量都对应有一个期待队列, 如下图,条件变量 A 和条件变量 B 别离都有本人的期待队列。

条件变量 条件变量期待队列 的作用是什么呢?其实就是解决线程同步问题。你能够联合下面提到的阻塞队列的例子加深一下了解(阻塞队列的例子,是用管程来实现线程平安的阻塞队列,这个阻塞队列和管程外部的期待队列没有关系,本文中 肯定要留神阻塞队列和期待队列是不同的)。

假如有个线程 T1 执行阻塞队列的出队操作,执行出队操作,须要留神有个前提条件,就是阻塞队列不能是空的(空队列只能出 Null 值,是不容许的),阻塞队列不空 这个前提条件对应的就是管程里的条件变量。如果线程 T1 进入管程后恰好发现阻塞队列是空的,那怎么办呢?期待啊,去哪里等呢?就去条件变量对应的 期待队列 外面等。此时线程 T1 就去“队列不空”这个条件变量的期待队列中期待。这个过程相似于大夫发现你要去验个血,于是给你开了个验血的单子,你呢就去验血的队伍里排队。线程 T1 进入条件变量的期待队列后,是容许其余线程进入管程的。这和你去验血的时候,医生能够给其余患者诊治,情理都是一样的。

再假如之后另外一个线程 T2 执行阻塞队列的入队操作,入队操作执行胜利之后,“阻塞队列不空”这个条件对于线程 T1 来说曾经满足了,此时线程 T2 要告诉 T1,通知它须要的条件曾经满足了。当线程 T1 失去告诉后,会从期待队列 外面进去,然而进去之后不是马上执行,而是从新进入到 入口期待队列 外面。这个过程相似你验血完,回来找大夫,须要从新分诊。

条件变量及其期待队列咱们讲清楚了,上面再说说 wait()、notify()、notifyAll()这三个操作。后面提到线程 T1 发现“阻塞队列不空”这个条件不满足,须要进到对应的 期待队列 里期待。这个过程就是通过调用 wait() 来实现的。如果咱们用对象 A 代表“阻塞队列不空”这个条件,那么线程 T1 须要调用 A.wait()。同理当“阻塞队列不空”这个条件满足时,线程 T2 须要调用 A.notify()来告诉 A 期待队列中的一个线程,此时这个期待队列外面只有线程 T1。至于 notifyAll()这个办法,它能够告诉期待队列中的所有线程。

这里我还是来一段代码再次阐明一下吧。上面的代码用管程实现了一个线程平安的阻塞队列(再次强调:这个阻塞队列和管程外部的期待队列没关系,示例代码只是用管程来实现阻塞队列,而不是解释管程外部期待队列的实现原理)。阻塞队列有两个操作别离是入队和出队,这两个办法都是先获取互斥锁,类比管程模型中的入口。

  1. 对于阻塞队列的入队操作,如果阻塞队列已满,就须要期待直到阻塞队列不满,所以这里用了 notFull.await();
  2. 对于阻塞出队操作,如果阻塞队列为空,就须要期待直到阻塞队列不空,所以就用了 notEmpty.await();
  3. 如果入队胜利,那么阻塞队列就不空了,就须要告诉条件变量:阻塞队列不空 notEmpty 对应的期待队列。
  4. 如果出队胜利,那就阻塞队列就不满了,就须要告诉条件变量:阻塞队列不满 notFull 对应的期待队列。
public class BlockedQueue<T>{
  final Lock lock =
    new ReentrantLock();
  // 条件变量:队列不满
  final Condition notFull =
    lock.newCondition();
  // 条件变量:队列不空
  final Condition notEmpty =
    lock.newCondition();

  // 入队
  void enq(T x) {lock.lock();
    try {while (队列已满){
        // 期待队列不满
        notFull.await();}
      // 省略入队操作...
      // 入队后, 告诉可出队
      notEmpty.signal();}finally {lock.unlock();
    }
  }
  // 出队
  void deq(){lock.lock();
    try {while (队列已空){
        // 期待队列不空
        notEmpty.await();}
      // 省略出队操作...
      // 出队后,告诉可入队
      notFull.signal();}finally {lock.unlock();
    }
  }
}

在这段示例代码中,咱们用了 Java 并发包外面的 Lock 和 Condition,如果你看着吃力,也没关系,前面咱们还会具体介绍,这个例子只是先让你明确条件变量及其期待队列是怎么回事。须要留神的是:await()和后面咱们提到的 wait()语义是一样的;signal()和后面咱们提到的 notify()语义是一样的

wait()的正确姿态

然而有一点,须要再次揭示,对于 MESA 管程来说,有一个编程范式,就是须要在一个 while 循环外面调用 wait()。这个是 MESA 管程特有的

while(条件不满足) {wait();
}

Hasen 模型、Hoare 模型和 MESA 模型的一个外围区别就是当条件满足后,如何告诉相干线程。管程要求同一时刻只容许一个线程执行,那当线程 T2 的操作使线程 T1 期待的条件满足时,T1 和 T2 到底谁能够执行呢?

  1. Hasen 模型外面,要求 notify()放在代码的最初,这样 T2 告诉完 T1 后,T2 就完结了,而后 T1 再执行,这样就能保障同一时刻只有一个线程执行。
  2. Hoare 模型外面,T2 告诉完 T1 后,T2 阻塞,T1 马上执行;等 T1 执行完,再唤醒 T2,也能保障同一时刻只有一个线程执行。然而相比 Hasen 模型,T2 多了一次阻塞唤醒操作。
  3. MESA 管程外面,T2 告诉完 T1 后,T2 还是会接着执行,T1 并不立刻执行,仅仅是从条件变量的期待队列进到入口期待队列外面。这样做的益处是 notify()不必放到代码的最初,T2 也没有多余的阻塞唤醒操作。然而也有个副作用,就是当 T1 再次执行的时候,可能已经满足的条件,当初曾经不满足了,所以须要以循环形式测验条件变量。

notify()何时能够应用

还有一个须要留神的中央,就是 notify()和 notifyAll()的应用,后面章节,我已经介绍过,除非通过三思而行,否则尽量应用 notifyAll()。那什么时候能够应用 notify()呢?须要满足以下三个条件:

  1. 所有期待线程领有雷同的期待条件;
  2. 所有期待线程被唤醒后,执行雷同的操作;
  3. 只须要唤醒一个线程。

比方下面阻塞队列的例子中,对于“阻塞队列不满”这个条件变量,其期待线程都是在期待“阻塞队列不满”这个条件,反映在代码里就是上面这 3 行代码。对所有期待线程来说,都是执行这 3 行代码,重点是 while 外面的期待条件是完全相同的

while (阻塞队列已满){
  // 期待队列不满
  notFull.await();}

所有期待线程被唤醒后执行的操作也是雷同的,都是上面这几行:

// 省略入队操作...
// 入队后, 告诉可出队
notEmpty.signal();

同时也满足第 3 条,只须要唤醒一个线程。所以下面阻塞队列的代码,应用 signal()是能够的。

并发包中的管程

Java SDK 并发包内容很丰盛,无所不包,然而我感觉最外围的还是其对管程的实现。因为实践上利用管程,你简直能够实现并发包里所有的工具类。在并发编程畛域,有两大外围问题:一个是 互斥 ,即同一时刻只容许一个线程访问共享资源;另一个是 同步,即线程之间如何通信、合作。这两大问题,管程都是可能解决的。Java SDK 并发包通过 Lock 和 Condition 两个接口来实现管程,其中 Lock 用于解决互斥问题,Condition 用于解决同步问题

你兴许听过,在 Java 的 1.5 版本中,synchronized 性能不如 SDK 外面的 Lock,但 1.6 版本之后,synchronized 做了很多优化,将性能追了上来,所以 1.6 之后的版本又有人举荐应用 synchronized 了。那性能是否能够成为“反复造轮子”的理由呢?显然不能。因为性能问题优化一下就能够了,齐全没必要“反复造轮子”。

在解决 死锁 问题时,咱们有一个 毁坏不可抢占条件 计划,然而这个计划 synchronized 没有方法解决。起因是 synchronized 申请资源的时候,如果申请不到,线程间接进入阻塞状态了,而线程进入阻塞状态,啥都干不了,也开释不了线程曾经占有的资源。但咱们心愿的是:

对于“不可抢占”这个条件,占用局部资源的线程进一步申请其余资源时,如果申请不到,能够被动开释它占有的资源,这样不可抢占这个条件就毁坏掉了。

如果咱们从新设计一把互斥锁去解决这个问题,那该怎么设计呢?我感觉有三种计划。

  1. 可能响应中断。synchronized 的问题是,持有锁 A 后,如果尝试获取锁 B 失败,那么线程就进入阻塞状态,一旦产生死锁,就没有任何机会来唤醒阻塞的线程。但如果阻塞状态的线程可能响应中断信号,也就是说当咱们给阻塞的线程发送中断信号的时候,可能唤醒它,那它就有机会开释已经持有的锁 A。这样就毁坏了不可抢占条件了。
  2. 反对超时。如果线程在一段时间之内没有获取到锁,不是进入阻塞状态,而是返回一个谬误,那这个线程也有机会开释已经持有的锁。这样也能毁坏不可抢占条件。
  3. 非阻塞地获取锁。如果尝试获取锁失败,并不进入阻塞状态,而是间接返回,那这个线程也有机会开释已经持有的锁。这样也能毁坏不可抢占条件。

这三种计划能够全面补救 synchronized 的问题。到这里置信你应该也能了解了,这三个计划就是“反复造轮子”的次要起因,体现在 API 上,就是 Lock 接口的三个办法。详情如下:

// 反对中断的 API
void lockInterruptibly()
  throws InterruptedException;
// 反对超时的 API
boolean tryLock(long time, TimeUnit unit)
  throws InterruptedException;
// 反对非阻塞获取锁的 API
boolean tryLock();

如何保障可见性

Java SDK 外面 Lock 的应用,有一个经典的范例,就是 try{}finally{},须要重点关注的是在 finally 外面开释锁。这个范例无需多解释,你看一下上面的代码就明确了。然而有一点须要解释一下,那就是可见性是怎么保障的。你曾经晓得 Java 里多线程的可见性是通过 Happens-Before 规定保障的,而 synchronized 之所以可能保障可见性,也是因为有一条 synchronized 相干的规定:synchronized 的解锁 Happens-Before 于后续对这个锁的加锁。那 Java SDK 外面 Lock 靠什么保障可见性呢?例如在上面的代码中,线程 T1 对 value 进行了 += 1 操作,那后续的线程 T2 可能看到 value 的正确后果吗?

class X {
  private final Lock rtl =
  new ReentrantLock();
  int value;
  public void addOne() {
    // 获取锁
    rtl.lock();
    try {value+=1;} finally {
      // 保障锁能开释
      rtl.unlock();}
  }
}

答案必须是必定的。Java SDK 外面锁 的实现非常复杂,这里我就不开展细说了,然而原理还是须要简略介绍一下:它是 利用了 volatile 相干的 Happens-Before 规定。Java SDK 外面的 ReentrantLock,外部持有一个 volatile 的成员变量 state,获取锁的时候,会读写 state 的值;解锁的时候,也会读写 state 的值(简化后的代码如上面所示)。也就是说,在执行 value+= 1 之前,程序先读写了一次 volatile 变量 state,在执行 value+= 1 之后,又读写了一次 volatile 变量 state。依据相干的 Happens-Before 规定:

  1. 程序性规定:对于线程 T1,value+=1 Happens-Before 开释锁的操作 unlock();
  2. volatile 变量规定 :因为 state = 1 会先读取 state,所以线程 T1 的 unlock() 操作 Happens-Before 线程 T2 的 lock()操作;
  3. 传递性规定:线程 T1 的 value+=1 Happens-Before 线程 T2 的 lock() 操作。
class SampleLock {
  volatile int state;
  // 加锁
  lock() {
    // 省略代码有数
    state = 1;
  }
  // 解锁
  unlock() {
    // 省略代码有数
    state = 0;
  }
}

可重入锁

如果你仔细察看,会发现咱们创立的锁的具体类名是 ReentrantLock,这个翻译过去叫 可重入锁 所谓可重入锁,顾名思义,指的是线程能够反复获取同一把锁 。例如上面代码中,当线程 T1 执行到 ① 处时,曾经获取到了锁 rtl,当在 ① 处调用 get() 办法时,会在 ② 再次对锁 rtl 执行加锁操作。此时,如果锁 rtl 是可重入的,那么线程 T1 能够再次加锁胜利;如果锁 rtl 是不可重入的,那么线程 T1 此时会被阻塞。

除了可重入锁,可能你还据说过可重入函数,可重入函数怎么了解呢?指的是线程能够反复调用?显然不是,所谓 可重入函数,指的是多个线程能够同时调用该函数,每个线程都能失去正确后果;同时在一个线程内反对线程切换,无论被切换多少次,后果都是正确的。多线程能够同时执行,还反对线程切换,这意味着什么呢?线程平安啊。所以,可重入函数是线程平安的。

class X {
  private final Lock rtl =
  new ReentrantLock();
  int value;
  public int get() {
    // 获取锁
    rtl.lock();         ②
    try {return value;} finally {
      // 保障锁能开释
      rtl.unlock();}
  }
  public void addOne() {
    // 获取锁
    rtl.lock();
    try {value = 1 + get(); ①
    } finally {
      // 保障锁能开释
      rtl.unlock();}
  }
}

偏心锁与非偏心锁

在应用 ReentrantLock 的时候,你会发现 ReentrantLock 这个类有两个构造函数,一个是无参构造函数,一个是传入 fair 参数的构造函数。fair 参数代表的是锁的偏心策略,如果传入 true 就示意须要结构一个偏心锁,反之则示意要结构一个非偏心锁。

// 无参构造函数:默认非偏心锁
public ReentrantLock() {sync = new NonfairSync();
}
// 依据偏心策略参数创立锁
public ReentrantLock(boolean fair){sync = fair ? new FairSync()
                : new NonfairSync();}

在后面咱们谈到入口期待队列,锁都对应着一个期待队列,如果一个线程没有取得锁,就会进入期待队列,当有线程开释锁的时候,就须要从期待队列中唤醒一个期待的线程。如果是偏心锁,唤醒的策略就是谁期待的工夫长,就唤醒谁,很偏心;如果是非偏心锁,则不提供这个偏心保障,有可能等待时间短的线程反而先被唤醒。

用锁的最佳实际

用锁尽管能解决很多并发问题,然而危险也是挺高的。可能会导致死锁,也可能影响性能。这方面有是否有相干的最佳实际呢?有,还很多。然而我感觉最值得举荐的是并发巨匠 Doug Lea《Java 并发编程:设计准则与模式》一书中,举荐的三个用锁的最佳实际,它们别离是:

  1. 永远只在更新对象的成员变量时加锁
  2. 永远只在拜访可变的成员变量时加锁
  3. 永远不在调用其余对象的办法时加锁

这三条规定,前两条预计你肯定会认同,最初一条你可能会感觉过于严苛。然而我还是偏向于你去恪守,因为调用其余对象的办法,切实是太不平安了,兴许“其余”办法外面有线程 sleep()的调用,也可能会有奇慢无比的 I / O 操作,这些都会重大影响性能。更可怕的是,“其余”类的办法可能也会加锁,而后双重加锁就可能导致死锁。

并发问题,原本就难以诊断,所以你肯定要让你的代码尽量平安,尽量简略,哪怕有一点可能会出问题,都要致力防止。

上面咱们谈谈另外一个话题:Dubbo 如何用管程实现异步转同步?

Java 语言内置的管程里只有一个条件变量,而 Lock&Condition 实现的管程是反对多个条件变量的,这是二者的一个重要区别。刚刚咱们讲了 Java SDK 并发包里的 Lock 有别于 synchronized 隐式锁的三个个性:可能响应中断、反对超时和非阻塞地获取锁。而 Java SDK 并发包里的 Condition,实现了管程模型外面的条件变量

在很多并发场景下,反对多个条件变量可能让咱们的并发程序可读性更好,实现起来也更容易。例如,实现一个阻塞队列,就须要两个条件变量。

那如何利用两个条件变量疾速实现阻塞队列呢?

一个阻塞队列,须要两个条件变量,一个是队列不空(空队列不容许出队),另一个是队列不满(队列已满不容许入队)

public class BlockedQueue<T>{
  final Lock lock =
    new ReentrantLock();
  // 条件变量:队列不满
  final Condition notFull =
    lock.newCondition();
  // 条件变量:队列不空
  final Condition notEmpty =
    lock.newCondition();

  // 入队
  void enq(T x) {lock.lock();
    try {while (队列已满){
        // 期待队列不满
        notFull.await();}
      // 省略入队操作...
      // 入队后, 告诉可出队
      notEmpty.signal();}finally {lock.unlock();
    }
  }
  // 出队
  void deq(){lock.lock();
    try {while (队列已空){
        // 期待队列不空
        notEmpty.await();}
      // 省略出队操作...
      // 出队后,告诉可入队
      notFull.signal();}finally {lock.unlock();
    }
  }
}

这里你须要留神,Lock 和 Condition 实现的管程,线程期待和告诉须要调用 await()、signal()、signalAll(),它们的语义和 wait()、notify()、notifyAll()是雷同的。然而不一样的是,Lock&Condition 实现的管程里只能应用后面的 await()、signal()、signalAll(),而前面的 wait()、notify()、notifyAll()只有在 synchronized 实现的管程里能力应用。如果一不小心在 Lock&Condition 实现的管程里调用了 wait()、notify()、notifyAll(),那程序可就彻底玩儿完了。

Java SDK 并发包里的 Lock 和 Condition 不过就是管程的一种实现而已,管程你曾经很相熟了,那 Lock 和 Condition 的应用天然是小菜一碟。上面咱们就来看看在出名我的项目 Dubbo 中,Lock 和 Condition 是怎么用的。不过在开始介绍源码之前,我还先要介绍两个概念:同步和异步。

同步与异步

什么时同步和异步?艰深点来讲就是调用方是否须要期待后果,如果须要期待后果,就是同步;如果不须要期待后果,就是异步

比方在上面的代码里,有一个计算圆周率小数点后 100 万位的办法 pai1M(),这个办法可能须要执行俩礼拜,如果调用 pai1M() 之后,线程始终等着计算结果,等俩礼拜之后后果返回,就能够执行 printf("hello world") 了,这个属于同步;如果调用 pai1M() 之后,线程不必期待计算结果,立即就能够执行 printf("hello world"),这个就属于异步。

// 计算圆周率小说点后 100 万位
String pai1M() {// 省略代码有数}

pai1M()
printf("hello world")

同步,是 Java 代码默认的解决形式。如果你想让你的程序反对异步,能够通过上面两种形式来实现:

  1. 调用方创立一个子线程,在子线程中执行办法调用,这种调用咱们称为异步调用;
  2. 办法实现的时候,创立一个新的线程执行次要逻辑,主线程间接 return,这种办法咱们个别称为异步办法。

Dubbo 源码剖析

其实在编程畛域,异步的场景还是挺多的,比方 TCP 协定自身就是异步的,咱们工作中常常用到的 RPC 调用,在 TCP 协定层面,发送完 RPC 申请后,线程是不会期待 RPC 的响应后果的。可能你会感觉奇怪,平时工作中的 RPC 调用大多数都是同步的啊?这是怎么回事呢?

其实很简略,肯定是有人帮你做了异步转同步的事件。例如目前出名的 RPC 框架 Dubbo 就给咱们做了异步转同步的事件,那它是怎么做的呢?上面咱们就来剖析一下 Dubbo 的相干源码。

对于上面一个简略的 RPC 调用,默认状况下 sayHello()办法,是个同步办法,也就是说,执行 service.sayHello(“dubbo”)的时候,线程会停下来等后果。

DemoService service = 初始化局部省略
String message =
  service.sayHello("dubbo");
System.out.println(message);

如果此时你将调用线程 dump 进去的话,会是下图这个样子,你会发现调用线程阻塞了,线程状态是 TIMED\_WAITING。原本发送申请是异步的,然而调用线程却阻塞了,阐明 Dubbo 帮咱们做了异步转同步的事件。通过调用栈,你能看到线程是阻塞在 DefaultFuture.get()办法上,所以能够推断:Dubbo 异步转同步的性能应该是通过 DefaultFuture 这个类实现的。

调用栈信息

不过为了理清前后关系,还是有必要剖析一下调用 DefaultFuture.get()之前产生了什么。DubboInvoker 的 108 行调用了 DefaultFuture.get(),这一行很要害,我略微批改了一下列在了上面。这一行先调用了 request(inv, timeout)办法,这个办法其实就是发送 RPC 申请,之后通过调用 get()办法期待 RPC 返回后果。

public class DubboInvoker{Result doInvoke(Invocation inv){
    // 上面这行就是源码中 108 行
    // 为了便于展现,做了批改
    return currentClient
      .request(inv, timeout)
      .get();}
}

DefaultFuture 这个类是很要害,我把相干的代码精简之后,列到了上面。不过在看代码之前,你还是有必要反复一下咱们的需要:当 RPC 返回后果之前,阻塞调用线程,让调用线程期待;当 RPC 返回后果后,唤醒调用线程,让调用线程从新执行。不晓得你有没有似曾相识的感觉,这不就是经典的期待 - 告诉机制吗?这个时候想必你的脑海里应该可能浮现出管程的解决方案了。有了本人的计划之后,咱们再来看看 Dubbo 是怎么实现的。

// 创立锁与条件变量
private final Lock lock
    = new ReentrantLock();
private final Condition done
    = lock.newCondition();

// 调用方通过该办法期待后果
Object get(int timeout){long start = System.nanoTime();
  lock.lock();
  try {while (!isDone()) {done.await(timeout);
      long cur=System.nanoTime();
      if (isDone() ||
          cur-start > timeout){break;}
    }
  } finally {lock.unlock();
  }
  if (!isDone()) {throw new TimeoutException();
  }
  return returnFromResponse();}
// RPC 后果是否曾经返回
boolean isDone() {return response != null;}
// RPC 后果返回时调用该办法
private void doReceived(Response res) {lock.lock();
  try {
    response = res;
    if (done != null) {done.signal();
    }
  } finally {lock.unlock();
  }
}

调用线程通过调用 get()办法期待 RPC 返回后果,这个办法外面,你看到的都是相熟的“脸孔”:调用 lock()获取锁,在 finally 外面调用 unlock()开释锁;获取锁后,通过经典的在循环中调用 await()办法来实现期待。

当 RPC 后果返回时,会调用 doReceived()办法,这个办法外面,调用 lock()获取锁,在 finally 外面调用 unlock()开释锁,获取锁后通过调用 signal()来告诉调用线程,后果曾经返回,不必持续期待了。

至此,Dubbo 外面的异步转同步的源码就剖析完了,有没有感觉还挺简略的?最近这几年,工作中须要异步解决的越来越多了,其中有一个次要起因就是有些 API 自身就是异步 API。例如 websocket 也是一个异步的通信协议,如果基于这个协定实现一个简略的 RPC,你也会遇到异步转同步的问题。当初很多私有云的 API 自身也是异步的,例如创立云主机,就是一个异步的 API,调用尽管胜利了,然而云主机并没有创立胜利,你须要调用另外一个 API 去轮询云主机的状态。如果你须要在我的项目外部封装创立云主机的 API,你也会面临异步转同步的问题,因为同步的 API 更易用。

总结

管程是一个解决并发问题的模型,你能够参考医院就医的流程来加深了解。了解这个模型的重点在于了解条件变量及其期待队列的工作原理。

Java 参考了 MESA 模型,语言内置的管程(synchronized)对 MESA 模型进行了精简。MESA 模型中,条件变量能够有多个,Java 语言内置的管程里只有一个条件变量。具体如下图所示。

Java 内置的管程计划(synchronized)应用简略,synchronized 关键字润饰的代码块,在编译期会主动生成相干加锁和解锁的代码,然而仅反对一个条件变量;而 Java SDK 并发包实现的管程反对多个条件变量,不过并发包里的锁,须要开发人员本人进行加锁和解锁操作。

并发编程里两大外围问题——互斥和同步,都能够由管程来帮你解决。学好管程,实践上所有的并发问题你都能够解决,并且很多并发工具类底层都是管程实现的,所以学好管程,就是相当于把握了并发编程的基石。

Java SDK 并发包里的 Lock 接口外面的每个办法,你能够感触到,都是通过三思而行的。除了反对相似 synchronized 隐式加锁的 lock()办法外,还反对超时、非阻塞、可中断的形式获取锁,这三种形式为咱们编写更加平安、强壮的并发程序提供了很大的便当。

除了并发巨匠 Doug Lea 举荐的三个最佳实际外,你也能够参考一些诸如:缩小锁的持有工夫、减小锁的粒度等业界广为人知的规定,其实实质上它们都是相通的,不过是在该加锁的中央加锁而已。

本文由 mdnice 多平台公布

正文完
 0