Skip to content

从内核角度看 IO 模型

前言

我们来看下 Netty 官网首页的简介,

img

上述文字翻译后为:

  • Netty 是一个异步事件驱动的网络应用框架,旨在快速开发可维护的高性能协议服务器和客户端。
  • Netty 是一个 NIO 客户端-服务器框架,能够快速且轻松地开发网络应用程序,如协议服务器和客户端。它极大地简化和优化了网络编程,例如 TCP 和 UDP 套接字服务器。
  • “快速和轻松” 并不意味着最终应用会遭受可维护性或性能问题。Netty 在设计时充分借鉴了实现众多协议(如 FTP、SMTP、HTTP 以及各种基于二进制和文本的传统协议)的经验。因此,Netty 成功地找到了在开发便捷性、性能、稳定性和灵活性之间实现平衡的方法,没有任何妥协。

由此,可以推测出 Netty 的高性能有很大部分源于其底层模型:

  • IO 模型:NIO(IO多路复用),当然你也可以用其他的,但主要还是 NIO。
  • IO 线程模型:Reactor 模型。

本文和 《IO 多路复用》 旨在说明这两部分的内容

网络包接收流程

image-20241112121730501

性能开销

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

再谈【阻塞&非阻塞】

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

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

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

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

阻塞

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

image-20241112121949082

从图中我们可以看出:阻塞的特点是在第一阶段和第二阶段都会等待

非阻塞

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

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

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

再谈【同步&异步】

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

在数据拷贝阶段,主要的任务是将数据从内核空间拷贝到用户空间,应用程序只有在数据拷贝到用户空间后才能读取数据。

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

同步

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

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

image-20241112122024181

异步

在异步模式下,数据准备阶段和数据拷贝阶段都是由内核来执行。内核完成这些操作后,会通知用户线程 IO 操作已完成,并将数据回调给用户线程。因此,在异步模式下,数据的准备和拷贝过程不会导致应用程序的阻塞。

基于这一特性,异步模式依赖操作系统的底层支持,因此它要求内核的支持。在现代操作系统中,只有 Windows 中的 IOCP(I/O Completion Ports)才真正实现了成熟的异步 IO。而且,Windows 操作系统已经有相当成熟的异步 IO 实现,但由于 Windows 很少用作服务器操作系统,这使得其在服务器领域的应用较为有限。

相反,常用于服务器的 Linux 操作系统的异步 IO 机制实现较为不成熟,且相对于 NIO,性能提升并不显著。

然而,Linux 在 5.1 版本引入了由 Facebook 的 Jens Axboe 提出的新的异步 IO 库——io_uring。该库优化了原有的 Linux 原生 AIO 的一些性能问题,并显著提高了性能,甚至超越了传统的 Epoll 和原生的 AIO。因此,io_uring 是一个值得关注的技术,尤其在 Linux 环境中对高并发和高性能应用场景下,具有较大潜力。

image-20241112121412075

IO 模型

在进行网络 IO 操作时,用什么样的 IO 模型来读写数据将在很大程度上决定了网络框架的 IO 性能。所以 IO 模型的选择是构建一个高性能网络框架的基础。

在《UNIX 网络编程》一书中介绍了五种 IO 模型:阻塞IO,非阻塞IO,IO多路复用,信号驱动IO,异步IO,每一种 IO 模型的出现都是对前一种的升级优化。

阻塞 IO

image-20241112121441168

阻塞读

当用户线程发起 read 系统调用时,线程从用户态切换到内核态,内核会检查 Socket 的接收缓冲区是否有数据到来。

  • 如果 Socket 接收缓冲区中 有数据,用户线程将在内核态将数据从内核空间拷贝到用户空间,之后系统调用返回。
  • 如果 Socket 接收缓冲区中 没有数据,用户线程会让出 CPU 并进入 阻塞状态。当数据到达接收缓冲区后,内核会唤醒处于阻塞状态的用户线程,使其进入就绪状态。随后,经过 CPU 调度,该线程获取 CPU 配额进入运行状态,继续将数据从内核空间拷贝到用户空间,系统调用返回。

阻塞写

当用户线程发起 send 系统调用时,线程从用户态切换到内核态,并将发送数据从用户空间拷贝到 Socket 的发送缓冲区。

  • 如果 Socket 发送缓冲区能够容纳 全部发送数据,用户线程将数据写入缓冲区并执行网络包发送的后续流程,随后返回。
  • 如果 Socket 发送缓冲区空间不足以容纳 全部发送数据,用户线程将让出 CPU 进入 阻塞状态,直到发送缓冲区有足够空间。此时,内核会唤醒线程,执行后续的发送流程。

阻塞IO 模型下的写操作风格较为刚性,要求必须将 所有数据 写入缓冲区,才能完成后续操作。

阻塞 IO 线程模型

image-20241112121514273

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

当客户端请求的并发量突然增大时,服务端会迅速创建大量的线程,而线程的创建需要消耗系统资源,这会导致系统在短时间内占用大量资源。

如果客户端已建立连接,但一直不发数据,通常情况下网络连接也并非总是有数据可读。在这种空闲时间里,服务端的线程会一直处于 阻塞状态,无法进行其他任务。这不仅导致 CPU 不能得到充分利用,还会引发 大量线程切换的开销

适用场景

基于 阻塞IO模型 的特点,这种模型适用于连接数较少、并发度低的场景。

例如,一些公司内部的管理系统,通常请求数在 100 个左右,使用 阻塞IO模型 仍然非常合适,且性能不会逊色于 NIO。

该模型在 C10K 之前广泛采用。

非阻塞 IO

阻塞IO模型 的最大问题是一个线程只能处理一个连接。如果连接上没有数据,线程就会阻塞在系统的 IO 调用上,无法进行其他操作。

image-20241112121547099

阻塞读

当用户线程发起非阻塞 read 系统调用时,线程从用户态转为内核态,检查 Socket 接收缓冲区是否有数据到达。

  • 如果 Socket 接收缓冲区中无数据,系统调用立即返回,并带有 EWOULDBLOCKEAGAIN 错误。在这个阶段,用户线程不会阻塞,也不会让出 CPU,而是会继续轮询,直到 Socket 接收缓冲区中有数据为止。
  • 如果 Socket 接收缓冲区中有数据,用户线程在内核态将内核空间中的数据拷贝到用户空间。注意,数据拷贝阶段,应用程序是阻塞的。当数据拷贝完成后,系统调用返回。

阻塞写

前面我们在介绍阻塞写时提到,阻塞写风格特别硬朗,它要求必须将所有数据一次性写入 Socket 的发送缓冲区才能返回。如果发送缓冲区没有足够的空间容纳数据,它将一直阻塞,直到缓冲区有足够空间。这种方式非常 刚性

相比之下,非阻塞写的特点则比较佛系。当发送缓冲区中没有足够空间容纳全部数据时,非阻塞写只会写入能够容纳的部分,剩下的部分会立即返回,并将实际写入的字节数返回给应用程序。这样,用户线程就可以不断地 轮询 尝试将 剩余的数据 写入发送缓冲区。

非阻塞 IO 线程模型

image-20241112121613469

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

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

通过这种方式,非阻塞 IO 模型实现了我们在本小节开始时提出的需求:用尽可能少的线程处理更多的连接

适用场景

虽然非阻塞 IO 模型与阻塞 IO 模型相比,减少了大量资源消耗和系统开销,但它仍然存在较大的性能问题。因为在非阻塞 IO 模型下,用户线程需要不断地发起系统调用进行轮询,检查 Socket 接收缓冲区。这就要求用户线程频繁地在 用户态 和 内核态 之间切换。随着并发量的增大,这种上下文切换的开销也变得非常巨大。

因此,单纯的非阻塞 IO 模型仍然无法适用于高并发场景,通常仅适用于 C10K 以下的场景。

IO 多路复用

信号驱动 IO

image-20241031144043825

大家对这个装备肯定不会陌生。当我们去一些美食城吃饭时,点完餐付了钱,老板会给我们一个信号器。然后我们带着这个信号器去找餐桌,或者做些其他事情。当信号器亮了时,表示饭餐已经做好,我们可以去窗口取餐了。

这个典型的生活场景与我们要介绍的信号驱动 IO 模型很像。

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

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

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

然而,实际上,在使用 TCP 协议进行通信时,信号驱动 IO 模型几乎不会被采用,原因如下:

  • 信号 IO 在大量 IO 操作时,可能因为信号队列溢出而无法通知。
  • SIGIO 信号是一种 Unix 信号,信号本身没有附加信息。如果一个信号源有多种原因产生信号,信号接收者无法判断究竟发生了什么。而 TCP socket 产生的信号事件有七种之多,应用程序收到 SIGIO 后,根本无法区分具体是哪种事件。

不过,信号驱动 IO 模型可以用于 UDP 通信。因为 UDP 只有一个数据请求事件,这意味着在正常情况下,UDP 进程只需要捕获 SIGIO 信号,调用 read 系统调用读取到达的数据。如果出现异常,则返回异常错误。

题外话:大家不觉得阻塞 IO 模型在生活中的例子就像是我们在食堂排队打饭吗?你需要排队去打饭,同时在配菜过程中也要等待。

image-20241031144137789

IO 多路复用模型 就像是我们在饭店门口排队等待叫号。叫号器就像是 selectpollepoll,它们可以统一管理所有顾客的就餐事件。当顾客的餐点准备好时,叫号器会通知相应的顾客。

image-20241031144235074

异步 IO

以上介绍的四种 IO 模型均为同步 IO,它们都会阻塞在第二阶段数据拷贝阶段。

通过前面小节《同步与异步》的介绍,相信大家可以很容易理解异步 IO 模型。在异步 IO 模型下,IO 操作在数据准备阶段和数据拷贝阶段均由内核来完成,不会对应用程序造成任何阻塞。应用进程只需要在指定的数组中引用数据即可。

异步 IO 与信号驱动 IO 的主要区别:信号驱动 IO 由内核通知何时可以开始一个 IO 操作,而异步 IO 由内核通知 IO 操作何时已经完成。

举个生活中的例子:异步 IO 模型就像我们去一个高档饭店里的包间吃饭。我们只需要坐在包间里面,点完餐(类比异步 IO 调用)之后,我们就什么也不需要管,该喝酒喝酒,该聊天聊天,饭餐做好后,服务员(类比内核)会自己把餐点送到包间(类比用户空间)。整个过程没有任何阻塞。

异步 IO 的系统调用需要操作系统内核来支持,目前只有 Windows 中的 IOCP 实现了非常成熟的异步 IO 机制。

而 Linux 系统对异步 IO 机制实现得不够成熟,且与 NIO 的性能相比,提升也不明显。

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

再加上信号驱动 IO 模型不适用于 TCP 协议,所以目前大部分采用的还是 IO 多路复用模型。

总结

Netty 是一个基于异步事件驱动的高性能网络框架,采用 NIO(非阻塞 IO)和 Reactor 模型来提升网络应用程序的性能和可维护性。网络数据的接收流程包括两大阶段:数据准备阶段和数据拷贝阶段。数据首先通过 DMA 从网卡传输到内存,然后通过中断和内核协议栈处理,最后存入内核的 Socket 缓冲区。接着,在数据拷贝阶段,内核将数据从内存拷贝到用户空间供应用程序使用。

阻塞和非阻塞模式的主要区别在于数据准备阶段。在阻塞模式下,应用线程会在接收缓冲区没有数据时阻塞等待,直到数据到达;而在非阻塞模式下,应用线程不会阻塞,而是立即返回错误,继续执行其他任务。尽管两者在数据拷贝阶段表现相同,都需要等待数据从内核空间复制到用户空间,但非阻塞模式提供了更高的并发处理能力。

同步与异步的区别体现在数据拷贝阶段。在同步模式下,数据拷贝由用户线程在内核态完成,可能会发生阻塞;而在异步模式下,内核负责完成数据拷贝,并在完成后通知应用线程,无需阻塞。异步 IO 依赖操作系统的底层支持,如 Windows 的 IOCP 和 Linux 的 io_uring。

在不同的 IO 模型中,阻塞 IO 模型适用于低并发的场景,虽然每个线程只能处理一个连接,且高并发时会消耗大量系统资源。相比之下,非阻塞 IO 和 IO 多路复用可以处理更多连接,减少线程阻塞和切换,适用于高并发场景。IO 模型的选择对网络框架性能至关重要,合理的选择能够显著提升系统效率和响应能力。

基于 MIT 许可发布