【鸿蒙应用开发】多线程并发(Stage模型)

写在前面

很多时候,我写的文章并不太详实,尤其是对于某个 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 并发模型实际上还对应了一种常见的并发模型,即:内存共享模型,它实际上也是一种广泛应用的模型,我们不再这里赘述,想了解的小伙伴可以去看鸿蒙官方对它的描述。

多线程并发概述https://developer.huawei.com/consumer/cn/doc/harmonyos-guides-V5/multi-thread-concurrency-overview-V5#%E5%A4%9A%E7%BA%BF%E7%A8%8B%E5%B9%B6%E5%8F%91%E6%A8%A1%E5%9E%8B

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 的注意事项https://developer.huawei.com/consumer/cn/doc/harmonyos-guides-V5/worker-introduction-V5#worker注意事项

多级 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当然它也有很多限制,我们挑重点说几个:

  1. 普通任务别超时,即通过 taskpool.Task 创建的任务,超过三分钟会强制退出。
  2. 任务参数得能序列化。(哪些能序列化?)。
  3. 别使用线程不安全的UI 相关库(API)。
  4. 传输的数据量别超过16MB。
  5. 别在工作线程中使用AppStroage。

官方其实也强调了其他的一些 注意事项,比如内存共享等,我更希望在一个全新的章节讨论它们。

异步并发(Promise、async、await)带来的误会

其实对于语法级关键字 Promise、async、await 很容易为并发多线程带来误会,他们是标准的JS 异步语法,事实上他们仅能保证同一时间保持一段代码执行,并不能多个线程参与执行同一块代码。

这种误会通常发生在 前端工程师转型到鸿蒙应用开发岗位时,如果你以前是前端工程师,不妨来复深入理解一下 “异步”。

异步 - MDN Web 文档术语表:Web 相关术语的定义 | MDN异步(Asynchronous)指的是两个或多个对象或事件不同时存在或发生,也就是说,它们不是同步的。当多个相关的,但是并不依赖于前面发生的事情完成的事情发生时,它们就是异步的。https://developer.mozilla.org/zh-CN/docs/Glossary/Asynchronous

当然为了消除这种误会,鸿蒙官方也对这些语言级别的异步关键字做了讨论:

异步并发 (Promise和async/await)https://developer.huawei.com/consumer/cn/doc/harmonyos-guides-V5/async-concurrency-overview-V5

如果觉得有用,点个赞支持一下呗(能关注一下就更好啦)。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值