关于鸿蒙中的异步(Promise)和线程(taskPool,worker)

689 阅读5分钟

    学习鸿蒙过程中,对于鸿蒙的线程和异步可能会存在很多疑惑,什么是异步,什么时候用异步。或者什么时候用线程?

    本文重点讲下三种方法的区别和使用场景,不注重具体如何使用代码,这里一笔带过。

    鸿蒙对于异步提供了三种处理方式(注意这里是异步,不是特指线程):

  1. JS原生的Promise异步
  2. taskPool线程
  3. worker常驻线程

Promise异步

    对于我们做 Android开发,经常提到异步,就习惯性的认为是起个子线程来执行。但是当我们做鸿蒙开发时候,要注意Promise异步和线程两个概念不同。

  1. Promise异步仍然是在当前线程中运行任务,结果以异步方式返回(接口回调方式)
  2. 当我们对某个方法定义为Promise异步执行,那么当前线程就会在空闲时候执行当前方法,结果以异步方式返回
使用方法
  • 方法1
  testFunc(suc: boolean): Promise<string> {
    return new Promise((resolve, reject) => {
      if (suc) {
        resolve("success")
      } else {
        reject("fail")
      }
    })
  }
  • 方法2
  async testFunc(suc: boolean): Promise<string> {
    if (suc) {
      return "success"
    }
    throw new Error("fail")
  }

 两种方法各有好处,看各自喜欢和习惯以及实际场景。

调用
  • 场景1 - 异步执行方法获取结果
testFunc1(true).then((result: string) => {
      // resolve返回的结果在这里输出
    }).catch((e: Error) => {
      // reject 抛出的异常在这里触发
    })
  • 场景2 - 同步等待方法执行完成
async call() {
    try {
      const suc = await this.testFunc(true)
    } catch (e) {
      // reject error
    }
  }

    场景2使用了await修饰,改修饰符要求方法必须使用async修饰,且异常通过try...catch来捕获。

Promise使用场景
  1. 模块或者接口通过异步封装
  2. 单次IO操作(网络或小文件读写)
  3. 耗时操作(非长时间耗时,必须保证CPU可以被释放)

taskPool

    对于一些耗时操作,使用Promise占用了主线程资源,可能会导致ANR,所以出现了taskPool来保证可以起子线程处理耗时操作。

    taskPool存活最长时间为3分钟,运行超过3分钟,那么会被系统杀掉。

    如果在taskPool里进行了网络请求或者文件读写,那么网络请求和文件读写的耗时是不计算在3分钟里的。

    由于鸿蒙线程使用了Actor模型,导致taskPool新起的子线程和主线程的内存是不共享的。所以无法在子线程直接操作主线程的内存。

    一定程度上解决了多线程问题,但是对于多线程操作也带来一定的影响。

    注意这里是一定程度上解决多线程问题,实质上多线程问题还是会存在。比如主线程和子线程同时对一个文件进行读写操作,会存在多线程问题。

    由于内存隔离机制,所以子线程在启动时候,可以接收来自主线程的数据只能是二进制流或者基本数据类型。同理 子线程运行完毕后,可以回传数据给主线程,也只能是二进制流或者基本数据类型。推荐使用JSON字符串。

使用方法
class TestA {
  execute() {
    taskpool.execute(calc, "123sss").then((result) => {
      const target = result as number
    })
  }
}

@Concurrent
function calc(value: string): number {
  return 123
}

    注意对于taskPool调用的函数,必须声明为全局函数,且使用 装饰器 @Concurrent 来修饰。

    如果条件不满足,那么运行时候,系统会打印错误日志进行提示。

E     [ecmascript] Function is not concurrent
E     [(worker.cpp:414)(InitTaskPoolFunc)] taskpool:: InitTaskPoolFunc fail

Worker

    我们经常会遇到,存在一个特别耗时的操作,或者可能存在业务需要一直死循环运行。那么这个时候就涉及到常驻线程了。

    通过worker可以启动一个线程,该线程不存在时长限制,可以一直存在,所以我们可以在里边做一些耗时操作。

    但是有几个特点:

  1. worker线程最多可以在APP里同时存在8个一起运行,超出数量的不会运行。但是创建可以创建多个,所以当worker不需要的时候要及时停止运行
  2. worker对内存有影响,每个空worker起来后就会占用2MB左右内存
  3. worker里可以继续启动taskPool执行逻辑
如何使用

    worker 比较特殊,需要在当前module的build-profile.json5配置文件中配置一下worker定义的文件的路径。

image.png

    该wSocketWorker文件中,会通过系统 workerPort.onmessage 来接收来自主线程或其他线程的信息,接收到信息后进行处理。

image (1).png

    workerPort通过如下方式声明。

let workerPort: ThreadWorkerGlobalScope = worker.workerPort;

    该SocketWorker是我们自定义的一个文件,可以看做是我们当前定义的Worker和其他线程交互的一个接口,也是当前Worker线程的入口。

    注意这里使用workerPort要在全局环境中使用,不能放到类中。

如何在主线程和Worker交互
  test() {
    let send = new worker.ThreadWorker("./SocketWorker.ets");
    send.onexit = (code) => {
      // 运行结束时候,接收来自worker的信息
    }
    send.postMessage(new SocketParam(1, 2, "s", 2, 3))
  }

    我们在主线程需要启动worker和worker交互的地方,通过上述代码即可启动我们定义好的worker。同时在onexit监听worker结束时候的回调。

对比Promise,taskPool和worker

  1. 对于单次IO,非阻塞性任务,可以使用Promise
  2. 对于UI刷新频繁的场景,不推荐使用Promise执行一些耗时逻辑,可能会造成UI刷新卡顿或者Promise逻辑无法得到执行
  3. 对于IO密集型任务,非常驻任务,建议使用taskPool执行。控制好时间,不要超过3分钟
  4. 对于常驻任务,使用worker
  5. 对于耗时,阻塞任务使用worker
  6. 使用Promise时候不需要考虑多线程问题,使用worker和taskPool时候,如果对文件进行写操作,注意多线程问题。