python中支持多线程,在默认实现的CPython中,使用了GIL,每个线程要执行字节码之前,需要获取GIL这个全局解释器锁,所以同时只有一个线程可以执行字节码.同时,CPython在进行io相关操作的时候,会释放GIL,此时其他的线程就可以拿到GIL来运行自己的字节码了. 所以,对于cpu密集型任务,无法使用python的多线程来加速;但是对于io密集型任务,可以使用python多线程来加速.
那么python在执行io操作的时候,会释放GIL锁这个具体是怎么做的呢? 这个事情无法在python层面来做,翻遍python的文档,都无法找到怎么让当前线程释放GIL锁的方法,这个事情只能在C代码里面来做.
Python可以很方便的写C扩展,而且Python的完档对Python的C API有很完备的描述.
0x01 使用C扩展处理GIL需要一个满足标准库distuitls的setup.py来组织所有的c代码.
这个和我们从pypi网站下载一个tar.gz的源代码,然后tar xvf xxx.tar.gz && cd xxx && python setup.py install来进行安装是一样的. 只是这儿我们自己写这个setup.py. 其内容如下. 最主要的是ext_modules部分,表示要编译出的.so(windows里面的.dll)文件,以及编译出这个文件需要的.c文件.
from distutils.core import setupfrom distutils.extension import Extensionif __name__ == '__main__': setup(name='testgil', version='1.0.0', description='test gil ', ext_modules=[Extension('testgil', ['testgil.c'])])其中testgil.c文件的内容是有固定格式的.
在setup.py里面的ext_modules定义的testgil,那么需要保证.c文件里面有一个PyMODINIT_FUNC PyInit_testgil的函数,其里面固定需要调用PyModule_Create来构建这个用C实现的module.PyModuleDef也是固定的,里面会写入module的名字,文档,以及这个module对外提供的函数列表testgilMethods. testgilMethods是一个数组,里面定义了所有的函数的名字和实现的C函数. 这儿我们对外暴露了run_with_gil和run_without_gil两个函数
static PyMethodDef testgilMethods[] = { {"run_with_gil", run_with_gil, METH_NOARGS, "拿着gil持续运行"}, {"run_without_gil", run_without_gil, METH_NOARGS, "放掉gil持续运行"}, {NULL},};static PyModuleDef testgilmodule = { PyModuleDef_HEAD_INIT, "testgil", "测试gil的影响", -1, testgilMethods};PyMODINIT_FUNC PyInit_testgil(void){ return PyModule_Create(&testgilmodule);}代码里面剩余的部分就是核心功能的实现了. 这儿我们实现了两个函数,他们都是在一个线程里面死循环(也就是这个线程不会退出),唯一的区别是一个函数会使用Py_BEGIN_ALLOW_THREADS和Py_END_ALLOW_THREADS在自己死循环的时候将GIL放掉,这样其他的线程就有机会拿到这个GIL执行字节码了. 另外一个函数没有将GIL放掉,那么自己死循环,同时其他的线程也无法拿到GIL,也就是整个python就死循环而无法做其他的事情了.
struct timespec ms_500 = {0, 1000000 * 500};static void doLongTimeThing(int with_gil){ // 下面这个函数在python 3.8开始才有 // pid_t tid2 = PyThread_get_thread_native_id(); // 否则使用这个函数 pid_t tid2 = syscall(SYS_gettid); for (;;) { if (!with_gil) { Py_BEGIN_ALLOW_THREADS printf("%d 释放GIL 使用C代码做很长时间的事情中...\n", tid2); nanosleep(&ms_500, NULL); Py_END_ALLOW_THREADS } else { printf("%d 不释放GIL 使用C代码做很长时间的事情中...\n", tid2); nanosleep(&ms_500, NULL); } }}static PyObject *run_with_gil(PyObject *self, PyObject *args){ doLongTimeThing(1); Py_RETURN_NONE;}static PyObject *run_without_gil(PyObject *self, PyObject *args){ doLongTimeThing(0); Py_RETURN_NONE;}0x02 测试一下使用python setup.py install对C扩展进行编译和安装,setup.py自己会使用gcc对c代码进行编译和连接,然后生成一个so文件,并安装到正确的位置去. 我这儿是使用miniconda安装的一个python3.8
C代码持有GIL运行
用如下的代码测试,一共两个线程,主线程调用我们的run_without_gil,也就是主线程进入我们用C写的死循环,但是进入死循环代码之前将GIL释放了,这样另外用python写的线程也能拿到GIL来跑了.
import timeimport subprocessimport testgilimport threadingdef thread_run(): tid=threading.get_native_id() print(f"{tid} 另外一个线程运行一下") time.sleep(2) while True: print(f'{tid} 另外一个线程还能拿到GIL运行') time.sleep(.5)th=threading.Thread(target=thread_run,daemon=True)th.start()# 主线程跑的时候把GIL放掉,那么其他的线程就可以拿着跑了testgil.run_without_gil()# testgil.run_with_gil()其运行结果如下,能看到两个线程都在跑了. 这个时候我们就达到了用C进行扩展,在里面将GIL释放,单独完成自己需要做的事情,而用python代码实现的线程也可以接着运行. 其实很多高性能的C扩展都是这样的.
C代码释放GIL运行
将代码做修改,调用run_with_gil,我们会发现用python实现的第二个线程无法再次获取到GIL来运行.
import timeimport subprocessimport testgilimport threadingdef thread_run(): tid=threading.get_native_id() print(f"{tid} 另外一个线程运行一下") time.sleep(2) while True: print(f'{tid} 另外一个线程还能拿到GIL运行') time.sleep(.5)th=threading.Thread(target=thread_run,daemon=True)th.start()# 主线程跑的时候把GIL放掉,那么其他的线程就可以拿着跑了# testgil.run_without_gil()# 主线程跑的时候不释放GIL,那么其他线程也跑不了了testgil.run_with_gil()0x03 总结CPython实现使用的GIL会影响到cpu密集型的多线程,这个时候我们一般使用多进程来处理,但是多进程写起来要比多线程麻烦. 对io密集型的多线程,CPython实现的时候就会在进入等待的地方释放GIL,所以影响不大. 对于CPU密集型的任务,我们可以将关键的地方使用C来重写,然后在进入计算的时候释放掉GIL.