tinyCoroLab Docs
直通tinyCoroLab源码直通tinyCoro源码
latest
latest
  • 🚀开启你的tinyCoroLab之旅!
  • 📮致读者
  • 👊C++协程入门
    • 协程初探
    • 有栈协程VS无栈协程
    • C++协程入门实践
    • 从编译器视角揭秘 C++ 协程
    • 协程调用优化
  • 🌟认识io_uring
  • 📖tinyCoroLab实验介绍
  • 📘Lab1 构建协程任务封装
    • Lab1 构建协程任务封装
    • Lab1 实验解析
  • 📗Lab2 构建任务执行引擎
    • Lab2a 构建任务执行引擎engine
    • Lab2a 实验解析
    • Lab2b 构建任务执行引擎context
    • Lab2b 实验解析
  • 📙Lab3 封装异步I/O执行模块
  • 📕Lab4 构建基础协程同步组件
    • Lab4pre 如何构建协程同步组件
    • Lab4a 构建基础协程同步组件event
    • Lab4a 实验解析
    • Lab4b 构建基础协程同步组件latch
    • Lab4b 实验解析
    • Lab4c 构建基础协程同步组件wait_group
    • Lab4c 实验解析
    • Lab4d 构建基础协程同步组件mutex
    • Lab4d 实验解析
  • 📓Lab5 构建进阶协程同步组件
    • Lab5a(选做) 构建进阶协程同步组件when_all
    • Lab5a 实验解析
    • Lab5b 构建进阶协程同步组件condition_variable
    • Lab5b 实验解析
    • Lab5c 构建进阶协程同步组件channel
    • Lab5c 实验解析
  • ✨tinyCoro Bonus Lab
  • 🎯tinyCoro悬赏令
  • ⚔️面试实战
    • 面试实战
    • tinyCoro面试相关问题
  • 🚩实验总结-终点亦是起点
  • 🪐番外杂谈
    • 从编译器视角揭秘 C++ 协程
    • 协程调用优化
  • 📌更新日志
Powered by GitBook
On this page
  • 无栈协程 vs 有栈协程
  • 什么是有栈协程和无栈协程
  • 为什么无栈协程更轻量
  • 无栈协程也有缺点
  • 对称协程 vs 非对称协程
  • 定义
  • 📋 表格对比
  • 为什么说 C++ 协程是非对称的?
  • 为什么现代语言更偏爱非对称协程
  • 1. 控制权更明确
  • 2. 简化堆栈管理,适配异步编程
  • 3. 错误传播更自然
  • 4. 容易组合(composability)
  • 📚 现实例子
  • ✨总结一句话
  • 各种语言协程分类
  • 为什么 Go 要使用有栈协程
  • 📚 为什么 goroutine 选择有栈模型?
  • 1. 为了支持任意复杂的函数调用和递归
  • 2. 为了保持程序员体验 —— “同步式写法,异步式运行”
  • 3. 配合调度器(M:N 调度模型)高效管理成千上万协程
  • 4. 动态栈扩展机制
  • 🎯对比一下,如果是无栈协程会怎样?
  • Go 为什么从链式栈→连续栈
  • 📚 先理解两种栈模型
  • ✨ 1. 链式栈(Segmented Stack)
  • ✨ 2. 连续栈(Contiguous Stack)
  • 🔥 总结区别
  • 🎯 为什么 Go 最终选择了连续栈?
  • io_uring vs epoll
  • 🔥 io_uring 相比 epoll 的特性总结
  • 1. io_uring 是全异步的、真正异步 IO
  • 2. io_uring 是批量、共享内存、极低系统调用
  • 3. io_uring 可以做链式 IO 操作(Linked SQE)
  • io_uring vs 传统 AIO
  • 📚 传统 Linux AIO 和 io_uring 的本质区别
  • io_uring 细节
  • SQE 里有什么
  • CQE 里有什么
  • 系统调用
  • 等待 IO 请求完成
  • io_uring 什么时候发生系统调用
  • 🔥 大致流程
  • 🔥 只有两种情况下才有额外系统调用
  • 🎁bonus
  • io_uring 高级特性
  • io_uring 的无锁队列
  • 1. 无锁接口(Lock-less Access Interface)
  • io_uring 中的无锁接口:
  • 2. 内存屏障(Memory Barriers)
  • 内存屏障的作用:
  • 常见类型的内存屏障:
  • io_uring 中的内存屏障:
  1. ⚔️面试实战

tinyCoro面试相关问题

💡本篇文档由电子科大邓同学贡献

无栈协程 vs 有栈协程

什么是有栈协程和无栈协程

  1. 有栈协程:

    • 每个协程自己有一块单独的栈(通常几 KB 到几 MB),用于保存局部变量、函数调用记录(调用链)。

    • 切换协程时需要保存和恢复整个栈的内容。

    • 比较像一个“迷你线程”。

  2. 无栈协程(stackless coroutine):

    • 没有独立的栈,或者说栈被"隐藏"了。

    • 协程的状态通过状态机(state machine)来维护,而不是靠传统意义上的调用栈。

    • 切换协程时只是更新几个简单的变量(比如执行到哪一步),非常轻量。

为什么无栈协程更轻量

项目
有栈协程
无栈协程

内存开销

每个协程分配一块固定大小的栈(比如 1MB),即使只用到了很小的一部分。

不需要大块栈空间,只保存必要的局部状态(通常是几个变量)。

调度切换开销

切换时要保存/恢复整个栈指针和上下文。

只需要保存少量的状态,比如“我上次执行到第几步了”。

创建销毁开销

创建需要分配栈空间,销毁要清理。

创建销毁基本就是分配/回收一点小对象,超快。

局限性

需要特别小心栈溢出,特别是大量协程并发时。

没有传统栈,不存在栈溢出问题,但编程模型更复杂。

无栈协程也有缺点

  • 不能自由地跨越函数调用(因为没有真正的栈调用链)。

  • 必须把程序写成状态机的风格(要么手动,要么通过编译器/语言特性辅助,比如 async/await)。

  • 对开发者不友好,逻辑复杂度上升。

对称协程 vs 非对称协程

定义

  1. 对称协程(Symmetric Coroutine)

    • 协程之间可以自由切换,任何协程可以主动切换到任意其他协程。

    • 切换是协程到协程(coroutine → coroutine),不是回到调度器。

  2. 非对称协程(Asymmetric Coroutine)

    • 协程的切换是必须返回调度器(或调用者),然后再从调度器切换到另一个协程。

    • 切换是协程 → 调度器 → 协程的模式。

📋 表格对比

特性
对称协程
非对称协程

切换方式

协程直接切到协程

协程只能 yield 回调度器

谁控制切换

协程自己

调度器控制

使用感觉

更灵活,但容易乱

更清晰,但受限

典型例子

Lua 协程、部分早期 C++ 协程库

Python 的 async/await、C#的 async/await

C++ 协程(co_await/co_yield)属于👉非对称协程(Asymmetric Coroutine)!


为什么说 C++ 协程是非对称的?

  • 在 C++20 里,协程通过 co_await 或 co_yield 只能把控制权交回调用者(也就是调度器或者管理这个协程的地方),而不是直接跳到另一个协程。

  • 协程自己不能控制跳到其他协程,它只能把自己挂起,调度器来决定下一步谁执行。

  • 切换流程如下。这是典型的非对称协程结构!

    协程 → 调度器 → (可能)另一个协程

为什么现代语言更偏爱非对称协程

因为非对称协程更容易控制、逻辑清晰、出错少,能更好地和异步(async IO)/事件循环(event loop)系统集成。

1. 控制权更明确

  • 非对称协程切换,必须回到调度器(event loop),所以调度器始终知道当前谁在运行。

  • 调度器可以统一做:

    • 资源管理(比如哪个协程在等 IO)

    • 时间片管理

    • 错误处理

  • 相比对称协程,调度器是一个中心化的大脑,一切有序地进行。

对称协程问题:协程之间可以随意跳转,调度器可能都不知道发生了什么,容易出现"鬼畜调度",难以 debug。


2. 简化堆栈管理,适配异步编程

  • 非对称协程可以天然配合异步 I/O(async IO)。

  • 举个例子:await一个 socket 读写时,只需要让协程暂停,然后调度器管理事件,IO 完成后唤醒协程。

  • 逻辑上就是顺序代码 + 异步调度,很符合人类直觉。

对称协程问题:如果协程直接跳到另一个协程,异步事件很难跟踪,IO 回调绑定很复杂。


3. 错误传播更自然

  • 在非对称协程里,异常只需要沿单一方向传播(从子协程抛回调度器或者调用者)。

  • 错误可以统一处理,比如集中 catch 一堆await失败。

对称协程问题:协程乱跳,异常传播就混乱了——你都不知道该传给谁,谁来处理。


4. 容易组合(composability)

  • 非对称协程更容易组织成并发任务集合。

  • 比如 await gather(task1, task2, task3) —— 多个协程可以像 Promise 一样被组合成一个大的异步操作。

对称协程问题:因为切换是任意的,很难自然地组合协程,除非加一堆约束,变得臃肿。

📚 现实例子

语言/框架
协程类型
特点

Python (async/await)

非对称

基于事件循环(asyncio)

JavaScript (async/await)

非对称

Promise+EventLoop

C# (async/await)

非对称

非阻塞异步操作

C++20 (co_await)

非对称

紧密结合 IO、异步调度

Lua (coroutine.resume/yield)

对称

经典设计,但适合小规模控制

✨总结一句话

非对称协程更利于统一调度、错误管理、异步控制,能让复杂的异步并发写起来像同步一样简单。

各种语言协程分类

现代语言协程几乎都是 无栈 + 非对称!

语言
协程支持
栈模型
对称/非对称
特点描述

C(手写 setjmp/longjmp,或 libco 等)

手动/库实现

有栈

对称

低级,手动保存上下文,适合底层高性能调度

Lua

原生协程

有栈

对称

轻量易用,经典小型协程系统

Golang(goroutine)

原生

有栈(动态扩展)

非对称(配合调度器)

每个 goroutine 有栈,调度器控制切换

Python (async/await)

原生语法

无栈(状态机)

非对称

基于asyncio事件循环,适合 IO 密集型

Python (yield/yield from)

生成器模拟协程

无栈

非对称

早期的轻量异步模型(比 async/await 原始些)

C# (async/await)

原生语法

无栈(状态机)

非对称

强大的异步编程支持,异常/组合管理优秀

C++20 (co_await)

原生语法

无栈(状态机)

非对称

细粒度控制,灵活但复杂,需要手写 promise_type

Rust (async/await)

语法 + 库支持

无栈(状态机,Pin/Future)

非对称

安全性强,零成本抽象,配合 tokio 等运行时

JavaScript (async/await)

原生语法

无栈(状态机+Promise)

非对称

结合 Promise 和事件循环,适合前端异步

Kotlin (suspend协程)

原生语法

无栈(状态机)

非对称

用挂起(suspend)来暂停和恢复,非常现代

为什么 Go 要使用有栈协程

Goroutine 选择有栈,是因为它要实现真正意义上的“看起来像同步、实际上是并发”的编程体验,同时要兼顾性能、简单性和兼容传统函数调用。

📚 为什么 goroutine 选择有栈模型?

1. 为了支持任意复杂的函数调用和递归

  • 有栈协程,像线程一样,有自己的调用栈(Call Stack)。

  • 这样写代码就可以像正常同步程序一样:

    • 正常调用函数

    • 正常递归

    • 正常分配局部变量

  • 你不需要把程序改造成状态机。

👉 如果 Go 用无栈协程,那么:

  • 每个if、for、函数调用都得手动建状态机,非常麻烦。

  • 写一段递归或深层函数调用就会痛苦不堪。


2. 为了保持程序员体验 —— “同步式写法,异步式运行”

  • Golang 的核心理念是:

    "不要让程序员感知并发的复杂性。"

  • 如果用无栈协程,程序员得不停 await、不停切来切去(像 Python async/await 那样),非常重感知。

  • Goroutine 是真正“同步代码异步跑”:直接写同步逻辑,不用 await、不关心任务挂起/恢复,像线程一样流畅。


3. 配合调度器(M:N 调度模型)高效管理成千上万协程

  • Go 的运行时(runtime)自己管理调度,把几万个 goroutine 映射到几核 CPU 上运行。

  • 这需要 goroutine 本身有自己的小栈,才能在任意点安全暂停/切换。


4. 动态栈扩展机制

  • 传统线程需要几 MB 栈,Goroutine 初始只要几 KB(2KB,后续可以自动增长)。

  • 栈扩展是自动的:运行时在需要时重新分配更大的栈,然后把旧栈的内容拷贝过去,继续执行。

这样就兼顾了小内存占用 + 支持复杂深度调用,非常灵活。


🎯对比一下,如果是无栈协程会怎样?

特性
Goroutine(有栈)
无栈协程(状态机)

函数调用/递归

完全自然

非常麻烦

写法

像同步程序

像控制流图,强制 await

编译器/运行时复杂度

运行时栈管理复杂

编译器状态管理复杂

性能

小开销,深递归也 OK

状态保存少,超轻量但写法痛苦

Go 为什么从链式栈→连续栈

Golang 刚设计的时候,goroutine 的栈是按需增长的链式栈(segmented stack)。

后来觉得性能不好,换成了连续栈 + 扩展复制的方式。

📚 先理解两种栈模型

栈模型
描述
特点

链式栈(Segmented Stack)

把栈切成很多小段,需要时动态链起来。

灵活、省内存,但性能差

连续栈(Contiguous Stack)

开一大块连续内存,不够了就整体扩容复制。

更快、更简单,但复制时有开销

✨ 1. 链式栈(Segmented Stack)

怎么做的?

  • 每段栈小小的,比如几 KB。

  • 栈用完时,分配一个新段,把链表指针连起来。

  • 一个协程的栈其实是一串小段的链表。

优点:

  • 初始栈极小,真正按需增长。

  • 内存碎片化少,很节省内存。

缺点:

  • 函数调用/返回要跨段时,需要频繁判断、跳指针,增加了开销。

  • 每次函数调用时都要多检查一次:"当前栈空间够不够?"

  • CPU 流水线乱了,分支预测失败,性能下降非常明显。


✨ 2. 连续栈(Contiguous Stack)

怎么做的?

  • 协程启动时分配一个小的连续栈,比如 2KB。

  • 栈快满了,暂停协程,把栈整体拷贝到一块更大的内存区域(比如翻倍),更新指针。

  • 平时调用/返回函数时,不需要多余检查,像正常栈一样快。

优点:

  • 调用开销跟普通线程一样快,因为大部分时间根本不需要特判。

  • 栈是连续的,CPU 缓存友好,内存访问更快。

  • 绝大部分情况下不会扩栈,所以常规路径(fast path)超级快。

缺点:

  • 扩栈时需要暂停协程 + 拷贝栈帧,是个大动作(但很少发生)。


🔥 总结区别

特性
链式栈
连续栈

调用开销

每次调用都要额外判断

调用超快,正常函数开销

内存增长

很细粒度,动态增长

分配翻倍增长,拷贝旧数据

CPU 友好性

差(链表访问)

好(连续内存访问)

复杂度

较高,运行时频繁管理链表

简单,扩容时才干预

最适用场景

内存极端紧张,调用层次浅

性能优先,普通应用


🎯 为什么 Go 最终选择了连续栈?

  • 链式栈在微基准测试(microbenchmark)里表现很差,每次调用都有成本,尤其是高频小函数调用(Go 里很常见)。

  • 连续栈能让90% 以上的 goroutine 永远不用扩容,而且大部分程序栈不会很深。

  • 即使扩容,Go runtime 做了栈拷贝 + 指针修正的优化,扩容代价可以接受。

  • 现代计算机内存大了,允许偶尔做大动作(拷贝),换取日常超低开销。

io_uring vs epoll

🔥 io_uring 相比 epoll 的特性总结

特性
epoll
io_uring

IO 类型支持

主要是网络 IO和文件描述符事件

几乎支持所有 IO(读/写/accept/connect/splice/sendfile/...)

IO 操作方式

通知模型:事件来了再调用 read/write

真正异步:read/write 本身也是异步的,彻底消除阻塞

系统调用次数

每次事件处理都要有系统调用(epoll_wait, read 等)

极少系统调用(批量提交/收取任务),有时可以零 syscall

数据拷贝

IO 数据通常要从内核拷贝回来

支持共享 buffer,固定内存,直接操作共享内存,减少拷贝

提交/完成模式

单次处理一个事件,单次触发

支持批量提交任务,批量收割完成事件

小 IO 性能

较低(频繁 syscall、频繁 epoll_ctl 管理 FD)

极高(共享内存 + 批处理)

支持链式 IO

❌(一个操作完成后用户代码才能发起下一个)

✅(支持 linked SQE,多个 IO 一气呵成,原子性保障)

连接建立(accept)

传统 accept,epoll 通知,自己 accept

可以直接注册 accept,完成直接返回新连接

零拷贝支持

无

部分零拷贝(比如注册 buffer、支持IORING_OP_SEND_ZC等)

内核版本支持

很老就有了(2.6+)

需要 4.14 起,功能完整最好是 5.4/5.10+

复杂度

简单成熟(虽然模型古老)

稍复杂(需要自己维护提交队列、完成队列)

1. io_uring 是全异步的、真正异步 IO

epoll 其实只是告诉你“可以读了”,但是读本身还是阻塞/同步操作。

而 io_uring 是“发起读之后,不管它什么时候完成”,本质就是异步任务提交,完成后通知。


2. io_uring 是批量、共享内存、极低系统调用

  • 提交任务时可以一次提交几十/上百个 IO 任务;

  • 处理完成时一次捞取几十/上百个完成事件;

  • 而且用户态和内核态通过 mmap 共享 SQ/CQ,根本不用频繁拷贝内存。


3. io_uring 可以做链式 IO 操作(Linked SQE)

比如:

  • 先 accept()

  • 再 read()

  • 再 write()

可以在一次提交中全链起来,内核帮你顺序执行,不用每步交互用户态,大大降低延迟。

io_uring vs 传统 AIO

📚 传统 Linux AIO 和 io_uring 的本质区别

传统 AIO (libaio)

io_uring

诞生背景

辅助大块磁盘 IO 异步化(高性能磁盘访问)

彻底提升 Linux 全面异步 IO 能力(包括文件、网络、设备等)

支持的 IO 类型

主要是块设备 IO(比如硬盘文件),网络 IO 支持差

几乎支持所有 IO(文件、网络、设备),未来目标是 All IO

内核内部实现

传统 AIO 内部其实是伪异步:有时会帮你开线程在后台做同步 read/write

真正的异步执行,内核主动调度,无需额外线程

用户态提交方式

通过 io_submit() syscall 提交,每次提交需要 syscall

通过共享 SQ,直接用户态写入,减少或零系统调用

完成通知方式

需要 io_getevents() 阻塞/轮询等待结果(系统调用)

共享 CQ 完成队列,用户态直接读,极少/零系统调用

批量支持

有,但不灵活(结构死板)

支持批量任务提交 + 批量收割完成,非常高效

数据结构映射

无 mmap 共享,传统 syscall 提交任务

mmap 出 SQ 和 CQ,用户态/内核态共享数据结构

性能

高负载/小 IO 下性能很差(频繁 syscall)

小 IO/高并发下性能爆表(共享内存、批处理)

零拷贝支持

没有

部分支持(注册缓冲区,减少拷贝)

可扩展性

很弱,支持类型少,设计僵硬

设计为模块化,可扩展更多操作(包括accept, recv, send, splice, openat2等)

开发体验

非常麻烦,接口难用

封装库(liburing)下,非常优雅易用

Linux AIO 的问题:

  1. 只支持 O_DIRECT 文件。因此对常规的非数据库应用几乎无用;

  2. 接口在设计时并未考虑扩展性。很难扩展。

  3. 虽然从**技术上说接口是非阻塞的。**但实际上有很多难以预料的原因都会导致其阻塞。

io_uring 与 linux-aio 的本质不同:

  1. 在设计上是真正异步的。只要设置了合适的 flag,它在系统调用上下文中就只是将请求放入队列,不会做其他任何额外的事情,保证了应用永远不会阻塞。

  2. 支持任何类型的 I/O:cached files、direct-access files 甚至 blocking sockets。

    由于设计上就是异步的,因此无需 poll+read/write 来处理 sockets。只需提交一个阻塞式读,请求完成之后,就会出现在 CQ。

  3. 灵活、可扩展:基于 io_uring 甚至能重写 Linux 的每个系统调用。

io_uring 细节

SQE 里有什么

SQE 是一个结构体,它存储了 I/O 请求的详细信息,包括:

  • 操作类型(如读、写、异步连接等)

  • 目标文件描述符

  • 缓冲区地址

  • 操作长度(读取的字节数)

  • 偏移量等

CQE 里有什么

CQE 结构体包含了 I/O 操作的返回值、状态码、用户自定义数据等信息。通过这些信息,应用程序可以判断 I/O 操作是否成功,并获取操作的相关结果。

比如,在文件读取操作完成后,CQE 中的返回值会表示实际读取的字节数,状态码则用于指示操作是否成功,若操作失败,状态码会包含具体的错误信息。

系统调用

io_uring 的实现仅仅使用了三个 syscall:io_uring_setup, io_uring_enter 和 io_uring_register

  1. io_uring_setup

    io_uring_setup 是用于初始化 io_uring 环境的系统调用。在使用 io_uring 进行异步 I/O 操作之前,首先需要调用 io_uring_setup 来创建一个 io_uring 实例。它接受两个参数,第一个参数是期望的提交队列(SQ)的大小,即队列中可以容纳的 I/O 请求数量;第二个参数是一个指向 io_uring_params 结构体的指针,该结构体用于返回 io_uring 实例的相关参数,如实际分配的 SQ 和完成队列(CQ)的大小、队列的偏移量等信息。

    在调用 io_uring_setup 时,内核会为 io_uring 实例分配所需的内存空间,包括 SQ、CQ 以及相关的控制结构。同时,内核还会创建一些内部数据结构,用于管理和调度 I/O 请求。如果初始化成功,io_uring_setup 会返回一个文件描述符,这个文件描述符用于标识创建的 io_uring 实例,后续的 io_uring 系统调用(如 io_uring_enter、io_uring_register)将通过这个文件描述符来操作该 io_uring 实例。

    如果用户在调用 io_uring_setup 时设置了 IORING_SETUP_SQPOLL 标志位,内核还会创建一个 SQ 线程,用于从 SQ 队列中获取 I/O 请求并提交给内核处理。

  2. io_uring_enter

    io_uring_enter 是用于提交和等待 I/O 操作的系统调用。它的主要作用是将应用程序准备好的 I/O 请求提交给内核,并可以选择等待这些操作完成。io_uring_enter 接受多个参数,其中包括 io_uring_setup 返回的文件描述符,用于指定要操作的 io_uring 实例;to_submit 参数表示要提交的 I/O 请求的数量,即从提交队列(SQ)中取出并提交给内核的 SQE 的数量;min_complete 参数指定了内核在返回之前必须等待完成的 I/O 操作的最小数量;flags 参数则用于控制 io_uring_enter 的行为,例如可以设置是否等待 I/O 操作完成、是否获取完成的 I/O 事件等。

    同时,如果设置了等待 I/O 操作完成的标志,内核会阻塞等待,直到至少有 min_complete 个 I/O 操作完成,然后将这些完成的操作结果放入完成队列(CQ)中。应用程序可以通过检查 CQ 来获取这些完成的 I/O 请求的结果。

  3. io_uring_register

    io_uring_register 用于注册文件描述符或事件文件描述符到 io_uring 实例中,以便在后续的 I/O 操作中使用。它接受四个参数,第一个参数是 io_uring_setup 返回的文件描述符,用于指定要注册到的 io_uring 实例;第二个参数 opcode 表示注册的类型,例如可以是 IORING_REGISTER_FILES(注册文件描述符集合)、IORING_REGISTER_BUFFERS(注册内存缓冲区)、IORING_REGISTER_EVENTFD(注册 eventfd 用于通知完成事件)等;

    第三个参数 arg 是一个指针,根据 opcode 的类型不同,它指向不同的内容,如注册文件描述符时,arg 指向一个包含文件描述符的数组;注册缓冲区时,arg 指向一个描述缓冲区的结构体数组;第四个参数 nr_args 表示 arg 所指向的数组的长度。通过 io_uring_register 注册文件描述符或缓冲区等资源后,内核在处理 I/O 请求时,可以直接访问这些预先注册的资源,而无需每次都重新设置相关信息,从而提高了 I/O 操作的效率。

等待 IO 请求完成

提交 I/O 请求后,应用程序可以选择等待请求完成。等待 I/O 请求完成有两种主要方式。

  1. 使用 io_uring_wait_cqe 函数,该函数会阻塞调用线程,直到至少有一个 I/O 请求完成,并返回完成的完成队列项(CQE)。当调用 io_uring_wait_cqe 时,它会检查完成队列(CQ)中是否有新完成的 I/O 请求。如果没有,线程会进入阻塞状态,直到内核将完成的 I/O 请求结果放入 CQ 中。一旦有新的 CQE 可用,io_uring_wait_cqe 会返回该 CQE,应用程序可以通过 CQE 获取 I/O 操作的结果。

  2. 使用 io_uring_peek_batch_cqe 函数,它是非阻塞的,用于检查 CQ 中是否有已经完成的 I/O 请求。如果有,它会返回已完成的 CQE 列表,应用程序可以根据返回的 CQE 进行相应的处理;如果没有完成的请求,函数会立即返回,应用程序可以继续执行其他任务,然后在适当的时候再次调用该函数检查 CQ。

io_uring 什么时候发生系统调用

在 io_uring 中,除了初始化情况下,只有 submit 时才会触发系统调用(io_uring_enter),其余时候都是用户态操作。

🔥 大致流程

阶段
是否系统调用
说明

setup (io_uring_setup)

是

建立共享的 ring buffer,分配内核资源

register (io_uring_register)

是

注册文件、缓冲区、个人数据等(减少后续开销)

写 SQE (Submit Queue Entry)

否

用户态直接往共享内存 ring buffer 里写

submit (io_uring_enter)

是

通知内核"我准备好了 N 个请求"

收取 CQE (Completion Queue Entry)

否

用户态直接从 ring buffer 读完成结果(大部分情况下)

🔥 只有两种情况下才有额外系统调用

  1. setup/注册阶段

    (io_uring_setup、io_uring_register)

  2. 运行时 submit 阶段

    (io_uring_enter,提交新的 IO 请求或要求等待完成)

除此之外——

  • 读取写入队列?→ 全是用户态内存拷贝!

  • 拿完成事件?→ 如果已经有完成事件了,也可以纯用户态读取,不需要陷入内核!

🎁bonus

如果用了 IORING_SETUP_SQPOLL(SQ thread poll),即内核帮你异步拉取 Submit Queue,那么连 submit 时都可以不需要系统调用了!!!

简直做到“0 系统调用地提交 IO”。

但当然,SQPOLL 模式需要额外占一个内核线程在后台轮询,占资源更多,适合超高频 IO。

io_uring 高级特性

io_uring 提供了一些用于特殊场景的高级特性:

  1. File registration(文件注册):每次发起一个指定文件描述符的操作,内核都需要花费一些时钟周期将文件描述符映射到内部表示。对于那些针对同一文件进行重复操作的场景,io_uring 支持提前注册这些文件,后面直接查找就行了。

  2. Buffer registration(缓冲区注册):与 file registration 类 似,direct I/O 场景中,内核需要 map/unmap memory areas。io_uring 支持提前注册这些缓冲区。

  3. Poll ring(轮询环形缓冲区):对于非常快的设备,处理中断的开销是比较大的。io_uring 允许用户关闭中断,使用轮询模式。

  4. Linked operations(链接操作):允许用户发送串联的请求。这两个请求同时提交,但后面的会等前面的处理完才开始执行。

io_uring 的无锁队列

1. 无锁接口(Lock-less Access Interface)

无锁接口(或称为“无锁编程”)的意思是在多线程或多进程环境中,通过一些特殊的同步技术,实现数据访问时不需要传统的 锁机制(如互斥锁、读写锁等)。它的核心思想是通过一些原子操作、内存屏障等方式来保证多线程并发访问共享数据时的正确性,同时避免锁带来的性能瓶颈和上下文切换开销。

为什么要用无锁编程?

  • 锁会导致线程的 阻塞 和 上下文切换,在高并发的环境下,这些都会成为性能瓶颈。

  • 在某些应用场景中(尤其是对性能要求极高的系统),无锁编程能够显著提高并发性能。

如何实现无锁编程?

  • 原子操作:使用硬件提供的原子操作(如 compare-and-swap、fetch-and-add 等)来保证在多个线程同时访问共享资源时,不会发生竞态条件。例如,CAS(Compare-And-Swap)是一个常见的原子操作,用来判断和更新数据。

  • 无锁队列:在 io_uring 的实现中,提交队列(SQE)和完成队列(CQE)都是 单生产者、单消费者 模式,意味着只有一个线程负责生产任务,另一个线程负责消费任务。这样可以利用无锁的方式进行数据交换,通过原子操作和内存屏障来确保数据的安全性。

io_uring 中的无锁接口:

在 io_uring 中,提交队列(SQE)和完成队列(CQE)的设计是基于无锁的访问接口,这样可以保证高效的并发操作:

  • 提交队列 由用户态填充,完成队列 由内核填充。

  • 用户在 提交队列 中插入新的 I/O 请求时,内核会通过原子操作和内存屏障确保对队列的修改不会发生数据竞争。

  • 内核在 完成队列 中插入完成事件时,用户则通过原子操作访问队列,轮询检查是否有新的事件完成。

2. 内存屏障(Memory Barriers)

内存屏障(又叫 内存栅栏 或 内存顺序屏障)是用于控制内存操作顺序的一种机制。它告诉编译器和处理器不要对特定的内存操作进行重排序,以保证多线程程序在并发执行时能够按照特定的顺序执行。

在多核处理器系统中,CPU 的缓存和编译器优化可能会导致内存操作的乱序执行。例如,某些内存操作可能被优化成在预期顺序之外的顺序执行,这可能会导致并发程序的竞态条件和不一致的状态。为了避免这种情况,需要使用内存屏障来限制操作的执行顺序。

内存屏障的作用:

  1. 确保顺序性:内存屏障确保在某些内存操作前后,执行顺序保持一致,避免操作顺序被 CPU 和编译器乱序。

  2. 禁止缓存重排序:内存屏障可以阻止 CPU 对内存操作的重排序,以保证多线程程序中各个操作的执行顺序。

常见类型的内存屏障:

  • 写屏障(store barrier):确保在屏障前的写操作,先于屏障后的写操作提交到内存。

  • 读屏障(load barrier):确保在屏障前的读操作,先于屏障后的读操作提交到内存。

  • 全屏障(full barrier):同时阻止读写操作的重排序,确保在屏障前的所有操作先于屏障后的所有操作执行。

io_uring 中的内存屏障:

在 io_uring 中,内存屏障的作用主要体现在:

  • 保证内核和用户空间之间对共享队列的访问顺序。

  • 提交队列 和 完成队列 是并发访问的,必须通过内存屏障来确保一个线程对队列的写入不会与另一个线程的读取操作发生冲突或乱序。

  • 在使用 无锁队列 时,内存屏障能够确保队列操作按照正确的顺序发生,并保证多线程环境中的数据一致性。

举个例子,当用户填充完提交队列(SQE),然后触发内核去执行这些请求时,需要通过内存屏障来确保提交的队列在内存中的顺序性。这保证了内核可以正确地读取到用户提交的队列数据,而不会由于 CPU 重排序或优化引发错误。

Previous面试实战Next🚩实验总结-终点亦是起点

Last updated 1 month ago