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可调用的模块。
  • GILGlobal 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一段文字就睡眠一秒。

1
2
3
4
5
6
7
void _c_loop ( void )
{
    while(1) {
        printf("Print from C loop\n");
        sleep(1);
    }
}
1
2
3
4
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循环里,其他python线程只能睡觉。考虑到这个C程序的情况,因为循环里面只有printf跟sleep两个操作系统的调用,完全威胁不到Python对象的安全,所以GIL完全没必要插手进来。解决办法有两种:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
void _c_loop_1 ( void )
{
    Py_BEGIN_ALLOW_THREADS 
    while(1) {
        printf("Print from C loop\n");
        sleep(1);
    }
    Py_END_ALLOW_THREADS
}
 
void _c_loop_2 ( void )
{
    while(1) {
        Py_BEGIN_ALLOW_THREADS
        printf("Print from C loop\n");
        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本身的代码来举例和分析。当然咯,本文加入了很多笔者自己的发散性理解。

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可以写成这样:

1
2
3
4
5
def cy_loop():
    with nogil:
        while True:
            printf("Print from Cython loop\n")
            sleep(1)

就是用with nogil块把循环围起来,对,就加一行。如果打开Cython转换而成的C代码,发现Cython用Py_UNBLOCK_THREADSPy_BLOCK_THREADS把这段循环括起来了,查看Python C-API,Py_UNBLOCK_THREADSPy_BEGIN_ALLOW_THREADS其实是一样的,只差一个花括号。不过Cython还会盯着你在nogil块里面的代码,里面不能引用Python对象,不能调用Python的函数,要画清界线!啊我只是调用个printfsleep,人格保证这是C库的函数!可是这样Cython还是不让过,说在nogil里面不能调用需要gil的函数,恩,printfsleep是通过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()。

例子代码运行效果:

例子代码运行效果

文章分类 Programming, Python, Unix/Linux 标签: , , , , , , ,
5 comments on “Python, C-Python, Cython代码与GIL的交互
  1. risent说道:

    正好前一阵子看了上面的那个关于GIL的讲述的连接后,毫不犹豫的就在当当把《Python源马剖析》买回来了

  2. vzomik说道:

    看完了,不太懂,还好,有点收获。

  3. fbwfbi说道:

    文中一下进程,一下线程,傻傻分不清,其实讲的都是线程,都有些打成进程,文章写的不错,但是这种错误还是要校正一下

    • PT说道:

      看来以前是有点概念混淆。改了。

      • fbwfbi说道:

        话说楼主的博客好久没更新了,我还是最近才看Cython的文档,而博主2010年就开始实践了。。。。博客中还有一个地方,可能要修正一下,python其实也是强类型,只不过是动态的,类型运行期间确定,它并不能像JS那样做类型推导,可以 1 + “2”

发表评论

电子邮件地址不会被公开。 必填项已用*标注

*