跨语言桥接:从 FFI 到 PyO3,底层到底在做什么

34 阅读 2254 字 · 约 8 分钟

你有没有想过,为什么 Python 能调用 C 写的 numpy 而不需要 IPC?为什么 Java 的 JNI 调用一次本地方法,比普通方法调用慢一个数量级?为什么 Rust 的 PyO3 比 ctypes 快?

这些问题的答案都指向同一个概念:FFI(Foreign Function Interface),也就是跨语言函数调用。

各语言想要什么,能给出什么

先看「想要什么」。Python、JavaScript、Ruby 这类动态语言,开发快、生态好,写业务逻辑顺手得不行。但 GIL、解释执行、单线程事件循环这些东西决定了它们在 CPU 密集场景下慢 50-100 倍。Java、Go、C# 这类编译型语言好不少,JIT 已经很快了,GC 也成熟。但它们的共同软肋是:你不想要 GC 的时候它也在。写操作系统组件、写数据库存储引擎、写 WASM 运行时,每秒几百万次小对象分配,GC 的停顿就是不可接受的。

再看「能给出什么」。C 给出了宇宙最稳定的 ABI——没有泛型、没有 vtable、没有 GC,函数调用的栈帧结构极其简单稳定。三十年来积累的库(SQLite、FFmpeg、OpenSSL、LAPACK)不可能每种语言重写一遍。Rust 给出的是「零成本抽象 + 无 GC + 编译期内存安全」的组合——它能做到和 C 一样快,但写出来的 FFI 代码不会莫名其妙 double free。C++ 有成熟的生态存量,Go 有 goroutine 和快速编译。

所以就形成了一条自然的策略线:用高层语言写 95% 的业务逻辑,把 5% 的计算热路径下沉到系统语言

常见组合一览

上层下层方案代表项目
PythonRustPyO3 / maturinpolars、ruff、tokenizers
PythonC/C++ctypes / cffi / pybind11 / Cythonnumpy、opencv-python
Node.jsRustnapi-rs / neonswc、lightningcss
Java/KotlinRustjni-rsmatrix-rust-sdk
Java/KotlinC/C++JNIAndroid NDK
GoCcgoSQLite 驱动、GPU 库
RustCbindgen调现有 C 库

值得单独提的是 Mozilla 的 uniffi——你在 Rust 侧定义一套接口,它自动生成 Python、Kotlin、Swift 的多语言绑定。抽象层级比 PyO3 高,灵活度比 PyO3 低。定位不同。

三个核心问题

不管什么语言怎么接,FFI 始终要解决三个问题。

第一,函数怎么互相找到并调用。 二进制层面:调用约定(参数走寄存器还是栈、谁清栈)、符号名(C++ 会把 foo(int) 修饰成 _Z3fooi,必须用 extern "C" 关掉 name mangling)。运行时层面:Python 怎么加载 .so 并找到里面的 PyInit_mylib 符号,JVM 怎么加载 .dll 并找到 Java_com_example_NativeLib_compute

第二,数据怎么传递。 i32int 这种基本类型可以零拷贝。但 Vec<String>list[PyString] 这种复杂类型就麻烦了。两条路:要么序列化(JSON/MessagePack),慢但安全,和微服务通信一个道理;要么直接操作对方运行时的对象表示——PyO3 读 PyList 的底层指针而不复制数组,这比 ctypes 快好几倍。但指针操作意味着你要处理所有权:谁分配、谁释放。Python 是引用计数,Rust 是 borrow checker。缝合策略:Rust 把所有权「给出去」(Box::into_raw 转成裸指针给 Python 管),或者「借出去」(返回 &T 告诉 Python 「这个引用在我控制下」),或者「互相持有」(Rust 持有 PyObject* 并手动 Py_INCREF/DECREF)。

第三,错误怎么传播。 Rust 的 Result<T, E> 要翻译成 Python 的 PyErr 异常,C 的 errno 要翻译成高层异常。Rust 的 panic 如果 unwind 穿过 C ABI 是未定义行为,所以必须用 catch_unwind 在边界拦截。

底层统一模型

所有方案最终的架构都可以画成同一条链:

[语言A的对象模型] → [翻译层] → [C ABI 函数调用] → [翻译层] → [语言B的对象模型]

C ABI 是那条唯一稳定的分界线。所有框架(PyO3、JNI、cgo、napi-rs)都是在这条分界线上各自搭建一套「对象模型翻译层」。翻译得越厚(序列化),越安全但越慢;翻译得越薄(直接操作指针),越快但越容易出内存 bug。

Rust 的独特价值就在这里了:它用类型系统和所有权检查,在「薄」这个方向上做到了以前只有「厚」才能保证的内存安全。一个 #[pyfunction] 宏展开后,自动生成的就是一套「把 Rust 的强类型约束映射到 Python 的引用计数」的胶水代码——这层胶水在 C/C++ 时代全靠人肉维护,出错是常态。

选型直觉

不用纠结,选型逻辑很简单:

  • 你的 Rust 项目需要调现有 C 库 → bindgen
  • Python 项目里有个计算密集的函数 → PyO3/maturin(比 ctypes/Cython 都快)
  • 一套 Rust 实现要暴露给多语言 → uniffi
  • Node.js 侧需要编译/解析工具 → napi-rs
  • Java 项目需要系统级能力 → jni-rs 或直接 JNI
  • 只是 Go 里调几个 C 函数 → cgo(但注意 cgo 的调用开销比纯 Go 大不少)

跨语言桥接不是新鲜事,但 Rust 的出现让它进入了「又快又安全」的新阶段。以前是「快但险」和「慢但稳」二选一,现在可以全都要。