本文是 unit_rc 系列第六篇。异步运行时见《多 Isolate 注册表与跨运行时消息队列》。更完整的性能实验见《跨语言传递 Protobuf:从拷贝路径到所有权模型》

跨语言框架的对象模型解决了“谁在调用谁”,数据通道还要解决“参数怎样过去”。基础数值可以直接按 ABI 传递,字符串、容器和 Protobuf 则会涉及分配、复制、编码、宿主对象包装和释放。

unit_rc 运行时提供 ByteBuffer 作为统一二进制对象,并在 Dart、Java、Objective-C、ArkTS 各端使用宿主的直接内存能力。与此同时,它用 BaseProfile 采集生成调用的耗时,用 UnitRCError 统一记录引用、转换、proxy 和消息问题。

这三部分应该一起看。零拷贝如果没有所有权协议,会变成 use-after-free;性能优化如果没有端到端 profile,只会数 memcpy;运行时如果只打印平台日志,线上无法比较不同语言链路。

Protobuf 数据进入 ByteBuffer 后,由多个平台视图共享访问同一块内存

减少拷贝的关键不是换一个容器名称,而是让多个宿主视图共享同一块 buffer,并把 owner 的生命周期说清楚。

一、ByteBuffer 的公共模型非常小

unit_rc/base/UnitRCByteBuffer.h 的核心只有:

class ByteBuffer : virtual public BaseRC {
 public:
  uint8_t* data = nullptr;
  size_t length = 0;
};

C++ 默认实现 ByteBufferCpp 按 length 分配一块连续内存,析构时 free。它仍然继承 BaseRC,因此可以复用对象 identity、强弱引用和各平台 wrapper。

同时提供 Protobuf 辅助:

template<class T>
static std::shared_ptr<ByteBuffer> fromMessage(
    const std::shared_ptr<T>& value) {
  auto length = value->ByteSize();
  auto buffer = ByteBuffer::create(length);
  value->SerializeToArray(buffer->data, length);
  return buffer;
}

template<class T>
static std::shared_ptr<T> toMessage(
    const std::shared_ptr<ByteBuffer>& value) {
  auto result = std::make_shared<T>();
  result->ParseFromArray(value->data, value->length);
  return result;
}

这层只定义“连续字节 + 长度”和编码入口,不定义业务协议。消息 schema 仍由 .proto 和生成器负责。

二、同一块内存在四个平台怎样呈现

Dart:native 指针映射为 typed data

Dart ByteBuffer wrapper 保存 native data pointer 和 length。Dart → C++ 时,运行时从 handle 附加信息中取得地址;C++ → Dart 时,生成的 Dart 函数根据地址与长度创建可访问的 typed list wrapper。

关键不变量是:Dart wrapper 存活期间,拥有内存的 BaseRC 不能析构。attach 的强弱关系不是附带功能,而是 no-copy 成立的前提。

Java:DirectByteBuffer

Java → C++:

GetDirectBufferCapacity(buffer)
GetDirectBufferAddress(buffer)

C++ → Java:

NewDirectByteBuffer(data, length)

这避免把数据复制进 byte[],但只适用于真正的 direct buffer。Java 上层如果最终又调用 get(byte[]) 才能给 Protobuf 解析,端到端仍会复制一次。

Objective-C:NSData no-copy

Objective-C → C++ 直接读取 NSData.byteslength;C++ → Objective-C 使用:

[NSData dataWithBytesNoCopy:data
                     length:length
               freeWhenDone:NO]

freeWhenDone:NO 表示 NSData 不负责内存释放,因此 BaseRC 必须维持 owner 生命周期。

ArkTS:External ArrayBuffer + Uint8Array

C++ → ArkTS 使用 napi_create_external_arraybuffer 包装 native 内存,再创建 napi_uint8_array。反方向用 napi_get_typedarray_info 取得 data、length 和 offset。

external array buffer 的 finalizer 在当前实现里不直接释放数据,因为内存由 ByteBuffer/BaseRC 管理。finalizer 与 wrapper detach 仍需确保引用闭环最终被打破。

三、ByteBuffer 是对象,性能也会付对象成本

ByteBuffer 复用 BaseRC 带来统一生命周期,却也意味着每次传输可能涉及:

  • 创建 C++ 对象和 shared_ptr
  • 分配 rcId
  • 创建宿主 wrapper;
  • attach strong/weak reference;
  • 写入 identity;
  • 加入/查询对象注册;
  • finalizer 和 detach;
  • 多 runtime 检查。

如果 buffer 只为了在一次同步调用中把 1 KB Protobuf 交给目标解析器,这些固定成本可能比一次 memcpy 更高。

因此要区分两种数据抽象:

General ByteBuffer object
  - can be stored
  - can cross async boundary
  - identity + reference protocol
  - heavier

Borrowed/raw buffer
  - valid only during call
  - cannot be retained
  - pointer + length
  - lighter

通用对象适合长期共享、异步和多次往返;Protobuf 的即时传输更可能适合作用域受控的 raw buffer。生成器能够保证“解析在返回前完成”时,才有资格选择 borrowed 路径。

四、零拷贝的真实条件

想声称某条链路零拷贝,至少要同时满足:

  1. 源编码器可以直接写入目标内存,或已有数据本身就在可共享内存;
  2. 桥接 API 能暴露同一地址;
  3. 目标解析器接受 pointer/ByteBuffer,而不是强制转换到另一容器;
  4. 数据在消费完成前不会被释放或复用;
  5. wrapper、引用和 finalizer 的固定成本低于被省掉的复制。

以 Java 为例,C++ NewDirectByteBuffer 确实不复制;但如果 Java Protobuf API 只接受 byte[],下一步仍然复制。此时只能说“C++ 到 DirectByteBuffer 这一步 no-copy”,不能说端到端零拷贝。

以 Objective-C 为例,NSData no-copy 能让消费者直接读 C++ 地址;若业务把 NSData 长期保存,而 C++ ByteBuffer 提前析构,就会用到无效内存。性能正确性必须由对象协议保证。

五、Protobuf 的两种抽象层级

.ur 可以导入 .proto,生成器识别 message/enum,并为各语言生成自然类型和跨边界转换。通常有两种实现思路:

5.1 传序列化数据

source protobuf object
  -> serialize into buffer
  -> cross boundary
  -> parse target protobuf object

优点是各端保持原生 Protobuf API,协议稳定,调用结束后互不共享状态。成本是每次编码与解析。

5.2 生成 Proto Proxy

把 message 暴露为跨语言 interface,字段访问通过 proxy 回到真实对象。优点是无需每次完整编解码,适合少量字段读取或大对象延迟访问。缺点是:

  • 每次 getter 都是跨语言调用;
  • 对象生命周期更复杂;
  • 批量遍历字段可能比一次解析慢得多;
  • schema 演进和可写字段需要额外契约。

选择标准不是“proxy 更高级”,而是访问模式:

读取绝大多数字段、跨边界后本地频繁使用
  -> 序列化后本地解析通常更合适

对象很大、只读取少量字段、需要共享实时状态
  -> proxy 可能更合适

六、SharedMemoryInt64 展示了另一种取舍

运行时中还有一个较新的 SharedMemoryBase/SharedMemoryInt64 实现。它用一块 ByteBuffer 保存:

1 byte current index
+ N slots × fixed-size data

写入时选择下一个 slot,把值以固定字节序写入,再更新 index;读取时先取 index,再从对应 slot 解码。它更像一个小型多版本缓存:读者持有上次 index 时,写者切换到另一个 slot,降低正在读取的数据被原地覆盖的概率。

index | slot 0 | slot 1 | slot 2
  1   | old    | current| next

这个实现提供了很有价值的思路,但不能自动等同于通用无锁共享内存:

  • index 的读写是否具有目标平台所需的原子与内存序;
  • 写完 slot 与发布 index 之间是否有可见性保证;
  • 多写者是否会竞争;
  • cacheSize 和 index 边界是否严格正确;
  • 运行时进程/线程模型是否允许同一物理内存直接共享。

所以把它写成文章时更适合作为“固定大小数据的实验性共享布局”,而不是已经验证的通用 lock-free 容器。

七、BaseProfile 怎样测跨语言调用

BaseProfileScoped 由生成代码通过宏放到调用作用域:

UR_PROFILE(module, language, interface, method)

构造时记录开始时间,析构时记录结束时间并加入全局 profile 缓冲。每项包含:

  • 模块;
  • 语言链路;
  • interface;
  • method;
  • duration;
  • 时间戳/辅助字段。

缓存达到数量、时间间隔或最大容量条件后,运行时把数据交换出来,异步聚合:

(module, language, interface, method)
   -> total duration / count
   -> average + count
   -> split report strings
   -> application callback

这样不会每次调用都直接触发上报 SDK,避免监控本身淹没被测调用。业务通过 ProfileConfig 配置采样/批量参数和回调。

平均值不够

当前聚合主要记录平均耗时与次数,适合发现高频方法的总体成本。对于尾部延迟,还应考虑直方图或分位数近似,否则少量极慢调用会被平均值稀释。

还要明确 profile 范围:生成 proxy 方法的 RAII 计时,是只覆盖桥接转换,还是包含目标业务实现?不同方向模板放置宏的位置会影响数字含义。监控文档应说明测量边界。

八、性能监控必须避免反向放大问题

跨语言调用可能非常高频。如果每次都分配字符串、取锁和打印日志,profile 会改变真实性能。当前实现采用:

  • 指针形式保存静态 module/interface/method 名称;
  • 批量 vector;
  • 达到阈值后 swap;
  • 后台聚合与分段上报;
  • 可全局关闭。

继续优化时可以关注:

  • 多线程写同一 vector 的锁竞争;
  • 上报阈值改变后已分配缓存的容量;
  • system_clocksteady_clock 的选择;
  • 字符串 key 拼接的聚合成本;
  • 回调执行失败后的数据处理;
  • 监控数据是否包含内部模块或敏感方法名。

公开文章和外部日志可以使用稳定哈希或抽象模块名,避免暴露内部业务结构。

九、UnitRCError 是运行时的结构化异常通道

UnitRCError 定义语言维度:

Cpp / Java / ObjC / Dart / ArkTs
CppJava / CppObjc / CppDart / CppArkTs

以及错误类型:

sharedThis
attach/detach strong/weak
from/to conversion
message queue / lose / notify / timeout
duplicate export
create proxy

UR_REPORT_ERROR 会先写日志,再调用全局 reportError();应用通过生成接口注册 callback,把结构化错误接入自己的上报体系。

这种设计让 runtime 不依赖具体监控 SDK,同时保证所有平台问题进入同一分类。分析时可以回答:

  • 哪条语言链更容易发生转换错误?
  • 弱引用 attach/detach 是否在特定版本突增?
  • 多 Isolate 改造后 timeout 与 lose 如何变化?
  • 某个接口的 proxy 创建失败是否集中在继承回退?

十、错误 message 应该是诊断码,不是业务数据

源码中不少 message 使用短码和数字组合,例如 sendId、port、期望序号。这种形式不够易读,却有两个优点:成本低、不会直接记录方法参数。

更可维护的折中是定义稳定的结构化字段:

error_type=create_proxy
language=dart
reason=creator_missing
runtime_valid=true
type_id=<stable hash>
object_state=proxy

不要上报:

  • 原始字符串参数;
  • Protobuf 内容;
  • 用户 ID;
  • 完整对象地址;
  • 内部域名或日志链接。

对象地址可以在本地 debug 日志使用,线上应转为进程内随机/哈希 identity,避免既无跨会话意义又带来信息风险。

十一、把性能与错误关联起来

单独看 profile 只能知道慢,单独看 error 只能知道失败。更成熟的观测可以共享一次调用的轻量 trace ID:

call start
  -> trace_id, language, interface, method
  -> conversion duration
  -> queue wait duration
  -> target execution duration
  -> return conversion duration
  -> error/cancel if any

不需要全量记录。对高频调用采样,对错误调用强制保留,就能区分:

  • 慢在序列化还是消息队列;
  • timeout 时目标 runtime 是否正处于退出;
  • create proxy 失败前是否发生类型回退;
  • 大 buffer 是否与 GC/内存峰值相关。

十二、数据通道的选择清单

基础值/短字符串

直接 ABI/JNI/FFI 转换,避免为小值创建 BaseRC 对象。

小型 Protobuf

序列化为普通 byte array 往往最简单;一次复制可能比通用 ByteBuffer 对象绑定更便宜。

大型 Protobuf

测量 direct/external buffer 的阈值;确认目标解析器可直接消费,且 owner 跨异步边界安全。

大对象少量字段

考虑 proto proxy,但必须测量跨语言 getter 次数,避免 N 个字段变成 N 次桥接。

长期共享二进制

使用完整 ByteBuffer/BaseRC 引用协议,明确只读/可写与释放端。

高频固定大小状态

可以探索共享布局或多槽缓存,但先证明内存序、并发模型和宿主支持,不要把实验代码直接宣称为 lock-free。

十三、系列总结

从第一篇到这里,unit_rc 的完整链路已经串起来:

.ur declaration


urgen parser -> model -> language templates


generated interface / proxy / conversion code


BaseRC identity + cross-language reference protocol

    ├─ JNI / ObjC / Dart / ArkTS bridges
    ├─ Isolate context + MessageQueue
    ├─ ByteBuffer / Protobuf data path
    └─ Profile + Error observability

这套工程最值得复用的并不是某个宏或模板,而是分层方式:

  • DSL 只描述跨语言契约;
  • 模型把语义标准化;
  • 后端处理语言表达;
  • C++ 运行时维护共同身份;
  • 平台桥尊重宿主限制;
  • 消息和数据路径都有明确所有权;
  • 失败与耗时被纳入框架能力。

跨语言调用永远不可能真的“和本地调用一模一样”。好的框架不是掩盖所有差异,而是把差异放到正确层级,让业务在安全契约内获得接近本地调用的体验,同时让维护者仍能沿源码还原每一次对象、消息和字节的流向。