这段时间一直被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的任务队列是可以做到异步并行的,它是如何避免上述问题的呢?。。。你们谁知道答案快点告诉我。。。