再谈协程

如果你对以下几个问题有疑问,那么本文可能会有所帮助。

  1. 什么是协程,或者说为什么会有协程这个概念?
  2. 怎么用?什么时候需要用?
  3. 都有并行的意味,那么协程和多线程有什么区别?两者能否相互替代?
  4. 协程底层的实现原理。

1.2.3

谈协程绕不开线程,按传统还得从进程谈起,不过我想业内人员对进程和线程应该是耳熟能详,这里就简单概括下。

进程拥有自己独立的堆和栈,既不共享堆,亦不共享栈,进程由操作系统调度;线程拥有自己独立的栈,共享堆(也可以有自己的私有域),不共享栈,线程亦由操作系统调度。一个进程可以有多个线程。

多线程一直以来是面试必考点,虽然[web]服务端开发人员似乎从来不用直接操作线程,其实是因为框架帮忙维护了,开发人员只需要关心业务实现。这也导致了部分人对多线程的某些概念模糊不清。比如关于多线程的效率:在多核cpu下,多个线程可以并行运行在不同内核上,效率高;而在单核cpu中,多个线程的并行执行其实是一个错觉,因为它们都是运行在一个内核上,一个cpu内核同一时间只能执行一个进程/线程,因此在一个内核上的多线程执行其实效率反而比串行执行低,只是给用户一种并发的错觉,反而增加了线程切换的时间。

但是效率的高低还要看线程占用cpu资源的占用率,比如存在大量IO操作,IO比较慢。也就是说,如果只有单线程,那么一旦涉及到IO操作,线程可能会被阻塞,程序的其余逻辑就只能傻等,就算那些逻辑不依赖于这个IO操作,此时线程对CPU的使用为0,CPU就是空闲状态。如果是多线程,是线程瓶颈,那么其余线程则可以使用cpu,而非等待IO结束。

题外话,一个空循环就能让cpu满载,参看 为什么一个空的死循环会让CPU占用达到100%

后来,出现了多路复用之类的技术,原先需要等待IO返回的线程也不需要等了,可以和其它线程一样忙别的事,IO返回时得到通知再处理接下去的事情。Java的NIO和.Net的async/await就是这么干的。

一般来说,为了避免线程频繁创建销毁带来的性能问题,程序里都会使用到线程池。

然而还是在单核的场景下,事情似乎变得有点诡异。既然线程们现在都能心无旁骛地使用CPU计算,而前面也说了,一个cpu内核同时只能运行一个线程,管理多线程又是抢占式,又是栈切换,维护生命周期啥的,影响性能不说,完全没得必要嘛,为什么不只用一个线程完成所有的计算呢。什么,你说可能需要[伪]并行计算?那就让线程自己来安排咯,毕竟具体逻辑方面,线程本身(或者说开发人员)比CPU要清楚的多,知道什么时候该干什么,什么时候切换逻辑,什么时候不切换,都由线程自己说了算。于是,协程粉墨登场。

协程主要是针对单线程的一个概念(如Js、NodeJs、Python由于GIL导致的伪多线程),可以将其看作线程运行时片段。和线程类似,虽然貌似多个协程可以并行执行,一个时间仍然只能运行一个。所以,如果业务逻辑是顺序相关(串行)或者各任务对反馈及时性要求不高,那么没必要用协程,就跟没必要多线程一样。协程对比线程,除了有更好的性能外,还让开发人员对执行片段有了更好的掌控。比如Go语言,通过阻塞条件(time.sleep()、select{}等),我们可以手动将控制权转移给其它的 Go 协程 , 也可以说是告诉调度器让它去调度其它可用空闲的 Go 协程(Go如何判断这是阻塞代码尚未研究过);或者通过channel调度指定协程。

Go默认情况下只用单线程。这就是说,你即使开了几百个goroutine,系统中同一时间在跑的只有一个线程,也就是一个协程。依据上面的内容,大家可以思考下Go为何默认如此。我们可以通过 runtime.GOMAXPROCS() 设置的是Go语言能跑几个线程,讲道理,CPU几核跑几个线程比较合理,使用 runtime.NumCPU() 查看内核数。

在编程层面来说,协程的概念偏向于以同步编程的模式实现异步处理的编程模式,避免了多层回调代码嵌套的问题。

其实在很多年以前,协程已经被提出了,现在只是它焕发生机的阶段。


4

上文说了,协程之间应该是非顺序相关的,即它们的上下文没有强依赖关系,是相对独立的。这里的上下文指的就是当前的运行栈空间,它包括了参数、局部变量、各寄存器的值等内容。在协程切换的时候,我们要想办法将对应的上下文投射到当前线程的运行栈中,即让线程执行特定的上下文。很容易想到malloc一块临时内存存放挂起的协程上下文信息,resume的时候再覆盖回去,运行栈在内存中只有一处,这就是stackless模式。相对的还有stackful模式,在这种模式下,每个协程都有自己的栈空间,运行栈指的就是当前协程的栈空间。现有语言的实现中,Python, Kotlin等定义的就是stackless协程, Go语言中实现的是stackful协程。

对于其它没有在语言层面直接支持协程的语言来说,由于协程涉及到底层的[堆]栈切换控制,因此很难单纯依靠现有语法构建算法的方式实现。有人做过此类尝试(可参看Coroutines in C),但也没有实用性。

能直接操作执行堆栈并暴露api的,现在市面上的语言以C/C++最为流行,基于它们也有很多开源的协程库。下面介绍几种实现方式。

协程分为非对称协程和对称协程。在非对称协程中,调用者和被调用者的关系是固定的,调用者将控制流转到被调用者,被调用者运行完毕后只能返回到调用者,而不能返回到其他协程。对称协程则不然。对称协程可以很容易由非对称协程来表达。且按一般的调用逻辑,A调B,B应返回到A,再由A发起到C的调用,而非B直接返回到C。因此,目前大多数协程库都只实现非对称协程。

  • 一种是借助glibc的ucontext,及相关的四个函数getcontext、setcontext、makecontext、swapcontext,如云风的库。当然这只能在linux环境下使用,在windows下,可以借助fiber实现类似的协程库;
  • 利用C标准库<setjmp.h>中的setjmp、longjmp实现协程。需要注意的是,setjmp仅负责保存寄存器的值,不负责维护其函数调用栈,这个需要另外实现;
  • 遵循规范从头实现。如libaco,它支持 Intel386 和 x86-64 两个平台的Sys V ABI,并提供了非对称协程的实现。关于Sys V ABI,It is today the standard ABI used by the major Unix operating systems such as Linux, the BSD systems, and many others. The Executable and Linkable Format (ELF) is part of the System V ABI. 也就是说,该协程库只支持类unix系统;
  • 使用汇编实现。较为著名的是Boost库,协程实现有两套:Corountine2和Corountine。Corountine2在Boost v1.59被引入,Boost.Corountine目前已被标记为deprecated。Boost.Corountine2使用了Boost.Context,因此要使用Boost.Corountine2,必须先编译Boost.Context。通用的C库tbox的协程模块也参照了Boost的实现。

关于汇编语法的平台差异,类Unix下采用的是AT&T的汇编语法格式,Dos/Windows下面采用的是Intel汇编语法格式。

 

参考资料:

云风-coroutine源码解析

System V ABI

Golang 协程调度

 

 

转载请注明本文出处:https://www.cnblogs.com/newton/p/11104187.html

 

 

posted @ 2019-07-08 10:19  莱布尼茨  阅读(674)  评论(0编辑  收藏