前言
在绝大部分的应用开发过程中都会使用到多线程技术来提升效率。在鸿蒙开发中系统支持两种并发技术,一种是基于Promise、async/await语法的异步并发技术,一种是基于Worker、TaskPool语法的多线程并发技术
什么是并发
并发是指在同一时间内,存在多个任务同时执行的情况。对于多核设备,这些任务可能同时在不同CPU上并行执行。对于单核设备,多个并发任务不会在同一时刻并行执行,但是CPU会在某个任务休眠或进行I/O操作等状态下切换任务,调度执行其他任务,提升CPU的资源利用率
异步并发
异步代码在执行到一定程度后会被暂停,以便在未来某个时间点继续执行,这种情况下,同一时间只有一段代码在执行。ArkTS通过Promise和async/await提供异步并发能力,适用于单次I/O任务的开发场景。详细请参见 使用异步并发能力。
Promise
Promise提供了一个状态机制来管理异步操作的不同阶段,并提供了一些方法来注册回调函数以处理异步操作的成功或失败的结果。
Promise有三种状态:pending(进行中)、fulfilled(已完成)和rejected(已拒绝)。Promise对象创建后处于pending状态,并在异步操作完成后转换为fulfilled或rejected状态。
最基本的用法是通过构造函数实例化一个Promise对象,同时传入一个带有两个参数的函数,通常称为executor函数。executor函数接收两个参数:resolve和reject,分别表示异步操作成功和失败时的回调函数
Promise对象创建后,可以使用then方法和catch方法指定fulfilled状态和rejected状态的回调函数。then方法可接受两个参数,一个处理fulfilled状态的函数,另一个处理rejected状态的函数。只传一个参数则表示当Promise对象状态变为fulfilled时,then方法会自动调用这个回调函数,并将Promise对象的结果作为参数传递给它。使用catch方法注册一个回调函数,用于处理“失败”的结果,即捕获Promise的状态改变为rejected状态或操作失败抛出的异常
async/await
async/await是一种用于处理异步操作的Promise语法糖
通过使用async关键字声明一个函数为异步函数,并使用await关键字等待Promise的解析(完成或拒绝),以同步的方式编写异步操作的代码。
async函数是一个返回Promise对象的函数,用于表示一个异步操作。在async函数内部,可以使用await关键字等待一个Promise对象的解析,并返回其解析值。如果一个async函数抛出异常,那么该函数返回的Promise对象将被拒绝,并且异常信息会被传递给Promise对象的onRejected()方法
多线程并发
在同一时间段内同时执行多段代码。 在UI主线程继续响应用户操作和更新UI的同时,后台线程也能执行耗时操作,从而避免应用出现卡顿。ArkTS通过TaskPool和Worker提供多线程并发能力,适用于耗时任务等并发场景。详细请参见多线程并发概述。
常见的并发模型分为基于内存共享的并发模型和基于消息通信的并发模型
共享内存并发模型
内存共享并发模型指多线程同时执行任务,这些线程依赖同一内存并且都有权限访问,线程访问内存前需要抢占并锁定内存的使用权,没有抢占到内存的线程需要等待其他线程释放使用权再执行
对于不同业务,如果包含I/O操作或者锁,为了业务不被阻塞,需要开启多个线程来执行不同的业务,线程情况如下图所示:
应用中经常存在几百个线程,增加了调度开销和内存占用
以一个例子说明一下
为了避免不同生产者或消费者同时访问一块共享内存的容器时产生的脏读,脏写现象,同一时间只能有一个生产者或消费者访问该容器,也就是不同生产者和消费者争夺使用容器的锁。当一个角色获取锁之后其他角色需要等待该角色释放锁之后才能重新尝试获取锁以访问该容器
Actor并发模型
Actor并发模型每一个线程都是一个独立Actor,每个Actor有自己独立的内存,Actor之间通过消息传递机制触发对方Actor的行为,不同Actor之间不能直接访问对方的内存空间
ArkTS采用内存隔离的线程模型,不同线程之间通过消息通信,线程内无锁化运行。
对于不同业务,其内部的I/O操作会由系统分发到后台的I/O任务池,不阻塞ArkTS上层逻辑,线程情况如下图所示:
异步I/O不阻塞ArkTS线程,同时TaskPool及I/O线程池由系统管理,提升能效。
TaskPool与Worker两种多线程并发能力均是基于 Actor并发模型实现的
以一个例子说明
Actor模型不同角色之间并不共享内存,生产者线程和UI线程都有自己的虚拟机实例,两个虚拟机实例之间拥有独占的内存,相互隔离。生产者生产出结果后通过序列化通信将结果发送给UI线程,UI线程消费结果后再发送新的生产任务给生产者线程
并发能力框架
-
主线程: 执行UI业务、不耗时操作、单次I/O任务,与其他ArkTS线程共享系统I/O线程池,不阻塞当前ArkTS线程。
-
TaskPool 高并发任务池: 执行耗时任务,基于TaskPool封装任务执行的入口,可统计模块负载,开发者无需管理线程实例的生命周期。
-
Worker 线程: 执行常驻任务,CPU密集型、耗时任务,当前限制线程个数为64。
-
FFRT 任务池:
- 系统任务:系统分发到FFRT线程的业务,例如异步I/O任务等,开发者无需关注;
- 用户任务:开发者创建的C/C++耗时任务,支持负载均衡及线程生命周期管理等能力。
-
Pthread 线程: 采用C/C++开发的模块,需要后台运行或者耗时的ArkTS无关业务,不限制线程个数
Worker
Worker主、子线程通过收发消息进行通信,空任务的Worker线程的内存占用大约2MB左右,因此需要控制线程的数量,避免内存过大。
Worker运作机制
- 创建Worker的线程称为宿主线程(不一定是主线程,工作线程也支持创建Worker子线程)
- Worker自身的线程称为Worker子线程(或Actor线程、工作线程)
- 每个Worker子线程与宿主线程拥有独立的实例,包含基础设施、对象、代码段等,因此每个Worker启动存在一定的内存开销,需要限制Worker的子线程数量。
- Worker子线程和宿主线程之间的通信是基于消息传递的,Worker通过序列化机制与宿主线程之间相互通信,完成命令及数据交互
Worker工作原理
Worker拥有独立的运行环境,每个Worker线程和主线程一样拥有自己的内存空间、消息队列(MessageQueue)、事件轮询机制(EventLoop)、调用栈(CallStack)等。线程之间通过Message进行交互
在多核的情况下(下图中的CPU 1和CPU 2同时工作),多个Worker线程(下图中的Worker thread1和Worker thread2)可以同时执行,因此Worker线程做到了真正的并发
TaskPool
TaskPool提供了任务分发的入口,基于Worker做了更多场景化的功能封装,支持将任务分发到不同优先级的队列,TaskPool底层自动管理了一定数量的工作线程,会从队列获取任务执行。同时,工作线程会根据任务数量进行自动扩缩容,保证任务执行效率。TaskPool内部会根据任务量及当前线程数量,决定是否扩容或缩容,当任务较多时会扩容。线程的上限跟硬件核数相关,例如8核设备,线程数上限大概为7-15左右。
TaskPool运作机制
TaskPool支持开发者在宿主线程封装任务抛给任务队列,系统选择合适的工作线程,进行任务的分发及执行,再将结果返回给宿主线程.
系统默认会启动一个任务工作线程,当任务较多时会扩容,工作线程数量上限跟当前设备的物理核数相关,具体数量内部管理,保证最优的调度及执行效率,长时间没有任务分发时会缩容,减少工作线程数量。
TaskPool工作原理
TaskPool在Worker之上实现了调度器和Worker线程池。在主线程(ArkTS Main Thread)中调用execute接口会将待执行的任务方法及参数信息,根据设置的任务优先级放入任务队列(TaskQueue)中等待调度执行。调度器会依据调度算法(优先级,防饥饿),从优先级队列中取出任务进行序列化,放入TaskPool中的Worker线程池,工作线程(ArkTS Worker Thread)根据调度器的安排执行任务方法,并将任务处理结果进行反序列化,最终以Promise-Then的方式返回给主线程。TaskPool的工作线程池会根据待执行的任务数量,任务的执行时间进行相应的扩容与缩容
并发能力选择
- 使用TaskPool解决 耗时任务并发执行场景
需要注意:任务时长不应超过3分钟,与主线程上下文无关,线程间传递对象不应过大
- 使用Worker解决 常驻任务并发执行场景
需要注意:开发者需要主动创建或关闭Worker线程,并监听onmessage等方法进行通信,Worker最大数量为64
-
根据需要使用Task或Worker解决 传统共享内存并发业务
并发任务管理
ArkTS提供串行队列(SequenceRunner)能力,可以将多个任务加入到串行队列中,使加入队列的任务按顺序执行,也可以创建多组串行队列分组管理
TaskPool目前提供addDependency(增加对其他任务的依赖)和removeDependency(移除对其他任务的依赖)两个接口,开发者可以通过调用这两个接口对任务设置依赖关系。任务默认不存在依赖关系,即不依赖其他任务和其他任务不依赖当前任务。
TaskPool内部维护一个任务依赖关系列表,调用addDependency/removeDependency对该列表进行数据更新。任务执行时前查询该列表,若该任务依赖其他任务,则该任务等待这些任务全部执行结束再执行;若该任务被其他任务依赖,则该任务执行结束会将依赖它的这些任务加入到Taskpool等待执行的队列中
-
多任务同步等待结果(任务组) 使用taskPool的TaskGroup API可以将多个任务一起执行,都执行完毕之后返回结果
TaskPool提供了4种优先级属性: HIGH、MEDIUM、LOW、以及IDLE(高、中、低、后台)。
目前,仅有taskpool.Task支持优先级属性的设置(function类型不支持),默认优先级为MEDIUM。开发者可以通过taskpool.execute()接口在抛任务时显式指定优先级。
TaskPool底层对HIGH、MEDIUM、LOW任务的调度按照M:N:1进行,即每调用M个高优先级任务后会去调用1个中优先级任务。每调用N个中优先级任务后会去调用1个低优先级任务。通过配置比例关系,在保证高优先级任务优先执行的情况下,中优先级任务得到合理调度,低优先级任务不会饿死(目前M:N:1为5:5:1)。
优先级机制底层对接了QoS(quality-of-service),因此3种属性也对应着不同的线程优先级。高优先级的任务除了在TaskPool队列中会得到优先调度外,在CPU调度上也会获得更多的系统资源。
Priority的IDLE优先级是用来标记需要在后台运行的耗时任务(例如数据同步、备份),它的优先级别是最低的。这种优先级标记的任务只会在所有线程都空闲的情况下触发执行,并且只会占用一个线程来执行
taskpool.Task提供了executeDelayed可以延时执行任务
TaskPool和Worker的对比
实现 | TaskPool | Worker |
---|---|---|
内存模型 | 线程间隔离,内存不共享。 | 线程间隔离,内存不共享。 |
参数传递机制 | 采用标准的结构化克隆算法(Structured Clone)进行序列化、反序列化,完成参数传递。支持ArrayBuffer转移和SharedArrayBuffer共享。 | 采用标准的结构化克隆算法(Structured Clone)进行序列化、反序列化,完成参数传递。支持ArrayBuffer转移和SharedArrayBuffer共享。 |
参数传递 | 直接传递,无需封装,默认进行transfer。 | 消息对象唯一参数,需要自己封装。 |
方法调用 | 直接将方法传入调用。 | 在Worker线程中进行消息解析并调用对应方法。 |
返回值 | 异步调用后默认返回。 | 主动发送消息,需在onmessage解析赋值。 |
生命周期 | TaskPool自行管理生命周期,无需关心任务负载高低。 | 开发者自行管理Worker的数量及生命周期。 |
任务池个数上限 | 自动管理,无需配置。 | 同个进程下,最多支持同时开启64个Worker线程,实际数量由进程内存决定。 |
任务执行时长上限 | 3分钟(不包含Promise和async/await异步调用的耗时,例如网络下载、文件读写等I/O任务的耗时),长时任务无执行时长上限。 | 无限制。 |
设置任务的优先级 | 支持配置任务优先级。 | 不支持。 |
执行任务的取消 | 支持取消已经发起的任务。 | 不支持。 |
线程复用 | 支持。 | 不支持。 |
任务延时执行 | 支持。 | 不支持。 |
设置任务依赖关系 | 支持。 | 不支持。 |
串行队列 | 支持。 | 不支持。 |
任务组 | 支持。 | 不支持。 |
线程间通信
普通对象
- 普通对象跨线程时通过拷贝形式传递
- 两个线程的对象内容一致,但是指向各自线程的隔离内存区间。例如Ecmascript262规范定义的Object、Array、Map等对象是通过这种方式实现跨并发实例通信的
ArrayBuffer对象
- ArrayBuffer内部包含一块Native内存。
- 其JS对象壳与普通对象一样,需要经过序列化与反序列化拷贝传递
- 但是Native内存有两种传输方式:拷贝和转移。
传输时采用拷贝的话,需要经过深拷贝(递归遍历),传输后两个线程都可以独立访问ArrayBuffer
如果采用转移的方式,则原线程无法使用此ArrayBuffer对象,跨线程时只需重建JS壳,Native内存无需拷贝,效率更高
在ArkTS中,TaskPool传递ArrayBuffer数据时,默认使用转移的方式,通过调用setTransferList()接口,指定对应的部分数据传递方式为转移方式,其余部分数据可以切换成拷贝的方式
SharedArrayBuffer对象
- SharedArrayBuffer内部包含一块Native内存,支持跨并发实例间共享
- 但是访问及修改需要采用Atomics类,防止数据竞争。
- SharedArrayBuffer可以用于多个并发实例间的状态共享或者数据共享
Transferable对象(NativeBinding对象)
- Transferable对象(也称为NativeBinding对象)指的是一个JS对象,绑定了一个C++对象,且主体功能由C++提供。
- 跨线程传输时可以直接复用同一个C++对象,相比于JS对象的拷贝模式,传输效率较高。
- 可共享或转移的NativeBinding对象也被称为Transferable对象
共享模式
- 如果C++实现能够保证线程安全性,则这个NativeBinding对象的C++部分可以支持共享传输。
- 此时,NativeBinding对象跨线程传输后,只需要重新创建JS壳,就可以桥接到相同的C++对象上
转移模式
- 如果C++实现包含了数据,且无法保证线程安全性,则这个NativeBinding对象的C++部分需要采用转移方式传输。
- 此时,NativeBinding对象跨线程传输后,只需要重新创建JS壳,就可以桥接到C++对象上,不过原对象需要移除对此对象的绑定关系
Sendable对象
ArkTS提供了Sendable对象类型,在并发通信时支持通过引用传递
- Sendable对象为可共享的,其跨线程前后指向同一个JS对象
- 如果其包含了JS或者Native内容,均可以直接共享
- 如果底层是Native实现的,则需要考虑线程安全性