Python、C-Python、Cython代码与GIL的交互
这篇笔记相对Python来说,有点底层,先来解释几个名词:
C-Python: 或者CPython,指C实现的Python虚拟机的基础API。最通用的Python就是是基于C实现的,它的底层API称为C-Python API,所有Python代码的最终变成这些API以及数据结构的调用,才有了Python世界的精彩;
Cython:准确说Cython是单独的一门语言,专门用来写在Python里面import用的扩展库。实际上Cython的语法基本上跟Python一致,而Cython有专门的“编译器”先将 Cython代码转变成C(自动加入了一大堆的C-Python API),然后使用C编译器编译出最终的Python可调用的模块。
GIL:Global Interpreter Lock,是Python虚拟机的多线程机制的核心机制,翻译为:全局解释器锁。其实Python线程是操作系统级别的线程,在不同平台有不同的底层实现(如win下就用win32_thread, posix下就用pthread等),Python解释器为了使所有对象的操作是线程安全的,使用了一个全局锁(GIL)来同步所有的线程,所以造成“一个时刻只有一个Python线程运行”的伪线程假象。GIL是个颗粒度很大的锁,它的实现跟性能问题多年来也引起过争议,但到今天它还是经受起了考验,即使它让Python在多核平台下CPU得不到最大发挥。
GIL的作用很简单,任何一个线程除非获得锁,否则都在睡眠,而如果获得锁的线程一刻不释放锁,别的线程就永远睡眠下去。对于纯Python线程,这个问题不大,Python代码会通过解释器实时转换成微指令,而解释器给他们算着,每个线程执行了一定的指令数后就要把机会让给别的线程。这个过程中操作系统的调度作用比较微妙,不管操作系统怎么调度,即使把有锁线程挂起到后台,尝试唤醒没锁的,解释器也不给他任何执行机会,所以Python对象很安全。
所以一般来说,做纯Python的编程不需要考虑到GIL,它们是不同层面的东西,但是模块级别的C-Python、Cython等C层面的代码,跟Python虚拟机是平起平坐的,所以GIL很可能需要考虑,特别那些代码涉及IO阻塞、长时间运算、休眠等情况的时候(否则整个Python都在等这个耗时操作的返回,因为他们没获得锁,急也没办法)。
想体现这个过程,很简单,考虑下面的代码,一段纯Python和一段纯C的循环,每次print一段文字就睡眠一秒。
void _c_loop ( void ) { while(1) { printf("Print from C loop "); sleep(1); } } def _py_loop(): while True: print "Print from Python loop" time.sleep(1)
先不管他们是如何揉合到同一Python进程里面,两个进程分别执行了这两个函数后,他们应该以大概相互间隔着输出文字;但实际情况是,Print from Python loop这句出现了一次之后(先启动了纯Python线程,否则它连启动的机会都没),剩下的输出全都是Print from C loop,不断的输出,按Ctrl + C都没反应(因为响应信号的只有住线程,终止信号是发出了,说不定主线程也收到了,但是解释器不给机会),只好从另外的控制台kill了整个python 进程。
显然问题在于,运行C那段程序的进程获取了GIL,但是这是个死循环是永远都出不去的,所以GIL永远都在这个进程手里,别的进程只能睡觉。考虑到这个C程序的情况,因为循环里面只有printf跟sleep两个操作系统的调用,完全威胁不到Python对象的安全,所以GIL完全没必要插手进来。解决办法有两种:
void _c_loop_1 ( void ) { Py_BEGIN_ALLOW_THREADS while(1) { printf("Print from C loop "); sleep(1); } Py_END_ALLOW_THREADS } void _c_loop_2 ( void ) { while(1) { Py_BEGIN_ALLOW_THREADS printf("Print from C loop "); sleep(1); Py_END_ALLOW_THREADS } }
Py_BEGIN_ALLOW_THREADS、Py_END_ALLOW_THREADS是定义在Python的C-API头文件里面的宏,需要给头文件包含上#include 。
两个解决办法看起来差不多,效果也差不多,但意义不大一样。
_c_loop_1 是在进入永远都不会停的循环之前,把GIL交出去(还有保存自己的线程状态,但现在的例子这个没有意义),然后自己继续运行,这时候_c_loop_1是跟python虚拟机是互不相关的线程了,井水不犯河水,解释器的GIL爱给谁给谁。这时候“Python只能有一个线程运行”的传说就被破除迷信了。
_c_loop_2则是每次进行printf/sleep的调用前交出GIL,之后又申请要回来,然后循环回去马上又交出去(=。=)……
这个例子来说,当然是前者有效率了(=。=反正都是无用功,何来效率),只是为了在复杂的情况(C代码会影响Python对象,调用Python的函数等)下灵活运用。Py_BEGIN_ALLOW_THREADS、Py_END_ALLOW_THREADS其实只是两句语句的宏包含,Python手册里面有详细说明,甚至直接打开Python的include就可以看到。其中BEGIN那句调用了 PyEval_SaveThread(),而打开Python源码,PyEval_SaveThread函数里面毅然有 PyThread_release_lock()的调用。
上面这些,在《Python源码剖析——深度探索动态语言核心技术》一书,第15章Python多线程机制 15.4.2阻塞调度一节讲解得比较详细,书里面是直接拿出time.sleep/raw_input这些Python本身的代码来举例和分析。当然咯,本文加入了很多PT自己的发散性理解。
Cython在旁边说,怎么还没到我出场。恩,马上就来。
Cython 其实是很纠结的东西,因为他用的是Python一样的语法,但经常不得不时时刻刻提醒自己,有时候是在写C,有时候在写Python(=。=)。C是强类型的,Python是弱类型的,所以写Cython的时候有时候是强类型的,有时候是弱类型的,一般变量还好,你可能会想一下类型,像我刚入门,经常把漏写参数类型,不过还好Cython的编译器是有提示的,当熟悉了规则后,用Python写C啊……通过Cython,不仅可以在Python调用C,还可以在C调用Python……Python的上天入地就靠他了。
Cython代码最终都是会编译成C的,上面例子那段py_loop,改个名字叫cy_loop吧,直接放入Cython里面编译即可,他完全会变成C,然后变得像C一样霸道,执行后拿了GIL永远不还回去。
显然,只有纯Python的代码需要在执行时候经过解释器解释的,才会“自动的”交回GIL,而Cython是编译成C的代码,执行不经过解释器,如果他不交回GIL,就拿它没办法的了。
Cython是留有GIL的交互接口,在Cython手册里面加起来才半页的说明,语焉不详地介绍了Cython下跟GIL的交互方法,如果没理解上面GIL的工作方式,像我那样把这两页翻烂了都看不懂的。像刚才的例子,cy_loop可以写成这样:
def cy_loop(): with nogil: while True: printf("Print from Cython loop ") sleep(1)
就是用with nogil块把循环围起来,对,就加一行。如果打开Cython转换而成的C代码,发现Cython用Py_UNBLOCK_THREADS和 Py_BLOCK_THREADS把这段循环括起来了,查看Python C-API,Py_UNBLOCK_THREADS跟 Py_BEGIN_ALLOW_THREADS其实是一样的,只差一个花括号。不过Cython还会盯着你在nogil块里面的代码,里面不能引用Python对象,不能调用Python的函数,要画清界线!啊我只是调用个printf跟sleep,人格保证这是C库的函数!可是这样Cython还是不让过,说在nogil里面不能调用需要gil的函数,恩,printf跟sleep是通过cdef预定义的,Cython可不知道这些函数是哪里的,就默认它们需要gil了。所以要在cdef引入的时候,在后面加上nogil的声明:
cdef extern from *: unsigned int sleep(unsigned int seconds) nogil int printf(char *format, ...) nogil
嗯,就这样,Cython编译器终于高兴了。
Cython里面关于GIL的另外一个接口是:
cdef void callback_func(void) with gil:
Cython会生成PyGILState_Ensure()的调用,来保证这个函数在线程获得锁的时候才运行。这个情况一般是用于C的回调函数,因为回调运行是不知道什么时候的,如果这个函数里面有对Python的引用,就需要保证获得了GIL才操作。又或者你在C代码里面又生成了新的线程,而且也会引用Python的东西(纠结……)。
本文例子代码包可这里下载,或Google Code在线查看;例子里面是分别是Python、Cython、C三个循环,因为有Cython的GIL接口,C代码里面就没有使用API的宏了(注释掉)。例子里面添加了全局变量end,主线程进入pause后按Ctrl + C触发使其为真,然后三个线程会相继退出。例子在linux下编译,如在win下编译可能要修改下C代码里面的sleep,以及 signal.pause()。
来源:PY学习网:原文地址:https://www.py.cn/article.html