
写这块内容时我一直觉得很多程序员对“异步”和“多线程”的理解停留在“会用但说不清”的状态。面试被问到区别时能答出“多线程是同时做多件事异步是单线程也能并发”的人已经算不错了但一旦追问“为什么异步能提高吞吐”“什么场景该用哪个”不少人就开始含糊。这篇文章我想把这两个概念彻底拆开来讲。不堆概念从底层原理讲到真实场景再配合代码实例和我在实际项目中踩过的坑争取让看完的人能真正判断“我这个任务到底该开线程还是写异步”。1. 先搞清楚概念线程、异步、阻塞与并发1.1 进程和线程的关系很多人在讨论多线程时其实没搞明白线程到底是个什么东西。打个比方进程像是“一家公司”公司有自己的办公楼内存空间、营业执照进程ID、规章制度代码段。线程则是这家公司里的“员工”每个员工可以独立干活但共用公司的办公场所和公共资源。一个进程至少有一个线程也就是主线程。如果你在代码里new Thread()或者threading.Thread()相当于公司里又多招了一个员工。操作系统负责调度这些员工谁先干活、干多久切换过程中会保存现场寄存器状态、栈指针等这些操作叫上下文切换。1.2 并发和并行是两码事这是最容易混淆的一对概念。并发Concurrency是指“同时处理多件事”注意这里不强调“同时执行”它可以是在一个 CPU 核心上快速地来回切换让多个任务都有推进。并行Parallelism才是真正的“同一时刻有多条指令在多个核心上同时执行”。一个很经典的类比并发是你一个人同时照看三锅菜一会儿炒这个、一会儿翻那个并行是你雇了三个厨师一人管一锅真正的同时在炒。多线程能不能实现并行取决于有没有多核 CPU。单核时代的线程切换其实是假并行本质上还是并发。异步编程则一开始就建立在并发这个思路上——它并不追求让多个线程在多个核心上跑而是让一个线程把等待的时间利用起来去干别的。1.3 阻塞与非阻塞阻塞容易理解你调用一个函数它不返回程序就卡在那里等结果。比如传统的socket.accept()、InputStream.read()在没有数据到达时线程会进入睡眠状态什么活都干不了。非阻塞就是调用不会让你等函数立即返回“数据还没准备好”的状态你过一阵再来问。异步编程常和非阻塞配合使用但也有阻塞式异步比如某些老式回调框架不能划等号。记住了这个基础我们再来看多线程和异步各自解决问题的思路就会发现它们走的是完全不同的设计哲学。2. 多线程与异步的核心哲学差异2.1 “加人干”与“一个人干”多线程的思路非常朴素任务多那我就多创建线程让操作系统帮我去并行处理。它的本质是“增加执行单元”靠的是操作系统对线程的调度能力。多线程下的并发单位是“线程”每个线程有自己独立的调用栈和寄存器上下文由内核来调度。异步的思路是我不管多少个任务我只用一个或少量线程但我不让这个线程闲下来。某个任务在等 I/O 时线程立刻切换到另一个不等的任务上去执行。它的本质是“在单个线程内重新安排执行顺序”把等待的时间压缩到接近零。可以这样理解多线程是“增加服务员”一个客人就派一个服务员去服务服务员等后厨做菜的时候就站在窗口干等。异步是“一个服务员同时服务多桌客人”下单后不干等先去给另一桌送水一会儿再回来取菜。2.2 资源开销的差异为什么线程切换很贵线程不是免费的午餐。每创建一个线程操作系统就要给它分配独立的栈空间Java 默认栈大小通常是 512KB 到 1MB还要注册到内核调度器里。线程一多光内存就吃掉不少。更麻烦的是上下文切换的代价。当内核把 CPU 从一个线程切换到另一个线程时需要保存当前线程的寄存器、程序计数器、栈指针等状态再把下一个线程的状态装载进来。这个过程是纯 CPU 开销并且会污染缓存Cache导致流水线中断。根据经验数据上下文切换一次大概消耗几微秒到几十微秒虽然听起来不多但高并发下每秒发生几千上万次切换累计开销就是性能杀手。异步在这里占了大便宜它没有线程切换只有一个线程在用户态自己决定下一步执行哪个任务。这个“任务切换”不是内核做的而是程序自己通过状态机或者协程实现的代价可能只是保存几个局部变量和一个程序计数器比内核级切换便宜一到两个数量级。2.3 数据竞争的博弈锁与共享状态多线程最大的痛点在于共享数据的竞争。既然多个线程在跑就可能在同一个时刻读写同一个变量线程 A 读到一半线程 B 改了那 A 拿到的可能是个“半新半旧”的垃圾值。解决方式靠锁synchronized、lock、原子类、CAS 操作等手段。锁又引出死锁、活锁、锁竞争、优先级反转等问题。异步编程因为默认在单线程里跑根本没有多线程同时访问同一块内存的问题也就不需要加锁。这是异步在开发体验上最大的优势也是很多人选择它的关键原因。但注意如果你的异步模型里有多个工作线程比如 Java 的虚拟线程或者 Go 的 runtime该竞争还是竞争只是粒度变了而已。2.4 一张表看透区别对比维度多线程编程异步编程并发单位线程内核级任务/协程用户态实现原理多执行单元并行/并发单线程内事件循环切换资源开销线程栈 内核上下文切换任务状态保存开销小数据共享需要锁和同步机制单线程内天然免锁适用场景CPU 密集型I/O 密集型对多核利用可充分利用多核单线程难利用多核调试难度死锁、竞态难以复现回调地狱、调试时调用栈丢失代表模型Java Thread、Pthreadasync/await、NIO、libuv、协程这个表可以根据自己的场景去补充但核心差异就这几条。记住这张表之后再往下看“具体怎么选”思路就会清晰很多。3. 真实项目里怎么选先看任务类型再看语言生态3.1 CPU 密集型任务优先多线程/多进程如果你的任务是纯计算比如图像处理、视频编码、矩阵运算、哈希碰撞这些计算过程中基本不碰网络、不读文件线程一旦拿到 CPU 就拼命算此时用异步没有任何优势——因为异步之所以快是因为它能“跳到别的任务上去”而这里的每个任务都不会让出 CPU。这种情况反而应该用多线程甚至多进程。假设你有一台 8 核机器跑一份解析 PDF 的代码需要 10 秒开了 8 个线程分别处理 8 份 PDF理论上就能接近 10 秒内全部完成。这就是“并行”的威力。我自己的习惯是Python 里 CPU 密集型会优先用multiprocessing而不是threading因为 CPython 的全局解释器锁GIL会让多线程在 CPU 密集任务下反而变慢。用多进程可以绕开 GIL但进程间通信成本高。Java 或者 Go 没有这类问题Thread/goroutine直接上。3.2 I/O 密集型任务异步的绝对主场现在的后端服务基本都是 I/O 密集请求进来 → 查数据库 → 调远程 API → 写缓存 → 返回。大部分时间线程都泡在网络上等待响应CPU 反而闲得发慌。一个典型的场景你的服务需要调用三个下游 HTTP 接口串行执行需要 300ms。如果用多线程三个线程并发调总耗时还是 300ms如果用异步在一个线程内同时发起三个请求总耗时同样是 300ms。看起来效果一样但异步方式只需要 1 个线程就扛住了多线程要 3 个。如果你的 QPS 是 10000线程数差距就是 10000 和 30000 的区别内存和切换开销完全不是一个量级。这也是 Node.js 能凭借单线程异步模型支撑高并发 I/O 的原因。Java 传统的 Blocking I/O 多线程在连接数上来后线程吃紧后来 Spring WebFlux 走异步路线本质上都是殊途同归。3.3 混合型任务怎么处理现实中哪有那么纯粹的任务。一个 Web 请求里既有参数校验的 CPU 计算又有查库的 I/O 等待怎么办我的推荐是分层处理I/O 部分用异步框架CPU 密集的子任务放进线程池隔离执行。不要试图用一个模式套住所有场景也不要“一把梭”全异步。比如在 Netty 项目里业务处理线程和 I/O 事件循环线程是分离的在 Node.js 中CPU 密集任务会主动丢到worker_threads里主线程继续跑异步。这种“异步为主、多线程辅佐”的架构在我看来是大型后台系统比较务实的形态。3.4 语言生态决定了推荐路径你在实际工程里能怎么选很大程度取决于用的什么语言。Java传统上以多线程闻名ThreadPoolExecutor和synchronized用得最多后来引入了CompletableFuture和响应式编程但心智负担不小。JDK 21 的虚拟线程Virtual Threads本质上是用多线程的壳装异步的芯写起来像同步调度是用户态切换。Gogoroutine严格说既不是传统线程也不是 Java 那类异步它是语言层面的协程由 runtime 调度写起来像多线程性能接近异步。和 Node 比Go 的优势在于不用回调。Pythonasyncio和threading经常被对比但要注意 GIL 让多线程在 CPU 密集任务里基本没用I/O 密集可以用多线程假并发也可以用 asyncio。aiohttp、Motor这类库的存在让 Python 的异步生态越来越完整。Node.js / JavaScript异步是默认模型回调、Promise、async/await 一路演化过来。Node 的多线程靠worker_threads补充适用于少量 CPU 密集任务。选择时别只看技术优劣还要看团队熟悉度。一个团队天天写同步代码你非上全异步重构光理解成本就能把收益吃掉。4. 代码走读同一个需求两种写法差在哪4.1 Python 下的多线程与 asyncio 对比我们来模拟一个真实需求下载 5 个网页每个耗时 1 秒网络等待不加任何异常处理看两种写法的差异。多线程版本很容易写出import threading import time def fetch(url): print(ffetching {url}) time.sleep(1) print(fdone {url}) urls [fhttps://example.com/{i} for i in range(5)] start time.time() threads [] for url in urls: t threading.Thread(targetfetch, args(url,)) t.start() threads.append(t) for t in threads: t.join() print(fcost: {time.time() - start:.2f}s)示例里time.sleep(1)模拟 I/O 等待。5 个线程并发执行总耗时约 1 秒而不是 5 秒。join()确保主线程等所有子线程干完再往下走。异步版本长这样import asyncio async def fetch(url): print(ffetching {url}) await asyncio.sleep(1) print(fdone {url}) async def main(): urls [fhttps://example.com/{i} for i in range(5)] await asyncio.gather(*(fetch(url) for url in urls)) start time.time() asyncio.run(main()) print(fcost: {time.time() - start:.2f}s)两者耗时差不多但两者底层完全不一样多线程是 5 个线程各自睡觉异步是 1 个事件循环内部轮询“哪个任务时间到了”到点就唤醒对应协程。线程版本每创建一个线程都有内核开销异步版本几乎没有。注意示例用asyncio.sleep模拟的是协程主动让出await它不是真的睡眠而是“把控制权交还给事件循环”。真正的阻塞操作必须用异步库改写比如requests在这段异步代码里跑会让整个事件循环卡住。4.2 回调地狱到 async/await 的演变异步编程一开始不是这么好写的JavaScript 早期的回调风格大家应该耳熟能详request(url1, function(res1) { request(url2, function(res2) { request(url3, function(res3) { console.log(res3); }); }); });这种嵌套如果超过三层逻辑就基本没法维护了。Promise 出现了链式调用比嵌套舒服一些request(url1) .then(res1 request(url2)) .then(res2 request(url3)) .then(res3 console.log(res3)) .catch(err console.error(err));到async/await之后形式上又回到了同步代码的感觉async function main() { const res1 await request(url1); const res2 await request(url2); const res3 await request(url3); console.log(res3); }但要注意await的串行写法在“必须前一步依赖后一步”时才正确。上面这个例子其实三个请求互相不依赖写成await Promise.all([...])才能并发。这个区别是我在 Code Review 时最常见到的问题——看起来代码更优雅了但性能比回调版本还差因为把并行变成串行了。4.3 不能忽略的“看起来像但是错”陷阱关于这两个模型我总结了四个特别容易踩的坑第一个在异步代码里直接跑同步阻塞调用。Python 的asyncio里调用requests.get()或者 Node 里调用fs.readFileSync()都会把整个事件循环堵死。正确的做法是用异步库或者把阻塞操作丢到线程池里跑。第二个异步不需要锁但如果你混用了线程池还是得小心。asyncio的事件循环单线程但run_in_executor会引入真正的线程回调里如果操作共享状态该加锁还是得加。第三个线程安全问题不能因为用了异步就完全不管。你在异步任务里如果用了一个全局列表两个协程之间没有真正的并行因为只有一个线程不太会有问题但你在多线程模型里操作同一个HashMap没有同步绝对是高概率事故。很多从异步框架转过来的同学容易低估多线程的耦合风险。第四个异步调试时堆栈丢失。无论 Python 还是 Node报错时看到的堆栈往往不是发起任务时候的完整调用链而是触发回调函数时的堆栈。新人遇到这种问题会很懵老手的经验是在 async 函数的入口处打日志带上 requestId错误信息里补上下文不然定位问题能愁死你。5. 常见问题与排查技巧实录5.1 多线程上锁怎么又死锁了一个基础的死锁例子线程 A 先锁 resource1 再锁 resource2线程 B 先锁 resource2 再锁 resource1。两个线程各持有一把钥匙等对方的那把程序就卡死在那里。排查方法先jstack或gdb抓线程栈看看线程各自在哪个锁上等待。修复的核心是锁顺序一致所有线程都按相同顺序拿锁死锁就能在逻辑上从根本上杜绝。另外能用tryLock加超时就不用无限期lock超时后可以检查失败然后释放已有锁避免一直挂着。5.2 异步程序的并发数上不去怎么回事写过异步服务的人应该都见过这个困惑明明是异步性能怎么跟同步差不多大概率是某处把异步调用写成了同步等待——asyncio.run()套在另一个事件循环里、await放在for循环里没并行、requests这种阻塞库直接出现在 handler 里等等。排查时先看 CPU 利用率和线程状态。如果大量时间花在等待而不是运行多半是阻塞了如果某个锁上有大量等待者多半是共享资源的竞争太激烈。在 Node.js 里可以用clinic/0x这类工具生成火焰图一是看热点函数二是看事件循环的平均延迟和阻塞时间。Python 里可以用asyncio的调试模式事件循环卡顿超过阈值会给出警告。5.3 为什么“开了很多线程”程序反而变慢了这说明线程数已经远超 CPU 核心数大量时间被消耗在调度和上下文切换上。多线程有个经验公式I/O 密集项目里线程池大小可以参考线程数 CPU 核数 × (1 平均等待时间 / 平均计算时间)如果这个值算出来是 200你手动开 10000 个线程系统光调度就忙不过来了。实际工程里线程池队列长度和拒绝策略也要一起设计不然任务堆积会造成服务雪崩。5.4 选错模型的信号与止损方法前段时间我接手过一个线上服务用 Pythonthreading做了高并发 HTTP 转发压测时线程数飙到 3000 多内存涨到令人血压升高的程度响应还慢。后来改成aiohttpasyncio几千连接用一个线程池带过来吞吐直接翻倍。判断模型是否选对还有一个信号观察大量线程是否长期处于 WAITING 状态。如果 80% 的线程都在等待 I/O那就是典型 I/O 密集异步会明显更优如果线程都在 RUNNABLE 状态拼命跑那说明是 CPU 密集换异步大概率改善不大。6. 我的一些实际经验与建议文章写到这里我想说说这几年折腾这两个模型的一些体会。第一点不要为了“先进”而用异步。我用过不少团队把服务全改成异步框架结果开发效率明显下降排查问题成本提高收益却不明显。异步框架的真正价值在高并发 I/O 场景如果你的服务 QPS 不到几百线程模型完全够用没必要强行上复杂度。第二点团队协作时一定要统一风格。在同一个项目里一会儿Thread一会儿async/await一会儿又有人用老回调最后代码会变成四不像。建议团队内约定I/O 操作一律走异步库CPU 密集任务统一丢线程池对外提供的方法都封装成异步接口。这样上层调用方不需要知道实现细节代码风格也会一致很多。第三点异步编程在现代语言里越来越“隐形”。Java 的虚拟线程、Go 的 goroutine、JavaScript 的 async/await都在把异步调度往语法层面收纳。未来程序员评判一个语言好不好用可能不再看得懂看不懂异步而是看语言和框架能不能把“阻塞”的问题在底层解决掉。但底层原理不会变知道你的程序在等什么、谁在执行、谁在做调度任何时候都是排查问题的关键。最后分享一个小建议如果你是第一次学这两块内容别只背概念。自己写一个简单的文件批量下载的工具先写多线程版再写异步版然后用压测工具看两种模式下的线程数、内存、延迟和吞吐。实践数据比任何理论都更能帮助你形成直觉等遇到线上性能问题时你才会真正理解今天聊的这些。