ABSTRACT
众所周知,JS是单线程的,ECMAScript规范没有提供并发语义表述;业界引擎,如浏览器或者Node.js,通常会提供基于Actor并发模型的Worker API来支持多线程开发。而Actor模型下执行体(actor)之间不共享任何数据对象,通过消息机制进行通信,因此Web引擎或者Node.js引擎的Worker都有启动速度慢、内存占用高这些缺陷。本文将会解析华为是如何在不鼓励并发编程的语言上优化多线程开发体验的。
线程与进程
我们都知道进程是资源分配的最小单位,线程是cpu调度的最小单位。那么为什么操作系统要设计线程和进程呢?
(1)需要多任务同时进行,以多进程形式允许多个任务同时运行;
(2)利用多核cpu加快单任务运行效率,以多线程形式允许单个任务分成不同的部分运行;
但回到JavaScript中,JS生来就是为单线程设计的,不允许跨线程共享内存,因为本身作为一种网页脚本语言来说,这太复杂了。好处是上手容易,不会出现堵塞,不会出现竞态条件,但是也会带来两个问题。
- 单线程带来的第一个问题是同步阻塞。
EventLoop就是为了解决这个问题而提出的,它是一个程序结构,用于等待和发送消息和事件。如果没有eventLoop,I/O操作到来时程序需要等待I/O的结束才能继续下去,这种模式叫做同步模式/阻塞模式。而在EventLoop中,I/O操作会被放入宏任务队列,等待下次EventLoop查验I/O操作是否完成,然后决定是否将该任务拉回主线程,这种模式叫做异步模式。
除此之外,JS中提供了Promise来解决异步操作,异步函数将被放进微任务队列,在本次宏任务执行结束后清空微任务队列。异步模式提供了一个"future"对象(即res/rej)来表示异步操作的结果,方便我们更清晰的编写异步代码,这种思想在后面的Actor模型中也会体现到。
- 单线程带来的第二个问题是对于CPU密集型计算有些束手无策。
但随着js的流行和前端程序的复杂化,多线程开发成为必须,Worker API出现了。下一小节会讲。
小总结:如何基于单线程模型实现异步和并发,JS给出的答案是:
- 引入EventLoop,循环监听队列中的宏任务与微任务。
- 引入 Wroker API,既然不能共享内存,那么就不使用共享内存模型(消费者生产者模式)而是通过序列化传输实现跨线程的通讯。
Stage模型的进程与线程模型
进程模型 (略
上一节讲到,进程是系统资源分配的最小单位,这意味着一个进程通常是代表一个独立的运行系统,鸿蒙应用中进程分为三类:
- 所有的UIAbility在一个进程中
- 每一类ExtensionAbility都有自己的进程,除了Service和datashare。
- WebView拥有独立的渲染进程
对与开发者来说,需要手动管理的进程主要有两个 主进程和卡片进程。
进程间通信通过自定义公共事件然后发布订阅来实现。
本文主要讲多线程,这边不多说了。
线程模型
下面以所有UIAbility所在的主进程讲述
每个进程都会有且仅有一个主线程,即UI线程,和若干个worker线程。
UI线程主要负责所有UIAbility组件的运行以及其他worker线程的管理。
ArkTS中如何实现多线程并发?
Worker
在entry模块下创建worker工作线程。
javascript
import worker from '@ohos.worker';
let parent = worker.workerPort;
// 处理来自主线程的消息
parent.onmessage = (message) => {
console.info("onmessage: " + message)
// 发送消息到主线程
parent.postMessage("message from worker thread.")
}
--------------------------------------------------
"buildOption": {
"sourceOption": {
"workers": [
"./src/main/ets/workers/worker.ts"
]
}
}
在主线程中引入创建好的worker文件,通过postMessage和onmessage的方式来发/收消息。
在合适的时间引入,在合适的时间注销,自己手动管理。
javascript
import worker from '@ohos.worker';
let wk = new worker.ThreadWorker("entry/ets/workers/worker.ts");
// 发送消息到worker线程
wk.postMessage("message from main thread.")
// 处理来自worker线程的消息
wk.onmessage = (message) => {
console.info("message from worker: " + message)
// 根据业务按需停止worker线程
wk.terminate();
}
TaskPool
通过TaskPool实例管理任务和线程,收到任务后会进行排序和汇总到任务队列中,然后通过任务分发器(Runner)给空闲线程分发任务,最后将执行结果返回给异步函数 taskpool.execute(someTask)
。
小总结:Worker是业界常见的多线程开发API,HarmonyOS同样也照搬了过来(不支持eTS),需要开发中手动管理子线程,仅Ability类型的模块支持;
而TaskPool是华为推的Actor在JS上的最佳实践,上手简单,无需开发者手动管理任务池及其生命周期。
无论Worker还是TaskPool都是基于Actor模型,下一节将会介绍Actor为什么可行。
Actor模型的理论基础
Actor 模型是一种并行计算模型,其理论基础是基于消息传递并发的概念,被用来描述系统中并发计算的实体以及它们之间的通信方式。
我们可以以worker为实体分析该理论基础。
什么是Actor
Actor作为线程的管理者,是一个最基本的计算单元,它能够接受一个消息并且基于消息执行计算;不同于面向对象编程,它强调三件事,计算、存储和通信,而OOP强调对象的状态和行为。
Address地址
每个actor都有地址,如何确定你要发送的消息去哪里,通过地址。
地址并不是强绑定Actor,一个Actor可以有一个或多个地址,我们对地址所能做的就是向它发送消息。
Actor信箱
快速到达的消息可能会导致 actor 出现某种拒绝服务,从而使 actor 无法处理传入的消息流。 为了缓解这个问题,存在一个邮箱 actor 接收信使并持有这些信使,直到 actor 能够处理它们。 消息可能需要任意长的时间才能最终到达接收方的邮箱。 在 Actor 模型的物理实现中,邮箱中消息的入队和出队是原子操作,因此不可能出现争用情况。
局部性公理
操作性 - 响应收到的消息,Actor 只能:
- 产生有限数量的新 actor。
- 仅将消息发送到刚刚收到的消息中的地址或其本地存储中的地址。
- 为下一条消息更新其本地存储(指定如何处理下一条消息)
组织性- Actor 的本地存储包括的地址只能是:
- 在创建时提供的
- 在消息中收到的
- 适用于此处创建的 Actors(操作公理的第 1 段)
小总结:actor仅能在收到message时发生改变,当message快速到达时,actor会使用信箱来管理不同这些信息流。
TaskPool - Actor模型在JS上的最佳实践
根据这张图我们可以大致推出TaskPool的组成类:
-
- TaskPool 任务池管理类
- 线程运行管理器 - 线程池
- 任务队列类
- 单个任务类的抽象表达
我们由上到下依次分析一下各自的实现思路以及在TaskPool系统中的作用。
TaskPool
-
- 采用单例模式管理全局任务池 在第一次调用时创建实例,并在后续调用中返回该实例。
arduino
Taskpool *Taskpool::GetCurrentTaskpool()
{
static Taskpool *taskpool = new Taskpool();
return taskpool;
}
-
- 确定最适合的线程数 taskpool在初始化的时候就会创建好工作线程,其数量根据cpu核心数而定,取核心数和MAX_TASKPOOL_THREAD_NUM = 7 中小的作为工作线程池个数。
c
// 计算最适合的线程数
uint32_t Taskpool::TheMostSuitableThreadNum(uint32_t threadNum) const
{
if (threadNum > 0) {
return std::min<uint32_t>(threadNum, MAX_TASKPOOL_THREAD_NUM);
}
uint32_t numOfThreads = std::min<uint32_t>(NumberOfCpuCore() / 2, MAX_TASKPOOL_THREAD_NUM);
return std::max<uint32_t>(numOfThreads, MIN_TASKPOOL_THREAD_NUM);
}
-
- 支持初始化任务池,销毁任务池,终止特定任务。
Runner - 线程运行管理器
-
- 初始化线程池,创建了一定数量的线程,并启动它们以执行任务。
arduino
Runner::Runner(uint32_t threadNum) : totalThreadNum_(threadNum)
{
for (uint32_t i = 0; i < threadNum; i++) {
// main thread is 0;
std::unique_ptr<std::thread> thread = std::make_unique<std::thread>(&Runner::Run, this, i + 1);
threadPool_.emplace_back(std::move(thread));
}
for (uint32_t i = 0; i < runningTask_.size(); i++) {
runningTask_[i] = nullptr;
}
}
-
- 线程执行,从任务队列中取出任务在线程中执行。
arduino
void Runner::Run(uint32_t threadId)
{
os::thread::native_handle_type thread = os::thread::GetNativeHandle();
os::thread::SetThreadName(thread, "OS_GC_WorkerThread");
RecordThreadId();
while (std::unique_ptr<Task> task = taskQueue_.PopTask()) {
SetRunTask(threadId, task.get());
task->Run(threadId);
SetRunTask(threadId, nullptr);
}
}
TaskQueue
任务队列管理类,负责加任务取任务。
arduino
// 加任务,它会对队列进行加锁,并将任务放入队列尾部,然后通知等待中的线程有新任务可执行。
void TaskQueue::PostTask(std::unique_ptr<Task> task)
{
LockHolder holder(mtx_);
ASSERT(!terminate_);
tasks_.push_back(std::move(task));
cv_.Signal();
}
// 取任务,这个函数是一个循环,会在队列非空时取出队首的任务并返回。
// 如果队列为空但没有被终止,则会等待新任务到来。
// 如果队列被终止,会通知所有等待中的线程并返回空指针。
std::unique_ptr<Task> TaskQueue::PopTask()
{
LockHolder holder(mtx_);
while (true) {
if (!tasks_.empty()) {
std::unique_ptr<Task> task = std::move(tasks_.front());
tasks_.pop_front();
return task;
}
if (terminate_) {
cv_.SignalAll();
return nullptr;
}
cv_.Wait(&mtx_);
}
}
Task
任务队列基类,定义单个任务的id,类型以及任务执行函数(调用Runner类的Run方法)。
小总结:taskpool类会根据用户设备初始化不同的线程数,维护线程池(Runner)和任务池(TaskQueue),在没有终止时持续监听新任务的到来,分配到线程池中执行。
Taskpool用法
导入模块
javascript
import taskpool from '@ohos.taskpool';
定义Concurrent函数
使用@Concurrent装饰器装饰函数,根据序列化传入/传出模型,函数中用到的变量必须作为参数传入,返回值作为成功后的"future"值。
在Concurrent函数中不能引入外部变量,对象,对象静态属性,但是可以引入外部的静态方法。
是否能在静态方法中操作数据库11.28号验证
typescript
// 跨线程并发任务
@Concurrent
async function produce(num: number) {
// 添加生产相关逻辑
console.log("producing...", num)
return num + 1; // 异步回调默认返回,作为fulfill回调函数的参数
}
执行函数并根据"future"值做回调操作
在异步回调中处理返回值
小样例
实现一个并发加法任务,结合上述文档梳理整个并发流程。
typescript
import taskpool from '@ohos.taskpool';
// 跨线程并发任务
@Concurrent
async function produce(num: number) {
// 添加生产相关逻辑
console.log("producing...", num)
return num + 1; // 异步回调默认返回,作为fulfill回调函数的参数
}
@Entry
@Component
struct Index {
@State message: number = 0
build() {
Row() {
Column() {
Text(this.message + '')
.fontSize(50)
.fontWeight(FontWeight.Bold)
Button() {
Text("start")
}.onClick(() => {
let produceTask = new taskpool.Task(produce, this.message)
// 执行生产异步并发任务
taskpool.execute(produceTask).then((res: number) => {
this.message = res;
}).catch((e: Error) => {
console.error(e.message)
})
})
.width('20%')
.height('20%')
}
.width('100%')
}
.height('100%')
}
}
小总结:TaskPool使得我们进行多线程并发开发像Promise异步编程一样简单,开发者只需关注任务本事,而不用关注任务在线程池中的分配。更详细的用法可参考taskpool api
总结
本文从 JavaScript 单线程的特性出发,介绍了并发编程的挑战和解决方案,并深入探讨了 Actor 模型在 JavaScript 中的应用,尤其是华为提供的 TaskPool,虽然就性能来说他并不比worker更高效,但是极大的简化了我们的多线程开发过程,在项目实战中展现了其优越性和实际应用场景,希望对大家后续的多线程开发有所帮助。