返回 Python
Python
12 分钟阅读

Python 协程

async/await、asyncio 事件循环与高并发 I/O——从爬虫脚本到面试追问,一份能讲清楚的 Python 协程笔记。

为什么写这篇

Python 爬虫 时,迟早会碰到一个问题:一百个 URL 要抓,用 requests 一个一个下,慢得让人想砸键盘;开一百个线程,又怕机器扛不住、还要处理锁和异常。

这时候就会有人推荐 协程 + asyncio + aiohttp——「单线程也能高并发」。听起来很香,但很多人(包括我刚开始时)脑子里只有几个散点:async defawaitasyncio.run,说不清 协程到底和线程差在哪、事件循环在干什么、什么时候该用、什么时候千万别用

这篇就是把这些点串成一张地图。若你熟悉 Kotlin 协程,文末有一张对照表,可以跳着看。


被问到时,我一般会先答这一句

协程是用户态的轻量任务:等 I/O 时主动挂起让出执行权,不阻塞线程;Python 里靠 asyncio 的事件循环统一调度。

往下展开之前,先看总表:

对比项同步多线程协程(asyncio)
并发模型串行,一步接一步多线程并行/并发,OS 调度单线程内多任务交替,协作式调度
等网络/磁盘时当前线程傻等线程阻塞,但别的线程还能跑挂起当前协程,事件循环去跑别的协程
创建成本无额外抽象线程栈 MB 级,数量有上限协程对象 KB 级,可成千上万
适合场景逻辑简单、量小CPU 并行、阻塞库改不动高并发 I/O:爬虫、Web 服务、批量请求
不适合大量 I/O 等待线程太多会崩、GIL 限制 CPU 并行CPU 密集计算(应用多进程)

协程是什么:别和线程混为一谈

协程(Coroutine)常被说成「轻量级线程」,但这容易误导——它默认不对应一条 OS 线程

更准确的说法:

  1. 用户态任务:协程是 Python 堆上的对象,切换时不需要内核介入,开销远小于线程上下文切换。
  2. 协作式调度:协程在 await主动让出执行权;不像线程那样被操作系统抢占。所以协程里若写了阻塞代码(比如 time.sleep、同步 requests.get),整个事件循环都会卡住。
  3. 为 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_taskgather

只写一个 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 客户端aiohttphttpx 的 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
挂起语法awaitsuspend / 挂起函数
调度器Event Loop(单线程默认)Dispatchers(Main / IO / Default)
并发启动create_task / gatherlaunch / 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_taskTask 与 Future 关系TaskGroup、取消与异常传播
适用场景爬虫、异步 Web、批量 API高连接数、等待时间长与多进程混用、背压与限流

关键词链 也常这么走:

async / await
  → 事件循环
  → 挂起 vs 阻塞
  → gather 并发
  → aiohttp 异步 I/O
  → CPU 密集 → multiprocessing
  → 对比 Kotlin 协程 / 线程池

小结

要点一句话
协程用户态任务,await 挂起,asyncio 调度
事件循环协程跑在哪、I/O 就绪后唤醒谁
适合高并发 I/O:爬虫、接口聚合、异步服务
不适合CPU 密集;同步阻塞库裸用
别踩坑async def 里别 requests.gettime.sleep

收尾

Python 协程不是什么银弹。写脚本抓一百个页面,它能省时间;写图像批处理,该上多进程还得上多进程。

把它讲清楚,是为了 知道自己在赌什么——赌的是 I/O 等待可以被复用,不是赌 Python 算得比 C 快。

若你问我 asyncio 源码里 selector 怎么注册 fd、TaskGroup 取消时子任务怎么级联,我多半也得翻文档。没关系的——能把挂起、事件循环、和线程的边界讲明白,日常工程和大部分面试已经够用。

相关文章

Python
公开
17 分钟
Python 爬虫
爬虫场景里 aiohttp 高并发抓取的典型用法。
Java / Kotlin
公开
20 分钟
Kotlin 协程与 Java Executors:内核态与用户态的线程资源对比
从操作系统线程模型出发,对比 Java Executors 与 Kotlin Coroutine 在线程创建、上下文切换、阻塞语义与并发规模上的差异
Java / Kotlin
公开
9 分钟
Kotlin Coroutine
若你从 Android/Kotlin 转过来,可对照协程的挂起与调度模型。