C API 扩展对自由线程的支持

从 3.13 版本开始,CPython 支持在名为自由线程的配置中禁用全局解释器锁 (GIL) 运行。本文档描述了如何调整 C API 扩展以支持自由线程。

在 C 中识别自由线程构建

CPython C API 暴露了 Py_GIL_DISABLED 宏:在自由线程构建中,它被定义为 1,在常规构建中则未定义。您可以使用它来启用仅在自由线程构建下运行的代码

#ifdef Py_GIL_DISABLED
/* code that only runs in the free-threaded build */
#endif

备注

在 Windows 上,此宏不会自动定义,而必须在编译时指定给编译器。sysconfig.get_config_var() 函数可用于确定当前运行的解释器是否定义了该宏。

模块初始化

扩展模块需要明确指示它们支持在禁用 GIL 的情况下运行;否则,导入扩展将引发警告并在运行时启用 GIL。

根据扩展是使用多阶段初始化还是单阶段初始化,有两种方法可以指示扩展模块支持在禁用 GIL 的情况下运行。

多阶段初始化

使用多阶段初始化(即 PyModuleDef_Init())的扩展应该在模块定义中添加一个 Py_mod_gil 槽。如果您的扩展支持旧版本的 CPython,您应该使用 PY_VERSION_HEX 检查来保护该槽。

static struct PyModuleDef_Slot module_slots[] = {
    ...
#if PY_VERSION_HEX >= 0x030D0000
    {Py_mod_gil, Py_MOD_GIL_NOT_USED},
#endif
    {0, NULL}
};

static struct PyModuleDef moduledef = {
    PyModuleDef_HEAD_INIT,
    .m_slots = module_slots,
    ...
};

单阶段初始化

使用单阶段初始化(即 PyModule_Create())的扩展应该调用 PyUnstable_Module_SetGIL() 来指示它们支持在禁用 GIL 的情况下运行。该函数仅在自由线程构建中定义,因此您应该使用 #ifdef Py_GIL_DISABLED 来保护调用,以避免在常规构建中出现编译错误。

static struct PyModuleDef moduledef = {
    PyModuleDef_HEAD_INIT,
    ...
};

PyMODINIT_FUNC
PyInit_mymodule(void)
{
    PyObject *m = PyModule_Create(&moduledef);
    if (m == NULL) {
        return NULL;
    }
#ifdef Py_GIL_DISABLED
    PyUnstable_Module_SetGIL(m, Py_MOD_GIL_NOT_USED);
#endif
    return m;
}

通用 API 指南

大多数 C API 都是线程安全的,但也有一些例外。

  • 结构体字段:如果 Python C API 对象或结构体中的字段可能会被并发修改,则直接访问这些字段不是线程安全的。

  • :访问器宏,例如 PyList_GET_ITEMPyList_SET_ITEM,以及使用 PySequence_Fast() 返回的对象的宏,例如 PySequence_Fast_GET_SIZE,不执行任何错误检查或锁定。如果容器对象可能被并发修改,这些宏不是线程安全的。

  • 借用引用:如果包含对象被并发修改,返回借用引用的 C API 函数可能不是线程安全的。有关更多信息,请参阅借用引用部分。

容器线程安全

在自由线程构建中,像 PyListObjectPyDictObjectPySetObject 这样的容器会执行内部锁定。例如,PyList_Append() 在追加项目之前会锁定列表。

PyDict_Next

一个显著的例外是 PyDict_Next(),它不锁定字典。如果字典可能被并发修改,您应该使用 Py_BEGIN_CRITICAL_SECTION 来保护字典在迭代期间。

Py_BEGIN_CRITICAL_SECTION(dict);
PyObject *key, *value;
Py_ssize_t pos = 0;
while (PyDict_Next(dict, &pos, &key, &value)) {
    ...
}
Py_END_CRITICAL_SECTION();

借用引用

一些 C API 函数返回借用引用。如果包含对象被并发修改,这些 API 不是线程安全的。例如,如果列表可能被并发修改,使用 PyList_GetItem() 是不安全的。

下表列出了一些借用引用 API 及其返回强引用的替代品。

借用引用 API

强引用 API

PyList_GetItem()

PyList_GetItemRef()

PyList_GET_ITEM()

PyList_GetItemRef()

PyDict_GetItem()

PyDict_GetItemRef()

PyDict_GetItemWithError()

PyDict_GetItemRef()

PyDict_GetItemString()

PyDict_GetItemStringRef()

PyDict_SetDefault()

PyDict_SetDefaultRef()

PyDict_Next()

无(请参阅PyDict_Next

PyWeakref_GetObject()

PyWeakref_GetRef()

PyWeakref_GET_OBJECT()

PyWeakref_GetRef()

PyImport_AddModule()

PyImport_AddModuleRef()

PyCell_GET()

PyCell_Get()

并非所有返回借用引用的 API 都有问题。例如,PyTuple_GetItem() 是安全的,因为元组是不可变的。类似地,并非所有上述 API 的使用都有问题。例如,PyDict_GetItem() 经常用于解析函数调用中的关键字参数字典;这些关键字参数字典实际上是私有的(其他线程无法访问),因此在这种情况下使用借用引用是安全的。

其中一些函数是在 Python 3.13 中添加的。您可以使用 pythoncapi-compat 包为旧版本的 Python 提供这些函数的实现。

内存分配 API

Python 的内存管理 C API 在三个不同的分配域中提供函数:“raw”、“mem”和“object”。为了线程安全,自由线程构建要求只有 Python 对象使用对象域分配,并且所有 Python 对象都使用该域分配。这与之前的 Python 版本不同,之前的版本这只是一种最佳实践,而不是硬性要求。

备注

在您的扩展中搜索 PyObject_Malloc() 的用法,并检查分配的内存是否用于 Python 对象。使用 PyMem_Malloc() 来分配缓冲区而不是 PyObject_Malloc()

线程状态和 GIL API

Python 提供了一组函数和宏来管理线程状态和 GIL,例如

即使禁用GIL,这些函数仍应在自由线程构建中使用,以管理线程状态。例如,如果您在 Python 之外创建线程,则在调用 Python API 之前,必须调用 PyGILState_Ensure(),以确保线程具有有效的 Python 线程状态。

您应该继续在阻塞操作(例如 I/O 或锁获取)周围调用 PyEval_SaveThread()Py_BEGIN_ALLOW_THREADS,以允许其他线程运行循环垃圾回收器

保护内部扩展状态

您的扩展可能具有以前受 GIL 保护的内部状态。您可能需要添加锁定以保护此状态。方法将取决于您的扩展,但一些常见模式包括

  • 缓存:全局缓存是共享状态的常见来源。考虑使用锁来保护缓存,或者如果缓存对性能不关键,则在自由线程构建中禁用它。

  • 全局状态:全局状态可能需要由锁保护或移动到线程本地存储。C11 和 C++11 提供 thread_local_Thread_local 用于线程本地存储

临界区

在自由线程构建中,CPython 提供了一种称为“临界区”的机制来保护原本受 GIL 保护的数据。虽然扩展作者可能不会直接与内部临界区实现交互,但在使用某些 C API 函数或在自由线程构建中管理共享状态时,了解其行为至关重要。

什么是临界区?

从概念上讲,临界区充当构建在简单互斥锁之上的死锁避免层。每个线程维护一个活动临界区堆栈。当线程需要获取与临界区关联的锁(例如,在调用线程安全的 C API 函数如 PyDict_SetItem() 时隐式获取,或显式使用宏时)时,它会尝试获取底层互斥锁。

使用临界区

使用临界区的主要 API 是

这些宏必须成对使用,并且必须出现在同一个 C 作用域中,因为它们会建立一个新的局部作用域。这些宏在非自由线程构建中是空操作,因此可以安全地添加到需要同时支持两种构建类型的代码中。

临界区的一个常见用途是在访问对象的内部属性时锁定对象。例如,如果扩展类型具有内部计数字段,则可以在读取或写入该字段时使用临界区。

// read the count, returns new reference to internal count value
PyObject *result;
Py_BEGIN_CRITICAL_SECTION(obj);
result = Py_NewRef(obj->count);
Py_END_CRITICAL_SECTION();
return result;

// write the count, consumes reference from new_count
Py_BEGIN_CRITICAL_SECTION(obj);
obj->count = new_count;
Py_END_CRITICAL_SECTION();

临界区的工作原理

与传统锁不同,临界区不能保证在整个持续时间内独占访问。如果线程在持有临界区时会阻塞(例如,通过获取另一个锁或执行 I/O),则临界区会暂时暂停——所有锁都会释放——然后在阻塞操作完成时恢复。

此行为类似于线程进行阻塞调用时 GIL 发生的情况。主要区别在于

  • 临界区按对象而非全局操作

  • 临界区在每个线程内遵循堆栈规则(“begin”和“end”宏强制执行此规则,因为它们必须成对且在同一作用域内)

  • 临界区在潜在的阻塞操作周围自动释放和重新获取锁

死锁避免

临界区通过两种方式帮助避免死锁

  1. 如果线程尝试获取已被另一个线程持有的锁,它会首先暂停所有活动临界区,暂时释放它们的锁。

  2. 当阻塞操作完成时,首先重新获取最顶层的临界区。

这意味着您不能依靠嵌套临界区一次锁定多个对象,因为内部临界区可能会暂停外部临界区。相反,请使用 Py_BEGIN_CRITICAL_SECTION2 同时锁定两个对象。

请注意,上面描述的锁仅基于 PyMutex。临界区实现不了解或不影响可能正在使用的其他锁定机制,例如 POSIX 互斥锁。另请注意,虽然阻塞任何 PyMutex 会导致临界区暂停,但只有作为临界区一部分的互斥锁才会释放。如果 PyMutex 在没有临界区的情况下使用,它将不会被释放,因此不会获得相同的死锁避免。

重要注意事项

  • 临界区可能会暂时释放其锁,允许其他线程修改受保护的数据。在可能阻塞的操作之后,请谨慎对数据状态做出假设。

  • 因为锁可以暂时释放(暂停),所以进入临界区不能保证在整个临界区持续时间内独占访问受保护的资源。如果临界区内的代码调用了另一个会阻塞的函数(例如,获取另一个锁,执行阻塞 I/O),则线程通过临界区持有的所有锁都将被释放。这类似于 GIL 在阻塞调用期间可以被释放的方式。

  • 在任何给定时间,只有与最近进入(最顶层)临界区相关联的锁才能保证被持有。外部、嵌套临界区的锁可能已被暂停。

  • 使用这些 API,您最多可以同时锁定两个对象。如果您需要锁定更多对象,则需要重构您的代码。

  • 虽然如果您尝试两次锁定同一个对象,临界区不会死锁,但对于此用例,它们效率低于专门构建的可重入锁。

  • 使用 Py_BEGIN_CRITICAL_SECTION2 时,对象的顺序不影响正确性(实现处理死锁避免),但始终以一致的顺序锁定对象是一种好习惯。

  • 请记住,临界区宏主要用于保护对可能涉及上述死锁场景的内部 CPython 操作的 Python 对象 的访问。为了保护纯粹的内部扩展状态,标准互斥锁或其他同步原语可能更合适。

为自由线程构建编译扩展

C API 扩展需要专门为自由线程构建编译。轮子、共享库和二进制文件由 t 后缀指示。

受限 C API 和稳定 ABI

自由线程构建目前不支持受限 C API 或稳定 ABI。如果您使用 setuptools 构建扩展并且当前设置了 py_limited_api=True,则可以在使用自由线程构建时使用 py_limited_api=not sysconfig.get_config_var("Py_GIL_DISABLED") 选择不使用受限 API。

备注

您需要专门为自由线程构建单独的轮子。如果您当前使用稳定 ABI,您可以继续为多个非自由线程 Python 版本构建单个轮子。

Windows

由于官方 Windows 安装程序的限制,您在从源代码构建扩展时需要手动定义 Py_GIL_DISABLED=1

参见

移植扩展模块以支持自由线程:一个由社区维护的扩展作者移植指南。