在目前的业务开发中,大规模的异步任务处理(任务数量多,任务流程复杂)不是很常见,通常就是将请求套一个 Promise.all
,比如这样:
const request = async () => {
const requestList = [];
requestList.push(requestA());
requestList.push(requestB());
if (someLimit) {
requestList.push(requestC());
}
return await Promise.all(requestList);
};
为了这个话题进行下去,我们需要来编造一些场景,比如说爬虫 🕷:
现在我们手上有一批 id,通过网络请求要拿到这批 id 对应的资源,这就涉及到了循环与异步结合。
模拟这个场景,我们有这样一些准备工作:
// sleep 函数,模拟异步任务
const sleep = (n) => new Promise((res) => setTimeout(res, n));
// 待执行的任务
const taskList = [100, 200, 300, 400, 500];
假设这些任务彼此不相关,可以独立执行,即任务并行(Concurrency),参照我们业务中的惯用方法:
const main = async () => {
const asyncList = [];
const startTime = new Date().getTime();
taskList.forEach((i) => {
asyncList.push(sleep(i));
});
await Promise.all(asyncList);
console.log(`Took ${new Date().getTime() - startTime} ms`);
};
main();
// Took 507 ms
大功告成,执行时间符合预期,再给自己多增加一点难度,任务串行(Sequential processing)该怎么写呢 🤔?祭出了尘封已久的 for 循环:
const main = async () => {
const startTime = new Date().getTime();
for (let index = 0; index < taskList.length; index++) {
await sleep(taskList[index]);
}
console.log(`Took ${new Date().getTime() - startTime} ms`);
};
main();
// Took 1513 ms
执行结果非常符合预期,跑的通!但总感觉 for 循环不是 2020 年的写法,于是我们踏上了优雅写法的探索之路,首先来看一看 JavaScript 中循环都有哪些。
JavaScript 中的循环
只列举了部分常用的方法,如 for...in
由于缺点比较多,详见 ES6 入门-遍历语法的比较、do
不常用,以及部分本孤陋寡闻的笔者闻所未闻的操作,此处省略,不加讨论。
- 循环语句
while
for
语句for...of
语句
- 方法
Array.prototype.forEach
Array.prototype.reduce
Array.prototype.map
Array.prototype.every
Array.prototype.some
Array.prototype.filter
while 和 for
循环语句 while
和 for
都是正常流程执行,在 async
函数中使用 await
关键字理所应当的 “await(等待)” 异步任务执行完成,再进行执行后续流程。
for…of 语句
for...of
语句就说来话长,毕竟阮老师的 ES6 入门中对其进行长篇大论的解释,我们结合 MDN 上的定义,发现还是阮老师的定义好一点:
我们尝试用 for...of
来实现以下需求:
const main = async () => {
const startTime = new Date().getTime();
for (const task of taskList) {
await sleep(task);
}
console.log(`Took ${new Date().getTime() - startTime} ms`);
};
main();
// Took 1511 ms
符合预期,代码简洁,原理还需要进一步推敲,先看下一个。
Array 的一系列迭代方法
发现了一个系列,讲的实在太好了,接下来进行的是文章精简与翻译与部分搬运的工作,文末有参考链接。
不变的是 sleep
函数。
const sleep = (n) => new Promise((res) => setTimeout(res, n));
Array.prototype.forEach
const arr = [1, 2, 3];
const main = async () => {
arr.forEach(async (i) => {
await sleep(10 - i);
console.log(i);
});
};
main();
console.log("Finished async");
// Finished async
// 3
// 2
// 1
于是我们问 Google 为什么会这样,发现一位朋友在“源码”中找到了答案。
???🤔 JavaScript 还有源码呢?怎么从来没听说过人讨论 JavaScript 源码呢,打开 MDN 一看,原来只是一个方法的 Polyfill。
forEach 只有一个执行 async 函数的逻辑,没有对返回的 Promise 进行任何处理,甚至本身连返回值也没有, forEach 没有将循环中的异步函数串起来的能力。
结论就是 forEach 不适合循环中异步这个场景,下一个。
Array.prototype.reduce
reduce
给我的印象一直以来比较绕,比较烧脑,直接看代码:
const arr = [1, 2, 3];
const main = async () => {
const startTime = new Date().getTime();
const asyncRes = await arr.reduce(async (memo, e) => {
await sleep(10);
return (await memo) + e;
}, 0);
console.log(`Took ${new Date().getTime() - startTime} ms`);
};
// Took 14 ms
看到这里我非常懵,难道这个代码不是串行吗,为什么串行可以做到 14 ms 执行完成 🤔🤔🤔?
作者还附上了时序图,将个模式称作 await memo last。
有 last 的地方就有 first,接下来就是 await memo first:
const arr = [1, 2, 3];
const main = async () => {
const startTime = new Date().getTime();
const asyncRes = await arr.reduce(async (memo, e) => {
await memo;
await sleep(10);
return (await memo) + e;
}, 0);
console.log(`Took ${new Date().getTime() - startTime} ms`);
};
// Took 38 ms
为什么会这样呢,这一段文字直接看原文好了:
关键就在最后一句话,好像明白的差不多了,可见 reduce 适合处理串行任务。仔细想一想,好像还是不那么明白,看 MDN 上的 Polyfill 实现:
我们将 callback 放入其中一探究竟,下文中的 callback(n) 指代第几轮循环中的 callback 函数,result(n) 代表第几轮循环的执行结果,第一轮循环中,我们调用了 async 函数 callback(1),调用一个 async 函数 return 的是一个 Promise,之后我们走第二轮循环,第二轮循环 callback(2) 中的代码该怎么执行怎么执行,到了 await result(1) 的时候,等待 result(1) 的结果,但是 result(2) 还是 Promise,以此类推。这里的关键是调用一个 async 函数,立即返回的是 Promise,reduce 函数在第二轮以及之后对上一轮的 Promise 结果做了 await,这是任务间串起来的原理。
Array.prototype.map
直接看用法,这里跟原文章相比,给时间调大了一些
const arr = [1, 2, 3];
const main = async () => {
const startTime = new Date().getTime();
const asyncRes = await Promise.all(
arr.map(async (i) => {
await sleep(100);
return i + 1;
})
);
console.log(asyncRes);
console.log(`Took ${new Date().getTime() - startTime} ms`);
};
// 2,3,4
// Took 118ms
从执行时间来看,这里是任务并行(Concurrency),map 适合处理并行任务的场景。
filter
、some
、every
就先不探究,看一些通用场景。
并发控制
map 虽然可以做到任务的并发,但一次执行太多任务对硬件资源消耗较大,再比如爬虫这种场景,需要限制一些请求频率,需要对并发进行一些控制,作者提到了三种控制
- Batch processing 分组执行
- Parallel processing 限制并发数,同一时间执行 n 个 task
- Sequential processing 顺序执行
这里只展示核心思路,不具体展开代码
Batch processing
直接看图,将任务分组,Group 与 Group 之间串行,Group 内的任务并行
根据之前得出的结论,串行 reduce,并行 map
return Object.values(groups).reduce(
async (memo, group) => [
...(await memo),
...(await Promise.all(group.map(iteratee))),
],
[]
);
Parallel processing
同一时间,两个任务在执行,这里作者用到 bluebird 这个仓库,设置一个并发数,async 这个库也可以做到同样的事情
// Bluebird promise
const res = await Promise.map(
arr,
async (v) => {
console.log(`S ${v}`);
await sleep(v);
console.log(`F ${v}`);
return v + 1;
},
{ concurrency: 2 }
);
Sequential processing
顺序执行直接 reduce
就好
const res = await arr.reduce(async (memo, v) => {
const results = await memo;
console.log(`S ${v}`);
await sleep(10);
console.log(`F ${v}`);
return [...results, v + 1];
}, []);
心得与收获
for
、for...of
语句中的异步好使- 为什么
Array.prototype.forEach
中的 await 不起作用 Array.prototype.map
适合处理任务并行、Array.prototype.reduce
适合处理任务串行- 常用的并发控制的手段
- 分组执行
- 控制并发数
- 串行执行
- bluebird、async 等更为高级的异步任务处理仓库
- 对于 JavaScript 上的一些内置对象的方法,可以到 MDN 上查看 Polyfill,理解其内部大概实现