问题描述
在 C++ 与 Python 混合编程(Python 编译为 pyd)的项目中,C++ 调用 pyd 启动一个 Python HTTP 服务程序,服务可以启动,但是无法处理响应。
- 同步启动阻塞:若同步启动 HTTP Server,虽服务正常,但会阻塞 C++ 主线程,导致后续 C++ 业务(如 cax)无法启动。
- 子线程启动无响应 :改为 C++ 创建子线程启动 HTTP Serve 后,服务端口可见,但 HTTP Client 无法得到响应。只有当 C++ 再次调用
pyd程序或 C++ 进程退出瞬间,积压的请求才会被突然处理。
GIL
GIL是Python解释器中的一种机制,它是一把全局锁,用于保护解释器免受多线程并发访问的影响。这意味着Python在同一时刻只允许一个线程执行Python字节码。GIL实际上是一个互斥锁,在Python解释器层面上实现。由于GIL的存在,同一时刻只有一个线程能够获得解释器的控制权,其他线程被阻塞,无法执行Python字节码。这意味着在多核CPU上,Python的多线程程序可能无法充分利用多核性能。
GIL的作用:
- 防止多线程竞争: GIL确保同一时刻只有一个线程执行Python字节码。
- 限制CPU密集型任务的并行性,简化内存管理: 对于CPU密集型任务,由于GIL的存在,多线程无法充分利用多核CPU,因为在任何给定时刻,只有一个线程能够执行Python字节码。
根因分析
问题的核心在于 Python GIL(全局解释器锁)在 C++ 主线程初始化后未被正确释放,导致 Python 解释器无法进行正常的线程调度。
- 主线程长期持有 GIL :在初始化嵌入式 Python 环境调用
Py_InitializeFromConfig()后,C++ 主线程会自动持有 GIL。由于未调用PyEval_SaveThread(),主线程在调用完 Python 的start()并返回后,依然持有 GIL。 - Python 后台线程饥饿:HTTP 的后台线程(asyncio 事件循环)在处理请求时需要获取 GIL。由于主线程不释放 GIL,后台线程处于"饥饿"状态,无法处理请求。
- 触发机制 :当 C++ 再次调用
pyd时,构造函数会通过PyGILState_Ensure()重新获取 GIL。这一动作间接触发了 Python 内部的线程调度机制,使得之前被阻塞的后台线程终于有机会获取 GIL 并处理积压的请求。
解决方案
"主线程释放 GIL"和"子操作获取 GIL"两端进行闭环管理:
1. 初始化阶段,主动释放主线程 GIL
- 在环境初始化函数中,
Py_InitializeFromConfig()执行完毕后,立即调用PyEval_SaveThread()。 - 作用是释放当前主线程持有的 GIL,并将主线程状态保存至成员变量
m_mainThreadState(PyThreadState*)。 - 效果是明确告知 Python 解释器主线程已放弃 GIL,允许其他 Python 线程(如 HTTP Server 线程)正常参与调度。
2. 运行阶段,为所有 C API 调用补齐 GIL 保护
由于主线程在初始化后已释放 GIL,后续任何从 C++ 侧调用 Python C API 的操作,都必须在持有 GIL 的安全环境下进行,否则会导致访问违规或崩溃。因此函数统一添加手动获取 GIL(构造时 PyGILState_Ensure,析构时 PyGILState_Release)。这些函数从外部线程调用时,通过构造函数的 PyGILState_Ensure() 正确获取 GIL,执行后析构 PyGILState_Release() 释放 GIL。
总结对比
修改前 :
Python环境初始化前主线程无 GIL,Python 未初始化,初始化后,主线程持有 GIL(Py_InitializeFromConfig 后自动持有),Python 后台线程饿死导致服务无响应。
修改后 :
Python环境初始化后,PyEval_SaveThread() 释放了 GIL,保存线程状态到 m_mainThreadState。
形成了闭环:
C++ 主线程调用 Python pyd -> 获取 GIL -> 执行 Python C API -> 释放 GIL -> Python 解释器可自由调度 -> HTTP 线程可获取 GIL 处理请求。