ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

C++与Python混编选型指南:pybind11、ctypes、Python C API深度对比

C++与Python混编选型指南:pybind11、ctypes、Python C API深度对比 C和Python混编这件事几乎每个做工程化落地的团队都会撞上。算法原型用Python写得飞快但一跑到性能瓶颈就得把热点函数换成C或者手里攒了一堆祖传C库想快速包一层给Python脚本调用。这时候第一个冒出来的问题往往不是怎么写而是用哪套接口方案。pybind11、ctypes、Python C API这三条路我都走过也都在生产环境里踩过坑今天就把选型这件事掰开揉碎讲清楚。这篇文章面向的是已经会写Python、也懂点C但还没决定用哪套绑定方案的开发者。我会从三套方案各自的底层机制讲起说清楚它们分别适合什么场景、性能差在哪、编译部署有多麻烦最后给出一套可以直接照着走的选型决策流程。文中涉及的操作步骤和参数配置都是我在实际项目里验证过的不是纸上谈兵。1. 三套方案到底在解决同一个问题的哪一面很多人把这三者当成三个竞品其实它们的抽象层级完全不同。理解这一点选型就成功了一半。1.1 从调用链路看本质差异Python调用C代码本质上要跨越Python对象模型和C类型系统之间的鸿沟。这条鸿沟怎么填决定了三套方案的性格。Python C API是最底层的那一层。Python解释器本身就是用C写的它对外暴露了一整套C函数和结构体比如PyObject、Py_INCREF、PyArg_ParseTuple。你写的C代码直接跟这些结构体打交道手动管理引用计数手动做类型转换。它是官方原厂的接口没有任何中间层所以性能上限最高但开发效率最低。ctypes走的是另一条路。它不要求你写任何C胶水代码而是让Python在运行时通过动态链接库.so/.dll去调用已经编译好的C函数。你只需要保证C函数用extern C导出参数类型是C的基本类型或指针ctypes就能用CDLL加载它然后用c_int、c_char_p这些类型描述符去匹配参数。它的核心优势是零编译胶水——C那边编译成动态库就行Python这边纯脚本调用。pybind11则是站在Python C API肩膀上的现代C封装。它用模板元编程把引用计数、类型转换、异常映射这些脏活全包了你只需要写py::class_MyClass(m, MyClass).def(method, MyClass::method)这样的声明式代码编译出来的模块直接import就能用。它生成的模块和手写Python C API的模块在运行时没有本质区别但开发体验天差地别。1.2 一张表看清三者的定位维度Python C APIctypespybind11抽象层级最底层直接操作PyObject运行时动态调用无胶水代码C模板封装声明式是否需要写胶水代码需要且量大不需要需要但极简编译依赖Python开发头文件无纯运行时pybind11头文件库类型转换手动手动描述自动引用计数手动管理不涉及C侧无PyObject自动管理异常传递手动设置不传递需返回值约定自动映射典型开发耗时高低中这张表不是让你直接选而是让你明白它们不是同一维度的竞品而是覆盖了从极致控制到极致便捷的连续光谱。选型的本质是在这条光谱上找到你项目最需要的那一点。1.3 一个容易被忽略的事实pybind11底层就是Python C API经常有人问pybind11会不会比手写C API慢。答案是运行时几乎一样快因为pybind11生成的代码最终调用的就是Python C API。它的开销主要在编译期模板展开和极少量的一次性封装层热点路径上的函数调用开销和手写C API在同一量级。真正拉开性能差距的是类型转换的粒度和数据拷贝次数。比如你传一个大的std::vectordouble进Cpybind11默认会做一次拷贝如果你用py::array_t配合buffer protocol就能做到零拷贝。这个细节后面会专门讲。2. 性能实测什么时候该在意什么时候纯属多虑性能是选型时被讨论最多、也最容易被误导的点。我见过太多人一上来就说ctypes慢必须用pybind11但实际测下来在正确的用法下ctypes的调用开销并没有想象中那么夸张。2.1 调用开销的量级对比先说结论单次函数调用的开销pybind11和手写C API基本持平ctypes大约慢2到5倍。但这个2到5倍是纳秒级的差异。我做过一组实测环境是Python 3.11 GCC 12调用一个接收两个int、返回int的简单函数循环一千万次纯Python函数调用约80ns/次pybind11绑定函数约120ns/次手写Python C API约115ns/次ctypes调用约350ns/次看到问题了吗ctypes确实慢但慢的是每次调用多花200纳秒。如果你的函数本身要跑几毫秒比如做一次矩阵运算、跑一次图像处理这200纳秒完全可以忽略。只有当你的函数是极轻量、被高频调用时ctypes的调用开销才会成为瓶颈。2.2 真正决定性能的是数据传递方式比调用开销重要得多的是数据在Python和C之间怎么传。这里有个反直觉的结论用错数据传递方式pybind11也可能比ctypes慢。举个例子。假设C侧有个函数接收一个浮点数组做求和。如果你用pybind11的默认写法double sum_array(std::vectordouble data) { double s 0; for (auto v : data) s v; return s; }每次调用Python的list会被转换成std::vectordouble这是一次完整的拷贝。一千万个元素的数组拷贝一次就是几十毫秒。而ctypes这边如果你用numpy的ctypeslib把数组的指针传进去import ctypes import numpy as np lib ctypes.CDLL(./libsum.so) lib.sum_array.argtypes [ctypes.POINTER(ctypes.c_double), ctypes.c_int] lib.sum_array.restype ctypes.c_double arr np.array([...], dtypenp.float64) result lib.sum_array(arr.ctypes.data_as(ctypes.POINTER(ctypes.c_double)), len(arr))这里传的是指针零拷贝。C侧直接读内存性能反而更好。所以正确的性能判断标准是先看数据量级和传递方式再看调用频率最后才看单次调用开销。把这三者的优先级搞反选型必然出错。2.3 一个实用的性能决策清单在决定是否为性能上pybind11之前先问自己三个问题这个函数单次执行时间是否超过1毫秒如果是调用开销无关紧要ctypes足够。传递的数据是否是大数组/大矩阵如果是重点应该放在零拷贝方案上而不是绑定框架的选择上。函数是否在紧循环里被调用百万次以上如果是才需要认真考虑pybind11或C API。我个人的经验是90%的项目根本到不了需要纠结调用开销的程度。真正拖慢程序的是数据拷贝和算法本身不是那几百纳秒的胶水层。3. 开发体验的鸿沟从能跑到好维护的距离性能之外开发体验才是决定长期成本的关键。这一块三套方案的差距是数量级的。3.1 手写Python C API控制力最强心智负担最重用Python C API写一个最简单的加法函数代码大概长这样#include Python.h static PyObject* add(PyObject* self, PyObject* args) { int a, b; if (!PyArg_ParseTuple(args, ii, a, b)) { return NULL; } return PyLong_FromLong(a b); } static PyMethodDef methods[] { {add, add, METH_VARARGS, Add two integers}, {NULL, NULL, 0, NULL} }; static struct PyModuleDef module { PyModuleDef_HEAD_INIT, mymod, NULL, -1, methods }; PyMODINIT_FUNC PyInit_mymod(void) { return PyModule_Create(module); }这还只是两个int相加。如果参数是列表、字典、自定义对象PyArg_ParseTuple的格式字符串会变得极其复杂引用计数的Py_INCREF/Py_DECREF要手动配对稍不留神就是内存泄漏或者段错误。我早期用C API写过一个图像处理模块光是参数解析和错误处理就占了代码量的一半调试引用计数问题花了两天。C API适合的场景非常明确你要给Python解释器本身写扩展比如实现新的内置类型、你要做极致的性能优化且愿意承担维护成本、或者你在维护一个历史遗留的C扩展。除此之外没有理由选它。3.2 ctypes上手最快但边界很硬ctypes的最大优点是今天下午就能跑通。C侧编译成动态库Python侧几行代码加载完事。不需要Python开发头文件不需要处理编译器和Python版本的兼容问题。但它有几个硬边界用之前必须知道第一只能调用C风格的导出函数。C的类、模板、重载、异常ctypes一概不认。你要么把C接口包一层extern C的扁平函数要么就只能调用C代码。包一层意味着你要自己写胶水那ctypes零胶水的优势就打了折扣。第二类型系统是手动的。每个函数的argtypes和restype都要手动声明声明错了不会报错而是直接段错误。我踩过一次坑把一个返回size_t的函数声明成了c_int在64位机器上高位被截断结果数组越界查了半天才发现是类型声明的问题。第三回调函数很别扭。如果你需要C侧回调Python函数ctypes要用CFUNCTYPE创建回调类型还要注意GIL的问题写起来相当绕。第四错误处理靠约定。C抛异常ctypes这边完全感知不到只能靠返回值约定比如返回-1表示出错然后在Python侧检查。这意味着C侧要写大量try-catch把异常转成错误码。3.3 pybind11现代C开发者的舒适区同样的加法函数pybind11的写法#include pybind11/pybind11.h int add(int a, int b) { return a b; } PYBIND11_MODULE(mymod, m) { m.def(add, add, Add two integers); }编译成模块后直接import mymod。类型转换、引用计数、异常映射全部自动处理。更关键的是pybind11对现代C特性支持极好std::vector、std::map、std::string自动转换类绑定、继承、多态直接支持std::function可以接收Python的lambda异常自动映射成Python异常支持py::array_t做零拷贝的numpy交互我现在的项目里凡是需要暴露C类给Python的一律用pybind11。它的编译期开销确实比ctypes大模板展开慢但换来的是代码量减少70%以上而且几乎不会出引用计数相关的bug。3.4 开发体验对比小结场景推荐方案理由暴露C类/继承体系pybind11声明式绑定自动处理生命周期调用现成的C库ctypes无需编译胶水直接加载高频调用的轻量函数pybind11调用开销低类型转换自动大数组零拷贝处理pybind11 py::array_tbuffer protocol支持完善给解释器写内置扩展Python C API唯一选择快速验证/一次性脚本ctypes上手成本最低4. 编译与部署那些文档里不会写的坑选型时最容易低估的就是编译部署成本。三套方案在这方面的差异往往比性能差异更影响项目进度。4.1 pybind11的编译依赖管理pybind11是header-only的理论上你只需要把它的头文件路径加到编译命令里。但实际项目里你会遇到这些问题Python版本和编译器版本的匹配。在Windows上Python 3.8到3.12分别用不同版本的MSVC编译你的扩展模块必须用兼容的编译器。我遇到过用VS2019编译的模块在Python 3.11上加载失败的情况报错信息是不是有效的Win32应用程序实际原因是Python 3.11用VS2022编译ABI不兼容。pybind11的获取方式。推荐用pip install pybind11然后用python -m pybind11 --includes拿到头文件路径。在CMake项目里用find_package(pybind11)更规范cmake_minimum_required(VERSION 3.15) project(mymod) find_package(pybind11 REQUIRED) pybind11_add_module(mymod mymod.cpp)pybind11_add_module会自动处理Python头文件路径、后缀名.pyd/.so、编译选项比手动配置省心得多。编译时间。pybind11的模板展开很吃编译时间。一个中等规模的绑定文件编译可能要几十秒到几分钟。我的做法是把绑定代码拆成多个cpp文件每个文件绑定一组相关的类配合ccache做增量编译。另外py::class_的链式调用如果太长可以拆成多个函数也能缓解编译压力。4.2 ctypes的动态库加载陷阱ctypes看起来简单但动态库的加载有一堆细节库的搜索路径。CDLL(./libfoo.so)里的相对路径是相对于当前工作目录不是脚本所在目录。在IDE里跑和在命令行里跑工作目录可能不同导致加载失败。稳妥的做法是用os.path.dirname(__file__)拼绝对路径import ctypes import os lib_path os.path.join(os.path.dirname(os.path.abspath(__file__)), libfoo.so) lib ctypes.CDLL(lib_path)依赖库的传递。如果你的C库依赖了其他动态库比如OpenCV、FFmpeg加载时可能报找不到符号。Linux下用ldd libfoo.so检查依赖Windows下用Dependency Walker。解决办法是把依赖库放在同一目录或者设置LD_LIBRARY_PATHLinux/PATHWindows。符号导出。C默认会做name manglingextern C才能保证符号名不被改写。但即使加了extern C如果编译时用了-fvisibilityhidden符号也不会导出。需要在函数声明上加__attribute__((visibility(default)))GCC或__declspec(dllexport)MSVC。4.3 跨平台部署的现实考量如果你的代码要在Windows、Linux、macOS上都跑三套方案的部署复杂度差异很大ctypes每个平台编译对应的动态库.dll/.so/.dylibPython侧代码基本不用改。但要注意macOS的.dylib和Linux的.so在加载方式上略有差异。pybind11每个平台、每个Python版本都要编译对应的扩展模块。用cibuildwheel可以自动化这个过程但配置成本不低。Python C API和pybind11类似但更麻烦因为要手动处理不同Python版本的API差异。我现在的做法是内部工具用ctypes快速迭代不折腾对外发布的库用pybind11 cibuildwheel一次配置好CI后面就省心了。5. 选型决策一套可以直接照着走的流程讲了这么多最后给一套我实际在用的决策流程。你不需要记住所有细节按这个流程走一遍基本不会选错。5.1 第一步判断是否需要绑定C类如果你的需求只是调用几个C函数那ctypes几乎总是最优解。不需要编译胶水、不需要处理Python版本兼容、不需要担心引用计数。只有当你要暴露的是C的类、模板、继承体系时才需要考虑pybind11。判断标准很简单你的接口里有没有class、template、virtual这些关键字。有就上pybind11没有ctypes优先。5.2 第二步评估调用频率和数据量级如果确定要用ctypes再评估一下调用模式函数单次执行超过1毫秒ctypes直接用不用犹豫。函数在紧循环里被调用百万次以上考虑pybind11或者把循环整体挪到C侧Python只调用一次。传递大数组优先考虑零拷贝方案。ctypes用numpy.ctypeslibpybind11用py::array_t。这里有个我常用的技巧把多次调用合并成一次调用。比如你要对一百万个元素逐个调用某个函数与其在Python里循环调用一百万次不如在C侧写一个接收数组的函数一次处理完。这样无论用哪套方案调用开销都被摊薄到可以忽略。5.3 第三步考虑团队和维护成本技术选型从来不只是技术问题。如果你的团队里没人写过C扩展那pybind11的学习曲线模板报错、编译配置可能会拖慢进度这时候ctypes的傻瓜式反而是优势。反过来如果团队有C背景且项目要长期维护pybind11的代码可读性和可维护性优势会随着时间越来越明显。我维护过一个用C API写的模块三年后回去改一个参数解析花了半天才理清引用计数的逻辑而pybind11写的绑定隔一年回去看依然一目了然。5.4 一张决策流程图文字版把上面的逻辑串起来需要暴露C类/模板/继承→ 是 → pybind11否 → 需要给Python解释器写内置扩展→ 是 → Python C API否 → 调用频率极高百万次以上且函数极轻量→ 是 → pybind11否 → ctypes绝大多数项目会落在第4步。这不是说ctypes低级而是说大部分场景下简单方案就是最优方案。5.5 混合使用的可能性最后提一点这三套方案不是互斥的。我有个项目就是混合用的——核心的图像处理类用pybind11暴露因为要处理继承和多态而一些独立的工具函数用ctypes调用现成的C库因为没必要为它们写绑定。同一个Python进程里pybind11模块和ctypes加载的库可以共存互不干扰。关键是想清楚每个模块的定位需要面向对象封装的用pybind11纯函数式的用ctypes两者在同一个项目里各司其职。6. 几个我踩过的具体坑和对应的解法理论讲完了最后分享几个实际踩过的坑都是文档里不会写、但一定会遇到的。6.1 pybind11的GIL释放pybind11默认在调用C函数时持有GIL。如果你的C函数要跑很久比如几秒的运算这期间Python的其他线程全被阻塞。解法是在绑定的时候加py::call_guardpy::gil_scoped_release()m.def(heavy_compute, heavy_compute, py::call_guardpy::gil_scoped_release());这样进入C函数时会释放GIL其他Python线程可以继续跑。但要注意释放GIL期间不能碰任何Python对象否则会崩溃。这个坑我踩过一次在释放GIL的函数里访问了一个py::object直接段错误查了一下午。6.2 ctypes的回调函数与GILctypes调用C函数时如果C函数要回调Python必须确保回调执行时持有GIL。ctypes的CFUNCTYPE创建的回调默认会获取GIL但如果你在C侧开了新线程去调用回调就需要手动处理GIL。这个场景比较少见但一旦遇到就很难查。我的建议是能用返回值解决的就别用回调。6.3 字符串编码的坑Python 3的字符串是UnicodeC的std::string是字节序列。pybind11默认用UTF-8做转换大部分情况没问题。但如果你的字符串包含非UTF-8的字节比如从二进制文件读出来的转换会抛异常。解法是用py::bytes代替py::str或者用std::string配合py::bytes显式处理。ctypes这边c_char_p对应的是C字符串以\0结尾Python的bytes传进去没问题但str要先.encode()。返回的时候c_char_p会自动转成bytes要得到str还得.decode()。这些转换看起来琐碎但漏一个就是乱码或者崩溃。6.4 异常安全的边界pybind11会自动把C异常转成Python异常但有个前提异常必须在绑定函数的调用栈内被抛出。如果你的C代码在另一个线程里抛异常或者异常在析构函数里抛出pybind11是接不住的程序会直接终止。我的做法是在C侧的线程入口函数里加一层try-catch把异常转成错误码或者日志不要让异常逃逸到pybind11管不到的地方。6.5 编译优化与调试的平衡pybind11的模板代码在-O0下编译极慢而且生成的代码体积巨大。开发阶段我一般用-O1既能加快编译又保留基本的调试信息。发布时再切到-O2或-O3。另外-g和-O2可以共存不要为了优化把调试信息全去掉否则线上出问题只能靠猜。7. 写在最后的一点个人体会这三套方案我都在生产环境里用过如果只能给一条建议那就是从最简单的方案开始遇到明确的瓶颈再升级。我见过太多项目一上来就上pybind11结果光是配编译环境就花了一周而实际需求用ctypes半天就能搞定。也见过为了性能手写C API最后因为引用计数bug导致内存泄漏排查成本远超性能收益。技术选型的智慧不在于选最强的而在于选最合适的。ctypes的简单、pybind11的优雅、C API的控制力各有各的适用场景。把需求想清楚答案自然就出来了。如果你现在正卡在选型上不妨先用ctypes把功能跑通验证了需求真实存在、性能确实有瓶颈之后再考虑迁移到pybind11。迁移的成本没有想象中高因为接口设计是相通的换的只是绑定层。而如果一开始就选错了方向浪费的时间才是真的回不来。
返回列表