ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

深入理解async/await:从事件循环到并发控制实战

深入理解async/await:从事件循环到并发控制实战 我最早被async/await“教育”了一顿是在写Node.js上传接口的时候。当时线上一直报一个很奇怪的错上传失败:网络请求错误接口返回了系统错误但代码里的回调逻辑都对日志却查不到任何异常。排查到最后才发现问题出在一个漏掉await的异步函数上——函数已经return了里面的操作还没执行完外面的流程已经提前走了下一步。从那天起我才真正意识到async/await并不只是“让代码好看一点”的语法糖它背后是一整套关于异步函数执行时机和事件循环调度的规则。理解这套规则比记住几个API要重要得多。这篇文章我打算从三个层面聊async/await先讲它到底解决了什么问题再讲它背后的执行机制接着结合JavaScript、Python、Rust三种语言的实际场景和踩坑经验把异步函数最常见的误区和并发控制范式一并梳理清楚。无论你是刚接触异步编程还是已经写了一阵子但偶尔被奇怪的执行顺序坑到这篇都应该有你能用的东西。1. 先弄明白没有async/await的时代是怎么写异步的1.1 回调地狱一层嵌一层的“金字塔”在async/await还没普及的年代写异步基本靠回调函数。比如前端要发一个网络请求拿到数据后再发下一个请求代码长这样request(/api/user, function (user) { request(/api/orders?uid user.id, function (orders) { request(/api/detail?oid orders[0].id, function (detail) { console.log(detail); }); }); });三层还勉强能看五层八层的时候缩进已经能把人逼疯。更折磨人的是错误处理每个回调里都要单独做一次判断只要有一层漏了try/catch或者回调里的错误参数没检查线上问题就来了。我记得当时有一个老项目里面嵌套了七层回调每次改需求都像在拆炸弹因为根本不知道哪一层返回的数据结构变了会牵连到后面哪些逻辑。回调地狱的本质不是“缩进丑”而是异步流程的控制权被切碎了。每一步的执行结果都要手动传递给下一步中间任何一个环节出错整个链路的错误处理都要跟着改。这种写法违背了人脑处理“先后顺序”的直觉。1.2 Promise链式写法解救了嵌套但没解放心智Promise把嵌套改成了链式request(/api/user) .then(user request(/api/orders?uid user.id)) .then(orders request(/api/detail?oid orders[0].id)) .then(detail console.log(detail)) .catch(err console.error(err));视觉上舒服了很多错误处理也统一到了catch里。但一旦遇到条件分支、循环、多个并行请求then链依然绕。比如“拿到用户信息后如果用户是VIP就同时请求订单和积分普通用户只请求订单”这种逻辑用Promise写出来状态变量满天飞稍不留神就有人把变量作用域写错。再往后就是async/await登场的时刻。它把异步代码彻底写成了同步的样子async function loadDetail() { const user await request(/api/user); const orders await request(/api/orders?uid user.id); const detail await request(/api/detail?oid orders[0].id); console.log(detail); }三行代码从语义到调试体验都是质的飞跃。这就是async/await为什么被称为“异步编程的终点站”——它不代表底层机制消失而是把这些机制收进了语法糖里让开发者能用最自然的心智模型去编排异步流程。但这里有个关键点async/await并没有改变异步的底层原理它只是改变了我们表达异步的方式。事件循环还是那个事件循环微任务还是那个微任务只是你不再需要手写then回调了。所以如果只知道“await后面会等”而不清楚“等的时候发生了什么”遇到复杂一点的并发场景照样会翻车。2. 事件循环里的调度规则await到底在等什么2.1 单线程却要排队干活事件循环的基本逻辑要理解await首先要理解JavaScript的运行模型。JavaScript是单线程的也就是说同一时刻只能执行一段代码。那它怎么做到同时处理网络请求、定时器、用户点击这些事答案是事件循环Event Loop。你可以把事件循环想象成一个叫“任务队列”的排队大厅。同步代码先执行遇到异步操作比如setTimeout、网络请求、Promise回调就扔给底层环境去处理等底层环境有了结果就把对应的回调函数排到队列末尾。主线程手里的同步代码执行完后再从队列里取一个任务出来执行执行完再取下一个循环往复。await的关键特点是“让出”而不是“阻塞”。当一个async函数执行到await那一行时它会立即把控制权还给外部调用者自己挂起等待。注意这里的挂起不会卡死主线程——主线程继续去执行别的事务比如响应用户点击、处理其他请求。等await背后的操作完成了事件循环再把async函数剩余的部分排入队列继续往下执行。这个设计最大的价值是用单线程模拟出了高并发的效果。一个Node.js服务主线程虽然只有一个但当它遇到I/O等待时并不会傻等而是立刻去服务下一个请求。这就是为什么Node.js能扛住大量并发连接的根本原因。如果await是“阻塞式等待”那整个服务早就卡死了。2.2 微任务与宏任务await之后的优先级事件循环里的任务其实分两种宏任务Macro Task和微任务Micro Task。setTimeout、setInterval、I/O事件属于宏任务Promise的回调、queueMicrotask、MutationObserver属于微任务。两者区别在于优先级微任务永远优先于宏任务执行。也就是说事件循环每次取一个宏任务执行完后不会立刻去取下一个宏任务而是先把当前宏任务执行期间产生的所有微任务全部处理完才会继续下一轮宏任务。await后面的代码本质上就是一个Promise的微任务回调。所以当你看到这样一段代码时console.log(1); async function test() { console.log(2); await Promise.resolve(); console.log(3); } test(); console.log(4);输出顺序是1、2、4、3。原因很简单test函数同步执行到console.log(2)遇到await让出控制权外部继续执行console.log(4)等微任务队列轮到时再执行console.log(3。这个例子我面试时经常拿来考候选人——很多人第一次都答错因为他们把await理解成了“阻塞等待”以为执行到await那一行会等结果出来才往下一步走。实际上await是“挂起等待”它的“等待”不会冻结程序只是冻结了当前这个async函数的作用域。2.3 一个容易误解的执行顺序例子再看一个稍复杂的场景把setTimeout和Promise混在一起console.log(A); setTimeout(() { console.log(B); }, 0); async function test() { console.log(C); await Promise.resolve(); console.log(D); } test(); console.log(E); // 输出A, C, E, D, B为什么setTimeout明明delay是0却最后才执行因为setTimeout注册的定时器回调是宏任务即使已经到时间了也要等当前宏任务里的同步代码和后续的微任务全部清空才有机会被取出来执行。而await后面的console.log(D)是微任务优先级高于宏任务所以它排在B前面。这个优先级规则在实际项目里的影响非常大。比如你写了一个异步上传函数在await之后刷新页面状态如果没搞清楚微任务的执行时机可能就会出现“状态还没刷新页面已经跳转了”这类诡异问题。很多“偶尔复现、查不出原因”的bug本质都是对事件循环时序的理解不到位。3. 从JS一路看到Rust三种语言对async/await的设计取舍3.1 JavaScript单线程里借时间JavaScript的async/await是建立在Promise之上的。一个async函数执行后返回的一定是Promise对象不管内部return的是什么值都会被Promise.resolve包装一层。await可以挂在任何Promise或thenable对象上但如果你await一个普通值比如await 42它会被直接当作已完成的Promise处理。JavaScript版本的async/await最典型的特点是“简单粗暴”它不提供内置的取消机制也不关心异步任务在哪个线程执行。你发起一个fetch请求await等待返回这期间事件循环继续处理其他任务。想取消请求要么用AbortController要么只能等它自己完成或超时。这种设计换来了极低的学习成本但代价是精细控制能力弱。3.2 Pythonasyncio让多路I/O并行Python的async/await是另一套体系核心是asyncio库。和JavaScript最大的区别是Python需要显式创建事件循环并且通常通过asyncio.run来启动import asyncio async def fetch_data(url): print(f开始请求 {url}) await asyncio.sleep(1) return f数据来自 {url} async def main(): result await fetch_data(https://example.com) print(result) asyncio.run(main())Python里有一个概念叫awaitable对象包括协程coroutine、Future、Task三种。你await的必须是这些对象之一否则会直接报TypeError。平时最常见的写法是await一个async def函数调用——这会创建一个协程对象如果要并发执行多个协程就用asyncio.gather或asyncio.create_task。有一个场景我印象很深就是WebSocket服务端处理函数。搜过相关问题的朋友应该见过这种签名async def voice_socket(websocket: WebSocket) - None: while True: data await websocket.receive_text() response await process_voice(data) await websocket.send_text(response)这里为什么必须用async def因为WebSocket的接收和发送都是I/O操作如果用同步阻塞的方式写整个事件循环都会被这个连接卡死其他客户端全连不上。用async/await写每个连接在等待数据时都会让出控制权事件循环可以继续服务其他连接一台服务器同时挂几千个WebSocket连接就是这么来的。Python的asyncio在实际项目中坑也不少最常见的是“不小心在协程里执行了同步阻塞操作”比如用requests库而不是httpx/aiohttp或者用time.sleep而不是asyncio.sleep。这些阻塞操作会直接卡住整个事件循环导致所有并发任务集体“假死”。很多刚上手Python异步的人都会踩这一脚。3.3 Rust零成本抽象的严谨代价Rust的async/await设计思路跟前两者都不太一样。Rust不允许有“隐式运行时”它只定义了Future trait至于怎么调度执行由外部执行器决定。最常用的执行器是tokio你要在自己的main函数里先启动tokio运行时use tokio::time::{sleep, Duration}; async fn fetch_user(id: u32) - String { sleep(Duration::from_secs(1)).await; format!(user-{}, id) } #[tokio::main] async fn main() { let user fetch_user(42).await; println!({}, user); }Rust的async/await最大的特点是零成本抽象和显式生命周期。Future对象在内存中被编码成一个状态机每次await点都是状态机的状态转换点。编译器会为你生成最精简的状态结构不引入额外的分配开销。这也是为什么像OpenVINO这样的推理框架会提供异步接口——在偏底层的系统编程里用同步阻塞的方式等待模型推理结果非常浪费CPU异步化之后可以让当前线程在等待期间处理其他任务。Rust的async在这些高性能场景里优势极其明显。但Rust的严谨也有代价错误消息复杂、借用检查器会和生命周期较长的Future打架、Send边界问题让人抓狂。我用Rust写异步代码时最常遇到的报错就是“future cannot be sent between threads safely”——因为某个中间变量没有实现Send。理解Future的Send边界几乎是Rust异步必过的一关。三种语言对async/await的实现各有取舍。JavaScript把它内置进了运行时让开发者无感使用Python用标准库提供完整的事件循环与工具集配合鸭子类型的协程写起来很顺手Rust把它拆成了语言特性和运行时两部分代价是使用门槛高换取了极致的性能和可控性。没有谁最好只有谁更适应当前的项目背景。4. 我实际遇到过的async/await翻车现场4.1 循环里逐个await任务全部串行很多写异步的人会在循环里犯一个显式的错误——把本应并发的操作写成了串行。比如批量上传文件const urls [/api/upload/1, /api/upload/2, /api/upload/3]; async function uploadAll() { for (const url of urls) { await upload(url); // 一个一个传 } }这个写法本身没问题但性能极差。每个上传请求都要等上一个完成才发起总耗时是三个请求耗时之和。如果三个文件分别是1秒、2秒、3秒总耗时就是6秒。但仔细想想这些文件互不依赖本来完全可以同时传总耗时最多3秒。区别就在“依赖关系”上。await表达的是一种依赖关系——后面的代码需要前一个结果。循环里如果每一步之间没有数据依赖就不该用await串行。正确做法是先用map把Promise全部创建出来再统一awaitasync function uploadAll() { const tasks urls.map(url upload(url)); // 立即发起全部请求 const results await Promise.all(tasks); // 等所有请求一起返回 console.log(results); }我在代码评审里见过太多这种写法了有时候把并发改成串行不是因为功能需要而是因为没意识到Promise在创建的那一刻就已经开始执行了。记住Promise对象一旦new出来背后的异步操作就开始了等到await它的时候它可能都已经完成了。4.2 异常没接住错误被静默吞掉异步里的错误处理是另一个重灾区。看这个例子async function uploadFile() { await api.upload(); } uploadFile(); // 没有catch如果api.upload失败这个Promise会进入rejected状态但因为没有catch处理就会产生一个unhandledRejection。在很多环境下这只会歪进控制台打印一行警告程序不会立刻崩溃但你的业务逻辑已经悄悄错了。最典型的就是上传接口报“上传失败:网络请求错误”前端只看到接口失败却排查不到错误来源。我在自己的代码里固定的写法是任何async函数调用要么加await并放在try/catch里要么显式链上.catch()。不要让一个“悬空”的Promise在代码里裸奔。另外如果在一个async函数里同时执行多个任务用Promise.all会遇到一个问题——只要有一个任务reject整个Promise.all立即reject其他任务的结果全部丢失。这个场景在批量上传时尤其致命一个文件上传失败你要知道其他文件哪些成功了哪些没传这时候该用Promise.allSettled而不是Promise.all。4.3 在async函数里做同步阻塞这个坑在Python和Node.js里都很常见。Node.js是没有真正的多线程的所有JS代码都跑在一个事件循环上。假如你在async函数里写了一个巨大的同步循环async function processHeavy() { heavyCalculation(); // 同步执行阻塞事件循环 await api.notify(); }heavyCalculation执行期间整个服务的所有请求都卡住了。用户点任何按钮都没反应其他接口也全部超时。这不是async/await能救的——async/await只能解决异步I/O的等待问题解决不了CPU密集型任务对事件循环的阻塞。这种情况要么拆成worker thread要么用setImmediate分批让出时间片。Python也一样。一个asyncio协程里如果混入了time.sleep(2)这2秒里整个事件循环都在睡觉。我在异步爬虫项目里就吃过这个亏用协程并发抓了几百个页面但每页解析时调了一个同步的HTML解析库结果并发效率还不如单线程。4.4 忘记awaitasync函数返回的不是结果是Promise另一种比较隐蔽的翻车是漏掉await。记住一条铁律async函数的返回值永远被Promise包裹。你return hello调用方拿到的不是字符串而是一个resolve为hello的Promise。如果不await你拿到的就是一个Promise对象用它来做字符串拼接、模板渲染大概率得到undefined。我遇到的“上传失败:网络请求错误”这类报错很多就是这种错误导致的。有的网站在上传前会调用一个预检函数如果忘记给这个预检函数加await后端还没处理完前端就开始读文件了文件还没准备好网络请求自然失败。还有个常见的例子是代码包上传上传接口明确提示“代码包大小超过限制”——一看代码发现压根没等压缩函数执行完就拿着未完成的压缩结果去上传当然会超过限制。排查这种问题最简单的方法把async函数返回值打出来看。如果是Promise对象说明你没await它如果是预期类型说明await了。很多人觉得这个低级但说实话这个bug出现的频率比想象中高得多尤其当函数调用链比较长的时候一个不起眼的漏写就能造成一连串连锁反应。5. 并发控制把await用对的经验5.1 并发工具对比Promise.all / Promise.allSettled / asyncio.gather把多个异步任务并发执行最常见的工具是Promise.all和Promise.allSettled。两者区别在于所有任务都成功时两个返回结果一样但只要有任务失败Promise.all会立刻reject而Promise.allSettled会等所有任务结束然后分别告诉你每个任务的成功或失败状态。简单说all是“要么全成功要么报错”allSettled是“不管怎样告诉我每个的结果”。Python里对应的工具是asyncio.gather和asyncio.waitasync def main(): tasks [fetch(url) for url in urls] results await asyncio.gather(*tasks, return_exceptionsTrue)这里return_exceptionsTrue很重要。如果某个协程抛异常gather默认会直接抛出导致其他协程的结果拿不到。设置成True之后异常会作为返回值返回你就可以逐个检查每个任务的结果了。5.2 限流与批处理给并发加一道闸门并发也不是越大越好。有一次我写爬虫一次性发起200个并发请求结果对方服务器直接把我IP封了。后来学乖了加了一个信号量限制并发数class Limit { constructor(n) { this.limit n; this.active 0; this.queue []; } call(fn) { return new Promise((resolve, reject) { this.queue.push({ fn, resolve, reject }); this._run(); }); } _run() { while (this.active this.limit this.queue.length) { const { fn, resolve, reject } this.queue.shift(); this.active; fn().then(resolve, reject).finally(() { this.active--; this._run(); }); } } }虽然写法上比Promise.all复杂一些但非常实用。限流逻辑本质上是“控制资源占用上限”不管下游是第三方API、数据库还是文件I/O都应该有一个并发上限保护。尤其是对接不稳定的上游服务时限流不是性能优化而是自我保护。5.3 我在项目里沉淀的三条心法第一先把任务分类有依赖关系的用await按顺序等没有依赖关系的用并发工具并行需要分批的用限流。大部分并发问题都是因为把这三类搞混了。第二错误处理要跟并发一起考虑。并发批次里有一个任务失败后你是想整体回滚还是想保留并标记这个决策必须在写代码之前就想清楚否则后续的日志排查会非常痛苦。我通常的做法是对外部依赖的调用全部走allSettled或gather(return_exceptionsTrue)自己在结果里做成功/失败判定而不是让异常直接冒泡。第三async函数的外部调用要当成Promise来对待。不管这个async函数是不是你写的调用它就有三种可能忘了await得到Promise、await但异常没捕获、捕获了但只处理了部分错误。我在团队里定了一个规矩——凡是调用外部模块返回的Promise必须在一行注释里写明它失败时该干什么。听起来死板但真的能挡住很多线上事故。关于async/await前面聊了机制、语言差异、反例和并发控制。说实话理解这些概念最好的方式不是背规则而是去写一个异步上传、批量请求或者WebSocket服务然后把并发量调大观察它在高负载下的表现。只有被问题痛过一次才能真正理解“await是让出控制权而不是阻塞等待”这句话的分量。如果这篇文章能帮你少踩一个循环串行或者漏await的坑那就不算白写。随手提一句如果你已经在用Promise.all或者asyncio.gather不妨自查一下自己的错误处理分支看看是不是真的做到了每个失败场景都有日志可查。很多看着像“偶发网络问题”的故障追到根上都是异步控制流没有设计好。
返回列表