JavaScript 任務池

幾乎一米八的徐某某發表於2022-05-13

JavaScript 任務池

本文寫於 2022 年 5 月 13 日

執行緒池

在多執行緒語言中,我們通常不會隨意的在需要啟動執行緒的時候去啟動,而是會選擇建立一個執行緒池。

所謂執行緒池,本意其實就是(不止這些作用,其餘作用可以自行查閱):

  1. 節省作業系統資源
  2. 限制最大執行緒數。

對於 JavaScript 來說,雖然不存在“啟動執行緒”這種問題,但我們還是可以通過類似的思想,來限制我們做非同步操作的數量

分析

首先我們需要一個陣列,用它來儲存尚未執行的任務,每個任務都是一個函式,這個函式必須要返回一個 Promise。

type Task = () => Promise<unknown>;

const tasks: Task[] = [];

其次我們需要一個方法來進行任務的新增。

function addTask(task: Task): void;

最後我們需要一個函式來執行我們所有的 task。

而在這之前,我們還需要定義一個值,來定義同時執行的非同步任務的最大數量。

function execTasks(): void;

實現

根據我們的分析,我們可以寫下基礎的程式碼如下:

interface TaskPool {
  addTask(task: Task): void;
}

type Task = () => Promise<unknown>;

function newTaskPool(max = 10): TaskPool {
  const tasks: Task[] = [];

  function addTask(task: Task): void {}

  function execTasks(): void {}
}

新增任務非常簡單,我們寫出如下程式碼填充 addTask

function addTask(task: Task): void {
  tasks.push(task);
}

接下來就是重頭戲。如何實現 execTasks 方法來限制最大非同步任務數量呢?

首先我們來明確一點,在下面這個場景中,如果 foo 函式是非同步操作,那麼是不會阻塞我們的程式碼執行的。

console.log("Before");
foo();
console.log("After");

那麼我們可以這麼操作:

  1. 定義一個變數用來記錄當前的空閒任務數量;
  2. 執行 execTasks 時,會選取當前任務數量和空閒任務數二者相比較小的一個;
  3. 根據該值進行迴圈,每次迴圈彈出 tasks 第一位的任務進行執行;
  4. 執行前將空閒任務數 -1,執行完畢後空閒任務數 +1,並再次執行 execTasks
let leisure = max;

function execTasks(): void {
  if (tasks.length === 0) return;

  const execTaskNum = Math.min(tasks.length, leisure);
  for (let i = 0; i < execTaskNum; i++) {
    const task = tasks.shift();
    if (!task) continue;

    leisure--;
    task().finally(() => {
      leisure++;
      execTasks();
    });
  }
}

最後我們只剩下了一個問題了,我們如何在 addTask 後執行 execTasks,但又不會讓下面這種情況導致頻繁執行 execTasks

for (let i = 0; i < 100; i++) addTask();

可以利用防抖 + setTimeout(() => {},0) 的特性來完成。

function addTask(task: Task) {
  tasks.push(task);
  execTasksAfterAdd();
}

// 這裡借用了 lodash 的 debounce 函式,具體實現不多說,可以看我以前的文章:防抖與節流
const execTasksAfterAdd = debounce(execTasks);

完整程式碼:

import { debounce } from "lodash";

interface TaskQueue {
  addTask: (task: () => Promise<any>) => void;
}

function newTaskQueue(maxTaskNum = 10): TaskQueue {
  let _leisure = maxTaskNum;

  const _tasks: Array<() => Promise<any>> = [];

  function addTask(task: () => Promise<any>) {
    _tasks.push(task);
    execAfterTask();
  }

  const execAfterTask = debounce(execTasks);

  function execTasks() {
    if (_tasks.length === 0) return;

    const execTaskNum = Math.min(_tasks.length, _leisure);
    for (let i = 0; i < execTaskNum; i++) {
      const task = _tasks.shift();
      if (!task) continue;

      _leisure--;
      task().finally(() => {
        _leisure++;
        execTasks();
      });
    }
  }

  return { addTask };
}

const queue = newTaskQueue(5);

for (let i = 0; i < 10; i++) {
  queue.addTask(function () {
    return new Promise<void>((resolve) => {
      setTimeout(() => resolve(), 800);
    });
  });
}

使用場景

其實這種做法的使用場景是比較少的。

絕大多數情況我們都不需要這麼去做,除非碰到很極端的需求。

例如我們需要用 Node.js 去設計一個吞吐量極大的服務,那麼同時發生大量的網路請求很可能把頻寬直接打滿,導致後續的請求無法打到該服務,此時就可以使用任務池來控制最大網路請求量。

(完)

相關文章