赞
踩
随着业务的发展,微服务应用的流量越来越大,使用到的资源也越来越多。
在微服务架构下,大量的应用都是 SpringCloud 分布式架构,这种架构总体上是全链路同步模式。
全链路同步模式不仅造成了资源的极大浪费,并且在流量发生激增波动的时候,受制于系统资源而无法快速的扩容。
全球后疫情时代,降本增效是大背景。如何降本增效?一条好的路径:全链路同步模式 ,升级为 全链路异步模式。
全链路异步模式改造 具体的内容,请参考尼恩的深度文章:全链路异步,让你的 SpringCloud 性能优化10倍+
先回顾一下全链路同步模式架构图
全链路同步模式 ,如何升级为 全链路异步模式, 就是一个一个 环节的异步化。
40岁老架构师尼恩,持续深化自己的3高架构知识宇宙,当然首先要去完成一次牛逼的全链路异步模式 微服务实操,下面是尼恩的实操过程、效果、压测数据(性能足足提升10倍多)。
全链路异步模式改造 具体的内容,请参考尼恩的深度文章:全链路异步,让你的 SpringCloud 性能优化10倍+
并且,上面的文章,作为尼恩 全链路异步的架构知识,收录在《尼恩Java面试宝典》V52版的架构专题中
注:本文以 PDF 持续更新,最新尼恩 架构笔记、面试题 的PDF文件,请从这里获取:码云
全链路异步化的最终目标,如下图所示:
一:应用层:编程模型的异步化
这个请大家去看 尼恩的 《响应式 圣经 PDF》电子书
随着 云原生时代的到来, 底层的 组件编程 越来越 响应式、流化, 从命令式 编程转换到 响应式 编程,在非常多的场景 ,是大势所趋。
而响应式编程, 学习曲线很大, 大家需要多看,多实操。
二:框架层:IO线程的异步化
这个大家 都选择 具有异步 回调功能的 异步线程模型,如 Reactor 线程模型
这个是面试的绝对重点
IO的王者组件,Netty框架,整体就是一个 Reactor 线程模型 实现
也是非常核心的知识,这里不做展开,请大家去看尼恩的畅销书《Java 高并发核心编程卷 1 加强版》。
三:OS层:IO模型的异步化
目前的一个最大难题,是IO模型的异步化。
注意,Netty 底层的IO模型,咱们一般用的是select或者 epoll,是同步IO,不是异步IO.
有关5大IO模型,是本文的基础知识,也是非常核心的知识,这里不做展开,请大家去看尼恩的畅销书《Java 高并发核心编程卷 1 加强版》。
首先来看线程模型的异步化。
了解了BIO和NIO的一些使用方式,Reactor模式就呼之欲出了。
NIO是基于事件机制的,有一个叫做Selector的选择器,阻塞获取关注的事件列表。获取到事件列表后,可以通过分发器,进行真正的数据操作。
上图是Doug Lea
在讲解NIO时候的一张图,指明了最简单的Reactor模型的基本元素。
你可以对比这上面的NIO代码分析一下,里面有四个主要元素:
我们可以对上面的模型进行近一步细化,下面这张图同样是Doug Lea
的ppt中的。
它把Reactor部分分为mainReactor和subReactor两部分。mainReactor负责监听处理新的连接,然后将后续的事件处理交给subReactor,subReactor对事件处理的方式,也由阻塞模式变成了多线程处理,引入了任务队列的模式。
这两个线程模型,非常重要。
一定要背到滚瓜烂熟。
这里不做展开,请大家去看尼恩的畅销书《Java 高并发核心编程卷 1 加强版》。
目前的一个最大难题,是IO模型的异步化。
注意,Netty 底层的IO模型,咱们一般用的是select或者 epoll,是同步IO,不是异步IO.
首先看看五大IO模型吧:
如上图,是典型的BIO模型,每当有一个连接到来,经过协调器的处理,就开启一个对应的线程进行接管。
如果连接有1000条,那就需要1000个线程。线程资源是非常昂贵的,除了占用大量的内存,还会占用非常多的CPU调度时间,所以BIO在连接非常多的情况下,效率会变得非常低。
就单个阻塞IO
来说,它的效率并不比NIO
慢。但是当服务的连接增多,考虑到整个服务器的资源调度和资源利用率等因素,NIO
就有了显著的效果,NIO非常适合高并发场景。
其实,在处理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有哪些改进?
这里直接给出答案:
有关5大IO模型,是本文的基础知识,也是非常核心的知识,非常重要
这里不做展开,请大家去看尼恩的畅销书《Java 高并发核心编程卷 1 加强版》。
这里有一个很大的性能损耗点,同步IO中,线程的切换、 IO事件的轮询、IO操作, 都是需要进行 系统调用完成的。
首先,线程是很”贵”的资源,主要表现在:
在Linux的性能指标里,有us
和sy
两个指标,使用top
命令可以很方便的看到。
us
是用户进程的意思,而sy
是在内核中所使用的cpu占比。
如果进程在内核态和用户态切换的非常频繁,那么效率大部分就会浪费在切换之上。一次内核态和用户态切换的时间,普遍在 微秒 级别以上,可以说非常昂贵了。
cpu的性能是固定的,在无用的东西上浪费越小,在真正业务上的处理就效率越高。
影响效率的有两个方面:
进程或者线程的数量,引起过多的上下文切换。
进程是由内核来管理和调度的,进程的切换只能发生在内核态。所以,如果你的代码切换了线程,它必然伴随着一次用户态和内核态的切换。
IO的编程模型,引起过多的系统态和内核态切换。
比如同步阻塞等待的模型,需要经过数据接收、软中断的处理(内核态),然后唤醒用户线程(用户态),处理完毕之后再进入等待状态(内核态)。
注意:一次内核态和用户态切换的时间,普遍在 微秒 级别以上,可以说非常昂贵了。
IO模型的异步化的第一个目标: 减少线程数量,减少线程切换系统调用带来 CPU 上下文切换的开销。
IO模型的异步化的第一个目标: 减少IO系统调用,减少线程切换系统调用带来的带来 CPU 上下文切换开销。
用户空间内核空间、用户态内核态,又是一组极致复杂的概念。同样是本文的基础知识,也是非常核心的知识,非常重要。这里不做展开,请大家去看尼恩的3 高架构笔记 《高性能之葵花宝典》。
在尼恩的疯狂创客圈社群(50+)中, 经常有人被 IO模型, Reactor反应器模型,同步、异步搞晕。
尼恩用几十年的经验总结,给大家做一个简单梳理:
很多小伙伴把Reactor 反应器,一定认为底层的IO模型是NIO, 大家去看看Netty源码, Netty反应器,支持各种IO模,包括BIO。
所以,一定要分层去看。
尼恩把线程模型和IO模型的,给大家分为三层: 应用层、框架层、 OS层。
具体如下图所示:
Netty的 Reactor 模式,对应到是:线程模型。不是对应到 IO模型。
在IO模型的层面,Tomcat 也用了 NIO,大家一定不要以为Tomcat还用BIO,还用 ,大部分的HTTPClient客户端组件,都用了NIO,都不会使用BIO模型的。
在线程模型的层面,很多的HTTPClient组件,要么没有使用 Reactor模型,要么是使用了Reactor反应性线程模型,但是我们的业务程序不用,咱们的业务程序,用的还是其同步阻塞线程模型的API代码。
大家都知道BIO非常的低效,而网络编程中的IO多路复用普遍比较高效。
Linux中,一直没有成熟的异步IO内核组件。
现在,io_uring已经能够挑战NIO的,功能非常强大。
io_uring在2019加入了Linux内核,目前5.1+的内核,可以采用这个功能。
随着一步步的优化,系统调用这个大家伙,调用次数越来越少了。
让我们先看看 linux 中的各种异步 IO,也就是 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
linux kernel 2.6 版本引入了原生异步 IO 支持 —— libaio,也被称为 native aio。
ibaio 与 glibc aio 的多线程伪异步不同,它真正的内核异步通知,是真正的异步IO。
虽然很真了,但是缺陷也很明显:libaio 仅支持 O_DIRECT 标志,也就是 Direct I/O,这意味着无法利用系统缓存,同时读写的的大小和偏移要以区块的方式对齐。
由于上面两个都不靠谱,所以 Marc Lehmann 又开发了一个 AIO 库 —— libeio。
与 glibc aio 的思路一样,也是在用户空间用多线程同步模拟异步 IO,但是 libeio 实现的更高效,代码也更稳定,著名的 node.js 早期版本就是用 libev 和 libeio 驱动的(新版本在 libuv 中移除了 libev 和 libeio)。
libeio 提供全套异步文件操作的接口,让用户能写出完全非阻塞的程序,但 libeio 也不属于真正的异步IO。
libeio 项目地址:https://github.com/kindy/libeio
接下来就是 linux kernel 5.1 版本引入的 io_uring 了。
io_uring 类似于 Windows 世界的 IOCP,但是还没有达到对应的地位,目前来看正式使用 io_uring 的产品基本没有,
目前还是没有一个成熟的基础框架与其匹配,至于 Netty 对 io_uring 的封装,看下来的总体感受是:Netty 为了维持编程模型统一,完全没有发挥出 io_uring 的长处。具体 Netty 是如何封装的,后面会一起探讨一下。
但是在未来,一定是异步IO的天下, 今天,咱们就从io_uring 的学习开始吧。
前面讲到,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 中,应用程序可以使用两个队列来和 Kernel 进行通信:
而这两个队列中的保存的主要是指针或者编号(index),真正的IO请求,保存在一个基于数组结构的环形队列中,这个环形队列的结构如下图:
这块内存共分为三个区域,分别是 SQ,CQ,SQEs。
SQEs是一个环形数组,保存实际的IO请求,之所以采用了一个额外数组保存 SQEs,是为了方便通过 RingBuffer 提交内存上不连续的请求。
两个队列 SQ 和 CQ 中每个节点,保存的并不是IO请求,保存的都是 SQEs 数组的偏移量,实际的请求只保存在 SQEs 数组中。一个 SQE 条目的结构,主要包含以下的内容:
CQE包含
注意:由于 SQ,CQ,SQEs 是在内核中分配的,所以用户态程序并不能直接访问。
应用程序如何和内核进行队列共享呢?
io_setup 的返回值是一个 fd,应用程序使用这个 fd 进行 mmap,和 kernel 共享一块内存。
注意,是应用程序拿到这个 fd 进行 mmap,映射到自己的内存地址。
映射完了之后,根据 offset 偏移量,进行 访问。
而偏移量,和内核的偏移量地址,是相同的。创建 kernel 返回的 io_sqring_offset 和 io_cqring_offset 两个偏移量:
这里很关键,用到了文件映射, 共享内存映射,有关文件映射和内存映射的原理和实操,请参见
MappedByteBuffer 详解(图解+秒懂+史上最全) - 疯狂创客圈 - 博客园 (cnblogs.com)
这个知识点,一定要掌握
在io_uring在准备阶段,会涉及到三个系统调用:
425 io_uring_setup
426 io_uring_enter
427 io_uring_register
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 参数包括两种:
其中:
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的主流程:
重点提一下匿名 fd 的事情,为什么会有匿名 fd ? 什么是匿名?
file/dentry/inode
这三驾马车是一定要配齐的,就算是匿名的(无 path,无效 dentry ),对于 file 结构体来说,一定要绑定 inode 和 dentry ,哪怕是伪造的、不完整的 inode。fs/anon_inodes.c
),涉及的文件描述符的引用操作,比较低性能:
这样对高 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_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
获得了以下几点收益:
sqpoll
轮询模式。IORING_SETUP_SQ_AFF
和 sq_thread_cpu 绑定特定的 CPU。IORING_ENTER_SQ_WAKEUP
唤醒该内核线程,用户态可以通过 sqring 的 flags 变量获取 SQ 线程的状态。常规的块设备IO使用的都是中断模式,即进程将IO请求提交给块设备后会进入睡眠(D)状态,块设备在处理完IO请求后会触发硬中断,硬中断中会唤醒进程并通知其IO的完成。
什么是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 提交的做法是找到一个空闲的 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上下文切换。
这里和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
的内核和应用交互方式,具体如下:
提交任务的过程如下:
io_uring
上下文。接下来我们简要介绍内核获取任务、内核完成任务、用户收割任务的过程。
当 IO 完成时,内核负责将完成 IO 在 SQEs 中的 index 放到 CQ 中。
io_uring
ctx 上下文的 SQ tail。由于 IO 在提交的时候可以顺便返回完成的 IO,所以收割 IO 不需要额外系统调用。
这是跟 IO提交有比较大的不同,省去了一次系统调用。
当然,如果使用了 IORING_SETUP_SQPOLL 参数,IO 收割也不需要系统调用的参与。
由于内核和用户态共享内存,所以收割的时候,用户态遍历 [cq->head, cq->tail) 区间,这是已经完成的 IO 队列,然后找到相应的 CQE 并进行处理,最后移动 head 指针到 tail,IO 收割就到此结束了。
所以在最理想的情况下,IO 提交和收割都不需要使用系统调用。
由于提交和收割的时候需要访问共享内存的 head,tail 指针,所以需要使用 rmb/wmb 内存屏障操作确保时序。
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提供了三种特定于平台的JNI(Native Transports)本地传输:
如果适当的库在其运行时可用,则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
)。
是通过 《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 的线程模型是面试的核心重点,也比较复杂,此处不再赘述,详见《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); } }
先交代两个非主干逻辑的细节:
搞清楚上述两个细节,主干流程就很清晰了:
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/
《4次迭代,让我的 Client 优化 100倍!泄漏一个 人人可用的极品方案!》
《阿里一面:你做过哪些代码优化?来一个人人可以用的极品案例》
《阿里二面:千万级、亿级数据,如何性能优化? 教科书级 答案来了》
《峰值21WQps、亿级DAU,小游戏《羊了个羊》是怎么架构的?》
《Springcloud gateway 底层原理、核心实战 (史上最全)》
《分库分表 Sharding-JDBC 底层原理、核心实战(史上最全)》
《clickhouse 超底层原理 + 高可用实操 (史上最全)》
《队列之王: Disruptor 原理、架构、源码 一文穿透》
《环形队列、 条带环形队列 Striped-RingBuffer (史上最全)》
《一文搞定:SpringBoot、SLF4j、Log4j、Logback、Netty之间混乱关系(史上最全)》
《缓存之王:Caffeine 源码、架构、原理(史上最全,10W字 超级长文)》
《Java Agent 探针、字节码增强 ByteBuddy(史上最全)》
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。