1. 从一次“卡死”的爬虫说起:为什么我不得不重新理解并发
先讲一个我自己的真实经历。两年前我写了一个批量抓取商品数据的工具,功能很简单:拿着几千个商品ID去请求详情接口,然后把返回的JSON落库。最早我按最“直白”的写法——一次性建一个线程池,20个线程并发跑。结果跑起来之后问题不断:数据库连接池被打满、目标服务频繁返回限流错误、程序偶尔还会因为线程安全问题在保存数据时丢字段。最难受的是,本地一跑就是半个多小时,大部分时间CPU占用却低得可怜,一看就是在傻等网络响应。
后来我换了协程的方案,同样的数据量,两三分钟跑完,CPU占用依然不高,但程序不再“堵车”了,也没有线程池那套加锁、放锁的烦恼。从那以后我就意识到一件事:很多写了三五年代码的人,对“进程/线程”概念滚瓜烂熟,但问到“协程到底是个什么东西、为什么能比线程还轻、跟异步到底什么关系”,往往是答不上来的。这很正常,因为协程不是一个孤立的概念,它牵涉到操作系统调度、函数调用栈、编译器的代码生成、语言层的语法糖,甚至还有I/O模型的演变。
这篇东西我不想写成教科书,而是想把自己从“知道这个词”到“真正理解并能上手用”的过程梳理一遍。内容会覆盖协程的底层原理、Python协程与C++协程两种常见实现形态、它们各自的适用场景、以及我在实际项目中踩过的坑。适合谁看?如果你对协程的理解停留在“async/await这种写法”或者“用协程能提升并发”这种模糊印象,想搞清楚它背后到底发生了什么,这篇文章应该能帮你把拼图补完。
2. 协程到底解决了什么问题:从阻塞、线程切换到回调地狱
要理解协程,必须先理解一个最基础的矛盾:我们的程序经常需要“等待”。等网络包、等磁盘扇区、等用户输入。在等待的时候,CPU本身是闲着没事干的。
2.1 阻塞式编程为什么在重IO场景下“拉胯”
传统同步代码是这样:请求发出去了,线程在那儿一直等,等到响应回来才继续执行下一行。单个请求没问题,但如果你要同时处理一万个连接,就得有一万条执行流去等。如果用线程去承载这些等待,代价非常高——创建一个线程动辄几十微秒,而且线程默认栈空间往往以MB计,一万个线程光是内存就十几个GB了,这还不算大量线程同时切换造成的CPU开销。操作系统在每条线程之间切换,涉及内核态切入切出、寄存器状态保存、缓存失效,开销都在微秒级别,看起来很“快”,但当切换频率达到每秒几十万次时,这个成本就很可观了。
所以对于高并发IO场景,传统“一个连接一个线程”的思路在国外有个更早的演进叫C10K问题——如何用单台机器支撑一万个并发连接?最初大家靠多进程/多线程堆硬件扛,后来发现纯靠堆线程是堆不动的。于是出了事件驱动模型:用一个或几个线程,通过非阻塞I/O去监听所有连接的事件,谁有数据谁就被处理,没数据就回到事件循环里继续等其他事件。Node.js就是个典型代表。
但事件驱动有几个让人头疼的问题。第一是编程模型的割裂:你写代码的方式和大脑思考的方式不一致。明明是一套连续的逻辑,为了不阻塞事件循环,只能把后半段拆进回调函数里。第二是错误处理极其被动:一旦回调里有异常,你经常不知道该往上抛给谁。第三是业务逻辑一旦复杂起来,回调一层套一层,代码的阅读顺序和实际执行顺序完全不是一回事,调试起来非常费劲。
2.2 协程的出现:让代码“看起来像同步,跑起来像异步”
协程想做的事情很简单:在某个执行点上停下来,保留现场,把一个长期运行的任务挂起来,然后去干别的;等那个任务的结果到了,再从这个执行点接着往下走。这个“停下来再回来”的动作,不需要操作系统参与调度,而是由程序自己控制,所以它比线程切换便宜得多。而它最妙的地方在于,写出来的代码逻辑是线性的、顺序的——请求发出后“停”一下,响应到了“回”来接着执行,从代码上看就跟你写同步请求一样自然,不需要拆成一堆回调。
换句话说,协程是“函数级别的挂起与恢复”。普通函数进去之后要么跑完要么报错,一次性到底;协程函数可以在一半的位置主动让出控制权,保存当前所有局部变量和执行位置,过一会儿再回来,从让出的那行接着跑。打个比方:普通函数像一趟直达列车,中间不停站;协程像一趟公交车,可以随时靠站让位,也可以从靠站的点继续开。
3. 底层机制拆解:挂起/恢复、状态机与“栈”的问题
光说“挂起和恢复”还是太抽象。这一节我带你看一眼协程在计算机里到底是怎么落地成指令和内存结构的。
3.1 协程挂起时到底保存了什么
一个函数在执行时,它眼里的世界无非三样东西:局部变量、程序计数器(当前执行到哪条指令)和调用链(从哪一路进来的)。普通函数在返回后这些东西就销毁了。协程在挂起的时候,首先要保住的是局部变量的值——下一次恢复时这些变量还得是谁就是谁;其次要保住执行位置——恢复后从哪一行哪条指令继续;最后要保住执行链路的上下文——比如异常往上抛的时候应该抛给谁。
这里最关键的技术分水岭出现了:你用什么数据结构保存这些状态?两种主流方案:
- 有栈协程(stackful coroutine):给协程分配一块独立的栈空间,挂起时把CPU寄存器和栈指针完整保存,恢复时再载入。上下文切换只需要保存/恢复少量寄存器,通常是几十到几百纳秒级别的成本。优点是程序员无感,你可以在协程函数里任意调用别的函数,只要下层的函数也配合挂起就行;缺点是要自己管理栈内存,栈空间设多大是个经验活,而且会引入“栈溢出”这种新问题。
- 无栈协程(stackless coroutine):不分配独立栈,编译期就把协程函数整个改造成一个状态机——每个挂起点对应状态机的一个状态,局部变量被提升到协程对象内部存起来,执行位置就是一个整型状态值。挂起时保存的只是一个对象,恢复时根据状态值跳到对应分支继续执行。优点是内存极其轻量,没有栈空间开销;缺点是“无栈”意味着你只能在协程函数本体内挂起,不能在普通函数里嵌套挂起。
正好Python和C++20的协程都是无栈协程。这也是很多人学协程时最难转过弯的地方:为什么你在普通函数里不能直接await?因为普通函数不是编译器改造过的状态机,它没有那个“中途保存局部变量再恢复”的能力,你得把它也声明成协程函数。一旦你在async函数里调用一个不带async的普通辅助函数,就不能await它——这在Python里几乎是每个新手必踩的坑,在后面专门说。
3.2 从编译器角度看Python的async/await
Python的协程核心是一个内部的对象结构,比如coroutine对象,它持有一个状态字段和局部变量字典。你调用一个async def函数时,函数体并不会马上执行,而是返回一个协程对象。只有你把这个对象交给事件循环,循环才会驱动它运行。
await在字节码层面做了什么?可以粗略理解为:把当前协程对象标记为挂起状态,把“等待目标(Future/Task)”登记到事件循环里,然后直接return控制权,从事件循环里退出去。事件循环在别的协程里继续转,等到那个Future有结果了,事件循环再把原协程对象推入可执行队列,重新调用它的send()方法,让它接着往下跑。整个过程中,函数真正的调用栈没有变深,只是同一个函数多次进出而已。
3.3 事件循环和协程是怎么配合的
如果没有事件循环,无栈协程的“挂起”就只是挂给自己看——没人告诉你什么时候该恢复。事件循环就是那个总调度员:它内部持有一个任务队列和一个I/O事件监听器(比如Linux下是epoll或io_uring,macOS下是kqueue,Windows下是IOCP)。一个协程里发出异步请求后,事件循环把它挂起,接着换另一个协程跑;一旦I/O事件就绪,事件循环找到之前登记的Future,把结果设置进去,再把对应的协程放回可执行队列。
所以真正的“并发”不是在单个协程内部发生的,而是多个协程在事件循环的调度下轮流执行,谁有事件谁跑,没事的人自动让位。整个过程都发生在单个线程内,因此不存在多线程的数据竞争——没有锁的需求,这也是协程方案为什么在多路IO时如此受欢迎的原因之一。
4. Python协程实战:从同步代码到asyncio的迁移路径
我给自己定了个原则:凡是装进博客里的代码,我一定先在自己机器上跑过。下面这些案例是我做迁移时最常做的事情,给你完整贴出来。
4.1 一段典型的同步代码长什么样
假设我们有一个消费消息的批处理模块,要从某个接口拉取一批订单数据,然后逐个做本地加工和落库。最开始是同步写法:
# 同步版本:先拉一批,再逐条处理 import time import requests def fetch_data_batch(batch_id): resp = requests.get(f"https://example.com/api/orders?batch={batch_id}", timeout=10) return resp.json()["data"] def process_one(order): # 模拟本地加工程:读取配置、格式化字段、写日志 time.sleep(0.1) return {"order_no": order["order_no"], "status": "processed"} start = time.perf_counter() total = 0 for batch_id in range(1, 101): orders = fetch_data_batch(batch_id) for order in orders: processed = process_one(order) total += 1 print(f"total={total}, elapsed={time.perf_counter() - start:.2f}s")跑完差不多要100多秒,因为100个接口请求全串行,每个请求就算只要300毫秒,加起来就得30秒,再加上处理逻辑和网络波动,时间根本压不下来。
4.2 改造成协程版:阻塞调用必须替换
改成协程版不是把requests换成异步库就算完,而是要把每一层的“等”都变成“可挂起”。我的改造步骤一般是四步:
第一步,把所有网络I/O调用替换成异步版本。requests是同步阻塞的,换成aiohttp。第二步,把耗时的本地逻辑里那些time.sleep换成asyncio.sleep。注意,time.sleep会真的阻塞线程,事件循环在睡眠期间一个事件都处理不了;而asyncio.sleep是注册一个定时器后挂起当前协程,让出控制权,循环还能跑别的。第三步,把批量循环改成协程任务列表,用asyncio.gather一起调度。第四步,外层入口用asyncio.run启动整个事件循环。
改造后长这样:
import asyncio import aiohttp import time async def fetch_data_batch(session, batch_id): async with session.get(f"https://example.com/api/orders?batch={batch_id}") as resp: data = await resp.json() return data["data"] async def process_one(order): await asyncio.sleep(0.1) # 模拟异步加工 return {"order_no": order["order_no"], "status": "processed"} async def main(): connector = aiohttp.TCPConnector(limit=20) async with aiohttp.ClientSession(connector=connector) as session: tasks = [] for batch_id in range(1, 101): tasks.append(process_batch(session, batch_id)) await asyncio.gather(*tasks) async def process_batch(session, batch_id): orders = await fetch_data_batch(session, batch_id) results = [] for order in orders: results.append(await process_one(order)) return results if __name__ == "__main__": start = time.perf_counter() asyncio.run(main()) print(f"elapsed={time.perf_counter() - start:.2f}s")我这台普通笔记本实测,同步版本约98秒,协程版本约6秒,差距来自所有网络请求被交错执行。整个执行过程中,主线程CPU占用基本维持在5%到15%,证明瓶颈是I/O等待而不是计算。
4.3 迁移过程中的三个认知拐点
刚开始写协程代码的人,最容易掉进三个坑:
第一个坑是“异步函数里不能混用同步阻塞调用”。哪怕你在协程里只调了一遍requests.get,整个事件循环都会被卡住,因为循环还在单线程里跑,而阻塞调用会占住线程不让它返回。排查方法很简单:给事件循环开个日志,看执行耗时是否突然飙升。原则就是协程世界里,所有可能从毫秒级以上等待的操作都要有异步替代品,包括time.sleep、requests、subprocess.run、os.read这种看上去“没什么问题”的调用。
第二个坑是“在所有async函数外调用事件循环方法导致的报错”。3.8之前的写法里,很多人习惯了loop = asyncio.get_event_loop(),但现代Python版本推荐直接用asyncio.run(),它会负责创建事件循环、执行协程、清理资源,你不需要也不应该反复创建和关闭循环。如果你在Jupyter这类已存在循环的环境里跑,记得用await表达式或nest_asyncio处理,别自己瞎开新循环。
第三个坑是“并发量没上限导致外发请求过多”。一开始我把所有batch都直接丢进gather,结果200个并发请求把上游服务打到429。后来引入了asyncio.Semaphore控制并发上限,并调节TCPConnector(limit=...)的IO连接数上限,才稳定下来。这跟线程池里的“最大线程数”是一个道理,并不是并发越高越好,而是要贴合下游的承载能力。
5. C++20协程的实现路径:和Python完全不同的那套语感
如果你以为C++的协程像Python那样写个async def就完事了,那你会被现实狠狠教训。C++20的标准协程从语言层面提供的是一套极其底层的机制,语法看着简单,但概念量非常大。
5.1 三个你必须先认识的术语
C++协程标准里,核心是三个东西:
coroutine handle:协程句柄,用来控制协程的挂起、恢复和销毁,本质上是一个指针操作,危险但高效。promise_type:虽然名字叫promise,但它和并发里的“承诺”关系不大,它更像是协程的“状态容器”——协程返回什么结果、协程抛出的异常、以及协程在挂起点时要做什么,都由这个类型描述。编译器会悄悄在协程对象内部嵌入一个promise对象。awaiter:一个用户自定义类型,承载await表达式最关键的三次回调:判断是否需要挂起(await_ready)、挂起时干什么(await_suspend)、恢复时干什么(await_resume)。
5.2 一个能跑的简单示例
来一个直观的例子。下面这个代码定义了一个简单的可等待对象CounterAwaiter,每次被co_await时输出日志,并决定是否挂起:
#include <coroutine> #include <iostream> #include <optional> struct CounterAwaiter { int limit; int current = 0; bool await_ready() const noexcept { return current >= limit; } void await_suspend(std::coroutine_handle<> h) { std::cout << "[suspend] current=" << current << std::endl; } int await_resume() noexcept { return current++; } }; struct CounterPromise { std::optional<int> min_value; std::suspend_always initial_suspend() noexcept { return {}; } // 初始挂起 std::suspend_always final_suspend() noexcept { return {}; } // 最终挂起 void return_value(int v) { min_value = v; } void unhandled_exception() { std::terminate(); } std::coroutine_handle<CounterPromise> get_return_object() noexcept { return std::coroutine_handle<CounterPromise>::from_promise(*this); } }; struct CounterTask { using promise_type = CounterPromise; std::coroutine_handle<CounterPromise> handle; explicit CounterTask(std::coroutine_handle<CounterPromise> h) : handle(h) {} ~CounterTask() { if (handle) handle.destroy(); } void start() { handle.resume(); } }; CounterTask counterTask() { int sum = 0; for (int value = 0; value < 3; value++) { sum += co_await CounterAwaiter{ /* limit = */ 2, /* current = */ 0 }; } co_return sum; } int main() { auto task = counterTask(); task.start(); }这段代码的核心逻辑是:协程函数counterTask通过co_await CounterAwaiter在循环里挂起三次,每次恢复后await_resume返回值+1,最终co_return把和值交给promise。如果在真实业务里,那个CounterAwaiter就会换成真正的I/O操作:发出异步请求,挂起,事件循环里等I/O完成,再恢复。
5.3 C++协程的“无栈”真相与编译产物
C++20的协程是无栈协程,编译器会把协程函数体转换成一个基于状态的机器。具体来说,每个co_await位置就是状态机里的一个转移点,函数内所有局部变量被提升到堆或栈上的一块连续内存里(实际语言标准称为coroutine frame),用当前状态值记录执行到哪个点。挂起时只需要保存状态值和frame中的数据;恢复时根据状态值跳回对应的代码段。
这个设计带来一个直接后果:因为不是独立栈,你不能在协程函数里调用另一个“非协程函数”并期望通过那个函数来挂起。可挂起的调用必须也写成协程,或者把awaiter对象显式传下去。这是C++协程和线程最根本的区别,也是不少人学习时最难以跨越的坎。
5.4 C++协程性能到底怎么样
我在整理这个主题时顺手拉了一份数据。以x86-64 Linux系统为例,一个不涉及系统调用的纯用户态协程挂起恢复,时间通常比线程切换或系统调用要低一到两个数量级:线程上下文切换在个位数微秒量级,无栈协程的一次挂起/恢复往往只有个位数纳秒到几十纳秒。当然,这是理想状态下,实际情况受到CPU调度、缓存命中率和内存分配等因素影响。下面这个对照表可以看个大概:
| 对比维度 | 线程 | C++20协程 |
|---|---|---|
| 创建/切换开销 | 微秒级以上 | 纳秒级 |
| 每个并发单位内存 | 一般MB级(栈空间) | 可控制在几百字节到几KB |
| 调度者 | 操作系统内核 | 用户态代码 |
| 移入多核扩展 | 天然支持 | 需要用户配合调度器 |
| 挂起点 | 内核抢占或系统调用 | 显式co_await |
| 编程难度 | 中(要关注锁) | 高(生命周期和资源管理) |
从这个表能看出,C++协程的定位不是替代线程,而是在“保持极低开销”的前提下提供精细化的并发控制。
6. 选型时的决策框架与实操避坑记录
最后我想把这段时间实践的结论收敛一下:你到底该不该用协程?以及哪些坑是我亲眼见过、手写过的。
6.1 什么时候用协程、什么时候老老实实用线程
我自己的判断标准比较简单:
- 符合“大量I/O等待 + 高并发连接 + 单机资源有限”的场景,优先协程。比如网关、消息消费模块、数据抓取、高并发RPC客户端。
- 计算密集型场景,协同程并没有本质帮助。
await再便宜也改变不了CPU轮转需求,这种场景该上多线程/多进程,配合队列做负载分担。 - 写一次性脚本或工具,除非只是速跑批量请求,否则还是用同步吧。协程带来的心智负担在简单工具场景里是负收益。
- 团队维护能力是硬指标。我见过很多团队因为“赶时髦”把原有的并发模型全面换成协程,结果新人不熟悉,改坏了几处挂起逻辑。技术选型不能脱离团队能力单独做决定。
为了帮你快速对照,这里有个我自己的判断清单:
是否以IO等待为主? -> 是,才有必要考虑协程 是否有高并发连接需求? -> 是,协程优势更明显 是否有大量CPU计算? -> 是,别只依赖协程 团队是否理解挂起语义? -> 否,建议先做培训和试点6.2 Python协程避坑清单(按权重排序)
- 不要在async函数里调
requests等同步库。可以重新设计为异步库,或用线程池包一层交由事件循环调度,但不能直接混用。 - 不要在事件循环运行期间反复创建/关闭loop。用
asyncio.run一次到底,复杂业务用asyncio.create_task管理任务生命周期。 - 始终给并发加上限。
Semaphore和连接池的limit参数是两个最常用的兜底手段。 - 注意异常在
gather中的传播行为。gather默认一传异常就全部取消;需要容忍个别任务失败时,改用return_exceptions=True。 - 调试技巧:给协程函数打日志时,尽量打到
await前后,不要只在函数入口打。因为协程真正的执行点是分散的,单看入口日志会完全没法定位卡在哪次挂起。
6.3 C++协程避坑记录(这条路上血更多)
C++协程的坑比Python多一个数量级,重点提这几个:
- 协程frame的生命周期需要自己负责。C++协程不像Python有gc自动回收。协程对象销毁或句柄销毁时,如果没有正确调用
handle.destroy(),内存泄漏几乎是必然的。我见过一个服务在高峰期内存不断上涨,最后排查下来就是在协程对象析构处没有销毁句柄。 - 避免局部引用悬垂。在协程里保存外部对象的引用时,要注意外部对象在协程挂起期间是否可能被析构或移动。编译器不会帮你检查这类问题,它是纯未定义行为。
- 理解
co_await中途异常的可能性。一旦在挂起或恢复过程中抛异常,promise的unhandled_exception必须处理,否则程序会直接terminate。上线前做一轮全量fuzz测试是有必要的。 - C++20协程目前没有标准的事件循环库。不像Python天然带
asyncio,你要么自研栈上比较简单的调度器,要么接第三方框架(比如cppcoro这类库)。选型前要评估好这部分工作量。
6.4 我给你的最实用建议:先写一个小型压力测试
如果你正在评估要不要把一个模块改成协程,我建议先不要把整个系统翻过来,而是选一条最典型的IO链路,写一个最小验证:同步版先跑一个基准数字,协程版再跑一个基准数字,对比内存峰值、吞吐和延迟分布,同时观察下游是否被打爆。用数据说话,而不是凭感觉做决定。等这一条链路验证稳定后,再逐步扩大范围。
我在几个项目里都是这个路径走过来的,最深的体会是:协程不是银弹,它的价值高不高取决于你的瓶颈到底是不是IO等待。如果瓶颈是CPU,协程只会让你得到一个“看起来并发很高却跑得很慢”的自嗨;如果瓶颈确实是网络和磁盘等待,协程往往能给你一个数量级的提升,这也是它值得学的根本原因。