仓库源文站点原文


title: 关于协程的一些思考 cover: https://img.paulzzh.com/touhou/random?22 toc: true date: 2020-03-17 10:50:09 categories: 并发编程 tags: [并发编程]

description: 在Java开发中,并发编程是一个不可或缺的东西。甚至有不会并发就相当于不会编程的言论。传统并发模型中有进程和线程的概念,而熟悉Python的同学应该有过使用yield的经历,而yield关键字就使用了协程的概念~

在Java开发中,并发编程是一个不可或缺的东西。甚至有不会并发就相当于不会编程的言论。传统并发模型中有进程和线程的概念,而熟悉Python的同学应该有过使用yield的经历,而yield关键字就使用了协程的概念;并且在现在golang大火的环境下,golang中也通过协程来解决了并发编程的问题;

本文内容包括:

<br/>

<!--more-->

什么是协程

协程,又称微线程。英文名Coroutine

协程的概念很早就提出来了,但直到最近几年才在某些语言(如Lua)中得到广泛应用

子程序,或者称为函数,在所有语言中都是层级调用,比如A调用B,B在执行过程中又调用了C,C执行完毕返回,B执行完毕返回,最后是A执行完毕

所以子程序调用是通过栈实现的,一个线程就是执行一个子程序

子程序调用总是一个入口,一次返回,调用顺序是明确的,而协程的调用和子程序不同

协程看上去也是子程序,但执行过程中,在子程序内部可中断,然后转而执行别的子程序,在适当的时候再返回来接着执行

注意,在一个子程序中中断,去执行其他子程序,不是函数调用,有点类似CPU的中断(熟悉嵌入式开发的朋友们应该很容易理解)

比如子程序A、B:

def A():
    print '1'
    print '2'
    print '3'
def B():
    print 'x'
    print 'y'
    print 'z'

假设由协程执行,在执行A的过程中,可以随时中断,去执行B,B也可能在执行过程中中断再去执行A,结果可能是:

1
2
x
y
3
z

但是在A中是没有调用B的,所以协程的调用比函数调用理解起来要难一些

协程和线程的区别

线程

协程

<br/>

为什么需要协程

就实际使用理解来讲,<font color="#f00">**协程允许我们写同步代码的逻辑,却做着异步的事,避免了回调嵌套**,使得代码逻辑清晰</font>

比如golang中异步读入写入文件的方法:

go(function*(next){
     let [err,data]=yield fs.readFile("./test.txt",next);//异步读文件
     [err]=yield fs.appendFile("./test2.txt",data,next);//异步写文件
     //....
   })()

异步:指令执行之后,结果并不立即显现的操作称为异步操作,及其指令执行完成并不代表操作完成

<font color="#f00">**可以说:子程序就是协程的一种特例,而协程是追求极限性能和优美的代码结构的产物**</font>

协程的由来

起初人们喜欢同步编程,然后发现有一堆线程因为I/O卡在那里,并发上不去,资源严重浪费

然后出了异步(select,epoll,kqueue……),将I/O操作交给内核线程,自己注册一个回调函数处理最终结果

然而项目大了之后代码结构变得不清晰,下面是个小例子。

  async_func1("hello world",func(){
     async_func2("what's up?",func(){
       async_func2("oh ,friend!",func(){ 
         //todo something
       })
     })
  })

于是发明了协程,写同步的代码,享受着异步带来的性能优势

程序运行时需要的资源:

协程的实现原理

实现协程要解决的问题有如下几个:

<br/>

前期知识准备

现代操作系统是分时操作系统,资源分配的基本单位是进程,CPU调度的基本单位是线程

大多数程序运行时会有一个运行时栈,一次函数调用就会在栈上生成一个record

运行时内存空间分为全局变量区(存放函数,全局变量),栈区,堆区;栈区内存分配从高地址往低地址分配,堆区从低地址往高地址分配

下一条指令地址存在于指令寄存器IP,ESP寄存值指向当前栈顶地址,EBP指向当前活动栈帧的基地址

发生函数调用时操作为:将参数从右往左依次压栈,将返回地址压栈,将当前EBP寄存器的值压栈,在栈区分配当前函数局部变量所需的空间,表现为修改ESP寄存器的值

协程的上下文包含属于他的栈区和寄存器里面存放的值

① 何时挂起、唤醒协程?

如开始介绍时所说,协程是为了使用异步的优势,异步操作是为了避免IO操作阻塞线程

<font color="#f00">**那么协程挂起的时刻应该是当前协程发起异步操作的时候,而唤醒应该在其他协程退出,并且他的异步操作完成时**</font>


② 如何挂起、唤醒协程,如何保护协程运行时的上下文?

协程发起异步操作的时刻是该挂起协程的时刻,为了保证唤醒时能正常运行,需要正确保存并恢复其运行时的上下文

所以这里的操作步骤为:


③ 如何封装异步操作?

基于就绪通知的协程框架

首先需要包装read/write,在发生read的时候检查返回;

如果是EAGAIN,那么将当前协程标记为阻塞在对应fd上,然后执行调度函数调度函数需要执行epoll(或者从上次的返回结果缓存中取数据,减少内核陷入次数),从中读取一个就绪的fd;

如果没有,上下文应当被阻塞到至少有一个fd就绪查找这个fd对应的协程上下文对象,并调度过去:

这样,异步的数据读写动作,在我们的想像中就可以变为同步的。而我们知道同步模型会极大降低我们的编程负担

我们经常可以看到某些协程应用,一启动就是数个进程,这并不是跨进程调度协程。一般来说,这是将一大群fd分给多个进程,每个进程自己再做fd-协程对应调度


④ IO阻塞了怎么办?

试想在一个多协程的线程里,一个阻塞IO由一个协程发起,那么整个线程都阻塞了,别的协程也拿不到CPU资源,多个协程在一起等着IO的完成

在C++的协程库libco中的做法是:利用同名函数+dlsym来hook socket族的阻塞IO

比如read/write等,劫持了系统调用之后把这些IO注册到一个epoll的事件循环中,注册完之后把协程yield掉让出cpu资源,在IO完成的时候resume这个协程,这样其实把网络IO的阻塞点放在了epoll上,如果epoll没有就绪fd,那其实在超时时间内epoll还是阻塞的,只是把阻塞的粒度缩小了,本质上其实还是用epoll异步回调来解决网络IO问题的

那么问题来了,对于一些没有fd的一些重IO(比如大规模数据库操作)要怎么处理呢?

答案是:libco并没有解决这个问题,而且也很难解决这个问题,首先要明确的一点是我们的目的是让用户只是仅仅调用了一个同步IO而已,不希望用户感知到调用IO的时候其实协程让出了cpu资源

按libco的思路一种可能的方法是:给所有重IO的api都hook掉,然后往某个异步事件库里丢这个IO事件,在异步事件返回的时候再resume协程

这里的难点是:可能存在的重IO这么多,很难写成一个通用的库,只能根据业务需求来hook掉需要的调用,然后协程的编写中依然可以以同步的方式调用这些IO

从以上可能的做法来看协程很难去把所有阻塞调用都hook掉,所以libco很聪明的只把socket族的相关调用给hook掉,这样可以libco就成为一个通用的网络层面的协程库,可以很容易移植到现有的代码中进行改造,但是也让libco适用场景局限于如rpc风格的proxy/logic的场景中

<br/>

在我的理解里:阻塞IO让出cpu是协程要解决的问题,但是不是协程本身的性质,从实现上我们可以看出我们还是在用异步回调的方式在解决这个问题,和协程本身无关


⑤ 如果一个协程没有发起IO,但是一直占用CPU资源不让出资源怎么办?

无解。所以:

协程的编写对使用场景很重要,程序员对协程的理解也很重要,协程不适合于处理重cpu密集计算(耗时)

只要某个协程即一直占用着线程的资源就是不合理的,因为这样做不到一个合理的并发,多线程同步模型由OS来调度并发,不存在说一个并发点需要让出资源给另一个,而协程在编写的时候cpu资源的让出是由程序员来完成的,所以协程代码的编写需要程序员对协程有比较深刻的理解

最极端的例子是:程序员在协程里写个死循环,好,这个线程的所有协程都可以歇歇了

<br/>

协程的好处

说了这么多协程,协程的好处到底是啥?为什么要使用协程?

<br/>

本质:

在单线程中多个任务来回自行如果出现长时间的I/O操作,让其让出目前的协程调度,执行下一个任务

当然,可能所有任务,全部卡在同一个点上,但是这只是针对于单线程而言,当所有数据正常返回时,会同时处理当前的I/O操作

① 协程极大的优化了程序员的编程体验

同步编程风格能快速构建模块,并易于复用,而且有异步的性能(这个看具体库的实现),也不用陷入callback hell的深坑


② 第二点也是我最近一直在纠结的一点,协程到底有没有性能提升?

1) 从多线程同步模型切到协程来看:

首先很明确的:<font color="#f00">**性能提升点在于同步到异步的切换,libco中把阻塞的点全部放到了epoll线程中,而协程线程并不会发生阻塞**</font>

其次是协程的成本比线程小,线程受栈空间限制,而协程的栈空间由用户控制,而且实现协程需要的辅助数据结构很少,占用的内存少,那么就会有更大的容量,比如可以轻松开10w个协程,但是很难说开10w个线程

<br/>

另外一个问题是:很多人拿线程上下文切换比协程上下文切换开销大来推出协程模型比多线程并发模型性能优这点

这个问题我纠结了很久;对于这个问题,我先做一个简单的具体抽象:

在不考虑阻塞的情况下,假设8核的cpu,不考虑抢占中断优先级等因素,100个任务并发执行,100个线程并发和10个线程每个线程10个协程并发对比两者都可以把cpu资源利用起来,对OS来说,前者100个线程参与cpu调度,后者10个线程参与cpu调度,后者还有额外的协程切换调度,先考虑线程切换的上下文:根据Linux内核调度器CFS的算法,每个线程拿到的时间片是动态的,进程数在分配的时间片在可变区间的情况下会直接影响到线程时间片的长短,所以100个线程每个线程的时间片在一定条件下会要比10个线程情况下的要短,也就意味着在相同时间里,前者的上下文切换次数是比后者要多的

所以可以得出一个结论:<font color="#f00">**协程并发模型比多线程同步模型在一定条件下会减少线程切换次数(时间片有固定的范围,如果超出这个范围的边界则线程的时间片无差异),增加了协程切换次数,由于协程的切换是由程序员自己调度的,所以很难说协程切换的代价比省去的线程切换代价小**</font>

合理的方式应该是:通过测试工具在具体的业务场景得出一个最好的平衡点

<br/>

2) 从异步回调模型切到协程模型来看:

从一些已有协程库的实现来看,协程的同步写法却有异步性能其实还是异步回调在支撑这个事情,所以我认为协程模型是在异步模型之上的东西,考虑到本身协程上下文切换的开销(其实很小)和数据结构调用的一些开销,理论上协程是比异步回调的性能要稍微差一点,但是可以处于几乎持平的性能,因为协程实现的代价非常小

<br/>

3) 从一些异步驱动库的角度来看:

因为异步框架把代码封装到很多个小类里面,然后串起来,这中间会涉及相当多的内存分配,而数据大都在离散的堆内存里面,而协程风格的代码,可以简单理解为一个简洁的连续空间的栈内存池,辅助数据结构也很少,所以协程可能会比厚重的封装性能会更好一些

但是这里的前提是:协程库能实现异步驱动库所需要的功能,并把它封装到同步调用里

<br/>

上述内容摘自:linux进程-线程-协程上下文环境的切换与实现

<br/>

Java中的协程

说了这么多关于python和golang,以及C++中协程的实现,那么Java本身是否提供了对于协程的支持呢?

答案是很遗憾,Java原生并未提供协程的支持。但是可以通过异步编程的思想自己实现协程!

比较流行的Java协程有:

具体可参考:Java之协程(quasar)

<br/>

总结

其实对于嵌入式开发的人来说,协程并不是那么难理解:类似于CPU中断;

而协程使用的主要场合是:

<font color="#f00">**当前线程在调用某个方法的时候可能会造成阻塞等(比如异步等待epoll把你要读的数据复制到工作区)问题,此时与其让这个线程阻塞,还不如保留当前方法的全部调用栈(上下文),然后让出yield这个阻塞的时间给其他任务(也在当前线程中),等异步调用结束,再将线程上下文返回,从而能够继续在yield之前的上下文中继续执行程序**</font>

当然,我主要是做Java开发,对于协程也仅限于以上的一些思考,有不对的地方还请留言指出!

<br/>

附录

文章参考:

如果觉得文章写的不错, 可以关注微信公众号: Coder张小凯

内容和博客同步更新~