Kotlin协程入门

1、概述

最开始准备学习协程的时候,网上铺天盖地的文章都在宣传“Kotlin协程是一种轻量级的线程”,因为官方确实也是这么说的。我非常疑惑,因为从语文的角度分析,去掉定语之后,就是“协程是线程”。既然协程是线程,那么线程是变成协程之后,怎么就轻量级了呢,是占用的资源少了?学完之后发现,其实协程的本质是个异步框架,只是与RxJava等其他异步框架不同的是,它是语法级别的异步框架,也可以说是一个更方便的线程API集合。用不用协程对于资源开销来说是没什么区别的,与使用线程池相关API相比也没有明显的效率上的区别,所以“轻量级”不知从何谈起。
那么协程跟线程到底有什么关系呢?首先相似的地方是使用线程的时候我们会说“启动一个线程去执行任务”,使用协程的时候我们也会说“启动一个协程去执行任务”,它们可以说都是执行任务的一种载体。不同的地方是,启动线程执行任务是任务会在启动的线程执行,如果任务执行过程中需要执行类似更新UI等操作我们需要手动地去切换线程;而启动协程执行任务,这个任务可能会跨越多个线程,线程切换的过程几乎是框架自动帮我们完成的。
其实所有的异步框架都试图解决两个问题:

  1. 臭名昭著的“回调地狱”
  2. 简化线程调度

相比于最原始的异步写法,RxJava也在一定程度上解决了这两个问题。RxJava仍有类似回调的东西,不过它只有一层而不是一层一层又一层。而协程更为彻底和简洁,它允许我们通过类似写同步代码的方式去写异步的代码。

2、协程的简单使用

看如下示例

import kotlinx.coroutines.*
import java.util.*

fun main() {
    GlobalScope.launch {
        // 通过IO线程进行IO操作并获取操作的字节数
        var bytes = doIO()
        // 当前线程展示结果
        println("Show result at ${Thread.currentThread()}, IO bytes: $bytes")

        // 通过IO线程进行IO操作并获取操作的字节数
        bytes = doIO()
        // 当前线程展示结果
        println("Show result at ${Thread.currentThread()}, IO bytes: $bytes")

        // 通过IO线程进行IO操作并获取操作的字节数
        bytes = doIO()
        // 当前线程展示结果
        println("Show result at ${Thread.currentThread()}, IO bytes: $bytes")
    }

    println("Main thread is going on...")

    Thread.sleep(4000) // 等待操作结束
}

suspend fun doIO() = withContext(Dispatchers.IO) {
    delay(1000) // 模拟IO操作
    val bytes = Random().nextInt(10000) // 假设这是io字节数
    println("Do IO at ${Thread.currentThread()},bytes:$bytes")
    bytes
}

输出:

Main thread is going on...
Do IO at Thread[DefaultDispatcher-worker-1,5,main],bytes:9482
Show result at Thread[DefaultDispatcher-worker-1,5,main], IO bytes: 9482
Do IO at Thread[DefaultDispatcher-worker-1,5,main],bytes:7627
Show result at Thread[DefaultDispatcher-worker-1,5,main], IO bytes: 7627
Do IO at Thread[DefaultDispatcher-worker-1,5,main],bytes:311
Show result at Thread[DefaultDispatcher-worker-1,5,main], IO bytes: 311

这个段代码进行了三次线程切换,请各位客官自行想象一下如果使用线程池API或者单开子线程的方式怎么才能比较优雅地实现呢?

再来解释一下,其中GlobalScope是协程的作用域,launch则是一个启动协程的函数。没错,协程是有作用域的,比如引入“lifecycle-viewmodel-ktx”后在ViewModel中,我们可以通过以下代码来启动一个协程

viewModelScope.launch { 
    // do sth
}

框架会在ViewModel被清理的时候自动帮我们清理未完成的任务,ViewModel里协程的作用域就是ViewModel存活的周期,全局作用域的协程除外。GlobalScope就是全局作用域,它伴随进程从启动到结束,由它启动的协程是不会被自动取消的,那么能不能手动取消呢?是可以的,launch方法其实是有返回值的

val job = GlobalScope.launch {
    ... // do sth
}

job.cancel() // 取消

另外注意到witContext方法是接收两个参数的

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

一个是CoroutineContext接口,另一个就是要执行的block了。我们上面传入的是Dispatchers .IO,它是CoroutineContext实现类的对象,在日常使用中其实我们可以简单地认为它的作用就是线程调度,看一下还有哪些取值

public actual object Dispatchers {
    
    @JvmStatic
    public actual val Default: CoroutineDispatcher = createDefaultDispatcher()

    @JvmStatic
    public actual val Main: MainCoroutineDispatcher get() = MainDispatcherLoader.dispatcher

    @JvmStatic
    public actual val Unconfined: CoroutineDispatcher = kotlinx.coroutines.Unconfined

    @JvmStatic
    public val IO: CoroutineDispatcher = DefaultScheduler.IO
}

值得一提的是launch也是可以指定CoroutineContext的

GlobalScope.launch(Dispatchers.Main) {
    //do sth
}

需要注意的是Dispatchers.Main需要依赖kotlinx-coroutines-android

3、非阻塞式和挂起

“非阻塞式”并不是一个新兴的名词,指的就是不阻塞当前线程,无论是通过协程还是通过线程承载耗时任务在工作线程里执行都是“非阻塞式”的,看如下示例

GlobalScope.launch(Dispatchers.IO) {
    delay(1000)
    println("Coroutine")
}

thread { 
    Thread.sleep(1000)
    print("Worker thread")
}

println("Main thread")

通过启动协程和启动线程来执行任务,对于主线程来说都是“非阻塞”的。所以阻塞与非阻塞是针对特定的线程来说的,耗时操作是必定会阻塞某一个线程的,只是我们开发中需要避免阻塞主线程。
而挂起,也是相对指定的线程而言的。比如最开始的例子

fun main() {
    GlobalScope.launch {
        // 通过IO线程进行IO操作并获取操作的字节数
        var bytes = doIO()
        // 当前线程展示结果
        println("Show result at ${Thread.currentThread()}, IO bytes: $bytes")
    }

    println("Main thread is going on...")

    Thread.sleep(4000) // 等待操作结束
}

suspend fun doIO() = withContext(Dispatchers.IO) {
    delay(1000) // 模拟IO操作
    val bytes = Random().nextInt(10000) // 假设这是io字节数
    println("Do IO at ${Thread.currentThread()},bytes:$bytes")
    bytes
}

协程在执行到doIO方法的时候,因为它是一个挂起函数,所以当前协程被当前线程挂起了,也就是当前线程暂时不会执行这个协程的代码了,直到挂起函数返回。那么当前线程挂起之后,协程发生了什么呢?协程切换到IO线程去做IO操作去了。也就是说协程被某一个线程挂起的时候,它是继续工作的,只是在另外一个线程。
被suspend关键字标记意味着这个函数是挂起函数,它只能在协程或者另外一个挂起函数中被调用,也就是说只能在协程里调用挂起函数。但是导致协程被挂起的并不是subspend关键字,而是kotlin内置的操作挂起的函数,比如上面的witchContext。subspend本身在我们使用的过程中只是起到一个提醒和语法检查的作用,这就导致了我们在普通函数里面调用被suspend关键字标记的函数时,Android Studio会给我们提示错位。

4、异步并发

开发过程中往往会有异步并发的需求,以2中的IO为例,假设我们需要进行三次IO,并且统计出IO操作的总字节数。如果使用线程的方式最简单的方法是在一个子线程里面串行地执行三次IO,最后把三次IO的结果累加;还有一个方法是并发地使用三个线程分别进行IO,然后每个IO操作执行完成之后,判断其他的IO操作是否已经完成,在最后一个IO操作完成的时候累加得出结果。前者是非常低效率的一种方式,因为它是串行的,后者又比较复杂,在对临界资源进行访问的时候要各种判断和加锁。如果使用协程,那问题将会变得非常简单:

fun main() {
    GlobalScope.launch() {
        val bytes1 = async(Dispatchers.IO) { doIO() }
        val bytes2 = async(Dispatchers.IO) { doIO() }
        val bytes3 = async(Dispatchers.IO) { doIO() }
        // 当前线程展示结果
        println("Show result at ${Thread.currentThread()}, IO bytes: ${bytes1.await() + bytes2.await() + bytes3.await()}")
    }

    Thread.sleep(4000) // 等待操作结束
}

suspend fun doIO(): Int {
    val random = Random()
    Thread.sleep(random.nextInt(2000).toLong()) // 模拟IO操作
    val bytes = random.nextInt(10000) // 假设这是io字节数
    println("Do IO at ${Thread.currentThread()}, IO bytes: $bytes")
    return bytes
}

输出:

Do IO at Thread[DefaultDispatcher-worker-4,5,main], IO bytes: 1230
Do IO at Thread[DefaultDispatcher-worker-2,5,main], IO bytes: 2243
Do IO at Thread[DefaultDispatcher-worker-3,5,main], IO bytes: 4261
Show result at Thread[DefaultDispatcher-worker-1,5,main], IO bytes: 7734

这种写法非常接近同步的写法,但它是异步的,三次IO分别在三个线程执行,结果却可以通过看似简单的相加得倒。

  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值