楼层: 首页/ 软件技术/ Python 基础/ C 扩展与 FFI:ctypes / cffi / pyo3
16

C 扩展与 FFI:ctypes / cffi / pyo3

Native Extensions & FFI

前面我们一直在 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 就能验证一个库值不值得用。唯一的代价是它有点啰嗦(每个参数都要声明类型),且有调用开销。

# 先准备一个 C 库:libmath.c // 编译: // Linux: gcc -shared -fPIC -O2 -o libmath.so libmath.c // macOS: cc -shared -fPIC -O2 -o libmath.dylib libmath.c // sum_array 返回数组元素之和 long sum_array(const long *data, int n) { long total = 0; for (int i = 0; i < n; i++) total += data[i]; return total; } // fill 通过指针写回结果 void fill(double *out, int n, double value) { for (int i = 0; i < n; i++) out[i] = value; }
import ctypes import time from ctypes import POINTER, c_double, c_int, c_long import numpy as np lib = ctypes.CDLL("./libmath.so") # 加载动态库 # 必须声明参数与返回类型,否则 ctypes 按 int 猜,64 位指针会被截断 lib.sum_array.argtypes = [POINTER(c_long), c_int] lib.sum_array.restype = c_long lib.fill.argtypes = [POINTER(c_double), c_int, c_double] lib.fill.restype = None # 方式一:从 numpy 数组零拷贝拿到指针,避免把百万元素转成 Python 列表 arr = np.arange(1_000_000, dtype=np.int64) ptr = arr.ctypes.data_as(POINTER(c_long)) # 直接指向数组内存,不复制 t = time.perf_counter() total = lib.sum_array(ptr, len(arr)) print(f"lib 求和 = {total} 耗时 {time.perf_counter() - t:.4f}s") # 方式二:用 ctypes 分配缓冲区,让 C 函数往里面写 n = 5 buf = (c_double * n)() # 定长数组类型 lib.fill(buf, n, 3.14) print(list(buf)) # [3.14, 3.14, 3.14, 3.14, 3.14]
# 定义结构体(必须和 C 里的内存布局完全一致) class Point(ctypes.Structure): _fields_ = [("x", c_double), ("y", c_double)] # C 侧签名:double distance(const Point *a, const Point *b); lib.distance.argtypes = [POINTER(Point), POINTER(Point)] lib.distance.restype = c_double a, b = Point(0.0, 0.0), Point(3.0, 4.0) print(lib.distance(ctypes.byref(a), ctypes.byref(b))) # 5.0 # 回调:把 Python 函数传给 C 调用(C 侧类型 typedef int (*cb)(int)) CALLBACK = ctypes.CFUNCTYPE(c_int, c_int) def on_event(code): print(f"回调收到 {code}") return 0 # 必须把回调对象保存到变量:临时对象被 GC 掉会让 C 侧调用野指针,直接崩溃 _cb = CALLBACK(on_event) lib.register_callback(_cb)
ctypes 类型C 对应易错点
c_int / c_longint / longLinux 64 位上 long 是 64 位,Windows 是 32 位,跨平台要用 c_int64
c_char_pchar *传的是只读字节串,C 若修改会出事;要可变用 create_string_buffer
POINTER(T)T *传数组首元素地址,用 array.ctypes.data_as 或 byref
Structurestruct字段顺序与对齐(padding)必须与 C 一致
CFUNCTYPE函数指针必须持有引用,否则被 GC 导致段错误
ctypes 最常见的三种"直接崩溃"

① 忘了设 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 真实头文件)
适合快速验证、调现成库、无编译器环境正式绑定、性能敏感的调用
# ABI 模式:不编译,直接跑 from cffi import FFI ffi = FFI() # 把 C 声明原样写进来 ffi.cdef(""" long sum_array(const long *data, int n); void fill(double *out, int n, double value); """) lib = ffi.dlopen("./libmath.so") arr = ffi.new("long[]", [1, 2, 3, 4]) print(lib.sum_array(arr, 4)) # 10 buf = ffi.new("double[5]") lib.fill(buf, 5, 1.5) print([buf[i] for i in range(5)])
# API 模式:生成真正的 C 扩展,速度快得多 # build_math.py from cffi import FFI ffi = FFI() ffi.cdef("long sum_array(const long *data, int n);") ffi.set_source( "my_ext", # 生成的模块名 '#include "libmath.h"', # 真实头文件 sources=["libmath.c"], # 一起编译的源文件 libraries=["m"], ) if __name__ == "__main__": ffi.compile(verbose=True) # 产出可直接 import 的扩展模块 # 之后代码里就能 from my_ext import lib 直接调用,像普通模块一样

论ctypes 和 cffi 该选哪个

① 只在"调一下试试"的阶段:用 ctypes。标准库、零依赖、马上能跑通,用来验证这个库值不值得接。② 要写正式的产品级绑定:用 cffi 的 API 模式。原因有两条——性能(调用开销比 ABI 模式低得多)和正确性(它解析真实的 C 头文件,结构体对齐、宏常量、函数签名都是编译器告诉它的,不会像 ctypes 那样要求你手写布局并可能写错)。③ 绑定的维护成本不容小视:库升级、头文件变化、跨平台差异,手写的 ABI 声明迟早会腐烂。④ 一个现实的替代方案:如果这个库已经有人维护了 pybind11(C++)或 pyo3(Rust)绑定,直接用现成的,别自己造——绑定是那种"写起来三天、调 bug 三周"的活。

cffi 的 ABI 模式被"悄悄"用成了瓶颈

很多人用 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 倍)。

# fast.pyx —— 关键是把变量声明成 C 类型 # cython: language_level=3 def sum_squares(long n): cdef long i, total = 0 # 声明成 C 的 long,不再走 Python 对象 for i in range(n): total += i * i return total
# pyproject.toml 里的构建配置(Cython 常配合 setuptools) [build-system] requires = ["setuptools>=69", "Cython>=3.0"] build-backend = "setuptools.build_meta" # setup.py(Cython 项目里仍然常见,用于声明扩展模块) from setuptools import Extension, setup from Cython.Build import cythonize setup(ext_modules=cythonize([Extension("fast", ["fast.pyx"])], language_level=3))
# mypyc:不写新语法,但要求代码有完整类型标注 $ pip install mypy $ mypyc fast_mod.py # 生成 C 扩展并编译 # 同一份源码,加不加类型标注,mypyc 的收益差别巨大 def sum_squares(n: int) -> int: # ← 有标注才会编译出高效的 C total: int = 0 i: int for i in range(n): total += i * i return total
方案改造量典型加速适合
Cython(大量类型标注)高:要改成 .pyx 并加 cdef10 到 100 倍核心热点模块、需要调 C API
Cython(不加标注)低:直接编译 .py1.2 到 2 倍小幅度加速、少改代码
mypyc中:全项目补齐类型标注1.5 到 4 倍已有完整类型标注的库
手写 C 扩展最高10 到 100 倍极致性能、需要精细控制内存
pyo3(Rust)高10 到 100 倍新写核心、需要内存与并发安全
Cython 编译不报错,不代表逻辑正确

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)。

# 1. 初始化项目(前提:本机装了 Rust 工具链 rustup 加 cargo) $ pip install maturin $ maturin new --bindings pyo3 my_ext $ cd my_ext # 生成的目录 my_ext/ ├── Cargo.toml ├── pyproject.toml # [build-system] requires = ["maturin"] ├── src/ │ └── lib.rs └── python/my_ext/__init__.py
# Cargo.toml [package] name = "my_ext" version = "0.1.0" edition = "2021" [lib] name = "my_ext" crate-type = ["cdylib"] # 关键:编成动态库给 Python 用 [dependencies] pyo3 = { version = "0.22", features = ["extension-module"] }
// src/lib.rs —— 一个最小扩展:Rust 版求和 use pyo3::prelude::*; /// 对一批整数求和,Vec 由 Python 侧的列表自动转换而来 #[pyfunction] fn sum_i64(values: Vec<i64>) -> i64 { values.iter().sum() } /// 计算期间释放 GIL,让其他 Python 线程能继续跑 #[pyfunction] fn heavy_compute(py: Python<'_>, n: u64) -> PyResult<u64> { Ok(py.allow_threads(|| { let mut total: u64 = 0; for i in 0..n { total = total.wrapping_add(i.wrapping_mul(i)); } total })) } #[pymodule] fn my_ext(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_function(wrap_pyfunction!(sum_i64, m)?)?; m.add_function(wrap_pyfunction!(heavy_compute, m)?)?; Ok(()) }
# 2. 开发:装进当前虚拟环境,改完代码自动重编 $ maturin develop --release # 3. 立刻用起来 $ python -c "import my_ext; print(my_ext.sum_i64([1,2,3,4]))" 10 # 4. 构建可发布的 wheel $ maturin build --release # 产出当前平台的 wheel $ maturin publish # 构建并上传 PyPI $ maturin build --release --target x86_64-unknown-linux-gnu # 交叉编译

论为什么 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"。

# cibuildwheel:在 CI 里自动构建全平台 wheel # pyproject.toml 配置 [tool.cibuildwheel] build = "cp39-* cp310-* cp311-* cp312-* cp313-*" # 要构建的 Python 版本 manylinux-x86_64-image = "manylinux2014" # 老镜像,兼容更广的 glibc test-command = "python -c 'import my_ext; my_ext.sum_i64([1,2])'" # 构建后自动冒烟测试 skip = "*-musllinux_i686" # 跳过不关心的组合 [tool.cibuildwheel.linux] archs = ["x86_64", "aarch64"]
# .github/workflows/wheels.yml name: Build wheels on: [push, workflow_dispatch] jobs: wheels: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: pypa/cibuildwheel@v2.20 - uses: actions/upload-artifact@v4 with: path: ./wheelhouse/*.whl # Rust 扩展的官方推荐:PyO3 官方 action,会自动处理交叉编译 # - uses: PyO3/maturin-action@v1 # with: # target: x86_64-unknown-linux-musl # args: --release --out dist

论为什么"发布"是写扩展最贵的部分

① 构建矩阵是组合爆炸:新增一个 Python 版本、新增一个 CPU 架构(比如 ARM 服务器变多),都意味着要重跑一遍全部构建。个人开发者很难覆盖全平台。② 依赖要一起打包:如果你的扩展链接了 libz、libssl,用户机器上版本不一致就会崩。auditwheel(Linux)、delocate(macOS)、delvewheel(Windows)的作用就是把非系统库塞进 wheel 并改好 RPATH,但这一步有不少坑(比如许可证、体积膨胀)。③ 调试靠用户:你只在 x86_64 Linux 上测过,ARM 用户报崩溃时你连复现环境都没有。④ 所以现实策略是:优先做"纯 Python 实现加可选加速"——纯 Python 版本保证所有平台都能用,加速版通过 CI 覆盖主流平台;剩下的平台让用户从源码编译(并在文档里写清楚需要什么工具链)。给用户一条退路,比追求"全平台完美"更实际。

ABI 不兼容:为什么你的 wheel 会在新 Python 上报错

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
// C 扩展里释放 GIL 的标准写法(CPython C API) static PyObject *heavy(PyObject *self, PyObject *args) { long n; if (!PyArg_ParseTuple(args, "l", &n)) return NULL; long result = 0; // 这两句之间的代码不持有 GIL:只能操作纯 C 数据,不能碰任何 PyObject Py_BEGIN_ALLOW_THREADS for (long i = 0; i < n; i++) result += i * i; Py_END_ALLOW_THREADS return PyLong_FromLong(result); // 回到 Python 侧,GIL 已重新持有 }

论引用计数到底在配什么

① 规则很简单:每个"新引用"必须有一个对应的释放。函数返回一个新对象给调用者(如 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。

自测 · C 扩展与 FFI

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 回退实现并在文档说明源码构建的前置条件。