一、前言
- 在传统的多线程编程中,单例模式意味着"全局唯一"。但当主线程与子线程同时访问这个单例时,它们拿到的是同一个对象吗?在 HarmonyOS NEXT 中,答案可能出乎你的意料。
- 假设现在有一个非常经典的单例类:
javascript
export class Singleton {
private static instance: Singleton | null = null;
public value: number = 0;
private constructor() {}
static getInstance(): Singleton {
if (Singleton.instance === null) {
Singleton.instance = new Singleton();
}
return Singleton.instance;
}
}
- 在主线程中,获取这个单例,并修改其 value 为 100:
javascript
const mainSingleton = Singleton.getInstance();
mainSingleton.value = 100;
- 然后,我们通过 TaskPool 开启一个子线程,同样获取这个单例,修改 value 为 999,并打印结果:
javascript
@Concurrent
function getSingletonValue(): number {
const workerSingleton = Singleton.getInstance();
workerSingleton.value = 999;
return workerSingleton.value;
}
- 主线程中的 mainSingleton.value 会变成 999 吗?换句话说,主线程和子线程获取到的"单例"是同一个对象吗?直觉告诉我们,单例模式保证全局唯一,当然应该是同一个。但在 HarmonyOS NEXT 中,答案是否定的。
二、原因分析
① 传统模型的"理所当然"
- 在 Java / Android 等传统共享内存并发模型中,所有线程共享同一块堆内存。静态变量 instance 存放在方法区(或堆中),所有线程看到的都是同一个引用。因此,无论哪个线程调用 getInstance(),拿到的都是同一个对象。
- 为了保证线程安全,通常需要加锁(synchronized)或使用双重检查锁。
② HarmonyOS NEXT 的"另辟蹊径"------ Actor 模型
- HarmonyOS NEXT 的 ArkTS 语言并没有采用传统的共享内存并发模型,而是选择了 Actor 模型。
-
- 每个线程(包括主线程、TaskPool 工作线程、Worker 线程)拥有一个完全独立的 ArkTS 引擎实例。
-
- 不同线程之间的内存是完全隔离的 ------ 它们不共享堆内存,也无法直接访问对方的变量。
-
- 跨线程通信的唯一方式是"消息传递",默认采用 结构化克隆算法(Structured Clone),也就是深拷贝,而非传递引用。
- 这意味着:
-
- 主线程中的静态变量 Singleton.instance 只存在于主线程的引擎中。
-
- 子线程(TaskPool / Worker)启动时,会初始化自己的一套运行环境,它也有自己的全局对象,其中的 Singleton.instance 初始为 null。
-
- 当子线程第一次调用 Singleton.getInstance() 时,它会重新创建一个新的 Singleton 对象,并存储到子线程自己的静态变量中。
- 因此,主线程和子线程各自持有完全独立的单例实例,互不干扰。
③ 为什么不共享内存?
- Actor 模型的设计目标是为了彻底避免锁竞争、死锁、数据竞态等传统并发编程的痛点。每个 Actor 独立处理自己的消息,状态不会意外被其他线程修改。这虽然牺牲了"直接共享对象"的便利性,但换来了极高的并发安全性,尤其适合 UI 与后台任务频繁交互的场景。
三、实例分析
- 如下所示,是一个完整的示例,运行在 HarmonyOS NEXT 环境下(API 12+):
javascript
// Singleton.ets
export class Singleton {
private static instance: Singleton | null = null;
public value: number = 0;
private constructor() {}
static getInstance(): Singleton {
if (Singleton.instance === null) {
Singleton.instance = new Singleton();
}
return Singleton.instance;
}
}
javascript
// Index.ets
import { taskpool } from '@kit.ArkTS';
import { Singleton } from './Singleton';
@Concurrent
function getSingletonValue(): number {
const s = Singleton.getInstance();
s.value = 999;
console.info(`[Worker] 设置 value = ${s.value}`);
return s.value;
}
@Entry
@Component
struct Index {
async onPageShow() {
// 主线程获取单例,设置 value = 100
const mainSingleton = Singleton.getInstance();
mainSingleton.value = 100;
console.info(`[Main] 主线程实例 value = ${mainSingleton.value}`);
// 子线程执行任务
const task = new taskpool.Task(getSingletonValue);
const result = await taskpool.execute(task) as number;
console.info(`[Main] 子线程返回的 value = ${result}`);
console.info(`[Main] 当前主线程实例 value = ${mainSingleton.value}`);
}
build() {
Column() {
Text("HarmonyOS NEXT 单例测试").fontSize(20)
}
}
}
- 运行结果如下:
javascript
[Main] 主线程实例 value = 100
[Worker] 设置 value = 999
[Main] 子线程返回的 value = 999
[Main] 当前主线程实例 value = 100
- 子线程将单例的 value 改为 999,但主线程的 value 仍然是 100,纹丝不动。如果它们是同一个对象,主线程的 value 也应该变成 999。因此,主线程和子线程获得的"单例"不是同一个对象。
javascript
// SharedModule.ets
"use shared" // 关键标记
import { ArkTSUtils } from '@kit.ArkTS';
@Sendable // 关键装饰器
export class TaskHandle {
private static instance: TaskHandle = new TaskHandle();
// ... 你的单例逻辑 ...
static getInstance(): TaskHandle {
return TaskHandle.instance;
}
}
四、子线程访问主线程"单例"对象的方法
① 共享模块(推荐方案)
- 原理:通过标记 "use shared" 和 @Sendable,指定一个模块为共享模块。系统会为所有线程创建并共享该模块中导出变量的同一份实例。
- 使用方式:将单例类定义在.ets文件顶部添加"use shared"的共享模块中。
javascript
"use shared" // 关键标记
import { ArkTSUtils } from '@kit.ArkTS';
@Sendable // 关键装饰器
export class TaskHandle {
private static instance: TaskHandle = new TaskHandle();
// ... 你的单例逻辑 ...
static getInstance(): TaskHandle {
return TaskHandle.instance;
}
}
- 这种方式是官方推荐方案,语法简洁,但是需要要求 API 12 及以上,且 @Sendable 类有较多限制(如成员变量类型需为基本类型或 @Sendable 类型)。
② Sendable共享对象
- 原理:若不需要完整单例,只想共享一个对象实例,可使用 @Sendable 装饰器。
- 使用方式:将需要共享的对象标记为 @Sendable。系统会在跨线程传递时传递该对象的引用,而非副本。
③ 共享内存(SharedArrayBuffer)
- 原理:对于大数据共享场景,SharedArrayBuffer 允许不同线程访问同一块内存区域。
- 使用方式:创建一个 SharedArrayBuffer 对象,并在主线程和子线程中操作同一块内存。
- 优点:性能高,无序列化开销。
- 缺点:操作复杂,需配合 Atomics API 进行原子操作。
④ 消息传递与数据副本
- 原理:最直接的方式。子线程通过 TaskPool.execute() 接收主线程的数据,并将结果返回。
- 适用场景:适合逻辑独立的任务,如计算、文件处理。
- 限制:数据量大时序列化成本较高。
五、如何实现真正的跨线程共享单例?
- 默认行为:
-
- 普通单例类:主线程与子线程各自拥有独立的实例,互不影响。
-
- 根本原因:Actor 模型带来的线程内存隔离 + 结构化克隆。
-
- 适用场景:如果你希望子线程的修改不影响主线程(例如纯计算任务),这种隔离反而是优点。
- 如果确实需要主线程与子线程操作同一个对象,HarmonyOS NEXT 提供了官方的解决方案:共享模块 + @Sendable 装饰器。步骤如下:
-
- 在 .ets 文件顶部添加 "use shared" 指令。
-
- 使用 @Sendable 装饰类。
-
- 正常编写单例逻辑。
javascript
// SharedSingleton.ets
"use shared" // 必须放在最顶部
import { ArkTSUtils } from '@kit.ArkTS';
@Sendable
export class SharedSingleton {
private static instance: SharedSingleton = new SharedSingleton();
public value: number = 0;
private constructor() {}
static getInstance(): SharedSingleton {
return SharedSingleton.instance;
}
}
- 此时,无论在哪个线程调用 SharedSingleton.getInstance(),返回的都是同一个对象,修改 value 会立即在所有线程中可见。
- 需要注意的是:
-
- 要求 API 12 及以上。
-
- @Sendable 类有较多限制(成员变量必须为基本类型、@Sendable 类型或某些特定类型)。
-
- 跨线程共享虽然方便,但需要自己保证线程安全(例如使用 Atomics 或消息串行化)。
- SharedArrayBuffer:适合大块数据的共享(如二进制数据),需要配合 Atomics 进行同步。消息传递 + 数据副本:最简单,适合数据量小、逻辑独立的场景。