「最后一次,彻底搞懂kotlin协程」(二) | 线程池,Handler,Coroutine

3,789 阅读17分钟


引言

  当我发现我不停的看到关于Kotlin协程的文章的时候,我突然意识到:可能现有的文章并没有很好的解决大家的一些问题。在看了一些比较热门的协程文章之后,我确认了这个想法。此时我想起了一个古老的笑话:当一个程序员看到市面上有50种框可用架的时候,决心开发一种框架把这个框架统一起来,于是市面上有了51种框架我最终还是决定:干,因为异步编程实在是一个过于重要的部分。


  我总结了现存资料所存在的一些问题:

  1. 官方文档侧重于描述用法,涉及原理部分较少。如果不掌握原理,很难融会贯通,使用时容易踩坑
  2. 部分博客文章基本是把官方文档复述一遍,再辅之少量的示例,增量信息不多
  3. 不同的博客文章之间内容质量参差不齐,理解角度和描述方式各不相同,部分未经验证的概念反而混淆了认知,导致更加难以理解
  4. 部分博客文章涉及大量源码相关内容,但描述线索不太容易理解,缺乏循序渐进的讲述和一些关键概念的铺垫和澄清
地心说与日心说.gif


  而为什么 coroutine 如此难以描述清楚呢?我总结了几个原因:

  1. 协程的结构化并发写法(异步变同步的写法)很爽,但与之前的经验相比会过于颠覆,难以理解
  2. 协程引入了不少之前少见的概念,CoroutineScope,CoroutineContext... 新概念增加了理解的难度
  3. 协程引入了一些魔法,比如 suspend,不仅是一个关键字,更在编译时加了料,而这个料恰好又是搞懂协程的关键
  4. 协程的恢复也是非常核心的概念,是协程之所以为协程而不单单只是另一个线程框架的关键,而其很容易被一笔带过
  5. 因为协程的“新”概念较多,技术实现也较为隐蔽,所以其主线也轻易的被掩埋在了魔法之中


  那么在意识到了理解协程的一些难点之后,本文又将如何努力化解这些难题呢?我打算尝试以下一些方法:

  1. 从基础的线程开始,澄清一些易错而又对理解协程至关重要的概念,尽量不引入对于理解协程核心无关的细节
  2. 循序渐进,以异步编程的发展脉络为主线,梳理引入协程后解决了哪些问题,也破除协程的高效率迷信
  3. 物理学家费曼有句话:“What I cannot create, I do not understand”,我会通过一些简陋的模拟实现,来降低协程陡峭的学习曲线
  4. 介绍一些我自己的独特理解,并引入一些日常场景来加强对于协程理解
  5. 加入一些练习。如看不练理解会很浅,浅显的理解会随着时间被你的大脑垃圾回收掉,陷入重复学习的陷阱(这也是本系列标题夸口最后一次的原因之一)


希望通过上面的方式可以让大家更好的理解 Kotlin Coroutine。我们在第一篇中澄清了线程中非常重要的一些概念,强烈推荐还没有看过的先从线程篇开始,那么下面我们进入第二篇,线程池。

线程池

  上一篇线程中我们澄清了关于线程和 Thread 对象的区别以及“切”线程的概念,提到了我们之后会用任务流转来替代切线程这个术语。在这篇文章中我们主要来看看线程池,同时看看是否 Kotlin Coroutine 的效率真的比线程池更高。

  我们先来回顾一下线程篇中 Thread3.kt 中的示例:

// Thread3.kt
val work = Runnable {
    printlnWithThread("do work 1")
    switchThread3()
}

val otherWork1 = Runnable {
    Thread.sleep(100) // 模拟耗时, 避免main方法中work结束太早,newThread添加work3失败
    printlnWithThread("do work a")
}

val otherWork2 = Runnable {
    printlnWithThread("do work X")
}

// prevent ConcurrentModificationException
val works = ConcurrentLinkedQueue<Runnable>()
fun main() {
    works.addAll(listOf(work, otherWork1, otherWork2))
    works.forEach { it.run() }
}

fun switchThread3() = thread {
    printlnWithThread("do work 2")
    works.add(Runnable { printlnWithThread("do work 3") })
}

// log
main: do work 1
Thread-0: do work 2
main: do work a
main: do work X
main: do work 3

我们不得不在 otherWork1 中加入 100ms 的 sleep,来防止 main 线程过早结束而导致 newThread 向 main 线程添加 work3 失败。线程设计就是这样,一旦执行完任务之后通常很快就会被回收掉。那么我们能否让线程一直运行不被结束呢?其实在 Android 中我们就一个很好的工具,Handler。

Handler

  上面的单线程 EventLoop 机制,其实就是 android Handler 机制的原理,不过 Handler 是无限循环,当没有可执行的任务时会阻塞等待。 Android 为我们准备好了一个在 App 运行期间会一直运行的 main 线程,其在 ActivityThread 执行到 main 方法时会开启 loop,无限循环,精简的源码如下:

// android.app.ActivityThread.java
public static void main(String[] args) {
	 // 1. 以当前 main 线程 准备 mainLooper
    Looper.prepareMainLooper();

  	 // 2. 开始 loop 处理消息,当暂时没有消息时会阻塞 main 线程,直到有任务可以处理
    Looper.loop();
  
  	 // 3. 正常情况不会走到这里来
    throw new RuntimeException("Main thread loop unexpectedly exited");
}

  我们完全可以仿照 Handler 自己来实现一个简单的版本,实现类似于上面的无限循环和等待,看看下面的例子

// ThreadPool1.kt
class MyHandler {
    private val threadLocal: ThreadLocal<BlockingQueue<Runnable>> = ThreadLocal()
    private val blockingQueue: BlockingQueue<Runnable> = threadLocal.get() ?: LinkedBlockingQueue()

    fun postRunnable(runnable: Runnable) = blockingQueue.put(runnable)

    fun loop() {
        while (true) blockingQueue.take().run() // 当queue中没有Runnable时,take会阻塞,直到queue中有值可取
    }
}

val otherWork1 = Runnable { printlnWithThread("do work a") }
val otherWork2 = Runnable { printlnWithThread("do work X") }

fun main() {
    val myHandler = MyHandler()

    myHandler.postRunnable {
        printlnWithThread("do work 1")
        switchToBackThread(myHandler)
    }
    myHandler.postRunnable(otherWork1)
    myHandler.postRunnable(otherWork2)

    myHandler.loop()
    println("Main thread loop unexpectedly exited")
}

fun switchToBackThread(myHandler: MyHandler) = thread {
    printlnWithThread("do work 2")
    myHandler.postRunnable { printlnWithThread("do work 3") }
}

// log
// main方法run起来后一直不会停止,loop 后面的日志也不会打印
main: do work 1
main: do work a
main: do work X
Thread-0: do work 2
main: do work 3

为了简单起见,示例把 Android 中的 Handler,Looper,MessageQueue 等功能压缩到了 MyHandler 中,上面 postRunable 方法与 Handler 中的 postRunable 方法类似,用于向该线程流转任务,可以看作是任务生产者。loop 方法跟 Looper 中的 loop 类似,用于无限循环消费任务。我们可以从 log 中看到这个运行效果和我们 Thread3.kt 中的示例效果一致,不过 MyHandler 这里 main 线程会一直执行。我们不需要在 otherWork1 中 sleep,甚至还在 newThread 中 sleep 了 100ms,此时 otherWork1,2 早已执行完毕,正被 block 住,添加完 work3 之后 unblock,执行完 work3 之后又被 block 住

MyHandler.drawio.png

MyHandler模型

线程池

  Handler 是一个很好的工具,但并非 Android 平台特有的技术。我们对比一下 Handler 和线程池,可以说 Handler 模型其实很像一个 singleThreadPool,我们来对比一下 MyHandler(简化版 Handler) 和 newSingleThreadExecutor 方法:

// My Handler
class MyHandler {
    private val threadLocal: ThreadLocal<BlockingQueue<Runnable>> = ThreadLocal()
    private val blockingQueue: BlockingQueue<Runnable> = threadLocal.get() ?: LinkedBlockingQueue()

    fun postRunnable(runnable: Runnable) = blockingQueue.put(runnable)

    fun loop() {
        while (true) blockingQueue.take().run()
    }
}

// 1. Executors.newSingleThreadExecutor
public static ExecutorService newSingleThreadExecutor() {
    return new FinalizableDelegatedExecutorService
        (new ThreadPoolExecutor(1, 1,
                                0L, TimeUnit.MILLISECONDS,
                                new LinkedBlockingQueue<Runnable>()));
}
// 2. Executor
public interface Executor {
    void execute(Runnable command);
}
// 3. 简化inline版 ThreadPoolExecutor.runWorker
final void runWorker(Worker w) {
    while (task != null || (task = workQueue.take()) != null) {
        task.run();
    }
}

  同样是一个线程,都有用于缓存任务的 LinkedBlockingQueue,part2 中 Executor.execute 并非马上执行,而是等同于 MyHandler 的 postRunnable 把任务加到队列里面。part3 中 runWorker 等同于 MyHandler 的 loop,都是单个线程作为消费者不断的消费一个阻塞容器中的任务。postRunnable 生产,Loop 消费

  以 Android 为例,交互事件、传感器回调等就是生产者,通过 Handler post 事件到主线程的 MessageQueue,MainLooper 按一定顺序消费这些事件。如果其中一个事件耗时过长(超过16ms),就可能导致一次可感知的(如果用户没在交互,或者没有动画等预期界面变化,则用户无法感知)卡顿,这就是我们不能在主线程上做网络请求的原因。我们需要把耗时任务流转到后台线程(对应的把与用户交互的主线程叫做前台线程),当后台线程完成耗时任务后再把任务流转回前台线程做进一步的非耗时处理。

Handler模型.drawio.png

Handler/SingleThreadPool 模型


  我们知道线程非常耗费内存,即使我们可以把耗时任务流转到后台线程,后台线程的创建运行及回收都是耗费资源的。创建线程会耗时,导致任务执行时间延后,频繁的创建回收还会导致内存抖动,而频繁的内存抖动可能导致卡顿,即使 main 线程未被阻塞。所以对于线程这种昂贵的资源,我们会做缓存,缓存机制就是线程池的的基础。由于线程一旦把工作做完,就会进入 TERMINATED 状态,无法被复用,所以我们需要一种机制来避免这点,这就是 EventLoop(事件循环)的作用。协程中也有用到线程池相关的理念,我们来瞥一眼协程中的相关源码:

// 1. Dispatchers
public actual val Default: CoroutineDispatcher = DefaultScheduler
public val IO: CoroutineDispatcher = DefaultIoScheduler

// 2. DefaultScheduler
internal object DefaultScheduler : SchedulerCoroutineDispatcher(
    CORE_POOL_SIZE, MAX_POOL_SIZE,
    IDLE_WORKER_KEEP_ALIVE_NS, DEFAULT_SCHEDULER_NAME
) 

// 3. Dispatchers.IO
internal object DefaultIoScheduler : ExecutorCoroutineDispatcher(), Executor {
    override val executor: Executor
        get() = this

    override fun execute(command: java.lang.Runnable) = dispatch(EmptyCoroutineContext, command)
}

// 4. 2,3 都调用了 CoroutineScheduler,下面是部分doc
// Coroutine scheduler (pool of shared threads) which primary target is to distribute dispatched coroutines over worker threads
internal class CoroutineScheduler(
    val corePoolSize: Int,
    val maxPoolSize: Int,
    val idleWorkerKeepAliveNs: Long = IDLE_WORKER_KEEP_ALIVE_NS,
    val schedulerName: String = DEFAULT_SCHEDULER_NAME
) : Executor, Closeable 

// 5. ThreadPoolExecutor
public ThreadPoolExecutor(int corePoolSize,
                          int maximumPoolSize,
                          long keepAliveTime,
                          TimeUnit unit,
                          BlockingQueue<Runnable> workQueue)

在协程中我们用 CoroutineDispatcher 来流转任务到不同的线程上,上面源码 part1 是常用的两个:Default 和 IO。part2 是 DefaultScheduler 。part3 是 DefaultIoScheduler 的声明和部分代码,可以看到其甚至直接实现了 Executor 接口,其 execute 方法直接转发了 dispatch 方法,所以我们几乎可以把其 dispatch 方法等同于线程池中的 execute 方法。part4 是 CoroutineScheduler,Default 和 IO 最终都会用到,我们把其与 part5 中的 ThreadPoolExecutor 对比一下会发现其相似性,corePoolSize, maxPoolSize,keepAliveTime 等线程池相关的核心参数在 CoroutineScheduler 都有直接的对应。doc 更直白,我翻译一下:协程调度程序(共享线程池),其主要目标是在工作线程上分配调度的协程,这说明协程内部分配调度任务的也是类似于线程池的机制,可以说 Coroutine 的直接对比对象就是 Runnable

异常处理

  我们接下来谈一下异常处理机制。为什么要谈异常处理, 不是直接 try catach 就好了吗? 没错,异常处理底层最终基本都是 try catch ,一般情况下我们直接用 try catch 就好(Thread.uncaughtExceptionHandler 会完全打断工作流,这里不作讨论)。我们知道我们在 Excutor.execute/ExecutorService.submit 方法只是把任务加到对应线程池的队列中,只生产,不消费,加入完成后当前线程就继续往下走了。当后续线程池中的线程执行到该任务时,如果执行任务遇到异常,我们有两种处理方式

  1. 在执行处 try catch 异常并处理
  2. 把异常抛出,由上级来处理

  我们主要来看第二种。使用抛出异常的方式主要是因为当前发生异常点没有处理该异常足够的上下文,或者我们需要在顶层统一处理异常。但跨线程打断了这个往上层抛异常的过程,因为这个异常只会直接影响当前执行线程,堆栈信息只会包含当前执行线程的调用栈,异常不会抛到我们加入任务的线程上,也不会包含加入任务的线程的调用栈,我们来看一下在 main 线程和 newThread 上分别抛出异常的调用栈:

// ThreadPool2.kt
fun main() {
    doWork1()
    thread { doWork2() }
    doWork3()
}

private fun doWork1() = printlnWithThread("do work 1")
private fun doWork2() {
    throw RuntimeException("new thread exception")
    printlnWithThread("do work 2")
}

private fun doWork3() {
    printlnWithThread("do work 3")
    throw RuntimeException("main thread exception")
}

// log
main: do work 1
main: do work 3
Exception in thread "main" Exception in thread "Thread-0" java.lang.RuntimeException: main thread exception
	at ThreadPool2Kt.doWork3(ThreadPool2.kt:21)
	at ThreadPool2Kt.main(ThreadPool2.kt:10)
	at ThreadPool2Kt.main(ThreadPool2.kt)
java.lang.RuntimeException: new thread exception
	at ThreadPool2Kt.doWork2(ThreadPool2.kt:15)
	at ThreadPool2Kt.access$doWork2(ThreadPool2.kt:1)
	at ThreadPool2Kt$main$1.invoke(ThreadPool2.kt:9)
	at kotlin.concurrent.ThreadsKt$thread$thread$1.run(Thread.kt:30)

  "new thread exception" 中并不包含任何 main 线程的相关调用栈信息,作为对比我们可以看到 "main thread exception" 打印了 main 方法(这里即 main 线程)的调用栈信息。线程的调用栈之间是相互独立的,main 线程不知道 work2 发生了异常,也就难以处理该异常。上面的调用栈可参考下图:

ThreadStack.drawio.png

独立的调用栈


  在真实 App 运行时,任务流转的时机难以追踪,而我们在处理异常信息时,往往需要结合两个线程的相关信息,但因为堆栈的天然隔离,我们无法同时拿到两者的信息。那针对这种割裂的现状,线程池有什么应对方法吗?让我们看看线程池如何处理跨线程异常:

// ThreadPool3.kt
fun main() {
    doWork1()

    val executor = Executors.newSingleThreadExecutor()
    val future = executor.submit { doWork2() }
    try {
        future.get()
    } catch (exception: Exception) {
        exception.printStackTrace()
        println("do work 2 fail, cancel work 3")
        executor.shutdown()
        return
    }

    doWork3()
}

private fun doWork1() = printlnWithThread("do work 1")
private fun doWork3() = printlnWithThread("do work 3")

private fun doWork2() {
    throw RuntimeException("new thread exception")
    printlnWithThread("do work 2")
}

// log
main: do work 1
do work 2 fail, cancel work 3
java.util.concurrent.ExecutionException: java.lang.RuntimeException: new thread exception
	at java.base/java.util.concurrent.FutureTask.report(Unknown Source)
	at java.base/java.util.concurrent.FutureTask.get(Unknown Source)
	at ThreadPool3Kt.main(ThreadPool3.kt:13)
	at ThreadPool3Kt.main(ThreadPool3.kt)
Caused by: java.lang.RuntimeException: new thread exception
	at ThreadPool3Kt.doWork2(ThreadPool3.kt:26)
	at ThreadPool3Kt.main$lambda$0(ThreadPool3.kt:11)
	at java.base/java.util.concurrent.Executors$RunnableAdapter.call(Unknown Source)
	at java.base/java.util.concurrent.FutureTask.run(Unknown Source)
	at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(Unknown Source)
	at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(Unknown Source)
	at java.base/java.lang.Thread.run(Unknown Source)

上面可以看到 work1 完成了,work3 因为 work2 失败的原因而被 cancel 了,并且通过 future 对象的 get 方法抛出了异常,不调用就不会抛出异常。该异常信息包含 "new thread exception",并且外层还有一个 ExecutionException 包含了 main 方法相关的调用栈。异常处理就这样回到了 main 线程,并且堆栈信息同时包含两个线程相关的信息,便于排查问题。

  线程池使用了什么魔法吗,居然可以从 main 线程拿到在线程池内发生的 "new thread exception",并同时包含了两个线程相关调用栈信息。上面有一个细节,我们调用的是 ExecutorService 接口的submit方法,而不是之前的 execute,该方法返回一个 Future 对象,异常是在调用 future.get() 时抛出的,让我们看看细节吧:

// 1. FutureTask.run
public void run() {
    try {
        result = c.call();
        set(result);
    } catch (Throwable ex) {
        result = null;
        setException(ex);
    }
}

// 2. FutureTask.set
protected void set(V v) {
    outcome = v;
    finishCompletion();
}

// 3. FutureTask.setException
protected void setException(Throwable t) {
    outcome = t;
    finishCompletion();
}

// 4. FutureTask.finishCompletion
// Removes and signals all waiting threads
private void finishCompletion()

// 5. Future.get
// Waits if necessary for the computation to complete
V get() throws InterruptedException, ExecutionException
  
// 6. Future.report
private V report(int s) throws ExecutionException {
    Object x = outcome;
    if (s == NORMAL)
        return (V)x;
    throw new ExecutionException((Throwable)x);
}

  根据前面 ThreadPool3.kt 抛出的异常堆栈信息,我们在 newThread 执行了 FutureTask.run,即 part1(FutureTask 实现了 Future)。c.call 是 run 我们 submit 的 task。执行 result 通过 part2 的 set 方法赋值给 outcome。如果发生异常,则通过 part3 的 setException 把 throwable 赋值给 outcome。

  我们再来看看 mian 线程。在 part5 中Future.get 是一个阻塞方法,当 future 未 complete 时会阻塞当前调用 get 方法的线程,即 main 线程,get 方法调用 part6 的 report 方法获取结果,或包装并抛出之前保存在 outcome 的异常 。这里的 complete 就是 part2 和 part3 都会调用的 finishCompletion(),即 part4,调用时会通知等待的线程。

  这样,包含两个线程的整个流程就串起来了,FutureTask.run() 执行任务,执行完成后把结果或者异常都保存在 FutureTask.outcome 里面,通过 future.get() 获取结果或者抛出异常。其本质就是利用双方线程调用都能访问的进行了一次通信,并做了一些状态的同步控制,参考下图会更清晰:

ThreadStack&dump.drawio.png

调用栈与Future


Coroutine高效迷思

  对理解协程必要的线程池相关的一些重要概念就讲完了。我们终于可以回到协程上,来看看 kotlin 官方文档那个著名的例子,官网用这个例子来说明 kotlin 协程的高效率和轻量级

fun main() = runBlocking {
    repeat(100_000) { // 启动大量的协程
        launch {
            delay(5000L)
            print(".")
        }
    }
}

  官方文档提出把协程换成线程试试,这里这么多的线程明显会 OutOfMemoryError。前面我们也提到 Coroutine 的对比对象应该是 Runnable,并且我们有了线程池,还对比啥的线程呢,我们直接看看对比线程池,协程的效率如何

// ThreadPool4.kt
const val times = 100_000
const val delayTime = 5000L

fun main() {
    println("coroutine time: ${measureTimeMillis { coroutine() }}")
    println("threadPool time:${measureTimeMillis { threadPool() }}")
}

fun threadPool() {
    val scheduledThreadPoolExecutor = ScheduledThreadPoolExecutor(1)
    repeat(times) {
        scheduledThreadPoolExecutor.schedule({ print(".") }, delayTime, TimeUnit.MILLISECONDS)
    }
    scheduledThreadPoolExecutor.shutdown()
    scheduledThreadPoolExecutor.awaitTermination(Long.MAX_VALUE, TimeUnit.DAYS)
}

private fun coroutine() = runBlocking {
    repeat(times) { // 启动大量的协程
        launch {
            delay(delayTime)
            print(".")
        }
    }
}

// log 节选
......................coroutine time: 5299
......................threadPool time:5146

执行 10w 个延时 5s 的任务,可以看到上面 coroutine 的效率和 threadPool 相似,在减去 delay 的 5s 后,效率甚至比 threadPool 还低了 1 倍,说好的高效、轻量级呢?这其实是因为协程有一些包装处理会耗费资源,threadPool 则相对更加简单,差出一倍的效率我们还是可以说两者的效率在同一个数量级(而且真正耗时的是任务的处理,而不在任务的调度上)。官网这个例子本来想取个巧,用 delay() 对比 Thread.sleep(),来说明 delay 的非阻塞性,其实我们可以用 ScheduledThreadPoolExecutor.schedule 达成类似的效果,同样是非阻塞的,甚至效率更高。不过协程是可恢复的,所以协程可以在一个 Coroutine 内部达成非阻塞的效果,Runnable 是不可恢复的,所以需要在 schedule 时从 Runnable 外部传入 delay 参数,灵活性会比 Coroutine 差。 从上面例子我们再次可以看出,启动的 Coroutine 对比的对象不应该是线程,而应该是 Runnable。

总结

1.线程池通过 EventLoop 模型保证线程不会结束,缓存多个线程保证高效率,并分发任务到各个线程,这些同样是协程的基础。
2.异常处理,线程池通过 FutureTask try catch 并缓存异常,再通过 get 方法来完成跨线程的异常处理。
3.kotlin coroutine 效率也并不比线程池高。启动的协程对比的对象不应该是线程,而是Runnable,因为这些协程最终也会被流转到合适的线程上去执行,就像 Runnable。

  这一篇我们知道了协程的效率相对线程池并不占优势,那协程优势又在哪里呢?我先给出几个我的答案:

  1. Scope 机制的安全的结构化并发
  2. CPS + 语言级状态机 形成的异步变同步写法
  3. Coroutine 的可恢复性带来的灵活性。

我们接下来正式进入协程篇:结构化并发,下一篇再见。

示例源码github.com/chdhy/kotli…
练习:实现上面几个例子,可以体会 EventLoop 模型,更好的理解线程池以及 Handler,这将帮助我们降低接下来理解协程的难度

点赞👍文章,关注❤️ 笔者,获取其他文章更新

  1. 「最后一次,彻底搞懂kotlin协程」(一) | 先回到线程

  2. 「最后一次,彻底搞懂kotlin协程」(二) | 线程池,Handler,Coroutine

  3. 「最后一次,彻底搞懂kotlin协程」(三) | CoroutineScope,CoroutineContext,Job: 结构化并发

  4. 「最后一次,彻底搞懂kotlin协程」(四) | suspend挂起,EventLoop恢复:异步变同步的秘密

  5. 「最后一次,彻底搞懂kotlin协程」(五) | Dispatcher 与 Kotlin 协程中的线程池

  6. 「最后一次,彻底搞懂kotlin协程」(六) | 全网唯一,手撸协程!

  7. 「最后一次,彻底搞懂kotlin Flow」(一) | 五项全能 🤺 🏊 🔫 🏃🏇:Flow

  8. 「最后一次,彻底搞懂kotlin Flow」(二) | 深入理解 Flow

  9. 「最后一次,彻底搞懂kotlin Flow」(三) | 冷暖自知:Flow 与 SharedFlow 的冷和热