Python 协程
async/await、asyncio 事件循环与高并发 I/O——从爬虫脚本到面试追问,一份能讲清楚的 Python 协程笔记。
为什么写这篇
写 Python 爬虫 时,迟早会碰到一个问题:一百个 URL 要抓,用 requests 一个一个下,慢得让人想砸键盘;开一百个线程,又怕机器扛不住、还要处理锁和异常。
这时候就会有人推荐 协程 + asyncio + aiohttp——「单线程也能高并发」。听起来很香,但很多人(包括我刚开始时)脑子里只有几个散点:async def、await、asyncio.run,说不清 协程到底和线程差在哪、事件循环在干什么、什么时候该用、什么时候千万别用。
这篇就是把这些点串成一张地图。若你熟悉 Kotlin 协程,文末有一张对照表,可以跳着看。
被问到时,我一般会先答这一句
协程是用户态的轻量任务:等 I/O 时主动挂起让出执行权,不阻塞线程;Python 里靠 asyncio 的事件循环统一调度。
往下展开之前,先看总表:
| 对比项 | 同步 | 多线程 | 协程(asyncio) |
|---|---|---|---|
| 并发模型 | 串行,一步接一步 | 多线程并行/并发,OS 调度 | 单线程内多任务交替,协作式调度 |
| 等网络/磁盘时 | 当前线程傻等 | 线程阻塞,但别的线程还能跑 | 挂起当前协程,事件循环去跑别的协程 |
| 创建成本 | 无额外抽象 | 线程栈 MB 级,数量有上限 | 协程对象 KB 级,可成千上万 |
| 适合场景 | 逻辑简单、量小 | CPU 并行、阻塞库改不动 | 高并发 I/O:爬虫、Web 服务、批量请求 |
| 不适合 | 大量 I/O 等待 | 线程太多会崩、GIL 限制 CPU 并行 | CPU 密集计算(应用多进程) |
协程是什么:别和线程混为一谈
协程(Coroutine)常被说成「轻量级线程」,但这容易误导——它默认不对应一条 OS 线程。
更准确的说法:
- 用户态任务:协程是 Python 堆上的对象,切换时不需要内核介入,开销远小于线程上下文切换。
- 协作式调度:协程在
await处主动让出执行权;不像线程那样被操作系统抢占。所以协程里若写了阻塞代码(比如time.sleep、同步requests.get),整个事件循环都会卡住。 - 为 I/O 等待而生:网络回包、磁盘读写完之前,与其占着线程干等,不如挂起,去干别的活。
用人话记
- 线程:操作系统排班,谁抢到 CPU 谁跑,切换成本高。
- 协程:自己说「我这步要等网络,先歇会儿」——事件循环记下进度,先去跑别的协程,等就绪了再回来。
若你写过 Kotlin 协程,直觉是一样的:suspend / await 都是挂起点,底层都不该阻塞承载调度的那条线程。区别见文末对照表。
Python 协程基础:async / await / 事件循环
定义与调用
Python 3.5+ 用 async def 定义协程函数;函数体里用 await 等待另一个可等待对象(协程、Task、Future 等):
import asyncio
async def say_hello():
print("Hello")
await asyncio.sleep(1) # 挂起 1 秒,不阻塞线程
print("World")注意:直接调用 say_hello() 不会执行函数体,只会得到一个协程对象。必须交给事件循环:
asyncio.run(say_hello()) # Python 3.7+ 推荐入口asyncio.run() 会:创建事件循环 → 跑完 say_hello → 关闭循环。脚本顶层这样写就够了。
事件循环(Event Loop)干什么
事件循环是 asyncio 的心脏:
┌─────────────────────────────────────┐ │ Event Loop │ │ ┌─────┐ ┌─────┐ ┌─────┐ │ │ │Task A│ │Task B│ │Task C│ ... │ │ └──┬──┘ └──┬──┘ └──┬──┘ │ │ │ await │ await │ 就绪 │ │ └─────────┴─────────┴──→ 调度执行 │ └─────────────────────────────────────┘
它维护一张「待运行 / 等待 I/O / 已就绪」的任务表:某个协程 await 住了,就切去跑别的;等 socket 可读、定时器到了,再把挂起的协程唤醒。
并发跑多个协程:create_task 与 gather
只写一个 async def 还是串行的。要并发,得把多个协程同时丢进事件循环:
import asyncio
async def task(name: str, delay: float) -> str:
print(f"{name} 开始")
await asyncio.sleep(delay)
print(f"{name} 结束")
return f"{name} result"
async def main():
# 立刻启动,不等待完成
t1 = asyncio.create_task(task("A", 2))
t2 = asyncio.create_task(task("B", 1))
results = await asyncio.gather(t1, t2)
print(results)
asyncio.run(main())输出:
A 开始
B 开始
B 结束
A 结束
['A result', 'B result']B 只睡 1 秒,所以先结束——说明 A、B 在交错执行,而不是 A 跑完再跑 B。
asyncio 几个核心名词
| 名词 | 是什么 | 怎么用 |
|---|---|---|
| 协程函数 | async def 定义的函数 | 描述一段可挂起的逻辑 |
| 协程对象 | 调用协程函数得到的对象 | 需被调度才会执行 |
| Task | 包装协程的调度单元 | asyncio.create_task(coro),事件循环里并发跑 |
| Future | 表示「将来会有结果」 | 底层占位符,业务代码较少直接 new |
| gather | 并发等多路结果 | await asyncio.gather(a(), b()),异常策略可配 |
实战:并发下载(爬虫里最常见)
同步写法——URL 越多越慢:
import requests
def download(url: str) -> str:
return requests.get(url, timeout=10).text
for url in urls:
download(url) # 前一个回包之前,后面的全在等协程写法——等待期间去抓别的页面(需安装 aiohttp):
import asyncio
import aiohttp
async def download(session: aiohttp.ClientSession, url: str) -> str:
async with session.get(url, timeout=aiohttp.ClientTimeout(total=10)) as resp:
return await resp.text()
async def main(urls: list[str]) -> list[str]:
async with aiohttp.ClientSession() as session:
tasks = [download(session, url) for url in urls]
return await asyncio.gather(*tasks)
# pages = asyncio.run(main(urls))要点:
- 用 异步 HTTP 客户端(
aiohttp、httpx的 async 模式),别在async def里调requests.get——那是同步阻塞,会拖死整个事件循环。 ClientSession要复用,别每个 URL 新建一个 session。- 并发不是无限开:站点有反爬、本机有文件描述符上限,常配合
asyncio.Semaphore限流。
更完整的爬虫链路见 Python 爬虫。
同步 vs 线程 vs 协程:怎么选
任务量小、逻辑简单 → 同步 requests,够用
CPU 密集(算哈希、转码) → multiprocessing / 向量化库,别指望 asyncio
I/O 密集、连接数多 → asyncio + aiohttp / asyncpg 等
老库只有阻塞 API、改不动 → 线程池 asyncio.to_thread(),或继续多线程三个常见误区
误区 1:「协程比线程快」
单次计算不会更快。优势是 大量 I/O 等待时少占线程,用协作式调度提高吞吐,不是让 for 循环跑得更快。
误区 2:「写了 async 就不会阻塞」
在协程里调用 time.sleep(1)、open().read()、同步 requests——照样阻塞事件循环,其他协程全停摆。阻塞操作用 await asyncio.to_thread(func)(3.9+)丢进线程池,或换原生异步库。
误区 3:「协程可以替代多进程」
Python 有 GIL,CPU 密集任务多协程也抢一根筋。算图像、压视频请用 multiprocessing 或把计算丢给 C 扩展。
和 Kotlin 协程的对照(给 Android 开发者)
若你日常写 viewModelScope.launch + suspend,可以这样对齐概念:
| 概念 | Python (asyncio) | Kotlin |
|---|---|---|
| 挂起语法 | await | suspend / 挂起函数 |
| 调度器 | Event Loop(单线程默认) | Dispatchers(Main / IO / Default) |
| 并发启动 | create_task / gather | launch / async |
| 作用域 / 结构化并发 | asyncio.TaskGroup(3.11+) | coroutineScope / supervisorScope |
| 底层线程 | 默认单线程事件循环;阻塞用 to_thread | 映射到少量 JVM 线程池 |
更深层的「用户态 vs 内核态线程」对比,见 Kotlin 协程与 Java Executors。
面试 / 自查:分层答法
| 问题 | 基础层 | 进阶层 | 深入层 |
|---|---|---|---|
| 协程是什么 | 用户态轻量任务,async/await | 协作式调度,在 await 让出执行权 | 事件循环、selector/epoll 唤醒就绪协程 |
| 和线程区别 | 协程更轻,适合 I/O 并发 | 协程不抢占,阻塞代码会卡死循环 | GIL、asyncio 默认单线程模型 |
| 怎么并发执行 | gather / create_task | Task 与 Future 关系 | TaskGroup、取消与异常传播 |
| 适用场景 | 爬虫、异步 Web、批量 API | 高连接数、等待时间长 | 与多进程混用、背压与限流 |
关键词链 也常这么走:
async / await → 事件循环 → 挂起 vs 阻塞 → gather 并发 → aiohttp 异步 I/O → CPU 密集 → multiprocessing → 对比 Kotlin 协程 / 线程池
小结
| 要点 | 一句话 |
|---|---|
| 协程 | 用户态任务,await 挂起,asyncio 调度 |
| 事件循环 | 协程跑在哪、I/O 就绪后唤醒谁 |
| 适合 | 高并发 I/O:爬虫、接口聚合、异步服务 |
| 不适合 | CPU 密集;同步阻塞库裸用 |
| 别踩坑 | async def 里别 requests.get、time.sleep |
收尾
Python 协程不是什么银弹。写脚本抓一百个页面,它能省时间;写图像批处理,该上多进程还得上多进程。
把它讲清楚,是为了 知道自己在赌什么——赌的是 I/O 等待可以被复用,不是赌 Python 算得比 C 快。
若你问我 asyncio 源码里 selector 怎么注册 fd、TaskGroup 取消时子任务怎么级联,我多半也得翻文档。没关系的——能把挂起、事件循环、和线程的边界讲明白,日常工程和大部分面试已经够用。