调用了 async 函数却没执行?一文讲透协程、event loop 与 await

「Python 进阶之路」系列 Day19

写在前面

Day17、Day18 分别讲了多线程和多进程,两者都是"重量级"的并发方案------线程有 GIL 限制,进程有创建开销和内存隔离。面对成千上万个 I/O 密集型任务(比如同时发出几千个网络请求),这两种方案都不够轻便。今天讲的 asyncio 协程,就是专门为这种场景设计的更轻量的并发方式。


一、是什么:协程、event loop 与 await

  • 协程(coroutine) :用 async def 定义的函数。调用它不会立即执行函数体,而是返回一个协程对象(类似 Day08 讲的生成器)
  • event loop(事件循环) :asyncio 的核心调度器,在单线程内切换执行多个协程------谁在等待 I/O 就先切到别的协程去执行,等 I/O 完成再切回来
  • await :暂停当前协程的执行,把控制权交还给 event loop,直到 await 的对象完成
python 复制代码
import asyncio

async def say_hello():
    print("hello")
    return "done"

result = say_hello()   # 只是调用,不会打印hello!
print(type(result))       # <class 'coroutine'>

# 必须用 asyncio.run() 或者在另一个协程里 await 它,才会真正执行
r = asyncio.run(say_hello())
print(r)   # hello(先打印) done(返回值)

二、为什么:协作式调度比线程更轻量

asyncio 本质上是单线程内的并发 ,不是并行------它用的是协作式多任务(cooperative multitasking) :协程代码自己在 await 这个点主动让出控制权,而不是像线程那样由操作系统/GIL 在任意时刻抢占式地切换(Day16/17 讲过 GIL 的切换是不可预测的)。

这带来两个好处:

  1. 切换开销远小于线程:线程切换需要操作系统参与(保存/恢复线程上下文),协程切换只是函数调用级别的操作,可以轻松开出成千上万个协程,而线程数量一旦上百就会有明显的内存和调度开销(呼应 Day17)
  2. 切换时机是可预测的 :协程只会在 await 这一行被切走,其他代码是"原子"执行的------这意味着很多场景下不需要像多线程那样处处加锁,因为你能精确知道哪些点可能被切换

三、怎么用

1. 顺序 await vs 并发执行的耗时对比

python 复制代码
async def fake_io(name, seconds):
    print(f"{name} 开始等待 {seconds}s")
    await asyncio.sleep(seconds)
    print(f"{name} 完成")
    return name

async def sequential():
    await fake_io("A", 1)
    await fake_io("B", 1)
    # 耗时 2.00s ------ 一个接一个地等

async def concurrent_version():
    await asyncio.gather(fake_io("C", 1), fake_io("D", 1))
    # 耗时 1.00s ------ 两个"同时"在等

实测:顺序 await 两次耗时 2.00s,用 asyncio.gather 并发执行两个协程只需要 1.00s。原因是 await asyncio.sleep(1) 并不会真的阻塞整个程序------协程在这里让出控制权,event loop 趁这个空档去推进另一个协程,两个协程的等待时间就重叠在了一起。

flowchart LR A[事件循环] --> B[推进协程A直到遇到await] B --> A A --> C[推进协程B直到遇到await] C --> A A --> D[谁的等待完成了就继续推进谁]

2. 常见坑:在协程里误用阻塞式 sleep

python 复制代码
async def bad_io(name, seconds):
    print(f"{name} 开始(阻塞式sleep)")
    time.sleep(seconds)   # ❌ 错误!这是阻塞整个event loop的
    print(f"{name} 完成")
    return name

async def bad_concurrent():
    await asyncio.gather(bad_io("E", 1), bad_io("F", 1))

实测耗时 2.01s------和顺序执行没有任何区别,完全没有拿到并发的好处。原因是 time.sleep() 是同步阻塞调用,它不会主动让出控制权,会把整个线程(也就是这个 event loop 所在的唯一线程)死死占住,其他协程根本没有机会被推进,直到这次 sleep 结束。在协程里必须用 await asyncio.sleep()(或其他专门的异步 I/O 库)而不是同步阻塞调用,否则 asyncio 的并发优势完全发挥不出来。

3. 协作式调度的好处:没有 await 就不会被切换

python 复制代码
shared = []

async def append_task(name):
    shared.append(f"{name}-开始")
    # 中间没有await,这一段不会被切走
    shared.append(f"{name}-结束")

async def run_two():
    await asyncio.gather(append_task("X"), append_task("Y"))

asyncio.run(run_two())
print(shared)
# ['X-开始', 'X-结束', 'Y-开始', 'Y-结束'] ------ X的两步连续出现,不会和Y交叉

因为 append_task 函数体内没有任何 await,协程调度器根本没有机会在这两行之间切换到别的协程去------X-开始X-结束 必然是连续的,不会被 Y 插进来。这正是"协程只在 await 点才可能被切换"这条规则的直接体现,也是很多 asyncio 代码不需要显式加锁的原因。

4. 手写极简 event loop 理解底层原理

用生成器 + yield(呼应 Day08)手写一个极简版的调度器,帮助理解 event loop 到底在干什么:

python 复制代码
def simple_coroutine(name, times):
    for i in range(times):
        print(f"{name} 执行第{i+1}步")
        yield   # 主动让出控制权

def simple_event_loop(coros):
    coros = list(coros)
    while coros:
        for co in coros[:]:
            try:
                next(co)   # 推进一步
            except StopIteration:
                coros.remove(co)

simple_event_loop([simple_coroutine("任务1", 2), simple_coroutine("任务2", 2)])
# 任务1 执行第1步
# 任务2 执行第1步
# 任务1 执行第2步
# 任务2 执行第2步

输出是交替进行的------simple_event_loop 就是一个最简化的"事件循环":轮流推进每个"协程"(这里用生成器模拟)一步,遇到 yield 就让出,直到 StopIteration 说明这个协程跑完了才移出队列。真正的 asyncio event loop 原理复杂得多(要处理真正的 I/O 事件、定时器等),但核心思想就是这样"轮流推进,谁让出就换下一个"。


四、面试追问

Q1:什么是协程,和线程有什么区别?

协程是用 async def 定义、能在 await 点主动让出执行权的函数,调用它会返回一个协程对象,需要驱动才会真正执行。线程由操作系统/GIL 抢占式调度,切换时机不可预测;协程是协作式调度,只在代码里明确写了 await 的地方才可能被切换,且协程切换是函数调用级别的开销,远小于线程切换。

Q2:event loop 是什么,是怎么工作的?

event loop 是 asyncio 的核心调度器,在单线程内维护多个协程的执行进度。它轮流推进每个协程,直到某个协程遇到 await 让出控制权,就转去推进别的协程;等某个协程等待的事情(比如网络响应)完成,就把它重新排入队列继续执行。

Q3:为什么协程适合 I/O 密集型任务,且比多线程更轻量?

I/O 等待期间协程会主动让出控制权,让 event loop 去推进别的协程,多个任务的 I/O 等待时间可以重叠进行------实测两个各等待 1 秒的任务并发执行只需 1 秒,而不是顺序执行的 2 秒。此外协程切换不需要操作系统参与,开销远小于线程切换,可以轻松开出远超线程数量级的并发协程。

Q4:在 async 函数里用 time.sleep() 而不是 await asyncio.sleep() 会有什么问题?

time.sleep() 是同步阻塞调用,不会主动让出控制权,会把整个 event loop 所在的线程完全占住。实测两个各"睡眠"1 秒的协程如果用 time.sleep(),并发执行耗时依然是 2 秒,和顺序执行没有区别,asyncio 的并发优势完全没有发挥出来,必须换成 await asyncio.sleep() 或其他真正异步的 I/O 调用。

Q5:协程是并行还是并发?

是单线程内的并发,不是并行------同一时刻依然只有一段代码在真正执行,只是通过在 I/O 等待点主动切换,让多个任务的等待时间重叠起来,从而缩短总耗时。这和 Day18 讲的多进程真正利用多核 CPU 并行计算是完全不同的概念:多进程解决的是"同时算",协程解决的是"同时等"。


下一篇预告

Day20 讲 asyncio 进阶------Taskasyncio.gather 到底是怎么配合 event loop 工作的,以及并发数量很大时该怎么用 Semaphore 控制并发上限,避免一次性发起几千个请求把目标服务器打垮。

相关推荐
OPEN-F1 小时前
Python进阶教程:装饰器与生成器深入
开发语言·python
Zane19941 小时前
HashMap 为什么要在长度16、容量必须是2的幂这些细节上较劲
java·后端
zhipujiaoyu1 小时前
2026年大数据专科何去何从?就业前景、发展趋势全揭秘!
大数据·python
l1258651 小时前
# RAG噪声知识库治理:一致性与可信度的四层防线设计
数据库·人工智能·python·自然语言处理·langchain
zoujiahui_20181 小时前
别再只写 np.random.seed() 了:default_rng 与 np.random、random 到底差在哪?
python
Patrick在香港1 小时前
Claude Agent 进阶:4 种工具调用编排模式,让 0820 那 13 个 endpoint 并行起来
python·ai·ai编程
掘金者阿豪2 小时前
若依启动突然报 Redis MISCONF?一次从应用报错到磁盘爆满的完整排查记录
后端
qq_513728042 小时前
StaleElementReferenceException(陈旧元素引用异常)
python
千里码aicood2 小时前
基于Python的电商数据采集系统(大数据+爬虫)
开发语言·python