C 扩展与 FFI:ctypes / cffi / pyo3
前面我们一直在 Python 的舒适区里优化。当优化做到尽头,剩下的路只有一条:把那几毫秒的热点交给别的语言去跑。这一章讲四种跨越语言边界的方式——用现成的动态库(ctypes)、写绑定(cffi)、编译 Python 代码(Cython / mypyc)、用 Rust 写扩展(pyo3 加 maturin),以及为什么"带二进制扩展的包"发布起来这么麻烦。
为什么需要扩展:三种动机,别搞混
动机一:CPU 密集的性能。Python 每执行一步都要过解释器(对象装箱、类型检查、字节码分派),同样的运算比 C 慢几十倍。当某个函数被剖析工具确认是热点、又占了总耗时的显著比例时,把那几十行用 C 或 Rust 重写,往往能拿到 10 到 100 倍提升。
动机二:复用现成的 C / C++ 库。你要用 OpenSSL、libgit2、FFmpeg、硬件厂商的 SDK、某个只提供 .so 的算法库——它们只有 C 接口。这时你不需要"更快",你需要"能调"。
动机三:绕过 GIL 做真正的并行计算。一段 C 代码在计算期间可以主动释放 GIL,让其他 Python 线程继续跑。NumPy 的矩阵运算、zlib 的压缩就是靠这个实现"多线程有效"。
| 方式 | 要不要写 C 代码 | 要不要编译 | 典型用途 |
|---|---|---|---|
ctypes | 不写,只写 Python 声明 | 不需要(除非编译那个动态库) | 调现成动态库、调自己写的少量 C 函数 |
cffi | 可选(API 模式要写声明式 C) | API 模式要 | 绑定较复杂的 C 库,比 ctypes 快且好维护 |
| Cython | 写 .pyx(类 Python 语法加类型标注) | 要(需 C 编译器) | 把已有 Python 代码逐步编译加速 |
mypyc | 不写,但要求代码有完整类型标注 | 要 | 整个包加速,改造成本最低 |
| pyo3 加 maturin | 写 Rust | 要(需 Rust 工具链) | 新写高性能核心,附带并发安全 |
论先问三个问题,再决定要不要下场写扩展
① 真的测过了吗?没经过剖析就写扩展,等于在没有地图的情况下挖隧道。多少项目是"把无辜的函数搬到 C 里,结果只快了 0.5%"。② 有没有现成的库?你要的加速可能在 NumPy、SciPy、orjson、uvloop、polars 的向量化里已经有了。自己写扩展是最后的手段,不是首选。③ 边界划对了吗?跨语言调用的开销通常在几十到几百纳秒,如果你把调用放进了热循环里逐元素调用(而不是"整批数据交过去算完拿回来"),跨边界成本会直接抵消掉 C 的优势。结论:扩展的正确形态是"粗粒度边界加大量计算",即一次调用传一批数据、跑很久。
引入 C 扩展意味着:开发者本机需要编译器(Linux 要 gcc,macOS 要 Xcode 命令行工具,Windows 要 MSVC Build Tools);CI 要为每个平台和每个 Python 版本各构建一次;发布要考虑 manylinux;任何 ABI 变化都可能让老 wheel 失效。所以先把成本算清楚:如果省下的时间只有几百毫秒、调用频率是每天一次,那段 C 代码带来的麻烦会比它解决的问题更多。判断标准是"热点乘以频率",不是"看起来能更快"。
ctypes:不改一行 C 代码调用动态库
是什么:ctypes 是标准库自带的 FFI(外部函数接口),能加载 .so / .dll / .dylib 并调用里面的函数,全程不需要编译任何东西。
为什么先用它:因为它把"能不能调通"这件事的成本压到了最低——写几十行 Python 就能验证一个库值不值得用。唯一的代价是它有点啰嗦(每个参数都要声明类型),且有调用开销。
| ctypes 类型 | C 对应 | 易错点 |
|---|---|---|
c_int / c_long | int / long | Linux 64 位上 long 是 64 位,Windows 是 32 位,跨平台要用 c_int64 |
c_char_p | char * | 传的是只读字节串,C 若修改会出事;要可变用 create_string_buffer |
POINTER(T) | T * | 传数组首元素地址,用 array.ctypes.data_as 或 byref |
Structure | struct | 字段顺序与对齐(padding)必须与 C 一致 |
CFUNCTYPE | 函数指针 | 必须持有引用,否则被 GC 导致段错误 |
① 忘了设 restype:返回指针的函数不声明返回类型,ctypes 会把 64 位指针当 32 位 int 截断,你拿到一个非法地址,一用就 Segmentation fault。② 缓冲区被回收:把 create_string_buffer 的结果临时传给 C 并让 C 存起来,Python 侧对象被 GC 后那块内存就没了,之后 C 一访问就崩。③ 字符串编码:c_char_p 需要 bytes,传 str 会抛 TypeError 或写入乱码,记得 .encode("utf-8")。调试手段:段错误不会给你 traceback。用 faulthandler.enable() 打印崩溃位置,或 gdb --args python script.py 拿到 C 层调用栈。
cffi:ABI 模式与 API 模式
是什么:cffi 是另一种 FFI,比 ctypes 更"懂 C"。它有两种工作模式,理解这两者的差别比记 API 更重要。
| 维度 | ABI 模式(ffi.dlopen) | API 模式(ffi.set_source 加 ffi.compile) |
|---|---|---|
| 怎么用 | 直接加载已有的动态库,写 C 声明字符串 | 把声明生成一个真正的 C 扩展模块再编译 |
| 需要编译器 | 不需要 | 需要 |
| 速度 | 和 ctypes 同量级(每次调用要查符号) | 快很多(编译期就绑定好,接近手写扩展) |
| 依赖头文件 | 不需要 | 需要(要 include 真实头文件) |
| 适合 | 快速验证、调现成库、无编译器环境 | 正式绑定、性能敏感的调用 |
论ctypes 和 cffi 该选哪个
① 只在"调一下试试"的阶段:用 ctypes。标准库、零依赖、马上能跑通,用来验证这个库值不值得接。② 要写正式的产品级绑定:用 cffi 的 API 模式。原因有两条——性能(调用开销比 ABI 模式低得多)和正确性(它解析真实的 C 头文件,结构体对齐、宏常量、函数签名都是编译器告诉它的,不会像 ctypes 那样要求你手写布局并可能写错)。③ 绑定的维护成本不容小视:库升级、头文件变化、跨平台差异,手写的 ABI 声明迟早会腐烂。④ 一个现实的替代方案:如果这个库已经有人维护了 pybind11(C++)或 pyo3(Rust)绑定,直接用现成的,别自己造——绑定是那种"写起来三天、调 bug 三周"的活。
很多人用 ABI 模式绑完库,发现"怎么还是慢",于是认定"Python 调 C 就是慢"。真正的问题是模式选错了:ABI 模式每次调用都要在运行时查符号、构造参数,开销和大批 ctypes 调用相当。正确的判断:如果你的调用是"一次传一批数据、函数内部算几毫秒以上",ABI 模式完全够;如果是"热循环里对每个元素都调一次",就必须上 API 模式。顺带一个通用结论:任何 FFI 方案的价值都取决于"计算量除以调用次数"这个比值,而不取决于 FFI 用得多先进。
Cython 与 mypyc:不写 C 的编译加速路线
是什么:这两条路不要求你写 C,而是把你写的 Python(或类 Python 的 Cython 代码)编译成 C 扩展。
Cython:用 .pyx 文件,语法基本就是 Python,额外支持 cdef 声明静态类型、直接调 C 函数、写 C 循环。你标注的类型越多,生成的 C 代码越接近手写 C;一个都不标注时,它只是"把 Python 代码编译一遍",提升有限(通常 1.2 到 2 倍)。
| 方案 | 改造量 | 典型加速 | 适合 |
|---|---|---|---|
| Cython(大量类型标注) | 高:要改成 .pyx 并加 cdef | 10 到 100 倍 | 核心热点模块、需要调 C API |
| Cython(不加标注) | 低:直接编译 .py | 1.2 到 2 倍 | 小幅度加速、少改代码 |
mypyc | 中:全项目补齐类型标注 | 1.5 到 4 倍 | 已有完整类型标注的库 |
| 手写 C 扩展 | 最高 | 10 到 100 倍 | 极致性能、需要精细控制内存 |
| pyo3(Rust) | 高 | 10 到 100 倍 | 新写核心、需要内存与并发安全 |
Cython 的类型声明会改变语义:把变量声明成 cdef long 之后,整数溢出变成静默回绕(Python 的 int 是任意精度,永远不会溢出),而 cdef int 装不下大数时会悄悄给出错误结果。另一个高频坑是 cdef 变量不参与 Python 的异常语义,数组越界这类问题可能变成未定义行为而不是 IndexError。纪律:① 加类型标注前后跑同一套测试,尤其是边界值和负数;② 只给确实需要的变量加 C 类型,别为了快一点把语义搞乱;③ 编译产物在 build/ 里,记得写进 .gitignore,并且在 CI 里也构建一次验证。
Rust pyo3 加 maturin:新写扩展的现代默认
是什么:pyo3 是 Rust 写 Python 扩展的库;maturin 是配套的构建工具,负责把 Rust 项目打包成可以直接 pip install 的 wheel。定位是"想拿原生性能,但不想面对 C 的内存管理地狱"。
为什么它现在受欢迎:Rust 没有 GC,也不需要你手动 free(所有权系统在编译期管内存),因此"引用计数泄漏""double free""野指针"这几类最容易让扩展崩溃的问题从根上消失了;同时它能安全地做并发。官方生态里从 Python 迁到 Rust 的例子已经很多(polars、pydantic-core、ruff、tokenizers)。
论为什么 pyo3 的扩展更容易"不出事"
① 内存由编译器管:C 扩展里最难的引用计数(Py_INCREF 与 Py_DECREF 配对)在 pyo3 里被 Bound 和 Py<T> 的 RAII 包装接管,函数返回时自动处理,你不写也不会泄漏。② 类型系统挡住低级错误:悬垂指针、数据竞争、忘记初始化在 Rust 里是编译错误,而不是"线上偶发段错误"。③ 错误处理是显式的:PyResult 把 Rust 错误转成 Python 异常,不再靠返回值或 errno。④ 代价是真实的:需要 Rust 工具链、编译时间比 C 长、团队要有人会 Rust,学习曲线存在。⑤ 一个判断建议:团队里没人写过 Rust 时,先问"这段热点能不能用现成的库解决";确实要自己写核心时,pyo3 的长期维护成本通常低于手写 C 扩展。
发布带二进制扩展的包:manylinux 与 cibuildwheel
问题是什么:纯 Python 包一个 py3-none-any wheel 走天下;带二进制的包要为"每个平台乘以每个 Python 版本"各构建一个 wheel。矩阵有多大?Linux(x86_64 与 aarch64)乘以 glibc 与 musl,再乘 Windows、macOS(x86_64 与 arm64),再乘 CPython 3.9 到 3.13,轻松上百个组合。
为什么 Linux 特别麻烦:你的 wheel 是在某台机器上编译的,它链接了那台机器的 glibc 版本和一堆 .so。用户机器上如果没有对应版本,就会报 ImportError: libxxx.so.1: cannot open shared object file。manylinux 就是为解决它而生的:在"古老"的 CentOS 基础镜像里编译(glibc 足够老,向后兼容),并把依赖的非系统库打进 wheel 里,让这份二进制在任何较新的 Linux 上都能跑。标签里的 manylinux_2_17 意思就是"要求 glibc 至少 2.17"。
论为什么"发布"是写扩展最贵的部分
① 构建矩阵是组合爆炸:新增一个 Python 版本、新增一个 CPU 架构(比如 ARM 服务器变多),都意味着要重跑一遍全部构建。个人开发者很难覆盖全平台。② 依赖要一起打包:如果你的扩展链接了 libz、libssl,用户机器上版本不一致就会崩。auditwheel(Linux)、delocate(macOS)、delvewheel(Windows)的作用就是把非系统库塞进 wheel 并改好 RPATH,但这一步有不少坑(比如许可证、体积膨胀)。③ 调试靠用户:你只在 x86_64 Linux 上测过,ARM 用户报崩溃时你连复现环境都没有。④ 所以现实策略是:优先做"纯 Python 实现加可选加速"——纯 Python 版本保证所有平台都能用,加速版通过 CI 覆盖主流平台;剩下的平台让用户从源码编译(并在文档里写清楚需要什么工具链)。给用户一条退路,比追求"全平台完美"更实际。
C 扩展通常绑定具体的 CPython ABI(所以标签里有 cp312)。Python 3.13 换了新版本,你为 3.12 编译的 wheel 就不能用了,pip 只能去找 3.13 的 wheel 或退回源码编译。缓解手段有两种:① abi3(稳定 ABI)——用 Py_LIMITED_API 只调用长期稳定的 C API,产出的 wheel 标签是 abi3(如 cp38-abi3),一个文件能覆盖 3.8 之后的多个版本;代价是不能用全部 C API,某些高性能技巧受限。② Rust 的 abi3——pyo3 支持 features = ["abi3-py38"],同样能产出跨版本 wheel。另外要注意:不要混用不同 Python 的构建产物(比如用 3.11 的头文件编 3.12 的扩展),会出现"能 import 但一调用就崩"的诡异现象,且极难排查。构建环境永远用目标 Python 自己。
常见坑合辑:引用计数、GIL 释放、ABI
这一节把前面散落的坑集中成排查表。写扩展时把右列当成"发版前必查清单"。
| 现象 | 原因与修法 |
|---|---|
| 进程越跑内存越大,最后 OOM | 引用计数泄漏:Py_INCREF 多、Py_DECREF 少(常见于临时对象与异常路径提前 return)。用 pyo3 的 RAII 包装,或在 C 里确保每条 return 路径都减引用 |
偶发 Segmentation fault,难以复现 | 释放后又使用、越界写、GIL 被释放期间访问了 Python 对象。用 faulthandler、AddressSanitizer(ASAN_OPTIONS)或 Valgrind 定位 |
| 多线程下崩溃或数据错乱 | 在释放 GIL 的代码块里访问了 Python 对象或 API。规则:释放 GIL 的代码块里只能碰纯 C 数据,回到 Python 侧前必须重新获取 GIL |
| 长计算把其他线程全卡住 | 没释放 GIL。在长循环里用 Py_BEGIN_ALLOW_THREADS 与 Py_END_ALLOW_THREADS 包裹纯计算部分(pyo3 是 py.allow_threads) |
| 换 Python 小版本就 import 失败 | ABI 不兼容。为每个版本构建,或改用 abi3 稳定 ABI |
| 用户机器报找不到某个 .so | 依赖的非系统库没打进 wheel。用 auditwheel / delocate / delvewheel 处理,或在文档里声明系统依赖 |
| C 侧回调 Python 函数导致崩溃 | 回调对象被 GC(必须持有引用),或回调里抛了异常而 C 侧没处理。回调里要包 try / except 并把错误转成返回码 |
| wheel 体积异常大 | 把调试符号也打进去了。构建时加 -g0 或 strip,Rust 侧配 [profile.release] strip = true |
论引用计数到底在配什么
① 规则很简单:每个"新引用"必须有一个对应的释放。函数返回一个新对象给调用者(如 PyLong_FromLong)时,引用计数已经是 1,交给调用者负责;借用来的引用(如 PyList_GetItem)不要减;保存到自己的结构体里(如 PyList_SetItem)等于"偷走"一个引用,你不能再减。② 出错路径最容易被忽略:参数校验失败提前 return NULL 时,前面已经创建的对象就泄漏了。C 里要写成 goto cleanup 模式,或者用 pyo3 让作用域退出时自动处理。③ 为什么用 pyo3 会省心:Rust 的借用检查器会在编译期告诉你"这个值在这里已经被移动/借出了",等价于把引用计数错误变成了编译错误。④ 验证方法:可以写个测试跑 10 万次循环,观察 sys.getrefcount 或进程 RSS 是否稳定——这是唯一能证明"没泄漏"的方法。
Python 代码出错抛异常,最坏情况是接口 500;C 扩展出错是整个进程直接死——比如越界写破坏了堆,或者在释放 GIL 时碰了 Python 对象。更麻烦的是它往往在很久之后、另一段无关的代码里才爆出来,让你怀疑错地方。四条纪律:① 所有跨语言边界都必须校验(长度、类型、空指针),不要假设 Python 侧传的一定合法;② 上线前用 AddressSanitizer 跑一遍测试套件;③ 关键路径保留纯 Python 实现作为回退(配置开关切换),别把整个服务押在一段新写的 C 上;④ 扩展涉及的所有权与线程约束,写进代码注释和文档——半年后接手的人不会自己猜出来。
① 三种动机:CPU 热点加速、复用现成 C 库、绕开 GIL 做并行。动手之前先确认"热点乘以频率"够不够回本。
② ctypes 零编译、上手最快,适合验证;务必声明 argtypes 与 restype、持有回调引用、用 bytes 传字符串。
③ cffi 的 ABI 模式等价于 ctypes,API 模式要编译但快得多;正式绑定优先 API 模式,并优先考虑现成的 pybind11 / pyo3 绑定。
④ Cython / mypyc 是"编译 Python",收益取决于类型标注;注意 C 类型会改变整数溢出与异常语义。
⑤ pyo3 加 maturin 是新写扩展的现代默认:编译期管内存、错误处理显式、能用 allow_threads 释放 GIL;代价是要维护 Rust 工具链。
⑥ 发布是最贵的环节:manylinux 保证 Linux 兼容、cibuildwheel 覆盖构建矩阵、abi3 减少版本数量;永远保留纯 Python 回退路径。
⑦ 清单:引用计数配对、释放 GIL 时不碰 Python 对象、跨版本 ABI 匹配、非系统库打进 wheel。
1.(概念题)什么情况下值得写 C 扩展?什么情况下不值得?
查看答案
答案:值得:经剖析确认的热点函数、且调用频率高(热点乘以频率足够大);或者必须复用只有 C 接口的现成库;或者需要释放 GIL 做并行计算。不值得:没测量过就优化、数据量小到瓶颈在别处、以及"只是想更快一点"——因为写扩展会引入跨平台构建、ABI、发布等一整套长期成本。
2.(概念题)ctypes 里最容易导致进程崩溃的三个错误是什么?
查看答案
答案:① 没声明 restype,返回的 64 位指针被当 32 位 int 截断,拿到非法地址;② 传给 C 的缓冲区或回调对象被 Python 侧 GC 回收,C 再访问就是野指针;③ 字符串类型没转 bytes(c_char_p 需要 bytes),导致类型错误或内存写坏。
3.(概念题)cffi 的 ABI 模式和 API 模式有什么区别?什么时候必须用 API 模式?
查看答案
答案:ABI 模式直接 dlopen 已有动态库、不需要编译器、每次调用要查符号,速度与 ctypes 相当;API 模式把头文件声明的绑定编译成真正的 C 扩展,速度快很多但要编译器。当调用发生在热循环里(对每个元素调一次)或对延迟敏感时,必须用 API 模式。
4.(概念题)在 C 扩展里 Py_BEGIN_ALLOW_THREADS 和 Py_END_ALLOW_THREADS 之间能做什么、不能做什么?
查看答案
答案:这段区间不持有 GIL,所以只能操作纯 C 数据(数组、结构体、文件句柄等),不能调用任何 CPython API、不能访问 PyObject,否则可能崩溃或数据错乱。它的用途正是让长计算期间其他 Python 线程能继续跑。
5.(思考题)你的 Rust 扩展在开发机(macOS ARM)上一切正常,用户(Linux x86_64)pip install 后报"找不到匹配的 wheel"。请给出完整解释和解决方案。
查看答案
答案:你在 macOS ARM 上构建,wheel 的平台标签是 macosx_..._arm64,Linux 用户标签不匹配,pip 只能回退去编 sdist;而 sdist 还需要 Rust 工具链,装不上就失败。解决:在 CI 里用 maturin-action(或 cibuildwheel)为 x86_64 / aarch64 / musl / Windows / macOS 各构建对应 wheel 并一起上传;如果用 abi3 特性还能让一个 wheel 覆盖多个 Python 版本,减少矩阵。同时提供纯 Python 回退实现并在文档说明源码构建的前置条件。