环形IO模型:io_uring

全链路异步化的最终目标

全链路异步化的最终目标,如下图所示:

  • 应用层:编程模型的异步化

  • 框架层: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操作, 都是需要进行 系统调用完成的。

系统调用的性能耗费在哪里?

首先,线程是很”贵”的资源,主要表现在:

  1. 线程的创建和销毁成本很高,线程的创建和销毁都需要通过重量级的系统调用去完成。

  2. 线程本身占用较大内存,像Java的线程的栈内存,一般至少分配512K~1M的空间,如果系统中的线程数过千,整个JVM的内存将被耗用1G。

  3. 线程的切换成本是很高的。操作系统发生线程切换的时候,需要保留线程的上下文,然后执行系统调用。过多的线程频繁切换带来的后果是,可能执行线程切换的时间甚至会大于线程执行的时间,这时候带来的表现往往是系统CPU sy值特别高(超过20%以上)的情况,导致系统几乎陷入不可用的状态。

在Linux的性能指标里,有ussy两个指标,使用top命令可以很方便的看到。

图片

us是用户进程的意思,而sy是在内核中所使用的cpu占比。如果进程在内核态和用户态切换的非常频繁,那么效率大部分就会浪费在切换之上。一次内核态和用户态切换的时间,普遍在  微秒  级别以上,可以说非常昂贵了。cpu的性能是固定的,在无用的东西上浪费越小,在真正业务上的处理就效率越高。

影响效率的有两个方面:

  1. 进程或者线程的数量,引起过多的上下文切换。

    进程是由内核来管理和调度的,进程的切换只能发生在内核态。所以,如果你的代码切换了线程,它必然伴随着一次用户态和内核态的切换。

  2. 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可以看出来,该机制的核心即userring:其申请了一块用户态和内核态共享的内存作为环形数组,并在共享内存中通过ringBuf环形队列的方式来实现内核态和用户态的通信。

缩略语英语中文解析
SQSubmission Queue提交队列一整块连续的内存空间存储的环形队列。用于存放将执行操作的数据。
CQCompletion Queue完成队列一整块连续的内存空间存储的环形队列。用于存放完成操作返回的结果。
SQESubmission Queue Entry提交队列项提交队列中的一项。
CQECompletion Queue Entry完成队列项完成队列中的一项。
RingRing比如 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的主流程:

  1. 计算sq_entries, cq_entries的大小

  2. 分配 一个 io_ring_ctx 上下文 对象,  这是io_uring运行过程的上下文

  3. 分配  sqe,   cqe这些数组空间

  4. 如果是sq-poll模式则创建内核线程

  5. 创建io_wq  对象及相应的worker

  6. 如果是sq-poll,  且需要启动线程  ,   则启动之

  7. 把sq, cq的一些信息写到用户空间的params里,   这些信息用来在setup成功后,  映射内核内存

  8. 创建io_uring  对应的文件及socket,   这个文件的fd用来与用户空间通信 , 这个 fd,是一个匿名fd

重点提一下匿名 fd 的事情,为什么会有匿名 fd ? 什么是匿名?

  1. 在 Linux 里一切皆文件,你理解的常见“文件”有什么特性?

    文件的名称是 path 路径,匿名的意思说的就是没有路径。匿名 fd 其实说的是匿名 inode 。

  2. 在 Linux 的文件体系中,一个文件句柄,对应一个 file 结构体,关联一个 inode 。

    file/dentry/inode 这三驾马车是一定要配齐的,就算是匿名的(无 path,无效 dentry ),对于 file 结构体来说,一定要绑定 inode 和 dentry ,哪怕是伪造的、不完整的 inode。

  3. anon_inodefs 就应运而生了,内核就帮你搞出来一个公共的 inode

    这就节省了所有有这样需求的内核模块,避免了内存的浪费,省了冗余重复的 inode 初始化代码。

  4. 匿名 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->fdsqe->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(提交实体)数组。

其中,SQCQ是两个环形队列,队列中的元素是SQESQE数组中的偏移量,使用这种方式可以使得提交实体能够被随机访问,提高灵活性。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.epollio.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);
    }
}

先交代两个非主干逻辑的细节:

  1. addEventFdRead(submissionQueue) 将 eventfd 的读操作提交 io_uring,其作用主要用于唤醒事件循环线程。由于 submissionQueue.submitAndWait() 这一步是阻塞的,想要唤醒事件循环,向 eventfd 执行一个写操作即可。

  2. submissionQueue.addTimeout(deadlineToDelayNanos(curDeadlineNanos), (short) 0) 用于处理延迟执行的任务,可以暂且忽略。

搞清楚上述两个细节,主干流程就很清晰了:

  1. submissionQueue.submitAndWait() 提交任务,等待至少一个任务完成;

  2. completionQueue.process(callback) 处理已经完成的任务,回调方法也就是 void handle(int fd, int res, int flags, byte op, short data);

  3. 最后就是向 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/

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值