本节目标:能用 C API 把 CPython 嵌进宿主程序并取回结果;从 3.14.6 的真实头文件读懂自由线程构建改动了什么,并准确区分「能实测」与「只能读源码」的部分。
适用版本:Python 3.12+(实测 3.14.6)
11.2 嵌入式与自由线程运行时
5.2 自由线程构建与迁移影响 回答的是「迁不迁、代价多大」;本节把镜头对准运行时本身——一头是把解释器嵌进 C 程序,另一头是 t 构建下对象与引用计数的物理布局。两者都靠直接读头文件和真编译来落地。
11.2.1 最小嵌入式解释器(真编译真跑)
用 Python 头文件写一个宿主 C 程序,把解释器当脚本引擎用:
#include <Python.h>
int main(void) {
Py_Initialize();
printf("Py_GetVersion: %s\n", Py_GetVersion());
int rc = PyRun_SimpleString(
"import sys\n"
"print('embedded run, version =', sys.version.split()[0])\n"
"print('gil enabled =', sys._is_gil_enabled())\n");
printf("PyRun_SimpleString rc = %d\n", rc);
int rc2 = PyRun_SimpleString("1/0\n"); /* 故意抛异常 */
printf("second call rc = %d\n", rc2);
if (Py_FinalizeEx() < 0) return 120;
return 0;
}
编译时 python3-config --includes 给头文件路径,--ldflags --embed 给嵌入模式的链接参数(少了 --embed 会找不到 Py_Initialize):
cc embed.c -o embed $(python3-config --includes) $(python3-config --ldflags --embed)
真实输出(本机 Apple clang 16.0.0 + Homebrew 3.14.6):
Traceback (most recent call last):
File "<string>", line 1, in <module>
ZeroDivisionError: division by zero
embedded run, version = 3.14.6
gil enabled = True
Py_GetVersion: 3.14.6 (main, Jun 10 2026, 10:03:53) [Clang 21.0.0 ...]
PyRun_SimpleString rc = 0
second call rc = -1
两个容易被忽略的点:
PyRun_SimpleString用返回值报告成败:成功返回0,脚本抛异常返回-1(traceback 打印到 stderr,但不终止宿主)。- 输出顺序是乱的:traceback 先冒出来,是因为 stderr 无缓冲、而 stdout 在非 tty 下是块缓冲。嵌入时若不主动 flush,日志顺序会误导排查。
11.2.2 用 PyConfig 初始化
Py_Initialize() 是最简入口,现代嵌入更推荐 Py_InitializeFromConfig——它把初始化参数收敛成一个 PyConfig 结构,可控且失败时返回 PyStatus 而非直接 abort:
PyConfig config;
PyConfig_InitPythonConfig(&config);
config.parse_argv = 0; /* 不解析宿主 argv */
config.optimization_level = 1; /* 等价 python -O */
config.write_bytecode = 0; /* 不写 .pyc */
PyStatus status = Py_InitializeFromConfig(&config);
PyConfig_Clear(&config); /* 无论成败都要清理 */
if (PyStatus_Exception(status)) { Py_ExitStatusException(status); }
$ ./embed2
sum of squares 0..9 = 285
configured optimization_level = 1
running optimization_level = 1
config.optimization_level 设进去后,运行期可用 Py_OptimizeFlag 读到同一个值——但它在 3.12 起已标记 deprecated(编译时报 -Wdeprecated-declarations),新代码应从 PyConfig 侧管理这项状态。
11.2.3 从 C 取回 Python 的值
嵌入最常见的需求是「跑一段 Python,把结果拿回 C」。用 __main__ 模块的 globals 字典取回对象:
PyRun_SimpleString("result = sum(i * i for i in range(10))");
PyObject *globals = PyModule_GetDict(PyImport_AddModule("__main__"));
PyObject *res = PyDict_GetItemString(globals, "result"); /* 借引用 */
long value = PyLong_AsLong(res);
printf("sum of squares 0..9 = %ld\n", value); /* 285 */
PyImport_AddModule("__main__") 与 PyDict_GetItemString 返回的都是借引用(borrowed reference),无需 Py_DECREF;PyLong_AsLong 把对象转成 C 的 long。这套「跑脚本 → 取全局名 → 转 C 类型」正是把 Python 当脚本引擎嵌进游戏、编辑器、科学计算宿主的最小骨架。
11.2.4 自由线程构建改了什么:读真实头文件
先如实交代环境:本机是标准构建,不是自由线程(t)构建,所以运行期数据都取自标准构建,机制描述则直接读 3.14.6 安装的真实头文件(比 PEP 703 的规范草案更权威)。
import sys, sysconfig
print("sys._is_gil_enabled():", sys._is_gil_enabled()) # True
print("Py_GIL_DISABLED :", sysconfig.get_config_var("Py_GIL_DISABLED")) # 0
print("SOABI :", sysconfig.get_config_var("SOABI")) # cpython-314-darwin
$ PYTHON_GIL=0 python3 -c "print('ok')"
Fatal Python error: config_read_gil: Disabling the GIL is not supported by this build
object.h 里,PyObject 的布局被 #ifndef Py_GIL_DISABLED / #else 分成两套。标准构建:
struct _object {
union {
PY_INT64_T ob_refcnt_full; /* 整个 union 的 64 位视图 */
struct {
uint32_t ob_refcnt; /* 引用计数(32 位) */
uint16_t ob_overflow; /* 溢出计数 */
uint16_t ob_flags; /* 标志位 */
};
};
PyTypeObject *ob_type;
};
自由线程构建(同文件 #else 分支):
struct _object {
uintptr_t ob_tid; /* 属主线程 id,0 表示无主(永生/已合并) */
uint16_t ob_flags;
PyMutex ob_mutex; /* 每个对象一把 1 字节锁 */
uint8_t ob_gc_bits; /* GC 状态(原在 PyGC_Head) */
uint32_t ob_ref_local; /* 属主线程本地引用计数 */
Py_ssize_t ob_ref_shared; /* 共享引用计数 + 状态位 */
PyTypeObject *ob_type;
};
两点值得注意:一是连标准构建的引用计数都变了——不再是裸的 Py_ssize_t ob_refcnt,而是 uint32 + uint16 + uint16 的 union,为永生对象与溢出预留了位;二是 t 构建把对象头显著撑大(多出 tid、mutex、两套 refcount),这正是 5.2 里「单线程内存上升」的物理来源。
11.2.5 无 GIL 下的引用计数:偏向计数与共享状态位
refcount.h 给出了共享引用计数的状态机——低两位是标志位,其余位才是计数:
#define _Py_REF_SHARED_SHIFT 2
#define _Py_REF_SHARED_FLAG_MASK 0x3
#define _Py_REF_SHARED_INIT 0x0 /* 纯共享计数 */
#define _Py_REF_MAYBE_WEAKREF 0x1 /* 存在弱引用 */
#define _Py_REF_QUEUED 0x2 /* 已入合并队列 */
#define _Py_REF_MERGED 0x3 /* 已合并 */
_Py_INCREF 的分支直接体现了「偏向」:
if (_Py_IsOwnedByCurrentThread(op)) {
_Py_atomic_store_uint32_relaxed(&op->ob_ref_local, new_local); /* 快路径 */
} else {
_Py_atomic_add_ssize(&op->ob_ref_shared, (1 << _Py_REF_SHARED_SHIFT)); /* 慢路径 */
}
属主线程增减引用只动自己的 ob_ref_local(relaxed 存储,不参与跨核同步);其他线程才走原子的 ob_ref_shared。 这就是「偏向引用计数」:单线程程序的计数操作几乎不退化,代价只在真正跨线程共享对象时付出。当属主线程退出或对象被跨线程频繁访问时,计数会被「合并」进共享字段(_Py_REF_MERGED),此后所有访问都走原子路径——头文件里 _PyObject_MergePerThreadRefcounts / _PyObject_DisablePerThreadRefcounting 就是这套「按线程计数 → 合并」机制的两个入口。
永生对象在 t 构建里用 ob_ref_local == UINT32_MAX(_Py_IMMORTAL_REFCNT_LOCAL)表示,彻底跳过增减。标准构建同样有永生对象(PEP 683),实测 sys.getrefcount 对 None、小整数、驻留字符串返回同一个巨大的哨兵值:
import sys
print(sys.getrefcount(None)) # 3221225472
print(sys.getrefcount(256)) # 3221225472
print(sys.getrefcount(object())) # 3(普通对象)
派活消息里提到的 QSR 在 3.14.6 的公开头文件中检索不到对应符号(
grep -rn QSR无结果),故本节不展开这个缩写,只讲能实证的_Py_REF_*状态位与偏向计数机制。
11.2.6 每对象一把锁与 Python 临界区
t 构建里每个对象带一个 PyMutex,但它不是普通互斥锁——cpython/lock.h 说明它只占一个字节,用最低两位编码四种状态:
_bits | 含义 |
|---|---|
0b00 | 未加锁 |
0b01 | 已加锁 |
0b10 | 未加锁,但有线程在等待(parked) |
0b11 | 已加锁,且有线程在等待 |
_PyMutex_Lock 先做一次 CAS,失败才落到慢路径 PyMutex_Lock 把线程 park 起来——「无竞争时零系统调用」。
但「每对象一把锁」会带来 GIL 时代不存在的死锁:Python 操作会嵌套,多线程若按不同顺序拿锁就会互锁。cpython/critical_section.h 给出的解法是临界区(critical section):它是加在 per-object lock 之上的「死锁规避层」,允许线程在嵌套操作时挂起外层锁,且只在真会阻塞时才挂起(减少加解锁次数),I/O 等阻塞操作前后也会挂起锁。头文件里那句注释点破了本质——critical section 与 per-object lock 一起,替代了 GIL 为 dict 等对象提供的线程安全。
11.2.7 嵌入时的 GIL 与宿主线程协作
嵌入场景里,宿主进程往往自带线程(UI 线程、网络线程)。此时 CPython 的 GIL 与宿主线程模型如何协作,是必须想清楚的一环:
- 谁持有 GIL:
Py_Initialize之后,调用它的那个宿主线程成为解释器主线程并持有 GIL。宿主的其他线程若要调用 Python API,必须先PyGILState_Ensure()取得 GIL,用完PyGILState_Release()归还。 - 长计算要主动让出:一段纯 C 长循环若不释放 GIL,会阻塞所有 Python 线程;嵌入方应在循环里周期性用
Py_BEGIN_ALLOW_THREADS/Py_END_ALLOW_THREADS释放再取回。 - 子解释器:3.12+ 支持 per-interpreter GIL(PEP 684),3.14 的
concurrent.interpreters(PEP 734)把它带到标准库层——这与 5.2 里实测的「多子解释器真并行」是同一套机制。
(上述 API 本机未单独编译验证,只讲接口契约。)嵌入时把「谁在什么时候持有 GIL」想清楚,比记 API 名字更重要。
11.2.8 能实测与不能实测的边界
| 维度 | 标准构建(本机 3.14.6) | 自由线程 t 构建 |
|---|---|---|
Py_GIL_DISABLED | 0 | 1 |
sys._is_gil_enabled() | True | 默认 False,可用 PYTHON_GIL=1 开回 |
PyObject 布局 | ob_refcnt/ob_overflow/ob_flags union | ob_tid + PyMutex + 两套 refcount |
| 嵌入开关 | — | PyConfig.enable_gil(仅 #ifdef Py_GIL_DISABLED 下存在) |
对嵌入方来说,最后一行最实际:控制 GIL 的字段 enable_gil 只编译进 t 构建,标准构建的头文件里根本没有它。也就是说,能不能在嵌入时开关 GIL,取决于你链接的是哪套 ABI——这又回到 11.1 讲的 abi 标签问题。
本节无法在本机跑自由线程解释器(需从源码 ./configure --disable-gil 构建),所有 t 构建结论均来自 3.14.6 的真实头文件,运行期行为未经本机验证。
小结
- 嵌入 CPython 的最小骨架是
Py_Initialize→PyRun_SimpleString→Py_FinalizeEx;PyRun_SimpleString用0/-1报告成败,异常不终止宿主。本机真编译真跑通过。 - 现代嵌入用
Py_InitializeFromConfig+PyConfig,参数可控、失败返回PyStatus;config.optimization_level与运行期Py_OptimizeFlag(3.12 起 deprecated)对应同一状态。 - 从 C 取回结果:
PyImport_AddModule("__main__")→PyModule_GetDict→PyDict_GetItemString(借引用)→PyLong_AsLong,实测sum(i*i for i in range(10))得285。 - 3.14.6 的
object.h里PyObject有两套布局:标准构建是ob_refcnt/ob_overflow/ob_flags的 union;t 构建多出ob_tid、PyMutex、ob_gc_bits与两套 refcount——对象头明显变大。 - 无 GIL 下的引用计数靠偏向计数:属主线程改
ob_ref_local(快路径),其他线程原子改ob_ref_shared(慢路径),低两位编码_Py_REF_QUEUED/_Py_REF_MERGED等状态。 PyMutex只占 1 字节、两位编码四态;临界区在其上做死锁规避,替代 GIL 给 dict 等的线程安全。- 本机为标准构建,t 构建结论全部来自真实头文件;控制 GIL 的
PyConfig.enable_gil只在 t 构建中编译进来。
从运行时回到生态,下一节 11.3 PEP 流程与版本迁移策略 讲这些变化是怎么被提案、被弃用、被迁移进你的项目的。
阅读导航:上一节:11.1 打包与分发机制 · 下一节:11.3 PEP 流程与版本迁移策略 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。