Python C扩展与GIL的真相

📅 2026/6/16 5:17:44
Python C扩展与GIL的真相
Python C扩展与GIL的真相#includestatic PyObject* cpu_intensive(PyObject* self, PyObject* args) {unsigned long long n;if (!PyArg_ParseTuple(args, K, n))return NULL;unsigned long long result 0;for (unsigned long long i 0; i n; i) {result i;}return PyLong_FromUnsignedLongLong(result);}static PyMethodDef methods[] {{cpu_intensive, cpu_intensive, METH_VARARGS, CPU密集型计算},{NULL, NULL, 0, NULL}};static struct PyModuleDef module {PyModuleDef_HEAD_INIT, fastmath, NULL, -1, methods};PyMODINIT_FUNC PyInit_fastmath(void) {return PyModule_Create(module);}用setuptools编译这个模块后在Python中调用fastmath.cpu_intensive(10**9)它的执行速度是纯Python版本的大约40倍。原因很简单C代码没有字节码解释的开销没有对象创建的开销没有类型分发的开销。但真正让Python程序员困惑的是在这个C函数执行期间GIL是被释放了还是被持有着答案是默认情况下GIL被持有。这意味着如果你在Python中启动10个线程全部调用cpu_intensive它们依然是串行执行的。每个C函数调用都占用着GIL其他线程无法执行任何Python代码。释放GIL的方式是使用Py_BEGIN_ALLOW_THREADS宏static PyObject* cpu_intensive_nogil(PyObject* self, PyObject* args) {unsigned long long n;if (!PyArg_ParseTuple(args, K, n))return NULL;unsigned long long result 0;Py_BEGIN_ALLOW_THREADSfor (unsigned long long i 0; i n; i) {result i;}Py_END_ALLOW_THREADSreturn PyLong_FromUnsignedLongLong(result);}Py_BEGIN_ALLOW_THREADS展开后是{PyThreadState *_save;_save PyEval_SaveThread();Py_END_ALLOW_THREADS展开后是PyEval_RestoreThread(_save);}PyEval_SaveThread释放GIL并保存当前线程的状态。PyEval_RestoreThread重新获取GIL并恢复线程状态。在这两个宏之间的代码运行时不持有GIL。但这里有个严重的问题在释放GIL的区域内绝对不能操作任何Python对象。不能调用PyArg_ParseTuple、不能创建PyObject、不能调用Python C API的任何函数。上面的例子中循环体只操作本地变量result普通的unsigned long long不涉及任何Python对象所以是安全的。实际中需要在释放GIL的C代码中回调Python函数的情况很常见。这时需要临时重新获取GILstatic PyObject* process_with_callback(PyObject* self, PyObject* args) {PyObject* callback;PyObject* data_obj;if (!PyArg_ParseTuple(args, OO, callback, data_obj))return NULL;if (!PyCallable_Check(callback)) {PyErr_SetString(PyExc_TypeError, callback must be callable);return NULL;}// 释放GIL执行大量计算Py_BEGIN_ALLOW_THREADSheavy_computation_without_python();Py_END_ALLOW_THREADS// 重新获取GIL后调用回调PyObject* result PyObject_CallFunctionObjArgs(callback, data_obj, NULL);return result;}每次获取和释放GIL有大约1-5微秒的开销。对于计算量大、Python交互少的场景这完全可以接受。但对于频繁切换的场景这个开销就明显了。C扩展释放GIL的能力是Python多线程能够真正并行执行的关键。Python线程在C扩展的Py_BEGIN_ALLOW_THREADS块执行期间是真正并行运行的——因为GIL被释放了。来看一个实际的多线程C扩展调用场景import threadingimport timeimport fastmathdef worker(n):result fastmath.cpu_intensive_nogil(n)print(f结果: {result})start time.time()threads []for i in range(4):t threading.Thread(targetworker, args(2*10**8,))threads.append(t)t.start()for t in threads:t.join()print(f耗时: {time.time() - start:.2f}秒)在4核CPU上这段代码的耗时大约等于单次计算的耗时而不是四倍因为四个C扩展实例在不同的CPU核心上真正并行执行。但如果改为调用cpu_intensive不释放GIL的版本总耗时是单次计算的四倍——四个线程串行执行。PyGILState_Ensure和PyGILState_Release提供了另一个获取/释放GIL的接口适用于从C线程调用Python API的场景void* worker_thread(void* arg) {// 这个C线程需要调用Python代码// 确保获取GILPyGILState_STATE gstate;gstate PyGILState_Ensure();// 现在可以安全调用Python APIPyObject* result PyObject_CallFunction(py_callback, i, 42);// 释放GILPyGILState_Release(gstate);return NULL;}PyGILState_Ensure内部维护了一个线程局部状态的自动管理。它检查当前线程是否已经有GIL状态如果没有创建新状态并获取GIL如果有只是增加嵌套计数。这个接口允许你在Python解释器完全不知道的原始C线程中安全地调用Python代码是实现C级异步回调使用Python回调函数的基石。Python 3.12引入了per-interpreter GIL这是一个实验性功能。不同解释器实例之间可以同时运行互不干扰。但使用这个特性需要显式创建子解释器import interpretersinterp interpreters.create()interp.run(import sys# 这个代码在独立的解释器中运行# 拥有独立的GILimport timetime.sleep(5)print(Done))# 主解释器可以同时运行其他代码print(主解释器工作中)per-interpreter GIL的实现原理是每个PyInterpreterState对象持有自己的GIL锁。PyEval_SaveThread和PyEval_RestoreThread操作当前解释器的GIL而不是全局的GIL。Cython提供了更便捷的GIL控制方式。用with nogil块指定释放GIL的区域import cythondef cpu_intensive_cython(unsigned long long n):cdef unsigned long long icdef unsigned long long result 0with nogil:for i in range(n):result ireturn resultCython将with nogil编译成Py_BEGIN_ALLOW_THREADS/Py_END_ALLOW_THREADS对并在释放GIL的区域内禁用所有Python对象操作。如果尝试在nogil块中操作Python对象Cython会报编译错误。Python C API中的TYPE_FLAG决定了模块是否能安全释放GIL。在模块定义中设置Py_MOD_PER_INTERPRETER_GIL_SUPPORTED标志表示模块支持per-interpreter GILstatic struct PyModuleDef module {PyModuleDef_HEAD_INIT,.m_name fastmath,.m_doc NULL,.m_size -1,.m_methods methods,.m_slots NULL,.m_traverse NULL,.m_clear NULL,.m_free NULL,.m_gil Py_MOD_PER_INTERPRETER_GIL_SUPPORTED,};不带这个标志的模块只能在主解释器中加载尝试在子解释器中导入会失败。C扩展的性能来源之一是避免了Python对象的内存分配开销。例如在纯Python中def sum_range(n):total 0for i in range(n):total ireturn total每次total i创建了一个新的整数对象Python整数是不可变的然后旧的total对象被垃圾回收。n次循环意味着n次整数对象的分配和n次释放。在C扩展中result是一个unsigned long long不存在任何对象分配。这就是40倍速度提升的主要来源。但有些情况下C扩展反而比纯Python慢——当C扩展频繁进行Python API调用时。每次PyObject_CallFunctionObjArgs、PyDict_SetItemString等调用都有不可忽略的开销static PyObject* slow_c_ext(PyObject* self, PyObject* args) {PyObject* dict;if (!PyArg_ParseTuple(args, O, dict))return NULL;for (int i 0; i 100000; i) {PyObject* key PyLong_FromLong(i);PyObject* value PyLong_FromLong(i * 2);PyDict_SetItem(dict, key, value);Py_DECREF(key);Py_DECREF(value);}Py_RETURN_NONE;}每次迭代创建两个Python整数对象调用PyDict_SetItem然后递减引用计数。这10万次迭代的C扩展版本实际上可能比纯Python的dict推导式还慢因为Python的虚拟机在执行字节码时也做了大量优化。引用计数管理是C扩展最容易出错的地方。上面的代码中Py_DECREF是必须的因为PyLong_FromLong返回一个new reference。少了一个Py_DECREF会造成内存泄漏多了一个会造成use-after-free。Py_INCREF和Py_DECREF在C层面实际上是一个原子的引用计数操作#define Py_INCREF(op) ((PyObject*)(op))-ob_refcnt#define Py_DECREF(op) \if (((PyObject*)(op))-ob_refcnt-- 1) \_Py_Dealloc((PyObject*)(op))CPython 3.12之后Py_DECREF的实现变成了延迟执行以减少GIL持有时长#define Py_DECREF(op) \if (--((PyObject*)(op))-ob_refcnt 0) \_Py_Dealloc((PyObject*)(op))#define _Py_DECREF_INTERNAL(op) \do { \if (--((PyObject*)(op))-ob_refcnt 0) { \if (Py_IsOwnedByCurrentThread(op)) { \_Py_Dealloc((PyObject*)(op)); \} else { \_Py_QueueDealloc((PyObject*)(op)); \} \} \} while(0)这个延迟释放队列的优化减少了GIL持有时间因为析构操作可能涉及复杂的递归引用计数清理在队列中批量处理更高效。C扩展开发中使用最多的宏之一是Py_RETURN_NONE它等价于Py_INCREF(Py_None); return Py_None;。Py_None是单例对象不需要每次创建但每次返回时需要增加它的引用计数。还有一个常见的模式是Py_XINCREF和Py_XDECREF它们检查参数是否为NULL再操作。与普通版本的唯一区别就是NULL检查#define Py_XINCREF(op) if ((op) ! NULL) Py_INCREF(op)#define Py_XDECREF(op) if ((op) ! NULL) Py_DECREF(op)tp_traverse和tp_clear是自定义类型支持GC必须实现的两个函数。tp_traverse在GC标记阶段被调用遍历对象引用的所有其他Python对象tp_clear在GC清除阶段被调用打破引用循环typedef struct {PyObject_HEADPyObject* parent;PyObject* children;} ContainerObject;static int Container_traverse(ContainerObject* self, visitproc visit, void* arg) {Py_VISIT(self-parent);Py_VISIT(self-children);return 0;}static int Container_clear(ContainerObject* self) {Py_CLEAR(self-parent);Py_CLEAR(self-children);return 0;}Py_VISIT宏调用visit函数它通知GC这个对象引用了另一个对象。Py_CLEAR递减引用计数并将指针置为NULL。不实现tp_traverse和tp_clear的自定义类型会导致包含循环引用的对象无法被GC回收造成内存泄漏。从Python 3.13开始CPython将GIL改造成了可禁用的实验特性--disable-gil编译选项。在no-GIL模式下引用计数的操作必须使用原子操作#define Py_INCREF(op) \atomic_fetch_add_explicit(((PyObject*)(op))-ob_refcnt, 1, \memory_order_relaxed)#define Py_DECREF(op) \if (atomic_fetch_sub_explicit(((PyObject*)(op))-ob_refcnt, 1, \memory_order_acq_rel) 1) \_Py_Dealloc((PyObject*)(op))原子操作的性能开销比普通操作大特别是在多核处理器上。benchmark显示no-GIL模式的CPython在单线程场景下有大约10-15%的性能下降。但多线程场景的并行能力完全弥补了这个损失。