ARTICLE DETAIL

资讯详情

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

同步请求与异步请求底层差异详解:从事件循环到 Promise 与 async/await

同步请求与异步请求底层差异详解:从事件循环到 Promise 与 async/await 做接口联调这几年我听到最多的问题之一就是“同步请求和异步请求到底差在哪里”不管你是刚把fetch用熟的前端还是天天写 HTTP 客户端代码的后端这组概念都躲不掉。有的同学能跑通代码却解释不了为什么点击按钮之后页面还能滑动有的同学知道一个请求会“占住线程”但说不出占的是哪一条线程、为什么有的调用会堵住整个系统。这两个词的“听说”门槛很低可一旦往深了问背后牵扯到操作系统、事件循环、线程模型和错误处理一整条链路。接下来我不打算上来就甩定义。我会从一次请求的生命周期讲起把同步和异步的底层差异拆开配上一段段能直接复现的代码最后把真实开发和联调中容易踩的坑整理成清单。这篇文章适合正在写前端交互、后端接口或者只是想把 HTTP 请求模型彻底弄明白的同学。看完之后你会知道什么场景该用同步、什么场景该用异步也不会再被“异步一定更快”这句话带偏。1. 先聊清楚同步和异步到底在“等”什么1.1 一次请求从发出到返回程序经历了什么比如你在浏览器里请求https://api.example.com/v1/users/1001。表面上看只是一条 URL但这条请求要依次完成DNS 解析、建立 TCP 连接、发送 HTTP 报文、等待服务端业务处理、返回响应报文、客户端读取响应体。其中任何一步变慢都会拉长整个请求的完成时间。同步模型下程序发起请求之后当前线程不会继续往下执行而是进入等待状态直到拿到完整响应。伪代码长得很直接response request(url)。这一行执行完之前后面的代码一行都不会跑如果响应需要 3 秒当前执行流就暂停 3 秒。用“打电话”来形容非常贴切你拨号之后必须一直拿着听筒等对方说完中途不能干别的事。异步模型下程序在发起请求后立刻返回只拿到一个“将来会有结果”的凭证比如 Promise。后面的代码可以继续执行等真正的响应到达事先注册好的回调再被推入任务队列执行。用“留言回电”来形容更合适你登记好手机号该干嘛干嘛对方处理完会主动打给你。也可以想象食堂叫号同步是在窗口前排队等饭异步是拿号找座餐好了大屏叫你。1.2 阻塞与非阻塞两个模型的本质差异很多资料喜欢把“同步”等同于“阻塞”“异步”等同于“非阻塞”这个简化在 Web 请求场景里基本能成立但概念层面需要更细致一点。同步和异步描述的是“结果怎么获取”是原地等待结果还是通过回调、事件、Future 去拿。像浏览器里过时的同步XMLHttpRequest会阻塞主线程Python 的requests.get()也阻塞调用线程。而异步请求通常不会阻塞发起方但它在底层是不是完全不阻塞要看具体实现——如果事件循环上跑了一个 CPU 密集任务照样会把后面的回调全部拖住。阻塞和非阻塞描述的是“当前线程能不能继续推进”。同步请求通常阻塞异步请求通常非阻塞但这并不是必然不阻塞的同步请求可以出现在轮询模型里阻塞的异步请求也可能出现在劣质实现里。你只需要把握一点只要一个请求会让当前线程被动停在原地它就是典型的同步阻塞调用只要调用函数在请求没完成时就返回了它就是异步调用。这两句话能记住后面看文档就不会被“异步非阻塞”“同步阻塞”这类词绕晕。1.3 为什么“异步”不等于“更快”异步请求不会减少服务端的处理时间也不会让单条请求真的变快它真正能改变的是把多个等待重叠加起来。比如三个接口每个都耗时 1 秒。同步串行去请求总时长约 3 秒异步同时发出去再统一等结果总时长可能只有 1.2 秒。节省的不是服务端算力而是发起方在多个等待之间浪费掉的空隙。这一点特别容易让人误判。很多新手以为用了异步每个接口就会神奇地变快实际上如果只有一个请求异步往往还更“麻烦”因为你要处理回调、竞态、未捕获异常心智负担更高。真正的收益来自并发同时等多个请求或者在一个请求慢时当前线程还能去处理其他事情。理解了这一点后面所有选型和分析就都顺了。2. 核心机制拆解异步请求为什么能“不排队等响应”2.1 浏览器事件循环调用栈、任务队列和微任务浏览器里的 JavaScript 是单线程的流行说法叫“主线程既负责执行 JS也负责回流和重绘”。那异步网络请求是怎么做到不卡界面的关键在事件循环。当fetch或XHR发起请求时真正的网络 I/O 并不是由 JS 主线程盯着而是由浏览器底层网络模块去做。主线程把请求交给底层后自己马上接着执行后面的代码。网络模块拿到完整或部分响应把回调包装成任务放进任务队列。事件循环的每一轮会先看调用栈是否为空为空就从队列里取一个任务来执行。所以主线程不会因为等待网络而停顿它只是在有空的时候才处理响应回调。微任务比如Promise.then也有自己的队列会在当前宏任务结束、下一个宏任务开始之前被清空。我经常拿餐厅来打比方主线程是服务员网络请求是后厨做菜。服务员把菜单递进窗口之后会继续接待其他客人后厨做完菜只负责放进上菜口服务员不会干盯着这道菜出锅。如果服务员一步都不离开某一张餐桌他每天只能服务一桌客人餐厅很快就会瘫痪。这个比喻虽然粗糙但能把“为什么异步不阻塞”的逻辑讲清楚。2.2 服务端语言的异步实现不只是“回调”一种Node.js 同样是事件循环模型但网络 I/O 和文件 I/O 的处理方式不太一样。网络 I/O 通常依赖底层事件通知比如 Linux 的 epoll文件操作则大多交给 libuv 的线程池。所以说“Node 是单线程”并不准确单个进程的 JS 主线程只有一个但很多底层 I/O 已经在线程池里并行了。这也解释了为什么 Node 里写一个死循环能把所有请求饿死因为主线程事件循环被占住没机会处理其他任务。Python 的情况又不同。常规的requests.get(url)是同步阻塞的httpx.AsyncClient和aiohttp则基于 asyncio用协程和事件循环来调度。协程本质上是用户态的可暂停函数在等待 I/O 时主动让出控制权把资源让给别的协程而不是真的占住主线程。Java 里则更常出现“异步化”方案CompletableFuture、Spring 的Async、WebFlux核心是用线程池或事件驱动把阻塞操作挪出主逻辑。这也解释了一件事不同语言说的“异步”底层手段可能完全不同。你不一定需要把每种实现都背下来但要有能力看懂文档里的一句话——这个接口是“异步非阻塞”还是“异步阻塞”。“异步”是发起方式“阻塞”才是代价两个维度要分开看混在一起最容易出事故。2.3 并发模型线程、协程与连接复用如果你在写后端同步和异步通常还牵扯到“线程够不够用”。同步模型的一般规律是“一个请求占一个线程”Spring Boot 默认的 Tomcat 线程池大小有限如果有人调用一个很慢的第三方接口线程就会被占住不放请求量一多线程池耗尽新请求只能排队出现连锁超时。同步代码容易写但并发天花板非常明显。异步模型则允许更少的线程服务更多的并发请求。Node 的单线程事件循环可以同时挂着成千上万个网络请求因为等待网络的空隙根本不占线程Python asyncio 的协程也能在阻塞点切换Java NIO 用少量线程处理大量连接。代价是代码理解成本高链路变长错误堆栈经常不好找。无脑推荐“全异步”的人多半没有处理过晦涩的异步链路所以你一定要学会按场景判断。客户端同样有连接复用的概念。浏览器对同一域名通常会限制并发连接数HTTP/1.1 下大概是 6 个左右如果同时发起 20 个请求超出部分会排队。HTTP/2 的多路复用缓解了这个问题但服务端并发、代理、CDN 仍然有各自的限制。所以一个页面上“到底放多少个并发请求”也是需要规划的功课不是越激进越好。2.4 为何 XMLHttpRequest 的同步模式成了反面教材老浏览器里XHR 支持把open(method, url, false)写出来让请求同步执行请求完成前 JS 彻底停住拿到响应再继续。我能理解为什么很多老开发者喜欢这么写因为逻辑足够直观链路也没有任何“隐藏行为”。但它有一个致命代价主线程被占住后页面既不能点击也不能滚动整个 Tab 像死了一样。现在的浏览器已经不建议在主线程使用同步 XHRChrome 更是在页面上下文里移除了它强行使用只会得到异常。核心原因很简单同步 XHR 会长时间占用主线程渲染任务、用户事件回调全部被堵死。开发者觉得“同步写着顺手”可用户感受到的是白屏、卡顿、点击无响应这两者之间的收益完全不对等。这个演变很好地说明了一个问题选型不能只看“好不好写”。如果你在维护旧代码时发现某处用了同步 XHR至少要先问一句这个请求是不是在主线程执行是的话改用fetch加async/await别让老写法变成新隐患。3. 实操对比从同步到异步代码到底改了什么3.1 同步写法适合小脚本但注意阻塞先看一个常见的 Python 同步请求示例import requests resp requests.get(https://api.example.com/users/1001, timeout5) print(resp.status_code) print(resp.json())第二行会阻塞当前线程等resp有值之后才继续。如果这段代码运行在后端的一个请求处理线程里那么这个线程在这几秒内什么都做不了但只要承受得住同步代码非常直观后面的打印可以直接使用resp不需要处理回调嵌套。浏览器时代的同步 XHR 写法因为已经被废弃这里只作历史对照不建议生产使用const xhr new XMLHttpRequest(); xhr.open(GET, https://api.example.com/users/1001, false); xhr.send(); console.log(xhr.responseText);send()执行完之前主线程被占住。今天的浏览器里这段代码大概率会直接报错。所以现代工程里“同步拿结果再继续”的写法主要存在于非浏览器环境或用于自动化脚本。判断标准很简单当前执行流允不允许长时间不能前进如果不允许就不该在主线程选同步写法。3.2 用 Promise 改写异步请求的现代写法浏览器端现代写法统一用fetch返回的是 Promise。基础版长这样fetch(/api/users/1001) .then((response) { if (!response.ok) { throw new Error(HTTP ${response.status}); } return response.json(); }) .then((data) { console.log(data); }) .catch((error) { console.error(请求失败, error); });Promise 的.then就是在注册回调收到响应后把“解析 JSON”这一步放回任务队列执行.catch注册的是失败回调。这个写法对新人来说确实比同步代码难读但它有一个关键好处发起fetch之后主线程没有等网络页面依然可以渲染用户可以继续滚动。如果链式.then依然让你别扭可以用async/await改写async function loadUser() { try { const response await fetch(/api/users/1001); if (!response.ok) { throw new Error(HTTP ${response.status}); } const user await response.json(); console.log(user); } catch (error) { console.error(请求失败, error); } }这里要特别强调一个高频误区await不是把异步请求变成同步它只是把 Promise 的写法改得更接近顺序思考。真正的执行仍然是fetch发起后立即返回 Promise函数在await处挂起但不会阻塞主线程Promise 完成后事件循环再把函数恢复。所以浏览器不会因为一个天气接口慢就连页面点击都卡住。3.3 async/await 的正确理解与并发控制多个独立请求时如果写“先await一个再await另一个”表面上是异步语法执行起来却是串行async function loadDashboardV1() { const user await fetch(/api/me).then((r) r.json()); const settings await fetch(/api/settings).then((r) r.json()); return { user, settings }; }两个请求先后等待总耗时接近两个请求之和。正确做法是同时发出去再统一收结果async function loadDashboardV2() { const [user, settings] await Promise.all([ fetch(/api/me).then((r) r.json()), fetch(/api/settings).then((r) r.json()), ]); return { user, settings }; }Promise.all有一个很实用的特性它保留数组顺序。即使settings先返回user依然会落在第一个位置不会出现“谁先回来谁就排前面”的错乱情况。这样既拿到了并发收益又不需要手动维护响应顺序。如果请求数量很多还要控制并发峰值不能无脑Promise.all。比如列表页要加载 20 个文件你可以每 5 个一批跑async function runInBatches(tasks, batchSize 5) { for (let i 0; i tasks.length; i batchSize) { const batch tasks.slice(i, i batchSize); await Promise.all(batch.map((task) task())); } }批次之间是串行的批次内部是并行的。这样做是为了把峰值并发压到可控范围避免瞬间打爆浏览器连接池也避免服务端出现突发流量。3.4 实际操作给异步请求加超时和取消很多新手把fetch用起来之后渐渐发现一个问题“请求一直没反应代码也没有报错。”原因在于fetch默认没有超时服务端连接挂住Promise 就会一直停在 pending 状态。所以生产环境里必须自己做超时控制或取消机制。浏览器端的标准做法是AbortControllerconst controller new AbortController(); const timer setTimeout(() controller.abort(), 8000); try { const response await fetch(/api/slow-report, { signal: controller.signal, }); // 正常处理响应 } catch (error) { if (error.name AbortError) { console.error(请求超过 8 秒已取消); } else { console.error(其他错误, error); } } finally { clearTimeout(timer); }这段代码的关键点是controller.abort()会让fetch的 Promise 变成 rejected并抛出一个名字为AbortError的异常。你需要在catch里区分“超时中断”和“其他网络错误”再决定是重试、提示还是降级。Node 环境下如果用axios可以直接在配置里写timeout如果继续用原生 fetch也需要考虑超时方案。给每一个外部请求配超时是我反复强调的规矩异步请求本身不可怕可怕的是没人知道它挂了。4. 真实场景选型什么时候该同步什么时候该异步4.1 必须立刻知道结果的请求同步更适合哪些请求必须立刻知道结果登录鉴权、支付回调校验、实时库存查询、服务端内部 RPC这些场景都算。上游调用方需要根据响应决定下一步甚至还要把结果直接展示给用户。如果把这些请求强行异步业务就断了用户提交支付后总得等到一个“成功还是失败”的结论不能丢下一句“稍后再看”。同步请求在这里的优势是直观请求、响应、异常处理都在同一条调用链上逻辑清晰排查问题也方便。真正的风险是线程占用。在 Java 这类线程池模型里每个同步请求占一个线程如果上游服务响应慢线程就会堆积。应对办法是把超时调好配置熔断和线程池上限不要让任何一次慢请求无限拖下去。很多架构问题不是“同步不好”而是“同步没有超时保护”。4.2 可以“先收下再处理”的请求异步更适合通知用户、发送邮件、生成报表、批量计算、缓存预热这类请求都有一个共同点调用方不关心“立刻完成”的具体结果只要知道任务被接收了。把它们做成异步往往更舒服。前端可以先返回“提交成功”后台通过任务队列慢慢跑客户端再通过轮询或长连接查询任务状态。这一类异步请求里真正的核心不是fetch或 Promise而是任务队列。服务端接到请求后把任务丢进消息队列立刻返回 200消费者后台逐步处理。这样用户不用一直空等服务端也不会因为某个慢任务堵住后续请求。我做过一个报表导出功能导出需要 20 秒如果同步处理HTTP 连接要挂 20 秒网关层还可能先超时用户只能干瞪眼。改成异步后任务状态写进 Redis前端几秒轮询一次体验立刻舒服很多。4.3 前端交互里最稳妥的组合方案前端大部分接口请求都应该异步这一点基本没有讨论空间。真正有讲究的是怎么组织页面初始化的多个独立数据接口用Promise.all并发拉取减少白屏时间相互有依赖的请求用async/await按顺序执行保证链路上的数据是新的用户高频操作触发的搜索或筛选要加防抖和取消机制避免旧请求覆盖新结果。表单提交通常也用异步但必须做好“防重复”和“结果反馈”。我见过没给提交按钮加防重的页面用户手滑点两下订单或工单就被建了两个。最简单的方案是提交前把按钮置为 loading 并禁用等请求结束后恢复对幂等性要求高的接口还要带固定 requestId服务端按幂等键去重。这组合技巧并不深但能覆盖绝大多数实战场景。4.4 选型失误的典型表现与补救经验选型失误最常见的症状是“请求一多系统就变慢”。比如在 Node 主线程里同步调用外部 API看起来代码逻辑没问题但同步请求把事件循环堵住后所有异步回调都会延迟整体表现就是服务“卡住”。类似的坑也会出现在 Python 协程里在协程中用了requests.get这种同步阻塞调用事件循环被卡住并发能力瞬间归零。补救经验有三条。第一在异步链路里尽量不混入同步阻塞调用必须用时把它放到线程池或独立调度之外。第二给外部请求统一设置超时并明确失败策略重试、降级、直接报错选哪种都要提前定。第三历史代码一时改不动至少用独立的线程池承接同步调用别让它占用主线程。真实系统往往是“同步与异步混用”完全同步容易资源耗尽完全异步容易代码不可读。关键是识别每一处等待并给等待设计兜底。5. 常见问题与排查技巧实录5.1 请求长时间不返回先分清楚卡在哪一层遇到“请求一直 pending”这种问题我建议按链路拆解先看客户端有没有把请求发出去再看服务端有没有收到最后看响应有没有回来。浏览器里打开 DevTools 的 Network 面板看单个请求的 Waterfall到底是卡在Stalled、DNS Lookup、Waiting (TTFB)还是连接建立阶段一直被拖住。用curl -w也可以辅助测量链路各阶段耗时curl -s -o /dev/null -w dns:%{time_namelookup} connect:%{time_connect} ttfb:%{time_starttransfer} total:%{time_total}\n https://example.com/api如果connect时间很短但ttfb很长说明客户端到服务器网络没问题问题大多在服务端处理或网络栈如果total已经接近超时阈值多半是缺少超时机制。后端排查时还要同步看应用日志耗时、数据库慢查询、第三方调用耗时。常见的一个坑是客户端配置了 10 秒超时服务端却还在继续处理一个 20 秒的任务客户端超时后服务端线程仍然把浪费延续到最后。这种场景需要的是上下文超时不只是一个 socket timeout。5.2 异步响应乱序、重复提交与过期结果异步并发的隐患之一是乱序。比如搜索功能用户输入 A 后快速改成 B两个请求几乎同时发出如果 A 的响应比 B 还慢晚回来的 A 就可能把 B 的前台展示覆盖掉。解决办法是多层防抖、请求序号或者干脆用AbortController取消旧请求。只要最新请求发出时把旧请求 abort 掉过期响应就不该有机会污染页面。若某些环境不支持取消就在响应里带 requestId在收到后只放行 ID 与最新请求一致的响应。重复提交是另一个高发问题。前端限制按钮自然是第一步后端做幂等更稳。前端防的是手滑后端幂等防的是超时重试、消息队列重投。幂等键可以是用户 ID 加操作类型加时间戳服务端存一个唯一索引重复请求直接返回上一次结果。排查时先看日志里有没有多个相同 requestId 的请求有的话就看幂等逻辑有没有正确命中。还有一个非常容易忽略的问题未捕获的 Promise rejection。异步请求失败如果没有被 catch控制台只留下一句unhandled rejection在 Node 服务端处理不当甚至可能让进程退出或者让请求挂起不释放。我通常会在代码里加一个全局兜底window.addEventListener(unhandledrejection, (event) { console.error(未处理的异步错误, event.reason); });全局日志只能保住排查线索不能代替每一处的try/catch。好的实践是“局部细粒度处理 全局兜底记录”两层并存。对于独立调用能捕捉到具体请求路径上的错误链路才是真正有用的信息。5.3 一套可以直接抄的“自检清单”我在做代码评审时整理过一张异步请求的自检清单现在贴出来给各位参考发起方是否需要立刻拿到结果如果需要优先同步可以等再异步。调用发生在主线程还是事件循环如果是浏览器主线程或 Node 主循环绝对要避免同步阻塞调用。并发请求数量是否超过浏览器或服务端连接限制并发太高需要分批或排队。有没有超时超时之后是重试、降级还是直接报错有没有取消旧请求的机制重复点击和过期响应怎么处理错误路径有没有被覆盖rejection 是否会被全局兜底记录资源是否得到释放这六条如果都能回答清楚至少能避开 90% 的请求相关事故。很多同学觉得自己“会用 async/await 就是懂了异步请求”真正到了联调、压测、排障时才发现阻塞没去掉、超时没设置、竞态没注意。这些问题不会出现在写第一行代码的时候但一定会在某次线上故障里冒出来。我个人做请求框架评审时总喜欢对同学说一句话不要把目光只放在并发速度上。慢请求、超时、取消、错误吞掉这些才是异步请求真正的战场。把这片战场管好同步也好异步也好都只是工具箱里不同规格的扳手而已。
返回列表