全链路异步化的最终目标
全链路异步化的最终目标,如下图所示:
-
应用层:编程模型的异步化
-
框架层:IO线程的异步化
-
OS层:IO模型的异步化
一:应用层:编程模型的异步化
随着云原生时代的到来,底层的组件编程越来越响应式、流化,从命令式编程转换到响应式编程,在非常多的场景,是大势所趋。
二:框架层:IO线程的异步化
选择具有异步回调功能的异步线程模型,如Reactor线程模型。
三:OS层:IO模型的异步化
目前的一个最大难题,是IO模型的异步化。注意,Netty 底层的IO模型,一般用的是select或者epoll,是同步IO,不是异步IO。
第二层:线程模型的异步化
首先来看线程模型的异步化。
Reactor模式
NIO是基于事件机制的,有一个叫做Selector的选择器,阻塞获取关注的事件列表。获取到事件列表后,可以通过分发器,进行真正的数据操作。
上图是Doug Lea
在讲解NIO时候的一张图,指明了最简单的Reactor模型的基本元素。
-
Acceptor 处理client的连接,并绑定具体的事件处理器
-
Event 具体发生的事件
-
Handler 执行具体事件的处理者。比如处理读写事件
-
Reactor 将具体的事件分配给Handler
我们可以对上面的模型进行近一步细化,下面这张图同样是Doug Lea
的ppt中的。
它把Reactor部分分为mainReactor和subReactor两部分。mainReactor负责监听处理新的连接,然后将后续的事件处理交给subReactor,subReactor对事件处理的方式,也由阻塞模式变成了多线程处理,引入了任务队列的模式。
这两个线程模型,非常重要。
第三层:OS中IO模型的异步化
目前的一个最大难题,是IO模型的异步化。注意,Netty 底层的IO模型,咱们一般用的是select或者 epoll,是同步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就会被激活,将事件通知到应用方
相对于select,epoll有哪些改进?
-
epoll不再需要像select一样对fd集合进行轮询,也不需要在调用时将fd集合在用户态和内核态进行交换
-
应用程序获得就绪fd的事件复杂度,epoll时O(1),select是O(n)
-
select最大支持约1024个fd,epoll支持65535个
-
select使用轮询模式检测就绪事件,epoll采用通知方式,更加高效
为啥需要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 上下文切换开销。
线程模型和IO模型的概念误区
-
需要要分层思考,就想 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 的长处。
io_uring (用户环形IO)
前面讲到,NIO依然有大量的系统调用,那就是Epoll的epoll_ctl。另外,获取到网络事件之后,还需要把socket的数据进行存取,这也是一次系统调用。虽然相对于BIO来说,上下文切换次数已经减少很多,但它仍然花费了比较多的时间在切换之上。
IO只负责对发生在fd描述符上的事件进行通知。事件的获取和通知部分是非阻塞的,但收到通知之后的操作,却是阻塞的。即使使用多线程去处理这些事件,它依然是阻塞的。
如果能把这些系统调用都放在操作系统里完成,那么就可以节省下这些系统调用的时间,io_uring就是干这个的。
从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 中,应用程序可以使用两个队列来和 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 详解: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 三种工作模式
-
中断驱动模式
默认模式。可通过系统调用 io_uring_enter() 提交IO请求,然后检查CQ状态判断是否完成。
-
轮询模式 / poll 模式。
需要文件系统和块设备支持。相比中断驱动,延迟更低,但可能会消耗更多CPU资源。
-
内核轮询模式 / 提交
sqpoll
轮询模式。
创建内核线程执行SQ轮询。当前应用更新 SQ ring 并填充一个新的 sqe,内核线程 sqthread 会自动完成提交,这样应用无需每次调用 io_uring_enter() 系统调用来提交 IO。
应用可通过 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
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
我们先看一下 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,使用默认中断模式,没有使用内核轮询模式,前面的三种模式的介绍到:中断模式是性能最差的一种。
总结
可以看到,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/