鸿蒙开发——并发之异步与线程

368 阅读12分钟

一、并发概述

并发是指在同一时间段内,能够处理多个任务的能力。并发并不一定意味着同时执行(这需要多线程或多核处理器),而是指任务的执行在时间上是交错的。为了提升应用的响应速度与帧率,以及防止耗时任务对主线程的干扰,系统提供了异步并发和多线程并发两种处理策略。

  • 异步并发是指异步代码在执行到一定程度后会被暂停,以便在未来某个时间点继续执行,同一时间只有一段代码在执行。ArkTS通过Promise和async/await提供异步并发能力,适用于单次I/O任务的开发场景。

  • 多线程并发允许在同一时间段内同时执行多段代码。ArkTS通过TaskPool和Worker提供多线程并发能力。

二、异步并发

Promise和async/await提供异步并发能力,适用于单次I/O任务的开发场景,标准的JS异步语法。

1、Promise

Promise是一种用于处理异步操作的对象,可以将异步操作转换为类似于同步操作的风格。Promise有三种状态:

  • pending(进行中)

  • fulfilled(已完成)

  • rejected(已拒绝)

Promise对象创建后处于pending状态,并在异步操作完成后转换为fulfilled或rejected状态。

const promise: Promise<number> = new Promise((resolve: Function, reject: Function) => {
setTimeout(() => {
  const randomNumber: number = Math.random();
  if (randomNumber > 0.5) {
    resolve(randomNumber);
  } else {
    reject(new Error('Random number is too small'));
  }
}, 1000);
})

Promise对象创建后,可以使用then方法和catch方法指定fulfilled状态和rejected状态的回调函数

promise.then((result: number) => {
 console.info(`Random number is ${result}`);
}).catch((error: BusinessError) => {
 console.error(error.message);
});

2、async/await

一种用于处理异步操作的Promise语法糖。

使用async关键字声明一个函数为异步函数,并使用await关键字等待Promise的解析,并将其解析值存储在result变量中。

async function myAsyncFunction(): Promise<void> {
  const result: string = await new Promise((resolve: Function) => {
    setTimeout(() => {
      resolve('Hello, world!');
    }, 3000);
  });
  console.info(result); // 输出: Hello, world!
}

myAsyncFunction();

三、JS中异步如何实现

  • 同步任务:在主线程上排队执行的任务,只有前一个任务执行完毕,才能执行后一个任务;

  • 异步任务:不进入主线程、而进入"任务队列"(task queue)的任务,只有"任务队列"通知主线程,某个异步任务可以执行了,该任务才会进入主线程执行。

1、Js中的Event Loop(事件循环)

事件循环是 JavaScript 中处理异步操作的核心机制,它负责管理任务队列的执行顺序。

image.png

  1. JavaScript 引擎首先执行所有的同步任务。

  2. 当遇到异步任务时,JavaScript 会将其交给其他工作线程处理(如 I/O 操作、定时器、网络请求等),主线程继续执行后续代码。

  3. 一旦异步任务完成(如 I/O 操作完成、定时器到期),相关的回调函数会被推入任务队列(Task Queue) 。

  4. 事件循环检查主线程是否空闲,如果空闲,则从任务队列中取出异步任务的回调函数,放入主线程执行。

2、宏任务和微任务

为什么分两种:

页面渲染事件,各种IO的完成事件等随时被添加到任务队列中,一直会保持先进先出的原则执行,我们不能准确地控制这些事件被添加到任务队列中的位置。但是这个时候突然有高优先级的任务需要尽快执行,那么一种类型的任务就不合适了,所以引入了微任务队列。

1、宏任务:

  • script(整体代码)
  • setTimeout()
  • setInterval()
  • postMessage
  • I/O
  • UI交互事件

2、微任务:

  • new Promise().then(回调)
  • async/await

3、运行机制

1、在当前执行栈为空时,主线程会查看微任务队列是否有事件存在

  • 存在,依次执行队列中的事件对应的回调,直到微任务队列为空,然后去宏任务队列中取出最前面的事件,把当前的回调加到当前指向栈。

  • 不存在,那么再去宏任务队列中取出一个事件并把对应的回到加入当前执行栈;

2、当前执行栈执行完毕后时会立刻处理所有微任务队列中的事件,然后再去宏任务队列中取出一个事件。同一次事件循环中,微任务永远在宏任务之前执行。

3、步骤

  • 执行一个宏任务(栈中没有就从事件队列中获取)

  • 执行过程中如果遇到微任务,就将它添加到微任务的任务队列中

  • 宏任务执行完毕后,立即执行当前微任务队列中的所有微任务(依次执行)

  • 当前宏任务执行完毕,开始检查渲染,然后GUI线程接管渲染

  • 渲染完毕后,JS线程继续接管,开始下一个宏任务(从事件队列中获取)

总结:

执行宏任务,然后执行该宏任务产生的微任务,若微任务在执行过程中产生了新的微任务,则继续执行微任务,微任务执行完毕后,再回到宏任务中进行下一轮循环。

四、线程

线程是是操作系统能够进行运算调度的基本单位,共享进程的资源,一个进程可以包含多个线程。

1、线程分类

1. 主线程

  • 执行UI绘制。

  • 管理主线程的ArkTS引擎实例,使多个UIAbility组件能够运行在其之上。

  • 管理其他线程的ArkTS引擎实例,例如使用TaskPool(任务池)创建任务或取消任务、启动和终止Worker线程。

  • 分发交互事件。

  • 处理应用代码的回调,包括事件处理和生命周期管理。

  • 接收TaskPool以及Worker线程发送的消息。

2. TaskPool Worker线程。

  • TaskPool自行管理线程数量,其生命周期由TaskPool统一管理。Worker线程最多创建8个,其生命周期由开发者自行维护。用于执行耗时操作,支持设置调度优先级、负载均衡等功能。

3. Worker线程

  • 用于执行耗时操作,支持线程间通信。

image.png

2、Actor并发模型

Actor并发模型线程之间不共享内存,需要通过线程间通信机制传输并发任务和任务结果。

  • Actor并发模型每一个线程都是一个独立Actor,每个Actor有自己独立的内存,Actor之间通过消息传递机制触发对方Actor的行为,不同Actor之间不能直接访问对方的内存空间。

  • Actor并发模型对比内存共享并发模型的优势在于不同线程间内存隔离,不会产生不同线程竞争同一内存资源的问题。开发者不需要考虑对内存上锁导致的一系列功能、性能问题,提升了开发效率。

3、线程通信 Emitter

Emitter是一种作用在进程内的事件处理机制,为应用程序提供订阅事件、发布事件、取消事件订阅的能力。

订阅事件

 // 定义一个eventId为1的事件。
 let event: emitter.InnerEvent = {
   eventId: 1
 };
 
 // on订阅事件,收到eventId为1的事件后执行回调函数。
 emitter.on(event, () => {
   console.info('on callback');
 });

发送事件

// 定义一个eventId为1的事件,事件优先级为Low。
let event: emitter.InnerEvent = {
  eventId: 1,
  priority: emitter.EventPriority.LOW
};
let eventData: emitter.EventData = {
  data: {
    content: 'emitter',
    id: 1,
    isEmpty: false
  }
};

// 发送eventId为1的事件,事件内容为eventData。
emitter.emit(event, eventData);

五、TaskPool和Worker

1、应用场景

主要用于CPU密集型任务、I/O密集型任务和同步任务等并发场景。提供了TaskPool和Worker两种并发能力

  • CPU密集型任务:占用系统资源处理大量计算能力的任务,需要长时间运行。例如图像处理、视频编码、数据分析等。

  • I/O密集型任务:通常需要频繁地进行磁盘读写、网络通信等操作。

  • 同步并发任务:在多个线程之间协调执行的任务,其目的是确保多个任务按照一定的顺序和规则执行,以达到特定的目的。

2、TaskPool

任务池(TaskPool)作用是为应用程序提供一个多线程的运行环境,降低整体资源的消耗、提高系统的整体性能,无需关心线程实例的生命周期。

image.png

2.1 注意事项:

  • 任务函数在TaskPool工作线程的执行耗时不能超过3分钟(不包含Promise和async/await异步调用的耗时,例如网络下载、文件读写等I/O任务的耗时),否则会被强制退出。

  • 实现任务的函数入参需满足序列化支持的类型。

  • 由于不同线程中上下文对象是不同的,因此TaskPool工作线程只能使用线程安全的库,例如UI相关的非线程安全库不能使用。

  • 序列化传输的数据量大小限制为16MB。

  • Promise不支持跨线程传递

2.2 使用示例

@Concurrent
async function asyncFunc(val1:number, val2:number): Promise<number> {
  let ret: number = await new Promise((resolve, reject) => {
    let value = val1 + val2;
    resolve(value);
  });
  return ret; // 支持。直接返回Promise的结果。
}

function taskpoolExecute() {
  taskpool.execute(asyncFunc, 10, 20).then((result: Object) => {
    console.info("taskPoolTest task result: " + result);
  }).catch((err: string) => {
    console.error("taskPoolTest test occur error: " + err);
  });
}
taskpoolExecute()

3、Worker

3.1 基本概念

  • 创建Worker的线程称为宿主线程(不一定是主线程,工作线程也支持创建Worker子线程),Worker自身的线程称为Worker子线程。

  • 每个Worker子线程与宿主线程拥有独立的实例,包含基础设施、对象、代码段等。

  • Worker子线程和宿主线程之间的通信是基于消息传递的,Worker通过序列化机制与宿主线程之间相互通信,完成命令及数据交互。

image.png

3.2 注意事项

  • Worker创建后需要手动管理生命周期,且最多同时运行的Worker子线程数量为64个。

  • 不支持跨HAP使用Worker线程文件。

  • 由于不同线程中上下文对象是不同的,因此Worker线程只能使用线程安全的库,例如UI相关的非线程安全库不能使用。

  • 序列化传输的数据量大小限制为16MB。

3.3 主线程发送和接收消息

在主线程中通过调用ThreadWorker的constructor()方法创建Worker对象,当前线程为宿主线程。


// 创建Worker对象

let workerInstance = new worker.ThreadWorker('entry/ets/workers/worker.ets');

在主线程中通过调用onmessage()方法接收Worker线程发送过来的消息,并通过调用postMessage()方法向Worker线程发送消息。

// 注册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');

3.4 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);
}

3.5 线程销毁

在宿主线程中通过调用onexit()方法定义Worker线程销毁后的处理逻辑。

// Worker线程销毁后,执行onexit回调方法
workerInstance.onexit = (): void => {
 console.info("main thread terminate");
}

在宿主线程中通过调用terminate()方法销毁Worker线程,并终止Worker接收消息。


// 销毁Worker线程

workerInstance.terminate();

在Worker线程中通过调用close()方法主动销毁Worker线程,并终止Worker接收消息。


// 销毁线程

workerPort.close();

4、TaskPool和Worker的对比

由于TaskPool的工作线程会绑定系统的调度优先级,并且支持自动扩缩容。而Worker需要开发者自行创建,存在创建耗时以及不支持设置调度优先级,故在性能方面使用TaskPool会优于Worker,因此大多数场景推荐使用TaskPool。

  • 编码效率:TaskPool写法比Worker更简洁更好掌控,TaskPool还支持任务组、任务优先级、取消任务等能力。

  • 线程创建:Worker比TaskPool创建线程的开销大,因此对于应用首帧要求快速响应的场景推荐使用TaskPool。

  • 数据传输:TaskPool支持将任务方法作为一个参数进行传输,任务方法的序列化与反序列化耗时很短,可以忽略其影响。在需要处理多个不同任务的场景,TaskPool可以直接传递任务方法,而Worker需要创建worker.js文件承载任务方法相对复杂,此场景推荐使用TaskPool;其他情况下开发者可以选择Worker,也可以选择TaskPool。

  • 任务执行耗时:在中载场景下两种并发方案都可以选择,在重载下需要任务优先执行的场景推荐使用TaskPool并发方案。

5、应用场景

  • 运行时间超过3分钟使用Worker。

  • 有关联的一系列同步任务需要使用Worker。

  • 需要设置优先级的任务需要使用TaskPool。

  • 需要频繁取消的任务需要使用TaskPool。

  • 大量或者调度点较分散的任务推荐采用TaskPool。