为什么需要 Sendable
ArkTS 的多线程模型(taskpool、Worker)不像 JVM 那样共享堆内存。跨线程默认走结构化克隆(structured clone),代价是拷贝开销 + 引用断裂:主线程改了一份,子线程看不到。业务里但凡涉及大列表、缓存、状态机在多线程之间流动,克隆语义就成了性能与一致性的双重瓶颈。
Sendable 数据模型的定位就是让一份对象真正跨线程共享,而不是每次传递都深拷贝。它是 ArkTS 静态类型系统 + 运行时并发原语共同支撑出来的机制,本质上把"可跨线程"变成了一种编译期能校验的对象契约。
原理:Sendable 到底做了什么
Sendable 对象在运行时被分配到一个跨线程可访问的堆区域(Shared Heap)。它的字段类型受限,只能是:
- 基础类型(number / string / boolean / bigint / null / undefined)
- 其他 Sendable 类实例
- Sendable 容器:
collections.Array、collections.Map、collections.Set等
关键约束有三条:
- 字段必须显式声明类型,不允许 any / unknown / 联合含非 Sendable 类型。
- 方法内不能捕获非 Sendable 的外部变量(闭包被静态检查器拒绝)。
- 并发访问不是自动加锁,共享 = 可见性;一致性需要开发者自己用
ArkTSUtils.locks.AsyncLock或 CAS 语义保证。
这三条决定了 Sendable 是"零拷贝但有代价"的模型:省下克隆开销,换来对并发正确性的显式管理。
最小可运行示例:跨线程共享一个计数器
`// shared_counter.ets
import { taskpool } from '@kit.ArkTS';
import { collections } from '@kit.ArkTS';
import { ArkTSUtils } from '@kit.ArkTS';
@Sendable
export class SharedCounter {
private value: number = 0;
private lock: ArkTSUtils.locks.AsyncLock = new ArkTSUtils.locks.AsyncLock();
async increment(): Promise<number> {
return this.lock.lockAsync(() => {
this.value += 1;
return this.value;
});
}
get(): number {
return this.value;
}
}
@Concurrent
async function bump(counter: SharedCounter, times: number): Promise<number> {
let last = 0;
for (let i = 0; i < times; i++) {
last = await counter.increment();
}
return last;
}
export async function runDemo(): Promise<number> {
const counter = new SharedCounter();
const tasks: Promise<number>[] = [];
for (let i = 0; i < 4; i++) {
tasks.push(taskpool.execute(bump, counter, 1000) as Promise<number>);
}
await Promise.all(tasks);
return counter.get(); // 期望 4000
}这里的重点:counter只 new 了一次,四个 taskpool 任务拿到的是同一个实例的引用。如果去掉@Sendable,运行时会报"参数不可跨线程";如果去掉AsyncLock,四个 worker 竞争value += 1` 会拿到少于 4000 的结果——共享内存意味着竞态可复现,这一点比 structured clone 时代更需要谨慎。
Sendable 容器 vs 普通数组
常规 Array<T> 不是 Sendable,扔进 taskpool 只能触发克隆。真正做到共享要用 @kit.ArkTS 的 collections 命名空间:
`import { collections } from '@kit.ArkTS';
@Sendable
export class HotCache {
data: collections.Map<string, string> = new collections.Map();
put(k: string, v: string): void { this.data.set(k, v); }
get(k: string): string | undefined { return this.data.get(k); }
}差异在于collections.Map底层实现了跨线程内存屏障与迭代安全;Map/ 普通Array只在当前 VM 隔离区可见。混用最常见的坑是:把普通Array塞到@Sendable` 类的字段里,编译器直接拒绝;而在方法内部临时构造普通数组再返回,则会在返回值路径上触发克隆,抵消了共享的意义。
性能对比:克隆 vs 共享
用一个 10 万条记录的列表做基准(DevEco Profiler 采样,Mate 60 Pro,冷启动后稳定态平均值):
方案主线程传递耗时子线程读耗时峰值内存
普通 Array<Record> + taskpool~180ms(结构化克隆)~4ms2× 数据大小
@Sendable + collections.Array<1ms(引用传递)~5ms(首次跨线程访问轻微开销)1× 数据大小
结论清晰:数据越大,Sendable 的收益越接近线性放大;小对象反而看不出差别,此时用 structured clone 更简单。选型的分界线一般在"单次传递 > 1 万条 / 1MB"这个量级。
常见踩坑清单
- 忘记标 @Sendable:类字段互相引用时,只要有一个漏标,编译器就会把整棵引用图判为不可跨线程。
- 字段用了普通 Array/Map:换成
collections.*,否则运行时抛BusinessError 10200201。 - 方法内捕获 UI 状态:
@State、AppStorage都不是 Sendable,禁止在 Sendable 类的方法里直接访问;应在主线程侧读出快照再传参。 - 忽略锁:Sendable = 共享内存,不 = 自动同步;写操作必须走
AsyncLock或原子容器。 - 把 Sendable 对象持久化:Preferences / relationalStore 不识别 Sendable 元数据,落盘前要转成 POJO 或 JSON。
方法论:什么时候该用 Sendable
把跨线程数据流分成三类,选型就清晰了:
- 一次性任务参数(< 100KB,用完即抛):直接 structured clone,代码最简单。
- 大批量只读数据(列表、字典、模型输入):Sendable +
collections,省克隆开销。 - 可变共享状态(缓存、计数器、状态机):Sendable +
AsyncLock,把并发正确性显式化到代码里。
不要把 Sendable 当成"高性能默认选项"。它引入的心智负担是真实的:一旦对象跨线程共享,任何写操作都要重新审视竞态。工程上更好的做法是——先用 structured clone 跑通业务,等 Profiler 指认克隆成为瓶颈时再迁移到 Sendable。
小结
Sendable 是 ArkTS 并发模型从"消息传递"走向"共享内存"的关键一步。它借助严格类型系统把可共享性做成了编译期契约,用 collections 容器把跨线程内存屏障做成了运行时能力,用 AsyncLock 把同步义务显式化。理解它的三条约束、掌握它的性能边界、遵守它的锁纪律,才能让多线程代码在鸿蒙上真正跑出该有的速度。