阅读 1854

聊聊协程的发展历程

前言

本文讲的协程主要以kotlin为主,同时可能参考python,go,但是会尽量避免使用代码,而是尝试用通俗的语言来聊协程的发展历程,尽量保证大家都能理解。

近些年,一些编程语言的新贵Go和Kotlin纷纷引入了协程这个语言特性,使得协程这个似乎十分陌生的概念开始频繁进入大家的视野,为了便于理解,开发者们都把它当作线程的小弟来对待,即轻量级线程。可是真要细说起来,协程其实是很早就出现的一个编程概念,它的出现甚至是是早于线程的,但是就编程语言的江湖地位而言,协程是不如线程的,所以向线程低头叫爸爸不奇怪。

看了我上面的介绍,大家一定很纳闷,你说协程出现早,有资历,那为啥几十年的编程语言发展下来就就混成了这副咸鱼样?线程出现晚,但怎么就一棵星星之火点着了编程语言的草原。成为了编程语言中的重要概念呢??再者,协程几十年的咸鱼一条,到如今怎么突然有了梦想,翻身把歌唱的呢?

今天就和大家一起来梳理一下协程的整个发展历程,希望能帮助大家更加理解协程。

协程的出现

咱们先来说说协程的历史,以及它是怎么混的这么惨的,毕竟悲催的人生都需要一个解释。

协程最早诞生于1958年,被应用于汇编语言中(距今已有60多年了),对它的完整定义发表于1963 年,协程是一种通过代码执行的恢复与暂停来实现协作式的多任务的程序组件

而与此同时,线程的出现则要晚一些,伴随着操作系统的出现,线程大概在1967年被提出。线程作为由操作系统调度最小执行组件,主要用于实现抢占式的多任务。

既然大家都搞多任务,按说谁也不能比谁强多少啊,况且协程还早生几年,理论上通过自身努力发展,在编程语言中占据核心地位是极有可能的。

但是这协程的发展啊,一方面当然要靠自我奋斗,另一方面,也要考虑历史进程。而上个世纪七八九十年代,是计算机疯狂朝着小型化和个人化的方向演进的时代,计算机非常依赖操作系统来提供用户交互和压榨CPU的最大性能,而操作系统怎么来压榨计算机性能的呢?靠多线程。操作系统跟随个人计算机的普及之后,编程语言自然也开始依赖操作系统提供的接口来驾驭计算机了,线程成了几乎所有编程语言跳不过的一个重要概念,并一直延续至今。

到这里你可能要问了,大家都是搞多任务的,为什么线程能提升cpu的资源利用率,协程不能呢?

当然有很多其他的原因能解释,但最本质的原因仍然是协程和线程是有显著区别的两个概念,到这里我们就要回过头来聊聊什么叫协作式多任务,什么叫抢占式多任务?以及这两种任务是否是同一种概念?

  • 协作式多任务:

上图是一个寿司生产的部分工序,我们可以把图中的传送转盘和机器抓手可视作两个任务,一起协作完成了食物的生产。这就是协作式多任务。协作式的多任务要求任务之间相互熟悉,才能实现协作。

  • 抢占式多任务:

喂金鱼的场景,一把饲料下去,所有金鱼马上围上来一抢而空,这里每个金鱼都相当于一个任务线程,这就是抢占式多任务。而抢占式多任务(线程)之间不需要了解和配合,只有竞争关系。

上面两张图,比较生动的展示了协作式多任务(协程)和抢占式多任务(多线程)之间的区别。我们能够发现,协程更加适合哪些相互熟悉的任务组件通过密切配合协作完成某些工作,协作式多任务里的“任务”是一种子程序(可称为函数)。抢占式多任务里的任务则是指能抢占资源的组件或代码(其实就是线程),这里的多任务也就是多线程。所以说,协程和线程本来是差异非常大的两种概念,他们的能力是不同的,而线程的这种能力正好迎合了那个时代的需求。自我奋斗+历史进程是线程成功的主要原因。

当然,在另一方面,也由于协程是基于编程语言层面的一种概念,它并没有统一定义的接口,因此在不同的语言中实现后的效果是不同的,这也会对开发者造成极大的困扰,不利于它的推广。而反观线程,通过操作系统的统一接口,定义了大体相同的线程使用方式,保证了不同的编程语言都对线程的使用是大体一致。

讲到这里,我们来总结下协程早期发展不顺的原因

  • 1,协程没有代表先进生产力的发展要求,先进文化的前进方向,和最广开发者的根本利益[手动狗头]。
  • 2,协程在不同编程语言中,它的实际表现有差异,非常不利于开发者的理解和使用。

以上两点,就是协程几十年以来一直不温不火的原因。我们也看到,虽然看起来都在搞多任务,但是协程和线程实际是没有太多交集的。

咸鱼翻身

虽说协程这种协作式多任务的组件不能提高程序执行的效率,似乎没有太广泛的应用前景,但这协程呐,也不能随意否定自己,因为不知道什么时候,你就突然被历史进程给关照了。

还是从线程说起,虽然线程成为编程世界的重要概念,但是在多年的使用过程中开发者们也逐渐意识到了它的痛点:

  • 线程之间(异步代码)难以交互难度比较大,往往只能用callback,大量的callback会代码难以阅读和理解,最终让项目变得难以维护。

简单说就是在开发者端,线程之间如何更方便的交互

而这里协程能做什么呢?

或许我再重新表达一下线程的痛点:在开发者端,线程之间如何更方便的协作

回想一下,我们是怎么介绍协程的?协作式多任务对吧,还记得上图中的转盘和机器抓手的协作么?我们当时说这两个任务更像是两个函数的协作,但如果把转盘和机器抓手视作两个线程呢?借助编译器,把线程封装成一个个能暂停和恢复的函数,线程是不是就可以像协程设计的那样协作呢?

我们还是从代码层面来看看如今协程是如何被使用的吧。设计一个简单的需求:社区内用户进行发帖时,需要先从后台验证发帖权限,请求两个接口,那么可能我们需要尝试开启两个线程先后来完成。

  • 普通的callback代码:

fun tryPost(){
    // 先通过接口验证权限 (实际开启了一个线程,然后等待回调)
    findUserPermission(user,callback()){
        public void onSuccess(UserPermission response){
            // 回调 如果成功 ,检查是否有权限
            if(response.hasPermission){
            // 如果有权限,则访问发帖接口 (同样开启线程,等待回调)
                postContent(content,callback()){ 
                    public void onSuccess(Result response){
                        // handle successful response
                    }
                    public void onFail(){
                        
                    }
                }
            }else{
                // 如果无权限,则......
            }
        }
        
        public void onFail(){
            
        }
    }
}
复制代码

这是比较常见的做法,我们需要访问两次接口,而交互只能在callback中进行,但是其实代码已经很难看了,如果还有其他的逻辑的话,那代码只会更加冗杂,难以维护。

那协程是怎么解决这种痛点的呢?我们看看(kotlin和python)协程的代码如何实现这种需求:

  • kotlin的协程代码
// 函数通过suspend关键字标识,可以被协程调用,具备暂停恢复的能力 ,实际上仍然使用了io线程来完成接口请求
suspend fun tryfindUserPermission():PermissionResponse {
    return withContext(Dispatchers.IO){
                findUserPermission(user)
        }
}

// 函数通过suspend关键字标识,可以被协程调用
suspend fun post():Result {
    return  withContext(Dispatchers.IO) {
                postContent(content)
            }
}
    
fun tryPost(){
    //启动一个协程
     launch{
     //代码执行到这一行,让出cpu,进入暂停状态,等待请求成功之后,会恢复执行)
        var response = tryfindUserPermission()    // 向后端访问用户权限 
        if(response.hasPermission){
        // 有权限则开始发帖 (开启线程,让出cpu,暂停执行,等待恢复)
            var response =  post()
             // handle response if need
        }
     }
 }
复制代码

可以看到,在kotlin中,协程通过把线程里的代码封装成一种能暂停/恢复的函数,让多线程之间的交互就像普通的函数一样简单,不需要callback。

  • python的协程代码
import asyncio

// async 的关键字,表明这个函数可以被协程调用
async def findUserPermission():
    // handle http request
    ...
    ...
    return response
async def postContent():
    // handle http request
    ...
    ...
    return response
    
    
async def main():
// 尝试获取权限信息 同样会让出cpu,进入暂停状态,等待恢复
    reponse = await findUserPermission()
    // 判断有权限的情况下,进行发帖
    if response.hasPermission :
        res = await postContent()
        // handle response if need

asyncio.run(main())

复制代码

python通过协程处理这种问题本质和kotlin是一致的。

相信大家也能看到,协程在不同的语言中的表现方式是有差异的

通过上面几段伪代码,我们能够比较清楚看到,协程能非常明显的简化了线程之间协作复杂度,让我们可以以编写同步代码的方式来编写异步代码,极大的简化你的逻辑,让你的代码容易维护。

那么协程是如何做到的呢?

虽然不同的语言中,协程有所差异,但是原理都差不多,编程语言的编译器通过一些关键字(kotlin中用suspend,python中用async等)来修饰函数,在编译期间根据关键字生成一些线程相关的代码来实现函数的暂停恢复的功能,从而实现把线程相关的代码留在编译期间产生,在开发层面就能提供像普通函数一般的协作方式。

因为解决了这个痛点,协程开始变得越来越受开发者欢迎。而协程通过编译器的帮助把线程相关的代码留在了编译期间产生,开发这可以通过操作协程就可以达到使用线程的目的,所以现在大家认为协程是一种轻量级的线程。

对于多线程的协作,或者说异步代码之间的协作并不是只有协程一家解决方案,在JS中,有promise,Java中有RxJava等等,他们都致力于解决异步编程的相关问题,希望能以编写同步代码的方式来写异步代码,目前来看,他们都做的很不错。

总结

大家对于协程的理解有很多分歧,但是对我而言,协程其实得分两个阶段来理解

  • 在协程诞生之初,只是用来解决编程中的某些特殊问题的编程组件,它的多任务更像多个函数的组合协作执行,那个时候,协程其实更像是一种具备暂停恢复的函数。但是这种功能似乎并不受欢迎,因此协程在很长一段时间内都是比较小众的。(此时协程和线程关系并不大)
  • 如今它成为底层支持多线程的协作式多任务组件,很好的解决了线程协作的痛点,同时也逐渐变得越来越受欢迎,协程和线程的关系更加亲密,它们似乎也变得更加相似。(如今你可以把协程视作一种轻量级线程)

而协程的发展历程,其实也就是经历了这两个阶段。