写这块内容时,我一直觉得很多程序员对“异步”和“多线程”的理解停留在“会用但说不清”的状态。面试被问到区别时,能答出“多线程是同时做多件事,异步是单线程也能并发”的人已经算不错了,但一旦追问“为什么异步能提高吞吐”“什么场景该用哪个”,不少人就开始含糊。
这篇文章我想把这两个概念彻底拆开来讲。不堆概念,从底层原理讲到真实场景,再配合代码实例和我在实际项目中踩过的坑,争取让看完的人能真正判断“我这个任务到底该开线程还是写异步”。
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、Pthread | async/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)本质上是用多线程的壳装异步的芯,写起来像同步,调度是用户态切换。 - Go:
goroutine严格说既不是传统线程也不是 Java 那类异步,它是语言层面的协程,由 runtime 调度,写起来像多线程,性能接近异步。和 Node 比,Go 的优势在于不用回调。 - Python:
asyncio和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(f"fetching {url}") time.sleep(1) print(f"done {url}") urls = [f"https://example.com/{i}" for i in range(5)] start = time.time() threads = [] for url in urls: t = threading.Thread(target=fetch, args=(url,)) t.start() threads.append(t) for t in threads: t.join() print(f"cost: {time.time() - start:.2f}s")示例里time.sleep(1)模拟 I/O 等待。5 个线程并发执行,总耗时约 1 秒而不是 5 秒。join()确保主线程等所有子线程干完再往下走。
异步版本长这样:
import asyncio async def fetch(url): print(f"fetching {url}") await asyncio.sleep(1) print(f"done {url}") async def main(): urls = [f"https://example.com/{i}" for i in range(5)] await asyncio.gather(*(fetch(url) for url in urls)) start = time.time() asyncio.run(main()) print(f"cost: {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 多,内存涨到令人血压升高的程度,响应还慢。后来改成aiohttp+asyncio,几千连接用一个线程池带过来,吞吐直接翻倍。
判断模型是否选对还有一个信号:观察大量线程是否长期处于 WAITING 状态。如果 80% 的线程都在等待 I/O,那就是典型 I/O 密集,异步会明显更优;如果线程都在 RUNNABLE 状态拼命跑,那说明是 CPU 密集,换异步大概率改善不大。
6. 我的一些实际经验与建议
文章写到这里,我想说说这几年折腾这两个模型的一些体会。第一点,不要为了“先进”而用异步。我用过不少团队把服务全改成异步框架,结果开发效率明显下降,排查问题成本提高,收益却不明显。异步框架的真正价值在高并发 I/O 场景,如果你的服务 QPS 不到几百,线程模型完全够用,没必要强行上复杂度。
第二点,团队协作时一定要统一风格。在同一个项目里,一会儿Thread,一会儿async/await,一会儿又有人用老回调,最后代码会变成四不像。建议团队内约定:I/O 操作一律走异步库,CPU 密集任务统一丢线程池,对外提供的方法都封装成异步接口。这样上层调用方不需要知道实现细节,代码风格也会一致很多。
第三点,异步编程在现代语言里越来越“隐形”。Java 的虚拟线程、Go 的 goroutine、JavaScript 的 async/await,都在把异步调度往语法层面收纳。未来程序员评判一个语言好不好用,可能不再看得懂看不懂异步,而是看语言和框架能不能把“阻塞”的问题在底层解决掉。但底层原理不会变:知道你的程序在等什么、谁在执行、谁在做调度,任何时候都是排查问题的关键。
最后分享一个小建议:如果你是第一次学这两块内容,别只背概念。自己写一个简单的文件批量下载的工具,先写多线程版,再写异步版,然后用压测工具看两种模式下的线程数、内存、延迟和吞吐。实践数据比任何理论都更能帮助你形成直觉,等遇到线上性能问题时,你才会真正理解今天聊的这些。