js多线程优化及线程池管理

391 阅读6分钟

1. 问题场景

在做ui编辑工具中,项目编译流程的子项有个字体转换功能,使用lvgl-fontconvert脚本实现;现在的问题是字体转换太多太慢,单个复杂字体就可能要30多秒来转换,而且可能有很多个字体需要转换,而且这个转换过程是在js主线程直接计算的,会导致loading之类的ui,已经其他界面响应完全停住。

基于这个原因,所以多线程优化已经是不得不为了。

2. 工作环境

项目是基于 vuecli5、vue3的,但是除了worker的引入有差异外,整个逻辑在哪都是共通的

3. 目录结构

大概如下图,只需要关注worker及其引入的脚本,其他不用关心

image.png

4. coding

  • public/linkjs/fontconvert.js 是用于实际计算的脚本,这里也不用关心里面做了什么操作
// 把函数挂载在全局,线程环境中没有window,同时需要保证在主线程环境也能正常跑
// eslint-disable-next-line no-undef
const that = self || globalThis; 
if (!that.JLTool) that.JLTool = {};
that.JLTool.fontconvert = function (files = [], options) {
  // console.log('start',arguments)
  // 一些计算代码
  // 返回的数据格式自己把握,在提取结果的时候用上
  return files.map(f=>f.name).join('_');
};
  • public/worker/fontconvert.worker.js 用于引入计算脚本,并且用于接收/回复主线程消息的线程工作文件

/**当你创建一个worker时,它实际上被执行了两次。
 * 第一遍是在全局“窗口”对象的上下文中(意味着您可以访问所有窗口对象函数)。
 * 第二次调用是在具有不同全局对象的工作线程上下文中,其中存在“importScripts”。
 *  
 * */
if (typeof importScripts === 'function') {
  // 加载处理脚本
  importScripts('../linkjs/fontconvert.js')

  onmessage = e => {
    /** {files,option} */
    const req = e.data
    
    // 统一正常输出、异常结果的返回值,便于前端数据处理
    const result = { data: null, code: 0, error: null }

    if (JLTool) {
      try {
        const data = JLTool?.fontconvert(req.files, req.option)
        result.data = data
      } catch (error) {
        result.code = -1
        result.error = error.message ? error.message : error
      }
    } else {
      result.code = -1
      result.error = '字体转换工具加载失败'
    }
    postMessage(result)
  }
}
  • src/utils/fontConvert.js 这里是工程里面对多线程字体转换的封装,导出一个可调用异步函数


/**
 * @description: 这是在<script>标签直接引入字体转换脚本,主线程直接调用的方法,
 * 用于演示未使用多线程前是如何使用的
 */
export function fontConvert(files, output, options) {
  const option = Object.assign({
    output,
    size: 20,
    bpp: 1,
    no_compress: true,
    lcd: false,
    lcd_v: false,
    use_color_info: false,
    format: 'lvgl',
  }, options)
  console.log(option)
  return window.JLTool?.fontconvert(files, option)
}


// 以下为线程池相关


/**  这是一个jsdoc类型声明,对工作代码无影响,只是用于代码编辑类型提示
 * @typedef {Object} TaskResult
 * @property {number} state - 任务状态:1 成功, 0 未执行, -1 失败
 * @property {object} data - 执行成功的数据
 * @property {Error| Map| string} error - 错误数据 根据实际可能返回的值来写类型
 */

/** 多线程字体转换任务池类,写成类是为了便于管理线程池 */
class FontConvertTaskPool {
  _taskList = [] //待办任务列表
  _list = [] //原始任务列表
  _errorThrow = false
  _lock = false //锁定状态不可添加任务
  _cb = null //用于批量任务的完成resolve
  _hasError = false // 任务results中存在异常
  /**@type {TaskResult[]} */ _results = []

  constructor(poolNum = 10) {
    const workerList = []
    //构建worker
    for (let i = 0; i < poolNum; i++) {
    // worker引入,因为放在Public目录下,所以直接按打包后路径引入就行,不用loader那些插件
      const worker = new Worker('worker/fontconvert.worker.js')
      const obj = { worker, curData: null }


       // 把消息发送接收,封装成promise,便于任务循环及线程复用
      let res, rej

      //接收到消息应该结束当前任务,执行下一个任务
      worker.addEventListener('message', (e) => {
        console.log('fontWorker:', obj.curData, e)
        if (res) {
          /**{ code:number, data:null|Object, error:string|null} */
          const result = e.data

          if (result && result.code === 0) {
            res(result.data)
          } else {
            rej(result.error instanceof Map ? result.error : new Error(result.error || '无返回数据'))
          }
        }
      })
      worker.addEventListener('error', e => {
        console.error('fontWorkerError:', obj.curData, e)
        if (rej) rej(e.message)
      })


// 这里是每个任务的触发函数,会在里面更新res/rej和给worker发消息
      obj.workerAsync = function (fontData) {
        return new Promise((resolve, reject) => {
          res = resolve
          rej = reject

          obj.curData = fontData
          worker.postMessage(fontData)
        })
      }

      workerList[i] = obj
    }

    this.workerList = workerList
  }

  /**
   * @description: 线程池中执行批量任务
   *  - 根据 errorThrow 不同逻辑
   *  - true : 异常以reject抛出错误消息,正常值为 {state:1 , error:null, data:Object}[]
   *  - false: 异常的项 state为 -1, 0 为未执行任务, 1为正常执行
   * @param { Array<{files:Array<object>, option:object}>} list 待转换字体数据列表 
   * @param {boolean} errorThrow 存在异常时是否终止
   */
  async runTaskList(list = [], errorThrow) {
    if (this._lock) throw new Error('当前任务池已在执行中')
    this.reset()
    this._lock = true
    this._list = list
    this._errorThrow = errorThrow

    try {
      const result = await this._runTaskList(list)

      this._lock = false
      this.reset()
      return result
    } catch (error) {
      this._lock = false
      this.reset()
      throw error
    }
  }
  /** @type {(list:Array)=>Promise<TaskResult[]>} */
  _runTaskList(list = []) {
    return new Promise((resolve, reject) => {
       // 整个线程池任务结束的回调,包括抛异常或者正常结束情况
      this._cb = () => {
        console.warn('_runTaskList', this._hasError, this._errorThrow, this._results)
        if (this._hasError && this._errorThrow) {
          const error = this._results.find(r => r.state === -1)?.error
          reject(error)
        }
        // else if (this._errorThrow) resolve(true)
        else {
          resolve(this._results)
        }
      }
      if (list.length < 1) {
        resolve(this._results)
      }
      for (let i = 0; i < list.length; i++) {
        const task = list[i]
        
        this._addTask(task, i)
      }
    })
  }

  /** 把字体数据转为并发任务 */
  _addTask(fontData, inx) {

    /** 任务执行状态, 0 待执行,1 执行成功,-1 执行失败 */
    const result = { state: 0, error: null, data: null }
    this._results[inx] = result
    const task = {
      result,
      func: async (workerAsync) => {
        return await workerAsync(fontData)
      }
    }

    this._taskList.push(task)

    return this._runTask()
  }
  async _runTask() {
    // 有错误就结束任务的情况,否则只有代办任务列表清空才停;
    if ((this._errorThrow && this._hasError)) {
      if (!this.workerList.find(p => p.curData)) {
        this._cb()
      }
      return
    }
    //空闲线程
    const w = this.workerList.find(p => !p.curData)
    if (w) {
      const task = this._taskList.shift()
      if (task) {
        const result = task.result
        try {
          const prom = task.func(w.workerAsync)

          const r = await prom
          result.state = 1
          result.data = r
        } catch (err) {
          result.state = -1
          result.error = err
          this._hasError = true
        } finally {
          w.curData = null

          this._runTask()
        }
      } else {
        if (!this.workerList.find(p => p.curData)) {
          this._cb()
        }
      }
    } else {
      return
    }
  }

   // 重置任务池状态,如果需要复用任务池,才有必要,不然一次性的用完直接destroy销毁所有线程;
  reset() {
    if (this._lock) return false

    this._taskList = []
    this._cb = null
    this._hasError = false
    this._results = []
    this._list = []
    return true
  }

  destroy() {
    //销毁线程
    this.workerList.forEach(w => w.worker.terminate())
  }
}




/**
 * @description: 多线程字体格式转换,这里是直接导出的封装完善线程池函数
 *  - 根据 errorThrow 不同逻辑
 *  - true : 异常以reject抛出错误消息,正常值为 {state:1 , error:null, data:Object}[]
 *  - false: 异常的项 state为 -1, 0 为未执行任务, 1为正常执行
 * @param {Array<{files:object[], output:string, options:object}>} dataList 数据列表
 * @param workerNum 线程数量 
 * @param {boolean} errorThrow 存在异常时是否终止
 */
export async function fontConvertWorker(dataList = [], workerNum = 10, errorThrow = false) {
  const convert = new FontConvertTaskPool(workerNum)

  try {
    //数据格式转换
    const tarList = dataList.map(({ files, output, options }) => {
      const option = Object.assign({ output }, options)
      return { files, option }
    })

    const resultList = await convert.runTaskList(tarList, errorThrow)

    //结果处理
    // console.log('resultList', resultList)


    return resultList
  } finally {
    // console.warn('fontConvertWorkerFinally')
    convert.destroy()
  }
}


5.总结

  • 线程池管理部分代码是比较复杂,我也是从自己的任务池管理中搞过来改的;
  • 根据反馈来说,这个多线程优化之后不管单个大字体文件的转换还是批量字体的转换,用这个多线程跑都是有大幅性能改善的;多文件的批量转换不用多说,单文件的转换也得益于webworker系统级的线程从35s优化到28s的转换时间。
  • 最后也是最重要的用户交互方面,通过子线程来计算,所以用户交互方面就不会受到影响,在编译过程中用户点击、loading旋转都不会卡住。