别再误会 `async def`:一篇讲透 `asyncio` 事件循环、线程与并发并行的实战指南
别再误会async def:一篇讲透asyncio事件循环、线程与并发并行的实战指南
很多团队新人第一次接触 Python 异步编程时,都会有一个很自然的误解:既然函数前面写了async def,是不是代码就会自动变快?是不是就能自动利用多核 CPU 并行执行?
答案很明确:不会。
asyncio的核心不是“自动多核并行”,而是在一个线程里高效调度大量等待型任务。它擅长处理网络请求、数据库访问、消息队列、WebSocket、爬虫、实时数据流等 I/O 密集型场景;但如果你把 CPU 密集型计算直接丢进asyncio,事件循环反而会被堵住,整个程序看起来像“卡死”了一样。
这篇文章会围绕一个核心问题展开:
asyncio事件循环是什么?它与操作系统线程是什么关系?
并回答团队追问:
如何一句话讲清楚“并发”和“并行”的差异?
一句话版本先给出来:
并发是“同一时间段内处理多个任务”,并行是“同一时刻真正执行多个任务”。
接下来,我们把这句话拆开讲透。
一、从 Python 的“胶水语言”气质说起
Python 从诞生之初就强调简洁、可读和高生产力。它没有把开发者困在复杂的语法细节里,而是让人把精力放在业务逻辑、数据处理和系统设计上。
这也是为什么 Python 能成为很多领域的首选语言:
- Web 开发:Django、Flask、FastAPI
- 自动化脚本:文件处理、接口调用、运维任务
- 数据科学:NumPy、Pandas、Matplotlib
- 人工智能:PyTorch、TensorFlow、Scikit-learn
- 爬虫与数据采集:requests、aiohttp、Scrapy
- 工程工具链:CLI 工具、测试框架、CI 脚本
Python 常被称为“胶水语言”,因为它擅长把不同系统、服务、库、接口连接起来。而在现代后端开发中,一个程序往往不是一直在计算,而是在大量等待:等待网络、等待数据库、等待磁盘、等待第三方 API。
这正是asyncio的舞台。
二、先搞清楚:async def到底做了什么?
很多人以为:
asyncdeffetch_data():...写了async def之后,函数就会自动异步执行。
实际上不是。
async def定义的是一个协程函数。调用它时,函数体并不会立刻运行,而是返回一个协程对象。
asyncdefhello():print("hello")coro=hello()print(coro)你会看到类似输出:
<coroutineobjecthello at 0x...>真正让它运行起来,需要事件循环:
importasyncioasyncdefhello():print("hello asyncio")asyncio.run(hello())这里的asyncio.run()会创建并启动一个事件循环,执行传入的协程,最后关闭事件循环。
所以第一条关键结论是:
async def只是声明“这个函数可以被异步调度”,不是自动开启线程,也不是自动并行。
三、事件循环是什么?
你可以把asyncio事件循环想象成一个非常忙碌但有条理的调度员。
它做的事情大概是:
- 维护一批待执行任务。
- 执行当前可以继续运行的协程。
- 当某个协程遇到
await,并且需要等待 I/O 时,把它挂起。 - 去执行其他已经准备好的协程。
- 等操作系统通知某个 I/O 已经完成,再把对应协程恢复执行。
简化流程如下:
┌─────────────┐ │ 事件循环启动 │ └──────┬──────┘ ↓ ┌─────────────┐ │ 执行可运行任务 │ └──────┬──────┘ ↓ ┌─────────────┐ │ 遇到 await? │ └───┬─────┬───┘ │是 │否 ↓ ↓ 挂起等待 继续执行 ↓ 等待 I/O 完成 ↓ 重新加入可运行队列换句话说,事件循环不是魔法,它本质上是一个任务调度器。
它的强大之处在于:当某个任务在等待时,事件循环不会傻等,而是立刻切换去处理其他任务。
四、事件循环与操作系统线程是什么关系?
这是最容易误解的地方。
默认情况下,一个asyncio事件循环运行在一个操作系统线程中,通常就是主线程。
也就是说,下面这段代码虽然同时创建了多个任务,但它们默认并不是跑在多个 CPU 核心上:
importasyncioasyncdefworker(name):print(f"{name}start")awaitasyncio.sleep(1)print(f"{name}done")asyncdefmain():tasks=[asyncio.create_task(worker("task-1")),asyncio.create_task(worker("task-2")),asyncio.create_task(worker("task-3")),]awaitasyncio.gather(*tasks)asyncio.run(main())输出大致是:
task-1 start task-2 start task-3 start task-1 done task-2 done task-3 done这段程序总耗时大约 1 秒,而不是 3 秒。
为什么?
因为asyncio.sleep(1)模拟的是“等待”,不是“计算”。当task-1在等待时,事件循环切换去执行task-2;task-2等待时,再切换去执行task-3。
但注意,它们不是在三个线程里同时计算,而是在一个线程里快速切换。
所以第二条关键结论是:
asyncio的并发通常发生在单线程内,它依赖协程主动让出控制权,而不是操作系统强制抢占线程。
五、并发与并行:一句话讲清楚
团队新人问:“并发和并行到底差在哪?”
可以这样说:
并发是一个咖啡师同时照看多杯咖啡的制作流程;并行是多个咖啡师同时制作多杯咖啡。
再技术化一点:
- 并发:多个任务在同一时间段内交替推进。
- 并行:多个任务在同一时刻由多个执行单元真正同时运行。
对应到 Python:
asyncio 单线程事件循环:并发 多线程:可能并发,I/O 场景常用 多进程:可以真正并行,适合 CPU 密集型任务六、为什么异步 I/O 会快?
假设我们要请求 5 个接口,每个接口耗时 1 秒。
同步写法:
importtimedefrequest_api(i):time.sleep(1)returnf"result-{i}"start=time.time()results=[]foriinrange(5):results.append(request_api(i))print(results)print("cost:",time.time()-start)总耗时大约 5 秒。
异步写法:
importasyncioimporttimeasyncdefrequest_api(i):awaitasyncio.sleep(1)returnf"result-{i}"asyncdefmain():start=time.time()tasks=[asyncio.create_task(request_api(i))foriinrange(5)]results=awaitasyncio.gather(*tasks)print(results)print("cost:",time.time()-start)asyncio.run(main())总耗时大约 1 秒。
这就是异步 I/O 的价值:不是让单个任务更快,而是让等待时间被充分利用起来。
七、危险误区:在协程里写阻塞代码
来看一个常见错误:
importasyncioimporttimeasyncdefbad_task():print("start bad task")time.sleep(3)print("end bad task")asyncdefticker():foriinrange(5):print("tick",i)awaitasyncio.sleep(1)asyncdefmain():awaitasyncio.gather(bad_task(),ticker())asyncio.run(main())很多人以为bad_task()是async def,所以不会阻塞。
但它里面用了time.sleep(3),这是阻塞调用,会直接卡住当前线程,也就是卡住整个事件循环。
正确写法是:
importasyncioasyncdefgood_task():print("start good task")awaitasyncio.sleep(3)print("end good task")在异步代码里,要尽量避免这些阻塞操作:
time.sleep()requests.get()subprocess.run()大量 CPU 计算 普通同步数据库驱动 普通同步文件大读写如果必须调用同步阻塞函数,可以用asyncio.to_thread()把它放到线程里执行。
importasyncioimporttimedefblocking_io():time.sleep(3)return"done"asyncdefmain():result=awaitasyncio.to_thread(blocking_io)print(result)asyncio.run(main())八、CPU 密集型任务:别指望asyncio自动多核
假设我们写一个耗 CPU 的函数:
defcpu_heavy(n):total=0foriinrange(n):total+=i*ireturntotal如果你这样写:
importasyncioasyncdeftask():returncpu_heavy(50_000_000)asyncdefmain():awaitasyncio.gather(task(),task(),task(),)asyncio.run(main())这不会自动多核并行。
原因很简单:cpu_heavy()里面没有await,它会一直占着当前线程执行,事件循环没有机会切换任务。
对于 CPU 密集型任务,更合适的是使用进程池:
importasynciofromconcurrent.futuresimportProcessPoolExecutordefcpu_heavy(n):total=0foriinrange(n):total+=i*ireturntotalasyncdefmain():loop=asyncio.get_running_loop()withProcessPoolExecutor()aspool:tasks=[loop.run_in_executor(pool,cpu_heavy,50_000_000),loop.run_in_executor(pool,cpu_heavy,50_000_000),loop.run_in_executor(pool,cpu_heavy,50_000_000),]results=awaitasyncio.gather(*tasks)print(results)asyncio.run(main())进程池可以绕开单个 Python 解释器进程内的执行限制,让多个 CPU 核心真正参与计算。
所以第三条关键结论是:
I/O 密集型任务优先考虑
asyncio;CPU 密集型任务优先考虑多进程、向量化计算或底层扩展。
九、事件循环、线程、进程的关系图
可以用下面这个示意图帮助新人建立直觉:
操作系统 ├── 进程 A │ ├── 主线程 │ │ └── asyncio 事件循环 │ │ ├── Task 1 │ │ ├── Task 2 │ │ └── Task 3 │ └── 工作线程 │ └── 阻塞 I/O 函数 │ ├── 进程 B │ └── CPU 密集型计算 │ └── 进程 C └── CPU 密集型计算在这个结构里:
- 事件循环跑在线程里。
- 协程任务跑在事件循环里。
- 一个事件循环同一时刻只执行一个 Python 协程片段。
- 遇到
await时,协程把控制权交还给事件循环。 - 线程可用于包装阻塞 I/O。
- 进程更适合 CPU 密集型并行计算。
十、一个实战案例:异步批量请求接口
假设我们要批量请求用户资料接口。真实项目中可能是调用第三方服务、内部微服务或数据平台 API。
示例用asyncio.sleep()模拟网络延迟:
importasyncioimportrandomimporttimeasyncdeffetch_user(user_id):delay=random.uniform(0.5,1.5)awaitasyncio.sleep(delay)return{"user_id":user_id,"name":f"user-{user_id}","delay":round(delay,2),}asyncdefmain():start=time.time()tasks=[asyncio.create_task(fetch_user(i))foriinrange(1,11)]users=awaitasyncio.gather(*tasks)foruserinusers:print(user)print("total cost:",round(time.time()-start,2))asyncio.run(main())这个程序不会按顺序一个个等待,而是把 10 个请求一起交给事件循环调度。实际耗时接近最慢的那个请求,而不是所有请求耗时之和。
但生产环境不能无限制创建任务,否则可能把对方服务打爆,也可能耗尽本机连接资源。我们需要加并发控制。
importasyncioimportrandom sem=asyncio.Semaphore(3)asyncdeffetch_user(user_id):asyncwithsem:delay=random.uniform(0.5,1.5)awaitasyncio.sleep(delay)returnf"user-{user_id}"asyncdefmain():tasks=[asyncio.create_task(fetch_user(i))foriinrange(10)]results=awaitasyncio.gather(*tasks)print(results)asyncio.run(main())Semaphore(3)表示最多同时执行 3 个请求。这是异步项目中非常重要的工程习惯:并发不是越高越好,稳定才是王道。
十一、最佳实践:写好asyncio的 10 条建议
1. 不要把async def当成加速器
async def只是定义协程,不代表自动并行。
2. 异步代码里避免阻塞调用
错误:
asyncdefbad():time.sleep(1)正确:
asyncdefgood():awaitasyncio.sleep(1)3. 使用真正的异步库
比如:
requests->aiohttp/httpx.AsyncClient psycopg2->asyncpg redis-py sync->redis.asyncio4. 用asyncio.gather()聚合任务
results=awaitasyncio.gather(task1,task2,task3)5. 给外部请求加超时
asyncdefmain():try:result=awaitasyncio.wait_for(fetch_user(1),timeout=2)print(result)exceptasyncio.TimeoutError:print("request timeout")6. 给并发加限制
sem=asyncio.Semaphore(10)这在爬虫、批量接口调用、消息消费中尤其重要。
7. CPU 密集型任务不要直接放进事件循环
使用:
ProcessPoolExecutor multiprocessing NumPy/Pandas 向量化 C/Rust 扩展8. 跨线程操作事件循环要小心
如果你在其他线程里想通知事件循环,应该使用线程安全方法:
loop.call_soon_threadsafe(callback)或者:
asyncio.run_coroutine_threadsafe(coro,loop)不要随意在多个线程里共享和操作同一个事件循环。
9. 学会取消任务
task=asyncio.create_task(fetch_user(1))task.cancel()try:awaittaskexceptasyncio.CancelledError:print("task cancelled")取消任务在超时控制、服务关闭、资源回收中很常见。
10. 保持代码可读性
异步不是炫技。好的异步代码应该让人一眼看懂:
- 哪些地方会等待
- 哪些任务会并发执行
- 超时如何处理
- 异常如何传播
- 资源如何释放
十二、常见面试级问题
问题 1:async def函数调用后会立刻执行吗?
不会。它返回一个协程对象。需要被await、asyncio.create_task()或事件循环调度后才会执行。
问题 2:await的本质是什么?
await表示当前协程愿意暂停,把控制权交还给事件循环,等等待对象完成后再恢复执行。
问题 3:asyncio是多线程吗?
默认不是。asyncio的事件循环通常运行在单个线程中,通过协程切换实现并发。
问题 4:异步能提高 CPU 计算速度吗?
通常不能。异步主要提升 I/O 密集型程序的吞吐能力。CPU 密集型任务应该考虑多进程或底层优化。
问题 5:asyncio.create_task()和直接await有什么区别?
直接await会等待当前协程完成后再往下走:
result=awaitfetch_user(1)而create_task()会把协程包装成任务,交给事件循环调度,可以和其他任务并发推进:
task1=asyncio.create_task(fetch_user(1))task2=asyncio.create_task(fetch_user(2))result1=awaittask1 result2=awaittask2更常见写法:
results=awaitasyncio.gather(task1,task2)十三、给团队新人最实用的判断口诀
当你不知道该用同步、多线程、异步还是多进程时,可以先这样判断:
任务主要在等网络、数据库、磁盘? 优先考虑 asyncio 或线程池 任务主要在做大量计算? 优先考虑多进程、向量化、底层扩展 代码里用了同步阻塞库? 不要直接塞进 async def 任务数量很多但单个任务大多在等待? asyncio 很适合 任务数量少但每个都很吃 CPU? asyncio 不适合再浓缩成一句工程建议:
asyncio解决的是“等待太多”的问题,不是“计算太慢”的问题。
十四、写在最后:理解事件循环,就是理解现代后端的一半
Python 的魅力不只是语法简单,而是它能让开发者用很低的心智成本连接复杂世界。asyncio正是这种能力在现代网络应用中的体现。
它不神秘,也不应该被神化。
它不是自动多核并行工具,不是性能银弹,也不是把所有函数前面加上async就能让系统起飞的魔法。
它真正擅长的是:
- 让大量 I/O 等待任务有序推进
- 提升服务吞吐能力
- 减少线程切换和资源消耗
- 让高并发网络程序更清晰、更可控
当你真正理解事件循环、协程、线程、进程之间的边界,你就不会再纠结“为什么我的 async 代码没变快”,而是能根据业务场景选择正确工具。
最后,把本文最重要的几句话送给正在学习 Python 异步编程的你:
async def不等于并行。
事件循环通常运行在一个线程里。await是协程主动让出控制权。
并发是任务交替推进,并行是任务同时执行。
I/O 密集用异步,CPU 密集用多进程或计算优化。
如果你在团队里带新人,可以把这句话作为最终解释:
asyncio就像一个单线程调度员:谁在等 I/O,就先挂起谁;谁准备好了,就继续执行谁。它能让很多任务并发推进,但不会自动让代码在多个 CPU 核心上并行运行。
你在项目中遇到过哪些异步编程的坑?是事件循环被阻塞、任务取消失效,还是把 CPU 计算误塞进了协程?欢迎把你的经验写下来,因为每一个踩坑故事,都是后来者少走弯路的路标。
