3.一文读懂网络 IO 模型原理

2023年 10月 9日 98.1k 0

本篇文章主要讲解网络 IO 模型的发展和演变过程,以及 linux 下网络 IO 模型的实现原理。

1. 什么是网络 IO

首先 IO 指的是输入(Input)和输出(Output)的缩写,针对不同的对象的读写可以划分为 “磁盘 IO” 和 “网络 IO”。

  • 磁盘IO:向磁盘读写数据
  • 网络IO:向网卡读写数据

这里我们以常用的操作系统 linux 为例:由于操作系统运行在内核空间,应用程序运行在用户空间,应用程序无法直接访问内核空间数据,而网卡数据需要经过驱动程序和操作系统才能被用户使用,因此网络 IO 操作会涉及到用户空间和内核空间的转换,我们先了解一下用户空间和内核空间的基本概念:

用户空间和内核空间:操作系统的核心是内核,独立于普通的应用程序,可以访问受保护的内存空间,也有访问底层硬件设备的所有权限。为了保证内核的安全,用户进程不能直接操作内核,操作系统将虚拟空间划分为两部分,一部分为内核空间,一部分为用户空间。

1.1 网络 IO 读写过程

那网络 IO 具体过程是怎样的呢?我们用一个图讲解一下:
网络IO-第 9 页.png

我们以内核接收网络数据的过程为例,接收网络数据需要经历以下过程:

  • 当网络数据包到达网卡时,网卡通过 DMA 的方式将数据放到 RingBuffer (环形缓冲区) 中。
  • 然后向 CPU 发起硬中断,在硬中断响应程序中创建 sk_buffer,并将网络数据拷贝至 sk_buffer 中。
  • 随后发起软中断,内核线程 ksoftirqd 响应软中断,调用 poll 函数将 sk_buffer 送往内核协议栈做层层协议处理(链路层、网络层、传输层)。在传输层 tcp_rcv 函数中,去掉 TCP 头,根据四元组(源IP,源端口,目的IP,目的端口)查找对应的 socket(什么是 socket)。
  • 最后将 sk_buffer 放到 socket 中的接收队列里。
  • 上面的步骤,仅仅只是把数据读取到了操作系统内核的缓存中,还没有真正的被应用程序所使用。
    因此,可以把用户进程从网络读取数据分为两个阶段:

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

    2. 网络 IO 的发展阶段

    随着网络 IO 的需求变得复杂,网络 IO 模型随着 linux 内核演变而变化,大致分为以下几个阶段:

  • 阻塞 IO(BIO)
  • 非阻塞 IO(NIO)
  • IO 多路复用
  • select
  • poll
  • epoll
  • 信号驱动式 IO
  • 异步 IO(AIO)
  • 每一个模型都是为了不断地改进网络 IO 现存模型的缺陷或适应新的需求而产生的。因此我们可以追随模型演变的过程,更好地了解每一个模型的优点、缺点。

    网络 IO 模型分为两个大类:同步 IO 和异步 IO。首先理解一下相关概念,这里我们引用 UNP 一书给出的定义:

    • 同步 IO 操作(synchronous IO operation)导致请求进程阻塞,直到 IO 操作完成。UNP 第6章中提到的 IO 模型——阻塞式 IO 模型、非阻塞式 IO 模型、IO 多路复用模型和信号驱动式 IO 模型都是同步 IO 模型,因为其中真正的 IO 操作将阻塞进程。
    • 异步 IO 操作(asynchronous IO operation)不导致请求进程阻塞。

    同步 IO 和异步 IO 的核心区别在于真正的 IO 操作(将数据从内核拷贝到用户空间)会不会阻塞进程,换句话说:同步 IO 需要进程真正地去操作 IO,而异步 IO 则由内核在 IO 操作完成后再通知应用进程结果。

    image.png

    2.1 阻塞 IO

    阻塞 IO 英文为 blocking IO,又称为 BIO。阻塞 IO 主要指的是第一阶段(硬件网卡到内核态)。当用户发生了系统调用后,如果数据未从网卡到达内核态,内核态数据未准备好,此时会一直阻塞。直到数据就绪,然后从内核态拷贝到用户态再返回。

    image.png

    我们以阻塞读进行举例说明(socket 的具体实现 一文对 linux 如何使用 socket 实现阻塞读有详细的讲解,这里不再赘述):
    当用户线程发起 read 系统调用,用户线程从用户态切换到内核态,在内核中去查看 socket 接收缓冲区是否有数据到来。

    • socket 接收缓冲区中有数据,则用户线程在内核态将内核空间中的数据拷贝到用户空间,系统 IO 调用返回。

    • socket 接收缓冲区中无数据,则用户线程让出 CPU,进入阻塞状态。当数据到达 socket 接收缓冲区后,内核唤醒阻塞状态中的用户线程进入就绪状态,随后经过 CPU 的调度获取到 CPU 进入运行状态,将内核空间的数据拷贝到用户空间,随后系统调用返回。

    • 缺点

    阻塞 IO 存在很明显的缺点:如果我们使用单线程处理网络 IO,我们的程序将同一时间只能服务于一个网络请求,这显然是不合理的。因此,在一般使用阻塞 IO 时,都需要配置多线程来使用,最常见的模型是阻塞 IO+多线程,每个连接由一个单独的线程进行处理(什么是 socket 一文中对该模型进行了实战)。
    在 linux 中一个程序可以开辟的线程是有限的,而且开辟线程的开销也是比较大的。也正是这些原因,会导致一个应用程序可以处理的客户端请求受限,面对大量连接的情况(C10K 问题的产生)是无法处理的,而且线程过多会造成大量的线程切换,因此带来额外的系统资源开销。

  • 大量的线程开辟,线程开辟数量存在上限,因此连接也存在上限。
  • 大量的线程阻塞、唤醒会导致频繁的线程切换,浪费系统资源。
    • 适用场景

    基于以上阻塞 IO 模型的特点,该模型只适用于连接数少,并发度低的业务场景。比如:公司内部的一些管理系统,通常并发请求数在 100 个左右,使用阻塞 IO 模型还是非常适合的,而且性能还不输 NIO(非阻塞 IO) 模型。

    BIO 模型要支持大量的客户端连接最大的问题是所需线程过多,一个线程只能处理一个连接,如果连接上没有数据的话,还会阻塞线程,造成资源的极大浪费。为了解决这个问题,需要考虑如何用更少的线程处理更多的连接,于是网络 IO 就演变出了 NIO(非阻塞 IO)模型。

    2.2 非阻塞 IO

    NIO:用户程序发起非阻塞读的系统调用,在第一阶段(数据准备阶段)数据未到达时不等待(不阻塞用户线程,不让出 CPU),直接返回 EWOULDBLOCK 或 EAGAIN 错误,后续线程会继续进行系统调用轮询直到数据就绪;如果第一阶段的数据就绪,用户线程在内核态会将内核空间中的数据拷贝到用户空间,注意这个数据拷贝阶段,应用程序是阻塞的,当数据拷贝完成,系统调用返回。
    NIO 主要解决了“一个线程同时处理多个连接”的问题。

    image.png

    • 缺点:太多无效的系统调用,导致“用户态”和“内核态”频繁切换的系统资源浪费。

    从图中也可以看出来,NIO 模型需要不断的发起“系统调用”进行轮询,这需要不断的从“用户态”切换到“内核态”,这种切换是十分消耗资源的,随着并发量的增大,要监控的连接增多,每一次轮询都会触发成千上万次系统调用,但有效的系统调用可能寥寥无几,这就造成了系统资源极大的浪费。

    • 适用场景:单纯的非阻塞 IO 模型还是无法适用于高并发的场景。只能适用于 C10K 以下的场景。

    2.3 IO 多路复用

    读到在这里你会发现 NIO 最大的缺点就是频繁且无效的系统调用导致的,如果可以减少无效的系统调用,就可以解决这个问题,因此产生了 IO 多路复用。

    首先理解一下什么是多路复用:

    维基百科:多路复用(Multiplexing,又称“多任务”[来源请求])是一个通信和计算机网络领域的专业术语,在没有歧义的情况下,“多路复用”也可被称为“复用”。多路复用通常表示在一个信道上传输多路信号或数据流的过程和技术。因为多路复用能够将多个低速信道集成到一个高速信道进行传输,从而有效地利用了高速信道。通过使用多路复用,通信运营商可以避免维护多条线路,从而有效地节约运营成本。

    在这里多路复用又指的是什么呢?

    • 多路指的是多条连接
    • 复用指的是使用一条通道(比如 select 系统调用)管理多条连接

    简单举个例子类比一下网络模型,看看多路复用到底好在哪里。
    如果把每个连接想象成一条高速公路,那么多个连接就是多条高速路:

    • BIO 的做法就是工作人员(线程)跑到一条高速路上等着车(网络数据)来,车不来就在这里死等(阻塞),车来以后进行处理,然后换下一条高速路继续等待;
    • NIO 的做法就是有一个工作人员循环跑到每一条高速公路上询问有没有要通行的车,没有则去下一条高速路询问,有车通行则就地处理,然后继续轮询;
    • 多路复用的做法就是在高速公路上安上了电话来管理所有的高速公路,有高速公路要通车就给工作人员打电话,然后再去处理,通过复用电话这个高速通道省去了工作人员无效跑路和一直循环跑的工作,有效的节省了工作人员的工作量(频繁的无效系统调用)。

    理解完多路复用的概念,我们来看看操作系统是如何实现多路复用的,核心要解决的问题:如何避免频繁的系统调用,同时能够使用一个线程管理多条连接。

    image.png

    如果想避免频繁的系统调用,则需要内核来支持这样的操作,把频繁的轮询操作放到内核来执行,就不会频繁的导致状态切换了,则可以避免在用户态频繁使用系统调用产生的性能开销。linux 操作系统支持三种 IO 多路复用的操作,分别是 select、poll、epoll ,它们都满足 IO 复用模型的基本架构(如上图所示),实现却不一样,接下来我们就一起走进它们的实现一起去探索一下。

    2.3.1 select

    select 是操作系统内核提供给我们使用的一个系统调用,它将轮询的操作交给了内核来帮助我们完成,从而避免了在用户空间不断的发起轮询所带来的的系统性能开销。

    参数详解

    select 系统调用是在规定的超时时间内,监听(轮询)用户感兴趣的文件描述符集合上的可读、可写、异常三类事件。

    // 返回:若有就绪描述符则为其数目,若超时则为0,若出错则为-1
    int select(int nfds, fd_set *readfds, fd_set *writefds,
    fd_set *exceptfds, struct timeval *timeout);
    

    参数详解:

    • nfds 参数用来告诉 select 需要检查的描述符的个数,取值为我们感兴趣的最大描述符 + 1;比如:select 监听的文件描述符集合为 {0,1,2,3,4},那么 nfds 的值为 5。
    • fd_set *readfds: 对可读事件感兴趣的文件描述符集合。
    • fd_set *writefds: 对可写事件感兴趣的文件描述符集合。
    • fd_set *exceptfds:对异常事件感兴趣的文件描述符集合。
    • const struct timeval *timeout:select 系统调用超时时间,在这段时间内,内核如果没有发现有 IO 就绪的文件描述符,就直接返回。

    fd_set 其实就是 long int 类型的数组(BitMap),数组中元素个数为 __FD_SETSIZE / __NFDBITS = 1024 / 64 = 16,因此该数组中一共有 16 个元素,每个元素为 long int 类型,占 64 位,分别用于表示文件描述符 0~1023,因此 select 同时监听的 socket 数量是有限的,最大 1024。
    用户进程传递这 select 的 readfds、writefds、exceptfds 3 个参数告诉内核对哪些 socket 的哪些事件感兴趣,执行完毕之后反过来内核会将就绪的描述符状态也放在这三个参数变量中,这种参数称为值-结果参数。

    操作系统提供了 4 个宏来帮我们设置数组中每个元素的每一位。

    // 将数组每个元素的二进制位重置为0
    void FD_ZERO(fd_set *fdset);
    
    // 将第fd个描述符表示的二进制位设置为1
    void FD_SET(int fd, fd_set *fdset);
    
    // 将第fd个描述符表示的二进制位设置为0
    void FD_CLR(int fd, fd_set *fdset);
    
    // 检查第fd个描述符表示的二进制位是0还是1
    int  FD_ISSET(int fd, fd_set *fdset);
    

    执行流程

    如果在操作系统内核中一直触发轮询,也会造成极大的资源浪费,所以 select 并不会一直轮询 socket,整个 select 系统调用在内核中最多发生两次轮询,其余时间进程处于阻塞状态,接下来我们就一起探索一下 select 的执行过程。

    网络IO-第 10 页.png

  • 首先用户线程在发起 select 系统调用的时候会阻塞在 select 系统调用上。 此时用户线程从用户态切换到了内核态完成了一次上下文切换。
  • 用户线程将需要监听的 socket 对应的文件描述符 fd 数组通过 select 系统调用传递给内核。此时用户线程将用户空间中的文件描述符fd数组拷贝到内核空间。
  • 当用户线程调用完 select 后开始进入阻塞状态,内核开始遍历 fd 数组(第一次轮询),查看 fd 对应的socket 接收缓冲区中是否有数据到来。
  • 如果有数据到来,则将 fd 对应 BitMap 的值设置为1,将修改后的 fd 数组返回给用户线程,此时会将 fd 数组从内核空间拷贝到用户空间。
  • 如果没有数据到来,则保持值为0,进程被唤醒的回调函数被设置到每一个 socket 的等待队列中,当前进程阻塞,等待数据就绪。经过网络 IO 第一阶段数据到达 socket 缓冲区,将其他 socket 等待队列中唤醒机制移除,唤醒进程,进程被唤醒后,不知道哪些 socket 有数据,因此还需要遍历一遍 fd 数组(第二次轮询),更改 fd 对应 BitMap 的值设置为 1,将修改后的 fd 数组返回给用户线程,此时会将 fd 数组从内核空间拷贝到用户空间。
  • 当内核将修改后的 fd 数组返回给用户线程后,用户线程解除阻塞,由内核态切换为用户态,由用户线程开始遍历 fd 数组然后找出 fd 数组中值为 1 的 socket 文件描述符。最后对这些 socket 发起系统调用读取数据(由于发起系统调用读的都是有数据的文件描述符,因此不会造成无效的系统调用浪费资源)。
  • 由于内核在遍历的过程中已经修改了 fd 数组,所以在用户线程遍历完 fd 数组后获取到 IO 就绪的 socket 后,就需要重置 fd 数组,并重新调用 select 传入重置后的 fd 数组,让内核发起新的一轮遍历轮询。
  • 拓展内容:用户层面在使用 select 过程中,会将“监听 socket” 和 “普通 socket” 同时维护在 select 的系统调用中,当有新客户端连接到来时,select 会修改 “监听 socket” 对应的 fd 的值为 1,返回给用户线程,进而新的连接会被加入到 select 的监听中,进行新一轮的轮询。

    性能开销

    下面我们来分析一下 select 存在的性能开销和不足之处:

    • 在发起 select 系统调用以及返回时,用户线程各发生了一次用户态到内核态以及内核态到用户态的上下文切换开销。(2 次内核态用户态相互切换)
    • 在发起 select 系统调用以及返回时,用户线程在内核态需要将文件描述符集合从用户空间拷贝到内核空间。以及在内核修改完文件描述符集合后,又要将它从内核空间拷贝到用户空间。内核会对原始的文件描述符集合进行修改,导致每次在用户空间重新发起 select 调用时,都需要对文件描述符集合进行重置,进而需要继续发生2次描述符的拷贝。(每次 select 都会进行 2 次文件描述符集合的拷贝)
    • select 内核中还是进行了轮询操作,对有数据的 fd 做了状态标记(更改了原始的文件描述符集合),没有直接告诉用户线程哪些是就绪的,需要用户线程再遍历一遍。(内核态与用户态多次轮询消耗)
    • BitMap 结构的文件描述符集合,长度为固定的1024,所以只能监听0~1023的文件描述符。(监听的socket 个数存在上限)

    适用场景

    很明显,select 也解决不了 C10K 的问题,只适用于低于 1024 并发连接的场景。
    虽然 select 解决了 NIO 模型中频繁发起系统调用的问题,但其最大连接数只有 1024,没办法提供更多的连接,因此 poll 对其进行了优化。

    2.3.2 poll

    poll 其实和 select 的区别不大,只是解决了最大连接数的问题。我们一起来看一下:

    // 返回:若有就绪描述符则为其数目,若超时则为0,若出错则为-1
    int poll(struct pollfd *fds, unsigned int nfds, int timeout)
    
    struct pollfd {
        int   fd;         /* 文件描述符 */
        short events;     /* 需要监听的事件 */
        short revents;    /* 实际发生的事件 由内核修改设置 */
    };
    

    poll 和 select 的执行流程大致类似,不同点主要体现在数据结构上,解读一下 pollfd 参数:

    • fd 文件描述符
    • events 表示描述符 fd 上待检测的事件类型(可读、可写、错误事件)
    • revents 字段用于存储每次遍历之后的结果

    poll 不再使用固定长度为 1024 的 BitMap 来存储所关注的文件描述符,取而代之用动态数组,以链表形式来组织,突破了 select 的文件描述符个数限制,当然还会受到系统文件描述符限制。

    poll 和 select 并没有太大的本质区别,都是使用「线性结构」存储进程关注的 socket 集合,因此都需要遍历文件描述符集合来找到可读或可写的 socket,时间复杂度为 O(n),而且也需要在用户态与内核态之间拷贝文件描述符集合,这种方式随着并发数上来,性能的损耗会呈指数级增长。
    poll 同样不适用高并发的场景,依然无法解决 C10K 问题。

    2.3.3 epoll

    如果说 select/poll 通过“电话”的角色解决了“高频无效系统调用”的问题,那 epoll 则是在“电话”上安装了一个记事本,能自动记录要监听的 socket,通过两个方面,很好解决了 select/poll 的问题。

    由于 epoll 实现原理很重要,且篇幅过长,因此单独整理了一篇文章进行讲解,有兴趣的可以阅读 epoll 原理详解 这篇文章。

  • epoll 在内核里使用红黑树来跟踪进程所有待检测的文件描述字,把需要监控的 socket 通过 epoll_ctl() 函数加入内核中的红黑树里,红黑树是个高效的数据结构,增删改一般时间复杂度是 O(logn)。而 select/poll 内核里没有类似 epoll 红黑树这种保存所有待检测的 socket 的数据结构,所以 select/poll 每次操作时都传入整个 socket 集合给内核,而 epoll 因为在内核维护了红黑树,可以保存所有待检测的 socket ,所以只需要传入一个待检测的 socket,减少了内核和用户空间大量的数据拷贝和内存分配。
  • epoll 使用事件驱动的机制,内核里维护了一个链表来记录就绪事件,当某个 socket 有事件发生时,通过回调函数内核会将其加入到这个就绪事件列表中,当用户调用 epoll_wait() 函数时,只会返回有事件发生的文件描述符的个数,不需要像 select/poll 那样轮询扫描整个 socket 集合,大大提高了检测的效率。
  • epoll 的方式即使监听的 socket 数量越多的时候,效率不会大幅度降低,能够同时监听的 socket 的数目也非常的多了,上限就为系统定义的进程打开的最大文件描述符个数。因而,epoll 被称为解决 C10K 问题的利器。

    2.4 信号驱动式 IO

    应用程序发起 IO 请求时,通过 sigaction 系统调用安装一个信号处理函数,请求立即返回,操作系统底层则处于等待状态(等待数据就绪),直到数据就绪,然后通过 SIGIO 信号通知主调程序,主调程序才去调用系统函数 recvfrom() 完成 IO 操作。
    image.png

    这种模型的优势在于等待数据报到达期间进程不被阻塞。主循环可以继续执行,只要等待来自信号处理函数的通知。比起多路复用 IO 模型来说,信号驱动 IO 模型针对的是一个 IO 的完成过程, 而多路复用 IO 模型针对的是多个 IO 同时进行时候的场景。

    2.5 异步IO

    前面介绍的所有网络 IO 都是同步 IO,因为当数据在内核态就绪时,在内核态拷贝用用户态的过程中,仍然会有短暂时间的阻塞等待,而异步 IO(AIO) 中该过程也是内核帮我们完成的。

    image.png

    我们调用 aio_read 函数,给内核传递描述符、缓冲区指针、缓冲区大小和文件偏移等参数,并告诉内核当整个操作完成时如何通知我们。该系统调用立即返回,而且在等待 IO 完成期间,我们的进程不被阻塞。内核在操作完成时产生某个信号,该信号直到数据已复制到应用进程缓冲区时才发生,这一点不同于信号驱动式I/O模型。

    相关文章

    服务器端口转发,带你了解服务器端口转发
    服务器开放端口,服务器开放端口的步骤
    产品推荐:7月受欢迎AI容器镜像来了,有Qwen系列大模型镜像
    如何使用 WinGet 下载 Microsoft Store 应用
    百度搜索:蓝易云 – 熟悉ubuntu apt-get命令详解
    百度搜索:蓝易云 – 域名解析成功但ping不通解决方案

    发布评论