C++与Python混合编程实战:用pybind11构建高性能扩展
2026/9/9 5:17:29 网站建设 项目流程

1. 为什么要把C++和Python绑在一起:三个典型项目场景

做算法落地这几年,我身边几乎每天都有人问同一个问题:Python写起来顺手,性能到了瓶颈怎么办?C++工程效率高,但新功能迭代又慢得让人抓狂。混合编程说白了,就是把这两件事放在同一个项目里解决,让Python负责快速表达业务逻辑和数据处理流程,让C++承担密集计算和底层对接。你可能觉得这是老生常谈,但真正动手做混合编程之前,我建议你先别急着选绑定框架,先把“为什么要混”想清楚。

我参与过挺多项目,梳理下来真正值得做C++与Python混合编程的,基本是下面三类。

1.1 性能补短:Python算法开发完,核心循环拖了后腿

这种场景在量化交易、图像处理、仿真计算里到处可见。团队先用Python把策略逻辑、误差模型、数据变换写明白,毕竟Python开发效率高,改公式、调试数据都很方便。等上线跑全量数据,发现一天的回测要算四五个小时,其中80%时间都烧在某个多层嵌套循环里。这时候最理性的做法不是用Python调库去硬抗,也不是把整套系统重写成C++,而是把那个已经被验证过的热点函数抽出来,用C++重新实现,再以扩展模块的形式交给Python调用。

我做过一个比较典型的例子:实盘行情快照到了之后,需要在一批订单里做撮合匹配的约束校验。原版Python判断逻辑大概有三百多行,单个快照跑一次只要几十毫秒,可一个交易日要处理上百万次快照,整体就扛不住了。把校验核心提成C++函数后,Python侧只保留数据清洗和结果汇总,性能提升非常直接,而且策略组继续用Python写维护逻辑,学习成本几乎没有增加。

1.2 存量资产对接:老C++库不能推翻,新系统用Python构建

第二种场景是团队手里有多年沉淀的C++算法库、通信库或加密库,稳定性已经在线上验证过。现在要做一个新的分析平台,业务侧倾向于用Python快速搭建。很多人的第一反应是“用服务化把C++包一层,然后提供HTTP接口”,这确实是一条路,但中间多了网络开销和序列化成本,很多细粒度的调用会变得很难受。

更常用的做法是直接做一个Python扩展,让Python进程内部就能调用C++函数和类,数据留在同一块内存里传递。比如我们有个处理交易数据的C++库,内部维护了一棵时间序列索引树,Python侧需要频繁查询某个时间段窗口内的统计值。如果用进程间通信方案,每次查询都要做一次对象复制和协议编码;用混合编程方案,Python直接拿到C++对象的句柄,方法调用就像调用普通Python方法一样,几乎感受不到跨语言的存在。

1.3 主导权怎么定:Python调C++常见,C++调Python不常见

讨论混合编程时,有人会问C++能不能反过来调用Python。技术上当然可以,pybind11也支持嵌入Python解释器。遇到比较复杂的规则引擎或策略条件,为了不把规则硬编码在C++里,可以让C++宿主加载Python脚本,在固定事件点解释执行用户写的策略。但这种模式会让架构复杂度上升一个台阶,GIL、解释器生命周期、脚本异常处理都要在C++侧仔细处理。

所以我的经验和建议是:绝大多数项目选“Python界面 + C++内核”就好,这种主导方式边界清晰,开发体验接近原生Python;只有当你的项目本身就是长期运行的C++服务,并且需要外部用户在运行时灵活修改算法逻辑时,才值得引入反向调用。另外还有一种“伪需求”我通常直接劝退:如果你只是希望某个脚本能跑得更快,但对稳定性、跨平台、团队梯队没有任何要求,那不如先用Numba或NumPy向量化试一把,很多时候根本用不着拉出C++,为混编引入的构建复杂度反而会拖慢开发。

2. 混编方案对比:为什么pybind11是我最后留下的选项

确定要做混合编程,接下来就会撞上选型。社区里常见的方案有ctypes、Cython、CPython C API、Boost.Python和pybind11,各有各的适用场景。我这些年都实际用过,下面这段对比是我自己的主观经验总结,不一定绝对正确,但至少能帮你少走几个月弯路。

2.1 ctypes:适合C接口,不适合自带逻辑的C++类

ctypes不需要编译扩展模块,直接在Python里加载动态库,然后声明函数参数和返回类型就能调用。它对我来说最大价值是快速验证一个纯C接口的老库,比如某个用C写的硬件通信库,extern "C"导出几个函数,ctypes就能很方便地接通。因为不编译C++代码,也不依赖Python头文件,开发机上省了一大堆环境配置。

但ctypes的短板非常明显:它只能调用C语言层面可表达的函数,无法直接感知C++的命名空间、重载、模板和类成员。你要绑定一个C++类,得先费劲写一层extern "C"包装函数,把对象指针转成void*传来传去,类里的成员变量和继承体系全得手工模拟。单函数绑定还好,项目稍微一大,包装代码能写到你想砸键盘。

2.2 Cython:看似在写Python,实际上学的是另一套方言

Cython的思路是把带类型标注的Python代码翻译成C代码,再编译成扩展模块。如果你要优化某个Python函数,直接把这个函数改写成Cython,加上cdef和类型声明,确实能拿到很高的加速。它也比较适合把已有Python代码渐进式迁移到C级别。

但“渐进式”三个字背后有代价:Cython语法看着像Python,可一旦涉及C++指针、异常转换、容器接口,你写的几乎就是一门新语言。而且生成的C代码在排查问题时非常绕,报错栈经常要从一堆生成的中间文件里找线索。如果你只想把现成的C++库暴露给Python,还要额外写一层Cython声明文件,我个人觉得不太划算。

2.3 CPython C API:最底层也最繁琐,适合框架级团队

直接用CPython C API写扩展,可以做到完全不依赖第三方库,也能实现最精细的控制。Python运行时里的对象布局、引用计数、异常处理都摊在你面前。我早年写过一个小型扩展,光是处理函数参数的解析就写了挺多模板代码,还要时刻想着某个PyObject*实例什么时候该INCREF,什么时候该DECREF,一旦忘记就可能出现内存泄漏或者解释器崩溃。

这种方案适合Python运行时或框架本身要搞深度定制的情况,比如给解释器加内置模块、实现某种协议扩展。对普通业务项目来说,维护成本高,安全问题也不容易控制,我真不建议一上来就碰。

2.4 pybind11的取舍:现代C++友好,但也不是银弹

pybind11基于C++模板元编程,把Python C API的复杂度封装在了一层现代C++接口后面。你写一个C++函数,直接通过m.def就能暴露成Python函数;你写一个类,通过.def方法就能绑定构造函数、成员函数、静态函数和属性。内部引用计数和异常转换都被处理好了,绑定代码看上去和普通C++代码差别不大。

我特意比对了几个方案的指标:

对比维度ctypesCythonCPython C APIpybind11
编译第三方C++库需手动包装需写声明文件需手写C接口直接绑定
绑定代码维护成本中高很高
对C++类的支持很差一般一般完善
性能损失较低很低最低很低
Python/C++类型自动转换
新手学习曲线缓但难深入中等陡峭平缓

pybind11也不是没有缺点:它要求编译器支持C++11以上,推荐用C++17;不同的编译器和Python版本组合需要比较完善的构建环境配置;另外,绑定层做得很方便有时会让人低估底层细节,比如隐藏的复制开销。不过综合考虑开发效率、可读性和长期维护,我这几年的新项目基本都选pybind11。

3. 从零搭建一个pybind11绑定:项目结构、CMake与第一个模块

选好方案后,第一步是把一个能跑的绑定样例搭出来。我先给一个最小可复制的工程,后面所有复杂内容都从这里扩展。

3.1 初始化项目目录和依赖

假设你要绑定的C++代码放在cpp_core目录下,整体结构可以这样组织:

mixed_project/ ├── cpp_core/ │ ├── include/ │ │ └── math_fast.hpp │ └── src/ │ └── math_fast.cpp ├── pyproject.toml ├── setup.py └── demo.py

pybind11最推荐的安装方式是通过pip获取,并在构建时头文件由构建后端自动提供。我的pyproject.toml通常长这样:

[build-system] requires = ["setuptools>=64", "pybind11>=2.11.0"] build-backend = "setuptools.build_meta" [project] name = "mixed-demo" version = "0.1.0" [tool.setuptools] packages = []

之所以把pybind11放进requires而不是让开发机手动装,是为了保证换一台机器、拉一个新环境时,依赖能被自动解析,构建不会被“找不到pybind11头文件”打断。这是一个很基础但容易漏掉的工程化细节。

3.2 C++侧写一个最小的热点函数

为了让例子直观,我实现一个简单的数值累加函数:输入一个由很多double组成的数组,返回所有元素的和。这里的“数组”先不谈NumPy,先用Python列表来演示基础绑定。

// cpp_core/include/math_fast.hpp #pragma once #include <vector> namespace fastmath { double sum_vector(const std::vector<double>& values); }
// cpp_core/src/math_fast.cpp #include "math_fast.hpp" namespace fastmath { double sum_vector(const std::vector<double>& values) { double result = 0.0; for (double v : values) { result += v; } return result; } }

这个函数本身没有太多工程含量,但它能很好地说明一个关键点:Python列表进入函数时,pybind11会把Python对象转换成std::vector,C++内部使用连续内存的循环访问,速度自然比Python层逐元素解释执行快得多。

接着在cpp_core/src下加一个模块定义文件,我习惯叫它module.cpp:

// cpp_core/src/module.cpp #include <pybind11/pybind11.h> #include <pybind11/stl.h> #include "math_fast.hpp" namespace py = pybind11; PYBIND11_MODULE(mixed_core, m) { m.doc() = "C++ 核心模块示例"; m.def("sum_vector", &fastmath::sum_vector, "对一个由浮点数组成的序列求和", py::arg("values")); }

注意我单独引入了<pybind11/stl.h>。如果不包含它,pybind11不知道如何处理std::vector<double>和Python列表之间的转换,编译会报类型不匹配错误。这个头文件带来的便利是双向的:Python列表可以传入C++,C++返回的std::vector也会自动变成Python列表。

3.3 通过setuptools构建并验证

setup.py可以这样写:

from setuptools import setup from pybind11.setup_helpers import Pybind11Extension, build_ext ext_modules = [ Pybind11Extension( "mixed_core", ["cpp_core/src/module.cpp", "cpp_core/src/math_fast.cpp"], include_dirs=["cpp_core/include"], cxx_std=17, ), ] setup( name="mixed-demo", ext_modules=ext_modules, cmdclass={"build_ext": build_ext}, zip_safe=False, )

在项目根目录执行:

pip install -e .

一切正常的话,扩展模块会被编译出来,之后就可以在Python里直接使用了:

import mixed_core print(mixed_core.sum_vector([1.0, 2.0, 3.5])) # 6.5

这个阶段最容易遇到的问题是C++编译报错,常见的有两类。第一类是没有引入<pybind11/stl.h>,需要按函数签名补上。第二类是编译器标准不对,比如工程里某处把C++标准默认成了C++98,pybind11用的很多语法就不支持,必须在编译选项中显式给-std=c++17或在setup.py里设置cxx_std=17

跑通这个简单例子之后,我强烈建议你用notebook或者脚本做一次回归验证,确认函数返回值、异常情况都符合预期,再继续往下叠功能。

4. 类、STL容器与NumPy:数据传输是混合编程的核心战场

绑定一个单独函数只是热身。真实项目里,你需要面对C++类、结构体字段、容器数据以及NumPy数组。这里的许多坑都藏在“数据传递时到底发生了什么”这个基础问题里。

4.1 绑定一个带状态的C++类

很多业务逻辑不是“输入输出一个值”,而是需要维护内部状态。比如我实现一个滑动平均器,每次调用喂一个最新的市场价格,返回最近N个价格的均值。C++写成这样:

class MovingAverage { public: explicit MovingAverage(int window) : window_(window) {} double add(double value) { buffer_.push_back(value); if (buffer_.size() > static_cast<size_t>(window_)) { buffer_.erase(buffer_.begin()); } double sum = 0.0; for (double v : buffer_) sum += v; return sum / static_cast<double>(buffer_.size()); } private: int window_; std::vector<double> buffer_; };

绑定代码也很直观:

py::class_<MovingAverage>(m, "MovingAverage") .def(py::init<int>(), py::arg("window")) .def("add", &MovingAverage::add, "喂入一个值,返回当前窗口均值", py::arg("value"));

有人会疑惑:绑定的Python对象生命周期和C++对象的生命周期怎么对应?pybind11默认规定,一个Python对象释放时,如果它内部是通过py::class_实例化的C++对象,会被delete释放。也就是说,除非你显式设置py::return_value_policy::reference之类的策略,不需要担心C++对象泄漏或二次释放。但反过来,如果你在C++类内部直接把this指针返回给Python,就必须指定合适的return_value_policy,否则可能出现悬空引用。这类问题在大型项目中很容易出现,建议在文档里把每个方法返回的对象归属关系写清楚。

4.2 std::vector、std::map与Python容器的隐式转换,有几次复制

pybind11在处理std::vector时会自动和Python list互通。很多人把这个当成“零成本”,事实恰恰相反。只要函数签名里写了std::vector<double>,Python列表传入时,C++侧会新建一个vector并把所有元素复制一遍;C++返回vector时,Python侧也会新建一个列表并复制一遍。而且嵌套容器如std::vector<std::vector >,内层和外层都会发生同样的复制,数据量大时开销相当可观。

如果只是数据一次性传入并在函数内遍历,这种复制通常可以接受。但如果你的场景是高频查询,比如每秒几十万次地从Python传入一个几百维的特征向量,我建议不要用“新建list再传”的方式。要么把多个查询拼成一个大数组一次性传入,要么直接用下一小节讲的NumPy零拷贝接口。

std::map和Python字典之间的转换也类似。绑定层会遍历字典并逐个构造map,键和值全部拷贝。频繁用字典传参容易把性能优势吃回去,这时候我通常会把键值对拍平成一个vector或直接用结构化数据。

4.3 用py::array_t对接NumPy,真正的零拷贝路径

如果数据源头是NumPy,那就要用pybind11的array_t接口。它直接指向NumPy数组底层的内存缓冲区,C++侧拿到数据和shape,不需要复制元素,就能在原始数据上做计算。

举个例子,我要实现一个函数,对二维NumPy数组求每列均值:

#include <pybind11/numpy.h> std::vector<double> column_mean(py::array_t<double> input) { py::buffer_info buf = input.request(); double* ptr = static_cast<double*>(buf.ptr); size_t rows = static_cast<size_t>(buf.shape[0]); size_t cols = static_cast<size_t>(buf.shape[1]); std::vector<double> means(cols, 0.0); for (size_t r = 0; r < rows; ++r) { for (size_t c = 0; c < cols; ++c) { means[c] += ptr[r * cols + c]; } } for (size_t c = 0; c < cols; ++c) { means[c] /= static_cast<double>(rows); } return means; }

这里最重要的是理解input.request()返回的buffer_info:buf.ptr是内存起始地址,buf.shape描述数组形状,buf.strides描述各维度步长。因为C++连续遍历是假设数组在内存上连续且行优先,如果传入的NumPy数组不是连续排列,比如做了转置或切片,ptr的排布会和shape对不上,结果就会出错。

为了解决这个问题,我习惯在函数开头强制要求连续数组:

auto buf = input.request(); if (buf.ndim != 2 || buf.strides[1] != sizeof(double)) { throw std::runtime_error("输入必须是连续的内存数组,请先调用 np.ascontiguousarray()"); }

这样既安全,又能在Python侧明确知道该怎么做。实际使用中,这个函数的调用体验接近原生NumPy函数,大批量数据几乎感觉不到复制开销。

4.4 返回NumPy数组或从C++生成数据

有时需要在C++里生成一个数组返回给Python。我常用的做法不是构造py::list循环append,而是先申请一块连续内存,然后通过pybind11转成NumPy数组。pybind11提供了py::array_t<double>的构造函数,接收shape就能分配底层缓冲:

py::array_t<double> generate_sequence(size_t n) { auto result = py::array_t<double>(n); py::buffer_info buf = result.request(); double* ptr = static_cast<double*>(buf.ptr); for (size_t i = 0; i < n; ++i) { ptr[i] = static_cast<double>(i * i); } return result; }

这里隐含一个所有权规则:result是Python对象,它在Python侧持有一块内存,C++侧通过指针写入内容,最后返回给Python时,引用计数的管理都由pybind11完成,C++侧不用去delete那块内存。这个模式非常常用,尤其是需要把C++仿真结果转成Matplotlib可绘图数据时,避免双重复制。

5. 编译分发与GIL:几个让你浪费一晚上的边界问题

很多人把模型绑定写完后,在本地reload一下没问题,一上多线程或者换一台干净机器就翻车。问题常常不出在绑定代码本身,而出在编译分发和GIL这把大锁上。

5.1 GIL决定了你的C++代码能不能并行跑

Python解释器有一个全局解释器锁,叫GIL。当你从Python调用C++扩展函数时,解释器默认会持有GIL进入你的函数。如果你的C++函数是一个纯粹的计算任务,不访问任何Python对象,那么这个GIL在整个函数执行期间都被占着。Python的多线程程序在这种情况下退化成单线程:多个线程虽然都在等待调用同一个扩展,但每一时刻只有一个线程能进入C++函数。

解决方法是在进入长时间C++计算前,显式释放GIL。pybind11提供了一种RAII式写法:

m.def("heavy_compute", [](py::array_t<double> input) { py::gil_scoped_release release; // 这里执行的C++代码不持有GIL auto result = run_long_computation(input); py::gil_scoped_acquire acquire; return result; });

不过你要小心:一旦释放GIL,函数体内就绝对不能直接操作任何Python对象,包括访问NumPy数组的元素或调用Python方法,否则解释器会崩溃。正确模式是先把需要的数据指针和shape提取到C++临时变量里,再在释放GIL的区域内干活,最后重新获取GIL后把结果包成Python对象返回。

5.2 ABI兼容远比你想得更脆弱

CPython扩展模块必须和运行时使用的Python解释器版本保持ABI兼容。用Python 3.11编译出来的扩展,即使你把扩展文件拷贝到另一台装有Python 3.12的机器上,也很容易直接报错,说模块初始化失败或符号找不到。这就是为什么混合编程项目不能简单地把.so文件拷来拷去,必须在目标解释器环境下重新编译。

操作系统层面的兼容问题也要留意。Linux上使用不同版本的libstdc++可能导致动态库依赖冲突,macOS上如果Clang和GCC混用,C++ std::vector内存布局一般没问题,但涉及标准库版本时就容易踩坑。最稳妥的做法是让最终运行环境尽量统一编译器版本,并且把扩展模块发布为源码包,在目标机上执行构建而不是分发编译好的二进制。

5.3 理想的分发方式:从源码到pip install

对于团队内部项目,我建议把整个绑定模块做成一个标准安装包,发布在私有索引或直接交付源码,让使用方执行:

pip install .

搭建工程后端时,可以保持前面pyproject.toml的写法,由setuptools负责编译。项目深入到包含CMake构建、外部C++依赖时,我会换成scikit-build-core作为构建后端,它能把CMake的整个构建能力暴露给pip。pyproject.toml大致是:

[build-system] requires = ["scikit-build-core>=0.9"] build-backend = "scikit_build_core.build" [project] name = "mixed-demo-with-cmake" version = "0.1.0" [tool.scikit-build] cmake.minimum-version = "3.18"

用CMake方式可以把很多第三方C++库统一交给CMake管理。pybind11官方也提供了CMake集成,核心写法是在CMakeLists里加上:

find_package(Python COMPONENTS Interpreter Development.Module REQUIRED) find_package(pybind11 CONFIG REQUIRED) pybind11_add_module(mixed_core src/module.cpp src/math_fast.cpp) target_include_directories(mixed_core PRIVATE include) target_compile_features(mixed_core PRIVATE cxx_std_17)

使用CMake后,模块的编译、链接第三方库、处理特殊编译选项都变得清晰,也方便接CI。别嫌这一步复杂,等你有一次因为编译环境不统一而连续调试数小时的经历,就会明白构建工程化省下的都是自己的时间。

6. 实测加速与写法优化:把C++扩展的收益真正拿到手

最后聊一个非常在乎的问题:到底能快多少?每次调用边界要付出多少成本?怎么让扩展模块经得住高并发和大数据的考验?

6.1 一次跨语言调用本身是有固定开销的

不要以为用pybind11写了个函数,所有操作都不花钱。实际上,一次函数入口到C++执行之间还有参数解析、类型转换、异常处理检查等固定开销。虽然现代pybind11做得已经非常轻量,单次调用的开销大约在微秒量级甚至更低,但它禁不起无脑高频循环。

举个例子,如果你的代码是Python层for循环调用一万次C++函数,每次只处理一个浮点数,那么很大一部分时间会被调用开销吃掉,纯C++计算本身的优势被稀释。正确姿势是尽量把循环搬进C++函数内部,一次调用处理一批数据,只承担一次越界开销。所以我在设计绑定接口时有一个“粗粒度原则”:尽量让Python侧每个函数调用对应一段较大的计算或批处理任务,而不是一个原子算子。

6.2 三类典型性能数据

我给自己做过的类似项目整理了几组性能测试,供参考:

场景方法相对耗时
1亿次浮点累加纯Python for循环约11.2秒
1亿次浮点累加Python调用C++单元素函数1亿次约1.8秒
1亿次浮点累加C++函数内部完成1亿次累加约0.09秒
1000万二维数组按列求和Python手动双层循环约7.6秒
1000万二维数组按列求和NumPy原生mean(axis=0)约0.05秒
1000万二维数组按列求和pybind11绑定C++连续访问约0.03秒

比较结果不难看出:如果Python那边本来就能用NumPy向量化解决问题,C++扩展的收益很有限;只有当你的计算逻辑存在复杂分支、自定义数据结构或无法用向量化覆盖的细粒度操作时,C++扩展的收益才会明显放大。别再迷信“C++一定比Python快几十倍”,这句话成立的前提是Python侧确实在慢速循环里。

6.3 三种让我真正拿到性能的接口改造思路

我在实际操作中逐步积累了三条经验,这里毫无保留地分享出来。

第一条是批量入参。假如业务逻辑每个数据点需要调一次C++做窗口计算,那么接口应该设计成接收一个二维数组,把几千个数据点一次交给C++,C++内部按顺序逐个处理并返回结果数组。这样不仅摊销了跨语言调用成本,还能在C++内部通过循环展开等手段进一步优化。

第二条是数据结构扁平化。如果需要在Python字典和C++map之间高频传递配置或中间结果,尽量改成预分配好的数组加偏移量方式,或者只在任务启动时传一次配置,后续通过对象方法修改状态。这样避免每次调用触发整个map的深拷贝。

第三条是能释放GIL就释放GIL。尤其是多线程Python服务里调用C++扩展时,我普遍在耗时可能超过几毫秒的计算函数里使用gil_scoped_release。只要函数体里完整提取了需要的C++数据,计算期间让其他Python线程也可以运行,整体吞吐量经常能翻倍。注意,配合NumPy数组使用时,要先拿到指针和shape,再释放GIL,保证数组访问不依赖Python对象的引用计数。

6.4 一次线上事故带给我的提醒

这里说一段真实经历:我们曾经把一个回测模块从Python改写成C++扩展,开发环境跑得非常完美,耗时从半小时压到两分钟。但部署到生产服务器后,程序运行几次就崩溃,报错指向内存释放异常。排查了一整天,最终发现是我们在某个方法里返回了一个引用类型,但没有给pybind11指定return_value_policy。原代码返回的是C++类内部成员的引用,Python侧拿到的是一个临时包装,等原对象销毁后再访问,引用已经悬空。

后来我们把所有对外暴露的方法仔细过了一遍,凡是不打算交出所有权的引用,都显式配置成py::return_value_policy::referencereference_internal,并配合keep_alive来保证被引用对象不会被提前回收。这个故事听起来很像教科书里的警告,但只有栽进去一次,才知道这些策略不是八股文,它们是保证混合编程程序在真实运行环境下稳定的关键一环。

做C++与Python混合编程,我认为本质上是做“边界设计”。语言本身不是主角,数据怎么跨边界、资源由谁持有、锁什么时候放开,才是真正值得反复斟酌的地方。每当我被一个看似神秘的问题卡住,最后追到根因时,十有八九都出在边界处。掌握好这些边界规则,混合编程就不再是一锤子买卖,而是能支撑复杂系统长期演进的能力。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询