Python原生扩展能否非全程持有GIL构建并返回dict?
即使你创建的Dict仅在扩展内部使用、未对外暴露,释放GIL后调用PyDict_SetItem、PyUnicode_FromString这类CPython内置API依然是不安全的,核心原因在于CPython底层的全局状态依赖GIL保护,具体可拆解为以下几点:
1. 内存分配器的线程不安全特性
CPython默认使用的pymalloc内存分配器并非线程安全,所有PyObject的创建、销毁都依赖GIL来保证内存操作的原子性。你代码中的PyUnicode_FromString会触发内存分配,无GIL时多线程并发分配内存会直接导致堆结构损坏,进而引发段错误。
2. 隐性全局状态的并发冲突
很多内置API看似只操作局部对象,实则会修改全局共享状态:
- 字符串驻留(intern机制):
PyUnicode_FromString会尝试将短字符串加入全局intern表以复用对象,这个表的访问和修改没有额外锁,完全依赖GIL保护,多线程并发操作会导致表结构混乱。 - 垃圾回收的隐性依赖:CPython的垃圾回收机制依赖GIL触发和执行,无GIL下创建的对象可能被错误标记为垃圾,或引发引用计数统计异常。
3. 内置对象API的设计前提
CPython所有内置对象API(如PyDict_*、PyUnicode_*)的设计前提是调用时必须持有GIL,即使操作的是私有对象,API内部也可能存在未加锁的全局变量访问、临时内存操作等逻辑,这些逻辑在无GIL的并发场景下会直接触发未定义行为。
你的代码崩溃原因分析
多线程调用generate_dict时,每个线程释放GIL后并发执行PyUnicode_FromString和PyDict_SetItem:
- 并发的内存分配操作破坏了
pymalloc的堆结构 - 字符串intern表的并发修改导致指针越界或无效引用
这些问题最终表现为随机位置的段错误(如你输出中[14] allocating pyunicode..时崩溃)。
可行的性能优化方案
想要在反序列化时提升并发性能,无需在无GIL下操作内置Dict,推荐以下两种方案:
方案1:无GIL下构建本地结构,批量转换为PyDict
在释放GIL的阶段,用Rust原生的线程安全结构(如HashMap<String, String>)存储所有键值对,待数据准备完成后重新持有GIL,一次性将本地结构内容插入PyDict。示例思路:
#[pyfunction] fn generate_dict(py: Python<'_>, num: usize) -> PyResult<&PyDict> { // 释放GIL,在Rust侧构建数据 let thread_state = unsafe { PyEval_SaveThread() }; let mut local_map = std::collections::HashMap::new(); for i in 0..num { let s = i.to_string(); local_map.insert(s.clone(), s); } unsafe { PyEval_RestoreThread(thread_state) }; // 持有GIL,批量插入PyDict let d = PyDict::new(py); for (k, v) in local_map { d.set_item(k, v)?; } Ok(d) }
方案2:IO阶段释放GIL,填充Dict阶段保留GIL
将文件读取的IO操作放到GIL之外执行,读取完成后再持有GIL填充Dict。IO操作本身是耗时的阻塞操作,释放GIL后可让其他Python线程并行执行,而填充Dict的CPU操作在GIL下执行,既安全又能获得大部分性能收益。
内容的提问来源于stack exchange,提问作者bplevin

