线程间通信:序列化与 Sendable 对象

Actor 模型的核心是线程间不共享内存,要交换数据只能靠消息传递。TaskPool 和 Worker 都遵循这个模型,所以只要跨线程传数据,就得过序列化这一关。序列化有性能开销,还有 16MB 大小限制。ArkTS 提供了 Sendable 对象,用共享堆实现引用传递,专门解决大数据跨线程传输的问题。这篇把序列化机制、支持的类型、Sendable 用法和两者的性能差异讲清楚。

为什么需要线程间通信

Actor 模型下每个线程有独立内存,主线程把任务交给工作线程执行,工作线程把结果返回主线程,都要传数据。比如图片处理场景,主线程把图片像素 ArrayBuffer 传给工作线程计算,工作线程算完把结果 ArrayBuffer 传回来。这中间两次跨线程传输,数据量大的话开销可观。

序列化传输
postMessage / Task 参数

序列化传输
postMessage / Promise 返回

主线程
独立内存

工作线程
独立内存

通信机制有两种:

  • 序列化(深拷贝):把对象按结构化克隆算法序列化成字节流传过去,那边反序列化重建对象。两边对象独立,互不影响。
  • 引用传递(共享内存):对象放在共享堆上,两边引用同一个对象,不拷贝。Sendable 走这条路。

序列化传输机制

ArkTS 用的是 Structured Clone 算法,和 Web 标准的 postMessage 序列化机制一致。算法递归遍历对象的所有属性,按类型序列化成字节流,传输到目标线程后反序列化重建对象。

支持序列化的类型

类型序列化行为
boolean / number / string / bigint值拷贝
null / undefined直接传递
Array / TypedArray递归拷贝元素
Map / Set递归拷贝键值
Date拷贝时间值
Error拷贝 message、stack 等
ArrayBuffer默认转移所有权(TaskPool)/ 拷贝(Worker 默认)
RegExp拷贝 pattern、flags
普通对象(字面量)递归拷贝可枚举属性

不支持序列化的类型

类型原因
Function函数有闭包,无法跨线程重建
SymbolSymbol 是唯一标识,跨线程失去意义
SharedArrayBuffer用共享内存,不走序列化
WeakMap / WeakSet弱引用无法跨线程保持
Promise只支持 fulfilled 状态的 Promise,结果可序列化则传递
循环引用对象Structured Clone 不支持循环引用
容器对象(如 ArkTS 容器)需用对应 Sendable 版本
带 @State / @Prop / @Link 装饰器的对象不支持

序列化的开销

序列化不是免费的。对象越大、属性越多,序列化反序列化越慢。TaskPool 在任务数为 1 时的实测数据:

内容数据量(bytes)序列化时间(μs)反序列化时间(μs)
任务方法589.54943.749
参数21736.111115.294
结果4716.66785.243

数据量到 MB 级别,序列化时间会到毫秒级,跨线程传输开销就不能忽略了。再加上 16MB 单次传输上限,大数据用序列化既慢又容易撞限制。

ArrayBuffer 的特殊处理

ArrayBuffer 在 TaskPool 里默认转移所有权,传过去后宿主线程里这个 buffer 就废了:

@Concurrent
function processBuffer(buffer: ArrayBuffer): ArrayBuffer {
  // 处理 buffer
  return buffer;
}

const buf = new ArrayBuffer(1024 * 1024); // 1MB
const task = new taskpool.Task(processBuffer, buf);
await taskpool.execute(task);
// 这里 buf 已经失效,访问会报错

如果需要多次传同一个 ArrayBuffer,用 setCloneList() 改成拷贝传递:

const task = new taskpool.Task(processBuffer, buf);
task.setCloneList([buf]); // 改成拷贝
await taskpool.execute(task);
// buf 还能用

转移所有权比拷贝快(不用复制数据),但只能用一次。拷贝可以多次用,但有复制开销。根据场景选。

Sendable 对象

Sendable 是 ArkTS 提供的跨线程共享对象机制。Sendable 对象分配在共享堆(SharedHeap)上,所有并发实例都能访问,跨线程传递时走引用传递,不拷贝。

进程

工作线程2

工作线程1

主线程

可引用

可引用

可引用

LocalHeap
工作线程2独占

LocalHeap
主线程独占

SharedHeap 共享堆
进程级,所有线程可访问

LocalHeap
工作线程1独占

各个 LocalHeap 互相隔离,SharedHeap 是进程级共享。Sendable 对象在 SharedHeap 上分配,所以多个线程能引用同一个对象。注意 SharedHeap 不能引用 LocalHeap 里的对象,否则会破坏隔离。

@Sendable 装饰器

@Sendable 装饰器用来声明并校验 Sendable class 和 Sendable function。规则比较严格:

维度规则
装饰器参数无
使用场景仅 Stage 模型 .ets 文件
装饰的函数类型仅普通 function 和 async function
类继承限制Sendable class 只能继承 Sendable class
属性类型限制string / number / boolean / bigint / null / undefined / const enum / Sendable class / collections 容器 / AsyncLock / SendableLruCache / ConditionVariable / 自定义 Sendable 函数
闭包限制禁止闭包变量,顶层定义的 Sendable class 和 function 除外
属性定义必须显式初始化,不能用 !,不能用 # 私有,不支持计算属性,不支持类型别名
属性修改不支持增加/删除属性,允许修改属性值(类型必须一致),不支持修改方法

Sendable class 示例

@Sendable
class SendableTestClass {
  desc: string = 'sendable: this is SendableTestClass';
  num: number = 5;

  printName() {
    console.info(`sendable: desc is: ${this.desc}`);
  }

  get getNum(): number {
    return this.num;
  }
}

// 跨线程传递
@Concurrent
function processSendable(obj: SendableTestClass) {
  obj.printName(); // 直接调用方法
  obj.num = 10;    // 修改属性,主线程那边也能看到
}

async function test() {
  const obj = new SendableTestClass();
  const task = new taskpool.Task(processSendable, obj);
  await taskpool.execute(task);
  console.info(`obj.num = ${obj.num}`); // 输出 10,引用同一个对象
}

注意 obj.num = 10 这步,工作线程修改后,主线程的 obj.num 也是 10。因为两边引用的是 SharedHeap 上的同一个对象。这是 Sendable 的优势,也是坑——多线程同时修改会有数据竞争。

Sendable function 示例

@Sendable
type SendableFuncType = () => void;

@Sendable
class TopLevelSendableClass {
  num: number = 1;
  printNum() {
    console.info('Top level sendable class');
  }
}

@Sendable
function topLevelSendableFunction() {
  console.info('Top level sendable function');
}

@Sendable
function sendableTestFunction() {
  const topClass = new TopLevelSendableClass(); // 顶层 Sendable class,OK
  topClass.printNum();
  topLevelSendableFunction(); // 顶层 Sendable function,OK
  console.info('Sendable test function');
}

@Sendable
class SendableTestClass {
  constructor(func: SendableFuncType) {
    this.callback = func;
  }
  callback: SendableFuncType; // 顶层 Sendable function 类型,OK

  callSendableFunc() {
    sendableTestFunction(); // 顶层 Sendable function,OK
  }
}

Sendable function 的闭包限制和 @Concurrent 类似:不能用闭包变量,但顶层定义的 Sendable class 和 function 可以用。

Sendable 支持的数据类型

  • 基本类型:boolean / number / string / bigint / null / undefined
  • const enum(常量枚举)
  • ArkTS 容器类型(@arkts.collections,如 SendableMap、SendableArray)
  • 异步锁对象(@arkts.utils.AsyncLock)
  • 异步等待对象(@arkts.utils.ConditionVariable)
  • SendableLruCache 对象
  • 继承 ISendable 的 interface
  • @Sendable 装饰的 class 和 function
  • 接入 Sendable 的系统对象(共享用户首选项、色彩管理、图片处理、资源管理、SendableContext 等)
  • 元素均为 Sendable 类型的 union type
  • 自定义 Native Sendable 对象

注意:JS 内置对象不是 Sendable 类型。比如 new Map() 走的是 Structured Clone,跨线程是拷贝。要用 Sendable 版本得显式引入 @arkts.collections。对象字面量和数组字面量也不是 Sendable,跨线程也是拷贝。

数据竞争和异步锁

多个线程同时修改 Sendable 对象会数据竞争。ArkTS 提供异步锁(AsyncLock)解决:

import { ArkTSUtils } from '@arkts.utils';

@Sendable
class SharedCounter {
  count: number = 0;
}

const counter = new SharedCounter();
const lock = new ArkTSUtils.locks.AsyncLock();

@Concurrent
async function increment(counter: SharedCounter, lock: ArkTSUtils.locks.AsyncLock) {
  await lock.lockAsync(() => {
    counter.count++;
  });
}

也可以用对象冻结接口把 Sendable 对象冻结为只读,避免数据竞争。

序列化 vs Sendable 性能对比

传输数据量越大,Sendable 优势越明显。官方数据:

  • 传输 100KB 数据,Sendable 效率提升约 20 倍
  • 传输 1MB 数据,Sendable 效率提升约 100 倍

原因:

维度序列化(深拷贝)Sendable(引用传递)
内存占用两份(主线程 + 工作线程各一份)一份(共享堆上)
传输耗时随数据量线性增长引用传递,常数时间
大小限制单次 16MB受共享堆大小限制
数据竞争无(独立副本)有(共享对象,需加锁)
修改可见性一边修改另一边看不到一边修改另一边立刻看到
支持类型Structured Clone 支持的类型Sendable 类型

小数据用序列化就行,简单直接。大数据(几百 KB 以上)用 Sendable,性能差异巨大。但 Sendable 要处理数据竞争,代码复杂度高一些。

总结一下下

Sendable class 属性必须显式初始化。 不能用 !,不能省略初值。这条规则强制你写出确定的初始状态,但也意味着不能延迟初始化。

@Sendable
class Bad {
  name!: string; // 报错,必须显式初始化
}

@Sendable
class Good {
  name: string = ''; // OK
}

Sendable class 不能用 # 私有属性。 用 private 关键字代替。

Sendable 不支持增加/删除属性。 对象结构在创建时固定,只能改值不能改结构。动态加属性会报错。

Sendable 方法不能改。 只能修改属性值,不能替换方法。如果需要"多态",考虑用不同的 Sendable class。

JS 内置对象不是 Sendable。 new Map() / new Set() / {} / [] 这些跨线程都是拷贝。要共享用 @arkts.collections 的 Sendable 版本。

Sendable 跨线程修改要加锁。 不加锁多线程同时改一个 Sendable 对象会数据竞争,结果不可预期。用 AsyncLock 或者把对象冻结成只读。

Promise 不能跨线程传递。 TaskPool 返回 pending 或 rejected 状态的 Promise 会失败,只支持 fulfilled 状态。Sendable 也不包 Promise。

16MB 限制是单次序列化的。 不是总数据量。多次 postMessage 每次都能传 16MB。但单次超 16MB 会报错,这种场景必须用 Sendable 或 ArrayBuffer 转移。

ArrayBuffer 转移 vs Sendable。 ArrayBuffer 转移所有权(setTransferList)也是避免拷贝的方案,但转移后宿主线程里 buffer 失效。如果要两边都能访问,用 Sendable 共享。如果只传一次,转移更轻量。

别把所有对象都改成 Sendable。 Sendable 的约束多(属性类型限制、不能动态加属性、不能改方法),写起来不灵活。小对象用序列化更简单。只在数据量大、传输频繁的场景才值得改造为 Sendable。

啥时候用 Sendable呢

判断标准:

否

是

否

是

否

是

跨线程传数据

数据量 > 100KB?

用序列化
简单直接

传输后两边都要访问?

用 ArrayBuffer 转移
一次性传输

需要修改数据?

用 Sendable + 冻结
只读共享

用 Sendable + AsyncLock
读写共享

简单说:小数据序列化,大数据一次性传用转移,大数据两边都要用且只读用 Sendable + 冻结,大数据两边都要读写用 Sendable + 锁。根据场景选最合适的,别一刀切。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐