先看一组我在自己机器上跑出来的数:
import asyncio, time
async def one():
await asyncio.sleep(0.02)
await asyncio.gather(*(one() for _ in range(1000))) # 0.049 s
for _ in range(1000):
await one() # 30.953 s
差了 626 倍。整个过程没开一个线程,没起一个进程,就是一个线程在那儿转。
很多人把这件事理解成「asyncio 比线程快」,其实不对。快的是等待被重叠了,不是代码跑得更快。要弄清这一点,得从 async def 到底造出了什么开始拆。
一、async def 定义的是协程函数,不是普通函数
import inspect
async def fetch():
return 42
print(inspect.iscoroutinefunction(fetch)) # True
print(type(fetch)) # <class 'function'>
函数还是那个函数对象,只是被打了个标记:CO_COROUTINE。这个标记决定了调用它的时候,解释器不会立刻执行函数体。
二、调用它,函数体一行都不会跑
这是新手最容易懵的地方:
c = fetch()
print(type(c)) # <class 'coroutine'>
print(inspect.iscoroutine(c)) # True
fetch() 返回的是一个协程对象,一串还没启动的字节码。此时函数体里哪怕写着 print("hello"),也不会输出。
直接这么写,Python 会给你一句警告:
RuntimeWarning: coroutine 'fetch' was never awaited
协程对象自己不会跑,得有人驱动它。这个人就是事件循环。CPython 里 asyncio.run() 干的第一件事,就是把最外层那个协程包装成 Task,交给循环。
顺手记一下:既然是对象,就能被存起来、放进列表、传给别的函数。这跟普通函数调用「立刻拿结果」的语义,是两种不同的东西。
三、await 让出的是控制权,不是 CPU
反汇编一个最小协程,看看 await 处生成了什么(CPython 3.13):
import dis
async def inner():
await asyncio.sleep(0)
dis.dis(inner)
关键几条指令是 GET_AWAITABLE、SEND、YIELD_VALUE、END_SEND。名字已经把机制说透了:SEND 把值送进被等待的对象,YIELD_VALUE 把当前协程挂起并产出一个值给调用方。
所以 await 的完整语义是:
- 拿到后面那个对象的「可等待」版本;
- 把当前协程执行到一半的现场(局部变量、指令指针、栈)连同控制权一起交回去;
- 交回给谁?交给驱动它的 Task / 事件循环;
- 等到它等的那件事(IO 就绪、定时器到期)完成了,循环再把现场恢复,从 YIELD_VALUE 后面接着跑。
注意第 3 步:控制权是在 Python 代码内部交接的,没有陷入内核,没有线程切换,没有 GIL 释放。这跟 time.sleep() 那种「把整个线程交给操作系统去睡」完全不是一回事。
我更愿意把 await 读成「我先让一让,好了叫我」,而不是「等待」。它是一次主动的、有记录的让出。
四、事件循环在干什么
把它想成一个不停转的单线程调度器,手里攥着两样东西:
- 就绪队列:现在可以往下跑的 Task;
- IO 多路复用器:Linux 上是 epoll,Windows 上是 IOCP / select,负责盯住一堆 socket 和定时器。
循环每一轮做三件事:
- 从就绪队列取一个 Task,驱动它跑到下一个 await;
- Task 让出时,把它挂起,把它在等的那个 fd 或定时器登记到多路复用器;
- 队列空了就调 selector.select() 阻塞等待;哪个 IO 好了,把对应 Task 放回就绪队列。
伪代码长这样:
while True:
while ready:
task = ready.popleft()
task.step() # 跑到下一个 await / 结束
timeout = next_timer_deadline()
events = selector.select(timeout)
for ev in events:
ready.append(task_of(ev))
关键在第 3 步:整条线程只在「确实没有任何任务可跑」时才阻塞。只要有一个协程在等网络,循环就能转头去跑别的协程。1000 个 asyncio.sleep(0.02) 之所以 0.049 秒跑完,是因为它们几乎同时把定时器登记进去,然后一起等,20 毫秒后一起被唤醒。
五、为什么 CPU 密集和阻塞调用白搭
单线程调度有个硬约束:任何一个 Task 不让出,整条线程就归它了。
import time
async def blocked():
time.sleep(0.02) # 阻塞调用,没有 await
async def main():
await asyncio.gather(*(blocked() for _ in range(20)))
我实测 main() 耗时 0.421 秒,正好约等于 20 × 0.02 秒的串行总和。一个协程都没并发起来。原因是 time.sleep 里没有 await,事件循环压根没拿到过控制权。
同理,纯计算(大循环、加密、图像处理)也不会让出。这类活要么扔进 asyncio.to_thread() / run_in_executor,要么直接上多进程。
一句话:asyncio 加速的是「等」,不是「算」。
六、四个最常见的坑
await asyncio.sleep(0) 这行看着没用,其实是「显式让出一次」的标准写法,长循环里加一句能让其他 Task 有机会跑。
七、三种并发模型怎么选
别把它们当竞争对手。真实项目里常见的组合是:asyncio 管网络收发,线程池兜住几个阻塞调用,多进程扛 CPU 计算。
八、一句话记住
async def 造出的是一个可暂停的函数对象;await 是它主动把现场和控制权交回事件循环的那一次让出;事件循环靠一个就绪队列加 IO 多路复用,把所有「在等」的时间叠到一起。
所以 1000 个任务能同时跑,不是因为 Python 变快了,是因为它们大部分时间根本没在跑,只是在等,而循环没陪着一起等。
(实测环境:CPython 3.13.14,Windows;文中耗时与 dis 输出均可直接复现)
本文由 AI图鉴自动抓取并翻译,版权归原作者所有;如需查看原文请点下方链接。
Look at a group of people I ran off on my own machine:
import asyncio, time
async def one():
await asyncio.sleep(0.02)
await asyncio.gather(*(one() for _ in range(1000))) # 0.049 s
for _ in range(1000):
await one() # 30.953 s
It's 626 times worse. The whole process did not open a thread, it did not start a process, it was a thread that passed there.
A lot of people understand this as "asyncio faster than the wire," which is not true. It's faster than waiting for overlap, not faster. To figure this out, we have to start with what the async def actually built.
i. Async def defines a co-ordinated function, not a normal function
import inspect
async def fetch():
return 42
print(inspect.iscoroutinefunction(fetch)) # True
print(type(fetch)) # <class 'function'>
The function is also that function object and is only marked: CO COROUTINE. This label determines that when it is called, the interpreter does not execute the function immediately.
Two, call it. No line of function goes anywhere.
This is the easiest place for beginner:
c = fetch()
print(type(c)) # <class 'coroutine'>
print(inspect.iscoroutine(c)) # True
Fetch() returns a list of unstarted byte yards as a coordinated object. The function at this point does not output, even if it says "hello".
Just so you know, Python will give you a warning:
RuntimeWarning: coroutine 'fetch' was never awaited
The target doesn't run by himself. Someone has to drive it. This is the cycle of events. The first thing that CPython does is wrap up the outermost course into Task and turn it around.
Note that since it is an object, it can be stored, placed in a list and passed on to another function. This is two different things from the syntax of the normal function calling "get the results now."
III. Await releases control, not CPU
Disassembly a minimal program to see what the await has generated (CPython 3.13):
import dis
async def inner():
await asyncio.sleep(0)
dis.dis(inner)
The key directives are GET AWAITABLE, SEND, YIELD VALUE, END SEND. The name already makes the mechanism perfectly clear: SEND sends the value to the subject that is waiting, YIELD VALUE hangs the current co-ordinate and gives a value to the caller.
So the full semantic of the await is:
- Get the "waitable" version of the last object;
- Half of the sites (local variables, pointers, bars) where the current coordination process is being implemented are returned with control;
- To whom? Give it to the Task/ Event Cycle;
- When it's ready, the timer's due, the loop will resume the scene and run from behind.
Note step 3: Control is interconnected within the Python code, without kernel, without linear switching, without GIL release. It's not the same thing as time.sleep() that "put the whole thread to the operating system."
I prefer to read "I'll leave first and then call me" instead of "wait." It's an active, recorded concession.
IV. What the cycle of events is doing
Think of it as a one-way dispatcher with two things in his hand:
- Ready to run now, Task;
- IO multi-road reuser: Linux on poll, Windows on IOCP / self, looking at a bunch of sockets and timers.
Three things per cycle:
- Take a Task from the line and drive it to the next await;
- When the Task is out, hang it up and register it with the fd or timer that is waiting to be used on multiple circuits;
- Queue empty.select() block and wait; which IO well, put the corresponding Task back in line.
It's a fake code:
while True:
while ready:
task = ready.popleft()
# Run to the next wait/ end
timeout = next_timer_deadline()
events = selector.select(timeout)
for ev in events:
ready.append(task_of(ev))
The key is step 3: the whole line is blocked only when "there is really no job to run." As long as there's a coordinated process waiting for the network, the cycle can turn around and run another one. 1,000 asyncio.sleep (0.02) ran out of 0.049 seconds because they almost simultaneously registered the timer, then waited together, and then woke up in 20 milliseconds.
V. WHY CPU TRANSFER AND CONCLUSION It's nothing.
There's a hard limit to one-way movement: any Task won't let out, and the whole line will be yours.
import time
async def blocked():
# Blocking call, no wait
async def main():
await asyncio.gather(*(blocked() for _ in range(20)))
I measured the main() time of 0.421 seconds, which is exactly the sum of 20 x 0.02 seconds. None of the courses were launched. The reason is that there is no await in time.sleep and the cycle of events has not been controlled at all.
Similarly, pure calculations (large circulation, encryption, image processing) will not be given away. This kind of work is either thrown into asyncio.to thread() / run in executor or directly into multiple processes.
One sentence: Asyncio accelerates by "waiting," not "counting."
VI. The four most common pits
This line looks useless, but it's a standard way to "show off once" and add a line to the cycle that gives other Task the chance to run.
Seven, three parallel models.
Don't make them competitors. The most common combination of real projects is the asyncio piped network, several blockages of call, multi-processed CPU calculations.
Eight words.
Async def creates a pauseable function object; await is the time when it voluntarily returns the scene and control back to the cycle of events; the cycle of events is repeated by a line-up plus IO multiple routes, folding all "waiting" times together.
So 1,000 missions can run simultaneously, not because Python gets faster, but because most of the time they're not running, they're just waiting, and the cycle isn't waiting.
(Assured environment: CPython 3.13.14, Windows; time-consuming text and dis output can be reproduced directly)
Auto-fetched and translated by AI Atlas. All rights belong to the original author.