目录

Linux 网络 IO 模型

Linux 的内核将所有外部设备都看做一个文件来操作(一切皆文件),对一个 File 的读写操作会调用内核提供的系统命令,返回一个 File Descriptor(FD 文件描述符)。而对一个 Socket 的读写也会有响应的描述符,称为 Socket File Descriptor(Socket 文件描述符),描述符就是一个数字,指向内核中的一个结构体(文件路径,数据区等一些属性)。

根据 UNIX 网络编程对 IO 模型的分类,UNIX 提供了 5 种 IO 模型。

设备处理速度

  • 内存读数据:纳秒级别;
  • 千兆网卡读数据:微妙级别,1 微秒等于 1000 纳秒,网卡比内存慢了千倍;
  • 磁盘读数据:毫秒级别。1 毫秒等于 10 万纳秒 ,硬盘比内存慢了 10 万倍;
  • CPU 一个时钟周期 1 纳秒上下,内存算是比较接近 CPU 的,其他设备都等不起;

CPU 处理数据的速度远大于 IO 准备数据的速度 。所以理论上 任何编程语言 都会遇到这种 CPU 处理速度和 IO 速度不匹配的问题,在网络编程中如何进行网络 IO 优化,怎么高效地利用 CPU 进行网络数据处理就变得非常重要。

程序空间与内核空间

五种模型经历的两个阶段:

  • 等待数据准备好(Waiting for the data to be ready);
  • 将准备好的数据,从内核空间复制到进程空间(Copying the data from the kernel to the process);

五种模型概念

Linux 在处理文件和网络连接时,都需要打开和关闭 FD(File Descriptor)。每个进程都会有默认的 FD:

  • 0:标准输入 stdin
  • 1:标准输出 stdout
  • 2:错误输出 stderr

阻塞 IO 模型

阻塞 IO 模型(Blocking IO)是最常用的 IO 模型,缺省情况下,所有文件操作都是阻塞的。以套接字为例:在进程空间中调用 recvfrom,其系统调用直到数据包到达且被复制到应用进程的缓冲区中或发生错误时才返回。在此期间一直会等待,进程从调用 recvfrom 开始到它返回的整段时间都是被阻塞的。即 recvfrom 的调用会被阻塞。

非阻塞 IO 模型

非阻塞 IO 模型(Non-blocking IO),recvfrom 从应用层到内核的时候,如果缓冲区没有数据的话,就直接返回一个 EWOULDBLOCK 错误,一般对非阻塞 IO 模型进行轮询检查这个状态,看内核是不是有数据到来。即反复调用 recvfrom 等待成功指示(轮询)。

IO 复用模型

IO 复用模型(IO Multiplexing),Linux 提供 select/poll,进程通过将一个或者多个 fd 传递给 selectpoll 系统调用,阻塞在 select 操作上,这样 select/poll 可以帮我们检测到多个 fd 是否处于就绪状态。select/poll 是顺序扫描 fd 是否就绪,而且支持的 fd 数量有限,因此它的使用受到了一些制约。Linux 还提供了 epoll 系统调用,epoll 使用基于事件驱动方式代替顺序扫描,因此性能更高。当有 fd 就绪时,立即回调函数 callback。即阻塞在 select/poll/epoll,以及数据复制拷贝的这段时间。

信号驱动 IO 模型

信号驱动 IO 模型(Signal Driven IO),首先开启信号曲驱动 IO 功能,并通过系统调用 Sigaction 执行一个信号处理函数(此系统调用立即返回,进程继续工作,它是非阻塞的)。当数据准备就绪时,就为该进程生成一个 SIGIO 信号,通过信号回调通知应用程序调用 recvfrom 来读取数据,并通知主循环函数处理数据。即阻塞在数据复制拷贝的这段时间。

异步 IO

异步 IO(Asynchronous IO),告知内核启动某个文件,并让内核整个操作完成后(包括将数据从内核复制到用户自己的缓冲区)通知我们。这种模型与信号模型的组要区别是:信号驱动 IO 由内核通知我们何时可以开始一个 IO 操作;异步 IO 模型由内核通知我们 IO 操作何时已完成。

五种模型的区别

  • 阻塞 IO、非阻塞 IO、多路复用 IO、信号驱动 IO 都是同步 IO,五种模型在内核数据 Copy 到用户空间时都是阻塞的。
  • 阻塞与非阻塞 IO:发起 IO 请求是否会被阻塞,如果阻塞就是传统的阻塞 IO,不如不阻塞就是非阻塞 IO;
  • 同步与异步 IO:如果实际的 IO 读写阻塞请求过程,那么就是同步 IO,如果不阻塞,而是操作系统协助做完 IO 操作再将结果返回给你,那么就是异步 IO;

IO 多路复用技术

应用场景

IO 多路复用就是通过一种机制,一个进程可以监视多个文件描述符,一旦某个描述符就绪(读就绪或写就绪),能够通知程序进行相应的读写操作。

  • 当客户处理多个描述字时(一般是交互式输入和网络套接口),必须使用 IO 复用;
  • 当一个客户同时处理多个套接口时,而这种情况是可能的,但很少出现;
  • 服务器需要同时处理多个处于监听状态或多个连接状态的套接字;
  • 服务器需要同时处理多个服务或多个协议的套接字;

与多进程和多线程技术相比,IO 多路复用技术的最大优势是系统开销小,系统不必创建进程/线程,也不必维护这些进程/线程,从而大大减小了系统的开销。

使用技术

select

关于 select 函数的系统调用和 do_select 定义。

1
2
3
int select(int nfds, fd_set *restrict readfds,
           fd_set *restrict writefds, fd_set *restrict exceptfds,
           struct timeval *restrict timeout);

select 函数的参数 __readfds__writefds__exceptfds 表示的是被监听描述符的集合,其实就是被监听套接字的集合。

select 函数使用三个集合,表示监听的三类事件,分别是读数据事件(对应 __readfds 集合)、写数据事件(对应 __writefds 集合)和异常事件(对应 __exceptfds 集合)。

使用 select 机制来实现网络通信:

  • 首先,我们在调用 select 函数前,可以先创建好传递给 select 函数的描述符集合,然后再创建监听套接字。而为了让创建的监听套接字能被 select 函数监控,我们需要把这个套接字的描述符加入到创建好的描述符集合中。
  • 然后,我们就可以调用 select 函数,并把创建好的描述符集合作为参数传递给 select 函数。程序在调用 select 函数后,会发生阻塞。而当 select 函数检测到有描述符就绪后,就会结束阻塞,并返回就绪的文件描述符个数。此时,就可以在描述符集合中查找哪些描述符就绪了。
  • 之后,就可以对已就绪描述符对应的套接字进行处理。比如,如果是 __readfds 集合中有描述符就绪,这就表明这些就绪描述符对应的套接字上,有读事件发生,此时,就在该套接字上读取数据。因为 select 函数一次可以监听 1024 个文件描述符的状态,所以 select 函数在返回时,也可能会一次返回多个就绪的文件描述符。这样一来,我们就可以使用一个循环流程,依次对就绪描述符对应的套接字进行读写或异常处理操作。

select 函数存在两个设计上的不足:

  • select 函数对单个进程能监听的文件描述符数量是有限制的,它能监听的文件描述符个数由 __FD_SETSIZE 决定,默认值是 1024。
  • select 函数返回后,我们需要遍历描述符集合,才能找到具体是哪些描述符就绪了。这个遍历过程会产生一定开销,从而降低程序的性能。

select 函数的特点:

  • 优点:
    • 良好跨平台支持。
  • 缺点:
    • 单个进程可监视的 FD 数量被限制,即能监听端口的大小有限。一般来说这个数目和系统内存关系很大,具体数目可以 cat /proc/sys/fs/file-max 查看。32 位机默认是 1024 个。64 位机默认是 2048 个。
    • 对 Socket 进行扫描时是线性扫描,即采用轮询的方法,效率较低:当套接字比较多的时候,每次 select() 都要通过遍历 FD_SETSIZE 个 Socket 来完成调度,不管哪个 Socket 是活跃的,都遍历一遍。这会浪费很多 CPU 时间。如果能给套接字注册某个回调函数,当他们活跃时,自动完成相关操作,那就避免了轮询,这正是 epollkqueue 做的。
    • 需要维护一个用来存放大量 FD 的数据结构,这样会使得用户空间和内核空间在传递该结构时复制开销大。
    • 可以在一个线程内同时处理多个 Socket 的 IO 请求。在网络编程中,当涉及到多客户访问服务器的情况,我们首先想到的办法就是 fork 出多个进程来处理每个客户连接。现在,我们同样可以使用 select 来处理多客户问题,而不用 fork

select 处理带外数据,网络程序中,select 能处理的异常情况只有一种:socket 上接收到带外数据。

带外数据:带外数据(out—of—band data),有时也称为加速数据(expedited data),是指连接双方中的一方发生重要事情,想要迅速地通知对方。这种通知在已经排队等待发送的任何普通(有时称为「带内」)数据之前发送。带外数据设计为比普通数据有更高的优先级。带外数据是映射到现有的连接中的,而不是在客户机和服务器间再用一个连接。

poll

关于 poll 函数的系统调用和 do_poll 的定义。

为了解决 select 函数受限于 1024 个文件描述符的不足,poll 函数对此做了改进。

1
int poll (struct pollfd *__fds, nfds_t __nfds, int __timeout);

其中,参数 *__fdspollfd 结构体数组,参数 __nfds 表示的是 *__fds 数组的元素个数,而 __timeout 表示 poll 函数阻塞的超时时间。

1
2
3
4
5
struct pollfd {
  int fd;            // 进行监听的文件描述符。
  short int events;  // 要监听的事件类型。
  short int revents; // 实际发生的事件类型。
};

pollfd 结构体里包含了要监听的描述符,以及该描述符上要监听的事件类型。

使用 poll 函数完成网络通信:

  • 第一步,创建 pollfd 数组和监听套接字,并进行绑定。
  • 第二步,将监听套接字加入 pollfd 数组,并设置其监听读事件,也就是客户端的连接请求。
  • 第三步,循环调用 poll 函数,检测 pollfd 数组中是否有就绪的文件描述符。
    • 如果是连接套接字就绪,这表明是有客户端连接,我们可以调用 accept 接受连接,并创建已连接套接字,并将其加入 pollfd 数组,并监听读事件。
    • 如果是已连接套接字就绪,这表明客户端有读写请求,我们可以调用 recv/send 函数处理读写请求。

其实,和 select 函数相比,poll 函数的改进之处主要就在于,它允许一次监听超过 1024 个文件描述符。但是当调用了 poll 函数后,我们仍然需要遍历每个文件描述符,检测该描述符是否就绪,然后再进行处理。

poll 函数的特点:

  • 优点:
    • pollfd 数组代替了 bitmap,没有最大文件描述符数量的限制。
    • 利用结构体 pollfd,每次置位 revents 字段,每次只需恢复 revents 即可。pollfd 可重用。
  • 缺点:
    • select 一样,包含大量文件描述符的数组被整体复制于用户态和内核的地址空间之间,而不论这些文件描述符是否就绪,它的开销随着文件描述符数量的增加而线性增大。
    • select 一样,内核需要将消息传递到用户空间,都需要内核拷贝动作。
    • select 函数一样,poll 返回后,需要轮询 pollfd 来获取就绪的描述符。事实上,同时连接的大量客户端在一时刻可能只有很少的处于就绪状态,因此随着监视的描述符数量的增长,其效率也会线性下降。

epoll

为了避免遍历每个描述符呢,使用 epoll 机制实现 IO 多路复用。

epoll 函数的特点:

  • 优点:
    • 没有最大并发连接的限制,能打开的 FD 的上限远大于 1024(1G 的内存上能监听约 10 万个端口)。
    • IO 效率不会随着 FD 数目的增加而线性下将,只有活跃可用的 FD 才会调用 callback 函数。
    • 内存拷贝,利用 mmap 文件映射内存加速与内核空间的消息传递。epoll 使用 mmap 减少复制开销,即 epoll 使用一个文件描述符管理多个描述符,将用户关系的文件描述符的事件存放到内核的一个事件表中,这样在用户空间和内核空间的 copy 只需一次。
    • 通过内核和用户空间共享一块内存来实现,不需要内核拷贝动作。
    • epoll 拥有更加简单的 API。
  • 缺点:
    • epoll 每次只遍历活跃的 fd(如果是 LT,也会遍历先前活跃的 fd),在活跃 fd 较少的情况下就会很有优势,如果大部分 fd 都是活跃的,epoll 的效率可能还不如 select/poll

epoll 对文件描述符的操作有两种模式:

  • LT(Level Trigger,水平触发)模式:是缺省的工作方式,并且同时支持 blocknon-block socket。当 epoll_wait 检测到描述符事件发生并将此事件通知应用程序,应用程序可以不立即处理该事件。下次调用 epoll_wait 时,会再次响应应用程序并通知此事件,这种机制可以比较好的保证每个数据用户都处理掉了。
  • ET(Edge Trigger,边缘触发)模式:是高速工作方式,只支持 non-block socket。,当 epoll_wait 检测到描述符事件发生并将此事件通知应用程序,应用程序必须立即处理该事件。如果不处理,下次调用 epoll_wait 时,不会再次响应应用程序并通知此事件。简而言之,就是内核通知过的事情不会再说第二遍,数据错过没读,你自己负责。这种机制确实速度提高了,但是风险相伴而行。

ET 模式在很大程度上减少了 epoll 事件被重复触发的次数,因此效率要比 LT 模式高。epoll 工作在 ET 模式的时候,必须使用非阻塞套接口,以避免由于一个文件句柄的阻塞读/阻塞写操作把处理多个文件描述符的任务饿死。

技术区别

selectpollepoll
事件集合用户每次调用将重置可读可写及异常事件参数,内核通过参数在线修改来反馈其中的就绪事件统一处理所有事件类型,仅一个事件参数,用户通过 pollfd.events 传入感兴趣的事件,内核通过修改 pollfd.revents 参数反馈其中就绪的事件内核通过一个事件直接管理用户感兴趣的所有事件,每次调用 epoll_wait 时,无需反复传入用户感兴趣的事件,epoll_wait 系统调用的参数 events 仅用来反馈就绪的事件
工作原则采用轮询方式检测就绪事件,时间复杂度:O(n)采用轮询方式检测就绪事件,时间复杂度:O(n)采用回调方式检测就绪事件,时间复杂度:O(1)
最大连接1024无上限无上限
工作模式LTLTLT 和 ET
文件描述每次调用,每次拷贝每次调用,每次拷贝通过 mmap 的内存映射技术,降低拷贝的资源消耗

参考