跨语言调用中,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_allocT_bind_objectT_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 allocationDart Uint8List 后复制
100 B × 1,000,000174 ms345 ms
1 KB × 100,00014 ms33 ms
10 KB × 10,0005 ms17 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 的创建成本相对固定,对小数据不划算。

一组测试显示:

数据规模与次数DirectByteBufferbyte[] 复制
10 B × 100,000499 ms22 ms
100 B × 100,000441 ms30 ms
1 KB × 10,00056 ms16 ms
10 KB × 1,00061 ms248 ms
100 KB × 1,0008 ms23 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 预热、分配器缓存或日志开销。至少应做到:

  1. Release 构建,关闭逐次日志;
  2. 预热编码器、JNI/FFI 入口和动态链接;
  3. 使用多种数据尺寸,而不是只测一个业务对象;
  4. 总字节量和调用次数分别控制,区分固定成本与线性成本;
  5. 测端到端:从源 message 到目标 message,而不只测函数调用;
  6. 单独记录 allocation、encode、boundary、copy、decode;
  7. 多轮运行,报告中位数和高分位,不只给一次结果;
  8. 校验结果内容,防止编译器删除无用工作;
  9. 同时观察 CPU、峰值内存和 GC 次数;
  10. 给出设备、系统、构建模式和消息结构,避免数字被错误外推。

十、实现前的检查表

  • 当前链路到底分配了几次、复制了几次?
  • 目标解析器能否直接接受 pointer、span 或 ByteBuffer?
  • buffer 是 borrowed、owned 还是 shared?
  • 异步调用是否把 borrowed 指针带出了作用域?
  • finalizer 在运行时退出时是否仍会执行?
  • 发送后内存是只读的,还是可能被复用?
  • 小数据与大数据是否应该走不同路径?
  • 通用对象绑定的成本是否比一次复制更高?
  • 优化测试是否覆盖了真实 payload 分布?

跨语言 Protobuf 优化的重点,不是追求一个漂亮的“零拷贝”标签,而是让每段内存只有必要的分配、明确的 owner 和可验证的释放时机。把数据路径画清楚以后,很多优化甚至不需要设计新的抽象:删掉一次多余的容器转换,往往比引入一个功能齐全的共享对象更有效。