Skip to content

Python asyncio:一个线程玩转高并发的"调度中心"

引言:从"自己换挡"到"自动驾驶"

上一篇我们学了协程:用 yieldsend() 手动在生产者和消费者之间传递执行权,就像手动挡汽车——什么时候换挡、换到哪一挡,全靠自己操作。灵活,但麻烦,任务一多就手忙脚乱。

asyncio 就是 Python 协程的"自动挡"

你只需告诉它:"这里有几个任务,都可能需要等待。"asyncio 内置的**事件循环(Event Loop)**会像一位经验丰富的调度员,自动决定:哪个任务该运行了、哪个任务在等待就先挂起、哪个任务的数据到了就唤醒它。你不再需要手写 send(),一切切换自动完成

asyncio 是 Python 3.4 引入的标准库,直接内置了对异步 IO 的支持。从 Python 3.5 开始,又有了专门的 async / await 语法,让协程代码像普通同步代码一样清晰易读。

这一篇,我们从"消息循环"讲起,说清楚 async/await 到底做了什么、事件循环如何调度多个任务、如何用 asyncio.gather() 实现并发,最后通过一个"单线程同时抓取三个网站"的实战和常见误区解析,让你真正掌握 asyncio 这把高并发利器。


一、asyncio 的核心:消息循环(Event Loop)

1.1 生活化理解:餐厅服务员的"巡台"模式

想象餐厅里只有一个服务员,却要照顾 10 桌客人:

  • 同步模式(笨办法):站在 1 号桌旁,等客人慢慢看菜单、点菜、吃完、结账,全程服务完才去 2 号桌。10 桌客人从中午等到天黑。
  • asyncio 模式(聪明办法):到 1 号桌,客人说"我先看看菜单"——服务员立刻在本子上记下"1 号桌:看菜单中",转身去 2 号桌;2 号桌说"我要点这个"——记下菜单送到厨房,又记下"2 号桌:等上菜",再去 3 号桌……服务员手里有个小本子(事件循环),记录每桌的状态,谁准备好了就去服务谁,绝不空等

这个小本子就是 Event Loop(事件循环),每桌客人就是一个协程任务,"看菜单""等上菜"就是 IO 等待。

1.2 asyncio 的编程模型

asyncio 的编程模型就是一个消息循环

  1. asyncio 模块内部实现了 EventLoop
  2. 把需要执行的协程(coroutine)扔进 EventLoop
  3. EventLoop 循环检查:哪些协程可以运行?哪些在等待 IO?IO 数据到了就唤醒对应的协程继续执行。

全程只有一个线程,但因为等待时间被充分利用,一个线程就能并发处理成百上千个任务。

1.3 语法演进:从装饰器到 async/await

版本写法现状
Python 3.4@asyncio.coroutine + yield from已淘汰,了解即可
Python 3.5+async def + await标准写法,强烈推荐

新语法让协程"看起来"就是普通函数,只是多了 asyncawait 两个关键字,可读性大幅提升。本文全部使用新语法。


二、第一个 asyncio 程序:Hello World

2.1 代码

python
import asyncio

async def hello():
    print("Hello world!")
    # 异步调用 asyncio.sleep(1)
    await asyncio.sleep(1)
    print("Hello again!")

asyncio.run(hello())

2.2 执行结果

Hello world!
(等待约1秒)
Hello again!

2.3 逐行拆解

  • async def hello()async 把一个普通函数变成协程(coroutine)。注意:此时调用 hello() 并不会真正执行函数体,只会返回一个协程对象。

  • await asyncio.sleep(1)await 用于调用另一个协程并等待它完成asyncio.sleep(1) 是一个异步版的"睡觉",表示一个耗时 1 秒的 IO 操作。

    关键区别:如果是普通的 time.sleep(1),整个线程会被卡住 1 秒,什么也干不了;而 await asyncio.sleep(1) 会让当前协程主动让出执行权,线程立刻转去执行事件循环中其他就绪的任务。1 秒后再回来接着执行下一行。

  • asyncio.run(hello()):把协程扔进事件循环并启动它。这是程序的入口,负责创建 Event Loop、运行协程、结束后关闭循环。

2.4 生活化理解

await 就像点外卖:你下单后不会站在原地干等,而是对调度员说"外卖到了叫我",然后去做别的事。外卖(IO 结果)一到,调度员通知你回来继续。


三、真正的并发:用 gather() 同时跑多个协程

上面的例子只有一个任务,看不出并发。现在让两个 hello() 同时执行。

3.1 代码

python
import threading
import asyncio

async def hello(name):
    # 打印 name 和当前线程
    print("Hello %s! (%s)" % (name, threading.current_thread()))
    await asyncio.sleep(1)
    print("Hello %s again! (%s)" % (name, threading.current_thread()))
    return name

async def main():
    # 同时调度多个协程
    L = await asyncio.gather(hello("Bob"), hello("Alice"))
    print(L)

asyncio.run(main())

3.2 执行结果

Hello Bob! (<_MainThread(MainThread, started 12360)>)
Hello Alice! (<_MainThread(MainThread, started 12360)>)
(等待约1秒)
Hello Bob again! (<_MainThread(MainThread, started 12360)>)
Hello Alice again! (<_MainThread(MainThread, started 12360)>)
['Bob', 'Alice']

3.3 结果解读:三个关键信息

  1. 同一个线程:两次打印的线程 ID 完全相同(MainThread),证明所有协程都在一个线程里执行;
  2. 真正的并发:两个 hello() 都先打印了 "Hello ...",然后一起等待 1 秒,再先后打印 "again"。总耗时约 1 秒,而不是 2 秒——如果是串行执行,就要等 2 秒;
  3. 能拿到返回值asyncio.gather() 把所有协程的 return 结果收集成列表 ['Bob', 'Alice'] 返回。

3.4 asyncio.gather() 的作用

gather() 就像一个"任务发射器":把多个协程同时交给事件循环,它们齐头并进,谁等待就让出执行权给别人,全部完成后统一返回结果列表。

生活化理解:你同时烧一壶水、烤一片面包、煮一个鸡蛋。三件事都在"进行中",你什么都不用盯着,定时器一响(各自完成)就处理对应那件。gather() 就是帮你"同时开火"的那个灶台开关。


四、实战:一个线程同时抓取三个网站首页

理论说够了,来一次真正的网络 IO 并发。用 asyncio 的异步网络连接,同时获取 sina、sohu 和 163 的首页响应头。

4.1 代码

python
import asyncio

async def wget(host):
    print(f"wget {host}...")
    # 第 1 步:异步连接 80 端口(IO 操作,会让出执行权)
    reader, writer = await asyncio.open_connection(host, 80)
    # 第 2 步:发送 HTTP 请求
    header = f"GET / HTTP/1.0\r\nHost: {host}\r\n\r\n"
    writer.write(header.encode("utf-8"))
    await writer.drain()  # 等待数据真正发出去(IO 操作)

    # 第 3 步:逐行读取 HTTP 响应头
    while True:
        line = await reader.readline()  # 异步读一行(IO 操作)
        if line == b"\r\n":             # 空行表示响应头结束
            break
        print("%s header > %s" % (host, line.decode("utf-8").rstrip()))

    # 第 4 步:忽略 body,关闭连接
    writer.close()
    await writer.wait_closed()
    print(f"Done {host}.")

async def main():
    # 三个网站的抓取任务同时并发执行
    await asyncio.gather(
        wget("www.sina.com.cn"),
        wget("www.sohu.com"),
        wget("www.163.com")
    )

asyncio.run(main())

4.2 执行结果

wget www.sohu.com...
wget www.sina.com.cn...
wget www.163.com...
(等待一段时间)
(打印出 sohu 的 header)
www.sohu.com header > HTTP/1.1 200 OK
www.sohu.com header > Content-Type: text/html
...
(打印出 sina 的 header)
www.sina.com.cn header > HTTP/1.1 200 OK
www.sina.com.cn header > Date: Wed, 20 May 2015 04:56:33 GMT
...
(打印出 163 的 header)
www.163.com header > HTTP/1.0 302 Moved Temporarily
www.163.com header > Server: Cdn Cache Server V2.0
...
Done www.sina.com.cn.
Done www.sohu.com.
Done www.163.com.

4.3 为什么快?

注意开头的三行 wget ... 几乎同时打印——三个连接同时发起,没有先后顺序的等待。

整个过程中,一个线程通过 3 个 await 点(连接、发送、读取)在三个任务之间来回切换:

  • 任务 A 发起连接后要等服务器响应 → 让出执行权;
  • 事件循环切到任务 B,B 也在等 → 切到任务 C;
  • 哪个服务器先返回数据,就唤醒对应的任务继续读取。

如果把每个 await 的 IO 操作想象成"等快递":三个包裹同时发货,谁先到你先拆谁,而不是傻等第一个包裹到了再发第二个。

4.4 对比同步写法

如果用同步的 requests 依次抓三个网站,每个假设 0.5 秒,总共要 1.5 秒;用 asyncio 并发,总耗时约等于最慢的那个(约 0.5 秒)。网站越多、网络越慢,asyncio 的优势越夸张——抓 1000 个网站,同步要几百秒,asyncio 可能只需几秒。


五、核心概念总结:三个关键字记住 asyncio

概念作用类比
async def定义一个协程(可以暂停和恢复的函数)一个"可被调度"的任务单
await调用另一个协程,等待时主动让出执行权"外卖到了叫我"
asyncio.run()启动事件循环,运行入口协程调度中心开机
asyncio.gather()并发调度多个协程,收集所有返回值灶台同时开多个火
Event Loopasyncio 内部的调度器,决定谁运行、谁等待、唤醒谁服务员的"巡台小本子"

最重要的三条使用规则

  1. asyncio.run() 调度一个入口协程;
  2. async 函数内部,用 await 调用另一个 async 函数——代码看起来是串行的,实际由消息循环自动调度;
  3. await asyncio.gather() 并发执行若干个 async 函数。

六、常见误区解析

误区一:协程函数直接调用就会执行

错误。调用 async 函数(如 hello())只是创建了一个协程对象,函数体一行都没执行:

python
hello()          # 只返回协程对象,什么都没打印
asyncio.run(hello())  # 这样才真正执行

如果你在调试时发现"明明调用了却没反应",多半是忘了交给事件循环。

误区二:在协程里用 time.sleep() 模拟等待

错误time.sleep()同步阻塞的,会把整个线程卡死,事件循环里所有协程一起停摆:

python
async def bad():
    time.sleep(1)        # 大错特错:整个线程睡死 1 秒

async def good():
    await asyncio.sleep(1)  # 正确:只挂起当前协程,线程去忙别的

记住:协程里一切 IO 和等待都必须用异步版本——asyncio.sleep() 替代 time.sleep()aiohttp 替代 requestsasyncio.open_connection() 替代普通 socket。

误区三:await 可以加在任何函数调用前

错误await 后面只能跟可等待对象(协程、Task、Future)。对普通函数用 await 会直接报错:

python
def normal_func():
    return 42

await normal_func()  # TypeError: object int can't be used in 'await' expression

误区四:asyncio 适合所有场景

错误。asyncio 是 IO 密集型任务的王者(网络请求、数据库查询、文件读写),但对 CPU 密集型任务(大规模计算、图像处理)毫无帮助——因为计算不会"等待",协程没有切换的机会,单线程还是一个核在算。CPU 密集任务请用多进程multiprocessing)。

误区五:gather() 会按顺序执行协程

不准确gather()并发启动所有协程,它们的执行是交错的,谁先谁后不确定(上面例子中 Bob 和 Alice 的打印顺序就是并发的体现)。但返回的结果列表顺序与传入顺序一致,方便对应。

如果需要严格按顺序执行,直接连续 await 即可,不需要 gather()

误区六:asyncio 程序里可以随便用普通库

危险。混用同步库和 asyncio 是最常见的线上事故。比如在一个高并发的 aiohttp 服务里,某个角落调用了同步的 requests.get()一次请求就会卡死整个事件循环,所有在线用户全部卡住。排查这类问题时,重点检查依赖库里是否藏着同步 IO。


七、小结

  • asyncio 是 Python 标准库提供的异步 IO 框架,核心是事件循环(Event Loop)——一个自动调度协程的"消息循环";
  • async def 定义协程,await 在协程内调用其他协程并让出执行权,asyncio.run() 启动事件循环;
  • asyncio.gather() 让多个协程并发执行,一个线程同时处理多个 IO 任务,总耗时约等于最慢的那个任务;
  • await 后的耗时操作想象成"等快递":发出请求后绝不干等,先处理别的,到了再回来;
  • 适用边界要牢记:IO 密集用 asyncio,CPU 密集用多进程;协程内禁用一切同步阻塞调用;
  • 掌握了 asyncio,下一步就是学习基于它的生态库:aiohttp(异步 Web 框架/HTTP 客户端)、aiomysql(异步数据库)等,构建真正的高并发服务。