tasklab 的词频任务可以附带 CPU 工作负载:把文本的每个字符按固定公式折叠成校验值,重复指定次数。这个值只用于执行模式对照,不会替换词频查询结果。把循环搬进 C 扩展之前,要先回答两个问题:输入 str 如何交给按字节读取的 C 函数,以及 C 持有数据的期间由谁保证它仍然有效?

本次复跑使用仓库外的两个精确解释器:CPython 3.12.3 macOS arm64 与 CPython 3.13.9 experimental free-threading macOS arm64。E03_build.py 直接读取目标解释器的 sysconfig 编译参数,把 .o 和 .so 都放在 /private/tmp/python-language-completion/native/;源码目录只保存 C 源码、构建脚本、比对脚本和证据。扩展不是主线 wheel 的隐藏依赖。

迁移的是哪个循环

纯 Python 基线 cpu_work(text, iterations=...) 对 str 逐字符取 ord,每步执行 (checksum * 33 + value) % 1000000007。experiments/E03_fastbytes.c 只迁移这个校验循环:参数限定为精确的不可变 bytes,先检查迭代次数确实是 int 且非负,再用 PyObject_GetBuffer(data, &buffer, PyBUF_SIMPLE) 取得一份只读使用的缓冲区视图。借来的 buffer.buf 在有效期内用于循环;退出循环先 PyBuffer_Release(&buffer),再创建结果 int。PyArg_ParseTuple 得到的对象引用和 Py_buffer 保持的导出者引用不是同一项所有权;漏掉 release 会让导出者被额外持有。参数错误都发生在获取 buffer 之前,因而失败路径没有已获得却未释放的 view。

这里的缓冲区接口没有消除所有复制。str 是 Unicode 码点序列,入口 text.encode("ascii") 会生成新的 bytes;从这个 bytes 对象到 C 循环才没有再做一次文本副本。非 ASCII 文本不能逐 UTF-8 字节复算码点:é 的码点不是它的两个 UTF-8 字节。compute 对非 ASCII 直接回退原 cpu_work,保持语义。直接传 bytearray 或 str 给扩展均抛 TypeError,这里拒绝可变导出者,避免把 free-threaded 下可能被另一线程修改的缓冲区误当稳定输入。更一般的可写 memoryview、跨步视图和外部内存所有权不是这个示例支持的合同。Python 3.12 缓冲区 C API描述了 view 的获取、有效期和释放。

正例对 "one two one"、空串、ASCII 文本 "e one"、含制表符和换行的 ASCII 文本各跑 0、1、13 次,另对 "é one" 与 "漢字 A" 验证回退路径,合计 14 条结果逐项与纯 Python 函数相等。另测精确 bytes 以外的三种数据、True、负数和超大迭代数六条拒绝路径;bool 虽是 int 子类,这里仍与主线 type(iterations) is int 的合同保持一致。对于超大整数转换到 Py_ssize_t,错误是 OverflowError,不冒充统一 ValueError。原 run 接受新计算作为 workload 钩子后,仍把词频结果 {"one":2,"two":1} 写成 succeeded;校验值本身没有变成数据库结果,更不能由此推断外部副作用完成。

哪个构建允许关掉 GIL

编译出了两个不同 ABI 的动态模块:普通 CPython 3.12.3 macOS arm64 的 cpython-312-darwin,和仓库外已冻结的 CPython 3.13.9 free-threaded macOS arm64 构建的 cpython-313t-darwin。它们不是通用的 abi3 wheel,不可互换。后一版的模块定义在 3.13 条件分支加入 Py_mod_gil: Py_MOD_GIL_NOT_USED;声明本身不证明任意扩展线程安全,当前代码只读取不可变的精确 bytes、没有共享可变 C 全局状态。更多输入或回调需要重新审查。

3.13.9t 运行记录里 sys._is_gil_enabled() 在导入前、导入后和本轮结束时均为 False;同一组选定正反例在 free-threaded 构建下复跑,校验值、拒绝错误和普通 3.12.3 一致。另以两个线程经屏障同时读取同一份不可变 bytes,两次结果均为 754902074,调用前后 GIL 仍为 False。反例用同一构建编译 38_legacyprobe.c,单独进程导入时收到运行时警告,sys._is_gil_enabled() 从 False 变成 True。这里把“编译器支持 free-threaded”“模块声明兼容”“导入后实际 GIL 状态”拆开验证,而不是拿 3.12 的表现替代 3.13t。Python 3.13 free-threading 扩展指南说明 Py_mod_gil 声明及旧扩展导入时的后果。

本次 3.13.9t 运行记录显示 Py_GIL_DISABLED=1、sys._is_gil_enabled() 为 False,_sqlite3 是 built-in 模块;这只说明该精确运行时具备 SQLite 扩展入口。本章仍只跑无需 SQLite 的选定工作负载,不能声称完整数据库回归通过。普通 3.12.3 路径从当前源码树加载 tasklab,以绝对路径加载独立编译的 C 模块;新增可选原生单测 2 项通过。主线包在没有 C 编译器的机器上依然可以单独安装。

测到的耗时与留存边界

输入为 600 个 ASCII 字符、120 次折叠;先暖机一次,再用 perf_counter_ns 对完整 Python 调用做五次顺序取样,C 侧样本也包含 str.encode("ascii")。普通 3.12.3 基线五次为 2.570–3.718 ms,C 包装路径 0.245–0.386 ms;3.13.9t 对应 3.053–4.716 ms 与 0.245–0.247 ms。两组测量来自不同构建、不同执行状态,不能据此宣称某个解释器整体更快,也不能将循环吞吐直接等同于真实任务吞吐。原始五个纳秒数、系统和模块哈希都保留在 examples/python-language/evidence/completion/native/。

错误与成功路径各重复 10,000 次,普通 3.12.3 中所用 bytes 的 getrefcount 前后都是 5,tracemalloc 当前留存 32 字节、峰值 120 字节;这只覆盖本次输入和被追踪的 Python 分配,不等于 C 堆的通用无泄漏证明。3.13t 给该对象的引用计数返回 4294967295 标记,前后相等不能拿来证明所有权正确;该运行的 tracemalloc 当前留存 48 字节、峰值 168 字节。没有运行 C sanitizer 或跨平台构建,均标为 NOT_RUN。审计资源时以“成功调用必经 PyBuffer_Release,验证失败发生在获取之前”这个代码路径和受限观测为依据,不把一个数字吹成普遍结论。

预测题。 画出 compute("é", 1, extension) 和 compute("one", 1, extension) 的数据路径,并判断哪次调用会获取 Py_buffer。答案:前者识别为非 ASCII,直接走原 cpu_work,没有把 UTF-8 字节当作码点;后者先编码生成新 bytes,C 调用获取 view,计算后释放,结果与原函数一致。可核对 14 条正例及图中的分叉。

变更题。 把扩展的输入放宽成可变 bytearray,同时从另一线程修改它,能否据现有证据声称结果确定、free-threaded 兼容?答案:不能。本章的类型守卫会先抛 TypeError,已有双线程对照共享的是不可变 bytes;移除守卫意味着要定义同步和数据所有权、扩展错误路径并在目标构建复验 GIL 状态。现有 Py_mod_gil 声明只覆盖这份受限实现,不等于所有新输入都安全。

参考资料

  • Python 3.12 Buffer Protocol:获取和释放 view 的资源合同。
  • Python 3.13 C API Extension Support for Free Threading:模块声明与未声明扩展导入行为。
  • examples/python-language/experiments/E03_fastbytes.c、examples/python-language/experiments/E03_build.py、examples/python-language/experiments/E03_compare.py、examples/python-language/evidence/completion/native/:独立构建源码、正反例、运行时 GIL 与未执行边界。