# 使用 async 控制并发
30 秒速记
- 并发控制 = 同时在跑的任务不超过
N个,一个结束再补一个,不是把数组全丢给Promise.all async库核心接口:mapLimit(arr, limit, iterator, cb)处理固定数组,queue(worker, concurrency)处理持续入队- 失败策略要先定:一错全停、收集全部错误、还是重试;别静默吞掉
- 重试只给幂等请求,用指数退避加随机抖动,再设总次数上限
- 现在新代码多用
Promise版:p-limit或手写任务池,思路和mapLimit一样
控制并发就是给异步任务加个闸:同一时间最多跑 N 个,跑完一个再放一个进来。 爬虫、批量上传、批量调接口都会用到,一下子发几百个请求,要么被对方封 IP,要么把自己的连接数、内存打满。async 库的 mapLimit 是早期最常用的写法,现在用 Promise 的话 p-limit 一行就能搞定,或者自己写个任务池。不管用哪个,重点是想清楚某个任务失败了整体怎么办,是马上停还是继续跑完再汇总错误。
理解「使用 async 控制并发」时,要先明确输入、运行阶段和可观察输出。这里的交互实验会分别执行正常路径与失败路径,并把 Node.js 的关键状态变化和同屏代码对应起来;不能只记命令名称。
工程中还要验证退出码、资源清理和异常输入。属于 event-loop 场景的配置应保持最小可复现,并在开发环境与 CI 中使用相同命令复跑,避免把平台差异或隐式默认值带到线上。
「并发」和「并发控制」常被混着说。Node 里发起异步请求本身就是并发的,问题在于不加限制时会一下子把所有请求都发出去。并发控制要做的是让「同时在途的任务数」保持在一个上限内。
用 Promise 写,最简单的是 p-limit:
import pLimit from 'p-limit';
const limit = pLimit(5);
const urls = Array.from({ length: 30 }, (_, i) => `https://example.com/${i}`);
const results = await Promise.allSettled(
urls.map((url) => limit(() => fetch(url).then((r) => r.text())))
);
// 任意时刻最多 5 个 fetch 在跑;allSettled 让失败的不影响其他
const failed = results.filter((r) => r.status === 'rejected');
console.log(`成功 ${results.length - failed.length},失败 ${failed.length}`);
注意这里 limit(() => fetch(url)) 包的是函数,真正的 fetch 由 limit 决定什么时候调用;写成 limit(fetch(url)) 就又变成一次全发出去了。
除了上限,真实场景通常还要配三样东西:单个任务的超时(AbortSignal.timeout(5000)),失败的重试策略,以及整体的取消能力,比如用户关掉页面或者任务被手动终止时,用 AbortController 让还没开始的任务不再启动。这些和用 async 还是 Promise 无关,是调度语义本身要回答的问题。
面试官追问
追问 1Promise.all(urls.map(fetch)) 处理 1000 个链接,有什么问题?
map 那一刻 1000 个请求就全发出去了,Promise.all 只负责等结果,不限流。要先把任务包成函数,再交给任务池控制同时启动的数量。
追问 2手写一个并发上限为 5 的任务池,核心思路是什么?
起 5 个 worker,每个 worker 循环从共享的下标里取下一个任务,跑完再取:
追问 3并发控制后脚本跑完不退出,最后一个回调已经执行了,查什么?
事件循环里还有东西挂着:没关的 keep-alive 连接、没清的 setInterval、没关的数据库连接。用 why-is-node-running 或 process._getActiveHandles() 看看是谁。
追问 4某个请求失败了,是让整批停掉,还是继续跑完?
看业务。批量导入要求全量一致就一错全停;爬虫、批量通知这类允许部分失败的,继续跑完,把失败项和原因汇总出来,再决定要不要重试。别让它静默变成空结果。
追问 5接口偶尔超时,加了重试后对方服务被打得更挂了,为什么?
所有失败请求同时立即重试,等于又来了一波洪峰。重试要退避,比如 1s、2s、4s 再加随机抖动,设最大次数,并且只重试 GET 这类幂等请求。
# 《使用 async 控制并发》
# 目标
30 秒速记
- 任务:
node app.js输出CNode首页每个主题的title、href、comment1,格式是JSON - 和上一节的区别只有一个:同时发出的请求不能超过
5个 - 流程:抓首页拿主题链接 → 限流抓每个详情页 → 解析第一条评论 → 按首页顺序组装输出
- 没评论的主题要有明确约定(比如
comment1: null),别凭空编一个
这一节的需求和上一节一样,抓首页主题和第一条评论,唯一的新约束是并发连接数最多 5 个。 上一节是解析完首页就把四十个详情页请求一次性发出去,这一节要换成排队执行:始终最多 5 个在跑,有一个完成才补下一个。所以代码结构上,抓首页和解析不变,变的只是「怎么发起详情页请求」这一层。顺带要处理没有评论的主题,输出里给个 null,不要让整个对象缺字段。
建立一个 lesson5 项目,在其中编写代码。
代码的入口是 app.js,当调用 node app.js 时,它会输出 CNode(https://cnodejs.org/ ) 社区首页的所有主题的标题,链接和第一条评论,以 json 的格式。
注意:与上节课不同,并发连接数需要控制在 5 个。
输出示例:
[
{
"title": "【公告】发招聘帖的同学留意一下这里",
"href": "http://cnodejs.org/topic/541ed2d05e28155f24676a12",
"comment1": "呵呵呵呵"
},
{
"title": "发布一款 Sublime Text 下的 JavaScript 语法高亮插件",
"href": "http://cnodejs.org/topic/54207e2efffeb6de3d61f68f",
"comment1": "沙发!"
}
]
面试官追问
追问 1怎么证明并发真的没超过 5?只看最终 JSON 行吗?
不行,最终结果和并发数没关系。在请求开始时计数加一、结束时减一,并记录最大值,跑完断言最大值不超过 5。失败分支也要减,不然计数会越来越大。
追问 2主题从 40 个变成 1000 个,并发上限要不要跟着调大?
不用。任务数和并发数是两个参数,任务多了只是排队时间变长。并发上限取决于对方服务能扛多少,而不是你有多少活。
追问 3跑完输出空数组,退出码还是 0,先查哪一段?
先查首页解析出了几个链接,多半是页面结构变了,选择器没匹配到,后面自然什么都没跑。确定有链接后再看详情任务有没有被提交、最终回调是不是在任务完成前就输出了。
追问 4有个主题没有评论,解析时报错导致整个程序崩了,怎么改?
取第一条评论前先判断存不存在,没有就给 null。cheerio 选不到元素时 .text() 返回空字符串不会报错,崩通常是对 undefined 继续取属性,比如直接用了 res.text 而请求其实失败了。
# 知识点
30 秒速记
- 学会用
async库的接口控制并发数,最常用的是mapLimit和queue - 只
require('async')不会限流,必须用带Limit或concurrency参数的接口 mapLimit:固定数组 → 结果数组,顺序和输入一致;queue:任务可以随时pushasync v3支持传async函数,省略回调时返回Promise,可以直接await- 每个任务的回调只能调一次,调两次会让结果和计数全乱
这一节要会的就是用 async 库把一批异步任务的同时执行数限制住。 最常用的是 mapLimit,传入数组、上限、处理函数,它会保证同时最多跑上限个,结果按输入顺序放好。另一个是 queue,适合任务不是一开始就确定、而是陆续进来的情况。新版本 async 已经支持 async 函数和 Promise,写起来不一定非得回调;但不管哪种写法,每个任务只能完成一次,这个约定被打破,库也救不了。
- 学习 async(https://github.com/caolan/async ) 的使用。这里有个详细的 async demo 演示:https://github.com/alsotang/async_demo
- 学习使用 async 来控制并发连接数。
面试官追问
追问 1代码里引入了 async,但还是 urls.forEach(url => fetch(url)),算限流吗?
不算。限流靠的是调度接口,不是装了这个包。要把请求放进 async.mapLimit(urls, 5, ...) 的处理函数里,由它决定什么时候调用。
