通过C/C++扩展的方式在Python中进行并发

这段时间一直被Python下的多线程所困扰,很是不爽,不过虽然很早就知道了GIL这个东西的存在,却依然不信邪,在想会不会有什么办法可以绕过它,经过一段时间的苦逼尝试后,总算想到了一个曲线救国的方法,今天就来详细介绍一下。

小故事

首先简单回顾一下我们常说的Python多线程蛋疼之处,这里用一个简单例子来进行介绍:

from multiprocessing.dummy import Pool

def cpu_fun(arg):
    K = 1000
    while K>0:
        i = 3000
        while i>0:
            i -= 1
        K -= 1

pool = Pool(4)
pool.map(cpu_fun, range(20))

如果问一个不知道GIL存在的Python初学者上述代码在一个CPU核心数非常大(比如128)的服务器上运行会占用多少CPU资源,他大概会先去查一下这个multiprocessing.dummy.Pool是什么东西,然后在了解到这是线程池之后回答“CPU占用率400%!”,然而非常不幸的是,它实际运行起来一般只能达到160-190%的占用率,即使你把Pool的参数从4调成8甚至100也没用,这时,你就可以开始以一副长者的姿态告诉他关于GIL的种种概念。

那么现在让我们换一段代码,

import cv2
import numpy as np
from multiprocessing.dummy import Pool

def cpu_fun(arg):
    S = 700
    x = np.random.uniform(0, 1, (S, S))
    y = cv2.SVDecomp(x)
    return y

pool = Pool(4)
pool.map(cpu_fun, range(20))

此时Python初学者的心理活动大概是:“尼玛,这不是和上面的差不多么?等等,这里的cpu_fun里面调用了OpenCV的SVD操作,这一定是个坑!”,然后回答说“考虑到OpenCV的SVD操作有可能是多线程实现的,假设其每次会调用M个线程进行计算,那么CPU占用率应该是上面160-190%的M倍。”那么我们运行一下看看,“卧槽,居然是400%!怎么GIL没用了。。。不可能,一定是M乘以160-190%后正好等于400%左右,请你把线程数调成16!”,OK,调整后再运行,“卧槽,居然是1600%!难道是GIL只限制纯粹的C代码?好像WIKI确实有这么写,GIL是用来限制pure python code的,这次一定是这个原因。”那我们现在把上面调用的SVD分解换成一个用C实现的循环Loop看看,

#include <Python.h>

static int test(int arg) {
    int i = 2000;
    while(i>0) {
        i--;
        int j = 2000;
        while(j>0) {
            j--;
            int k=2000;
            while(k>0) k--;
        }
    }
    return arg;
}

PyObject * _test(PyObject *self, PyObject *args) {
    int arg;
    if(!PyArg_ParseTuple(args, "i", &arg));
    int ret = test(arg);
    PyObject *obj;
    obj = Py_BuildValue("i", ret);
    return obj;
}

/* Method list */
static PyMethodDef CalMethods[] = {
    {"test", _test, METH_VARARGS, "compute."},
    {NULL, NULL, 0, NULL}
};

/* Initialization function */
PyMODINIT_FUNC
initcalculate(void) {
    PyObject *m;
    m = Py_InitModule("calculate", CalMethods);
    if(m == NULL) {
        return;
    }
}

Python初学者:“这次调用的还是C实现的代码,所以多线程并发应该没有问题,占用率是1600%。”,运行,CPU占用100%,Python初学者卒。

小故事的答案

那么上面第二段代码为什么在更改一个CPU计算函数后就能够正常的并发了呢,而第三段代码又为什么不能够并发?答案是既然Python有GIL可以把多线程锁住,那么也一定有办法把这个GIL打开!

经常有人告诉我们Python可以调用C/C++的扩展来对计算密集型的模块进行加速,但是这里的原因除去一般人所知道的Python语言本身速度慢(例如loop特别慢),还有一点就是在C/C++中我们可以有办法执行真正的多线程并发,官方WIKI说只要在调用函数首尾加两个神奇的宏:

#include "Python.h"
...

PyObject *pyfunc(PyObject *self, PyObject *args) {
   ...
   Py_BEGIN_ALLOW_THREADS
   // Threaded C code
   ...
   Py_END_ALLOW_THREADS
   ...
}

boost.python的WIKI上提供了一个更好的遵循"Resource Acquisition Is Initialization"原则的方法,就是定义一个变量,让其在创建时释放GIL,析构时重新上锁,用户不需要手动进行操作,另外在Python的C API文档里有提到Py_BEGIN_ALLOW_THREADS实际上会被展开为

{ PyThreadState *_save; _save = PyEval_SaveThread();

而Py_END_ALLOW_THREADS会被展开为

PyEval_RestoreThread(_save); }

因此上述方法可以用下面的代码进行表示

class ScopedGILRelease {
public:
    inline ScopedGILRelease() {
        m_thread_state = PyEval_SaveThread();
    }

    inline ~ScopedGILRelease() {
        PyEval_RestoreThread(m_thread_state);
        m_thread_state = NULL;
    }
private:
    PyThreadState * m_thread_state;
};

然后在你的C函数加上一个wrapper

int foo_wrapper(int x) {
    ScopedGILRelease scoped;
    return foo(x);
}

亲测上述方法有效,可以让上面第三段代码也实现正常并发。不过如果你不是手写这种很底层的接口代码,而是调用boost.python或者ctypes之类的已有框架,它们一般都会自动帮你在自定义函数首尾执行这两个宏,让你完全忽略Python GIL的存在!

通过C/C++扩展的方式在Python中进行并发计算

看起来这篇文章到上面一节就已经可以结束了,毕竟此篇不负责介绍如何在Python中调用C/C++的模块,而我们也已经知道Python可以通过调用C/C++代码的方式进行并发计算,现在还需要介绍什么呢?答案是对于不同种类的并发程序,我们还得在C/C++代码中有不同的实现方式,这里主要介绍两类常见的并发任务:

  • 同步任务:并发只存在于任务函数内部,在函数返回后不再占用任何资源;
  • 异步任务:即使任务函数返回,程序回到Python主线程后,后台仍有继续运行的后台并发线程(例如任务队列)。

对于同步任务类型,使用起来非常简单,类似于上面的ret_list = pool.map(fun, arg_list),在程序并发执行这些任务时,Python主线程被阻塞,直到所有的任务线程返回,上面第二段代码实际上就是一个标准的同步任务类型。

对于异步任务类型,稍微有些不一样,通常在使用时的代码如下:

for i in range(task_num):
    submit_task(i)

do_something()

for i in range(task_num):
    fetch_result(i)

即在Python主线程中先把所有任务提交,提交时主线程不会被阻塞,可以继续执行一些代码,然后最后根据需要获取后台线程的结果。注意,此时由于提交任务后主线程不被阻塞,因此如果GIL被释放后没有重新上锁的话,所有的线程(不管C线程还是Python线程)都是可以并发运行的。

看起来Python并发的问题已经完全被解决了呢!不过等等,如果真的可以这么解决,那GIL是不是有点太容易被绕过了?或者说如果这样就解决了的话,GIL存在的意义是啥?

在Python的官方WIKI上我们可以看到这样一段话:

“In CPython, the global interpreter lock, or GIL, is a mutex that prevents multiple native threads from executing Python bytecodes at once. This lock is necessary mainly because CPython's memory management is not thread-safe. ”

意思是Python内部的内存管理不是线程安全的,因此必须得有GIL保证不会有多个Python对其竞争访问。现在回想一下刚才介绍的两种任务类型,对于同步任务类型而言,并发线程都在C/C++代码里实现,而且并发的时候Python主线程也是阻塞住的,所以确实不会有任何问题;但是对于异步任务类型,由于Python主线程和C/C++的后台线程都是同时运行,因此Python的内存访问是存在一定风险的,我们无法保证所有线程都可以安全的访问内存地址(除非C/C++线程不会与任何Python对象打交道)!

好吧,看来用C/C++来实现多线程任务队列的计划必须非常小心,否则很有可能跑着跑着出现memory的问题,不过问题来了,听说TensorFlow的任务队列是可以做到异步并行的,它是如何避免上述问题的呢?。。。你们谁知道答案快点告诉我。。。