协程的基本介绍
协程的概念
协程(Coroutine)是一种比线程更轻量级的用户态并发机制,它允许在函数执行过程中在指定位置挂起(suspend)并在之后恢复(resume)运行。协程完全由应用程序通过代码控制切换,避免了系统内核态切换的昂贵开销,特别适合高并发的 IO 密集型任务。
协程是通过协程函数实现的,每个协程任务的载体是协程函数。
核心概念详解:
-
用户态轻量级线程:协程运行在用户空间,由代码或程序库(如Go语言的goroutine,Kotlin协程)调度,不需要操作系统内核参与,因此创建和切换的成本极低。
-
挂起与恢复:与普通函数必须执行完毕再返回不同,协程可以在执行中暂停,让出 CPU 执行权,并在需要时从暂停的地方继续执行。
-
协作式多任务:一个线程内可以有多个协程,它们之间通常以协作的方式运行,即当前协程不主动出让控制权,其他协程就无法运行。
-
与线程的关系:一个进程中可以包含多个线程,一个线程中可以包含多个协程。线程是操作系统调度的原子单元,而协程是线程调度的原子单元。
进程、线程与协程区别
- 进程是操作系统资源分配的基本单位,每个进程都有自己独立的进程空间。
- 线程又叫轻量级的进程实体,是操作系统任务调度和执行最小单位,每个线程都有自己的线程栈,线程栈的大小一般为8M,线程与同属于一个进程的其它线程共享进程所有的资源
- 协程,也可以叫做微线程,是一种用户态的轻量级线程,重点是用户态,即协程的调度与操作系统无关,完全由用户自己控制,协程拥有自己的协程栈和寄存器上下文。
协程的关键词
说起协程,我们应该想起一些关键词,这些词与协程息息相关。主要由IO密集型任务、事件循环、高并发、节省资源。
IO密集型任务:这里要对上层任务的类型进行一个分类,任务分为CPU密集型任务和IO密集型任务,对于协程过来说仅适用于IO密集型任务。对于CPU密集型任务,协程的使用无法提高效率。
事件循环:协程的实现基于一个事件循环,脱离事件循环,协程无法实现。这个事件循环不需要我们自己实现,调用第三方的协程库相关接口即可
高并发:协程的应用能够极大的提高并发性,与线程对比而言,一个线程处理一个连接,假设需要同时处理10000个连接,按照1个线程8M的内存消耗计算,那么需要大量80G内存来处理,但是使用协程,一个协程只有几KB的内存消耗,只需要几百兆的资源消,能够极大地减少内存消耗,从而提高并发性能,重点是一个线程可以运行很多个协程。
协程的核心思想
协程的本质:
在"等待 I/O"的时候
主动让出 CPU 给其他任务
等 I/O 完成后再回来继续执行
┌─────────────────────────────────────────┐
│ │
│ 线程:被动挂起 │
│ 由操作系统决定什么时候切换 │
│ 你没有控制权 │
│ │
│ 协程:主动让出 │
│ 由程序员(或框架)决定什么时候切换 │
│ 你有完全控制权 │
│ │
└─────────────────────────────────────────┘
协程存在的意义如下:
协程解决的核心矛盾:
现代应用的瓶颈在 I/O,不在 CPU
而线程是为 CPU 并行设计的
用线程处理 I/O 并发 = 用卡车送外卖
协程专为 I/O 并发设计
用极小的资源代价
实现极高的并发吞吐量
同时保持代码的可读性
这就是协程存在的全部意义
线程切换开销为什么大?
线程A 正在运行,操作系统决定切换到 线程B:
第一步:触发内核态切换
─────────────────────────────────────────────
线程A 执行系统调用 或 时间片到期
│
│ CPU 从 Ring3 → Ring0
│ 这一步本身就需要:
│ ① 检查权限
│ ② 切换 CPU 特权级
│ ③ 跳转到内核代码入口
↓
进入内核态
第二步:保存线程A的完整上下文
─────────────────────────────────────────────
需要保存的内容:
CPU 通用寄存器(x86_64 有16个):
RAX, RBX, RCX, RDX
RSI, RDI, RSP, RBP
R8 ~ R15
共 16 × 8字节 = 128字节
程序计数器 PC(下一条指令地址)
栈指针 SP
标志寄存器 RFLAGS
浮点/SIMD 寄存器(如果用了):
XMM0~XMM15 = 16 × 16字节 = 256字节
YMM/ZMM 更大
→ 全部写入内核维护的 PCB(进程控制块)
第三步:切换虚拟内存页表(如果跨进程)
─────────────────────────────────────────────
切换 CR3 寄存器(页表基址)
│
↓
TLB(地址转换缓存)全部失效!!
│
↓
之后每次内存访问都要重新查页表
直到 TLB 被重新填满
(这是最大的隐性开销之一)
第四步:调度器选择下一个线程
─────────────────────────────────────────────
操作系统调度算法运行
从就绪队列中选出线程B
这本身也需要 CPU 时间
第五步:恢复线程B的上下文
─────────────────────────────────────────────
从线程B的 PCB 中读取所有寄存器
恢复栈指针、程序计数器
CPU 从 Ring0 → Ring3
跳转到线程B上次暂停的位置
第六步:CPU 流水线污染
─────────────────────────────────────────────
现代 CPU 有分支预测、指令预取
切换后 CPU 缓存(L1/L2/L3)中
全是线程A的数据
线程B开始执行时大量 Cache Miss
需要重新从内存加载数据
协程开销为什么小?
协程A 执行到 await,主动让出给 协程B:
第一步:没有内核态切换!
─────────────────────────────────────────────
整个过程始终在用户态 Ring3 运行
操作系统完全不知道这件事发生了
没有特权级切换
没有系统调用
第二步:只保存极少的寄存器
─────────────────────────────────────────────
协程是"主动让出"
让出点是程序员明确指定的
(await / yield 的位置)
因此只需要保存:
栈指针 SP → 知道栈在哪
程序计数器 PC → 知道下次从哪继续
少量调用者保存寄存器
不需要保存:
✗ 浮点寄存器(协程自己知道用没用)
✗ 大量通用寄存器(编译器优化)
第三步:切换栈指针(核心操作)
─────────────────────────────────────────────
协程A 的栈 ──→ 保存 SP 到协程A控制块
协程B 的栈 ──→ 从协程B控制块恢复 SP
本质上就是:
mov [coroutineA.sp], rsp ; 保存A的栈
mov rsp, [coroutineB.sp] ; 加载B的栈
jmp [coroutineB.pc] ; 跳转到B
几条汇编指令就完成了!
第四步:不影响 TLB 和 CPU Cache
─────────────────────────────────────────────
同一个线程内切换
虚拟内存页表不变
TLB 完全不失效!
协程之间共享同一个线程的内存空间
CPU Cache 中的数据大概率还有效
Cache Miss 极少
与线程进行对比;
┌─────────────────────────────────────────┐
│ 协程切换的开销 │
│ │
│ ① 无特权级切换 ← 直接省掉 │
│ ② 只保存几个寄存器 ← 纳秒级 │
│ ③ TLB 完全不失效 ← 直接省掉 │
│ ④ 无调度器介入 ← 直接省掉 │
│ ⑤ Cache 基本不污染 ← 几乎省掉 │
│ │
│ 实测开销:约 10ns ~ 100ns │
│ (比线程切换快 100 倍) │
└─────────────────────────────────────────┘
python中协程的关键字
async def:协程函数或者协程任务的定义
# 普通函数
def normal_func():
return 42
# 协程函数(加上 async 就变成协程函数)
async def coroutine_func():
return 42两者的本质区别:
normal_func()
→ 直接执行,返回 42coroutine_func()
→ 不会立即执行!
→ 返回一个"协程对象"
→ 必须被 await 或交给事件循环才会真正运行
await — 挂起并等待结果
请看下面的例子:
async def main():
result = await some_coroutine()
await 做了三件事:
① 挂起当前协程
→ 把控制权交还给事件循环
→ 当前协程暂停在这一行② 等待右边的对象完成
→ 右边必须是"可等待对象"(Awaitable)
→ 完成后把结果返回给左边的变量③ 恢复执行
→ 事件循环在合适时机唤醒此协程
→ 从 await 的下一行继续执行可等待对象(Awaitable)有三种:
─────────────────────────────────────────
① 协程对象 async def 定义的函数调用结果
② asyncio.Task 被 create_task 包装的协程
③ asyncio.Future 底层的异步原语
async with — 异步上下文管理器
# 普通 with(同步)
with open("file.txt") as f:
data = f.read()
# async with(异步)
async with aiofiles.open("file.txt") as f:
data = await f.read()
为什么需要 async with?
普通 with 的 __enter__ 和 __exit__ 是同步的
如果进入/退出资源需要 I/O(比如建立数据库连接)
就会阻塞整个线程async with 的 __aenter__ 和 __aexit__ 是异步的
进入和退出时可以 await,不阻塞事件循环
async for — 异步迭代器
# 普通 for(同步)
for item in some_list:
process(item)
# async for(异步)
async for item in async_generator():
await process(item)
为什么需要 async for?
普通 for 循环的 __next__ 是同步的
如果每次获取下一个元素需要 I/O
(比如从数据库逐行读取、从网络流读取数据)
就需要 async forasync for 的 __anext__ 是异步的
每次取下一个元素时可以 await
yield in async — 异步生成器
# async def + yield = 异步生成器函数
async def async_generator():
for i in range(3):
await asyncio.sleep(1)
yield i # 产出值,同时挂起
异步生成器 = async def + yield 的组合
普通生成器: def f(): yield x → 用 for 迭代
异步生成器: async def f(): yield x → 用 async for 迭代每次 yield 时:
① 产出当前值给调用方
② 挂起自己,等待下一次 __anext__ 调用
③ 恢复时从 yield 下一行继续
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐

所有评论(0)