网络包接受流程

|500

性能开销

  • 应用程序通过 系统调用用户态 转为 内核态 的开销以及系统调用 返回 时从 内核态 转为 用户态 的开销。
  • 网络数据从内核空间通过CPU拷贝用户空间的开销。
  • 内核线程ksoftirqd响应软中断的开销。
  • CPU响应硬中断的开销。
  • DMA拷贝 网络数据包到 内存 中的开销。

网络包接受流程

|500

注意点 sk_buffer

sk_buffer 内部其实包含了网络协议中所有的 header。在设置 TCP HEADER的时候,只是把指针指向 sk_buffer的合适位置。后面再设置 IP HEADER的时候,在把指针移动一下就行,避免频繁的内存申请和拷贝,效率很高。
|450
为什么不直接使用 Socket 发送队列中的 sk_buffer 而是需要拷贝一份呢? 因为 TCP协议 是支持 丢包重传 的,在没有收到对端的 ACK 之前,这个 sk_buffer 是不能删除的。内核每次调用网卡发送数据的时候,实际上传递的是 sk_buffer拷贝副本,当网卡把数据发送出去后,sk_buffer 拷贝副本会被释放。当收到对端的 ACK 之后,Socket 发送队列中的 sk_buffer 才会被真正删除。

性能开销

  • 和接收数据一样,应用程序在调用 系统调用send 的时候会从 用户态 转为 内核态 以及发送完数据后,系统调用 返回时从 内核态 转为 用户态 的开销。
  • 用户线程内核态CPU quota用尽时触发NET_TX_SOFTIRQ类型软中断,内核响应软中断的开销。
  • 网卡发送完数据,向CPU发送硬中断,CPU响应硬中断的开销。以及在硬中断中发送NET_RX_SOFTIRQ软中断执行具体的内存清理动作。内核响应软中断的开销。
  • 内存拷贝的开销。我们来回顾下在数据包发送的过程中都发生了哪些内存拷贝:
  • 在内核协议栈的传输层中,TCP协议对应的发送函数tcp_sendmsg会申请sk_buffer,将用户要发送的数据拷贝sk_buffer中。
  • 在发送流程从传输层到网络层的时候,会拷贝一个sk_buffer副本出来,将这个sk_buffer副本向下传递。原始sk_buffer保留在Socket发送队列中,等待网络对端ACK,对端ACK后删除Socket发送队列中的sk_buffer。对端没有发送ACK,则重新从Socket发送队列中发送,实现TCP协议的可靠传输。
  • 在网络层,如果发现要发送的数据大于 MTU,则会进行分片操作,申请额外的 sk_buffer,并将原来的 sk_buffer 拷贝 到多个小的 sk_buffer 中。

阻塞非阻塞 与 异步非异步

经过前边对网络数据包接收流程的介绍,在这里我们可以将整个流程总结为两个阶段:

Image|475

  • 数据准备阶段:  在这个阶段,网络数据包到达网卡,通过 DMA 的方式将数据包拷贝到内存中,然后经过硬中断,软中断,接着通过内核线程 ksoftirqd 经过内核协议栈的处理,最终将数据发送到 内核Socket 的接收缓冲区中。
  • 数据拷贝阶段:  当数据到达 内核Socket 的接收缓冲区中时,此时数据存在于 内核空间 中,需要将数据 拷贝用户空间 中,才能够被应用程序读取。

阻塞与非阻塞

阻塞与非阻塞的区别主要发生在第一阶段:数据准备阶段

当应用程序发起系统调用read时,线程从用户态转为内核态,读取内核Socket的接收缓冲区中的网络数据。

阻塞

如果这时内核Socket的接收缓冲区没有数据,那么线程就会一直等待,直到Socket接收缓冲区有数据为止。随后将数据从内核空间拷贝到用户空间,系统调用read返回。

Image|500

阻塞的特点是在第一阶段和第二阶段 都会等待

非阻塞

阻塞非阻塞 主要的区分是在第一阶段:数据准备阶段

  • 在第一阶段,当Socket的接收缓冲区中没有数据的时候,阻塞模式下应用线程会一直等待。非阻塞模式下应用线程不会等待,系统调用直接返回错误标志EWOULDBLOCK
  • Socket的接收缓冲区中有数据的时候,阻塞非阻塞的表现是一样的,都会进入第二阶段等待数据从内核空间拷贝到用户空间,然后系统调用返回

Image

从上图中,我们可以看出: 非阻塞 的特点是第一阶段 不会等待,但是在第二阶段还是会 等待

同步与异步

同步异步 主要的区别发生在第二阶段:数据拷贝阶段

前边我们提到在数据拷贝阶段主要是将数据从内核空间拷贝到用户空间。然后应用程序才可以读取数据。

当内核 Socket 的接收缓冲区有数据到达时,进入第二阶段。

同步

同步模式在数据准备好后,是由用户线程内核态来执行第二阶段。所以应用程序会在第二阶段发生阻塞,直到数据从内核空间拷贝到用户空间,系统调用才会返回。

Linux下的 epoll和Mac 下的 kqueue都属于同步 IO

Image|475

异步

异步模式下是由内核来执行第二阶段的数据拷贝操作,当内核执行完第二阶段,会通知用户线程IO操作已经完成,并将数据回调给用户线程。所以在异步模式下 数据准备阶段数据拷贝阶段均是由内核来完成,不会对应用程序造成任何阻塞。

基于以上特征,我们可以看到异步模式需要内核的支持,比较依赖操作系统底层的支持。

在目前流行的操作系统中,只有Windows 中的 IOCP才真正属于异步 IO,实现的也非常成熟。但Windows很少用来作为服务器使用。

而常用来作为服务器使用的Linux,异步IO机制实现的不够成熟,与NIO相比性能提升的也不够明显。

但Linux kernel 在5.1版本由Facebook的大神Jens Axboe引入了新的异步IO库io_uring 改善了原来Linux native AIO的一些性能问题。性能相比Epoll以及之前原生的AIO提高了不少,值得关注。

Image|500

IO 模型与适用场景

阻塞 IO 模型

Image|500

阻塞IO模型.png

由于阻塞IO的读写特点,所以导致在阻塞IO模型下,每个请求都需要被一个独立的线程处理。一个线程在同一时刻只能与一个连接绑定。来一个请求,服务端就需要创建一个线程用来处理请求。

当客户端请求的并发量突然增大时,服务端在一瞬间就会创建出大量的线程,而创建线程是需要系统资源开销的,这样一来就会一瞬间占用大量的系统资源。

如果客户端创建好连接后,但是一直不发数据,通常大部分情况下,网络连接也并不总是有数据可读,那么在空闲的这段时间内,服务端线程就会一直处于阻塞状态,无法干其他的事情。CPU也无法得到充分的发挥,同时还会导致大量线程切换的开销

适用场景

基于以上阻塞IO模型的特点,该模型只适用于连接数少并发度低的业务场景。

比如公司内部的一些管理系统,通常请求数在100个左右,使用 阻塞IO模型 还是非常适合的。而且性能还不输 NIO。

非阻塞 IO 模型

Image|500

基于以上非阻塞IO的特点,我们就不必像阻塞IO那样为每个请求分配一个线程去处理连接上的读写了。

我们可以利用 一个线程或者很少的线程 ,去 不断地轮询 每个 Socket 的接收缓冲区是否有数据到达,如果没有数据,不必阻塞 线程,而是接着去 轮询 下一个 Socket 接收缓冲区,直到轮询到数据后,处理连接上的读写,或者交给业务线程池去处理,轮询线程则 继续轮询 其他的 Socket 接收缓冲区。

这样一个 非阻塞IO模型 就实现了我们在本小节开始提出的需求: 我们需要用尽可能少的线程去处理更多的连接

适用场景

虽然非阻塞IO模型阻塞IO模型相比,减少了很大一部分的资源消耗和系统开销。

但是它仍然有很大的性能问题,因为在 非阻塞IO模型 下,需要用户线程去 不断地 发起 系统调用 去轮训 Socket 接收缓冲区,这就需要用户线程不断地从 用户态 切换到 内核态内核态 切换到 用户态。随着并发量的增大,这个上下文切换的开销也是巨大的。

IO 多路复用

什么是 多路?,什么又是 复用 呢?

我们还是以这个核心需求来对这两个概念展开阐述:

  • 多路 :我们的核心需求是要用尽可能少的线程来处理尽可能多的连接,这里的 多路 指的就是我们需要处理的众多连接。
  • 复用 :核心需求要求我们使用 尽可能少的线程尽可能少的系统开销 去处理 尽可能多 的连接(多路),那么这里的 复用 指的就是用 有限的资源,比如用一个线程或者固定数量的线程去处理众多连接上的读写事件。换句话说,在 阻塞IO模型 中一个连接就需要分配一个独立的线程去专门处理这个连接上的读写,到了 IO多路复用模型 中,多个连接可以 复用 这一个独立的线程去处理这多个连接上的读写。

好了,IO多路复用模型的概念解释清楚了,那么 问题的关键 是我们如何去实现这个复用,也就是如何让一个独立的线程去处理众多连接上的读写事件呢?

这个问题其实在非阻塞IO模型中已经给出了它的答案,在非阻塞IO模型中,利用非阻塞的系统IO调用去不断的轮询众多连接的Socket接收缓冲区看是否有数据到来,如果有则处理,如果没有则继续轮询下一个Socket。这样就达到了用一个线程去处理众多连接上的读写事件了。

但是非阻塞IO模型最大的问题就是需要不断的发起系统调用去轮询各个Socket中的接收缓冲区是否有数据到来,频繁系统调用随之带来了大量的上下文切换开销。随着并发量的提升,这样也会导致非常严重的性能问题。

那么如何避免频繁的系统调用同时又可以实现我们的核心需求呢?

这就需要操作系统的内核来支持这样的操作,我们可以把频繁的轮询操作交给操作系统内核来替我们完成,这样就避免了在用户空间频繁的去使用系统调用来轮询所带来的性能开销。

正如我们所想,操作系统内核也确实为我们提供了这样的功能实现,下面我们来一起看下操作系统对 IO多路复用模型 的实现。

epoll

|450

  • epoll 在内核中通过 红黑树 管理海量的连接,所以在调用 epoll_wait 获取 IO就绪 的 socket 时,不需要传入监听的 socket 文件描述符。从而避免了海量的文件描述符集合在 用户空间内核空间 中来回复制。

select,poll每次调用时都需要传递全量的文件描述符集合,导致大量频繁的拷贝操作。

  • epoll 仅会通知 IO就绪 的 socket。避免了在用户空间遍历的开销。

select,poll 只会在 IO就绪 的 socket 上打好标记,依然是全量返回,所以在用户空间还需要用户程序在一次遍历全量集合找出具体 IO就绪 的 socket。

  • epoll 通过在 socket 的等待队列上注册回调函数 ep_poll_callback 通知用户程序 IO就绪 的 socket。避免了在内核中轮询的开销。

大部分情况下 socket 上并不总是 IO活跃 的,在面对海量连接的情况下,select,poll 采用内核轮询的方式获取 IO活跃 的 socket,无疑是性能低下的核心原因。

信号驱动 IO

信号驱动IO模型 下,用户进程操作通过 系统调用 sigaction 函数 发起一个 IO 请求,在对应的 socket 注册一个 信号回调,此时 不阻塞 用户进程,进程会继续工作。当内核数据就绪时,内核就为该进程生成一个 SIGIO 信号,通过信号回调通知进程进行相关 IO 操作。

这里需要注意的是:信号驱动式 IO 模型 依然是 同步IO,因为它虽然可以在等待数据的时候不被阻塞,也不会频繁的轮询,但是当数据就绪,内核信号通知后,用户进程依然要自己去读取数据,在 数据拷贝阶段 发生阻塞。

信号驱动 IO模型 相比于前三种 IO 模型,实现了在等待数据就绪时,进程不被阻塞,主循环可以继续工作,所以理论上性能更佳。

但是实际上,使用 TCP协议 通信时,信号驱动IO模型 几乎 不会被采用。原因如下:

  • 信号IO 在大量 IO 操作时可能会因为信号队列溢出导致没法通知
  • SIGIO 信号 是一种 Unix 信号,信号没有附加信息,如果一个信号源有多种产生信号的原因,信号接收者就无法确定究竟发生了什么。而 TCP socket 生产的信号事件有七种之多,这样应用程序收到 SIGIO,根本无从区分处理。

Note

信号驱动和多路复用的 epoll 很像。
只不过一个是通过信号的方式,直接发送信号到对应的进程。
一个是有一层中转,需要有一个监听线程 epoll, 通过监听线程 epoll 去通知用户进程。

总结

针对以上 IO 模型, 有一个相对比较有趣的比喻
去食堂打饭,你是排队打饭?还是等着叫号,还是怎么样?详见 IO 模型的比喻

用户线程 IO 模型

前边的内容都是以 内核空间 的视角来剖析网络数据的收发模型,本小节我们站在 用户空间 的视角来看下如果对网络数据进行收发。

相对 内核 来讲,用户空间的IO线程模型 相对就简单一些。这些 用户空间IO线程模型 都是在讨论当多线程一起配合工作时谁负责接收连接,谁负责响应 IO 读写、谁负责计算、谁负责发送和接收,仅仅是用户 IO 线程的不同分工模式罢了。

Reactor 是利用 NIOIO线程 进行不同的分工:

  • 使用前边我们提到的IO多路复用模型比如select,poll,epoll,kqueue,进行IO事件的注册和监听。
  • 将监听到就绪的IO事件分发dispatch到各个具体的处理Handler中进行相应的IO事件处理

通过IO多路复用技术就可以不断的监听IO事件,不断的分发dispatch,就像一个反应堆一样,看起来像不断的产生IO事件,因此我们称这种模式为Reactor模型。

下面我们来看下Reactor模型的三种分类:

单Reactor单线程

Image|500

Reactor模型 是依赖 IO多路复用技术 实现监听 IO事件,从而源源不断的产生 IO就绪事件,在 Linux 系统下我们使用 epoll 来进行 IO多路复用,我们以 Linux 系统为例:

  • Reactor 意味着只有一个 epoll 对象,用来监听所有的事件,比如 连接事件读写事件
  • 单线程 意味着只有一个线程来执行 epoll_wait 获取 IO就绪Socket,然后对这些就绪的 Socket 执行读写,以及后边的业务处理也依然是这个线程。

单Reactor单线程 模型就好比我们开了一个很小很小的小饭馆,作为老板的我们需要一个人干所有的事情,包括:迎接顾客(accept事件),为顾客介绍菜单等待顾客点菜(IO请求),做菜(业务处理),上菜(IO响应),送客(断开连接)。

单Reactor多线程

随着客人的增多(并发请求),显然饭馆里的事情只有我们一个人干(单线程)肯定是忙不过来的,这时候我们就需要多招聘一些员工(多线程)来帮着一起干上述的事情。

于是就有了 单Reactor多线程 模型:

Image|500

单Reactor多线程

  • 这种模式下,也是只有一个 epoll 对象来监听所有的 IO事件,一个线程来调用 epoll_wait 获取 IO就绪Socket
  • 但是当 IO就绪事件 产生时,这些 IO事件 对应处理的业务 Handler,我们是通过线程池来执行。这样相比 单Reactor单线程 模型提高了执行效率,充分发挥了多核 CPU 的优势。

主从Reactor多线程

做任何事情都要区分 事情的优先级,我们应该 优先高效 的去做 优先级更高 的事情,而不是一股脑不分优先级的全部去做。

当我们的小饭馆客人越来越多(并发量越来越大),我们就需要扩大饭店的规模,在这个过程中我们发现,迎接客人 是饭店最重要的工作,我们要先把客人迎接进来,不能让客人一看人多就走掉,只要客人进来了,哪怕菜做的慢一点也没关系。

于是,主从Reactor多线程 模型就产生了:

Image|450

  • 我们由原来的 单Reactor 变为了 多Reactor主Reactor 用来优先 专门 做优先级最高的事情,也就是迎接客人(处理连接事件),对应的处理 Handler 就是图中的 acceptor
  • 当创建好连接,建立好对应的 socket 后,在 acceptor 中将要监听的 read事件 注册到 从Reactor 中,由 从Reactor 来监听 socket 上的 读写 事件。
  • 最终将读写的业务逻辑处理交给线程池处理。

注意 :这里向 从Reactor 注册的只是 read事件,并没有注册 write事件,因为 read事件 是由 epoll内核 触发的,而 write事件 则是由用户业务线程触发的(什么时候发送数据是由具体业务线程决定的),所以 write事件 理应是由 用户业务线程 去注册。

用户线程注册 write事件 的时机是只有当用户发送的数据 无法一次性 全部写入 buffer 时,才会去注册 write事件,等待 buffer重新可写 时,继续写入剩下的发送数据、如果用户线程可以一股脑的将发送数据全部写入 buffer,那么也就无需注册 write事件从Reactor 中。

主从Reactor多线程 模型是现在大部分主流网络框架中采用的一种 IO线程模型。我们本系列的主题 Netty 就是用的这种模型。

netty 的 IO 模型

Image

  • Reactornetty 中是以 group 的形式出现的,netty 中将 Reactor 分为两组,一组是 MainReactorGroup 也就是我们在编码中常常看到的 EventLoopGroup bossGroup,另一组是 SubReactorGroup 也就是我们在编码中常常看到的 EventLoopGroup workerGroup
  • MainReactorGroup 中通常只有一个 Reactor,专门负责做最重要的事情,也就是监听连接 accept 事件。当有连接事件产生时,在对应的处理 handler acceptor 中创建初始化相应的 NioSocketChannel(代表一个 Socket连接)。然后以 负载均衡 的方式在 SubReactorGroup 中选取一个 Reactor,注册上去,监听 Read事件

MainReactorGroup 中只有一个 Reactor 的原因是,通常我们服务端程序只会 绑定监听 一个端口,如果要 绑定监听 多个端口,就会配置多个 Reactor

  • SubReactorGroup 中有多个 Reactor,具体 Reactor 的个数可以由系统参数 -D io.netty.eventLoopThreads 指定。默认的 Reactor 的个数为 CPU核数 * 2SubReactorGroup 中的 Reactor 主要负责监听 读写事件,每一个 Reactor 负责监听一组 socket连接。将全量的连接 分摊 在多个 Reactor 中。
  • 一个 Reactor 分配一个 IO线程,这个 IO线程 负责从 Reactor 中获取 IO就绪事件,执行 IO调用获取IO数据,执行 PipeLine

Socket连接 在创建后就被 固定的分配 给一个 Reactor,所以一个 Socket连接 也只会被一个固定的 IO线程 执行,每个 Socket连接 分配一个独立的 PipeLine 实例,用来编排这个 Socket连接 上的 IO处理逻辑。这种 无锁串行化 的设计的目的是为了防止多线程并发执行同一个 socket 连接上的 IO逻辑处理,防止出现 线程安全问题。同时使系统吞吐量达到最大化

由于每个 Reactor 中只有一个 IO线程,这个 IO线程 既要执行 IO活跃Socket连接 对应的 PipeLine 中的 ChannelHandler,又要从 Reactor 中获取 IO就绪事件,执行 IO调用。所以 PipeLineChannelHandler 中执行的逻辑不能耗时太长,尽量将耗时的业务逻辑处理放入单独的业务线程池中处理,否则会影响其他连接的 IO读写,从而近一步影响整个服务程序的 IO吞吐

  • IO请求在业务线程中完成相应的业务逻辑处理后,在业务线程中利用持有的ChannelHandlerContext引用将响应数据在PipeLine中反向传播,最终写回给客户端。

参考

聊聊内核 IO 那点事