即便同步与异步API已经提供了基本的并发能力,在实际开发中,开发者仍可能出于性能或架构考虑,选择手动创建子线程来处理特定任务,避免主线程负担过重。尤其在业务逻辑复杂或处理密集型任务时,自定义线程成为必要手段。那么,如何在这种场景下合理地创建和管理子线程呢?

1. 深入理解 HarmonyOS 中的并发、线程和锁机制

在 HarmonyOS 应用开发领域,并发、线程和锁是极为关键的概念,深刻理解它们有助于打造高性能、稳定的应用程序。如下图所示。

1.1. 并发与并行:任务处理的不同策略

1.1.1. 并发

并发指的是在同一时间应对多项任务的能力。在单核设备中,并发任务通过 CPU 时间片轮转的方式实现。比如,假设有任务 1、任务 2、任务 3 和任务 4,在单核 CPU0 上,它们会按顺序轮流占用 CPU 资源执行,看似同时在进行,实则是快速切换执行,给人一种同时处理多项任务的错觉 。

1.1.2. 并行

并行强调的是同一时间真正做多项任务的能力,这依赖于多核设备。在多核环境下,如具有 CPU0、CPU1、CPU2 的设备,不同任务可以被分配到不同的 CPU 核心上同时执行。例如任务 1 在 CPU0 执行,任务 2 在 CPU1 执行,任务 3 在 CPU2 执行,而任务 4 也可在合适的 CPU 核心上并行处理,极大提升了任务处理效率。

1.2. 线程和锁:保障内存一致性的关键

1.2.1. 基于共享内存

线程和锁机制是基于共享内存的,它们更接近于硬件本质。在多线程环境下,为保证多线程内存的一致性,需要使用锁等同步语义。比如当多个线程同时访问和修改共享内存中的数据时,若不加以控制,就会出现数据竞争等问题,导致程序运行结果不可预测。

1.2.2. 锁的优化类型

为提升性能,有多种锁的优化类型。乐观锁假定大概率不会发生冲突,仅在提交更新时检查冲突,适用于读多写少场景;自旋锁在等待锁时不放弃 CPU,而是循环检测锁是否可用,减少线程上下文切换开销;偏向锁则是在无竞争情况下,把锁偏向第一个获得它的线程,减少锁获取的开销;精准内存屏障用于控制内存操作的顺序,确保数据的一致性和可见性。

1.2.3. 使用挑战

虽然锁机制能保障多线程内存一致性,但也存在使用难点。锁的正确使用难度大,容易出现死锁情况,即多个线程相互等待对方释放锁,导致程序无法继续执行。而且,涉及锁的代码难以进行测试和维护,开发人员需要具备较高的技术水平,才能实现性能优秀的多线程程序。

总之,在 HarmonyOS 应用开发中,合理运用并发、线程和锁机制,趋利避害,才能充分发挥系统性能优势,打造出优质的应用体验。

2. 并发API:Worker API

在 HarmonyOS 应用开发领域,为了打造流畅、高效的应用体验,处理好耗时任务与主线程的关系至关重要,Worker API 正是解决这一问题的关键技术。

2.1. 运行机制

2.1.1. 主线程与工作线程的关系

主线程包含基础设施、对象和代码等关键要素。通过序列化机制,可派生出工作线程 1、工作线程 2 等多个工作线程。这些工作线程也拥有各自的基础设施、对象和代码,它们与主线程相互独立,并行运作。

2.1.2. 消息传递与协作

  • 主线程操作:在主线程中,首先从@ohos.worker模块导入Worker类。然后通过new Worker('./worker.ets')创建一个 Worker 实例,这里的./worker.ets指定了工作线程执行的脚本文件路径。接着,通过worker.onmessage方法监听来自工作线程的消息,一旦接收到消息,就可进行相应处理。例如,使用worker.postMessage(10)向工作线程发送消息,要求计算第 10 个斐波那契数。
  • 工作线程响应:在工作线程对应的worker.ets文件中,先从@ohos.worker导入相关内容。通过parentPort.onMessage监听来自主线程的消息,当收到消息后,获取其中的数据(如const num = event.data; ),然后调用fibonacci函数进行计算。计算完成后,再使用postMessage(result)将结果发回主线程。其中,fibonacci函数采用递归方式实现斐波那契数的计算,当n <= 1时返回n ,否则返回fibonacci(n - 1) + fibonacci(n - 2)

2.2. 应用价值与场景

Worker API 让开发者能够轻松实现耗时任务的异步处理,避免主线程阻塞。在实际应用开发中,无论是处理复杂的图形渲染计算、大数据量的解析处理,还是等待网络请求返回数据等场景,都可以借助 Worker API,将这些任务放到后台线程执行,确保应用的主线程能够及时响应用户操作,提升应用的整体性能和用户体验。

2.3. Worker API 示例

// 主线程代码
import worker from '@ohos.worker';

// 创建Worker线程
const fibWorker = new worker.Worker('./fibonacci.worker.js');

// 发送计算请求
fibWorker.postMessage(40);

// 接收计算结果
fibWorker.onmessage = (event) => {
  console.info(`斐波那契结果: ${event.data}`);
};

// worker线程代码 (fibonacci.worker.js)
import worker from '@ohos.worker';

// 监听主线程消息
globalThis.onmessage = (event) => {
  const result = calculateFibonacci(event.data);
  // 返回计算结果
  globalThis.postMessage(result);
};

function calculateFibonacci(n) {
  if (n <= 1) return n;
  return calculateFibonacci(n - 1) + calculateFibonacci(n - 2);
}

3. 并发API:TaskPool API

在 HarmonyOS 应用开发的高性能架构设计中,并发编程是提升用户体验的关键技术。除了前文介绍的 Worker API,HarmonyOS 还提供了 TaskPool API 这一强大工具,二者在不同场景下发挥着独特作用。

3.1. TaskPool API 的核心功能

TaskPool,即任务池,其核心作用在于为应用程序构建多线程运行环境。它能够显著降低整体资源的消耗,提升系统的整体性能。更为便利的是,开发者在使用时无需操心线程实例的生命周期管理,这大大简化了开发流程。

3.2. 运行机制剖析

3.2.1. 任务队列与分发

在 TaskPool 的运行架构中,首先存在一个任务队列。任务进入队列后,由任务分发管理器进行处理。任务分发管理器具备优先级调度和负载均衡的能力,能根据系统的统一管理策略,将任务合理分配到不同的任务池工作线程中。

3.2.2. 任务池工作线程

任务池工作线程具有自适应、可伸缩的特性。它包含多个任务工作线程,如任务工作线程 1、任务工作线程 2、任务工作线程 3、任务工作线程 4 等。这些线程能够灵活应对任务负载的变化,自动调整资源分配,确保任务高效执行。

3.3. 开发者友好特性

3.3.1. 易于开发并发任务

  • 易用直观,减少代码编写量:TaskPool API 的设计理念注重开发者体验,使用方式简单直观,无需编写大量复杂代码即可实现并发功能。
  • 无需关心并发实例生命周期:开发者无需花费精力去处理线程的创建、销毁等生命周期管理操作,降低了开发难度和出错风险。
  • 无需关心场景下并发任务负载轻重:无论任务负载是轻是重,TaskPool API 都能自动进行优化调度,开发者无需手动干预。

3.3.2. 统一任务负载资源管理

  • 降低系统资源消耗:通过合理的任务调度和线程管理,TaskPool API 能有效避免资源浪费,降低系统整体资源消耗。
  • 提升系统整体性能:借助自适应可伸缩的任务池工作线程以及精准的任务分发管理,系统性能得到显著提升,应用响应更加流畅。

3.4. 代码示例解读

以下代码展示了在主线程中使用 taskpool 执行耗时计算(如斐波那契数列的计算)的过程:

import { taskpool } from '@ohos.taskpool';
@concurrent
  function fibonacci(n: number): number {
    if (n <= 1) return n;
    return fibonacci(n - 1) + fibonacci(n - 2);
  }
let result = await taskpool.execute(fibonacci, 10);

首先从@ohos.taskpool导入taskpool,然后定义一个带有@concurrent装饰器的fibonacci函数用于计算斐波那契数。最后通过taskpool.execute方法执行该函数并传入参数 10,等待计算结果返回。

总之,TaskPool API 作为 HarmonyOS 应用开发中并发处理的重要工具,与 Worker API 相辅相成,为开发者提供了强大且便捷的多线程编程支持,助力打造高性能、低资源消耗的优质应用。

4. TaskPool 和 Worker的对比

在 HarmonyOS 应用开发中,TaskPool 和 Worker 是两种重要的并发处理机制,它们在实现特点和使用场景上存在诸多差异。

4.1. 实现特点对比

实现

TaskPool

Worker

参数传递

直接传递,无需封装,默认进行 transfer

消息对象唯一参数,需要自己封装

方法调用

直接将方法传入调用

在 Worker 线程中进行消息解析并调用对应方法

返回值

异步调用后默认返回

主动发送消息,需在 onMessage 解析赋值

生命周期

自行管理生命周期

自行管理 Worker 的数量及生命周期

任务池个数上限

自动管理,无需配置

同个进程下,最多支持同时开启 64 个 Worker 线程,实际数量由进程内存决定

任务执行时长上限

3 分钟,长时任务无执行时长上限

无限制

设置任务的优先级

支持配置任务优先级

不支持

执行任务的取消

支持取消已经发起的任务

不支持

线程复用

支持

不支持

任务延时执行

支持

不支持

设置任务依赖关系

支持

不支持

串行队列

支持

不支持

任务组

支持

不支持

4.2. 使用场景对比

TaskPool 的工作线程会绑定系统的调度优先级,并且支持负载均衡;Worker 需要开发者自行创建,存在创建耗时以及不支持设置调度优先级的问题,大多数场景推荐使用 TaskPool。

  • TaskPool 偏向独立任务维度,无需关注线程的生命周期,超长任务会被系统自动回收;Worker 偏向线程的维度,支持长时间占据线程执行,需要主动管理线程生命周期。
  • 运行时间超过 3 分钟的任务需要使用 Worker。
  • 有关联的一系列同步任务,例如在一些需要创建、使用句柄的场景中,句柄创建每次都是不同的,该句柄需永久保存,保证使用该句柄进行操作,需要使用 Worker。
  • 需要设置优先级的任务需要使用 TaskPool。
  • 需要频繁取消的任务需要使用 TaskPool。
  • 大量或者调度点较分散的任务推荐采用 TaskPool。

5. Sendable API

5.1. JS对象的跨线程传递问题

在前文中,我们介绍了两种在鸿蒙系统中实现并发的方式:WorkerTaskPool。虽然这两种方式都可以满足多线程处理的基本需求,但它们在性能上仍存在一些不可忽视的问题,尤其是在进行线程间数据传递时。

无论使用 Worker 还是 TaskPool,都不可避免地涉及到数据的序列化与反序列化Worker 模式中需要开发者手动处理序列化/反序列化,而 TaskPool 虽然由框架自动完成这一过程,但本质上的开销是一样的,无法避免。

序列化与反序列化会引发两个主要的性能问题:

  1. 内存开销增加:数据在序列化前后,分别存在于主线程和子线程中,会在内存中形成两份副本。当传输的数据对象较大,且频繁在线程之间传递时,会显著增加内存使用,尤其在内存资源有限的设备上,可能导致应用变慢,甚至被系统终止。
  2. 时间开销增加:序列化与反序列化本身是耗时操作。随着数据量的增大,这一过程所耗费的时间也会同步增长,主线程和子线程各执行一次,对性能敏感的应用来说,这种开销可能是不可接受的。

除此之外,序列化还存在功能层面的限制:只能传递可被序列化的数据结构。像函数、类实例(尤其是带有方法的类)这类复杂的内存对象,无法通过序列化完整地进行跨线程传递。

那么,如何才能避免这些问题?答案是:使用 Sendable 类来实现高性能、无复制的数据传递机制

5.2. Sendable的作用

在 HarmonyOS 应用开发的 ArKTS 语言体系里,Sendable 是保障并发场景下数据安全传递与操作的重要概念。

5.2.1. Sendable 协议的核心功能

符合 Sendable 协议的数据具备在 ArKTS 并发实例间传递的能力。默认情况下,这些数据在并发实例间采用实引用传递,即内存中只存在一份对象,不同线程直接引用该对象。这种机制在提高数据传递效率的同时,也带来了潜在风险:当多个并发实例试图同时更新可变的 Sendable 数据时,会引发数据竞争问题,导致程序运行结果不可预测。为解决这一问题,ArKTS 提供了异步锁机制,用于避免不同实例间的数据竞争,保障数据的一致性和操作的正确性。

5.2.2. Sendable 数据的范围界定

基本数据类型

ArKTS 中的基本数据类型,如 boolean(布尔型)、number(数值型)、string(字符串型)、bigint(大整型)、null(空值)、undefined(未定义),天然属于 Sendable 数据范畴。

容器类型数据

ArKTS 语言标准库中定义的容器类型数据,需要显式引入@arkts.collections模块后,才可作为 Sendable 数据使用。这些容器类型为开发者提供了更丰富的数据组织和管理方式,同时在并发场景下也遵循 Sendable 协议的规则。

异步锁对象

ArKTS 语言标准库中的 AsyncLock 对象,需显式引入@arkts.utils模块。它在解决并发数据竞争问题中扮演关键角色,配合 Sendable 数据,为多线程操作提供安全保障。

接口与类
  • 继承了ISendable接口的类型,需显式引入@arkts.lang。通过实现该接口,自定义类型能够满足 Sendable 协议要求,在并发实例间安全传递。
  • 标注@Sendable的 class(类),通过这种标注方式,开发者可明确指定自定义类的数据符合 Sendable 协议,从而在并发环境中正确使用。
联合类型数据

元素均为 Sendable 类型的 union type(联合类型)数据,例如string | number ,也属于 Sendable 数据范围。这种联合类型在实际开发中为处理多样化数据提供了便利,同时也保证了在并发场景下的数据安全性。

5.2.3. Sendable 数据在多线程间的传递与操作

在实际应用中,Sendable 数据在主线程和子线程间传递时,是通过引用直接传递的,这属于静态类型无锁化的只读共享模式。这意味着在只读操作时,不会涉及锁的操作,提高了数据访问效率。但当涉及对可变 Sendable 数据的写操作时,就需要借助 ArKTS 的异步锁机制来协调多线程间的操作,防止数据竞争,确保数据的完整性和准确性。

理解 Sendable 的作用、数据范围以及其在多线程环境下的工作机制,是开发者在 HarmonyOS 应用开发中处理并发任务、保障应用性能和数据安全的重要基础。

5.3. 在Worker和TaskPool中使用Sendable

@Sendable装饰器声明并校验Sendable class,@Concurrent装饰器声明该方法是线程安全的。

5.3.1. 在Worder下使用

import { taskpool, worker } from '@kit.ArkTS';

@Sendable
class A {}
let a: A = new A();

@Concurrent
function foo(a: A) {}
let task: taskpool.Task = new taskpool.Task(foo, a);
let w: worker.ThreadWorker = new worker.ThreadWorker("entry/ets/worker");

// 1. TaskPool 共享传输实现方式
taskpool.execute(task).then(() => {})
// 2. Worker 共享传输实现方式
w.postMessageWithSharedSendable(a)
// 3. TaskPool 拷贝传输实现方式
taskpool.setCloneList([a])
taskpool.execute(task).then(() => {})
// 4. Worker 拷贝传输实现方式
w.postMessage(a)
  • 导入模块import { taskpool, worker } from '@kit.ArkTS';@kit.ArkTS模块中导入taskpoolworker ,用于使用 TaskPool 和 Worker 相关功能。
  • 定义 Sendable 类@Sendable class A {} 使用@Sendable装饰器声明类A ,表示该类的实例数据可在并发实例间传递,let a: A = new A(); 创建类A的实例a
  • 定义并发函数@Concurrent function foo(a: A) {} 使用@Concurrent装饰器声明函数foo ,表示该函数是线程安全的,它接收类A的实例作为参数。
  • 创建任务和 Worker 实例let task: taskpool.Task = new taskpool.Task(foo, a); 创建一个 TaskPool 任务,将函数foo和实例a作为参数传入;let w: worker.ThreadWorker = new worker.ThreadWorker("entry/ets/wo"); 创建一个 Worker 线程实例,指定其入口脚本路径。
  • TaskPool 共享传输taskpool.execute(task).then(() => {}) 使用 TaskPool 执行任务task ,并在任务完成后执行回调(这里是一个空回调),这是共享传输实现方式,直接传递任务和数据引用。
  • Worker 共享传输w.postMessageWithSharedSendable(a) 使用 Worker 的postMessageWithSharedSendable方法,将Sendable类型的实例a以共享方式发送到工作线程。
  • TaskPool 拷贝传输taskpool.setCloneList([a]) 将实例a添加到 TaskPool 的克隆列表,这样在执行任务时会传递数据的拷贝;taskpool.execute(task).then(() => {}) 执行任务,此时传递的是a的拷贝。
  • Worker 拷贝传输w.postMessage(a) 使用 Worker 的postMessage方法,将实例a以拷贝方式发送到工作线程。

5.3.2. 在TaskPool下使用

AsyncLock实现异步锁,允许在锁下执行异步操作。

import { taskpool, worker, ArkTSUtils } from '@kit.ArkTS';

@Sendable
class MySendable {
    private key: number;
    setKey(v: number) {
        this.key = v;
    }
}
let sendable: MySendable = new MySendable();

@Concurrent
function setSendableValue(sendable: MySendable) {
    const lock = ArkTSUtils.locks.AsyncLock.request('lock0');
    lock.lockAsync(() => {
        sendable.setKey(0);
    }, ArkTSUtils.lock.AsyncLockMode.EXCLUSIVE);
}

let task0: taskpool.Task = new taskpool.Task(setSendableValue, sendable);
let task1: taskpool.Task = new taskpool.Task(setSendableValue, sendable);
taskpool.execute(task0);
taskpool.execute(task1);
  • 导入模块import { taskpool, worker, ArkTSUtils } from '@kit.ArkTS';@kit.ArkTS模块中导入taskpoolworkerArkTSUtils ,其中ArkTSUtils用于获取异步锁相关功能。
  • 定义 Sendable 类@Sendable class MySendable {... } 使用@Sendable装饰器声明类MySendable ,该类有一个私有成员变量key和一个设置key值的方法setKeylet sendable: MySendable = new MySendable(); 创建类MySendable的实例sendable
  • 定义并发函数@Concurrent function setSendableValue(sendable: MySendable) {... } 使用@Concurrent装饰器声明函数setSendableValue ,它接收MySendable实例作为参数。函数内部通过ArkTSUtils.locks.AsyncLock.request('lock0'); 获取名为lock0的异步锁,然后使用lock.lockAsync方法在异步锁保护下执行sendable.setKey(0); ,这里指定锁模式为ArkTSUtils.lock.AsyncLockMode.EXCLUSIVE (独占模式)。
  • 创建任务并执行let task0: taskpool.Task = new taskpool.Task(setSendableValue, sendable);let task1: taskpool.Task = new taskpool.Task(setSendableValue, sendable); 创建两个 TaskPool 任务,都使用setSendableValue函数和sendable实例作为参数;taskpool.execute(task0);taskpool.execute(task1); 依次执行这两个任务,在异步锁的保护下,避免对sendable实例操作时的数据竞争问题。

--THE END--

鸿蒙高性能API设计理念(上)-CSDN博客

 更多内容请参见视频教程:《HarmonyOS应用开发实战指南》

Logo

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

更多推荐