注:学习资料来源<尚硅谷>
视频:点击跳转
代码仓库: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 场景下的调度逻辑。
这个流程图体现了协程并发的关键特征:并不是多个任务真的同时占用一个线程执行,而是在等待期间不断切换,让线程保持忙碌。
四、协程函数与协程对象
在 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() 是异步程序中最常见的入口函数,它通常完成以下三件事:
创建事件循环
将传入的协程对象包装为任务并交由事件循环调度
启动事件循环,直到协程执行完成
执行结束后,会返回协程函数中的 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 执行过程图
十、多个任务同步执行
即使使用的是协程,如果写法上是一个接一个地 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 秒。
关键结论
仅仅“有多个协程对象”并不等于并发。 真正的并发执行,需要将这些协程注册到事件循环中,让它们同时进入可调度状态。
十一、同步等待的时序图
十二、多个任务异步执行: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)为什么这种写法会更快
虽然结果仍然是按 task1、task2、task3 的顺序获取,但三个任务在创建完成后,就已经被提交给事件循环了。也就是说,它们的 I/O 等待阶段是重叠发生的。
因此,总耗时通常接近 2 秒,而不是 6 秒。
Task 的本质
Task 是对协程对象的进一步封装。它具备以下特点:
可被事件循环直接调度
可以查询状态
可以取消
可以等待结果
可以纳入更复杂的异步控制流
十三、并发任务的调度图
可以看到,三个任务虽然都需要等待 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()
适合“一次性并发执行一组任务,并在全部完成后统一收集结果”的场景。书写简洁、语义清晰。
十六、三种执行方式对比
十七、传统同步下载图片
在同步模型中,一张图片没有下载完成,下一张图片就无法开始下载。这种方式代码简单,但吞吐能力较低。
示例:同步方式下载
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) 都会阻塞当前线程,直到网络响应完整返回。在此期间,程序无法继续处理其他下载任务。因此,总耗时大致接近每张图片下载时间之和。
十八、使用协程并发下载图片
在异步模型中,多个下载请求可以几乎同时发起。当某个请求处于网络等待阶段时,事件循环可以切换去处理其他请求,从而提升整体下载效率。
示例:使用 aiohttp 和 asyncio
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() 读取响应体时,也可能经历多次网络分片到达的等待。这两次等待都可以让出控制权。
这样一来,多个下载任务会在事件循环中交错推进,而不是一个任务完整结束后再轮到下一个任务。
二十、同步下载与异步下载对比图
二十一、协程适合什么场景
协程最适合 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 等待点时,才可能体现出并发优势。
二十四、协程调度全景图
二十五、实战层面的理解方式
理解协程最有效的方式,不是把它看成“更高级的线程”,而是把它看成一种“面向等待的编程模型”。
在同步程序中,等待是一种阻塞。 在协程程序中,等待是一种调度机会。
这两者的差异,直接决定了系统在高并发 I/O 场景中的吞吐表现。
当任务的大部分时间都花在等待远程响应,而不是本地计算时,协程往往可以以更低的线程成本,支撑更高数量的并发连接和任务。
二十六、总结
协程是一种在线程内部运行的用户态调度机制,其核心能力是让任务在 I/O 等待时挂起,并在等待完成后恢复。整个过程由事件循环统一调度,因此协程特别适合 I/O 密集型应用。
从 Python 的使用角度来看,掌握协程通常需要理解以下几个核心概念:
async def用于定义协程函数调用协程函数得到的是协程对象
await用于挂起当前协程并等待可等待对象完成asyncio.run()用于启动事件循环asyncio.create_task()用于将协程注册为可调度任务asyncio.gather()用于批量并发执行并统一收集结果
协程并不是所有并发问题的通用解法,但在网络请求、异步服务、并发下载、爬虫采集等典型 I/O 密集场景中,它是一种非常重要且高效的技术方案。