协程基础:用 async/await 改写你的并发逻辑

说实话,我当年刚开始接触协程的时候,也觉得很玄乎。什么 async、await,看着像魔法一样。直到我在一个高并发的网关项目里,用线程池扛到 5000 QPS 就开始频繁上下文切换,CPU 飙升到 90%,服务直接卡死……

后来我把那套逻辑改成了协程,同样的机器,QPS 干到了 3 万,CPU 才 60%。你说差距大不大?

什么是协程?跟线程到底差在哪

你可以把协程想成一个「轻量级线程」。但它不是操作系统管理的,而是由我们的程序自己在用户态调度的。什么意思呢?线程切换要进内核态,保存一堆寄存器、栈信息,开销很大;协程切换只改几个指针,几乎零成本。

对比维度 线程 协程
调度者 操作系统内核 用户态程序(事件循环)
切换成本 微秒级(涉及上下文保存) 纳秒级(函数调用级别)
内存占用 默认每个线程栈 1~8 MB 每个协程栈几十 KB 起步
并发数量 通常几千个就扛不住了 轻松几万、甚至几十万
数据竞争 共享内存,需要加锁 单线程模型内天然无竞争

简单粗暴地记:协程是用户态线程,协程切换就是一次函数调用。我刚开始用 Python 的 asyncio 时,一个进程里跑了 5 万个协程任务,内存才 200 多 MB。换成线程?光栈空间就爆炸了。

async / await 语法——让异步代码看起来像同步

你想想看,如果不用 async/await,我们写异步回调是什么画面?

def fetch_data(callback):
    # 发起 IO,假设用回调通知
    pass

fetch_data(lambda result: handle(result))
# 再来一层回调嵌套?噩梦就开始了

这种回调地狱,我维护过一个老项目,三层回调嵌套,改一个逻辑要在五个不同函数间跳转,调试的时候想骂人。async/await 就是来解决这个问题的。

import asyncio

async def fetch_data():
    # 假装在做网络 IO,等待的时候不阻塞线程
    await asyncio.sleep(1)
    return "数据拿到了"

async def main():
    result = await fetch_data()
    print(result)  # 跟同步代码一模一样

asyncio.run(main())

核心要点就两个:

  • async def 定义一个协程函数。调用它不会立刻执行,而是返回一个协程对象。
  • await 挂起当前协程,把控制权交还给事件循环。等到被等待的协程完成,再回来继续执行。
我的习惯: 一个协程函数里只要有 IO 操作,前面一定加 await。漏掉 await 会导致协程不会执行,返回一个挂起的协程对象,查 bug 时很容易忽略。我曾经有一次排查了整整半天,才发现同事漏写了三个 await。

事件循环模型——协程的调度器

协程自己不会跑,它需要一个「引擎」来驱动,这个引擎就是事件循环(Event Loop)。

在 Python 里,asyncio.run() 会帮你创建一个事件循环,然后把入口协程放进去运行。事件循环内部维护一个任务队列,不断从队列里取出可执行的任务,遇到 await 就挂起,等 IO 完成后再把任务放回就绪队列。

来看个实际例子:

import asyncio
import time

async def worker(n):
    print(f"任务 {n} 开始")
    await asyncio.sleep(1)  # 等待但不阻塞
    print(f"任务 {n} 结束")

async def main():
    tasks = [worker(i) for i in range(3)]
    # 并发执行三个协程
    await asyncio.gather(*tasks)

start = time.time()
asyncio.run(main())
print(f"总耗时: {time.time() - start:.2f} 秒")

输出类似:

任务 0 开始
任务 1 开始
任务 2 开始
任务 0 结束
任务 1 结束
任务 2 结束
总耗时: 1.01 秒

看到了吗?三个任务几乎是同时开始的,总耗时只有 1 秒,而不是 3 秒。因为 asyncio.sleep(1) 是协程版的 sleep,它告诉事件循环:“我要歇 1 秒,你先去跑别的任务”。

重点记忆: 事件循环的核心是「遇到 IO 就切走,IO 完再切回来」。所以协程最适合 IO 密集型的场景——网络请求、文件读写、数据库查询。如果是 CPU 密集计算,协程帮不上忙,因为计算不涉及 IO,没法切走,必须用多进程。

协程与线程的协作关系

很多人误解:「用了协程就不需要线程了」。不对。Python 的协程默认跑在一个线程里,这个线程负责事件循环。如果遇到阻塞式操作(比如 time.sleep()、普通的 requests.get()),整个事件循环都会被卡住。

import asyncio
import time

async def bad_task():
    print("坏任务开始")
    time.sleep(2)  # 这是同步阻塞!事件循环歇菜了
    print("坏任务结束")

async def good_task():
    print("好任务开始")
    await asyncio.sleep(2)  # 协程式等待
    print("好任务结束")

async def main():
    await asyncio.gather(bad_task(), good_task())

asyncio.run(main())
# 坏任务先开始,然后整个程序卡死2秒,好任务才能开始
# 总耗时 4 秒+

所以我的建议是:协程和线程不是互斥的,而是互补的。如果非要用同步库,就用 concurrent.futures.ThreadPoolExecutor 把阻塞操作扔到线程池里跑,再 await 那个线程池的 future。

import asyncio
import time
import requests
from concurrent.futures import ThreadPoolExecutor

executor = ThreadPoolExecutor(max_workers=10)

async def fetch_url(url):
    loop = asyncio.get_event_loop()
    # 把同步 requests 交给线程池,不会阻塞事件循环
    result = await loop.run_in_executor(executor, requests.get, url)
    return result.text

这样既享受了协程的轻量,又不丢同步生态的便利。

避坑指南: 千万不要在协程里混用 time.sleep() 或带 GIL 释放的复杂计算。我曾经把一个加密验证的 CPU 密集操作扔进协程里,结果事件循环被锁死,所有请求超时。排查时还以为是网络问题,折腾了两天才发现罪魁祸首。

知识体系图:协程模型全景

下面这张图把协程的执行流程、组件关系理清楚了。建议你保存下来,写代码时对照着看。

协程执行模型全景图 事件循环 (Event Loop) 就绪任务队列 等待任务队列 取出可运行协程 挂起等待 IO 协程 A await read_socket() 协程 B await asyncio.sleep(1) 协程 C await rpc_call() IO 完成通知 从等待队列移回就绪 说明:事件循环不断轮询就绪队列中的协程,遇到 await 就挂起并加入等待队列; 当 IO 完成后,协程被唤醒,重新放回就绪队列等待调度。整个过程在单线程内完成。

什么时候用协程,什么时候用线程?

我的经验法则:

  • IO 密集型 + 大量并发连接(比如 Web 服务、网关、消息队列消费者)→ 优先用协程。单机能扛几万连接,线程根本做不到。
  • CPU 密集型任务(图像处理、加密、科学计算)→ 不要用协程,用多进程。
  • 混合型(即有一部分 CPU 计算,又有大量 IO)→ 可以把 CPU 部分丢给进程池,IO 部分走协程。Python 的 asyncio 和 concurrent.futures 配合得很好。
  • 需要强实时性(微秒级响应)→ 线程可能更合适,因为事件循环内部如果有一个协程执行时间过长(比如不小心写了个大循环),会阻塞其他所有协程。
一句话总结: 协程让单线程也能高并发,但前提是——你写的代码必须是协程安全的(不用同步阻塞操作)。我面试时经常问候选人:“如果让你用 Python 写一个能撑 10 万连接的聊天服务器,你用什么模型?” 能第一时间想到 asyncio 的人,至少说明他对并发层次有感觉。

嗯,到这你应该对协程的核心概念有个清晰的画面了。下一部分我们会深入事件循环的实现细节,包括如何自定义 loop、如何与多线程混合使用——但那些是后面的章节了。先把基础打牢,写几个简单例子跑一跑,比看十遍理论都管用。