写在前面
很多时候,我写的文章并不太详实,尤其是对于某个 API 的使用细节,某个模型或者某个设计的详细讨论,(当然,我尽可能的能在谋篇文章里和你畅谈这些)但我希望能通过对知识体系进行 “重点筛选”,降低你对学习曲线陡峭的焦虑,因此像我前面说的,我并不会很详实,文章中充满了 官方文档的引用 和 超链接卡片,我希望这能够弥补一些求知欲更为强烈的同学,我希望他们能够跳转过去,我鼓励所有人这么做,因为每个人都有各自的学习方式,另外我也必须承认,官方文档绝对是现阶段最出色的学习工具。
不适合阅读此文章
- 零基础(鸿蒙基础都没有)
- 试图进阶鸿蒙多线程(你应该多看看官方文档)
- 没时间写代码,没耐心看(写都不写一下咋可能学明白)
JavaScript 的多线程
鸿蒙的多线程并发模型实际上是一个并不是一个多么高级的玩意,如果你使用过其他的一些编程语言,比如 JavaScript,你甚至能发现他们在并发模型的设计上有相似之处,在浏览器环境里,你可以使用 Web Worker 来进行多线程处理数据,我们看以下代码(如果您没有使用过JavaScript,阅读以下代码也不会产生什么障碍):
在主线程中,调用 worker 构造函数,创建 worker 线程:
let worker = new Worker('work.js') // 必须传递一个 url,受同源限制
主线程通过调用 postMessage
方法,向 worker 线程传递数据,数据类型不受限制:
worker.postMessage('Hello World');
worker.postMessage({a:1, b:2});
主线程通过监听 onmessage
事件,接收子线程传递的信息:
worker.onmessage = function(event) {
console.log(event.data); // data 就是 worker 传递的数据
}
woker 线程内同样通过监听 onmessage
事件,接收主线程传递的数据,通过 postMessage
传递数据:
self.addEventListener('message', function(event) {
console.log(event.data); // data 为传递的数据
// do something...
self.postMessage('It's me');
})
其实也很简单对么?即便是您从来没有使用过JavaScript,没有进行过并发编程,这也非常易于理解,总的来说:主线程 与 work线程 之间通过 postMessage 发送任意的数据。他们可以通过消息事件监听器来对信息做出响应。但事实上:它们存在一些使用限制。
限制条件
实际上这些限制会让你有些厌恶,因为在编码的过程中极易出现错误,但正是这些限制让你的代码更易维护,更不容易在运行时出现异常。
- 同源限制:创建 worker 线程的时候需要分配一个 JS 文件,该文件必须是同源的,且不能是本地文件;
- 环境隔离:worker 线程所在的上下文环境与主线程不一样,无法读取网页的 DOM 对象,全局对象不再是 window,可以通过 this 或 self 访问。
- 通信受限:主线程和 worker 线程不能直接通信,通过
postMessage
方法进行消息传递。
你可能注意到了(尤其你编写过Web worker的代码的话),你可能会发出疑问,为什么我不能够在worker 的线程中直接操作 DOM?
接下来我们将来探寻鸿蒙的 Actor 模型帮助你理解为什么会这样。
鸿蒙的多线程
要了解鸿蒙的多线程,我们必须先来认识和理解 Actor 模型,Actor 并发模型实际上还对应了一种常见的并发模型,即:内存共享模型,它实际上也是一种广泛应用的模型,我们不再这里赘述,想了解的小伙伴可以去看鸿蒙官方对它的描述。
Actor 模型
Actor 模型实际上非常易于理解,我们看以下图例(来自官方的 Actor 模型介绍)。
发现了么 ? 其实非常类似于我们上面谈到的 JavaScript 多线程。UI线程与生产者线程之间独占内存,相互隔离,互不直接影响,而是通过序列化通信进行数据传递(但严格意义上来讲,JavaScript 的多线程模型并不是 Actor 模型)。
在鸿蒙的多线程模型中提供了两种并发能力供你选择,TaskPool 和 Worker,为了保持你的思想接续,接下来我们使用 worker 来向你演示如何进行并发编程。
Worker
Worder 的主要作用与 JavaScript 的多线程几乎一样,它为应用程序提供了多线程运行环境,把密集型计算和高延迟的任务分离到 Worker 中去,降低阻塞宿主线程(主要是UI线程)的可能性。
创建 Worker 文件
我们仅讨论 Stage 模型,我们首先找到 build-prefile.json5 配置文件,配置我们的 Worder 线程文件(这与前面我们了解到的 JavaScript 多线程一样,我们独立使用一个文件来保存我们的 worker 代码),只有这样线程文件才能被正确的打包到应用中。
"buildOption": {
"sourceOption": {
"workers": [
"./src/main/ets/workers/worker.ets"
]
}
}
建议你直接使用 DecEco 的一键生成功能,在对应的模块下的任意位置,右键 》 New 》 Worker,它会帮你直接创建好文件并配置好该文件。
如果你想了解更多关于 Worker 的细节,从而逃离这篇文章带给你的想象(你随时都可以这么干),去吧:
Worker简介https://developer.huawei.com/consumer/cn/doc/harmonyos-guides-V5/worker-introduction-V5
启动 Worker
在我们完成了 Worker 文件的创建和配置工作后,我们来编写启动 Worker 对象的代码。
在顶部导入 Worder 模块:
// Index.ets
import { ErrorEvent, MessageEvents, worker } from '@kit.ArkTS'
在宿主线程中(通常是UI线程中,即组件代码中),创建 Worker 对象,并注册回调函数:
// Index.ets
@Entry
@Component
struct Index {
build() {
RelativeContainer() {
Text('Hello World')
.onClick(() => {
// 创建Worker对象
let workerInstance = new worker.ThreadWorker('entry/ets/workers/worker.ets');
// 注册onmessage回调,当宿主线程接收到来自其创建的Worker通过workerPort.postMessage接口发送的消息时被调用,在宿主线程执行
workerInstance.onmessage = (e: MessageEvents) => {
let data: string = e.data;
console.info("workerInstance onmessage is: ", data);
}
// 注册onerror回调,当Worker在执行过程中发生异常时被调用,在宿主线程执行
workerInstance.onerror = (err: ErrorEvent) => {
console.info("workerInstance onerror message is: " + err.message);
}
// 注册onmessageerror回调,当Worker对象接收到一条无法被序列化的消息时被调用,在宿主线程执行
workerInstance.onmessageerror = () => {
console.info('workerInstance onmessageerror');
}
// 注册onexit回调,当Worker销毁时被调用,在宿主线程执行
workerInstance.onexit = (e: number) => {
// 当Worker正常退出时code为0,异常退出时code为1
console.info("workerInstance onexit code is: ", e);
}
// 向Worker线程发送消息
workerInstance.postMessage('1');
})
}
.height('100%')
.width('100%')
}
}
然后,我们看 Worker 文件中能做什么:
// worker.ets
import { ErrorEvent, MessageEvents, ThreadWorkerGlobalScope, worker } from '@kit.ArkTS';
const workerPort: ThreadWorkerGlobalScope = worker.workerPort;
// 注册onmessage回调,当Worker线程收到来自其宿主线程通过postMessage接口发送的消息时被调用,在Worker线程执行
workerPort.onmessage = (e: MessageEvents) => {
let data: string = e.data;
console.info('workerPort onmessage is: ', data);
// 向主线程发送消息
workerPort.postMessage('2');
}
// 注册onmessageerror回调,当Worker对象接收到一条无法被序列化的消息时被调用,在Worker线程执行
workerPort.onmessageerror = () => {
console.info('workerPort onmessageerror');
}
// 注册onerror回调,当Worker在执行过程中发生异常被调用,在Worker线程执行
workerPort.onerror = (err: ErrorEvent) => {
console.info('workerPort onerror err is: ', err.message);
}
从上述代码中我们不难看出,在主线程中,我们创建 Worker 对象并注册了关于 Worker 线程的回调,当我们点击了 Text 组件时,我们向 Worker 线程中发送了 数字 “1”,在 worker 文件中,我们对主线程发送的数字 “1” 进行了响应(向其发送了数字 “2”),其次,我们注册了 错误回调(无法序列化、执行错误)。
限制条件
在官方文档中,对于Worker 的限制条件有很多,我们挑几个重点讨论:
- 一定要记得配置 Worker 文件。
- 不同的版本中,创建 Worker 文件对象时,我们向构造器传递线程文件的路径因版本的不同而不同。
- 别在 worker 线程中使用 UI 相关的非线程安全的库(API)。
- 记得在宿主线程注册 onerror 回调,否则 worker 出现错误容易引发 crash。
- worker 线程记得在不用的时候进行 terminate()处理,避免占用。
另外,在官方的文档中还提到了传输数据量大小限制等问题,如果你想了解更多,那么请看这里:
多级 Worker 嵌套
Worker 还支持多级嵌套,即 Worker 还支持创建子 Worker 形成嵌套关系,这里要着重注意生命周期的维护,避免产生不可预期的效果。
我们看以下代码(在主线程创建 worder):
// 在主线程中创建Worker线程(父Worker),在worker线程中再次创建Worker线程(子Worker)
// main thread
import { worker, MessageEvents, ErrorEvent } from '@kit.ArkTS';
// 主线程中创建父worker对象
const parentworker = new worker.ThreadWorker("entry/ets/workers/parentworker.ets");
parentworker.onmessage = (e: MessageEvents) => {
console.info("主线程收到父worker线程信息 " + e.data);
}
parentworker.onexit = () => {
console.info("父worker退出");
}
parentworker.onerror = (err: ErrorEvent) => {
console.info("主线程接收到父worker报错 " + err);
}
parentworker.postMessage("主线程发送消息给父worker-推荐示例");
继续在 parentworker 中创建 childworker。
// parentworker.ets
import { ErrorEvent, MessageEvents, ThreadWorkerGlobalScope, worker } from '@kit.ArkTS';
// 创建父Worker线程中与主线程通信的对象
const workerPort: ThreadWorkerGlobalScope = worker.workerPort;
workerPort.onmessage = (e : MessageEvents) => {
if (e.data == "主线程发送消息给父worker-推荐示例") {
let childworker = new worker.ThreadWorker("entry/ets/workers/childworker.ets");
childworker.onmessage = (e: MessageEvents) => {
console.info("父Worker收到子Worker的信息 " + e.data);
if (e.data == "子Worker向父Worker发送信息") {
workerPort.postMessage("父Worker向主线程发送信息");
}
}
childworker.onexit = () => {
console.info("子Worker退出");
// 子Worker退出后再销毁父Worker
workerPort.close();
}
childworker.onerror = (err: ErrorEvent) => {
console.info("子Worker发生报错 " + err);
}
childworker.postMessage("父Worker向子Worker发送信息-推荐示例");
}
}
childworker 的实现:
// childworker.ets
import { ErrorEvent, MessageEvents, ThreadWorkerGlobalScope, worker } from '@kit.ArkTS';
// 创建子Worker线程中与父Worker线程通信的对象
const workerPort: ThreadWorkerGlobalScope = worker.workerPort;
workerPort.onmessage = (e: MessageEvents) => {
if (e.data == "父Worker向子Worker发送信息-推荐示例") {
// 子Worker线程业务逻辑...
console.info("业务执行结束,然后子Worker销毁");
workerPort.close();
}
}
在parentworker 代码中,你会发现当 childworker 退出时(onexit),parentworker 也随即退出。
一旦你编写了类似的代码后,一定要注意他们的生命周期维护,避免出现意外情况。
TaskPool
TaskPool (任务池)与 Worker 一样,为多线程提供了另一种运行机制,它的使用方法与 Worker 完全不同,它的运行机制如下:
引用来自官方的图例:
与 Worker 的主要不同是:
当宿主线程发送任务时,系统首先会将任务增加至任务队列,系统在合适的时机为其寻找合适的工作线程,最后将结果响应给宿主线程。
任务队列其实在 API 的设计上有具体体现:
Taskpool 的API参考https://developer.huawei.com/consumer/cn/doc/harmonyos-references-V5/js-apis-taskpool-V5它允许你为任务设置优先级,延迟时间等,这点区别与 Worker,如果你想更加深层级的了解它,你可以点击上面的链接去看看它的 API。
并发函数作为任务
要使用Taskpool你必须先创建并发函数,并发函数需要使用 @Concurrent 装饰器装饰。
并发函数你可以理解为你的耗时任务的执行函数。
我们看以下代码:
import { taskpool } from '@kit.ArkTS';
@Concurrent
function add(num1: number, num2: number): number {
return num1 + num2;
}
async function ConcurrentFunc(): Promise<void> {
try {
let task: taskpool.Task = new taskpool.Task(add, 1, 2);
console.info("taskpool res is: " + await taskpool.execute(task));
} catch (e) {
console.error("taskpool execute error is: " + e);
}
}
@Entry
@Component
struct Index {
@State message: string = 'Hello World'
build() {
Row() {
Column() {
Text(this.message)
.fontSize(50)
.fontWeight(FontWeight.Bold)
.onClick(() => {
ConcurrentFunc();
})
}
.width('100%')
}
.height('100%')
}
}
在上述代码中,add 作为并发函数被 @Concurrent 装饰器装饰,其内部进行了简单的 “加法”运算,我们用来模拟耗时操作。
接着:我们创建任务 Task,由导入的 taskpool 内的构造函数 Task 创建它,构造器函数内我们传入 并发函数 add,和它接受的两个参数(数字)。
最后:我们通过taskpool 的 execute 函数执行我们创建好的 task (任务)。
当然,这些代码的执行需要你点击 “Hello world” 文本才会执行。
执行延迟任务
如果你不希望立刻开始一个任务,你还可以使用延迟执行任务API:
// import BusinessError
import { BusinessError } from '@kit.BasicServicesKit'
@Concurrent
function printArgs(args: number): void {
console.info("printArgs: " + args);
}
let t: number = Date.now();
console.info("taskpool start time is: " + t);
let task: taskpool.Task = new taskpool.Task(printArgs, 100); // 100: test number
taskpool.executeDelayed(1000, task).then(() => { // 1000:delayTime is 1000ms
console.info("taskpool execute success");
}).catch((e: BusinessError) => {
console.error(`taskpool execute: Code: ${e.code}, message: ${e.message}`);
})
周期性执行任务
如果你希望每隔一段时间来执行一次任务,你还可以这样做:
@Concurrent
function printArgs(args: number): void {
console.info("printArgs: " + args);
}
@Concurrent
function testExecutePeriodically(args: number): void {
let t = Date.now();
while ((Date.now() - t) < args) {
continue;
}
taskpool.Task.sendData(args); // 向宿主线程发送消息
}
function printResult(data: number): void {
console.info("taskpool: data is: " + data);
}
function taskpoolTest() {
try {
let task: taskpool.Task = new taskpool.Task(printArgs, 100); // 100: test number
taskpool.executePeriodically(1000, task); // 1000: period is 1000ms
} catch (e) {
console.error(`taskpool execute-1: Code: ${e.code}, message: ${e.message}`);
}
try {
let periodicTask: taskpool.Task = new taskpool.Task(testExecutePeriodically, 200); // 200: test number
periodicTask.onReceiveData(printResult);
taskpool.executePeriodically(1000, periodicTask); // 1000: period is 1000ms
} catch (e) {
console.error(`taskpool execute-2: Code: ${e.code}, message: ${e.message}`);
}
}
taskpoolTest();
当你每次周期性的执行任务后,你还可以为任务创建接收数据的回调(onReceiveData)来接收执行结果。
限制条件和总结
任务池(taskpool)提供的API数量远超 Worker,事实上,taskpool 不仅允许你对任务优先级,延迟执行,周期性执行等进行配置,还对 任务 (Task) 本身进行了相当多的抽象,如:“长时任务”,taskpool 对于某个任务的执行时间其实做了限制(一般不能超过三分钟),“长时任务”就是为了弥补这一点,通过创建 LongTask 来突破这一限制,允许保持你的线程长时间执行。
如果你想进一步了解 taskpool 的更多高级用法,这里有它的全部 API:
taskpool API参考https://developer.huawei.com/consumer/cn/doc/harmonyos-references-V5/js-apis-taskpool-V5#longtask12当然它也有很多限制,我们挑重点说几个:
- 普通任务别超时,即通过 taskpool.Task 创建的任务,超过三分钟会强制退出。
- 任务参数得能序列化。(哪些能序列化?)。
- 别使用线程不安全的UI 相关库(API)。
- 传输的数据量别超过16MB。
- 别在工作线程中使用AppStroage。
官方其实也强调了其他的一些 注意事项,比如内存共享等,我更希望在一个全新的章节讨论它们。
异步并发(Promise、async、await)带来的误会
其实对于语法级关键字 Promise、async、await 很容易为并发多线程带来误会,他们是标准的JS 异步语法,事实上他们仅能保证同一时间保持一段代码执行,并不能多个线程参与执行同一块代码。
这种误会通常发生在 前端工程师转型到鸿蒙应用开发岗位时,如果你以前是前端工程师,不妨来复深入理解一下 “异步”。
当然为了消除这种误会,鸿蒙官方也对这些语言级别的异步关键字做了讨论:
如果觉得有用,点个赞支持一下呗(能关注一下就更好啦)。