greenlet上下文切换的原理

2016-07-28 Li Shuai 更多博文 » 博客 » GitHub »

Python C 协程

原文链接 https://cyrusin.github.io/2016/07/28/greenlet-20150728/
注:以下为加速网络访问所做的原文缓存,经过重新格式化,可能存在格式方面的问题,或偶有遗漏信息,请以原文为准。


greenlet是Python众多协程实现技术中的一种,eventlet是基于greenlet实现的。而eventlet和libev又是gevent的核心。greenlet的上下文切换清晰易懂,可以结合IO事件循环构建出一些高效的事件处理逻辑。不同于yield类型的上下文切换,greenlet的上下文切换从表现形式上看更纯粹,可以直接switch到另一个greenlet,不用管目标greenlet是否已经在运行,不同greenlet之间处于完全对等的状态,可以相互switch。基于yield实现的协程往往只能切换回自己的直接或间接调用者,要想在嵌套的调用中切换出去是比较麻烦的。本质上是因为yield只能保留栈顶的帧,Python3对此有改进,可以通过yield from嵌套的挂起内层过程调用,但依然不能任意的切换到其他上下文。而greenlet就可以,只要一个过程被封装进一个greenlet,可以认为这个greenlet就成了一个可以随时挂起和恢复的实体。当然这种灵活性的代价是代码的逻辑会变得混乱,debug会更难,不过如果适当的使用greenlet,却可以收到很好的效果,比如之前介绍过的Motor。

协程是用户态下的上下文切换技术,是对线程时间片的再切分。所谓上下文,一般是指一个子过程调用,比如一个函数。另一方面,我们知道,Python虚拟机的原理是通过栈帧对象PyFrameObject抽象出运行时(栈,指令,符号表,常量表等),通过执行PyEval_EvalFrameEx这个C级别的函数来逐个解析字节码指令。也就是说可调用对象都是通过PyEval_EvalFrameEx来执行自己的PyFrameObject的,而按照调用的先后顺序,当前PyFrameObject的f_back指针会指向上一个PyFrameObject,这样,某一个时刻,当前线程的栈帧对象按调用的先后顺序形成了一个链表, 线程的top_frame属性正好是链表的表头(栈顶),这就是当前线程正在运行的帧。从这种状态可以看出,对于Python而言,切换当前栈顶的帧是容易的,只要保留栈顶的PyFrameObject,回退到栈顶下的一帧就行,这也是yield的基本原理。但问题是,Python的栈帧对象本身从一开始并不是为协程设计的,所以栈帧与栈帧之间的这种执行的先后顺序(其实可以理解为执行栈)语言本身却没有提供恢复和挂起的机制。这就要求假如你想任意切换上下文的话,必须实现一个机制,可以保存一个执行栈。

通过上面的分析,可以看到,要想在Python中在两个子过程中作任意的挂起和恢复的话,需要做到两点:

  1. 保存当前子过程的执行栈。
  2. 恢复目标子过程的执行栈,并恢复栈顶的状态继续执行。

greenlet之所以可以在任意两个greenlet之间作切换,就是因为其实现了上述的两点。其总共加起来2000多行C代码,其中内联了一小部分但确实相当关键的汇编代码,看懂greenlet的代码至少要把C语言的过程调用原理、汇编、进程的堆栈、Python的虚拟机执行原理等弄清楚,如果有一个不懂,就不用看源码了,能用起来就好。毕竟有些部分很绕,光看源码分析也不一定能完全消化吸收,有些东西也很难用文字表达,只可意会,不可言传。

greenlet的基本原理简单说起来就是:

  1. 将一个子过程封装进一个greenlet里,而一个greenlet代表了一段C的栈内存。
  2. 在greenlet里执行Python的子过程(通常是个函数),当要切换出去的时候,保存当前greenlet的栈内存,方法是memcpy到堆上,也就是说每一个greenlet可能都需要在堆上保存上下文,挂起的时候就把栈内存memcpy到堆上,恢复的时候再把堆上的上下文(运行的时候栈内存的内容)拷贝到栈上,然后释放堆上的内存。
  3. 恢复栈顶只需要将当前线程的top_frame修改为恢复的greenlet的top_frame就行。

greenlet的基本原则:

  1. 除了main greenlet之外,任意一个greenlet都有唯一一个父greenlet。
  2. 假如当前greenlet执行完毕,回到自己的父greenlet即可。
  3. 可以通过给switch方法的参数来在不同greenlet之间传递数据。

greenlet实现的关键是先切换C函数的栈,而切换和恢复C的栈需要将%ebp(函数栈底)、%esp(函数栈顶)等寄存器的值保存到本地变量,而恢复的时候就可以通过从堆上拷贝的内存,来恢复寄存器的值。从而达到恢复上下文的目的。