干翻 nio ,王炸 io_uring 来了 !!(图解+史上最全)
大趋势:全链路异步化,性能提升10倍+
随着业务的发展,微服务应用的流量越来越大,使用到的资源也越来越多。
在微服务架构下,大量的应用都是 SpringCloud 分布式架构,这种架构总体上是全链路同步模式。
全链路同步模式不仅造成了资源的极大浪费,并且在流量发生激增波动的时候,受制于系统资源而无法快速的扩容。
全球后疫情时代,降本增效是大背景。如何降本增效?一条好的路径:全链路同步模式 ,升级为 全链路异步模式。
全链路异步模式改造 具体的内容,请参考尼恩的深度文章:全链路异步,让你的 SpringCloud 性能优化10倍+
先回顾一下全链路同步模式架构图
全链路同步模式 ,如何升级为 全链路异步模式, 就是一个一个 环节的异步化。
40岁老架构师尼恩,持续深化自己的3高架构知识宇宙,当然首先要去完成一次牛逼的全链路异步模式 微服务实操,下面是尼恩的实操过程、效果、压测数据(性能足足提升10倍多)。
全链路异步模式改造 具体的内容,请参考尼恩的深度文章:全链路异步,让你的 SpringCloud 性能优化10倍+
并且,上面的文章,作为尼恩 全链路异步的架构知识,收录在《尼恩Java面试宝典》V52版的架构专题中
注:本文以 PDF 持续更新,最新尼恩 架构笔记、面试题 的PDF文件,点此处获取
全链路异步化的最终目标
全链路异步化的最终目标,如下图所示:
应用层:编程模型的异步化 框架层:IO线程的异步化 OS层:IO模型的异步化
一:应用层:编程模型的异步化
这个请大家去看 尼恩的 《响应式 圣经 PDF》电子书
随着 云原生时代的到来, 底层的 组件编程 越来越 响应式、流化, 从命令式 编程转换到 响应式 编程,在非常多的场景 ,是大势所趋。
而响应式编程, 学习曲线很大, 大家需要多看,多实操。
二:框架层:IO线程的异步化
这个大家 都选择 具有异步 回调功能的 异步线程模型,如 Reactor 线程模型
这个是面试的绝对重点
IO的王者组件,Netty框架,整体就是一个 Reactor 线程模型 实现
也是非常核心的知识,这里不做展开,请大家去看尼恩的畅销书《Java 高并发核心编程卷 1 加强版》。
三:OS层:IO模型的异步化
目前的一个最大难题,是IO模型的异步化。
注意,Netty 底层的IO模型,咱们一般用的是select或者 epoll,是同步IO,不是异步IO.
有关5大IO模型,是本文的基础知识,也是非常核心的知识,这里不做展开,请大家去看尼恩的畅销书《Java 高并发核心编程卷 1 加强版》。
第二层:线程模型的异步化
首先来看线程模型的异步化。
Reactor模式
了解了BIO和NIO的一些使用方式,Reactor模式就呼之欲出了。
NIO是基于事件机制的,有一个叫做Selector的选择器,阻塞获取关注的事件列表。获取到事件列表后,可以通过分发器,进行真正的数据操作。
上图是Doug Lea
在讲解NIO时候的一张图,指明了最简单的Reactor模型的基本元素。
你可以对比这上面的NIO代码分析一下,里面有四个主要元素:
Acceptor 处理client的连接,并绑定具体的事件处理器 Event 具体发生的事件 Handler 执行具体事件的处理者。比如处理读写事件 Reactor 将具体的事件分配给Handler
我们可以对上面的模型进行近一步细化,下面这张图同样是Doug Lea
的ppt中的。
它把Reactor部分分为mainReactor和subReactor两部分。mainReactor负责监听处理新的连接,然后将后续的事件处理交给subReactor,subReactor对事件处理的方式,也由阻塞模式变成了多线程处理,引入了任务队列的模式。
这两个线程模型,非常重要。
一定要背到滚瓜烂熟。
这里不做展开,请大家去看尼恩的畅销书《Java 高并发核心编程卷 1 加强版》。
第三层:OS中IO模型的异步化
目前的一个最大难题,是IO模型的异步化。
注意,Netty 底层的IO模型,咱们一般用的是select或者 epoll,是同步IO,不是异步IO.
首先看看五大IO模型吧:
IO模型层的异步化
阻塞式IO (bio) 非阻塞式IO IO复用 (nio) 信号驱动式IO 异步IO(aio)
1.阻塞IO模型
如上图,是典型的BIO模型,每当有一个连接到来,经过协调器的处理,就开启一个对应的线程进行接管。
如果连接有1000条,那就需要1000个线程。线程资源是非常昂贵的,除了占用大量的内存,还会占用非常多的CPU调度时间,所以BIO在连接非常多的情况下,效率会变得非常低。
就单个阻塞IO
来说,它的效率并不比NIO
慢。但是当服务的连接增多,考虑到整个服务器的资源调度和资源利用率等因素,NIO
就有了显著的效果,NIO非常适合高并发场景。
2.非阻塞IO模型
其实,在处理IO动作时,有大部分时间是在等待。
比如,socket连接要花费很长时间进行连接操作,在完成连接的这段时间内,它并没有占用额外的系统资源,但它只能阻塞等待在线程中。这种情况下,系统资源并不能被合理的利用。
Java的NIO,在Linux上底层是使用epoll实现的。epoll是一个高性能的多路复用I/O工具,改进了select和poll等工具的一些功能。在网络编程中,对epoll概念的一些理解,几乎是面试中必问的问题。
epoll的数据结构是直接在内核上进行支持的。通过epoll_create和epoll_ctl等函数的操作,可以构造描述符(fd)相关的事件组合(event)。
这里有两个比较重要的概念:
fd
每条连接、每个文件,都对应着一个描述符,比如端口号。内核在定位到这些连接的时候,就是通过fd进行寻址的event
当fd对应的资源,有状态或者数据变动,就会更新epoll_item
结构。在没有事件变更的时候,epoll就阻塞等待,也不会占用系统资源;一旦有新的事件到来,epoll就会被激活,将事件通知到应用方
关于epoll还会有一个面试题:相对于select,epoll有哪些改进?
这里直接给出答案:
epoll不再需要像select一样对fd集合进行轮询,也不需要在调用时将fd集合在用户态和内核态进行交换 应用程序获得就绪fd的事件复杂度,epoll时O(1),select是O(n) select最大支持约1024个fd,epoll支持65535个 select使用轮询模式检测就绪事件,epoll采用通知方式,更加高效
有关5大IO模型,是本文的基础知识,也是非常核心的知识,非常重要
这里不做展开,请大家去看尼恩的畅销书《Java 高并发核心编程卷 1 加强版》。
为啥需要IO模型异步化
这里有一个很大的性能损耗点,同步IO中,线程的切换、 IO事件的轮询、IO操作, 都是需要进行 系统调用完成的。
系统调用的性能耗费在哪里?
首先,线程是很”贵”的资源,主要表现在:
线程的创建和销毁成本很高,线程的创建和销毁都需要通过重量级的系统调用去完成。
线程本身占用较大内存,像Java的线程的栈内存,一般至少分配512K~1M的空间,如果系统中的线程数过千,整个JVM的内存将被耗用1G。
线程的切换成本是很高的。操作系统发生线程切换的时候,需要保留线程的上下文,然后执行系统调用。过多的线程频繁切换带来的后果是,可能执行线程切换的时间甚至会大于线程执行的时间,这时候带来的表现往往是系统CPU sy值特别高(超过20%以上)的情况,导致系统几乎陷入不可用的状态。
在Linux的性能指标里,有us
和sy
两个指标,使用top
命令可以很方便的看到。
us
是用户进程的意思,而sy
是在内核中所使用的cpu占比。
如果进程在内核态和用户态切换的非常频繁,那么效率大部分就会浪费在切换之上。一次内核态和用户态切换的时间,普遍在 微秒 级别以上,可以说非常昂贵了。
cpu的性能是固定的,在无用的东西上浪费越小,在真正业务上的处理就效率越高。
影响效率的有两个方面:
进程或者线程的数量,引起过多的上下文切换。
进程是由内核来管理和调度的,进程的切换只能发生在内核态。所以,如果你的代码切换了线程,它必然伴随着一次用户态和内核态的切换。
IO的编程模型,引起过多的系统态和内核态切换。
比如同步阻塞等待的模型,需要经过数据接收、软中断的处理(内核态),然后唤醒用户线程(用户态),处理完毕之后再进入等待状态(内核态)。
注意:一次内核态和用户态切换的时间,普遍在 微秒 级别以上,可以说非常昂贵了。
IO模型的异步化的第一个目标:减少线程数量,减少线程切换系统调用带来 CPU 上下文切换的开销。
IO模型的异步化的第一个目标:减少IO系统调用,减少线程切换系统调用带来的带来 CPU 上下文切换开销。
用户空间内核空间、用户态内核态,又是一组极致复杂的概念。同样是本文的基础知识,也是非常核心的知识,非常重要。这里不做展开,请大家去看尼恩的3 高架构笔记 《高性能之葵花宝典》。
线程模型和IO模型的概念误区
在尼恩的疯狂创客圈社群(50+)中, 经常有人被 IO模型, Reactor反应器模型,同步、异步搞晕。
尼恩用几十年的经验总结,给大家做一个简单梳理:
一定要分层,就想 WEB应用架构要分层一样。 线程模型和IO模型,要分开来看,不能混为一谈。
很多小伙伴把Reactor 反应器,一定认为底层的IO模型是NIO, 大家去看看Netty源码, Netty反应器,支持各种IO模,包括BIO。
所以,一定要分层去看。
尼恩把线程模型和IO模型的,给大家分为三层:应用层、框架层、 OS层。
具体如下图所示:
Netty的 Reactor 模式,对应到是:线程模型。不是对应到 IO模型。
在IO模型的层面,Tomcat 也用了 NIO,大家一定不要以为Tomcat还用BIO,还用 ,大部分的HTTPClient客户端组件,都用了NIO,都不会使用BIO模型的。
在线程模型的层面,很多的HTTPClient组件,要么没有使用 Reactor模型,要么是使用了Reactor反应性线程模型,但是我们的业务程序不用,咱们的业务程序,用的还是其同步阻塞线程模型的API代码。
如何进行IO模型的异步化。
大家都知道BIO非常的低效,而网络编程中的IO多路复用普遍比较高效。
Linux中,一直没有成熟的异步IO内核组件。
现在,io_uring已经能够挑战NIO的,功能非常强大。
io_uring在2019加入了Linux内核,目前5.1+的内核,可以采用这个功能。
随着一步步的优化,系统调用这个大家伙,调用次数越来越少了。
让我们先看看 linux 中的各种异步 IO,也就是 AIO。
1. glibc aio
官方地址:Perform I/O Operations in Parallel(官方文档用的字眼比较考究)
glibc 是 GNU 发布的 libc 库,该库提供的异步 IO 被称为 glibc aio,在某些地方也被称为 posix aio。glibc aio 用多线程同步 IO 来模拟异步 IO,回调函数在一个单线程中执行。
该实现备受非议,存在一些难以忍受的缺陷和bug,极不推荐使用。详见:http://davmac.org/davpage/linux/async-io.html
2. libaio
linux kernel 2.6 版本引入了原生异步 IO 支持 —— libaio,也被称为 native aio。
ibaio 与 glibc aio 的多线程伪异步不同,它真正的内核异步通知,是真正的异步IO。
虽然很真了,但是缺陷也很明显:libaio 仅支持 O_DIRECT 标志,也就是 Direct I/O,这意味着无法利用系统缓存,同时读写的的大小和偏移要以区块的方式对齐。
3. libeio
由于上面两个都不靠谱,所以 Marc Lehmann 又开发了一个 AIO 库 —— libeio。
与 glibc aio 的思路一样,也是在用户空间用多线程同步模拟异步 IO,但是 libeio 实现的更高效,代码也更稳定,著名的 node.js 早期版本就是用 libev 和 libeio 驱动的(新版本在 libuv 中移除了 libev 和 libeio)。
libeio 提供全套异步文件操作的接口,让用户能写出完全非阻塞的程序,但 libeio 也不属于真正的异步IO。
libeio 项目地址:https://github.com/kindy/libeio
4. io_uring
接下来就是 linux kernel 5.1 版本引入的 io_uring 了。
io_uring 类似于 Windows 世界的 IOCP,但是还没有达到对应的地位,目前来看正式使用 io_uring 的产品基本没有,
目前还是没有一个成熟的基础框架与其匹配,至于 Netty 对 io_uring 的封装,看下来的总体感受是:Netty 为了维持编程模型统一,完全没有发挥出 io_uring 的长处。具体 Netty 是如何封装的,后面会一起探讨一下。
但是在未来,一定是异步IO的天下, 今天,咱们就从io_uring 的学习开始吧。
io_uring (用户环形IO)
前面讲到,NIO依然有大量的系统调用,那就是Epoll的epoll_ctl。
另外,获取到网络事件之后,还需要把socket的数据进行存取,这也是一次系统调用。
虽然相对于BIO来说,上下文切换次数已经减少很多,但它仍然花费了比较多的时间在切换之上。
IO只负责对发生在fd描述符上的事件进行通知。事件的获取和通知部分是非阻塞的,但收到通知之后的操作,却是阻塞的。
即使使用多线程去处理这些事件,它依然是阻塞的。
如果能把这些系统调用都放在操作系统里完成,那么就可以节省下这些系统调用的时间,io_uring就是干这个的。
尼恩提示:这里io_uring娶一个 io_uring 这样名字,非常反人性,
在取名字上面,可以叫做 io_ring,ring_io更合适。u 是user的意思,ring是环形的意思。
一看到这里的ring,很容易知道,这里用了 环形队列。
环形队列是一个高性能的基础结构,大家去看 队列之王Disruptor、缓存之王 Caffeine ,里边用的就是环形队列。
关于环形队列,这里不做展开,请大家去看尼恩的3 高架构笔记 《穿透缓存之王Caffeine 源码和架构》、3 高架构笔记 《穿透队列之王Disruptor源码和架构》。
从io_uring的名字uring
我们就可以看出来,该机制的核心即user
和ring
:其申请了一块用户态和内核态共享的内存作为环形数组,并在共享内存中通过ringBuf
环形队列的方式来实现内核态和用户态的通信,
后文中会出现大量的简写,在这里先做一些介绍。
缩略语 | 英语 | 中文 | 解析 |
---|---|---|---|
SQ | Submission Queue | 提交队列 | 一整块连续的内存空间存储的环形队列。用于存放将执行操作的数据。 |
CQ | Completion Queue | 完成队列 | 一整块连续的内存空间存储的环形队列。用于存放完成操作返回的结果。 |
SQE | Submission Queue Entry | 提交队列项 | 提交队列中的一项。 |
CQE | Completion Queue Entry | 完成队列项 | 完成队列中的一项。 |
Ring | Ring | 环 | 比如 SQ Ring,就是“提交队列信息”的意思。包含队列数据、队列大小、丢失项等等信息。 |
io_uring 的环形队列长成啥样?
闲话少说,这里简单说一下io_uring 的环形队列长成啥样?
前面讲到,io_uring 中,应用程序可以使用两个队列来和 Kernel 进行通信:
Submission Queue(SQ) Completion Queue(CQ) 。
而这两个队列中的保存的主要是指针或者编号(index),真正的IO请求,保存在一个基于数组结构的环形队列中,这个环形队列的结构如下图:
这块内存共分为三个区域,分别是 SQ,CQ,SQEs。
SQEs是一个环形数组,保存实际的IO请求,之所以采用了一个额外数组保存 SQEs,是为了方便通过 RingBuffer 提交内存上不连续的请求。
两个队列 SQ 和 CQ 中每个节点,保存的并不是IO请求,保存的都是 SQEs 数组的偏移量,实际的请求只保存在 SQEs 数组中。一个 SQE 条目的结构,主要包含以下的内容:
Opcode:描述要进行的系统调用的IO 操作码。如果是读,操作码IORING_OP_READV。 Flags:修饰符,可以通过任何请求传递 Fd:要读取的文件描述符 Address:对于我们的readv调用,它创建了一个缓冲区(或向量)数组来读入数据。因此,address字段包含了该数组的地址。 Length:Address 缓冲区 向量数组的长度。 User Data:通常这是一个指针,指向一些结构体,其中保存了请求的元数据,来识别应用的请求。当请求从CQ 队列中出来时,并不能保证IO结果与 请求SQEs的顺序相同。如果一定保证有序的就会降低性能, 就违背了异步API的初衷。因此,我们需要一些东西来识别我们发出的请求。User Data这可以达到这个目的。
CQE包含
Result:readv系统调用的返回值。如果成功,就会有读取的字节数; 否则它将有一个错误代码。 User Data:在SQE中传递的指针。
注意:由于 SQ,CQ,SQEs 是在内核中分配的,所以用户态程序并不能直接访问。
应用程序如何和内核进行队列共享呢?
io_setup 的返回值是一个 fd,应用程序使用这个 fd 进行 mmap,和 kernel 共享一块内存。
注意,是应用程序拿到这个 fd 进行 mmap,映射到自己的内存地址。
映射完了之后,根据 offset 偏移量,进行 访问。
而偏移量,和内核的偏移量地址,是相同的。创建 kernel 返回的 io_sqring_offset 和 io_cqring_offset 两个偏移量:
返回 io_sqring_offset ,表示 SQ 的指针在 mmap 中的 offset 返回 io_cqring_offset ,表示 CQ 的指针在 mmap 中的 offset
这里很关键,用到了文件映射, 共享内存映射,有关文件映射和内存映射的原理和实操,请参见
MappedByteBuffer 详解(图解+秒懂+史上最全) - 疯狂创客圈 - 博客园 (cnblogs.com):https://www.cnblogs.com/crazymakercircle/p/15625329.html
这个知识点,一定要掌握
内核io_uring的三个系统调用
在io_uring在准备阶段,会涉及到三个系统调用:
425 io_uring_setup
426 io_uring_enter
427 io_uring_register
syscall 1:io_uring_setup 设置
io_uring_setup 需要两个参数,entries 和 io_uring_params。
(1)entries 代表 queue depth。要创建的sqe的数量
(2)param s 代表 用户层指定的参数。
/*
entries: 要创建的sqe的数量
params: 用户层指定的参数
*/
static long io_uring_setup(u32 entries, struct io_uring_params __user *params)
{
struct io_uring_params p;
int i;
// 把用户空间的params复制到内核空间
if (copy_from_user(&p, params, sizeof(p)))
return -EFAULT;
// resv是保留的空间,所以不能用
for (i = 0; i < ARRAY_SIZE(p.resv); i++) {
if (p.resv[i])
return -EINVAL;
}
/*
flags只支持这些标志,如果有其它标志都会报错
#define IORING_SETUP_IOPOLL (1U << 0) // io poll 模式
#define IORING_SETUP_SQPOLL (1U << 1) // sq poll 模式
#define IORING_SETUP_SQ_AFF (1U << 2) // 指定线程cpu时指定这个参数
#define IORING_SETUP_CQSIZE (1U << 3) // 应用设置完成队列大小
#define IORING_SETUP_CLAMP (1U << 4) // 当用户指定的entries太大时,可以把值改小
#define IORING_SETUP_ATTACH_WQ (1U << 5) //添加到当前已经存在的wq里
#define IORING_SETUP_R_DISABLED (1U << 6) // 如果是sq-poll模式,一开始不启动sq-thread
*/
if (p.flags & ~(IORING_SETUP_IOPOLL | IORING_SETUP_SQPOLL |
IORING_SETUP_SQ_AFF | IORING_SETUP_CQSIZE |
IORING_SETUP_CLAMP | IORING_SETUP_ATTACH_WQ |
IORING_SETUP_R_DISABLED))
return -EINVAL;
return io_uring_create(entries, &p, params);
}
io_uring_params 的定义如下。
struct io_uring_params {
__u32 sq_entries;
__u32 cq_entries;
__u32 flags;
__u32 sq_thread_cpu;
__u32 sq_thread_idle;
__u32 resv[5];
struct io_sqring_offsets sq_off;
struct io_cqring_offsets cq_off;
};
struct io_sqring_offsets {
__u32 head;
__u32 tail;
__u32 ring_mask;
__u32 ring_entries;
__u32 flags;
__u32 dropped;
__u32 array;
__u32 resv1;
__u64 resv2;
};
struct io_cqring_offsets {
__u32 head;
__u32 tail;
__u32 ring_mask;
__u32 ring_entries;
__u32 overflow;
__u32 cqes;
__u64 resv[2];
};
io_uring_params 参数包括两种:
输入参数 输出参数
其中:
flags、sq_thread_cpu、sq_thread_idle 属于输入参数,由应用负责设置,用于定义 io_uring 在内核中的行为。
其他参数属于输出参数,由内核负责设置。
syscall 2:io_uring_create
static int io_uring_create(unsigned entries, struct io_uring_params *p,
struct io_uring_params __user *params)
{
struct user_struct *user = NULL;
struct io_ring_ctx *ctx;
struct file *file;
bool limit_mem;
int ret;
....省略几万字
// 调用trace接口
trace_io_uring_create(ret, ctx, p->sq_entries, p->cq_entries, p->flags);
return ret;
err:
io_disable_sqo_submit(ctx);
io_ring_ctx_wait_and_kill(ctx);
return ret;
}
io_uring_create是setup的主流程:
计算sq_entries, cq_entries的大小 分配 一个 io_ring_ctx 上下文 对象, 这是io_uring运行过程的上下文 分配 sqe, cqe这些数组空间 如果是sq-poll模式则创建内核线程 创建io_wq 对象及相应的worker 如果是sq-poll, 且需要启动线程 , 则启动之 把sq, cq的一些信息写到用户空间的params里, 这些信息用来在setup成功后, 映射内核内存 创建io_uring 对应的文件及socket, 这个文件的fd用来与用户空间通信 , 这个 fd,是一个匿名fd
重点提一下匿名 fd 的事情,为什么会有匿名 fd ? 什么是匿名?
在 Linux 里一切皆文件,你理解的常见“文件”有什么特性?
文件的名称是path 路径,匿名的意思说的就是没有路径。匿名 fd 其实说的是匿名 inode 。
在 Linux 的文件体系中,一个文件句柄,对应一个 file 结构体,关联一个 inode 。
file/dentry/inode
这三驾马车是一定要配齐的,就算是匿名的(无 path,无效 dentry ),对于 file 结构体来说,一定要绑定 inode 和 dentry ,哪怕是伪造的、不完整的 inode。anon_inodefs 就应运而生了,内核就帮你搞出来一个公共的 inode
这就节省了所有有这样需求的内核模块,避免了内存的浪费,省了冗余重复的 inode 初始化代码。
匿名 fd 背后的是一个叫做 anon_inodefs 的内核文件系统( 位于
fs/anon_inodes.c
),这个文件系统极其简单,整个文件系统只有一个 inode ,这个 inode 是文件系统初始化的时候创建好的。
之后,所有需要一个匿名 inode 的句柄都直接跟这个 inode 关联即可。
syscall 3:io_uring_register
涉及的文件描述符的引用操作,比较低性能:
应用 每次将 文件描述符填充到 sqe ,然后提交给内核时,内核都必须检索对 文件描述符 的引用,也是低性能的 当 IO 完成后,会再次删除文件引用,由于文件引用要保障的原子性,也是低性能的
这样对高 IOPS 的工作场景而言,速度会明显下降。
为了缓解此问题,io_uring 提供了一种对 io_uring 实例预注册文件集的方法
int io_uring_register(unsigned int fd, unsigned int opcode, void *arg, unsigned int nr_args);
fd
是io_uring 实例
的文件描述符opcode
执行的注册类型。对于注册文件集来说,必须是 IORING_REGISTER_FILES
。arg
必须指向应用准备打开的文件描述符数组nr_args
便是数组的大小
一旦 io_uring_register 成功将文件集注册后,应用就可以将文件集数组的索引(而不是使用实际的文件描述符)赋值给 sqe->fd 了,并设置 sqe->flags 字段为 IOSQE_FIXED_FILE 来标记 sqe->fd 是一个文件集索引
应用可以继续使用未注册的文件,即使是注册过的文件也可以通过文件描述符
赋值 sqe->fd
,sqe->flags
不设置 IO_FIXED_FILE
来正常使用文件描述符
当 io_uring 实例被移除后,注册的文件集会自动释放,或者使用 IORING_UNREGISTER_FILES opcode 来调用 io_uring_register
梳理一下io_uring的核心流程
在 io_setup 设置的时候,内核会初始化两个队列 SQ 和 CQ 和一个数组 SQEs ( Submission Queue Entries)
如图, 每一个io_uring
实例,都会被分配一个fd
,该过程是通过io_uring_setup()
系统调用实现的。
io_uring_setup()
调用会根据用户提供的参数,分配一块共享内存。
这块共享内存中,包含了一个SQ
(提交队列)、一个CQ
(完成队列)和一个SQE
(提交实体)数组。
其中,SQ
和CQ
是两个环形队列,队列中的元素是SQE
在SQE
数组中的偏移量,使用这种方式可以使得提交实体能够被随机访问,提高灵活性。
io_uring_setup()
调用返回的fd,该内存可以通过mmap()
的方式映射到用户态
用户从CQ
的头部获取SEQ
,将想要执行的操作(如文件的读写)初始化到其中,并添加到SQ
队列的尾部,然后使用io_uring_enter()
系统调用来进行提交队列的处理。
用户态和内核态共享 提交队列(submission queue)和 完成队列(completion queue),这两条队列通过mmap共享,高效且安全。
提交队列(SQ)给内核源源不断的布置任务,然后从另外一条队列完成队列(CQ)获取结果;
内核则按需进行 epoll(),并在一个线程池中执行就绪的任务。
用户态支持Polling模式,不会发生中断,也就没有系统调用,通过轮询即可消费事件;
内核态也支持Polling模式,同样不会发生上下文切换。
可以看出关键的设计在于,内核通过一块和用户共享的内存区域进行消息的传递,可以绕过Linux 的 syscall 机制。
内核会从SQ
中依次取出对应的io request 提交实体,并根据io request 提交实体中定义的动作来执行对应的操作。由于用户只操作SQ
尾部,而内核只操作头部,因此两者对于共享队列的访问并不会产生冲突,节省了锁的开销。
内核侧的主要操作流程如下:
上图中为内核的处理流程简图,为了提高性能、降低时延,内核并不是一定会采用异步的方式来处理提交实体,而是会检查该实体所对应的文件系统是否支持非阻塞式的操作。
在操作完成后,内核会将完成了的提交实体放到CQ
队列的尾部,方便用户继续进行操作的提交。通过ringBuf
的使用,io_uring
获得了以下几点收益:
能够以批量的方式进行IO的提交,减少了系统调用的次数,节省了开销; 通过共享内存的使用,避免了用户态与内核态频繁的系统调用参数拷贝,提升了性能。
io_uring 三种工作模式
中断驱动模式
轮询模式 / poll 模式。
需要文件系统和块设备支持。相比中断驱动,延迟更低,但可能会消耗更多CPU资源
内核轮询模式 / 提交
sqpoll
轮询模式。
应用可通过 IORING_SETUP_SQ_AFF
和 sq_thread_cpu 绑定特定的 CPU。
同时,为了节省无 IO 场景的 CPU 开销,该内核线程会在一段时间空闲后自动睡眠。
应用在下发新的 IO 时,通过 IORING_ENTER_SQ_WAKEUP
唤醒该内核线程,用户态可以通过 sqring 的 flags 变量获取 SQ 线程的状态。
中断驱动模式
常规的块设备IO使用的都是中断模式,即进程将IO请求提交给块设备后会进入睡眠(D)状态,块设备在处理完IO请求后会触发硬中断,硬中断中会唤醒进程并通知其IO的完成。
轮询模式 / poll 模式
什么是IO轮询(poll)模式?
轮询模式是相对于中断模式的。io_uring
提供了一种block
层的轮询模式,即IO请求提交后不进入睡眠,而是循环检查硬件设备的完成状态。
该模式下,io_uring
会额外启动一个内核进程来循环检查IO的完成。
由于不需要等待硬件设备的通知,因此可以更快地获取到IO请求的完成,这对于延迟非常低以及IOPS
很高的设备,能够显著提高性能,同时避免了高频的中断所带来的性能开销。
内核轮询模式 / 提交sqpoll
轮询模式
通过ringBuf
的使用,我们现在可以批量地进行IO操作的提交,降低了系统调用次数。
io_uring
还提供了另一种机制用于进一步降低系统调用次数、提高IO效率,即:提交队列轮询SQPOLL
模式。
这个功能让采用内核线程 Polling 的模式收割用户的请求。
当没有使用 SQ 线程时,io_uring_enter 函数会主动的 Poll,以检查提交给 应用层的请求是否已经完成,而不是挂起,并等待 Block 层完成后再被唤醒。
使用 SQ 线程时也是同理。
该模式下,内核会启动一个内核进程专门用于SQE
提交实体的处理,该进程会循环检查提交队列中是否存在实体。
用户态程序只需要取出完成队列中的SEQ
,进行初始化并添加到提交队列中即可,整个过程都不需要产生系统调用。
为了降低开销,内核进程会有一个超时时间,在该时间段内如果都没有检测到提交队列中存在实体,就会进入睡眠状态,同时将进程的状态更新到共享内存中。
用户进程在提交SQE
之后,会通过IORING_SETUP_SQPOLL 标志位检查poll
进程是否在运行。
若未运行,则通过io_uring_enter
系统调用唤醒poll
进程。
可以看出,在高IO频率的情况下,使用该模式可以大幅降低系统调用的次数,同时减少由于系统调用而带来的IO延迟。
图解:io_uring 用户侧+内核侧的完整执行流程
用户侧提交IO请求 应用创建SQ entries(SQE),更新SQ tail 内核消费SQE,更新SQ head 内核侧完成 内核为完成的一个或多个请求创建CQ entries(CQE),更新CQ tail 用户侧收割结果 应用消费CQE,更新CQ head,消费CQE无需切换到内核态
IO 提交
IO 提交的做法是找到一个空闲的 SQE,根据请求设置 SQE,并将这个 SQE 的索引放到 SQ 中。
SQ 是一个典型的 RingBuffer,有 head,tail 两个成员,如果 head == tail,意味着队列为空。
SQE 设置完成后,需要修改 SQ 的 tail,以表示向 RingBuffer 中插入一个请求。
当所有请求都加入 SQ 后,就可以使用下面的方法来提交 IO 请求 :
int io_uring_enter(unsigned int fd, u32 to_submit, u32 min_complete, u32 flags);
io_uring_enter 被调用后, 进程会陷入到内核,这里存在着CPU上下文切换。
to_submit 表示一次提交多少个 IO。 如果 flags 设置了 IORING_ENTER_GETEVENTS,并且 min_complete > 0,那么这个系统调用会同时处理 IO 收割。 min_complete 是最少的完成数量,这个系统调用会一直 block,直到 min_complete 个 IO 已经完成。
这里和epoll类似,IO 提交的过程中依然会产生系统调用。
不过不急, io_uring有三种模式,这里只能算第一种。
在第三种模式中,如果在调用 io_uring_setup 时设置了 IORING_SETUP_SQPOLL 的 flag,内核会额外启动一个内核线程,我们称作 SQ 线程。
这个内核线程可以运行在某个指定的 core 上(通过 sq_thread_cpu 配置)。
这个内核线程会不停的 Poll SQ (轮询),除非在一段时间内没有 Poll 到任何请求(通过 sq_thread_idle 配置),才会被挂起。
当程序在用户态设置完 SQE,并通过修改 SQ 的 tail 完成一次插入时,如果此时 SQ 线程处于唤醒状态,那么可以立刻捕获到这次提交,这样就避免了用户程序调用 io_uring_enter 这个系统调用。
如果 SQ 线程处于休眠状态,则需要通过调用 io_uring_enter,并使用 IORING_SQ_NEED_WAKEUP 参数,来唤醒 SQ 线程。
如何知道 SQ 线程处于休眠状态 呢?用户态可以通过 sqring 的 flags 变量获取 SQ 线程的状态。
接下来以图的方式,介绍 io_uring
的内核和应用交互方式,具体如下:
提交任务的过程如下:
将 SQE 写入 SQEs 区域, 将 SQE 的 index (编号,或者类似数组下标)写入 SQ。 更新用户态记录的队头。 如果有多个任务需要同时提交,用户不断重复上面的过程。 将最终的队头编号写入与内核共享的 io_uring
上下文。
用户侧IO 收割
接下来我们简要介绍内核获取任务、内核完成任务、用户收割任务的过程。
当 IO 完成时,内核负责将完成 IO 在 SQEs 中的 index 放到 CQ 中。
内核态获取任务的方式是,从队尾读取 SQE,并更新
io_uring
ctx 上下文的 SQ tail。内核态完成任务:往 CQ 中写入 CQE,更新上下文 CQ head。
用户态收割任务:从 CQ 中读取 CQE,更新上下文 CQ tail。
由于 IO 在提交的时候可以顺便返回完成的 IO,所以收割 IO 不需要额外系统调用。
这是跟 IO提交有比较大的不同,省去了一次系统调用。
当然,如果使用了 IORING_SETUP_SQPOLL 参数,IO 收割也不需要系统调用的参与。
由于内核和用户态共享内存,所以收割的时候,用户态遍历 [cq->head, cq->tail) 区间,这是已经完成的 IO 队列,然后找到相应的 CQE 并进行处理,最后移动 head 指针到 tail,IO 收割就到此结束了。
所以在最理想的情况下,IO 提交和收割都不需要使用系统调用。
内存可见性和有序性保证:
由于提交和收割的时候需要访问共享内存的 head,tail 指针,所以需要使用 rmb/wmb 内存屏障操作确保时序。
io_uring 与 epoll 的使用对比
epoll 通常的编程模型如下:
struct epoll_event ev;
/* for accept(2) */
ev.events = EPOLLIN;
ev.data.fd = sock_listen_fd;
epoll_ctl(epollfd, EPOLL_CTL_ADD, sock_listen_fd, &ev);
/* for recv(2) */
ev.events = EPOLLIN | EPOLLET;
ev.data.fd = sock_conn_fd;
epoll_ctl(epollfd, EPOLL_CTL_ADD, sock_conn_fd, &ev);
然后在一个主循环中:
new_events = epoll_wait(epollfd, events, MAX_EVENTS, -1);
for (i = 0; i < new_events; ++i) {
/* process every events */
...
}
epoll本质上是实现类似如下事件驱动结构:
struct event {
int fd;
handler_t handler;
};
将fd通过epoll_ctl进行注册,当该fd上有事件ready, 在epoll_wait返回时可以获知完成的事件,然后依次调用每个事件的handler, 每个handler里调用recv(2), send(2)等进行消息收发。
io_uring的编程模型如下(这里用到了liburing提供的一些接口):
/* 用sqe对一次recv操作进行描述 */
struct io_uring_sqe *sqe = io_uring_get_sqe(ring);
io_uring_prep_recv(sqe, fd, bufs[fd], size, 0);
/* 提交该sqe, 也就是提交recv操作 */
io_uring_submit(&ring);
/* 等待完成的事件 */
io_uring_submit_and_wait(&ring, 1);
cqe_count = io_uring_peek_batch_cqe(&ring, cqes, sizeof(cqes) / sizeof(cqes[0]));
for (i = 0; i < cqe_count; ++i) {
struct io_uring_cqe *cqe = cqes[i];
/* 依次处理reap每一个io请求,然后可以调用请求对应的handler */
...
}
Netty 对 io_uring 的封装
3个(NativeTransports)本地传输
Netty提供了三种特定于平台的JNI(Native Transports)本地传输:
epoll on Linux io_uring on Linux (Incubator) kqueue on MacOS/BSD
如果适当的库在其运行时可用,则Lettuce默认为本机传输。
与基于NIO的传输相比,使用本机传输会添加特定于特定平台的功能,产生更少的垃圾,并通常会提高性能。
通过Unix域套接字连接本机传输是必需的,并且也适用于TCP连接。
本机传输可用于:
最低Netty版本为4.0.26.Final
的Linux epoll x86_64系统,需要netty-transport-native-epoll
,分类器linux-x86_64
<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-transport-native-epoll</artifactId>
<version>${netty-version}</version>
<classifier>linux-x86_64</classifier>
</dependency>
Linux io_uring x86_64系统的最低Netty版本为4.1.54.Final,需要netty-incubator-transport-native-io_uring,分类器为linux-x86_64。
请注意,此传输仍处于实验阶段。
<dependency>
<groupId>io.netty.incubator</groupId>
<artifactId>netty-incubator-transport-native-io_uring</artifactId>
<version>0.0.1.Final</version>
<classifier>linux-x86_64</classifier>
</dependency>
最低Netty版本为4.1.11.Final
的MacOS kqueue x86_64系统,需要netty-transport-native-kqueue
,分类器osx-x86_64
<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-transport-native-kqueue</artifactId>
<version>${netty-version}</version>
<classifier>osx-x86_64</classifier>
</dependency>
你可以通过系统属性禁用本机传输。
将io.lettuce.core.epoll
, io.lettuce.core.iouring
设置为false
(如果未设置,则默认为true
)。
通过Netty使用io_uring
是通过 《Java高并发核心编程 卷1 加强版》随书源码改的,改动没有超过 5行, 没有超过5行
参考的代码如下:
package com.crazymakercircle.imServer.server;
import com.crazymakercircle.im.common.codec.SimpleProtobufDecoder;
import com.crazymakercircle.im.common.codec.SimpleProtobufEncoder;
import com.crazymakercircle.imServer.handler.NettyEchoServerHandler;
import io.netty.bootstrap.ServerBootstrap;
import io.netty.buffer.PooledByteBufAllocator;
import io.netty.channel.*;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.SocketChannel;
import io.netty.channel.socket.nio.NioServerSocketChannel;
import io.netty.handler.logging.LogLevel;
import io.netty.handler.logging.LoggingHandler;
import io.netty.incubator.channel.uring.IOUringEventLoopGroup;
import io.netty.incubator.channel.uring.IOUringServerSocketChannel;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.net.InetSocketAddress;
@Data
@Slf4j
@Service("EchoIOUringServer")
public class EchoIOUringServer {
// 服务器端口
@Value("${server.port}")
private int port;
// 通过nio方式来接收连接和处理连接
private EventLoopGroup bg;
private EventLoopGroup wg;
// 启动引导器
private ServerBootstrap b = new ServerBootstrap();
public void run() {
//连接监听线程组
bg = new IOUringEventLoopGroup(1);
//传输处理线程组
wg = new IOUringEventLoopGroup(1);
try {
//1 设置reactor 线程
b.group(bg, wg);
//2 设置nio类型的channel
b.channel(IOUringServerSocketChannel.class);
//3 设置监听端口
b.localAddress(new InetSocketAddress(port));
//4 设置通道选项
// b.option(ChannelOption.SO_KEEPALIVE, true);
b.option(ChannelOption.ALLOCATOR, PooledByteBufAllocator.DEFAULT);
b.childOption(ChannelOption.ALLOCATOR, PooledByteBufAllocator.DEFAULT);
//5 装配流水线
b.childHandler(new ChannelInitializer<SocketChannel>() {
//有连接到达时会创建一个channel
protected void initChannel(SocketChannel ch) throws Exception {
// 管理pipeline中的Handler
ch.pipeline().addLast(NettyEchoServerHandler.INSTANCE);
}
});
// 6 开始绑定server
// 通过调用sync同步方法阻塞直到绑定成功
ChannelFuture channelFuture = b.bind().sync();
log.info(
"疯狂创客圈 EchoIOUringServer 服务启动, 端口 " +
channelFuture.channel().localAddress());
// 7 监听通道关闭事件
// 应用程序会一直等待,直到channel关闭
ChannelFuture closeFuture =
channelFuture.channel().closeFuture();
closeFuture.sync();
} catch (Exception e) {
e.printStackTrace();
} finally {
// 8 优雅关闭EventLoopGroup,
// 释放掉所有资源包括创建的线程
wg.shutdownGracefully();
bg.shutdownGracefully();
}
}
}
从 Netty 官方给的这个例子来看,io_uring 的使用方式与 epoll 一样,初步来看线程模型也是一样的,
也是分了 bossGroup 和 workerGroup 两个EventLoopGroup,
从名字猜测 bossGroup 还是处理连接创建,workerGroup 还是处理网络读写。
io_uring 的具体逻辑都封装在了 IOUringEventLoopGroup 和 IOUringServerSocketChannel 中。
Netty源码 IOUringEventLoopGroup
Netty 的线程模型是面试的核心重点,也比较复杂,此处不再赘述,详见《Java高并发核心编程 卷1 加强版》第四章,有太多小伙伴通过此章掌握了Netty 的线程模式。
我们先看一下 IOUringEventLoop 构造方法:
IOUringEventLoop(IOUringEventLoopGroup parent, Executor executor, int ringSize, int iosqeAsyncThreshold,
RejectedExecutionHandler rejectedExecutionHandler, EventLoopTaskQueueFactory queueFactory) {
super(parent, executor, false, newTaskQueue(queueFactory), newTaskQueue(queueFactory),
rejectedExecutionHandler);
// Ensure that we load all native bits as otherwise it may fail when try to use native methods in IovArray
IOUring.ensureAvailability();
ringBuffer = Native.createRingBuffer(ringSize, iosqeAsyncThreshold);
eventfd = Native.newBlockingEventFd();
logger.trace("New EventLoop: {}", this.toString());
}
可见每个事件循环处理线程都创建了一个 io_uring ringBuffer,另外还有一个用来通知事件的文件描述符 eventfd。
深入 Native.createRingBuffer(ringSize, iosqeAsyncThreshold) 看一下:
ringSize 默认值为 4096,iosqeAsyncThreshold 默认为 25
Netty 的这个 RingBuffer 封装基本上与 io_uring 的结构一一对应。
再深入看一下 io_uring_setup 的 JNI 封装,发现 Netty 当前的实现并没设置任何 flag,使用默认 中断模式,也就是通过 io_uring_enter 提交任务。
在实现层面,该模式倒是与 Netty 的线程模型很匹配,如果要支持 SQPOLL 模式,Netty的源码架构, 可能需要较大改动。
回过头来再看一下 IOUringEventLoop 的事件循环:
@Override
protected void run() {
final IOUringCompletionQueue completionQueue = ringBuffer.ioUringCompletionQueue();
final IOUringSubmissionQueue submissionQueue = ringBuffer.ioUringSubmissionQueue();
// Lets add the eventfd related events before starting to do any real work.
addEventFdRead(submissionQueue);
for (;;) {
try {
logger.trace("Run IOUringEventLoop {}", this);
// Prepare to block wait
long curDeadlineNanos = nextScheduledTaskDeadlineNanos();
if (curDeadlineNanos == -1L) {
curDeadlineNanos = NONE; // nothing on the calendar
}
nextWakeupNanos.set(curDeadlineNanos);
// Only submit a timeout if there are no tasks to process and do a blocking operation
// on the completionQueue.
try {
if (!hasTasks()) {
if (curDeadlineNanos != prevDeadlineNanos) {
prevDeadlineNanos = curDeadlineNanos;
submissionQueue.addTimeout(deadlineToDelayNanos(curDeadlineNanos), (short) 0);
}
// Check there were any completion events to process
if (!completionQueue.hasCompletions()) {
// Block if there is nothing to process after this try again to call process(....)
logger.trace("submitAndWait {}", this);
submissionQueue.submitAndWait();
}
}
} finally {
if (nextWakeupNanos.get() == AWAKE || nextWakeupNanos.getAndSet(AWAKE) == AWAKE) {
pendingWakeup = true;
}
}
} catch (Throwable t) {
handleLoopException(t);
}
// Avoid blocking for as long as possible - loop until available work exhausted
boolean maybeMoreWork = true;
do {
try {
// CQE processing can produce tasks, and new CQEs could arrive while
// processing tasks. So run both on every iteration and break when
// they both report that nothing was done (| means always run both).
maybeMoreWork = completionQueue.process(this) != 0 | runAllTasks();
} catch (Throwable t) {
handleLoopException(t);
}
// Always handle shutdown even if the loop processing threw an exception
try {
if (isShuttingDown()) {
closeAll();
if (confirmShutdown()) {
return;
}
if (!maybeMoreWork) {
maybeMoreWork = hasTasks() || completionQueue.hasCompletions();
}
}
} catch (Throwable t) {
handleLoopException(t);
}
} while (maybeMoreWork);
}
}
先交代两个非主干逻辑的细节:
addEventFdRead(submissionQueue) 将 eventfd 的读操作提交 io_uring,其作用主要用于唤醒事件循环线程。由于 submissionQueue.submitAndWait() 这一步是阻塞的,想要唤醒事件循环,向 eventfd 执行一个写操作即可。 submissionQueue.addTimeout(deadlineToDelayNanos(curDeadlineNanos), (short) 0) 用于处理延迟执行的任务,可以暂且忽略。
搞清楚上述两个细节,主干流程就很清晰了:
submissionQueue.submitAndWait() 提交任务,等待至少一个任务完成; completionQueue.process(callback) 处理已经完成的任务,回调方法也就是 void handle(int fd, int res, int flags, byte op, short data); 最后就是向 submissionQueue 添加任务。原来的epoll 模型是,epoll_wait 等待就绪事件,然后执行相关的 IO 系统调用;
Netty 当前的实现并没为 io_uring 设置任何 flag,使用默认 中断模式, 没有使用 内核轮询模式,
前面的三种模式的介绍到:中断模式是性能最差的一种。
可见,Netty 要努力迭代呀。
作为 IO之王, 大家可以通过尼恩对Netty源码的解读发现,可谓金碧辉煌、编程界的世界屋脊,
尼恩相信,Netty这种的王者组件,一定会在 aio这块提交出一份顶级的代码。
这一天,一定不会太远。
总结
可以看到,io_uring 是完全为性能而生的新一代 native async IO 模型。
通过全新的设计,共享内存,IO 过程不需要系统调用,由内核完成 IO 的提交, 以及 IO completion polling 机制,实现了高IOPS,高 Bandwidth。
参考文献
https://blog.csdn.net/BUG_zhentan/article/details/119538429
https://zhuanlan.zhihu.com/p/62682475
https://zhuanlan.zhihu.com/p/400927380
https://blog.csdn.net/u012549626/article/details/111520493
https://blog.csdn.net/qq_17045267/article/details/117953632
https://www.skyzh.dev/posts/articles/2021-06-14-deep-dive-io-uring/
End
此真题面试题,收录于《尼恩Java面试宝典》V52
硬核面试题推荐
网易一面:select分页要调优100倍,说说你的思路?(内含Mysql的36军规)
阿里二面:千万级、亿级数据,如何性能优化?教科书级 答案来了
硬核文章推荐
峰值21WQps、亿级DAU,小游戏《羊了个羊》是怎么架构的?
一文搞懂:缓存之王 Caffeine 架构、源码、原理(5W长文)
硬核电子书
👍《尼恩Java面试宝典》(极致经典,不断升级)全网下载超过300万次
👍尼恩Java高并发三部曲:全网下载超过200万次
👍《Java高并发核心编程-卷1(加强版)》,不断升级
👍《Java高并发核心编程-卷2(加强版)》,不断升级
👍《Java高并发核心编程-卷3(加强版)》,不断升级
👍《顶级3高架构行业案例 + 尼恩架构笔记 》N 篇+,不断添加
👍100份简历模板