协程不是线程:深入理解 Python async/await 运行机制

协程不是线程:深入理解 Python async/await 运行机制

"我以为 async 就是开了个新线程......" ------ 几乎每一位第一次接触异步编程的同事

如果你也有过这样的困惑,这篇文章就是为你写的。


一、为什么要有异步编程?

在聊 async/await 之前,我们先回到一个最朴素的问题:同步代码哪里不够用了?

想象你是一家餐厅的服务员。同步模式下,你服务完1号桌(等他点菜、上菜、结账),才能去服务2号桌。如果1号桌的客人在纠结菜单,你就只能站在那里干等。

这就是同步 I/O 的本质:CPU 在等待网络、磁盘响应的过程中,什么都不做,白白浪费时间。

异步模式下,你把菜单留给1号桌,转身去招呼2号桌,等1号桌准备好了再回来。一个人,服务多张桌,没有多雇人(没有多开线程),效率却大幅提升。

这就是 Python 异步编程的核心动机。


二、async/await 的运行机制

2.1 从一个最简单的例子开始

python 复制代码
import asyncio

async def say_hello():
    print("Hello")
    await asyncio.sleep(1)  # 模拟 I/O 等待
    print("World")

asyncio.run(say_hello())

运行结果:

复制代码
Hello
(等待约1秒)
World

看起来和同步代码没什么区别?别急,我们加点料:

python 复制代码
import asyncio
import time

async def task(name, delay):
    print(f"[{name}] 开始")
    await asyncio.sleep(delay)
    print(f"[{name}] 完成,耗时 {delay}s")

async def main():
    start = time.time()
    # 并发执行三个任务
    await asyncio.gather(
        task("A", 2),
        task("B", 1),
        task("C", 3),
    )
    print(f"总耗时:{time.time() - start:.2f}s")

asyncio.run(main())

输出:

复制代码
[A] 开始
[B] 开始
[C] 开始
[B] 完成,耗时 1s
[A] 完成,耗时 2s
[C] 完成,耗时 3s
总耗时:3.01s

三个任务总共需要 2+1+3=6 秒,但实际只用了 3 秒。没有多线程,没有多进程,只有一个线程在跑。 这就是异步的魔法。

2.2 async 函数到底返回什么?

这是理解异步的第一个关键点。

python 复制代码
async def greet():
    return "你好"

result = greet()
print(result)        # <coroutine object greet at 0x...>
print(type(result))  # <class 'coroutine'>

调用 async 函数,不会执行函数体,而是返回一个协程对象(coroutine object)。

协程对象就像一份"执行计划书",它描述了"要做什么",但还没有真正开始做。你需要把它交给 Event Loop 去调度执行。

2.3 await 做了什么?

await 是一个暂停点。当执行到 await 时,当前协程会:

  1. 暂停自身执行,把控制权交还给 Event Loop
  2. Event Loop 去执行其他就绪的协程
  3. 等待的操作完成后,Event Loop 恢复这个协程,从暂停处继续执行
python 复制代码
async def demo():
    print("步骤1:开始")
    await asyncio.sleep(0)   # 暂停,让出控制权
    print("步骤3:恢复执行")  # Event Loop 调度回来后继续

async def other():
    print("步骤2:趁机执行")

async def main():
    await asyncio.gather(demo(), other())

asyncio.run(main())

输出:

复制代码
步骤1:开始
步骤2:趁机执行
步骤3:恢复执行

这就是协程的"协作式调度"------主动让出,而非被动抢占


三、协程对象、Task、Event Loop 三者的关系

这是很多人最容易混淆的地方,我用一个类比把它讲清楚。

类比:剧本、演员、导演

概念 类比 职责
协程对象(coroutine) 剧本 描述"要做什么",是惰性的,不会自己执行
Task 演员 包裹剧本,负责实际执行,有状态(pending/running/done)
Event Loop 导演 统筹调度所有演员,决定谁上场、谁等待

3.1 协程对象:惰性的执行计划

python 复制代码
async def fetch_data():
    await asyncio.sleep(1)
    return {"data": 42}

# 只是创建了协程对象,什么都没发生
coro = fetch_data()
print(coro)  # <coroutine object fetch_data at 0x...>

协程对象本身不会运行,它需要被"驱动"。

3.2 Task:被调度的执行单元

Task 是对协程的封装,创建 Task 后,Event Loop 会在合适的时机调度它执行。

python 复制代码
async def main():
    # 方式1:asyncio.create_task(推荐)
    task1 = asyncio.create_task(fetch_data())
    
    # 方式2:asyncio.ensure_future(较旧的写法)
    task2 = asyncio.ensure_future(fetch_data())
    
    # Task 创建后立即被调度,不需要 await 就已经"启动"了
    print(task1)  # <Task pending name='Task-1' coro=<fetch_data()>>
    
    # await 等待 Task 完成并获取结果
    result = await task1
    print(result)  # {'data': 42}

关键区别:

python 复制代码
async def main():
    # 顺序执行:先等 A 完成,再执行 B
    await asyncio.sleep(2)  # 等2秒
    await asyncio.sleep(2)  # 再等2秒
    # 总耗时:4秒

    # 并发执行:A 和 B 同时跑
    task_a = asyncio.create_task(asyncio.sleep(2))
    task_b = asyncio.create_task(asyncio.sleep(2))
    await task_a
    await task_b
    # 总耗时:2秒

3.3 Event Loop:一切的核心调度器

Event Loop 是异步程序的心脏,它做的事情本质上是一个无限循环:

复制代码
while True:
    1. 检查哪些 Task 已经就绪(I/O 完成、定时器到期等)
    2. 执行就绪的 Task,直到它遇到下一个 await
    3. 处理 I/O 事件(通过 select/epoll/kqueue)
    4. 重复

用代码感受一下 Event Loop 的存在:

python 复制代码
import asyncio

async def main():
    loop = asyncio.get_event_loop()
    print(f"当前 Event Loop:{loop}")
    print(f"是否在运行:{loop.is_running()}")

asyncio.run(main())

asyncio.run() 做了三件事:创建 Event Loop → 运行传入的协程直到完成 → 关闭 Event Loop。

3.4 三者协作的完整流程

复制代码
你的代码
  │
  ├─ 调用 async 函数 ──→ 生成 协程对象(coroutine)
  │
  ├─ asyncio.create_task() ──→ 包装成 Task,注册到 Event Loop
  │
  └─ asyncio.run() / await
          │
          ▼
      Event Loop
          │
          ├─ 调度 Task A(运行到 await,暂停)
          ├─ 调度 Task B(运行到 await,暂停)
          ├─ I/O 完成,Task A 就绪,恢复执行
          └─ Task A 完成,返回结果

四、如何向只写同步代码的同事解释"协程不是线程"

这是我在团队里真实遇到过的场景。下面是我用过的最有效的解释方式。

4.1 先破除误解

同事的直觉:"async 函数会在后台开一个新线程跑。"

验证一下:

python 复制代码
import asyncio
import threading

async def check_thread():
    print(f"协程运行在线程:{threading.current_thread().name}")
    await asyncio.sleep(0.1)
    print(f"恢复后还是线程:{threading.current_thread().name}")

async def main():
    print(f"主线程:{threading.current_thread().name}")
    await asyncio.gather(
        check_thread(),
        check_thread(),
        check_thread(),
    )

asyncio.run(main())

输出:

复制代码
主线程:MainThread
协程运行在线程:MainThread
协程运行在线程:MainThread
协程运行在线程:MainThread
恢复后还是线程:MainThread
恢复后还是线程:MainThread
恢复后还是线程:MainThread

三个协程,全部在同一个线程里运行。 这是铁证。

4.2 用"单线程的并发"来描述

我会这样跟同事说:

"线程是操作系统级别的并发,由 OS 调度,可以真正并行(多核)。协程是用户态的并发,由 Event Loop 调度,同一时刻只有一个协程在执行,但它们可以在等待 I/O 时互相切换,从而实现并发效果。"

4.3 展示最关键的差异:阻塞行为

python 复制代码
import asyncio
import time

# ❌ 错误示范:在协程里用同步阻塞调用
async def bad_task(name):
    print(f"[{name}] 开始")
    time.sleep(2)  # 这会阻塞整个 Event Loop!
    print(f"[{name}] 完成")

# ✅ 正确做法:用异步版本
async def good_task(name):
    print(f"[{name}] 开始")
    await asyncio.sleep(2)  # 只暂停当前协程,不阻塞其他
    print(f"[{name}] 完成")

async def main_bad():
    start = time.time()
    await asyncio.gather(bad_task("A"), bad_task("B"))
    print(f"bad 总耗时:{time.time() - start:.2f}s")  # 4秒

async def main_good():
    start = time.time()
    await asyncio.gather(good_task("A"), good_task("B"))
    print(f"good 总耗时:{time.time() - start:.2f}s")  # 2秒

asyncio.run(main_bad())
asyncio.run(main_good())

这个对比是最直观的教学工具。time.sleep() 会冻结整个 Event Loop,因为它阻塞了线程;asyncio.sleep() 只是告诉 Event Loop "我先歇一会儿,你去忙别的"。

4.4 一张对比表,贴在工位上

维度 线程(Thread) 协程(Coroutine)
调度者 操作系统 Event Loop(用户态)
切换时机 任意时刻(抢占式) 遇到 await(协作式)
内存开销 约 1-8 MB/个 约 几 KB/个
并发数量 数百到数千 轻松数万
适合场景 CPU 密集型 I/O 密集型
共享状态风险 需要锁(race condition) 单线程,相对安全
调试难度 较高(竞态条件) 中等(回调地狱已消失)

4.5 一个实战案例:并发抓取网页

python 复制代码
import asyncio
import aiohttp
import time

# 模拟 URL 列表
URLS = [f"https://httpbin.org/delay/1" for _ in range(5)]

async def fetch(session, url, idx):
    async with session.get(url) as response:
        data = await response.json()
        print(f"请求 {idx} 完成,状态码:{response.status}")
        return data

async def main():
    start = time.time()
    async with aiohttp.ClientSession() as session:
        tasks = [fetch(session, url, i) for i, url in enumerate(URLS)]
        results = await asyncio.gather(*tasks)
    print(f"5个请求总耗时:{time.time() - start:.2f}s")
    # 每个请求需要1秒,但并发执行,总耗时约1秒

asyncio.run(main())

如果用同步 requests 逐个请求,需要约 5 秒。异步并发只需约 1 秒。同一个线程,5倍的效率提升。


五、常见陷阱与最佳实践

陷阱1:忘记 await

python 复制代码
async def main():
    # ❌ 忘记 await,协程对象被创建但从未执行
    fetch_data()  # RuntimeWarning: coroutine 'fetch_data' was never awaited
    
    # ✅ 正确
    await fetch_data()

陷阱2:在协程中调用阻塞函数

python 复制代码
import asyncio
from concurrent.futures import ThreadPoolExecutor

# 对于无法避免的同步阻塞操作(如某些第三方库)
# 用 run_in_executor 放到线程池里跑
async def safe_blocking_call():
    loop = asyncio.get_event_loop()
    result = await loop.run_in_executor(None, time.sleep, 2)
    return result

陷阱3:混淆 gather 和顺序 await

python 复制代码
async def main():
    # 顺序执行,总耗时 = 各任务之和
    await task_a()
    await task_b()
    
    # 并发执行,总耗时 = 最长任务的耗时
    await asyncio.gather(task_a(), task_b())

六、总结

async/await 的本质是单线程内的协作式并发,三个核心概念的关系一句话概括:

协程对象 是执行计划,Task 是被调度的执行单元,Event Loop 是统筹一切的调度器。

协程不是线程,它不会神奇地并行执行 CPU 计算,但在 I/O 密集型场景(网络请求、文件读写、数据库查询)下,它能用极低的资源开销实现极高的并发吞吐。

理解了这一点,你就掌握了 Python 异步编程的灵魂。


互动讨论

  • 你在项目中有没有遇到过"把同步库用在异步代码里导致性能反而下降"的情况?是怎么排查和解决的?
  • 你认为 Python 的 GIL 和异步编程之间是什么关系?欢迎在评论区聊聊你的理解。

参考资料

相关推荐
程序员老乔2 小时前
Java 新纪元 — JDK 25 + Spring Boot 4 全栈实战(五):FFM API,告别JNI在Spring Boot中直连推荐引擎
java·开发语言·spring boot
va学弟2 小时前
Java 网络通信编程(7):完善视频通信
java·服务器·网络
fengpan20042 小时前
ubuntu下vscode使用串口
linux·运维·服务器
后青春期的诗go2 小时前
泛微OA-E9与第三方系统集成开发企业级实战记录(九)
java·金蝶·erp·泛微·oa·集成开发·e9
IMPYLH2 小时前
Linux 的 cut 命令
linux·运维·服务器·数据库
逸Y 仙X2 小时前
文章十:ElasticSearch索引字段高级属性
java·大数据·elasticsearch·搜索引擎·全文检索
阿贵---2 小时前
如何为开源Python项目做贡献?
jvm·数据库·python
NGC_66112 小时前
详解Java包装类
开发语言·windows·python
就叫飞六吧2 小时前
Tomcat /hvm类加载机制
java·笔记