调用了 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 进阶------Task、asyncio.gather 到底是怎么配合 event loop 工作的,以及并发数量很大时该怎么用 Semaphore 控制并发上限,避免一次性发起几千个请求把目标服务器打垮。

相关推荐
飞Link3 小时前
什么是 AirSim?一文读懂无人机仿真平台
python·算法·无人机
曲径海盗3 小时前
【Python 学习第 38 天】for 循环
python·学习笔记·编程入门
小满zs4 小时前
Go语言第十七章(反射)
后端·go
小满zs4 小时前
Go语言第十五章(JSON序列化)
后端·go
在世修行4 小时前
智学文枢:变式题生成引擎
python·ai·pyside·题目生成
小满zs4 小时前
Go语言第十六章(自定义类型)
后端·go
金銀銅鐵5 小时前
[Java] 用 GUI 展示 EnumMap 增删键值对时的内部变化
java·后端·ai编程
学长毕设5 小时前
基于SpringBoot的上门洗车服务系统设计与实现
java·spring boot·后端
On The Road :6 小时前
Java 多线程详解:从入门到线程安全
java·python·安全
岁月宁静6 小时前
如何设计支持 Skills 的 Agent?一套可扩展的四层架构
python·agent