全链路异步化的最终目标
全链路异步化的最终目标,如下图所示:
-
应用层:编程模型的异步化
-
框架层: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中传递的指针。

最低0.47元/天 解锁文章
521

被折叠的 条评论
为什么被折叠?



