前端并发请求数量控制

204 阅读4分钟

这是一道常见面试题,类似下图:

image.png

我面试被问到过不止一次,举个例子:有 3 个异步任务 a,b 和 c,a 花费 1.5s 完成,b 花费 0.5s 完成,c 花费 1s 完成,但最多只能 2 个任务同时执行,现在想让你尽快的执行完 3 个任务并且获取到结果。

如果没有「2 个任务同时执行」的限制,那么直接 3 个任务一起,调用 Promise.all 即可,但是现在不行,那这样,我先 Promise.all([a,b]),结束之后再Promise.all([c]),这不就保证了「2 个任务同时执行」,但还是不行,因为没有保证「尽快的执行完」,你这样执行需要花费 1.5+1=2.5 秒的时间,就是你把 b 和 c 换位置,那也需要 1.5+0.5=2 秒的时间,如果真的尽快的执行完,那么只需要 1.5 秒的时间。所以如何解决?

重点在于 2 个任务 a,b 在同时执行中,其中 a 先执行完了,那么就应该马上执行后面排队的任务 c,而不是要等 a 和 b 都执行完了,才去执行 c。大圣有对此问题的解法,我们来看看:

首先写个异步函数 sleep,他的功能是延迟一定时间之后 resolve,并输出开始和结束的时间

const now = Date.now()

async function sleep(n, name) {
    return new Promise((resolve, reject) => {
        console.log(n, name, 'start', Date.now() - now)
        setTimeout(() => {
            console.log(n, name, 'end', Date.now() - now)
            resolve({ n, name })
        }, n * 500);
    })
}

再写个调用函数 start,功能是调用并发控制函数 asyncPool,并传入异步函数数组:

async function start() {
    await asyncPool({
        limit: 2,
        items: [
            () => sleep(3, '3'),
            () => sleep(1, '1'),
            () => sleep(2, '2'),
        ]
    })
}

start()

最后看看asyncPool的实现:

async function asyncPool({ limit, items }) {
    // 保存所有的异步函数
    const promises = []
    // 异步函数执行池,如limit为2时,这里只有2个同时执行的异步函数
    // 使用Set的原因:可以自动去除重复的异步函数
    const pool = new Set()
    for (const item of items) {
        // 生成promise函数,要使用async包装下的原因是:可能item不是异步函数
        const fn = async item => await item()
        // 调用异步函数item并拿到引用
        const promise = fn(item)
        // 放到异步函数列表中
        promises.push(promise)
        // 放到异步函数执行池中
        // 问题:这里直接放入异步函数执行池中,是不是有可能超出limit限制个数?
        // 答:不会,因为下面有 await Promise.race 这会阻止 pool.add 的连续执行
        pool.add(promise)
        // 定义执行池的清除函数
        const clean = () => pool.delete(promise)
        // 不管成功还是失败,都执行清除,给下一个异步函数让位置
        promise.then(clean).catch(clean)
        if (pool.size >= limit) {
            // 当执行池的个数大于等于限制个数时,就要等待一下,即把执行池中的函数进行比赛
            // 先执行完的就让出位置,给后面的异步函数
            await Promise.race(pool)
        }
    }
    // asyncPool要求返回值是promise
    return Promise.all(promises)
}

我认为这里巧妙的有 2 点

  1. 使用 for 和 await 结合,等待执行
  2. 对已经结束或执行中的异步函数使用 Promise.race 和 Promise.all 获取状态和结果

第二点你可能有疑问,我举个例子:执行到 return Promise.all(promises) 这一行时,promises 中的大部分异步函数都已经执行完了,但他还是能拿到执行完后的结果,看个示例:

(async function main() {
    const s1 = sleep(1, 'ha')
    const s2 = sleep(2, 'xi')
    let ans = await Promise.all([
        s1,
        s2
    ])
    console.log('ans', ans)
    ans = await Promise.all([
        s1,
        s2
    ])
    console.log('ans', ans)
})()

输出

1 'ha' 'start' 1
2 'xi' 'start' 3
1 'ha' 'end' 505
2 'xi' 'end' 1006
ans [ { n: 1, name: 'ha' }, { n: 2, name: 'xi' } ]
ans [ { n: 1, name: 'ha' }, { n: 2, name: 'xi' } ]

对于第一个 Promise.all 他会等待 s1 和 s2 执行完成后得到结果,但是对于第二个 Promise.all 他就不会执行了,因为 Promise 已经从 pending 到 fufilled 了,所以就直接返回答案。Promise.race 也是一样,你可能在这些异步任务完成期间多次调用了 Promise.race,但他们的结果都是一样的,并且也不会多次执行。

好啦,这个题目算是完成了,但还可以扩展下:有个库可以直接解决此类,即 p-limit 他的源码很少,几十行,中心思想和我们类似,都是在异步任务执行池中有一个结束时,就开始执行异步任务队列的其他问题。