
假设一个用 C 写的 Python 扩展,要从列表里取出第一个对象,留着稍后使用:
PyObject *item = PyList_GetItem(items, 0);
if (item == NULL) return NULL;
Py_INCREF(item);
这几行没有写锁,却可能依赖 CPython 的全局解释器锁 GIL。撤掉它,原本正确的代码就可能访问已经释放的内存。
社区已经在解决 GIL。CPython 3.13 提供了可选的自由线程构建,可以在关闭 GIL 的情况下运行;3.14 将它推进到正式支持、仍然可选的阶段。长期难以直接移除 GIL 的一项重要原因,是旧代码把它提供的保护当成了前提。解释器需要补上新的保证,扩展也需要检查自己的使用方式。开头这三行就是一个具体例子。
第一行取得的是对象的地址,没有复制对象,也没有替调用者取得一份持有权。这叫“借用引用”。item 这个 C 变量记住了地址,不会自动阻止对象被回收。
Py_INCREF(item) 才表示:这段代码也要持有这个对象。取得的这份持有权叫“强引用”,不用时要通过 Py_DECREF 释放。
假设列表本身始终有效,里面只有一个普通、可回收的对象 X,而且列表是 X 的唯一持有者。列表还持有 X 时,X 不会被回收;列表放手以后,借来的地址就未必能用了。
在启用 GIL 的情况下,执行这几行 C 代码的线程持有 GIL,中途没有释放它,也没有回调其他 Python 代码。同一解释器内,别的 Python 线程便无法在取值和 Py_INCREF 之间插进来删除列表元素。调用者可以先借到地址,再取得自己的强引用。
关掉 GIL,同时允许另一线程修改这个列表,顺序就可能变成:
线程 A 取得 X 的地址,还没执行 Py_INCREF。
线程 B 执行 del items[0],释放列表对 X 的最后一份强引用。X 可能随之被销毁。
线程 A 继续执行 Py_INCREF(item),尝试访问 X 的引用计数,此时那块内存可能已经失效。
A 手里虽然有地址,却还没来得及让这个地址对应的对象继续活着。
把引用计数的加一改成原子操作,也不能单独修复它。原子加一可以避免多个线程更新同一个计数时相互覆盖,却不能保证加一之前,对象仍然存在。对已经失效的内存做原子操作,依然是错误的访问。
即便给旧取值函数内部加锁,如果函数返回时就解锁,B 仍能在返回之后、Py_INCREF 之前删除 X。调用者需要的保护,必须持续到自己取得强引用。只保证“取值这一步安全”,覆盖不到两次调用之间的空隙。
CPython 从 3.13 起提供了另一个接口:
PyObject *item = PyList_GetItemRef(items, 0);
if (item == NULL) return NULL;
/* 使用 item */
Py_DECREF(item);
PyList_GetItemRef 成功返回时,调用者已经持有一份强引用。取值与取得引用之间需要的同步,由解释器内部处理。之后即使列表删除 X,也只是释放列表持有的那一份,调用者仍然可以使用 X,直到自己释放引用。
这份引用保证对象继续存活。若其他线程还会修改对象的内容,仍然需要相应的同步。
旧接口又不能直接改成这种返回规则。原来只借用、没有安排释放的调用方,会漏掉新增引用的释放;开头已经执行 Py_INCREF 的代码,则会额外多持有一份。新增接口以后,调用方还得调整取得和释放引用的方式。
审查这类代码时,要检查的是:从借到地址,到取得强引用,这段空隙里,另一个线程是否可能移除并释放对象。 如果列表不会被并发修改,或者已有同步保护覆盖整个过程,这条风险就不成立。同一个旧接口,可能在一处需要迁移,在另一处仍然安全。
这也是自由线程支持需要扩展维护者参与的具体原因。在 CPython 3.14 的自由线程构建中,未强制关闭 GIL 时,导入一个没有声明支持自由线程的 C 扩展,会触发警告并重新启用 GIL。维护者作出支持声明之前,需要检查包括开头这三行在内的旧依赖;声明本身不会替代码补上缺失的保护。
以上就是“为什么 Python 的 GIL 问题一直让人诟病,Python 社区却不解决?”的详细内容,想要了解更多Python教程欢迎持续关注编程学习网。
扫码二维码 获取免费视频学习资料

- 本文固定链接: http://www.phpxs.com/post/14654/
- 转载请注明:转载必须在正文中标注并保留原文链接
- 扫码: 扫上方二维码获取免费视频资料