干翻 nio ,王炸 io_uring 来了 !!(图解+史上最全)

文章很长,而且持续更新,建议收藏起来,慢慢读!疯狂创客圈总目录 博客园版 为您奉上珍贵的学习资源 :

免费赠送 :《尼恩Java面试宝典》 持续更新+ 史上最全 + 面试必备 2000页+ 面试必备 + 大厂必备 +涨薪必备
免费赠送 :《尼恩技术圣经+高并发系列PDF》 ,帮你 实现技术自由,完成职业升级, 薪酬猛涨!加尼恩免费领
免费赠送 经典图书:《Java高并发核心编程(卷1)加强版》 面试必备 + 大厂必备 +涨薪必备 加尼恩免费领
免费赠送 经典图书:《Java高并发核心编程(卷2)加强版》 面试必备 + 大厂必备 +涨薪必备 加尼恩免费领
免费赠送 经典图书:《Java高并发核心编程(卷3)加强版》 面试必备 + 大厂必备 +涨薪必备 加尼恩免费领

免费赠送 资源宝库: Java 必备 百度网盘资源大合集 价值>10000元 加尼恩领取


大趋势:全链路异步化,性能提升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操作, 都是需要进行 系统调用完成的。

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

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

  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 上下文切换开销。

用户空间内核空间、用户态内核态,又是一组极致复杂的概念。同样是本文的基础知识,也是非常核心的知识,非常重要。这里不做展开,请大家去看尼恩的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我们就可以看出来,该机制的核心即userring:其申请了一块用户态和内核态共享的内存作为环形数组,并在共享内存中通过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)

这个知识点,一定要掌握

内核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);
  • fdio_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.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);
    }
}

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

  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,使用默认 中断模式, 没有使用 内核轮询模式,

前面的三种模式的介绍到: 中断模式是性能最差的一种。

可见,Netty 要努力迭代呀。

作为 IO之王, 大家可以通过尼恩对Netty源码的解读发现,可谓金碧辉煌、编程界的世界屋脊,

尼恩相信,Netty这种的王者组件,一定会在 aio这块提交出一份顶级的代码。

这一天,一定不会太远。

总结

可以看到,io_uring 是完全为性能而生的新一代 native async IO 模型。

通过全新的设计,共享内存,IO 过程不需要系统调用,由内核完成 IO 的提交, 以及 IO completion polling 机制,实现了高IOPS,高 Bandwidth。

注:本文以 PDF 持续更新,最新尼恩 架构笔记、面试题 的PDF文件,请从这里获取:码云


参考文献

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/

推荐阅读:

SpringCloud+Dubbo3 = 王炸 !

响应式圣经:10W字,实现Spring响应式编程自由

4次迭代,让我的 Client 优化 100倍!泄漏一个 人人可用的极品方案!

100亿级订单怎么调度,来一个大厂的极品方案

Linux命令大全:2W多字,一次实现Linux自由

阿里一面:谈一下你对DDD的理解?2W字,帮你实现DDD自由

阿里一面:你做过哪些代码优化?来一个人人可以用的极品案例

网易二面:CPU狂飙900%,该怎么处理?

阿里二面:千万级、亿级数据,如何性能优化? 教科书级 答案来了

峰值21WQps、亿级DAU,小游戏《羊了个羊》是怎么架构的?

场景题:假设10W人突访,你的系统如何做到不 雪崩?

2个大厂 100亿级 超大流量 红包 架构方案

Nginx面试题(史上最全 + 持续更新)

K8S面试题(史上最全 + 持续更新)

操作系统面试题(史上最全、持续更新)

Docker面试题(史上最全 + 持续更新)

Springcloud gateway 底层原理、核心实战 (史上最全)

Flux、Mono、Reactor 实战(史上最全)

sentinel (史上最全)

Nacos (史上最全)

分库分表 Sharding-JDBC 底层原理、核心实战(史上最全)

clickhouse 超底层原理 + 高可用实操 (史上最全)

nacos高可用(图解+秒懂+史上最全)

队列之王: Disruptor 原理、架构、源码 一文穿透

环形队列、 条带环形队列 Striped-RingBuffer (史上最全)

一文搞定:SpringBoot、SLF4j、Log4j、Logback、Netty之间混乱关系(史上最全)

红黑树( 图解 + 秒懂 + 史上最全)

分布式事务 (秒懂)

缓存之王:Caffeine 源码、架构、原理(史上最全,10W字 超级长文)

缓存之王:Caffeine 的使用(史上最全)

Docker原理(图解+秒懂+史上最全)

Redis分布式锁(图解 - 秒懂 - 史上最全)

Zookeeper 分布式锁 - 图解 - 秒懂

Netty 粘包 拆包 | 史上最全解读

Netty 100万级高并发服务器配置

posted @ 2023-02-23 22:06  疯狂创客圈  阅读(2302)  评论(0编辑  收藏  举报