Python 与 C 互操作:把热点下沉到 C 的正确姿势
更新时间:2026-09-02。本文回答:Python 太慢,怎么让一段代码真正跑在 C 速度上?
ctypes到底怎么调 C 函数,为什么不声明argtypes就会崩?能让 C 反过来调用 Python 函数吗?结构体、数组怎么传?听说过 Cython、C 扩展、NumPy,它们分别是什么、该选哪个? 上一篇 05 · 字节码与求值循环 解释了「Python 为什么慢」——慢在求值循环的解释开销和一切皆对象的动态分派。这一篇讲工程上的标准答案:把计算密集的热点下沉到 C,Python 只负责编排(glue code)。本文 6 个 demo 全部在服务器(CentOS 7,gcc 4.8.5,Python 3.6.8)实测,代码见文末「代码位置」。
一、为什么要互操作:80/20 法则与「两层语言」架构
先立一个判断标准:不要一上来就写 C。 绝大多数 Python 程序的瓶颈不在「语言慢」,而在算法、I/O、或重复计算。正确顺序是:先 profile(见 01 · 性能优化路径)找到热点 → 用更好的数据结构/算法/内置函数优化 → 如果热点确实是「大量数值计算」且 Python 层循环无法消除,才考虑下沉 C。
典型的「两层语言」架构:Python 做胶水——读配置、组织流程、处理 I/O、拼业务逻辑,这些部分慢一点无所谓(往往在等网络/磁盘);C 做引擎——把那 5% 真正吃 CPU 的内层循环(数值计算、编解码、压缩、图像处理、仿真)用 C 实现,一次调用处理一大批数据。NumPy、OpenCV、TensorFlow 全是这个模式:你在 Python 里写 a @ b,真正的矩阵乘法跑在 C/Fortran 里。
这条路线有四种主流实现,成本和适用面递增:
| 方案 | 是什么 | 改动量 | 适用 |
|---|---|---|---|
| NumPy / 内置 C 库 | 直接用已有的 C 实现库 | 零 | 数值向量化、标准库能干的事 |
| ctypes | 标准库 FFI,运行时加载 .so/.dll 调 C | 小,写 C + Python 绑定 | 复用现成 C 库、不想引入构建依赖 |
| Cython | Python 超集,可加类型标注编译成 C 扩展 | 中,.pyx + 编译 | 大段数值代码想渐进加速 |
| CPython C API 扩展 | 直接用 Python.h 写原生扩展模块 | 大,纯 C + 引用计数 | 生态库、需要深度操作对象 |
本文用 ctypes 讲透互操作的本质,因为它零额外依赖(标准库自带)、不需要理解 CPython 内部对象模型就能调任意 C 函数,最能看清「Python 与 C 之间到底发生了什么」。
二、ctypes:运行时加载 C 共享库
ctypes 是 Python 自带的外部函数接口(FFI, Foreign Function Interface)。它能在运行时加载一个 C 共享库(Linux 的 .so、Windows 的 .dll、macOS 的 .dylib),然后像调普通函数一样调库里的 C 函数。
最省事的是直接调系统的 C 运行库 libc——连编译都不用。demo1(d1_ctypes_libc/libc_demo.py):
getpid() = 31699 (this Python process)
getuid() = 1001
strlen(b'hello ctypes') = 12
abs(-42) = 42
atoi(b'12345') = 12346
CDLL loaded from: libc.so.6
pid matches os.getpid() ? True这里藏着 ctypes 的两条铁律:
CDLL表示 C 调用约定(WinDLL是 Windows 的 stdcall)。ctypes.CDLL("libc.so.6")让操作系统把 libc 动态链接进来。- 必须声明类型签名。ctypes 默认把每个函数当成「返回
int、参数也是int」。这对getpid()恰好正确,但对strlen(参数是指针、返回size_t)就会在 64 位系统上崩——指针被当成 32 位 int 截断。所以要显式设置:f.restype:返回值类型;f.argtypes:参数类型列表。 字符串要传bytes(c_char_p),不是 Python 的str——因为 C 的char*是字节,Unicodestr得先.encode()。
这些 c_int / c_double / c_char_p / c_size_t 就是 ctypes 对 C 类型的一一映射,它们在内存里的表示和 C 完全一致,这是后续传数组、传结构体的基础。
三、调用我们自己编译的 C 库:数组与指针
调 libc 不过瘾,真正的场景是调自己写的 C。把 C 源码编译成共享库(position-independent code):
gcc -O2 -shared -fPIC -o libmymath.so mymath.c-shared 生成共享库,-fPIC 生成位置无关代码(共享库必需)。demo2(d2_mymath/)的 C 端写了三个函数:add_i、对 double 数组求平均的 avg_d、以及 CPU 密集的整数点积 dot_i。Python 端用 ctypes 加载并声明签名:
add_i(3, 4) = 7
avg_d([1,2,3,4,10]) = 4.0
dot_i([1,2,3,4],[5,6,7,8]) = 70传数组的关键是 ctypes 的数组类型:ArrT = c_double * n 先构造一个「长度为 n 的 double 数组」类型,再 arr = ArrT(1.0, 2.0, ...) 实例化。这个 arr 在内存里就是一段连续的 double,和 C 的 double arr[5] 二进制一致,把它传给 POINTER(c_double) 参数即可。指针参数用 ctypes.POINTER(c_double) 声明。
对照 C:这正是 C 里「数组名退化为指针」的边界——Python 侧的 ctypes 数组对象,传给 C 时就是首元素地址。理解 C 的指针/数组关系见 C 指针深入。
四、C 反过来调 Python:回调函数指针
互操作不是单向的。C 库里大量 API 是回调风格的(qsort 的比较器、事件循环的 handler、遍历的钩子)。ctypes 用 CFUNCTYPE 把一个普通 Python 函数包装成 C 能调用的函数指针。
demo3(d3_callback/)的 C 端拥有循环,但对每个元素调用我们传入的函数:reduce_cb(arr, n, f) 用 Python 函数 f 变换每个元素再累加;count_if(arr, n, pred) 用 Python 谓词计数。
reduce_cb(square) over 1..6 = 91
count_if(is_even) over 1..6 = 3
reduce_cb(closure x*10) = 210CFUNCTYPE(restype, *argtypes) 声明函数指针类型,再用 @REDUCE_FN 装饰器(或 REDUCE_FN(pyfunc))把 Python 可调用对象包成 C 回调。注意闭包也能当回调(x*10 捕获了 k=10),但有一个致命的生命周期坑:C 持有回调指针期间,Python 侧的回调对象必须保持引用存活,否则被垃圾回收后 C 再调用就会段错误。这是 ctypes 回调最常见的崩溃源。
本质:C 侧只存了一个函数地址,完全不知道它背后是个需要 GC 管理的 Python 对象。生命周期责任在调用方——这和 C 里你必须保证回调期间函数指针有效是一回事。
五、结构体:按值传与按指针传
C 的结构体和 Python 的类内存布局完全不同,但 ctypes 让你用 ctypes.Structure 子类声明一个二进制兼容的镜像:_fields_ 按相同顺序、相同类型列出字段即可。demo4(d4_structs/)演示三种传法:
dist^2 by value = 25.0
midpoint(out*) = Point(1.5, 2.0)
centroid by val = Point(2.0, 4.0)- 按值传参 / 按值返回:
argtypes=[Point, Point]、restype=Point,ctypes 会把整个结构体拷进/拷出(C 的值语义)。 - 按指针传 + 输出参数:C 里极常见的「
void f(..., T* out)」 idiom——不传返回值,而是传一个指针让 C 往里写结果。Python 侧用ctypes.byref(p)传地址,调用后读mid.x/mid.y。byref比pointer()轻量,专用于这种「只在本次调用内借地址」的场景。 - 结构体数组:
Point * 3造数组,首元素地址即Point*,和第三节的普通数组同理。
字段顺序必须和 C 严格一致,否则读到的就是错位的字节(C 结构体还有对齐 padding,ctypes 会按 C 规则自动补齐,但混布类型时要心里有数)。C 结构体布局与对齐详见 结构体对齐。
六、提速实测与调用边界
6.1 热循环下沉 C:约 32 倍
demo5(d5_speedup/)对 200 万个 double 算「残差平方和」(两遍循环:求均值、再累加偏差平方)。纯 Python vs 编译成 .so 后 ctypes 调用:
n = 2000000
pure Python : 0.1268 s result=166666500000.000
C via ctypes: 0.0040 s result=166666500000.000
results close: True
speedup: 31.8x约 32 倍提速,结果一致。这印证了 05 的结论:Python 慢在每条字节码的解释开销和对象装箱;同样的循环在 -O2 的 C 里是紧凑的原生浮点指令,连 SIMD 都可能自动用上。注意数据只在进 C 前搬运一次(list → C 数组),然后整个计算在 C 内部完成。
6.2 边界粒度:一次批量调用,而非逐元素调用
新手最容易犯的错:以为「调 C 就快」,于是在 Python 的 for 循环里每个元素调一次 C 函数。但每一次跨 Python↔C 边界都有固定开销(类型转换、GIL、调用约定)。demo6(d6_boundary/)用 inc_one(每次只 +1,调 200 万次)对比 inc_batch(一次调用处理整个数组):
n = 2000000
per-element FFI (N calls): 1.4183 s
one batch call (1 call) : 0.3214 s
ratio: 4.4x slower when calling per element
both computed the same sum? True逐元素跨边界比批量慢 4.4 倍,而且逐元素版甚至比纯 Python 还慢——每次 FFI 的开销远超 +1 本身。
核心原则:边界要粗,不要细。 把「循环」整体搬进 C,而不是把「循环体的一步」搬进 C。这也是 NumPy 的设计哲学——向量化本质就是「一次操作一整个数组,循环在 C 里」。
七、ctypes 之外:C API、Cython、NumPy 怎么选
- NumPy(首选,零胶水):只要问题能表达成多维数组上的批量运算,优先用 NumPy,底层就是 C/Fortran,通常不用自己写 C。
- ctypes(本文):适合复用已有的 C 库(你只有
.so/头文件)、或不想引入编译期依赖的小模块。纯运行时、标准库自带、构建简单;缺点是类型签名全靠手写声明、出错常是段错误而非异常、每次调用有 FFI 开销。 - CPython C API(原生扩展):
#include <Python.h>,用PyObject*、引用计数(Py_INCREF/Py_DECREF)、PyArg_ParseTuple手写模块。功能最强、能深度操作 Python 对象,但最繁琐、要手动管引用计数(泄漏/崩溃风险高)、跨版本维护成本大。对象模型见 02 · CPython 内部机制。 - Cython:Python 的超集,允许给变量加 C 类型(
cdef int i、cdef double[:] x),编译成 C 扩展。能在一个文件里渐进地「Python 写法 + C 速度」,是 SciPy/pandas 大量加速代码的选择;代价是引入.pyx编译步骤。 - GIL 注意:在 C 扩展里做纯计算(不碰 Python 对象)时可以
Py_BEGIN_ALLOW_THREADS释放 GIL,让多线程真正并行;ctypes 调用普通 C 函数期间默认持有 GIL。GIL 机制见 00 · GIL、多进程与 asyncio。
八、交叉引用与延伸阅读
| 你想接着了解 | 去读 |
|---|---|
| 下沉 C 之所以快的根因:字节码解释开销与动态分派 | 05 · 字节码与求值循环 |
原生扩展里操作的 PyObject*、引用计数、内存布局 | 02 · CPython 内部机制 |
| 释放 GIL 让 C 计算并行、何时用多进程/线程 | 00 · GIL、多进程与 asyncio |
| 先 profile 找热点,再决定值不值得下沉 C | 01 · 性能优化路径 |
| C 的指针/数组、结构体与对齐 | C 指针深入、结构体对齐 |
| C 编译成机器码的四阶段(对照 Python 的解释执行) | C 01 · 编译、汇编与运行 |
九、坑速查
- 不声明
argtypes/restype就崩:ctypes 默认按 int 处理,64 位下指针/size_t/double必错。凡涉及指针、浮点、64 位整数,先声明签名。 - 字符串传
str而非bytes:c_char_p要的是b"...";传str会报类型错误。返回的c_char_p也是 bytes,需.decode()。 - 回调对象被 GC:C 持有函数指针期间,Python 侧回调(尤其闭包)必须保持引用,否则段错误。把回调存到长生命周期的变量/列表里。
- 逐元素跨边界调用:在 Python
for里每次调一个小 C 函数,FFI 开销吞掉所有收益(实测慢 4.4x)。一次传整片数据,让 C 拥有循环。 - 结构体字段顺序/类型不匹配:
_fields_必须和 C 严格一致,否则读到错位字节;注意对齐 padding。 - 忘记
restype导致指针截断:返回指针/句柄的函数不设restype=c_void_p,在 64 位上会被截成 32 位而崩溃。 - 内存归属混乱:C
malloc的内存 Python 不会自动释放(要调对应的 free);Python 传给 C 的 buffer 在调用返回后可能失效,C 若长期持有指针要自己拷贝。 - 跨平台扩展名:Linux 是
.so、Windows 是.dll、macOS 是.dylib;用sys.platform或ctypes.util.find_library做可移植加载。
十、代码位置
本文 6 个 demo 均在服务器(CentOS 7,gcc 4.8.5,Python 3.6.8)实测通过,仓库路径 demos/python-expert/c-interop/。先 bash build.sh 编译 5 个共享库,再 python3 <demo>/xxx.py 运行(或直接 bash run_all.sh):
| Demo | 文件 | 演示内容 |
|---|---|---|
| d1 | d1_ctypes_libc/libc_demo.py | ctypes 直接调 libc(getpid/strlen/atoi)、argtypes/restype 声明、c_char_p 传 bytes |
| d2 | d2_mymath/mymath.c + call_mymath.py | 自编 .so(-shared -fPIC)、数组类型 c_double*n、指针传参 |
| d3 | d3_callback/cb.c + call_cb.py | CFUNCTYPE 把 Python 函数/闭包包成 C 回调(reduce/谓词计数) |
| d4 | d4_structs/structs.c + call_structs.py | ctypes.Structure 二进制镜像、按值传/返回、byref 输出参数、结构体数组 |
| d5 | d5_speedup/hot.c + bench.py | 200 万 double 残差平方和:纯 Python vs C 实测 31.8x |
| d6 | d6_boundary/boundary.c + bench_boundary.py | 逐元素 FFI vs 一次批量调用,实测 慢 4.4x(边界粒度教训) |
服务器上:cd /tmp/c-interop && bash run_all.sh(会自动 gcc -O2 -shared -fPIC 编译并依次运行)。
十一、动手练习
- 用 ctypes 调 libc 的
qsort对一个整数数组排序:用CFUNCTYPE写比较器,体会「C 拥有排序循环、Python 只提供比较逻辑」。 - 给 d2 的
avg_d故意不设argtypes再运行,观察在 64 位下的错误/崩溃,理解类型声明为什么是强制的。 - 写一个 C 函数处理一维
unsigned char*缓冲区 + 长度,Python 侧用 ctypes 数组传入做一次逐元素变换,对比纯 Python 的耗时。 - 把 d5 的计算改用 NumPy(
((x - x.mean())**2).sum())重写,对比 NumPy、ctypes-C、纯 Python 三者耗时,理解「向量化 ≈ 内置的下沉 C」。 - 用 Cython(若环境允许)把 d5 的纯 Python 函数加
cdef类型标注编译,对比它与 ctypes 方案的代码量和性能,用一句话总结各自适用场景。