小白学 Python
AI 编程指南GitHub
© 2026 小白学 Python · 基于 walter201230/Python 教程
课程目录关于本站联系方式隐私政策GitHub

async / await

完整讲解 · 5 段教学·配 4 道练习题·预计 35 分钟

本页是本章的通读版,可直接读完全部讲解。想动手写代码、跑判分,去 闯关模式。

教学 01 / 05

async/await:让一个线程同时等很多件事

各位写过爬虫吗?随手一段:

python到闯关页运行这段 →
import time

def fake_get(url):
    time.sleep(0.01)
    return f'GOT {url}'

urls = [f'https://example.com/{i}' for i in range(5)]
results = [fake_get(u) for u in urls]
print(f'拿到 {len(results)} 个')

每个请求假装耗时 0.01 秒,5 个串起来就是 0.05 秒。如果是真实网络请求,每个 200 毫秒,100 个串起来就是 20 秒——慢得想骂人。

「那好办,开线程嘛!」有人立刻反应过来,「100 个 URL 就开 100 个线程,一起跑。」

线程当然能解决问题。但开 100 个线程是不是有点奢侈?这 100 个线程 99% 的时间都在干同一件事:等网络包回来。本质上这是个等待问题,不是个计算问题。为了「等」而开 100 个操作系统级的线程,CPU 不开心,内存也不开心。

而且线程一多,「锁、竞态、死锁」这些老朋友就会接连找上门。你只是想抓个网页,怎么忽然要研究操作系统了?

另一条路:asyncio

Python 给了我们另一条路:asyncio。它的卖点很直白——

一个线程,同时等一百件事。

各位听到这句话的第一反应大概是:「这不科学吧,单线程怎么同时做一百件事?」

魔法的关键就一句话:

当某个协程在等 IO 的时候,让出 CPU 给其他协程;等回来再继续。

asyncio 没有偷偷开线程,也没有把 CPU 加速。它做的事很朴素——「等」可以重叠。三件事每件等 1 秒,串行要 3 秒;让它们的「等」重叠起来,1 秒就够了。

这一节的目标

  • 看懂 async def、await、asyncio.run() 这三个最基本的概念
  • 用 asyncio.gather 同时跑多个协程
  • 知道什么时候该用 async(IO bound),什么时候别用(CPU bound)

注意:浏览器里的 Python 沙盒不能联网,所以咱们用 asyncio.sleep 来模拟「等」的过程。逻辑和真实网络请求是一样的。

教学 02 / 05

一、协程函数:和普通函数差在哪

普通函数长这样:

python到闯关页运行这段 →
def hello():
    return '你好'

print(hello())   # 你好

加一个 async 关键字,它就成了协程函数(coroutine function):

python到闯关页运行这段 →
async def hello():
    return '你好'

print(hello())

输出大致是这样:

<coroutine object hello at 0x...>
RuntimeWarning: coroutine 'hello' was never awaited

各位看出区别了吗——同样写 print(hello()),普通函数返回的是字符串,协程函数返回的是一个 协程对象,而且还附赠一句警告:「这协程从来没被 await 过」。

关键认知

async def 定义的不是一个「会立刻跑的函数」——它返回一张「待执行的任务单」。

打个比方:

函数类型f() 的行为
普通函数立刻派人去执行,立刻拿到结果
协程函数写一张任务单递给你;什么时候做、谁做,另说

这张「任务单」就是协程对象。它必须被 await 或者扔给事件循环,里面的代码才会真正跑起来。

await:让协程跑起来

那怎么让任务单真的执行?用 await:

python到闯关页运行这段 →
import asyncio

async def hello():
    return '你好'

async def main():
    result = await hello()    # ← 这里让 hello 真正跑
    print(result)

asyncio.run(main())

输出:

你好

await hello() 的语义是「请帮我把这张任务单完成,然后把结果给我」。

但**await 只能写在 async def 函数体内部**。在普通函数里写 await,Python 会报 SyntaxError: 'await' outside async function。

asyncio.run():进入异步世界的大门

「那我想在普通脚本里调一个 async def 怎么办?」答案就是 asyncio.run():

python到闯关页运行这段 →
import asyncio

async def main():
    print('我在异步世界里')
    print('我还在异步世界里')

asyncio.run(main())

输出:

我在异步世界里
我还在异步世界里

asyncio.run() 是同步代码和异步代码之间的「门」:

  • 接收一个协程对象
  • 启动一个事件循环(event loop)
  • 把协程跑完,再关掉事件循环

记住一条:整个程序里通常只调一次 asyncio.run()——它就是从同步世界踏进异步世界的入口。

三件套:async def + await + asyncio.run

到这里,最基础的三件套你已经齐了:

工具作用
async def f()定义协程函数(写出一张任务单的能力)
await x等一张任务单跑完,拿结果
asyncio.run(c)从同步世界进入异步世界,跑一个协程

下一题就用这三个东西写一个最小的 hello world。

练习 1 / 4第一个 async 函数去闯关页做这题 →
教学 03 / 05

二、asyncio.sleep:模拟「等」

真实场景里,协程主要在等什么?答案是 IO——等网络包、等磁盘、等数据库。

浏览器里咱们没法发真请求,但可以用 asyncio.sleep 模拟「等」的过程,行为完全等价:

python到闯关页运行这段 →
import asyncio

async def task():
    print('开始')
    await asyncio.sleep(0.01)   # 模拟 IO 等待
    print('结束')

asyncio.run(task())

输出:

开始
结束

注意中间那行——await asyncio.sleep(0.01)。这就是协程的「等待点」:执行到这一句时,协程会告诉事件循环「我要等 0.01 秒,这段时间你随便调度别人」,然后让出 CPU。

串行 await:一行接一行

只要把 await 一个接一个写下来,多个协程就是串行执行的——前一个跑完,后一个才开始:

python到闯关页运行这段 →
import asyncio

async def step(name):
    print(f'{name} 开始')
    await asyncio.sleep(0)
    print(f'{name} 结束')

async def main():
    await step('A')
    await step('B')

asyncio.run(main())

输出:

A 开始
A 结束
B 开始
B 结束

await step('A') 的意思是「等 A 跑完,再继续」。所以 B 一定排在 A 后面。

划重点:async/await 不会自动并发。一个 await 接一个 await,本质上是按顺序跑的。

「忘了 await」的经典坑

异步编程最经典的陷阱是调了协程函数但没 await:

python到闯关页运行这段 →
import asyncio

async def task():
    print('我跑了吗?')

async def main():
    task()              # ← 漏了 await!
    print('main 跑完了')

asyncio.run(main())

输出:

main 跑完了

'我跑了吗?' 根本没打印——task() 只是创建了一个协程对象就被丢掉了,里面的代码一行都没跑。同步世界里写函数名加括号就等于执行;异步世界里,没 await 的协程等于没写。

正确写法当然是 await task()。

阻塞坑:time.sleep 不能用

最后一条铁律。在 async 函数里,绝对不要用 time.sleep:

python到闯关页运行这段 →
import time

async def task():
    time.sleep(0.5)   # 这里把整个事件循环卡死了

time.sleep 是同步阻塞的——调用时不会让出 CPU。整个事件循环会被卡住,所有协程一起停。

同步(阻塞,禁用)异步(替代品)
time.sleep(s)await asyncio.sleep(s)
requests.get(...)await httpx.AsyncClient().get(...)
open(f).read()aiofiles 库 / 线程池

记一条原则:在 async 函数里,凡是会阻塞的同步调用都要换成异步版本。

练习 2 / 4串行 await 的顺序去闯关页做这题 →
教学 04 / 05

三、asyncio.gather:一次等多个

上一节我们看到 await 串行写下来是排队执行的——和同步代码没区别,并不会更快。

要真正享受到「同时等多件事」的并发效果,得明确告诉事件循环「这几件事可以一起开始」。asyncio.gather 就是干这个的。

用法:await asyncio.gather(coro1, coro2, ...)

python到闯关页运行这段 →
import asyncio

async def task(name):
    await asyncio.sleep(0.01)
    return f'{name} 完成'

async def main():
    r1, r2, r3 = await asyncio.gather(
        task('A'),
        task('B'),
        task('C'),
    )
    print(r1)
    print(r2)
    print(r3)

asyncio.run(main())

输出:

A 完成
B 完成
C 完成

asyncio.gather 接收任意多个协程,把它们一起扔给事件循环跑,然后按你传进去的顺序把结果打包返回。

为什么这就「并发」了

  • 三个 task('X') 几乎同时开始
  • 各自跑到 await asyncio.sleep(0.01) 时让出 CPU
  • 事件循环在「等」的这段时间里调度其他协程
  • 等三个都好了,gather 把结果按顺序收回来

如果是真实网络请求,三个请求 200 毫秒——串行 600 毫秒,gather 大约 200 毫秒。

几个要点

要点说明
结果顺序和传入顺序一致,不是「谁先跑完谁先返回」
任意一个抛异常默认情况下整个 gather 都会抛
同一个协程对象不能被多次 await想跑同一函数多次就写多个调用 [task() for _ in range(N)]

串行 vs 并发,差在哪一行?

对照看一下,「等」的方式不一样,效率天差地别:

python到闯关页运行这段 →
# 串行:1 个 sleep 等完再开下一个
async def serial():
    await task('A')
    await task('B')
    await task('C')

# 并发:3 个 sleep 一起等
async def concurrent():
    await asyncio.gather(task('A'), task('B'), task('C'))

gather 是 asyncio 里出现频率最高的函数之一。看到「同时跑多个」的需求,第一反应就该是它。

Python 3.11+ 还有个升级版 asyncio.TaskGroup——更安全、出错时会自动取消其他任务。日常入门用 gather 就够。

练习 3 / 4用 gather 并发跑两个协程去闯关页做这题 →练习 4 / 4并发抓取(模拟)去闯关页做这题 →
教学 05 / 05

四、什么时候用 async,什么时候别用

讲了这么多,最后回到一个本质问题:什么场景适合 async?

asyncio 的强项是 IO bound——程序大部分时间在「等」:

  • 等网络(爬虫、API 调用、Web 服务)
  • 等磁盘(读写文件、数据库)
  • 等用户输入(聊天机器人)

这种场景下「等待」是可以重叠的,async 能让一个线程同时等无数件事——立竿见影。

asyncio 的弱项是 CPU bound——程序大部分时间在「算」:

  • 加密解密
  • 图像处理 / 视频转码
  • 机器学习推理
  • 大矩阵计算

这种场景下没有「等」可以利用,CPU 一直在干活,async 帮不上忙。一个线程也只能用一个核——剩下的核全在睡大觉。

场景对照表

场景用什么
抓 100 个 URLasyncio + httpx
同时读写一堆文件asyncio + aiofiles
高并发 Web 服务(一台机器扛几千连接)asyncio + FastAPI / aiohttp
视频转码、大矩阵计算multiprocessing 或 C 扩展
海量数据本地排序多进程 + 分块
大部分 IO 夹一点点 CPUasync 主框架 + asyncio.to_thread

各位可以这么记:

async 是用来「等」的,不是用来「算」的。

这一节的核心

回顾一下你现在掌握的工具:

工具用途
async def f()定义协程函数
await x等一个协程的结果
asyncio.run(c)同步代码进入异步世界的入口
await asyncio.sleep(s)异步等待 s 秒(替代 time.sleep)
asyncio.gather(...)并发跑多个协程,按顺序收结果

进阶(这里不展开)

asyncio 的世界还有很多东西,本课不展开,列个清单方便你后面查阅:

  • asyncio.create_task(coro):把协程「派出去」在后台跑,可以单独 cancel
  • asyncio.wait_for(coro, timeout) / asyncio.timeout(...):超时控制
  • asyncio.TaskGroup(3.11+):gather 的安全升级版
  • async with / async for:异步上下文管理器、异步迭代器
  • asyncio.Semaphore:限制并发数(避免把对方服务器打挂)
  • asyncio.to_thread(fn):把同步阻塞调用扔到线程池里

asyncio 的语法看起来花哨,本质上只是给「等待」加了一层调度。各位写代码时多想一句:「这一步是不是在等?」如果是,套个 await 试试,可能就有惊喜。

读完了?动手练一遍才算真会。

去闯关模式练习 →