Kotlin协程的一些便于理解的类比

1,087 阅读6分钟

我在2021年8、9月份第一次开始看协程,被各种概念搞得不胜其烦,一头雾水,就看不下去,放弃了。2021年12月底,又开始看,这一次因为想通几个概念,忽然能看懂了。 在跨年夜做梦又把这些想通的概念串了一遍,在梦中决定把它记录下来。

怎样理解协程是个什么东西?

以前看过一些文章,说协程是轻量级的线程,以后,把这个说法扔掉。不管协程的定义是什么,至少在Kotlin/Android里,不要想这句话。

Android开发者网站说:“协程是一种并发设计模式,您可以在 Android 平台上使用它来简化异步执行的代码。” 我用非常通俗的方式来解释这句话:

  1. 无论如何,Kotlin协程的代码最终都是运行在线程上,即便是 Dispatchers.IO/Default 加个参数后线程就变了,它也最终是运行在线程上。只不过是Kotlin自己悄咪咪得开启了线程池。

  2. Kotlin协程的作用就是:把异步代码变成同步代码的形式。目标很明确,至于怎么实现,先别管。

  3. 既然都是在线程中运行,那么,什么样的东西能在线程里运行,这东西还必须是能够抽象成类,因为代码千差万别,Kotlin协程作为一个框架,它搭建了一个架构,定义一个接口,然后把猿的码变成接口的实现。在Java里,只有Runnable满足此条件。线程池可以execute(Runnable), Android.Handler可以post(Runnable)。在理解CPS转换时,可以用Runnable的方式去理解,会轻松很多。

  4. 有些人会想,协程的线程同步怎么处理呢?以我现在的理解是:这就要再次强调,协程不是轻量级线程,不是线程,它只是一种管理线程的方式,就跟Java线程池不是线程而是线程管理方式类似,你线程之间的同步是你线程的事,跟线程池和协程没关系。如[3]据说,不管是线程池还是协程,最终都是线程调用Runnable里的方法,同步操作就在这些方法里做喽。后来有看到协程自己有Mutex,还没搞懂怎么用,不过我认为我的理解从原理上来看没有问题。

怎么理解作用域--结构化并发,上下文?

异步代码要变同步形式。既然要有一个和原有的代码运行方式不同的新方式,那么写法必定与原有方式有区别。我怎么知道你哪些代码保持原有,哪些代码用新方式?你得给我画个框,在这个框里,你Kotlin想干啥就干啥,这是你Kotlin的特区,出了这个框,就按原有的正常方式。

这个特区,起个名字,就叫 : CoroutineScope 协程作用域。

打个比方:有一家网吧,原来所有客户都是直接跟网管交互,充钱啦,买泡面啦……现在,我跟网管说,我建了个战队,有5个人,要开5台机做某个游戏的任务,你没事别过来烦我们。现在,我划分了一个Scope出来随便我做什么。

那么,结构化并发来了,它的意思打个比方:我的充值用完了,网管让我们下机,那我这个队长就要跟队员说,时间到了,我们走吧,都赶紧把任务停掉。Scope.cancel()时,它的子任务也全部要停掉。

Scope只是标识个区间,这个区间的持续时间可长可短,它可以长到比如我是网吧老板,除非网吧倒闭,否则没人能把我的战队电脑给关掉,也可以短一点比如我是开个通宵,晚10点到早6点,到点就停,也可以短到只开一个小时。它对应的就是GlobeScope, viewModelScope, lifecycleScope。

我就是这个上下文。

Scope它只是一个范围,战队管理的事还是要有具体的人来负责的,它要贯空始终,在Scope创建的同时也要创建出来,它就是 CoroutineContext 协程上下文。

Context最常做的事就是切换线程,可以把它理解为战队队员来申请用他自己喜欢的方式去完成某个任务,这样,withContext()的概念也出来了:

// 比如打CS,默认大家都用AK,某人过来申请说这局他想用大狙,用代码模拟就是
scope.launch (大家都用AK) {
    队员A.开枪()
    队员B.开枪()
    withContext(队员C说他想用大狙) {
        队员C.开枪()
    }
    队员D.开枪()
    队员E.开枪()
}

怎么理解CPS?

异步代码变同步形式的实现,就是Kotlin编译器对suspend函数做CPS变换。如果觉得这个东西很难理解,就把它往Runnable上想。

假设 前面的AK是主线程,大狙是IO线程,队员C的前一行还是主线程,这一行就变成了IO线程,下一行又变成了主线程,它这样在线程间切来切去的,而且还是串行的,我们先不管什么CPS和Continuation,直接把它翻译成可能的伪代码:

Handler.postRunnable {
    队员A.开枪()
    队员B.开枪()
    IO线程池.executeRunnable {
        队员C.开枪()
        Handler.postRunnable {
            队员D.开枪()
            队员E.开枪()
        }
    }
}

CPS实际干的事,跟这个差不多,只是它用了另外一种方式,用一个Continuation+状态机来把嵌套给铺平。我也没很认真得看CPS后的代码,理解个大概意思就行。

Continuation {
    int state = 0; // 当前状态, 每个Runnable执行完成后更新状态
    
    Handler.Runnable r1 = {
        // 因为这俩没切线程, 就用一个Runnable去做
        队员A.开枪()
        队员B.开枪()
        state = 1;
        我做完了();
    }
    
    IO.Runnable r2 =  {
        // IO线程
        队员C.开枪()
        state = 2;
        我做完了();
    }
    
    Handler.Runnable r3 =  {
        // 因为这俩没切线程, 就用一个Runnable去做
        队员D.开枪()
        队员E.开枪()
        state = 3;
        我做完了();
    }
    
    void 我做完了() {
        when (state) :
        0 -> r1; // 完成后会改变state值, 再调一遍此方法, 就会进入r2
        1 -> r2; // 假设它是耗时的, 在耗时任务完成后改变state, 再调用一遍此方法, 进入r3
        2 -> r3; // 完成后改变state, 再进入此方法, 由下一状态结束任务
        3 -> 任务结束
    }
}

这个Continuation同样是在Scope创建时,跟Context一起创建的,整个Scope里使用同一个Continutation对象(虽然有其他包装,但不影响理解),CPS对Scope里的suspend方法添加的参数都是这一个,它也必须是同一个,因为它要维护state,线程跳来跳去,全靠这个state。

就这样吧

用这些比喻,应该会方便大家理解协程吧。

还有Job,异常处理机制,我还没什么头绪,等我捋清楚了,再看有没有什么 不那么严谨但容易理解 的方式来解释。