14协程

14协程

_

注:学习资料来源<尚硅谷>

视频:点击跳转

代码仓库:pythonStudy

一、协程是什么

协程(Coroutine)是一种运行在线程内部的任务调度机制。它的核心思想并不是创建更多线程,而是在同一个线程中,以用户态的方式完成多个任务之间的切换。当某个任务遇到 I/O 等待时,例如网络请求、文件读写、数据库访问,这段等待时间并不会继续占用 CPU,而是由事件循环接管执行权,转去调度其他可运行的任务,从而提升整体吞吐能力。

从本质上看,协程是在一个线程内,对多个任务进行有组织的切换。它并不依赖操作系统内核调度,因此相比线程切换,其上下文切换成本通常更低,特别适合 I/O 密集型场景。

协程的关键价值并不在于“让代码同时运行”,而在于“在等待期间不浪费 CPU”。这也是异步编程模型能够高效处理高并发 I/O 请求的重要原因。


二、协程的几个核心认知

1. 协程不是线程,也不是进程

协程并不是操作系统原生提供的调度单位。操作系统调度的是进程和线程,而不是协程。换句话说,CPU 看不见协程,内核也不知道协程的存在。

协程完全工作在用户态,其调度逻辑由程序自身实现。开发者通过语言提供的异步语法、运行时以及事件循环机制,构建出一套任务挂起与恢复的流程。

因此,协程不是“更轻量的线程”这么简单,更准确地说,它是一种用户态的协作式调度模型。

2. 协程发生在线程内部

协程的切换不是线程之间的切换,而是一个线程内部多个任务之间的切换。一个线程可以只运行一个事件循环,而该事件循环负责调度多个协程任务。

这意味着协程并不会自动利用多核 CPU。默认情况下,一个事件循环通常只绑定一个线程。因此,协程模型擅长解决 I/O 阻塞问题,但并不天然适合重度 CPU 密集型计算。

3. 协程的核心能力是挂起与恢复

当协程执行到 I/O 等待点时,它不会像同步代码那样阻塞线程,而是主动挂起当前任务,把控制权交还给事件循环。待 I/O 完成后,事件循环再将该任务恢复到原来中断的位置继续执行。

这种“可暂停、可恢复”的执行模型,是协程最重要的能力。

4. 协程离不开事件循环

事件循环(Event Loop)是协程运行时的调度中枢,可以视作整个异步系统的“大脑”。它负责以下几类工作:

  • 接收并管理协程任务

  • 监控 I/O 事件是否就绪

  • 在适当的时机挂起任务

  • 在 I/O 完成后恢复任务执行

  • 维护任务队列、回调队列和定时器

没有事件循环,协程对象本身并不会自动执行。

5. 协程的目标是减少无意义等待

协程的目标并不是替代所有并发模型,而是在单线程环境中尽可能减少等待带来的资源浪费。尤其在大量网络请求、API 调用、消息队列处理、爬虫、异步 Web 服务等场景中,协程的优势十分明显。


三、协程运行机制示意

下面通过 Mermaid 图展示协程在 I/O 场景下的调度逻辑。

flowchart TD A[协程开始执行] --> B{遇到I/O操作?} B -- 否 --> C[继续执行当前协程] C --> D[协程结束] B -- 是 --> E[挂起当前协程] E --> F[控制权交给事件循环] F --> G[调度其他可运行任务] G --> H{I/O完成?} H -- 否 --> G H -- 是 --> I[恢复原协程] I --> C

这个流程图体现了协程并发的关键特征:并不是多个任务真的同时占用一个线程执行,而是在等待期间不断切换,让线程保持忙碌。


四、协程函数与协程对象

在 Python 中,使用 async def 定义的函数称为协程函数(Coroutine Function)。调用协程函数后,不会立即执行函数体,而是返回一个协程对象(Coroutine Object)。

这是一条非常重要的规则:调用协程函数,只是创建协程对象,不会真正运行其中的代码。

示例:协程函数与协程对象

 import asyncio
 ​
 # 定义协程函数
 async def work():
     print("work开始")
     print("work执行中......")
     print("work结束")
     return "工作结果"
 ​
 # 调用协程函数,得到的是协程对象,而不是立即执行
 coroutine_object = work()
 ​
 # 将协程对象交给事件循环执行
 result = asyncio.run(coroutine_object)
 print(result)

输出逻辑分析

在执行 work() 这一行时,并不会打印任何内容,因为此时只是创建了一个协程对象。只有当这个协程对象被交给事件循环,例如通过 asyncio.run() 执行时,函数体中的代码才会真正开始运行。

asyncio.run() 的作用

asyncio.run() 是异步程序中最常见的入口函数,它通常完成以下三件事:

  1. 创建事件循环

  2. 将传入的协程对象包装为任务并交由事件循环调度

  3. 启动事件循环,直到协程执行完成

执行结束后,会返回协程函数中的 return 结果。


五、协程对象的执行关系图

async def 定义协程函数

调用协程函数

得到协程对象

交给 asyncio.run 或事件循环

包装为任务

事件循环调度执行

返回结果

这个过程揭示了异步编程中一个常见误区:协程对象不是结果本身,而是“未来可被执行的异步任务描述”。


六、await 关键字的本质

await 是 Python 异步编程中最关键的语法之一,其本质可以概括为三个动作:挂起、等待、恢复。

1. 挂起当前协程

当执行到 await 时,当前协程会暂停,控制权让出。

2. 等待后面的可等待对象执行完成

await 后面必须是“可等待对象”(Awaitable)。常见的可等待对象包括:

  • 协程对象(coroutine object)

  • 任务对象(Task)

  • Future 对象(Future)

3. 恢复原协程继续执行

一旦被等待的对象执行结束,事件循环会恢复原协程,并将结果返回给 await 表达式左侧变量。


七、await 的调度行为解析

很多初学者会误以为只要写了 await,程序就一定会“并发起来”。实际上并非如此。

真正决定是否发生任务切换的关键,不是单纯出现了 await,而是 await 后面的对象在执行过程中,是否产生了 I/O 等待。

情况一:await 到了 I/O 操作

如果等待的是一个真正包含 I/O 挂起点的任务,例如:

  • 网络请求

  • 文件读写

  • 数据库查询

  • asyncio.sleep()

那么当前协程会暂停,事件循环能够利用这段空闲时间去执行其他任务。

情况二:await 到了不发生 I/O 的代码

如果被等待对象内部只是普通计算、打印、字符串处理、数学运算等同步逻辑,那么即使写了 await,也不意味着一定能让出 CPU。因为没有实际的挂起点,事件循环也无法切换到其他任务。

这说明一个重要事实:协程并不是自动加速器,异步收益只在 I/O 密集场景下明显。


八、await 基础示例

 import asyncio
 ​
 async def work():
     print("work开始")
     print("work执行中......")
     res = await asyncio.sleep(2)
     print(res)
     print("work结束")
     return "工作结果"
 ​
 async def main():
     print("main开始")
     res = await work()
     print(res)
     print("main结束")
     return "main的返回值"
 ​
 result = asyncio.run(main())
 print(result)

代码分析

这里的 asyncio.sleep(2) 并不是阻塞线程的 time.sleep(2),而是一个异步休眠操作。执行到这里时,当前协程会挂起 2 秒,事件循环可以趁机去处理其他任务。

需要特别区分:

  • time.sleep(2):阻塞线程

  • await asyncio.sleep(2):挂起协程,不阻塞事件循环


九、await 执行过程图

sequenceDiagram participant C1 as 当前协程 participant EL as 事件循环 participant IO as I/O任务 C1->>EL: 执行到 await EL->>IO: 启动 I/O 操作 C1-->>EL: 挂起当前协程 EL->>EL: 调度其他任务 IO-->>EL: I/O完成 EL-->>C1: 恢复原协程 C1->>C1: 继续执行 await 之后的代码


十、多个任务同步执行

即使使用的是协程,如果写法上是一个接一个地 await,那么整体执行效果仍然是同步的。也就是说,前一个任务不完成,后一个任务不会开始。

示例:顺序等待多个协程

 import asyncio
 import time
 ​
 async def work(n, delay):
     print(f"work{n}开始")
     print(f"work{n}执行中......")
     await asyncio.sleep(delay)
     print(f"work{n}结束")
     return f"work{n}的返回值"
 ​
 async def main():
     print("main开始")
     start = time.time()
 ​
     coroutine1 = work(1, 2)
     coroutine2 = work(2, 2)
     coroutine3 = work(3, 2)
 ​
     res1 = await coroutine1
     print(res1)
 ​
     res2 = await coroutine2
     print(res2)
 ​
     res3 = await coroutine3
     print(res3)
 ​
     print("main结束", time.time() - start)
     return "我是main的返回值"
 ​
 result = asyncio.run(main())
 print(result)

结果分析

虽然这里创建了三个协程对象,但它们并没有被同时调度。因为代码是按顺序 await 的:

  • 先等 coroutine1

  • 再等 coroutine2

  • 最后等 coroutine3

总耗时通常约为 6 秒,而不是 2 秒。

关键结论

仅仅“有多个协程对象”并不等于并发。 真正的并发执行,需要将这些协程注册到事件循环中,让它们同时进入可调度状态。


十一、同步等待的时序图

gantt title 多个协程顺序执行示意 dateFormat X axisFormat %s section 同步等待 work1 :a1, 0, 2s work2 :a2, 2, 2s work3 :a3, 4, 2s


十二、多个任务异步执行:asyncio.create_task()

如果希望多个协程并发执行,可以使用 asyncio.create_task()。它会将协程对象包装为任务对象(Task),并立即注册到当前事件循环中,使其进入可调度状态。

示例:使用 create_task() 并发执行

 import asyncio
 import time
 ​
 async def work(n, delay):
     print(f"work{n}开始")
     print(f"work{n}执行中......")
     await asyncio.sleep(delay)
     print(f"work{n}结束")
     return f"work{n}的返回值"
 ​
 async def main():
     print("main开始")
     start = time.time()
 ​
     task1 = asyncio.create_task(work(1, 2))
     task2 = asyncio.create_task(work(2, 2))
     task3 = asyncio.create_task(work(3, 2))
 ​
     res1 = await task1
     print(res1)
 ​
     res2 = await task2
     print(res2)
 ​
     res3 = await task3
     print(res3)
 ​
     print("main结束", time.time() - start)
     return "我是main的返回值"
 ​
 result = asyncio.run(main())
 print(result)

为什么这种写法会更快

虽然结果仍然是按 task1task2task3 的顺序获取,但三个任务在创建完成后,就已经被提交给事件循环了。也就是说,它们的 I/O 等待阶段是重叠发生的。

因此,总耗时通常接近 2 秒,而不是 6 秒。

Task 的本质

Task 是对协程对象的进一步封装。它具备以下特点:

  • 可被事件循环直接调度

  • 可以查询状态

  • 可以取消

  • 可以等待结果

  • 可以纳入更复杂的异步控制流


十三、并发任务的调度图

gantt title 多个协程并发执行示意 dateFormat X axisFormat %s section 并发等待 work1 :a1, 0, 2s work2 :a2, 0, 2s work3 :a3, 0, 2s

可以看到,三个任务虽然都需要等待 2 秒,但它们的等待时间是重叠的,因此总时长大幅下降。


十四、asyncio.gather() 批量并发执行

在需要同时运行多个协程,并在它们全部完成后统一收集结果时,asyncio.gather() 是非常实用的工具。

示例:使用 gather() 收集多个结果

import asyncio
import time

async def work(n, delay):
    print(f"work{n}开始")
    print(f"work{n}执行中......")
    await asyncio.sleep(delay)
    print(f"work{n}结束")
    return f"work{n}的返回值"

async def main():
    print("main开始")
    start = time.time()

    result = await asyncio.gather(
        work(1, 2),
        work(2, 2),
        work(3, 2)
    )

    print(result)
    print("main结束", time.time() - start)
    return "我是main的返回值"

result = asyncio.run(main())
print(result)

gather() 的特点

asyncio.gather() 会将多个可等待对象统一提交给事件循环,并等待它们全部完成。最终返回值是一个列表,结果顺序与传入参数顺序一致,而不取决于实际完成先后顺序。

例如,work(2) 可能先完成,但如果它在参数列表中排第二,结果仍然会出现在结果列表的第二个位置。

适用场景

gather() 适用于以下场景:

  • 批量 API 调用

  • 多资源并发下载

  • 并行采集多个页面数据

  • 聚合多个异步子任务结果


十五、create_task()gather() 的区别

两者都能实现并发,但适用重点略有不同。

asyncio.create_task()

适合先创建任务,再在后续流程中按需等待、取消或跟踪状态。控制粒度更细。

asyncio.gather()

适合“一次性并发执行一组任务,并在全部完成后统一收集结果”的场景。书写简洁、语义清晰。


十六、三种执行方式对比

flowchart TD A[多个协程任务] --> B{采用哪种方式执行?} B --> C[逐个 await] B --> D[create_task] B --> E[gather] C --> F[顺序执行<br/>总耗时累加] D --> G[并发执行<br/>可单独控制任务] E --> H[并发执行<br/>统一收集结果]


十七、传统同步下载图片

在同步模型中,一张图片没有下载完成,下一张图片就无法开始下载。这种方式代码简单,但吞吐能力较低。

示例:同步方式下载

import requests

def download_picture(url):
    print(f"开始下载:{url}")
    response = requests.get(url)
    print("下载完毕")

    with open(url[-10:], "wb") as file:
        file.write(response.content)

def main():
    url_list = [
        "https://n.sinaimg.cn/spider20260129/217/w600h417/20260129/3e26-917ee55a8a42b8626807c332c24981de.png",
        "https://n.sinaimg.cn/finance/transform/97/w630h267/20260129/97c4-b211cc51784830f09ee19e450475c93b.png",
        "https://n.sinaimg.cn/spider20260129/539/w1439h700/20260129/e09a-cc2ca319e00f701ccfca3ebc62aa8772.png"
    ]

    for url in url_list:
        download_picture(url)

main()

同步模型的问题

这种写法中,每次 requests.get(url) 都会阻塞当前线程,直到网络响应完整返回。在此期间,程序无法继续处理其他下载任务。因此,总耗时大致接近每张图片下载时间之和。


十八、使用协程并发下载图片

在异步模型中,多个下载请求可以几乎同时发起。当某个请求处于网络等待阶段时,事件循环可以切换去处理其他请求,从而提升整体下载效率。

示例:使用 aiohttpasyncio

import aiohttp
import asyncio

async def download_picture(session, url):
    print(f"开始下载:{url}")

    response = await session.get(url)
    content = await response.read()

    print("下载完毕")

    with open(url[-10:], "wb") as file:
        file.write(content)

    await response.release()

async def main():
    url_list = [
        "https://n.sinaimg.cn/spider20260129/217/w600h417/20260129/3e26-917ee55a8a42b8626807c332c24981de.png",
        "https://n.sinaimg.cn/finance/transform/97/w630h267/20260129/97c4-b211cc51784830f09ee19e450475c93b.png",
        "https://n.sinaimg.cn/spider20260129/539/w1439h700/20260129/e09a-cc2ca319e00f701ccfca3ebc62aa8772.png"
    ]

    session = aiohttp.ClientSession()

    coroutine_list = [download_picture(session, url) for url in url_list]

    await asyncio.gather(*coroutine_list)

    await session.close()

asyncio.run(main())

十九、异步下载的执行原理

异步下载的高效性主要来自两个阶段的非阻塞处理。

首先,session.get(url) 发起请求后,需要等待服务端返回响应头,这一段属于典型网络 I/O 等待。其次,response.read() 读取响应体时,也可能经历多次网络分片到达的等待。这两次等待都可以让出控制权。

这样一来,多个下载任务会在事件循环中交错推进,而不是一个任务完整结束后再轮到下一个任务。


二十、同步下载与异步下载对比图

sequenceDiagram participant P as 程序 participant U1 as 图片1 participant U2 as 图片2 participant U3 as 图片3 rect rgb(245,245,245) Note over P,U3: 同步下载 P->>U1: 请求图片1 U1-->>P: 返回图片1 P->>U2: 请求图片2 U2-->>P: 返回图片2 P->>U3: 请求图片3 U3-->>P: 返回图片3 endsequenceDiagram participant EL as 事件循环 participant U1 as 图片1 participant U2 as 图片2 participant U3 as 图片3 rect rgb(235,248,255) Note over EL,U3: 异步下载 EL->>U1: 请求图片1 EL->>U2: 请求图片2 EL->>U3: 请求图片3 U2-->>EL: 返回图片2 U1-->>EL: 返回图片1 U3-->>EL: 返回图片3 end


二十一、协程适合什么场景

协程最适合 I/O 密集型任务,例如:

  • HTTP 请求与接口聚合

  • 网络爬虫

  • 文件传输

  • 消息消费

  • WebSocket 通信

  • 数据库异步访问

  • 微服务间高并发调用

这些任务的共同特点是:CPU 计算量不大,但等待外部资源的时间较长。协程能有效利用这些等待空隙,提高线程利用率。


二十二、协程不适合什么场景

协程并不适合重度 CPU 密集型任务,例如:

  • 大规模数值计算

  • 图像编码解码

  • 复杂加密解密

  • 大型机器学习推理

  • 长时间不发生挂起的纯 Python 循环

原因在于,协程调度依赖主动让出执行权。如果任务长时间只做计算而不触发 await,事件循环就无法切换到其他任务,整个线程仍然会被占满。

对于 CPU 密集型任务,更适合考虑:

  • 多进程

  • 原生线程配合释放 GIL 的 C 扩展

  • 分布式计算框架


二十三、协程开发中的常见误区

1. 把协程当成线程

协程不是系统级并发单位,不会自动并行使用多核 CPU。它解决的是 I/O 等待问题,而不是 CPU 并行问题。

2. 以为 async def 一定义就会执行

async def 定义的是协程函数,调用后得到的是协程对象,必须交给事件循环才能运行。

3. 误把阻塞函数放进协程

例如在协程内部直接写 time.sleep(3)、同步文件操作、同步网络请求,这会阻塞整个事件循环,破坏异步模型的价值。

错误示例:

 import asyncio
 import time
 ​
 async def bad_task():
     print("开始阻塞")
     time.sleep(3)   # 错误:阻塞事件循环
     print("阻塞结束")
 ​
 asyncio.run(bad_task())

更合理的写法:

import asyncio

async def good_task():
    print("开始异步等待")
    await asyncio.sleep(3)
    print("等待结束")

asyncio.run(good_task())

4. 认为多个协程天然并发

只有当多个协程被注册到事件循环,并且在运行中包含可挂起的 I/O 等待点时,才可能体现出并发优势。


二十四、协程调度全景图

flowchart TB A[主程序入口] --> B[创建协程对象] B --> C[提交给事件循环] C --> D[包装为Task/Future] D --> E[事件循环开始调度] E --> F{任务遇到await I/O?} F -- 否 --> G[继续执行当前任务] F -- 是 --> H[挂起当前任务] H --> I[调度其他就绪任务] I --> J{I/O完成?} J -- 否 --> I J -- 是 --> K[恢复任务] K --> G G --> L{任务是否结束?} L -- 否 --> F L -- 是 --> M[返回结果]


二十五、实战层面的理解方式

理解协程最有效的方式,不是把它看成“更高级的线程”,而是把它看成一种“面向等待的编程模型”。

在同步程序中,等待是一种阻塞。 在协程程序中,等待是一种调度机会。

这两者的差异,直接决定了系统在高并发 I/O 场景中的吞吐表现。

当任务的大部分时间都花在等待远程响应,而不是本地计算时,协程往往可以以更低的线程成本,支撑更高数量的并发连接和任务。


二十六、总结

协程是一种在线程内部运行的用户态调度机制,其核心能力是让任务在 I/O 等待时挂起,并在等待完成后恢复。整个过程由事件循环统一调度,因此协程特别适合 I/O 密集型应用。

从 Python 的使用角度来看,掌握协程通常需要理解以下几个核心概念:

  • async def 用于定义协程函数

  • 调用协程函数得到的是协程对象

  • await 用于挂起当前协程并等待可等待对象完成

  • asyncio.run() 用于启动事件循环

  • asyncio.create_task() 用于将协程注册为可调度任务

  • asyncio.gather() 用于批量并发执行并统一收集结果

协程并不是所有并发问题的通用解法,但在网络请求、异步服务、并发下载、爬虫采集等典型 I/O 密集场景中,它是一种非常重要且高效的技术方案。

13进程与线程 2026-04-26

评论区