IO 多路复用是指内核一旦发现进程指定的一个或者多个 IO 条件准备读取,它就通知该进程。与多进程和多线程技术相比,I/O 多路复用技术的最大优势是系统开销小,系统不必创建进程 / 线程,也不必维护这些进程 / 线程,从而大大减小了系统的开销。

目前支持 I/O 多路复用的系统调用有 select,pselect,poll,epoll,I/O 多路复用就是通过一种机制,一个进程可以监视多个描述符,一旦某个描述符就绪(一般是读就绪或者写就绪),能够通知程序进行相应的读写操作,但 select,pselect,poll,epoll 本质上都是同步 I/O,因为他们都需要在读写事件就绪后自己负责进行读写,也就是说这个读写过程是阻塞的,而异步 I/O 则无需自己负责进行读写,异步 I/O 的实现会负责把数据从内核拷贝到用户空间。

IO 多路复用适用如下场合:

  1. 当客户处理多个描述符时(一般是交互式输入和网络套接口),必须使用 I/O 复用。
  2. 当一个客户同时处理多个套接口时,而这种情况是可能的,但很少出现。
  3. 如果一个 TCP 服务器既要处理监听套接口,又要处理已连接套接口,一般也要用到 I/O 复用。
  4. 如果一个服务器即要处理 TCP,又要处理 UDP,一般要使用 I/O 复用。
  5. 如果一个服务器要处理多个服务或多个协议,一般要使用 I/O 复用。

从 C10k 引述 select、poll、epoll

C10k 问题

随着互联网的普及,应用的用户群体几何倍增长,此时服务器性能问题就出现。最初的服务器是基于进程 / 线程模型。新到来一个 TCP 连接,就需要分配一个进程。假如有 C10K,就需要创建 1W 个进程,可想而知单机是无法承受的。那么如何突破单机性能是高性能网络编程必须要面对的问题,进而这些局限和问题就统称为 C10K 问题,最早是由 Dan Kegel 进行归纳和总结的,并且他也系统的分析和提出解决方案。

C10K 问题的本质上是操作系统的问题。对于 Web 1.0/2.0 时代的操作系统,传统的同步阻塞 I/O 模型处理方式都是 requests per second。当创建的进程或线程多了,数据拷贝频繁(缓存 I/O、内核将数据拷贝到用户进程空间、阻塞,进程 / 线程上下文切换消耗大, 导致操作系统崩溃,这就是 C10K 问题的本质。

可见,解决 C10K 问题的关键就是尽可能减少这些 CPU 资源消耗。

解决方案

从网络编程技术的角度来说,主要思路:

  1. 每个连接分配一个独立的线程 / 进程
  2. 同一个线程 / 进程同时处理多个连接

每个进程 / 线程处理一个连接

该思路最为直接,但是申请进程 / 线程是需要系统资源的,且系统需要管理这些进程 / 线程,所以会使资源占用过多,可扩展性差

每个进程 / 线程同时处理 多个连接 (I/O 多路复用)

  1. select 方式 :使用 fd_set 结构体告诉内核同时监控那些文件句柄,使用逐个排查方式去检查是否有文件句柄就绪或者超时。该方式有以下缺点:文件句柄数量是有上线的,逐个检查吞吐量低,每次调用都要重复初始化 fd_set。
  2. poll 方式 :该方式主要解决了 select 方式的 2 个缺点,文件句柄上限问题 (链表方式存储) 以及重复初始化问题 (不同字段标注关注事件和发生事件),但是逐个去检查文件句柄是否就绪的问题仍然没有解决。
  3. epoll 方式 :该方式可以说是 C10K 问题的 killer,他不去轮询监听所有文件句柄是否已经就绪。epoll 只对发生变化的文件句柄感兴趣。其工作机制是,使用 “事件” 的就绪通知方式,通过 epoll_ctl 注册文件描述符 fd,一旦该 fd 就绪,内核就会采用类似 callback 的回调机制来激活该 fd, epoll_wait 便可以收到通知,并通知应用程序。而且 epoll 使用一个文件描述符管理多个描述符,将用户进程的文件描述符的事件存放到内核的一个事件表中,这样数据只需要从内核缓存空间拷贝一次到用户进程地址空间。而且 epoll 是通过内核与用户空间共享内存方式来实现事件就绪消息传递的,其效率非常高。但是 epoll 是依赖系统的 (Linux)。
  4. 异步 I/O 以及 Windows,该方式在 windows 上支持很好。

传统思路

最简单的方法是循环挨个处理各个连接,每个连接对应一个 socket,当所有 socket 都有数据的时候,这种方法是可行的。

但是当应用读取某个 socket 的文件数据不 ready 的时候,整个应用会阻塞在这里等待该文件句柄,即使别的文件句柄 ready,也无法往下处理。

思路 :直接循环处理多个连接。

问题 :任一文件句柄的不成功会阻塞住整个应用。

select

要解决上面阻塞的问题,思路很简单,如果我在读取文件句柄之前,先查下它的状态,ready 了就进行处理,不 ready 就不进行处理,这不就解决了这个问题了嘛?

于是有了 select 方案。用一个 fd_set 结构体来告诉内核同时监控多个文件句柄,当其中有文件句柄的状态发生指定变化(例如某句柄由不可用变为可用)或超时,则调用返回。之后应用可以使用 FD_ISSET 来逐个查看是哪个文件句柄的状态发生了变化。

这样做,小规模的连接问题不大,但当连接数很多(文件句柄个数很多)的时候,逐个检查状态就很慢了。因此,select 往往存在管理的句柄上限(FD_SETSIZE)。同时,在使用上,因为只有一个字段记录关注和发生事件,每次调用之前要重新初始化 fd_set 结构体。

select 函数监视的文件描述符分 3 类,分别是 writefds、readfds、和 exceptfds。调用后 select 函数会阻塞,直到有描述符就绪(有数据 可读、可写、或者有 except),或者超时(timeout 指定等待时间,如果立即返回设为 null 即可),函数返回。当 select 函数返回后,可以通过遍历 fdset,来找到就绪的描述符。

21155028_QOj2

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

思路 :有连接请求抵达了再检查处理。

问题 :句柄上限 + 重复初始化 + 逐个排查所有文件句柄状态效率不高。

工作过程

一个典型的用户应用调用 select 机制请求数据过程:

  1. 使用 copy_from_user 从用户空间拷贝 fd_set 到内核空间

  2. 注册回调函数 __pollwait

  3. 遍历所有 fd,调用其对应的 poll 方法(对于 socket,这个 poll 方法是 sock_pollsock_poll 根据情况会调用到 tcp_poll,udp_poll 或者 datagram_poll

  4. tcp_poll 为例,其核心实现就是 __pollwait,也就是上面注册的回调函数。

  5. __pollwait 的主要工作就是把 current(当前进程)挂到设备的等待队列中,不同的设备有不同的等待队列,对于 tcp_poll 来说,其等待队列是 sk->sk_sleep(注意把进程挂到等待队列中并不代表进程已经睡眠了)。在设备收到一条消息(网络设备)或填写完文件数据(磁盘设备)后,会唤醒设备等待队列上睡眠的进程,这时 current 便被唤醒了。

  6. poll 方法返回时会返回一个描述读写操作是否就绪的 mask 掩码,根据这个 mask 掩码给 fd_set 赋值。

  7. 如果遍历完所有的 fd,还没有返回一个可读写的 mask 掩码,则会调用 schedule_timeout 是调用 select 的进程(也就是 current)进入睡眠。当设备驱动发生自身资源可读写后,会唤醒其等待队列上睡眠的进程。如果超过一定的超时时间(schedule_timeout 指定),还是没人唤醒,则调用 select 的进程会重新被唤醒获得 CPU,进而重新遍历 fd,判断有没有就绪的 fd。

  8. fd_set 从内核空间拷贝到用户空间。

137084-20180611142415772-1018872947

poll

poll 主要解决 select 的前两个问题:通过一个 pollfd 数组向内核传递需要关注的事件消除文件句柄上限,同时使用不同字段分别标注关注事件和发生事件,来避免重复初始化。

1
int poll(struct pollfd *fds, nfds_t nfds, int timeout);

思路 :设计新的数据结构提供使用效率。

问题 :逐个排查所有文件句柄状态效率不高。

epoll

既然逐个排查所有文件句柄状态效率不高,很自然的,如果调用返回的时候只给应用提供发生了状态变化(很可能是数据 ready)的文件句柄,进行排查的效率不就高多了么。

epoll 采用了这种设计,适用于大规模的应用场景。

实验表明,当文件句柄数目超过 10 之后,epoll 性能将优于 select 和 poll;当文件句柄数目达到 10K 的时候,epoll 已经超过 select 和 poll 两个数量级。

1
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);

思路 :只返回状态变化的文件句柄。

问题 :依赖特定平台(Linux)。

因为 Linux 是互联网企业中使用率最高的操作系统,epoll 就成为 C10K killer、高并发、高性能、异步非阻塞这些技术的代名词了。FreeBSD 推出了 kqueue,Linux 推出了 epoll,Windows 推出了 IOCP,Solaris 推出了 /dev/poll。这些操作系统提供的功能就是为了解决 C10K 问题。epoll 技术的编程模型就是异步非阻塞回调,也可以叫做 Reactor,事件驱动,事件轮循(EventLoop)。Nginx,libevent,node.js 这些就是 Epoll 时代的产物。

libevent

由于 epoll, kqueue, IOCP 每个接口都有自己的特点,程序移植非常困难,于是需要对这些接口进行封装,以让它们易于使用和移植,其中 libevent 库就是其中之一。跨平台,封装底层平台的调用,提供统一的 API,但底层在不同平台上自动选择合适的调用。

按照 libevent 的官方网站,libevent 库提供了以下功能:当一个文件描述符的特定事件(如可读,可写或出错)发生了,或一个定时事件发生了,libevent 就会自动执行用户指定的回调函数,来处理事件。目前,libevent 已支持以下接口 /dev/poll, kqueue, event ports, select, poll 和 epoll。Libevent 的内部事件机制完全是基于所使用的接口的。因此 libevent 非常容易移植,也使它的扩展性非常容易。目前,libevent 已在以下操作系统中编译通过:Linux,BSD,Mac OS X,Solaris 和 Windows。

使用 libevent 库进行开发非常简单,也很容易在各种 unix 平台上移植。一个简单的使用 libevent 库的程序如下: