跨语言调用中,Protobuf 经常被当成“已经解决的数据格式”:各端有成熟生成器,序列化结果也是一段连续字节,看起来只要把 buffer 传过去就行。真正做性能分析时却会发现,序列化只占链路的一部分,buffer 在语言堆、FFI/JNI 内存和 C++ 对象之间反复搬运,可能比业务计算更贵。
于是很自然会想到零拷贝:让两端共享同一块内存,理论上总比复制更快。但实际测量常出现反直觉结果——换成可共享的 ByteBuffer 后,端到端反而变慢。原因不是零拷贝失效,而是为了管理这个“共享对象”,桥接层付出了对象绑定、引用计数、代理创建和 finalizer 等额外成本。
优化这类问题,不能只数 memcpy 次数。必须把从对象编码到目标对象解析的整条路径画出来,并把每一步的固定成本和随数据量增长的成本分开。
一、先画出完整数据路径
以 Dart 把一个 Protobuf 对象传给 C++ 为例,最朴素的路径可能是:
Dart message
│ encode
▼
Dart Uint8List allocation #1
│ copy to native memory
▼
FFI buffer copy #1, allocation #2
│ construct std::string
▼
C++ string copy #2, allocation #3
│ parse
▼
C++ message
如果目标 C++ Protobuf API 支持从已有内存直接解析,最后一次构造 std::string 也可能不需要。此时更合理的路径是:
native allocation exposed as Uint8List
│ Dart encodes directly into it
▼
native buffer
│ C++ parses from pointer + length
▼
C++ message
优化的第一步不是引入一个复杂的跨语言 buffer 类,而是确认每一份临时内存为什么存在:是编码器只能写入某种容器,还是桥接 API 强制复制,抑或只是方便地构造了一个 string?
可以用下面的成本模型帮助判断:
T_total = T_alloc
+ T_encode
+ T_boundary
+ N_copy × T_copy(size)
+ T_bind_object
+ T_decode
+ T_release
零拷贝只减少 N_copy × T_copy(size),却可能增加 T_alloc、T_bind_object 和 T_release。数据越小,固定成本越容易占主导。
二、先定义 buffer 的所有权,而不是先定义类名
跨语言 buffer 至少有三种语义:
2.1 BorrowedBuffer:只借用到本次调用结束
struct BorrowedBuffer {
const uint8_t* data;
size_t size;
};
目标函数不能保存指针,也不能异步使用。优点是无需引用计数和对象注册,适合“调用期间立即解析”的 Protobuf。缺点是约束必须非常明确,错误保存就会产生悬垂指针。
2.2 OwnedBuffer:所有权随调用转移
发送端分配,接收端接管释放。接口必须说明分配器和释放函数,不能让一端 malloc、另一端用不兼容的 allocator 释放。
struct OwnedBuffer {
uint8_t* data;
size_t size;
void (*release)(uint8_t*, size_t, void*);
void* context;
};
这种模型适合异步消费,但需要处理异常路径:调用尚未送达、目标运行时退出、解析失败,都必须保证 release 恰好执行一次。
2.3 SharedBuffer:两端都可能长期持有
需要引用计数、跨语言 finalizer、线程安全和对象身份。它最通用,也最重。对于只为 Protobuf 传输而存在、生命周期局限于一次调用的 buffer,完整 SharedBuffer 往往属于过度设计。
优化中真正有效的中间形态通常是“raw/external buffer”:保留直接访问 native 内存的能力,但不进入通用对象绑定系统。它的作用域由生成代码限定,而不是交给业务任意保存。
三、Dart/FFI:可以双向减少一次拷贝
Dart FFI 允许把 native 指针通过 asTypedList 暴露成 Uint8List。因此 Dart → C++ 可以先在 native 侧分配内存,再让 Dart 编码器直接写入:
final ptr = calloc<Uint8>(length);
final bytes = ptr.asTypedList(length);
message.writeToBufferInto(bytes); // 伪代码:编码器直接写入目标区域
nativeParse(ptr, length);
实际 Protobuf 库是否支持“写入已有 buffer”,取决于具体实现。如果只能返回新的 Uint8List,仍然会保留一次编码分配和复制。优化前必须确认 API,而不是只看最终参数类型。
在一组端到端写入并传递的测试中,直接使用 native allocation 映射的 Uint8List,相较先 new Uint8List 再复制到 native,结果如下:
| 数据规模与次数 | native allocation | Dart Uint8List 后复制 |
|---|---|---|
| 100 B × 1,000,000 | 174 ms | 345 ms |
| 1 KB × 100,000 | 14 ms | 33 ms |
| 10 KB × 10,000 | 5 ms | 17 ms |
这些数字只能说明同一环境下的相对趋势,不能跨设备直接复用;但它揭示了一个重要事实:少一次边界复制的收益可以覆盖 native allocation 的成本。
C++ → Dart 更直接:C++ 生成序列化数据后,把指针映射成 Dart Uint8List,避免再复制一份。这里最危险的是生命周期:Dart 仍在读取时,C++ 不能释放或复用内存。建议用外部 typed data 的 finalizer,或把借用范围限制在同步回调内。
不可忽略的 Dart 风险
Uint8List可能是可写的,接收端修改会影响 native 数据;- finalizer 执行时间不确定,不能用它维持稀缺资源;
- 指针只在创建它的运行时有效,Engine 退出时要提前失效;
- 32/64 位长度类型和越界检查要在边界完成;
- 异步解析必须把 borrowed 升级为 owned/shared。
四、Java/JNI:DirectByteBuffer 有明显的尺寸阈值
Java → C++ 可以通过 ByteBuffer.allocateDirect() 分配堆外内存,C++ 再用 GetDirectBufferAddress 访问。它确实省掉 JNI 对 byte[] 的复制或 pin,但 direct buffer 的创建成本相对固定,对小数据不划算。
一组测试显示:
| 数据规模与次数 | DirectByteBuffer | byte[] 复制 |
|---|---|---|
| 10 B × 100,000 | 499 ms | 22 ms |
| 100 B × 100,000 | 441 ms | 30 ms |
| 1 KB × 10,000 | 56 ms | 16 ms |
| 10 KB × 1,000 | 61 ms | 248 ms |
| 100 KB × 1,000 | 8 ms | 23 ms |
数据表明 crossover 大致出现在 10 KB 量级,但这个阈值会随设备、JVM、构建模式和编码器变化,不能写死为通用常量。真正的实现可以:
- 小数据继续使用
byte[]; - 大数据使用 direct buffer;
- 阈值通过基准和线上分布确定;
- 避免每次重新分配,必要时使用有上限的 buffer 池。
为什么 C++ → Java 往往优化不了
C++ 可以用 NewDirectByteBuffer 把已有指针包装给 Java,但如果 Java Protobuf 解码器最终只接受 byte[],上层仍需要 buffer.get(byteArray),那次复制并没有消失,只是换了位置,还增加了 wrapper 和生命周期管理。
对比几种常见路径:
1. C++ encode -> NewByteArray + copy -> Java parse
2. NewByteArray -> access elements -> C++ encode -> release -> Java parse
3. C++ encode -> DirectByteBuffer -> Java copy to byte[] -> parse
4. JNI 创建 direct buffer -> C++ encode -> Java copy -> parse
测试中,小数据的路径 1 通常最快;数据增大到 10 KB 以上时几种方式逐渐接近,但 DirectByteBuffer 并未稳定胜出。原因很简单:只要解析 API 仍要求 byte[],端到端拷贝数就没有减少。
因此,Java 侧是否值得零拷贝,首先取决于消费者 API 能否直接解析 ByteBuffer。如果不能,盲目换容器只是在数据路径中增加一站。
五、Objective-C:NSData 的 no-copy 很好用,但借用关系要写清楚
Objective-C → C++ 时,可以直接读取 NSData.bytes,在同步调用期间作为 borrowed buffer 使用。C++ → Objective-C 时,可以用 no-copy initializer 包装 C++ 内存:
NSData *data = [NSData dataWithBytesNoCopy:pointer
length:length
freeWhenDone:NO];
freeWhenDone:NO 的含义不是“这块内存不需要释放”,而是 NSData 不负责释放。C++ owner 必须活得比所有 NSData 读者更久。若无法保证,应使用自定义 deallocator 的 API,或干脆复制一份。
另一个容易遗漏的问题是可变性。如果 native 后续会复用这块内存,Objective-C 侧看到的数据会发生变化。只读协议应当在发送后冻结 buffer,直到接收端释放。
六、ArkTS/N-API:外部 ArrayBuffer 需要 finalizer 闭环
ArkTS/JavaScript 侧的 Uint8Array 可以通过 N-API 获取底层指针;反方向则可用 external ArrayBuffer 把 native 内存包装成 typed array。双向都具备减少一次复制的条件。
关键仍然是所有权:
napi_create_external_arraybuffer(
env,
data,
length,
finalize_callback,
finalize_context,
&array_buffer);
finalize_callback 必须能够识别运行时提前退出、重复回调和线程限制。如果 native 还持有同一 buffer,就需要共享所有权;如果所有权已经移交给 JS,则 native 不能再次释放。
七、为什么通用 ByteBuffer 可能比 byte[] 更慢
假设桥接框架已有一个可跨语言长期保存的 ByteBuffer 对象。把它用于 Protobuf 后,数据路径可能变成:
创建目标语言对象
-> FFI/JNI 读取对象信息
-> 创建 C++ proxy
-> 创建目标语言强引用
-> 加入 identity map
-> 建立跨语言引用计数
-> 传递指针
-> 解析 Protobuf
-> 解除绑定与引用
虽然省了一两次 memcpy,却加入了大量固定成本。对于 3~5 KB 这种常见消息,完整对象绑定很可能比复制更贵。
把它缩减为只在本次调用存在的 raw buffer 后,一组测试的端到端耗时约为原 byte array 路径的 90%~95%,也就是提升 5%~10%。这个结果并不惊艳,却更可信:它优化了真正多余的工作,同时没有引入更复杂的长期对象协议。
这也是性能优化中很常见的结论:共享内存是一种能力,不代表所有共享内存都应该建模成共享对象。
八、阈值策略比“全量零拷贝”更实用
固定成本和线性复制成本相交后,天然会出现阈值:
T_copy(size) = C_array + K_copy × size
T_external(size) = C_external + K_external × size
通常 C_external > C_array,但 K_external < K_copy。小数据选简单路径,大数据选 external/direct buffer,才是合理策略。
阈值不要只用合成基准决定,还要结合线上 payload 分布:
- P50/P90/P99 大小;
- 同一调用的频率;
- 冷启动还是稳态;
- buffer 是否能复用;
- 编码和解码占总耗时的比例;
- 内存峰值与 GC 压力;
- 大对象失败时的降级成本。
如果 99% 的消息都小于 2 KB,为少数大包让所有调用进入复杂路径,可能得不偿失。可以由生成代码在运行时按长度分流。
九、如何做不自欺的基准测试
跨语言性能测试很容易只测到 JIT 预热、分配器缓存或日志开销。至少应做到:
- Release 构建,关闭逐次日志;
- 预热编码器、JNI/FFI 入口和动态链接;
- 使用多种数据尺寸,而不是只测一个业务对象;
- 总字节量和调用次数分别控制,区分固定成本与线性成本;
- 测端到端:从源 message 到目标 message,而不只测函数调用;
- 单独记录 allocation、encode、boundary、copy、decode;
- 多轮运行,报告中位数和高分位,不只给一次结果;
- 校验结果内容,防止编译器删除无用工作;
- 同时观察 CPU、峰值内存和 GC 次数;
- 给出设备、系统、构建模式和消息结构,避免数字被错误外推。
十、实现前的检查表
- 当前链路到底分配了几次、复制了几次?
- 目标解析器能否直接接受 pointer、span 或 ByteBuffer?
- buffer 是 borrowed、owned 还是 shared?
- 异步调用是否把 borrowed 指针带出了作用域?
- finalizer 在运行时退出时是否仍会执行?
- 发送后内存是只读的,还是可能被复用?
- 小数据与大数据是否应该走不同路径?
- 通用对象绑定的成本是否比一次复制更高?
- 优化测试是否覆盖了真实 payload 分布?
跨语言 Protobuf 优化的重点,不是追求一个漂亮的“零拷贝”标签,而是让每段内存只有必要的分配、明确的 owner 和可验证的释放时机。把数据路径画清楚以后,很多优化甚至不需要设计新的抽象:删掉一次多余的容器转换,往往比引入一个功能齐全的共享对象更有效。