并发:协程不需要处理同步吗?

364 阅读6分钟

Kotlin 的协程仍然是基于线程运行的。但是,经过一层封装以后,Kotlin 协程面 对并发问题的时候,它的处理手段其实跟 Java 就大不一样

面试官的题库,让一部分人先拿Offer!

我们都知道,Kotlin 的协程仍然是基于线程运行的。但是,经过一层封装以后,Kotlin 协程面 对并发问题的时候,它的处理手段其实跟 Java 就大不一样。所以这节课,我们就来看看协程 在并发问题上的处理,一起来探究下 Kotlin 协程的并发思路

协程与并发

在 Java 世界里,并发往往需要多个线程一起工作,而多线程往往就会有共享的状态,这时候 程序就要处理同步问题了。

很多初学者在这一步,都会把协程与线程的概念混淆在一起。比如 你可以来看看下面这段代码,你觉得有多线程同步的问题吗?

在这段代码里,在 Default 线程池上创建了一个协程,然后对变量 i 进行了 1000 次自增 操作,接着我又 delay 了一小会儿,防止程序退出,最后输出结果

如果你仔细分析上面的代码,会发现代码中压根就没有并发执行的任务,除了 runBlocking, 只在 launch 当中创建了一个协程,所有的计算都发生在一个协程当中。所以,在这种情况 下你根本就不需要考虑同步的问题。

我们再来看看多个协程并发执行的例子

我创建了 10 个协程任务,每个协程任务都会工作在 Default 线程池,这 10 个协程任务,都会分别对 i 进行 1000 次自增操作。如果一切正常的话,代码的输出结果应该 是 10000。但如果你实际运行这段代码,你会发现结果大概率不会是 10000。

出现这个问题的原因也很简单,这 10 个协程分别运行在不同的线程之上,与此同时,这 10 个协程之间还共享着 i 这个变量,并且它们还会以并发的形式对 i 进行自增,所以自然就会产 生同步的问题。

有点意思了,Kotlin 协程看来也需要处理多线程同步的问题

借鉴 Java 的并发思路

由于 Kotlin 协程也是基于 JVM 的,所以,当我们面对并发问题的时候,脑子里第一时间想到的肯定是Java 当中的同步手段,比如 synchronized、Atomic、Lock,等等。在 Java 当中,最简单的同步方式就是 synchronized 同步了。

那么换到 Kotlin 里,我们就可以 使用 @Synchronized 注解来修饰函数,也可以使用 synchronized(){} 的方式来实现同步代

以上代码中,我们创建了一个 lock 对象,然后使用 synchronized(){} 将“i++”包裹了起来。这样 就可以确保在自增的过程中不会出现同步问题

不过,如果你在实际生产环境使用过协程的话,应该会感觉 synchronized 在协程当中也不是 一直都很好用的。毕竟,synchronized 是线程模型下的产物

就比如说,假设我们这里的自增操作需要一些额外的操作,需要用到挂起函数 prepare()。

这时候,你就不能天真地把协程看作是“Java 线程池的封装”,然后继续照搬 Java 的同步手段了。你会发现:synchronized(){} 当中调用挂起函数,编译器会给你报错!

这是为什么呢?因为这里的挂起函数会被翻译成带有 Continuation 的异步函数,从而就造成了 synchronized 代码块无法正确处理同步。

另外从这个例子里,我们也可以看出:即使 Kotlin 协程是基于 Java 线程的,但它其实已经脱离 Java 原本的范畴了。所以,单纯使用 Java 的同步手段,是无法解决 Kotlin 协程里所有问 题的。

协程的并发思路

由于 Java 的线程模型是阻塞式的,比如说 Thread.sleep(),所以在 Java 当 中,并发往往就意味着多线程,而多线程则往往会有状态共享,而状态共享就意味着要处理同步问题。

但是,因为 Kotlin 协程具备挂起、恢复的能力,而且还有非阻塞的特点,所以在使用协程处理并发问题的时候,我们的思路其实可以更宽。比如,我们可以使用单线程并发

单线程并发

当我们在协程中面临并发问题的时候,首先可以考虑:是否真的需要多线程?如果不需 要的话,其实是可以不考虑多线程同步问题的

对于前面代码段 2 的例子来说,我们则可以把计算的逻辑分发到单一的线程之上

在这段代码中,我们使用“launch(mySingleDispatcher)”,把所有的协程任务都分发到了单线程的 Dispatcher 当中,这样一来,我们就不必担心同步问题了。另外,如果仔细分析的 话,上面创建的 10 个协程之间,其实仍然是并发执行的

Mutex

在 Java 当中,其实还有 Lock 之类的同步锁。但由于 Java 的锁是阻塞式的,会大大影响协程 的非阻塞式的特性。

所以,在 Kotlin 协程当中,我们也是不推荐直接使用传统的同步锁的,甚至在某些场景下,在协程中使用 Java 的锁也会遇到意想不到的问题。

为此,Kotlin 官方提供了“非阻塞式”的锁:Mutex。下面我们就来看看,如何用 Mutex 来改造

在上面的代码中,我们使用 mutex.lock()、mutex.unlock() 包裹了需要同步的计算逻辑,这样 一来,代码就可以实现多线程同步了,程序的输出结果也会是 10000。

实际上,Mutex 对比 JDK 当中的锁,最大的优势就在于支持挂起和恢复

不过 mutex.lock()、mutex.unlock() 之间发生异常,从而导致 mutex.unlock() 无法被 调用。这个时候,整个程序的执行流程就会一直卡住,无法结束

所以,为了避免出现这样的问题,我们应该使用 Kotlin 提供的一个扩展函数: ****mutex.withLock{}

withLock{} 的本质,其实是在 finally{} 当中调用了 unlock()。这样一来,我们就再也不必担心因为异常导致 unlock() 无法执行的问题了

小结

这节课,我们学习了 Kotlin 协程解决并发的两大思路,分别是 Java 思路、协程思路。要注 意,对于 Java 当中的同步手段,我们并不能直接照搬到 Kotlin 协程当中来,其中最大的问 题,就是 synchronized 不支持挂起函数。

而对于协程并发手段,如下手段需要关注。

  1. 第一种手段,单线程并发,在 Java 世界里,并发往往意味着多线程,但在 Kotlin 协程当

中,我们可以轻松实现单线程并发,这时候我们就不用担心多线程同步的问题了。

  1. 第二种手段,Kotlin 官方提供的协程同步锁,Mutex,由于它的 lock 方法是挂起函数,所

以它跟 JDK 当中的锁不一样,Mutex 是非阻塞的。需要注意的是,我们在使用 Mutex 的时

候,应该使用 withLock{} 这个高阶函数,而不是直接使用 lock()、unlock()