协程的概念

协程(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()
        → 直接执行,返回 42

    coroutine_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 for

    async 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 下一行继续

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐