Kotlin 协程基础

1,891 阅读11分钟

协程的历史

早期计算机只有一个CPU,同一时刻只能执行一个任务,为了能在单核心机器上并发执行多个任务,衍生出了两种技术:

  • 协作式多任务: 任务自己控制执行,执行一段时间后主动让出执行权,使其它任务有机会执行。这种任务调度方式称为非抢占式调度
  • 抢占式多任务: 由操作系统监控、调度所有的任务,公平分配执行时间片给每个任务,当一个任务所分配的时间片用完以后,操作系统会强制停止它,保存执行现场,把执行权安排给另一个任务。与非抢占式调度对应,这被称为抢占式调度

从上述概念我们能发现:协作式多任务高度依赖开发者的自觉性,需要每个人都为他人考虑。这看起来似乎很美好,然而,设计经验告诉我们,依赖外部事物往往是不稳定的。如果有人故意不遵守这个规则,或者因为出现错误导致调度失败,那么所有的美好都将被打破。尤其是在进程调度这种比较重要的场合,你肯定不想因为自己不小心写了个死循环,整个电脑都没法使用吧。因此当前主流的操作系统都选择了抢占方式实现来进程调度,而线程作为进程的衍生,也自然的选择了抢占式实现调度。

虽然抢占式调度方式占据了主流,但它也存在一些弊端:因为任务的执行权会被强行剥夺,因此若数据或资源被多个任务共享时,正确性将无法得到保障(比如脏读脏写)。所以抢占式调度的任务在使用共享资源时需要采取一些手段以保证并发安全性(比如加锁)。另外抢占式多任务需要操作系统来执行调度,这又增加了系统内核态与用户态之间切换的开销(过多的内核调用会引发C10K/C10M问题)。

在进程调度中,这些问题尚可忍受,因为进程之间需要共享的数据的场景并不多,更因为需要保证系统的稳定性,强制剥夺执行权是极其必要的。然而,在同一个进程中,代码通常是由同一个团队编写的,任务都处于团队的控制之中,谁捣乱谁有问题都很容易被“处理掉”,再加上应用内需要进行数据共享的场景很多,此时抢占式带来的好处便没那么明显了,反而因抢夺执行权、内核调用等带来的问题越发突出。

在这种背景下,协作式调度的好处就凸显出来了:因为任务自己控制调度,无需通过内核,节省了内核调用的系统开销。也因为任务自己控制调度,在数据未被处理完之前其它任务不会得到执行,也就没有脏读写等并发问题了,提高了执行效率。

协程就是协作式多任务的一种实现,早期在操作系统上用做多任务调度;之后人们为了避免某个程序一直霸占CPU,又研究了抢占式调度的进程/线程来替代了它;再后来又因为抢占式调度的线程比较耗费资源,为了避免大量使用线程,再次引入了协程配合线程一起解决并发问题。

也有部分语言,不支持多线程,但是为了实现并发执行任务,也引入了协程。

协程到底是什么

协程并没有一个明确的定义,因为随着计算机技术的不断发展,协程本身也在不断发生着变化,不过我们可以从上面总结出一些关键词:”多任务并发“、”协作执行“、”自己控制调度“、”在用户态实现“。这些,基本就是协程在当前这个阶段的全部特征了。然后,笔者在这里给出自己对于协程的定义:

协程是在用户空间上实现的可以自己控制多任务调度(挂起、恢复)的程序。

  • 示例

    -- yield挂起,resume恢复
    co2 = coroutine.create(
        function()
            for i=1,10 do
                print(i)
                coroutine.yield()
            end
        end
    )
     
    coroutine.resume(co2) --1
    coroutine.resume(co2) --2
    coroutine.resume(co2) --3
    

协程的分类

  • 按调用栈
    • 有栈协程:每个协程会分配单独的调用栈,挂起点的状态保存在调用栈中,类似线程的调用栈。(能在函数嵌套中挂起)
    • 无栈协程:不会分配单独的调用栈,挂起点的状态通过闭包或者对象保存。(Kotlin的协程是一种无栈协程,但是通过编译器的“魔法”,它可以嵌套suspend函数并在其中挂起)
  • 按调用关系
    • 对称协程:调度权可以转移给任意协程,协程之间是对等关系。
    • 非对称协程:调用者与非调用者的关系是固定的,被调用者运行完毕后只能返回到调用者,而不能返回到其他协程。

协程的作用

  • 协程可以更加精细、自由的控制异步任务的调度。
  • 协程可以降低异步程序的复杂度。
  • 协程可以让异步代码同步化。(这点可能只是Kotlin协程的好处😀)

Kotlin协程的基本要素

Kotlin的协程分为kotlin标准库stdlib中提供的基本底层API(kotlin.coroutines)以及Kotlin官方协程库kotlinx.coroutines中提供的协程框架两部分,本篇仅探讨协程基本底层API的使用及其原理。

挂起函数

挂起函数用于在协程中抽离与封装代码块,使用suspend关键字修饰普通函数可将其转化为挂起函数。挂起函数只能在其它挂起函数或者协程中调用。

Kotlin协程非常巧妙的将协程的挂起与恢复与挂起函数结合到了一起,调用挂起函数时意味着主调用协程被”挂起“,挂起函数返回结果时意味着主调用协程被”恢复“。

具体是怎么挂起与恢复的?

先来看一下使用线程执行异步任务是如何挂起与恢复的:

fun getUser(
    name: String, 
    onResult: (User) -> Unit, 
    onError: (Throwable) -> Unit
) { 
    thread {
	try {
            // do something
            handler.post { onResult(user) }
	} catch(e: Throwable) {
            handler.post { onError(e) }
        }
    }
}

getUser("zhangsan", { user -> setUser(user) }, { err -> loge(err) })

上面的代码我们已经很熟悉了,当我们调用getUser后,该方法创建了一个新的线程开始执行异步任务,紧接着getUser就返回了,对于后续的setUser,loge等它们就处于一个被挂起的状态,需要等到异步任务执行完成后再恢复执行。

Kotlin协程的挂起与恢复也是同样的原理,只不过编译器在代码中做了一些手脚,把这些烦人的回调给消除了,从而使异步代码变得像同步代码一样清晰易懂:

suspend fun getUser(name: String): User {
    // doSomething
    return user
}

try {
   val user = getUser(name)
   setUser(user)
} catch (e: Throwable) {
   loge(e)
}

那么编译器做了什么魔法呢?这得从continuation说起:

public interface Continuation<in T> {
    public abstract val context: CoroutineContext
    public abstract fun resumeWith(result: Result<T>): Unit
}

fun <T> Continuation<T>.resume(value: T) 
    = resumeWith(Result.success(value))
fun <T> Continuation<T>.resumeWithException(t: Throwable)
    = resumeWith(Result.failure(t))

Kotlin中使用Continuation来实现协程的恢复,在被调用协程中通过调用主调用协程传入的continuation的resumeWith()来实现协程的恢复,参数则是协程要返回的结果/执行中出现的错误(的封装—Result)。发现没有,这玩意儿不就是Callback么。

然后我们来看看编译器做了什么:

suspend fun getUser(name: String): User {
    // ...
    val user = getUserAsync(name)
    // ...
    return user
}
// ==> 伪代码
fun getUser(name: String, completion: Continuation<User>): Any {
    val continuation = object : ContinuationImpl(completion) {
         var label = 0;
         // ...
    }
    // ...
    val result = getUserAsync(name, continuation)
    if (result == COROUTINE_SUSPENDED) {
        return COROUTINE_SUSPENDED
    }
    // ...
}

fun getUserAsync(name: String, completion: Continuation<User>): Any {
    // execute the task asynchronously
      completion.resumeWith(...)
    // ...
    return COROUTINE_SUSPENDED
}

对于挂起函数,经过编译器编译后,它的参数表后面会多出一个Continuation,主调用协程的continuation就会通过这个参数传递进来,然后用ContinuationImpl包装一下,保存自己的调用状态等数据。如果挂起函数中还有其它的挂起函数,则会将这个新的Continuation作为参数传递给它,如此层层递归,直至遇到真正的异步逻辑,再调用resumeWith来返回异步执行的结果。

返回COROUTINE_SUSPENDED则表明这个调用开始执行异步任务来,结果不会立即返回,也就是协程被挂起了。

思考:调用挂起函数一定会被挂起吗?

那么如何获取到这个continuation?

我们可以通过suspendCoroutine函数获取挂起函数的Continuation,进而通过此对象实现挂起函数的恢复。下面是使用continuation将回调转换为挂起函数的例子:

suspend fun getUser(name: String) = suspendCoroutine<User> { continuation ->
    api.getUser(name).enqueue(object : Callback<User> {
        override fun onFailure(call: Call<User>, t: Throwable) =
            continuation.resumeWithException(t)

        override fun onResponse(call: Call<User>, response: Response<User>) =
            response.takeIf { it.isSuccessful }
                    ?.body()
                    ?.let(continuation::resume)
                    ?: continuation.resumeWithException(HttpException(response))
    })
}

如何从挂起点恢复执行?

为了回答这个问题,我们先用标准库的createCoroutine函数手动创建一个协程并运行它:

fun <T> (suspend () -> T).createCoroutine(
    completion: Continuation<T>
): Continuation<Unit>

createCoroutine函数可以为一个挂起函数创建协程,该函数接收一个Continuation用于在这个挂起函数执行完成后返回结果,并且返回一个Continuation<Unit>作为创建出来的协程的载体,可以通过调用它的resumewith函数来间接的启动协程。

suspend fun requestUserData(): String {
    println("Start get user")
    log.d(getUser()) // suspend
    println("Start get avatar")
    show(getAvatar()) // suspend
    return "success"
}

fun main() {
    ::requestUserData.createCoroutine(
        // 这个continuation用来接收requestUserData返回的结果
        object : Continuation<String> {
            override val context = EmptyCoroutineContext
            override fun resumeWith(result: Result<String>) {
                // result中封装了requestUserData的执行结果。
            }
        }
    ).resume(Unit) // 启动协程
}

同样的,上面的代码将被编译器转换:

// 伪代码
public static final void main() {
    Continuation con = ContinuationKt.createCoroutine(
        new Function1() {
            public final Object invoke(Continuation continuation) {
                // (2). 此处的continuation可以近似看作是我们构造并传入的completion.
                return TeKt.requestUserData(continuation);
            }
        },
        new Continuation() {
            ...
        });
     // (1). resumeWith将间接的调用Function1的invoke,从而开始requestUserData的执行。
     con.resumeWith(Unit.INSTANCE);
}

public static final Object requestUserData(Continuation completion) {
    Object continuation;
    label0: {
        if (completion instanceof XXX) {
            continuation = completion;
            if (xxx) {
                break label0;
            }
        }
        // (3). 使用外层传入的completion构造属于此挂起函数的continuation
        continuation = new ContinuationImpl(completion) {
            Object result; // 保存从子调用协程中返回的结果
            int label; // 记录执行的位置

            public final Object invokeSuspend(Object result) {
               this.result = result;
               this.label |= Integer.MIN_VALUE;
               return requestUserData(this);
            }
         }
    }
    label1: {
        // (4). 获取从子调用协程中返回的结果,如果是第一次执行,则可以作为协程的初始化参数
        Object result = continuation.result;
        switch (continuation.label) {
            case 0: {
                ResultKt.throwOnFailure($result);
                println("Start get user");
                // (5). 将continuation传入getUser,当getUser里的代码执行完毕后
                // 通过调用该continuation的resumeWith间接的调用invokeSuspend
                // 重新执行requestUserData,但是此时continuation的label已经发生了变化。
                //  // 这里返回的result是COROUTINE_SUSPENDED挂起标志,真正的执行结果
                //  // 通过resumeWith设置到传入的continuation中
                result = getUser(this);
                label++;
                if (isSuspended(result)) return;
            }
            case 1: {
                ResultKt.throwOnFailure($result);
                log.d(result);
                println("Start get avatar");
                result = getAvatar(this);
                label++;
                if (isSuspended(result)) return;
            }
            case 3: {
                ResultKt.throwOnFailure($result);
                show(result);   
            }
        }
	return "success";
    }
}

private boolean isSuspended(Object o) {
    return o == IntrinsicsKt.getCOROUTINE_SUSPENDED();
}

协程上下文

Continuation的对象中的context: CoroutineContext是协程的上下文,它里面存储着协程执行过程中需要的环境、数据等。

**特殊的协程上下文元素—拦截器:**拦截器可以对协程上下文所在协程的Continuation进行拦截。通过拦截器可以实现对协程运行环境的更改,如切换线程等。

interface ContinuationInterceptor : CoroutineContext.Element {
    fun <T> interceptContinuation(
        continuation: Continuation<T>
    ): Continuation<T>
}

在上面的例子中,若协程上下文中存在拦截器,拦截器就会拦截挂起函数生成的Continuation,并将返回的新的Continuation返回给SafeContinuation

切换线程的实现原理:

open class DispatcherContext(
    private val dispatcher: Dispatcher = DefaultDispatcher
) : AbstractCoroutineContextElement(ContinuationInterceptor),
    ContinuationInterceptor
{
    override fun <T> interceptContinuation(continuation: Continuation<T>): Continuation<T>
            // 返回的DispatchedContinuation将作为原来continuation的代理
            // 调用resumeWith时在新的线程中调用原来continuation的resumeWith
            // 就实现了线程切换。
            = DispatchedContinuation(continuation, dispatcher)
}

private class DispatchedContinuation<T>(
    val delegate: Continuation<T>,
    val dispatcher: Dispatcher
) : Continuation<T> {
    override val context= delegate.context

    override fun resumeWith(result: Result<T>) {
        dispatcher.dispatch {
            delegate.resumeWith(result)
        }
    }
}

结语

至此我们简单的了解了kotlin协程的基本要素及其原理,利用这些基本元素,我们就能实现大部分现有语言中的协程用法(如果你感兴趣的话可以了解bennyhuo的kotlin教程,或者查看我学习的实例代码)。当然,在实际开发过程中,我们通常不会直接使用kotlin协程的基本API,而是使用更加方便的官方协程框架kotlinx.coroutines,关于官方框架的使用,将在后续文章中介绍。

参考