控制請求併發數量:p-limit 原始碼解讀

xingba-coder發表於2024-09-22

p-limit 是一個控制請求併發數量的庫,他的整體程式碼不多,思路挺好的,很有學習價值;

舉例

當我們同時發起多個請求時,一般是這樣做的

Promise.all([
    requestFn1,
    requestFn2,
    requestFn3
]).then(res =>{})

或者

requestFn1()
requestFn2()
requestFn3()

而使用 p-limit 限制併發請求數量是這樣做的:

var limit = pLimit(8); // 設定最大併發數量為 8

var input = [ // Limit函式包裝各個請求
    limit(() => fetchSomething('1')),
    limit(() => fetchSomething('2')),
    limit(() => fetchSomething('3')),
    limit(() => fetchSomething('4')),
    limit(() => fetchSomething('5')),
    limit(() => fetchSomething('6')),
    limit(() => fetchSomething('7')),
    limit(() => fetchSomething('8')),
];

// 執行請求
Promise.all(input).then(res =>{
    console.log(res)
})

上面 input 陣列包含了 8limit 函式,每個 limit 函式包含了要發起的請求

當設定最大併發數量為 8 時,上面 8 個請求會同時執行

來看下效果,假設每個請求執行時間為1s

var fetchSomething = (str) => {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            console.log(str)
            resolve(str)
        }, 1000)
    })
}

當設定併發請求數量為 2

image

當設定併發請求數量為 3

image

p-limit 限制併發請求數量本質上是,在內部維護了一個請求佇列;

當請求發起時,先將請求推入佇列,判斷當前執行的請求數量是否小於配置的請求併發數量,如果是則執行當前請求,否則等待正在發起的請求中誰請求完了,再從佇列首部取出一個執行;

原始碼(v2.3.0)

pLimit 原始碼如下(這個原始碼是 v2.3.0 版本的,因為專案中引入的版本比較早。後面會分析從 2.3.0 到最新版本的原始碼,看看增加或者改進了什麼):

'use strict';
const pTry = require('p-try');

const pLimit = concurrency => {
    // 限制為正整數
    if (!((Number.isInteger(concurrency) || concurrency === Infinity) && concurrency > 0)) {
        return Promise.reject(new TypeError('Expected `concurrency` to be a number from 1 and up'));
    }

    const queue = []; // 請求佇列
    let activeCount = 0; // 當前併發的數量

    const next = () => { // 一個請求完成時執行的回撥
        activeCount--;

        if (queue.length > 0) {
            queue.shift()();
        }
    };

    const run = (fn, resolve, ...args) => { // 請求開始執行
        activeCount++;

        const result = pTry(fn, ...args);

        resolve(result); // 將結果傳遞給 generator

        result.then(next, next); // 請求執行完呼叫回撥
    };

    // 將請求加入佇列
    const enqueue = (fn, resolve, ...args) => {
        if (activeCount < concurrency) {
            run(fn, resolve, ...args);
        } else {
            queue.push(run.bind(null, fn, resolve, ...args));
        }
    };

    const generator = (fn, ...args) => new Promise(resolve => enqueue(fn, resolve, ...args));
    
    // 暴露內部屬性給外界
    Object.defineProperties(generator, {
        activeCount: {
            get: () => activeCount
        },
        pendingCount: {
            get: () => queue.length
        },
        clearQueue: {
            value: () => {
                queue.length = 0;
            }
        }
    });

    return generator;
};

module.exports = pLimit;
module.exports.default = pLimit;

下面一一剖析下

1、pLimit 函式整體是一個閉包函式,返回了一個名叫 generator 的函式,由 generator 處理併發邏輯,
generator 返回值必須是 promise,這樣才能被 Promise.all 捕獲到

const generator = (fn,...args) => new Promise((resolve,reject)=7enqueue(fn,resolve,...args))

2、在 enqueue 函式里面

// 將請求加入佇列
const enqueue = (fn, resolve, ...args) => {
    if (activeCount < concurrency) {
        run(fn, resolve, ...args);
    } else {
        queue.push(run.bind(null, fn, resolve, ...args));
    }
};

activeCount 表示正在執行的請求數量,當 activeCount 小於配置的併發數量(concurrency)時,則可以執行當前的 fn(執行 run 函式),否則推入請求佇列等待。

3、run 函式接收了三個形參

const run = (fn, resolve, ...args) => { // 請求開始執行
    activeCount++;
    const result = pTry(fn, ...args);
    resolve(result);
    result.then(next, next);
};
  • fn 表示執行的請求,

  • resolvegenerator 定義並往下傳,一直跟蹤到請求執行完畢後,呼叫 resolve(result); 代表 generator 函式 fulfilled

  • ···args 表示其餘的引數,最終會作為 fn 的引數。

4、執行 run 函式時

const run = (fn, resolve, ...args) => { // 請求開始執行
    activeCount++; // 請求開始執行,當前請求數量 +1

    const result = pTry(fn, ...args);

    resolve(result);

    result.then(next, next);
};

這裡執行 fn 使用的是 const result = pTry(fn,...args)pTry 的作用就是建立一個 promise 包裹的結果,不論 fn 是同步函式還是非同步函式

// pTry 原始碼
const pTry = (fn,...args) => new Promise((resolve,reject) => resolve(fn(...args)));

現在 fn 執行(fn(...args))完畢並兌現(resolve(fn(...args)))之後,result 就會兌現。

result 兌現後,generatorpromise 也就兌現了( resolve(result) ),那麼當前請求 fn 的流程就執行完了。

5、當前請求執行完後,對應的當前正在請求的數量也要減一,activeCount--

const next = () => { // 一個請求完成時執行的回撥
    activeCount--;

    if (queue.length > 0) {
        queue.shift()();
    }
};

然後繼續從佇列頭部取出請求來執行

6、最後暴露內部屬性給外界

Object.defineProperties(generator, {
    activeCount: { // 當前正在請求的數量
        get: () => activeCount
    },
    pendingCount: { // 等待執行的數量
        get: () => queue.length
    },
    clearQueue: {
        value: () => {
            queue.length = 0;
        }
    }
});

原始碼(v2.3.0)=> 原始碼(v6.1.0)

v2.3.0 到最新的 v6.1.0 版本中間加了一些改進

1、v3.0.0:始終非同步執行傳進 limit 的函式

image

3.0.0 中,作者將請求入隊放在前面,將 if 判斷語句和請求執行置於微任務中執行;正如原始碼註釋中解釋的:因為當 run 函式執行時,activeCount 是非同步更新的,那麼這裡的 if 判斷語句也應該非同步執行才能實時獲取到 activeCount 的值。

這樣一開始批次執行 limit(fn) 時,將會先把這些請求全部放入佇列中,然後再根據條件判斷是否執行請求;

2、v3.0.2:修復傳入的無效併發數引起的錯誤;

image

return Promise.reject 改為了直接 throw 一個錯誤

3、v3.1.0:移除 pTry 的依賴;改善效能;

image

移除了 pTry 依賴,改為了 async 包裹,上面有提到,pTry 是一個 promise 包裝函式,返回結果是一個 promise;兩者本質都是一樣;

增加了 yocto-queue 依賴,yocto-queue是一個佇列資料結構,用佇列代替陣列,效能更好;佇列的入隊和出隊操作時間複雜度是 O(1),而陣列的 shift()O(n);

4、v5.0.0:修復上下文傳播問題

image

引入了 AsyncResource

export const AsyncResource = {
    bind(fn, _type, thisArg) {
        return fn.bind(thisArg);
    }
}

這裡用 AsyncResource.bind() 包裹 run.bind(undefined, fn, resolve, args) ,其實不是太明白為啥加這一層。。。這裡用的到三個引數(fn,resolve,args)都是透過函式傳參過來的,和 this 沒關係吧,各位知道的可以告知下麼。

相關 issue這裡

5、6.0.0:效能最佳化,主要最佳化的地方在下面

image

移除了 AsyncResource.bind(),改為使用一個立即執行的 promise,並將 promiseresolve 方法插入佇列,一旦 resolve 完成兌現,呼叫相應請求;相關 issue這裡

6、v6.1.0:允許實時修改併發限制數

image

改變併發數後立馬再檢測是否可以執行請求;


最後

在上面第4點的,第5點中的最佳化沒太看明白,因為執行請求用的到三個引數(fn,resolve,args)都是透過函式傳參過來的,看起來 this 沒關係,為啥要進行多層 bind 繫結呢?各位知道的可以不吝賜教下麼。

相關文章