Xiaojun 的技术笔记

Python 协程与 Go 并发对照

Go约 8 分钟读完
目录

写过 Go 的人学 Python 协程,最容易踩的坑是把 await 当成 go,把 asyncio 当成 Go runtime。两者都叫“协程”,但调度方式、编程模型和出错方式差别很大。

先给结论:

  • Python 的协程是协作式的:只有遇到 await 才会让出执行权,没有 await 就没人能打断它。
  • Go 的 goroutine 由 runtime 隐式调度,并且支持抢占,写代码时几乎感知不到切换点。
  • Python 把“能暂停”写进了函数签名(async def),代价是同步与异步代码之间有一道墙;Go 没有这道墙。

下文假设你熟悉 goroutine、channel 和 context,只讲 Python 这一侧的机制,再逐项对照。示例在 Python 3.10 下验证过。

语法与运行时两层

async 和 await 是 Python 语言本身的关键字(3.5 起),不属于 asyncio。asyncio 是标准库里运行它们的引擎。两者是不同的层:

层 是什么 例子
语法 语言关键字 async def、await、async with、async for
运行时 事件循环,负责调度协程 asyncio,也可以是 trio、anyio

语法只定义“这个函数可以在等待时暂停”,由谁来恢复它,要靠事件循环。

这和 Go 形成第一个对比:Go 的 go 关键字和调度器同属语言运行时,用户无法替换;Python 的语法与运行时是解耦的,所以同一套 async/await 可以跑在不同的事件循环上。

flowchart TD
    A["async def / await<br/>语言语法"] --> B["事件循环"]
    B --> C["asyncio"]
    B --> D["trio"]
    B --> E["uvloop"]

四个关键字的含义:

  • async def f():定义协程函数。
  • await x:等待 x 完成,等待期间当前协程暂停,事件循环去跑别的协程。
  • async with:进入和退出时需要 await 的上下文管理器,比如从连接池借连接要等池里有空位。
  • async for:每取下一项都可能要等待的循环,比如流式读取模型输出:async for chunk, _ in agent.astream(...)。

async with 与 async for

这两个关键字是普通 with、for 的异步版本,区别在于底层协议里的方法本身是协程,可以挂起。

普通 with 调用 __enter__ / __exit__,不能暂停。但有些资源的进入和退出要等 IO:借连接时池已满,要等别人归还;归还时要等网络往返。写成同步方法会卡死事件循环,所以改用 __aenter__ / __aexit__:

import asyncio

class Pool:
    def __init__(self, size):
        self._sem = asyncio.Semaphore(size)   # 模拟连接池容量

    async def __aenter__(self):
        await self._sem.acquire()             # 池满时挂起,等别人归还
        return "conn"

    async def __aexit__(self, exc_type, exc, tb):
        await asyncio.sleep(0.1)              # 模拟归还时的网络往返
        self._sem.release()

async def job(pool):
    async with pool as conn:                  # 进入、退出时各可能挂起一次
        await asyncio.sleep(0.2)

async def main():
    pool = Pool(1)                            # 只有 1 个连接,两个任务会排队
    await asyncio.gather(job(pool), job(pool))

asyncio.run(main())

async for 同理,对应 __aiter__ / __anext__,每取下一项都可以 await。最简单的写法是异步生成器(带 yield 的 async def):

async def ticks(n):
    for i in range(n):
        await asyncio.sleep(0.1)              # 每次取下一项都要等
        yield i

async def main():
    async for i in ticks(3):                  # 等待期间事件循环可以跑别的协程
        print("got", i)

前面提到的 agent.astream(...) 就是这种异步迭代器:模型每吐出一个 chunk,循环体执行一次,等下一个 chunk 时协程挂起。

Go 里没有对应的区分:defer conn.Close() 里的 Close 本来就可以阻塞,只会阻塞当前 goroutine。Python 需要单独的 async with,是因为“等待”必须显式表示为“挂起”,这也是后面要讲的函数着色的一种体现。

协程对象

调用协程函数不会执行函数体,只返回一个协程对象:

import asyncio

async def fetch(name, delay):
    await asyncio.sleep(delay)
    return f"{name} done"

async def main():
    c = fetch("a", 0.1)      # 此时函数体还没开始执行
    print(type(c))           # <class 'coroutine'>
    print(await c)           # await 才真正驱动它运行

asyncio.run(main())          # 创建事件循环并运行入口协程

这是与 Go 最直观的差别:go f() 立刻把 f 交给调度器;f() 在 Python 里只是造了个对象。忘记 await 是最常见的 bug,Python 会给出 coroutine was never awaited 警告,函数体则根本没有执行。

协程对象本质上是一个可暂停、可恢复的状态机。每次 await 一个尚未完成的对象,协程就把控制权交还给事件循环,等对象就绪后再被恢复。

await 与 Task 的区别

直接 await 一个协程调用是串行的:当前协程等它跑完再往下走,并不会产生并发。要并发,必须把协程包成 Task,交给事件循环调度。下面沿用上一节的 fetch,对比两种写法:

import time

async def main():
    t = time.perf_counter()
    # 串行:第一个 await 结束,第二个才开始
    await fetch("x", 0.3)
    await fetch("y", 0.3)
    print("串行", round(time.perf_counter() - t, 1))      # 约 0.6 秒

    t = time.perf_counter()
    # 并发:gather 把两个协程包成 Task,同时交给事件循环
    r = await asyncio.gather(fetch("x", 0.3), fetch("y", 0.3))
    print("并发", round(time.perf_counter() - t, 1))      # 约 0.3 秒

asyncio.run(main())

对应关系:asyncio.create_task(fetch(...)) 近似于 go fetch(...),不同点在于它返回一个可以 await、可以取消的 Task 句柄,而 Go 的 goroutine 没有句柄。

事件循环如何调度

事件循环是一个单线程循环,大致做三件事:

  1. 取出一个就绪的协程,运行到下一个 await。
  2. 该协程等待的对象(IO、定时器、Future)尚未完成时,把协程挂起,登记回调。
  3. 对象完成后,把协程重新放回就绪队列。
sequenceDiagram
    participant L as 事件循环
    participant A as 协程 A
    participant B as 协程 B
    L->>A: 运行
    A-->>L: await 网络 IO(挂起)
    L->>B: 运行
    B-->>L: await sleep(挂起)
    L->>L: 等待 IO 事件(epoll 等)
    L->>A: IO 就绪,恢复

底层 IO 复用(Linux 上通常是 epoll)与 Go 的网络轮询器思路相同。区别在于:Go 的调度器把 goroutine 分发到多个线程(GMP 模型),而默认的 asyncio 事件循环只有一个线程,所有协程在同一个线程里轮流跑。

由此得到两个直接后果:

  • 协程之间不存在真正的并行,CPU 密集任务不会因为 async 变快。
  • 两个 await 之间的代码天然是原子的,不会被其他协程打断,因此很多场景不需要锁。

协作式与抢占式

这是两种模型最本质的差别。下面故意写一个错误示范,对比同步阻塞和异步等待:

import asyncio, time

async def blocking():
    time.sleep(0.5)               # 错误:同步阻塞,不让出执行权,整个事件循环被卡住

async def waiting():
    await asyncio.sleep(0.5)      # 正确:挂起当前协程,事件循环去跑别的

async def ticker():
    for _ in range(3):
        print("tick", round(time.perf_counter() % 100, 1))
        await asyncio.sleep(0.1)

async def main():
    await asyncio.gather(ticker(), blocking())   # 换成 waiting() 对比

asyncio.run(main())

gather 按参数顺序调度,所以 ticker 先跑,第一次 tick 立刻打印,随后在 await sleep(0.1) 处让出执行权。接着轮到 blocking,它里面没有 await,time.sleep 占着线程不放,ticker 即使 0.1 秒的定时器早已到期,也要等 0.5 秒后才能被恢复,所以第二次 tick 约在 0.5 秒后才出现,随后两次紧挨着输出。换成 waiting 后,三次 tick 按 0.1 秒间隔正常输出。两者都“等了 0.5 秒”,区别是前者占着线程干等,后者把线程让给了别人。

这个现象和调度顺序有关:把参数顺序换成 gather(blocking(), ticker()),blocking 先拿到执行权,连第一次 tick 也会被推迟 0.5 秒。

同样的问题在 Go 里不会这样发生:goroutine 里写死循环,其他 goroutine 照样运行。Go 1.14 起引入基于信号的异步抢占,runtime 可以在函数调用之外的位置打断长时间运行的 goroutine。

维度 Python asyncio Go
切换点 显式 await runtime 隐式决定
线程 默认单线程 多线程并行(GMP)
长时间计算 卡死事件循环 可被抢占
数据竞争 两个 await 之间原子 需要锁或 channel

代价对称:Go 需要考虑数据竞争,Python 需要考虑“有没有哪里悄悄阻塞了事件循环”。

函数着色问题

await 只能出现在 async def 里,同步函数无法直接调用协程并拿到结果。于是函数被分成两种“颜色”,异步会沿着调用链向上传染:底层一个函数改成 async,所有调用者都得跟着改。

def sync_caller():
    result = fetch("a", 0.1)      # 拿到的是协程对象,不是结果
    # await fetch(...)            # SyntaxError:await 只能在 async 函数内

这个问题通常被称为“函数着色”。Go 没有这个问题:任何函数都可以放进 go,阻塞调用写起来和普通代码一样,调度由 runtime 负责。

反过来看,着色也有好处:函数签名告诉调用者“这里可能暂停”,代码审查时一眼能找到所有切换点。Go 里一个看似普通的函数调用,内部可能就阻塞在 channel 或锁上。

通信原语对照

需求 Go Python asyncio
启动并发任务 go f() asyncio.create_task(f())
等待一组任务 sync.WaitGroup asyncio.gather / TaskGroup
任务间传值 channel asyncio.Queue
一次性结果 带缓冲 channel 或 chan T asyncio.Future / Task 本身
互斥 sync.Mutex asyncio.Lock
多路等待 select asyncio.wait(..., return_when=FIRST_COMPLETED)

Python 没有语言级的 channel,也没有 select 语句,通信靠 asyncio.Queue。下面用 Queue 实现 Go 里常见的 worker pool:

import asyncio

async def worker(q, results):
    while True:
        item = await q.get()          # 队列空时挂起
        try:
            results.append(item * 2)
        finally:
            q.task_done()             # 配合 q.join() 判断全部处理完

async def main():
    q, results = asyncio.Queue(maxsize=2), []
    workers = [asyncio.create_task(worker(q, results)) for _ in range(3)]
    for i in range(10):
        await q.put(i)                # 队列满时挂起,等价于带缓冲 channel 的背压
    await q.join()                    # 等所有任务处理完(worker 仍挂在 q.get() 上)
    for w in workers:
        w.cancel()                    # 只是发出取消请求,worker 还没真正退出
    # 等 worker 真正退出;被取消的 task 以 CancelledError 结束,
    # return_exceptions=True 把它当普通结果收集,避免异常打断 main
    await asyncio.gather(*workers, return_exceptions=True)
    print(sorted(results))

asyncio.run(main())

注意两处与 Go 的差别:

  • Queue 没有 close。Go 里 close(ch) 让接收方的 range 自然退出;Python 需要发送哨兵值或直接取消 worker。
  • 没有 select。要同时等待多个来源,只能用 asyncio.wait 或 wait_for 组合。

取消与超时

Go 用 context 显式传递取消信号,每个可取消的函数都要接收 ctx 并监听 ctx.Done()。Python 把取消做进了 Task:对 Task 调用 cancel(),下一次它被恢复时,在挂起的那个 await 处抛出 asyncio.CancelledError。

async def slow():
    try:
        await asyncio.sleep(10)
    except asyncio.CancelledError:
        print("cleanup")              # 做清理
        raise                         # 必须重新抛出,否则取消被吞掉

async def main():
    try:
        await asyncio.wait_for(slow(), timeout=0.2)
    except asyncio.TimeoutError:
        print("timeout")

asyncio.run(main())
维度 Go context Python CancelledError
传递方式 显式参数,层层传递 隐式,沿 await 链传播
响应取消 主动检查 ctx.Done() 在 await 处自动抛异常
忘记处理 取消无效,goroutine 泄漏 吞掉异常会让取消失效
超时 context.WithTimeout asyncio.wait_for

Python 的方式写起来省事,但有一个与协作式调度相关的盲区:取消只在 await 点生效。一个不含 await 的循环永远无法被取消。

CancelledError 在 3.8 起继承自 BaseException,所以 except Exception 不会误吞它;但 except BaseException 或裸 except: 会。

阻塞调用的处理

Go 里调用阻塞的系统调用或 CGO 时,runtime 会把线程交接出去,其他 goroutine 不受影响。Python 没有这种保护,同步阻塞调用(time.sleep、requests.get、同步数据库驱动)必须自己隔离,常见做法是放进线程:

async def good():
    # 把同步阻塞函数丢进线程池,事件循环不被占用
    await asyncio.to_thread(time.sleep, 0.5)

asyncio.to_thread 从 3.9 开始提供。CPU 密集任务因为 GIL 的存在,线程帮不上忙,要用 ProcessPoolExecutor 配合 loop.run_in_executor。

实践上的更好选择是直接用异步生态的库:httpx/aiohttp 代替 requests,asyncpg 等异步驱动代替同步驱动。

LLM 流式调用场景

LLM 应用是 asyncio 最典型的用武之地:一次请求要等模型数秒甚至更久,期间连接一直挂着,每隔一小段时间吐出一个 chunk。绝大部分时间花在等网络上,CPU 几乎空闲。

import asyncio, time

async def astream(prompt):
    # 模拟 LLM 流式输出:每个 chunk 之间都在等网络
    for i in range(5):
        await asyncio.sleep(0.1)
        yield f"{prompt}-{i}"

async def handle(prompt):
    parts = []
    async for chunk in astream(prompt):   # 每取一个 chunk 都可能挂起
        parts.append(chunk)               # 真实场景在这里推送给前端
    return "".join(parts)

async def main():
    t = time.perf_counter()
    results = await asyncio.gather(*(handle(f"q{i}") for i in range(1000)))
    print(len(results), round(time.perf_counter() - t, 1))   # 约 0.5 秒,而不是 500 秒

asyncio.run(main())

1000 个并发流在一个线程里完成,总耗时约等于单个流的耗时。这里没有 GIL 的问题,原因有两点:

  • 只有一个线程,不存在线程间争抢 GIL。
  • 协程挂起时线程在等 IO 事件,没有在执行字节码。

所以“Python 做 IO 密集的高并发”成立,用 asyncio 加异步 SDK(如 httpx、各厂商的 async client)即可。

需要留意的是单核上限。每个 chunk 到达时要做反序列化、拼接、推送,这些 CPU 开销累计到吃满一个核,吞吐就到顶了。工程上的做法是多进程部署(如 uvicorn 多 worker),每个进程一个事件循环;同时避免在循环里做重计算,比如超大 JSON 解析或本地 embedding,这类任务放进进程池。

和 Go 对比:IO 并发能力在同一思路上,差别在于 Go 一个进程可以用满多核,Python 要靠多进程横向扩展。

选型建议

场景 建议
大量网络 IO、长连接、LLM 流式输出 asyncio 很合适,吃满单核后多进程扩展
CPU 密集计算 多进程;或换用 Go
已有大量同步代码 线程池隔离,逐步迁移
需要多核并行的高并发服务 Go 更自然

对 Go 开发者的几条心智转换:

  1. 把 await 理解为“这里会让出执行权”,不是“启动并发”;并发由 create_task 或 gather 产生。
  2. 事件循环里禁止同步阻塞,这是 asyncio 里最重要的一条纪律。
  3. 取消靠异常传播,写 except 时要小心别吞掉 CancelledError。
  4. 不要期待 channel 与 select,用 Queue 加 asyncio.wait 组合。

总结

Python 协程把“何时切换”交给程序员,用 async/await 在代码里标出每个切换点,换来单线程下无需加锁的简单性;Go 把调度全部交给 runtime,换来更低的心智负担和多核并行,代价是需要自己处理数据竞争。理解了协作式调度这一条,其余差异(函数着色、取消只在 await 生效、阻塞调用要隔离)都是它的推论。

延伸阅读:Python 官方文档 asyncio 章节(Coroutines and Tasks、Event Loop);Bob Nystrom 的 What Color is Your Function?;Go 1.14 发布说明中关于异步抢占的部分。