Kotlin协程教程(1):启动

协程

协程简单的来说,就是用户态的线程。

emmm,还是不明白对吧,那想象一个这样的场景,如果在一个单核的机器上有两个线程需要执行,因为一次只能执行一个线程里面的代码,那么就会出现线程切换的情况,一会需要执行一下线程A,一会需要执行一下线程B,线程切换会带来一些开销。

假设两个线程,交替执行,如下图所示
Kotlin协程教程(1):启动

线程会因为Thread.sleep方法而进入阻塞状态(就是什么也不会执行),这样多浪费资源啊。

能不能将代码块打包成一个个小小的可执行片段,由一个统一的分配器去分配到线程上去执行呢,如果我的代码块里要求sleep一会,那么就去执行别的代码块,等会再来执行我呢。
Kotlin协程教程(1):启动

协程就是这样一个东西,我们作为使用者不需要再去考虑创建一个新线程去执行一坨代码,也不需要关心线程怎么管理。我们需要关心的是,我要异步的执行一坨代码,待会我要拿到它的结果,我要异步的执行很多坨代码,待会我要按某种顺序,或者某种逻辑得到它们的结果。

总而言之,协程是用户态的线程,它是在用户态实现的一套机制,可以避免线程切换带来的开销,可以高效的利用线程的资源。

从代码上来讲,也可以更漂亮的写各种异步逻辑。

这里想再讲讲一个概念,阻塞与非阻塞是什么意思

阻塞与非阻塞

简单来说,阻塞就是不执行了,非阻塞就是一直在执行。
比如

Thread.wait() // 阻塞了
// 这里执行不到了

但是,如果

while (true) { // 一直在运行,没有阻塞
   i++;
}
// 这里也执行不到了

runBlocking:连接阻塞与非阻塞的世界

runBlocking是启动新协程的一种方法。

runBlocking启动一个新的协程,并阻塞它的调用线程,直到里面的代码执行完毕。

举个例子

println("aaaaaaaaa ${Thread.currentThread().name}")

runBlocking {
    for (i in 0..10) {
        println("$i ${Thread.currentThread().name}")
        delay(100)
    }
}

println("bbbbbbbbb ${Thread.currentThread().name}")

上面代码的输出为:

aaaaaaaaa main
0 main
1 main
2 main
3 main
4 main
5 main
6 main
7 main
8 main
9 main
10 main
bbbbbbbbb main

emmm,这并没有什么稀奇,所有的代码都在主线程执行,按照顺序来,去掉runBlocking也是一样的嘛。

但是,runBlocking可以指定参数,就可以让runBlocking里面的代码在其他线程执行,但同样可以阻塞外部线程。

println("aaaaaaaaa ${Thread.currentThread().name}")

runBlocking(Dispatchers.IO) { // 注意这里
    for (i in 0..10) {
        println("$i ${Thread.currentThread().name}")
        delay(100)
    }
}

println("bbbbbbbbb ${Thread.currentThread().name}")

上面的代码,给runBlocking添加了一个参数,Dispatchers.IO,这样里面的代码块就会执行到其他线程了。

来一起看看效果:

aaaaaaaaa main
0 DefaultDispatcher-worker-1
1 DefaultDispatcher-worker-1
2 DefaultDispatcher-worker-1
3 DefaultDispatcher-worker-4
4 DefaultDispatcher-worker-4
5 DefaultDispatcher-worker-6
6 DefaultDispatcher-worker-7
7 DefaultDispatcher-worker-7
8 DefaultDispatcher-worker-9
9 DefaultDispatcher-worker-1
10 DefaultDispatcher-worker-5
bbbbbbbbb main

通过断点在runBlocking里面的代码,查看这个时候,主线程是什么状态,发现它是进入了WAIT态。
Kotlin协程教程(1):启动

当给runBlocking指定Dispatchers参数时,就仿佛是使用了join方法。

val t = thread {
    for (i in 0..10) {
        println("$i ${Thread.currentThread().name}")
        Thread.sleep(100)
    }
}

t.join()

launch:启动一个协程

launch可以启动一个协程,但不会阻塞调用线程,但是launch必须要在协程作用域中才能调用。

fun main() {

    launch {
        // no, no, no...
    }
    
    runBlocking {
        launch {
            // is ok
        }
    }
}

如果要在非协程作用域调用launch,可以使用GlobalScope.launch。

fun main() {
    GlobalScope.launch {
        // is ok
    }
}

同样的launch也是可以传入一个Dispatcher参数来指定它会被分配到什么线程上执行。

此时,大家就会想了,GlobalScope.launch那么方便,是不是只用它就行了?什么时候该用launch,什么时候该用GlobalScope.launch呢?

文档这样说道:GlobalScope.launch会启动一个top-level的协程,它的生命周期将只受到整个应用程序生命周期的限制。

emmmm,那是不是说,普通的launch,它所创建的协程会受到外层的一个作用域的生命周期的影响,而GlobalScope所创建的协程,不收外层的影响。

于是,有了下面的实验

fun main() {

    runBlocking(Dispatchers.IO) {

        val job = launch { // 外层任务,包裹两个协程

            GlobalScope.launch { // 第一个协程
                for (i in 0..10) {
                    println("GlobalScope $i ${Thread.currentThread().name} -----")
                    delay(100)
                }
            }

            launch { // 第二个协程
                for (i in 0..10) {
                    println("normal launch $i ${Thread.currentThread().name} #####")
                    delay(100)
                }
            }
        }

        delay(300); // 延迟一会,让第二个协程能执行3次左右

        job.cancel() // 将外层任务取消了

        delay(2000) // 继续延迟,期望看到GlobalScope能继续运行
        
    }
}

看看实验结果

GlobalScope 0 DefaultDispatcher-worker-2 -----
normal launch 0 DefaultDispatcher-worker-5 #####
GlobalScope 1 DefaultDispatcher-worker-5 -----
normal launch 1 DefaultDispatcher-worker-1 #####
GlobalScope 2 DefaultDispatcher-worker-5 -----
normal launch 2 DefaultDispatcher-worker-3 #####
GlobalScope 3 DefaultDispatcher-worker-7 -----
GlobalScope 4 DefaultDispatcher-worker-8 -----
GlobalScope 5 DefaultDispatcher-worker-8 -----
GlobalScope 6 DefaultDispatcher-worker-7 -----
GlobalScope 7 DefaultDispatcher-worker-1 -----
GlobalScope 8 DefaultDispatcher-worker-3 -----
GlobalScope 9 DefaultDispatcher-worker-9 -----
GlobalScope 10 DefaultDispatcher-worker-5 -----

如我的预料一样,GlobalScope无法被cancel。

再来看一下文档里面怎么描述的,体会一下:

Global scope is used to launch top-level coroutines which are operating on the whole application lifetime
and are not cancelled prematurely.

接下来,解释一下上面提到的协程作用域的概念。

什么是协程作用域(Coroutine Scope)?

协程作用域是协程运行的作用范围,换句话说,如果这个作用域销毁了,那么里面的协程也随之失效。就好比变量的作用域。

{ // scope start
    int a = 100;
} // scope end
println(a); // what is a?

协程作用域也是这样一个作用,可以用来确保里面的协程都有一个作用域的限制。

一个经典的示例就是,比如我们要在Android上使用协程,但是我们不希望Activity销毁了,我的协程还在悄咪咪的干一些事情,我希望它能停止掉。

我们就可以

class MyActivity : AppCompatActivity(), CoroutineScope by MainScope() {
    // ....
}

这样,里面运行的协程就会随着Activity的销毁而销毁。

launch的返回值:Job

回到launch的话题,launch启动后,会返回一个Job对象,表示这个启动的协程,我们可以方便的通过这个Job对象,取消,等待这个协程。

像这样:

fun main() {

    runBlocking(Dispatchers.IO) {

        val job1 = launch {
            for (i in 0..10) {
                println("normal launch $i ${Thread.currentThread().name} #####")
                delay(100)
            }
        }

        val job2 = launch {
            for (i in 0..10) {
                println("normal launch $i ${Thread.currentThread().name} -----")
                delay(100)
            }
        }

        job1.join()
        job2.join()

        println("all job finished")
    }
}

使用job的join方法,来等待这个协程执行完毕。这个和Thread的join方法语义一样。

async:启动协程的另一种姿势

launch启动一个协程后,会返回一个Job对象,这个Job对象不含有任何数据,它只是表示启动的协程本身,我们可以通过这个Job对象来对协程进行控制。

假设这样一种场景,我需要同时启动两个协程来搞点事,然后它们分别都会计算出一个Int值,当两个协程都做完了之后,我需要将这两个Int值加在一起并输出。

如果使用launch,我们可能要在外层建立一个变量来记录协程的输出数据了,但是使用async,就可以轻松的解决这个问题!

async的返回值依然是个Job对象,但它可以带上返回值。

上面的小需求可以用下面的代码实现:

fun main() {

    runBlocking(Dispatchers.IO) {

        val job1 = async {
            for (i in 0..10) {
                println("normal launch $i ${Thread.currentThread().name} #####")
                delay(100)
            }
            10 // 注意这里的返回值
        }

        val job2 = async {
            for (i in 0..10) {
                println("normal launch $i ${Thread.currentThread().name} -----")
                delay(100)
            }
            20 // 注意这里的返回值
        }

        println(job1.await() + job2.await())

        println("all job finished")
    }
}

这里使用了await方法来获取返回值,它会等待协程执行完毕,并将返回值吐出来。

这样上面的代码就是两个协程自己吭哧吭哧弄完之后,各自返回了10和20,外层再将它们加起来。

总结

这篇文章,我大概的讲了一下协程的概念和被发明的初衷,以及在kotlin中,启动协程的基本方法,最后再总结一下,方便快速复习。

进程是一个应用程序的资源管理单元,线程是一个执行单元,但当线程这个执行单元需要切换状态,停止,启动,或者大量启动的时候,就会比较消耗资源。我们需要一个更轻巧,更容易被控制的执行单元,这就是协程啦。

本篇介绍了runBlocking方法,它可以在非协程作用域下创建一个协程作用域,它的名字也很好,阻塞的执行,意味着,它会阻塞它的调用线程,直到它内部都执行完毕。

launch和async都可以在协程作用域下启动协程,launch以Job对象的形式返回协程任务本身,可以通过Job来操作协程,async以Deferred对象的形式返回协程任务,可以获取执行流的返回值。

GlobalScope.launch会创建一个顶层的协程,它只受限于整个应用的生命周期,不建议使用。


如果你喜欢这篇文章,欢迎点赞评论打赏
更多干货内容,欢迎关注我的公众号:好奇码农君
Kotlin协程教程(1):启动