新闻详情

新闻详情

首页 / 资讯中心 / 详情

同步请求与异步请求:底层机制、工程取舍与代码重构实战

发布时间:2026/10/2 1:54:47来源:尧图网络
同步请求与异步请求:底层机制、工程取舍与代码重构实战
1. 一次页面卡死排查让我重新理解了同步请求接手过一个后台管理系统的性能排查印象很深。用户反馈点导出按钮页面就白屏五六秒然后才弹出下载框。我们第一反应是接口慢结果抓包一看导出接口只跑了800毫秒真正耗时的是前端代码在拿到导出结果前用了一个同步请求去拉权限配置而且这个同步请求还放在关键流程前面——它一阻塞整个页面的按钮、滚动、点击全部冻住。那次之后我就把异步请求 vs 同步请求这件事彻底掰开揉碎想了一遍也才有了这篇文章。同步请求和异步请求是网络编程里最基础、也最容易被糊弄过去的一对概念。很多人知道同步会阻塞、异步不阻塞这句话但真到选型、排查、设计接口的时候还是拎不清。这篇文章我打算从两者的底层机制讲起落到实际代码重构最后聊几个我踩过的典型误解适合刚入门的前后端同学也适合写过一阵子代码但想系统梳理一遍的工程师。1.1 同步请求的定义与本质阻塞式的等待先说定义。同步请求指的是调用方发出请求后当前执行流程必须停下来等结果完全返回才能继续执行后面的代码。注意关键词是停下来。在操作系统层面这个停下来不是比喻而是真实发生的线程状态切换。举个例子你在命令行里执行curl https://api.example.com终端会一直挂着直到响应数据全部打印出来命令才结束。这期间你输入任何新命令终端都不会响应——因为当前进程还在等待这次网络交互完成。换成代码也一样import requests # 这一行会阻塞当前线程 resp requests.get(https://api.example.com/user/1, timeout5) # 只有拿到结果下面的代码才会执行 print(resp.json())这行resp requests.get(...)背后发生了什么它向操作系统发起了一次网络 IO然后主动让出 CPU线程状态从执行中变成等待中。操作系统把这部分 CPU 时间分给其他线程直到网络数据就绪再把执行权切回来函数才返回。用生活类比就很好理解同步请求像你在小面馆点单付完钱就站在窗口前干等老板不把面端出来你哪儿都去不了。等待的时间长短取决于老板出餐速度而你在这个过程中什么都做不了。这里有个容易忽略的细节同步请求的耗时是你全部等待时间的总和。DNS 解析慢一点、TCP 握手慢一点、服务端处理慢一点、网络传输慢一点这些时间全部累加在一次调用里调用方感知到的就是卡住这么久。1.2 一次同步请求的完整生命周期要把同步请求讲透最好拆开看一次请求到底经历了什么。以浏览器或后端程序发起一次 HTTPS 请求为例DNS 解析把域名解析成 IP需要向 DNS 服务器发起一次 UDP 请求并等待返回。建立 TCP 连接三次握手客户端和服务端各确认一次这也要等待网络往返。TLS 握手HTTPS 场景交换证书、协商密钥又是几次往返。发送请求数据把 HTTP 报文写入 socket。等待响应服务端处理完返回数据客户端等待这段时间。读取响应体数据从内核缓冲区拷贝到用户空间函数返回。同步请求的问题在于这六步里每一步的等待都必须由发起请求的线程亲自扛着。这个线程哪怕只是单纯在等也不能被拿去执行别的任务。如果有1000个用户同时发起同步请求你就得准备至少1000个线程来伺候这些等面的人——这在并发场景下是灾难性的资源浪费。在浏览器里还有个更特殊的约束同步请求会卡住主线程。浏览器的主线程既要执行 JavaScript 脚本又要负责渲染页面、响应用户事件。一旦主线程上出现一个同步请求页面渲染、按钮点击、滚动条拖动全部暂停表现就是白屏或假死。现在浏览器其实已经逐步废弃了同步XMLHttpRequest就是因为它在主线程上的阻塞行为太伤用户体验——你在 console 里用同步 XHR浏览器会直接打警告提醒你这是错误用法。1.3 同步也并非一无是处它的三个核心优势讲了这么多同步的坏话但它能存在到今天肯定有自己的优势而且很多场景下同步就是最合理的选择。第一逻辑简单直接。代码从上往下执行上一行的结果就是下一行的输入不需要任何回调、事件、中间状态。对一个不熟悉异步模型的开发来说同步代码几乎是零心智负担。第二正确性容易保证。因为天然串行数据依赖关系一目了然。比如转账功能必须先查余额、再扣款、再记录流水每一步依赖前一步的结果。用同步写三步写完就对了用异步写你得考虑如果扣款成功但流水写入失败怎么办这类事务边界问题。第三适合低频、串行、结果驱动的任务。比如定时爬虫脚本、数据迁移工具、批量处理任务本身没有高并发诉求也没有 UI 交互压力同步就完全够用。我见过有人把这种脚本强行改成异步结果多了一堆排错成本收益却几乎为零——为异步而异步是比不会用异步更常见的问题。所以别一听到同步就皱眉头。技术在特定场景下没有绝对优劣只有是否匹配。2. 异步请求的底层机制不等结果但结果一个不丢理解了同步的阻塞成本异步请求的价值就非常直观了。异步请求的核心是调用发起后程序不等待结果返回立刻继续执行后续代码等结果就绪后通过某种机制回调、事件、Future等拿到数据。还是面馆的例子异步点单就是你取一个号牌回座位上刷手机叫号器响了再去窗口端面。等待的时间里你没有浪费——刷手机、聊微信、处理邮件这些事都在同步进行。面馆老板也不用因为你一个人占着窗口就让后面所有客人干等着。2.1 异步的三块基石非阻塞、回调、事件循环异步请求能够成立靠的是三样东西非阻塞 IO、回调机制和事件循环。非阻塞 IO这是地基。发起网络请求后系统调用立刻返回线程不会因为等待数据而挂起。数据什么时候到由内核通知你。回调机制这是结果怎么通知回来的方式。你提前注册一个函数数据到达后系统调用这个函数把结果作为参数传进去。调用方不需要主动询问好了没而是被动等通知。事件循环这是调度中心。它维护一个任务队列不断检查哪些 IO 事件已经就绪就绪的就把对应回调推入执行队列。事件循环跑起来整个异步流水线就转起来了。以 Node.js 为例事件循环里有几个核心阶段timers定时器回调、pending callbacks系统层面的回调、poll轮询 IO 事件、checksetImmediate回调等等。一张图就能说明白的事我用文字总结一下逻辑每个阶段的回调都执行完后事件循环才进入下一个阶段这种阶段式循环保证了回调和 IO 事件都能在合适的时间被处理。有人可能会问回调函数确实是异步的但谁来执行回调答案是事件循环所在的那个线程。所以异步模型里通常只有一个主执行线程其他都是辅助性子系统比如 IO 事件监听、线程池中的任务执行。这也回到一个经常被弄混的点——异步不等同于多线程我在后面会专门展开。2.2 从回调到 Promise 再到 async/await 的演进逻辑异步模型最原始的表达就是回调函数。Node.js 早期的文件读取、网络请求全走回调风格const fs require(fs); fs.readFile(a.json, utf8, (err, data) { if (err) { console.error(err); return; } // 拿到 a.json 的内容再去读 b.json fs.readFile(b.json, utf8, (err2, data2) { if (err2) { console.error(err2); return; } // 继续读 c.json…… }); });嵌套到三四层的时候代码就成倒金字塔了——这就是著名的回调地狱。错误处理尤其痛苦每一层回调都得写一份if (err)漏一处就可能导致错误静默丢失。于是Promise出现了。它把异步操作包装成一个对象提供.then()链式调用和统一的.catch()错误处理fetch(/api/user) .then(res { if (!res.ok) throw new Error(HTTP res.status); return res.json(); }) .then(user fetch(/api/order/ user.id)) .then(res res.json()) .then(order console.log(order)) .catch(err console.error(请求链路出错, err));.then()返回的是新的 Promise所以可以无限链下去。错误会沿链条向下传播最终被.catch()接住。这比回调嵌套好维护多了但大量.then()链读起来还是不够直观。async/await是语法层面的糖衣它让异步代码长得像同步代码async function loadOrderData() { try { const user await fetch(/api/user).then(r r.json()); const order await fetch(/api/order/ user.id).then(r r.json()); return order; } catch (err) { console.error(加载订单数据失败, err); } }注意async/await没有改变异步的底层机制它只是把 Promise 的链式调用改写成了顺序结构。你依然需要理解 Promise 才能用好它——await只能等 Promise它背后依然是事件循环在调度。这是很多人学 async/await 时容易踩空的地方把await当成转同步其实它只是暂停这个 async 函数的执行把线程还给事件循环。2.3 单线程为什么能同时处理成千上万个连接这可能是异步概念里最反直觉的一点JavaScript 是单线程的Python 的 asyncio 也是单线程的但它们都能扛住成千上万的并发连接。以前我们用 Java 的时候1万个并发连接往往意味着1万个线程——每个线程默认约1MB栈空间光内存就得准备10GB上下线程切换更是消耗巨大。异步模型完全绕开了这个限制。单线程只负责两件事发起请求和处理就绪事件。网络的等待、内核的缓冲区管理、数据的到达通知全由操作系统处理。线程空闲下来就去执行其他代码直到有 IO 事件就绪才回头处理。所以1万个连接在异步模型里可能只需要一个线程加几个辅助线程。Node.js 的 libuv 在线程池里处理文件 IO、DNS 查询这类任务网络 IO 则用 epoll/kqueue 这类高效的 IO 多路复用机制。Python 的 asyncio 基于 selectors 模块底层同样是 OS 提供的非阻塞机制。核心思想一致把等待从业务线程中剥离交给系统层去监听。我调整过一个小服务的架构从每个连接一个线程的阻塞模型切到异步模型后同样一台4核8GB的机器支撑的并发连接数从几百涨到了上万CPU 占用反而下降了。原因不难理解线程切换少了内存占用小了系统的有效工作比例自然就上来了。3. 同步与异步的工程取舍一张对比表和一个决策清单把同步和异步的概念讲清楚后最难的问题反而来了实际项目中到底该用哪种答案很扫兴——没有固定答案只有场景判断。但判断有规律可循。3.1 六维度对比谁在什么场景占优我习惯把两者放在六个维度上对比这样选型时可以直接对照对比维度同步请求异步请求执行流程发起后等待代码串行推进发起后立即返回回调/事件驱动线程占用等待期间占用整个线程等待期间线程可复用代码可读性顺序结构直观需要 Promise/await 等语法支撑有一定门槛错误处理try/catch 直接包裹路径清晰链式/事件传递容易遗漏未捕获异常资源成本并发量高时线程数爆炸固定线程数即可支撑高并发典型场景强依赖流程、低并发脚本、管理后台小工具高并发 IO、浏览器 UI、批量接口调用这张表不是同步差、异步好的二元结论。比如代码可读性这一行对一个小团队开发的内部系统来说可读性带来的维护成本可能比性能收益更重要。我不能替你做决定但我可以给你一套决策方法。3.2 不同技术栈里的异步形态核心思想一致表现天差地别异步的思想跨语言通用但每个技术栈的表达方式完全不同挑主流的几个说JavaScript/Node.js基于事件循环技能点是 Promise、async/await、微任务队列。前端必须懂因为浏览器主线程不接受同步网络阻塞。Python有两条路线一是concurrent.futures.ThreadPoolExecutor实现线程池异步二是asyncio协程方案。爬虫、接口聚合用协程收益最明显。Java传统上是多线程 Future后来演进到CompletableFuture再后来是 Spring WebFlux 的响应式编程。JDK 21 引入的虚拟线程则在用轻量线程近似异步的收益。Go不强调同步/异步这个词而是用 goroutine 加 channel。goroutine 开销极小写起来像同步跑起来是并发的这是它设计上的高明之处。选技术栈时要明确一点异步是思想不是语法。你可以在任何语言里实现发起后不等待、事件到位再处理的模型只是表达方式各异。接触新语言时先去找它的事件循环或调度模型比背 API 有效得多。3.3 判断该用哪种的三步决策法我给团队定的决策流程就三步很土但很管用问并发这个接口/任务会被高并发调用吗如果峰值只有每分钟几百次同步加连接池完全扛得住没必要折腾异步。问依赖后面的逻辑强依赖这个请求的结果吗依赖就没办法简单改成异步——你总不能让查余额后转账的第二步在余额未知时就开始。异步前置条件是任务可以被分解成松耦合阶段。问等待请求本身慢吗比如导出报表、跨系统同步数据这种动辄好几秒的 IO同步模式下线程被白白占住异步就让等待成本降到最低。如果三个问题的答案都是否/低用同步只要有一个明显偏是异步就值得纳入设计。另外提醒一句不要一个人拍脑袋决定架构选型。团队里如果多数人不熟悉异步调试再好的性能收益也会被排错成本吃掉——这是我这些年见过最多的翻车模式。4. 重构实战把一段 6 秒的串行请求改到 2 秒理论讲再多不如看一次完整的重构过程。这是一个我经常在分享里用的例子既有代表性又足够简单大家自己也能复现。4.1 原始同步实现的痛点场景是这样的有一个内部服务需要根据一批用户 ID 去第三方平台拉取信息然后聚合返回。当时的代码如下import requests def fetch_user(uid): # 模拟一次耗时约2秒的第三方调用 resp requests.get(fhttps://api.thirdparty.example/user/{uid}, timeout5) return resp.json() def fetch_all(uid_list): results [] for uid in uid_list: data fetch_user(uid) results.append(data) return results # 三个用户串行执行 data fetch_all([1001, 1002, 1003]) print(data)这段代码逻辑没错但fetch_all里三个用户是一个一个等的第一个请求等2秒第二个等2秒第三个再等2秒总耗时约6秒。问题在于三个请求之间没有依赖关系完全是独立的接口调用串行纯粹是被同步模型硬排出来的。这种模式在真实业务里太常见了批量查询、多人信息聚合、多个上游接口的结果合并。同步实现清晰但低效而且一旦用户数从3个涨到30个总耗时直接从6秒涨到60秒完全不可接受。4.2 两种异步改造方案及其原理改造最简单的方案是线程池不需要换库改动量最小from concurrent.futures import ThreadPoolExecutor, as_completed def fetch_all(uid_list): results [] with ThreadPoolExecutor(max_workers10) as executor: futures {executor.submit(fetch_user, uid): uid for uid in uid_list} for future in as_completed(futures): results.append(future.result()) return results原理不复杂executor.submit()立刻返回一个 Future 对象三个请求被扔进线程池最多10个线程并发执行。总耗时从三个请求的和变成最慢的那个请求的耗时——约2秒。as_completed的作用是按完成顺序取结果先完成的先进入结果列表。另一条更正统的异步路线是asyncio aiohttpimport asyncio import aiohttp async def fetch_user(session, uid): async with session.get(fhttps://api.thirdparty.example/user/{uid}) as resp: return await resp.json() async def fetch_all(uid_list): async with aiohttp.ClientSession() as session: # 并发发起三个请求 tasks [fetch_user(session, uid) for uid in uid_list] # 等待全部完成 return await asyncio.gather(*tasks) uid_list [1001, 1002, 1003] # 运行事件循环 data asyncio.run(fetch_all(uid_list)) print(data)这里的关键是asyncio.gather(*tasks)它把多个协程汇聚成一个任务事件循环并发调度它们等所有协程完成后统一返回列表。aiohttp 在网络等待时会让出事件循环所以三个请求同时在途等待成本被充分摊薄。两个方案怎么选我的经验是改造量最小、代码路径最熟的就优先。如果团队本来就熟concurrent.futures就用线程池如果想彻底走上协程路线、对每次 IO 的调度粒度有更强掌控选 asyncio。性能上两者在这个场景差别不大真正的差别在后续扩展协程方案在高频请求下内存占用更低线程池在线程数量上仍有上限压力。4.3 改造后要立刻解决的问题异步改造不是把同步代码攒一下就完事有几个坑几乎必然出现第一个坑连接数突增导致下游被限流。同步时3个请求是一个一个来的改造后同时打过去3个。如果是30个用户并发就是30路——第三方平台未必扛得住。解决方式是加信号量限流import asyncio semaphore asyncio.Semaphore(5) # 最多5个并发 async def fetch_user(session, uid): async with semaphore: # 原有请求逻辑 pass信号量能精确控制并发上限。线程池方案则通过max_workers配合队列长度来控制。第二个坑异常处理的边界变了。同步代码里一个请求抛异常整个调用栈都是能捕获的。异步里asyncio.gather默认是只要有一个协程抛异常整个 gather 就抛异常——你需要用return_exceptionsTrue或对每个任务单独包裹异常处理保证一个失败不影响其他结果。第三个坑超时设置。同步的requests.get(timeout5)很好理解。异步里如果忘了超时一旦某个协程卡死gather会一直等下去。aiohttp 里要配置ClientTimeoutasyncio 里要善用asyncio.wait_for给任务包一层超时。改完我一般会做一轮对比验证记录改造前后同样输入数据的响应时间、CPU 占用、内存峰值并专门用超过预期并发 3 倍的数据压测一次确认限流机制真的生效。追求的不只是变快而是快得稳。5. 四个最常见的异步误解与对应的排查经验异步概念烧脑主要是因为它颠覆了自顶向下执行的直觉。下面这几个误解我几乎在每个团队里都见过每个都对应过真实的线上事故。5.1 异步就是多线程这是最普遍的误解。很多人以为异步请求就是开几个线程同时跑其实两者是不同维度的事。多线程是并行执行的手段同一时刻有多个线程在跑。异步是非阻塞等待的思想一个线程就能管理大量在途任务。最典型的证据就是 JavaScript 和 Python asyncio——它们都是单线程模型却能实现高并发。单线程异步的确能扛住上千并发靠的是事件循环和 IO 多路复用而不是线程堆叠。反过来多线程也不代表异步Java 里用new Thread()跑一个同步InputStream.read()那个线程照样阻塞。实际排查时如果混淆了这两者很容易在加大线程池和调整事件循环之间找错方向。比如 Node.js 服务卡顿增加线程配置反而可能加剧资源竞争Python 协程卡在整个进程不动问题大概率出在某个同步阻塞调用把事件循环饿死了。5.2 异步一定比同步快必须纠正一下对单次请求来说异步几乎没有性能优势有时还更慢。因为异步涉及事件循环调度、回调入队、上下文切换这些都是开销。异步的收益主要在两点并发场景下的资源利用率高同样资源能扛更多请求。等待期间的线程复用把阻塞时间转化为有效工作时间。如果任务是 CPU 密集型比如大量计算、图像处理、加密解密异步收益极小因为瓶颈在 CPU 计算而不是 IO 等待。这类任务更适合用多进程或专门的并行计算方案而不是异步 IO。判断依据很朴素你等的到底是网络/磁盘/数据库还是 CPU 本身。前者上异步立竿见影后者别费劲。5.3 传了回调函数就表示异步回调函数容易给人我已经异步了的错觉。其实回调只是一个函数参数它本身和同步/异步没有必然关系。看两个例子// 这是同步回调 const arr [1, 2, 3]; arr.forEach((item) { console.log(item); // 立刻执行forEach 不会等待任何东西 }); // 这是异步回调 setTimeout(() { console.log(2秒后执行); }, 2000); console.log(先执行这行);区分标准就一条调用是否立即返回回调是否延迟触发。forEach里的回调在forEach遍历过程中同步执行没有延迟setTimeout则立即返回回调被挂到事件循环的定时器阶段2秒后才执行。同样地addEventListener注册的事件处理函数、fetch().then()里的回调都是异步触发。所以谈异步时请先确认谁在等待、回调何时被触发而不是看到函数参数就叫异步。5.4 异步代码的错误用 try/catch 就能兜住这是排错阶段最烧心的一个。同步代码的 try/catch 对异步错误完全无能为力这是异步模型的结构性特征当错误发生时外层调用栈早就执行完了。回调时代错误的传递靠各回调的第一个参数Node.js 风格漏传或漏判错误就隐没。Promise 时代好一些但要记住Promise 链里抛出的异常必须由链尾或同链的.catch()接住。如果fetch(/api)返回的 Promise 后面既没有catch也没有await请求失败时你在外层写的 try/catch 什么也抓不到控制台只会出现一条UnhandledPromiseRejection警告。async/await 代码的 try/catch 是有效的但边界要清楚async function loadData() { // 这样是能捕获的 try { const data await fetch(/api/user).then(r r.json()); console.log(data); } catch (err) { console.error(请求失败, err); } }前提是你确实await了。如果你在一个 async 函数里调用另一个 async 函数却忘了await它返回的是 Promise这个 Promise 的 rejection 就不会被当前 try/catch 捕获——它飘在事件循环里直到进程警告甚至崩溃。排查这类问题我的经验是先打开所有unhandledrejection/process.on(unhandledRejection)的监听把漏网的错误全部打印出来再看 Promise 链上是否每个分支都有错误出口最后检查 async 函数之间是否都正确await。三步走完绝大多数神秘消失的错误都会现出原形。把这套东西吃完再回头看异步 vs 同步你会发现它从来不是一道谁替代谁的选择题而是一组围绕阻塞、等待、资源利用的权衡。我在实际项目里的体会是不要因为流行而异步也不要因为熟悉而同步而是问清楚三件事——并发量有多大、任务之间有无强依赖、团队能不能维护异步排错。我自己写业务代码时默认先上同步只有当明确的性能测不过、或者在浏览器主线程上感知到卡顿才动手改异步。这个习惯帮我避开了很多不必要的复杂度也希望它能帮到你。
网站建设高端定制企业官网
RELATED

相关资讯

更多精彩内容,欢迎继续阅读

较早相关资讯

最新相关资讯

微信小程序语音识别全链路实战:从录音到文字,PCM转换与讯飞接口对接 2026/10/2 3:30:54

微信小程序语音识别全链路实战:从录音到文字,PCM转换与讯飞接口对接

简介:这份资源面向微信小程序开发者,提供一套对接科大讯飞语音识别能力的完整集成方案,重点解决音频上传、语音提取、PCM格式转换与实时语音转文字等环节的落地问题,适合具备一定小程序开发基础、希望快速为应用添加语音交互功能的…

阅读更多 →
PyQt5与PyQtWebEngine版本兼容性排查指南 2026/10/2 3:30:53

PyQt5与PyQtWebEngine版本兼容性排查指南

1. 这不是“软件打不开”的简单故障,而是一场PyQt生态链的兼容性排查实战 Anaconda装完Spyder打不开——这句看似平平无奇的标题,背后藏着Python科学计算环境里最典型、也最容易被新手误判为“玄学”的一类问题。我从2016年开始带学生搭数据科学环境&am…

阅读更多 →
VSCode+ESP-IDF开发环境搭建避坑指南 2026/10/2 3:30:53

VSCode+ESP-IDF开发环境搭建避坑指南

1. 为什么选VSCode配ESP-IDF而不是Arduino IDE或PlatformIO? 我从2018年开始做ESP32项目,最早用Arduino IDE写温湿度节点,后来接米家Mesh要调底层Wi-Fi参数,Arduino那套封装直接卡死——连 esp_wifi_set_max_tx_power() 这种基…

阅读更多 →
从默认MySQL到按图索骥:一套可落地的数据库选型思考框架 2026/10/2 3:30:53

从默认MySQL到按图索骥:一套可落地的数据库选型思考框架

从"闭眼选MySQL"到"按图索骥":一套能落地的数据库选型思考框架这两年我参与了不少项目的技术评审,发现一个很普遍的现象:只要一说"上数据库",默认就是MySQL,再问为什么,回答…

阅读更多 →
有限元仿真软件全解析:主流工具盘点与选型避坑指南 2026/10/2 3:30:53

有限元仿真软件全解析:主流工具盘点与选型避坑指南

做机械、土木、汽车、航空这些方向的人,对“有限元仿真软件”这个词一定不陌生。不管是做毕业设计的学生,还是负责产品强度校核的工程师,手里基本都离不开这类工具。它解决的是一个非常现实的问题:一个零件、一台设备,…

阅读更多 →
mac协议与uuid算法组合:设备身份模拟与滑块轨迹生成实战 2026/10/2 3:30:47

mac协议与uuid算法组合:设备身份模拟与滑块轨迹生成实战

简介:这是一份聚焦MAC协议UUID算法、滑块算法及滑块环境算法的Go语言资源包,面向网络安全开发、爬虫逆向、自动化验证等方向的开发者,也适合对通讯识别与验证码抗自动化机制感兴趣的技术爱好者。内容围绕设备唯一标识生成、滑块轨迹模拟与环境…

阅读更多 →

今日资讯

本周资讯

本月资讯

看完文章仍有疑问?

联系尧图顾问,获取一对一建站咨询

立即免费咨询 📞 400-888-8888
📞 ✉