JavaScript 任務池
本文寫於 2022 年 5 月 13 日
執行緒池
在多執行緒語言中,我們通常不會隨意的在需要啟動執行緒的時候去啟動,而是會選擇建立一個執行緒池。
所謂執行緒池,本意其實就是(不止這些作用,其餘作用可以自行查閱):
- 節省作業系統資源
- 限制最大執行緒數。
對於 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");
那麼我們可以這麼操作:
- 定義一個變數用來記錄當前的空閒任務數量;
- 執行
execTasks
時,會選取當前任務數量和空閒任務數二者相比較小的一個; - 根據該值進行迴圈,每次迴圈彈出
tasks
第一位的任務進行執行; - 執行前將空閒任務數 -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 去設計一個吞吐量極大的服務,那麼同時發生大量的網路請求很可能把頻寬直接打滿,導致後續的請求無法打到該服務,此時就可以使用任務池來控制最大網路請求量。
(完)