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 不支持挂起函数。
而对于协程并发手段,如下手段需要关注。
- 第一种手段,单线程并发,在 Java 世界里,并发往往意味着多线程,但在 Kotlin 协程当
中,我们可以轻松实现单线程并发,这时候我们就不用担心多线程同步的问题了。
- 第二种手段,Kotlin 官方提供的协程同步锁,Mutex,由于它的 lock 方法是挂起函数,所
以它跟 JDK 当中的锁不一样,Mutex 是非阻塞的。需要注意的是,我们在使用 Mutex 的时
候,应该使用 withLock{} 这个高阶函数,而不是直接使用 lock()、unlock()