协程(3) | runBlocking和async启动协程

3,390 阅读4分钟

前言

上一篇文章中,我们使用launch启动了一个用来做一劳永逸任务的协程,我们把它看成射箭,从代码调用地方射出去,射出去的箭该干啥就干啥,协程后面代码继续执行,即体现出非阻塞特性。

正文

本篇文章我们来看其他2个启动协程的API:runBlockingasync

runBlocking

其实runBlocking从名字就能看的出来,是阻塞的意思,所以它启动的协程特点就是会阻塞线程执行

这里其实可以从两个方面来理解:

  • 一个是阻塞线程,比如Android程序在主线程调用runBlocking函数,这时线程将进入等待状态,等待runBlocking函数执行完成,假如该函数执行太久,就会导致ANR。
  • 另一个方式是,在runBlocking启动的协程中,我们使用launch再启动子协程,这时runBlocking会等待子协程执行完成。

这里的理解难点是"阻塞线程",所以runBlocking只用于推荐连接线程和协程。

我们和launch对比一下,比如都在Android主线程启动协程,runBlocking会全占线程,Android的绘制UI工作将无法执行,而使用launch时,Android的绘制UI工作可以正常执行。

我们来看个简单例子:

fun main() {
    runBlocking {
        println("coroutine start")
        //模拟耗时
        delay(1000)
        println("coroutine end")
    }
    println("process end")
}

这段代码的执行结果如下:

image.png

在这里我们发现,runBlocking启动的协程和launch就有非常大的区别了,在调用runBlocking那一刻开始,程序的执行就进入了协程内部,等协程内部执行完后,才继续执行线程上的代码。

这里或许注意到一点,之前launch启动时,必须要有协程范围,而runBlocking却不需要,我们看一下其方法定义:

public actual fun <T> runBlocking(context: CoroutineContext, 
    block: suspend CoroutineScope.() -> T): T {
    ...
}

会发现它不是CoroutineScope的扩展函数,同时block的函数类型返回值是T,说明是可以返回值的,我们这里来尝试一下:

fun main() {
    val result =runBlocking {
        println("coroutine start")
        //模拟耗时
        delay(1000)
        println("coroutine end")
        return@runBlocking "hello"
    }
    println("process end $result")
}

这里的代码结果如下:

image.png

可以发现result是有结果的。

那有没有即不阻塞线程,又可以获得协程结果的方法呢 那就是async了。

async

async方法集成了前面2种的优点,话不多说,直接看下面代码:

fun main() = runBlocking {
    println("coroutine start ${Thread.currentThread().name}")

    val result = async {
        println("inner coroutine start ${Thread.currentThread().name}")
        //模拟耗时
        delay(1000)
        println("inner coroutine end ${Thread.currentThread().name}")
        return@async "fine!"
    }
    println("coroutine continue")
    println("coroutine end ${result.await()}")
}

首先这里会发现之前launch都是使用GlobalScope,但是Kotlin官方不建议使用,所以这里我们使用runBlocking来启动一个协程(虽然runBlocking不建议在项目中使用,但是测试demo可以使用)。

根据前面所说的runBlocking特点,在它里面启动的协程中,再启动其他的协程,会等待子协程执行完,所以这里我们就免去了使用Thread.sleep的写法了。

其次为什么在这里面可以用async启动协程呢?我们可以看一下前面runBlocking的参数block的参数类型:block: suspend CoroutineScope.() -> T,它是一个带接收者的函数类型。根据带接收者函数类型的定义,该block就相当于是CoroutineScope的成员方法了。

所以在lambda中已经有了CoroutineScope,所以可以调用async方法,下面是上面代码的执行结果:

image.png

会发现协程1开始后,接着就是调用continue,说明async没有阻塞后续代码执行,然后是协程2开始运行,当调用await()方法时,能获取到结果。

关于await()方法,后面挂起函数文章中会具体说明其实现原理。

我们来看一下async函数的定义:

public fun <T> CoroutineScope.async(
    context: CoroutineContext = EmptyCoroutineContext,
    start: CoroutineStart = CoroutineStart.DEFAULT,
    block: suspend CoroutineScope.() -> T
): Deferred<T> {
    ...
}

从这里我们可以看出,block类型是有返回值T的,而不是像launch返回Unit,同时async返回的是Deferred<T>,可以通过这个拿到协程返回值。

钓鱼模型

既然之前launch可以类比与射箭,那async钓鱼来做比喻就再恰当不过了。

其中Deferred就相当于是鱼竿,我们把鱼饵和鱼钩甩入水中,就相当于协程已经开始工作了,这时主线程可以继续干别的事。

当需要返回结果时,再拿起鱼竿,得到钓的鱼(返回结果)。注意,拿起鱼竿这个事是阻塞后续代码执行的,假如调用await方法时,async的结果还没有,这时是需要等待运行结束的,这是非常符合逻辑的。

我们最常见的可以使用async来同时开启多个请求,等请求结果都拿到,再接着进行后续操作。

这个模型可以完美解释非阻塞和拿到返回结果,同时还有一些细节,比如下面代码:

fun main() = runBlocking {
    val deferred: Deferred<String> = async {
        println("In async:${Thread.currentThread().name}")
        delay(1000L) // 模拟耗时操作
        println("In async after delay!")
        return@async "Task completed!"
    }

    // 不再调用 deferred.await()
    delay(2000L)
}

这里我们不再调用await方法,结果如下:

image.png

会发现启动的协程依旧执行了,这就相当于我钓鱼把鱼钩丢入水中,协程已经运行了,但是我没有抬杆,这个await函数就相当于是抬杆,没有获取结果而已。

阻塞探究

这里说async不完全阻塞也是不对的,原因很简单,当你调用await()方法时,会等待返回结果,也是不会继续方法剩余代码的,但是注意它不会阻塞线程的,就比如你的方法在Android主线程调用async,线程依旧可以绘制UI,只是方法暂时会被挂起不执行,等待一个合适的时机继续执行

这个思考点很有意思,重点是理解挂起await()挂起函数到底挂起了什么?它挂起了它后面的代码,可以等恢复后再继续执行。但是Android主线程该干啥还是干啥,不会阻塞线程。

但是它有个很关键的用法,就是我可以同时async多个协程,然后这些协程并发执行耗时操作,这样可以解决串行增加耗时的问题。

所以这里的阻塞可以理解为挂起,只是将这个方法的执行给暂停(挂起)了,等一会获取到结果再继续执行。

下面有个gif图,可以增强理解一下协程代码执行的顺序:

async.gif

这个gif图有一个容易理解有误的点,就是认为它会阻塞线程。这里阻塞线程的原因是,我们使用了runBlocking,假如上面代码运行在launch启动的协程中,就完全好理解了。

总结

runBlocking的主要作用是连接协程和线程,我们要少用。一方面是会阻塞线程执行,另一方面是它会等待它的子协程执行完成。

async创建的协程就像是钓鱼,把鱼竿抛出去我们继续干活。await是获取协程结果,它是挂起函数,把后面继续执行的操作给挂起来,等获取到结果再继续。