常见 I/O 模型

  • 同步阻塞IO(Blocking IO):用户线程通过调用系统调用read发起IO读操作,由用户空间转到内核空间。内核等到数据包到达后,然后将接受的数据拷贝到用户空间,完成read操作。整个IO请求过程,用户线程都是被阻塞的,对CPU利用率不够
  • 同步非阻塞IO(Non-blocking IO):默认常见的socket都是阻塞的,不过可以在同步基础上,将socket设置为NONBLOCK,这样用户线程可以在发起IO请求后立即返回,但此时并未读到任何数据,用户线程需要不断的轮询、重复请求,直到真正读到数据。
  • IO多路复用(IO Multiplexing):即经典的Reactor设计模式,也被称为异步阻塞IO。核心是可以同时处理多个connection,而不是更快,所以连接数不高的话,性能不一定比多线程+阻塞IO好。
  • 异步IO(Asychronous IO):即Proactor设计模式,也被称为异步非阻塞IO。 当用户线程收到通知时候,数据已经被内核读取完毕,并放在了用户线程指定的缓冲区内,内核在IO完成后通知用户线程直接使用就行了。而不是由由用户线程自行读取数据、处理数据。

基本概念

I/O 多路复用是为了解决之前每个 I/O 请求都得开一个线程去 read 或者 write I/O 操作。在计算机网络越来越爆炸的情况下,这个模型就 cover 不住了。所以其本质是为了缓解 线程/进程数量过多对服务器开销造成的压力。

I/O多路复用(又被称为“事件驱动”),一个线程可以监视多个描述符(socket),一旦某个描述符就绪(一般是读就绪或者写就绪),能够通知程序进行相应的读写操作。可以保证每次read都能读到有效数据,而不做纯返回-1和EAGAIN的无用功。写操作类似。操作系统的这个功能通过Linux 下的select/poll/epoll,或者macOS/FreeBSD 中的kqueue 之类的系统调用函数来使用。这样,多个描述符的I/O操作都能在一个线程内并发交替地顺序完成,这就叫I/O多路复用,这里的“复用”指的是复用同一个线程。

LINUX 下三种多路复用的对比

io多路复用是阻塞的 redis io多路复用阻塞吗_io多路复用是阻塞的 redis

Select

原理:

调用后select函数会阻塞,直到有描述符就绪(有数据 可读、可写、或者有except),或者超时(timeout指定等待时间,如果立即返回设为null即可),函数返回。当select函数返回后,可以通过遍历fdset,来找到就绪的描述符。select具有O(n)的无差别轮询复杂度

优点:

跨平台性。 

缺点:

  1. select最大的缺陷就是单个进程所打开的FD是有一定限制的,它由FD_SETSIZE设置,默认值是1024。

一般来说这个数目和系统内存关系很大。32位机默认是1024个。64位机默认是2048.

  1. 对socket进行扫描时是线性扫描,即采用轮询的方法,效率较低。每次调用select,都需要把fd集合从用户态拷贝到内核态,这是一份开销。然后再内核中去轮询整个集合,在集合比较大的时候,开销也会很大。

Poll

原理:(基本与select相同)

  1. 用户线程调用poll,并将文件描述符链表拷贝到内核空间
  2. 内核对文件描述符遍历一遍,如果没有就绪的描述符,则内核开始休眠,直到有就绪的文件描述符,返回给用户线程就绪的文件描述符数量
  3. 用户线程再遍历一次文件描述符链表,找出就绪的文件描述符
  4. 用户线程对就绪的文件描述符进行读写操作

数据结构以链表存储,所以没有最大数量的限制。

  • 与select的异同点
    相同点:
  1. 内核线程都需要遍历文件描述符,并且当内核返回就绪的文件描述符数量后,用户还需要遍历一次找出就绪的文件描述符
  2. 需要将文件描述符数组或链表从用户空间拷贝到内核空间
  3. 性能开销会随文件描述符的数量而线性增大

不同点:

  1. select存储的数据结构是文件描述符数组,poll采用链表。所以一个有最大数量限制,poll没有

Epoll

epoll 是对 select和poll的改进。

struct eventpoll {
    ...
    /*红黑树的根节点,这颗树存储着所有添加到epoll中的需要监控的事件*/
    struct rb_root  rbr;
    /*双链表存储所有就绪的文件描述符*/
    struct list_head rdlist;
    ...
};
  • 针对每次都要从用户空间到内核空间的拷贝问题,epoll 在注册时就将fd通过内存映射的方式,映射到了内核。不仅减少了单次拷贝的开销,并且保证了每个fd在整个过程中只会拷贝一次,减少整体的拷贝开销。
  • 针对内核每次都要使用轮询的方式来判断是否有文件描述符就绪的问题,通过为每个 fd 都绑定回调函数的方法,来减少这里轮询的开销。时间复杂度变更为 O(1)。这样的实现机制是红黑树
  • epoll 还会维护一个双向链表,当有就绪事件回调时,就将就绪的 fd 放到双向链表中。这样,只需要查看就绪链表中是否有数据,就可以直接得知当前是否有 fd 是就绪态。

epoll对文件描述符的操作有两种模式:LT(level trigger)和ET(edge trigger)。LT模式是默认模式,LT模式与ET模式的区别如下:

LT模式:当epoll_wait检测到描述符事件发生并将此事件通知应用程序,应用程序可以不立即处理该事件。下次调用epoll_wait时,会再次响应应用程序并通知此事件。

ET模式:当epoll_wait检测到描述符事件发生并将此事件通知应用程序,应用程序必须立即处理该事件。如果不处理,下次调用epoll_wait时,不会再次响应应用程序并通知此事件。减少了重复触发

Redis 下的多路复用

Linux环境下,Redis数据库服务器大部分时间以单进程单线程模式运行(执行持久化BGSAVE任务时会开启子进程),网络部分属于Reactor模式,同步非阻塞模型,即非阻塞的socket文件描述符号加上监控这些描述符的I/O多路复用机制(在Linux下可以使用select/poll/epoll)。

服务器运行时主要关注两大类型事件:文件事件和时间事件。文件事件指的是socket文件描述符的读写就绪情况,「文件事件处理器」使用 I/O 多路复用模块同时监控多个文件描述符(fd)的读写情况,当 acceptreadwrite 和 close 文件事件产生时,文件事件处理器就会回调 FD 绑定的事件处理器进行处理相关命令操作。

时间事件分为一次性定时器和周期性定时器。redis的定时器机制并不那么先进复杂,它只用了一个链表来管理时间事件,而且目前链表也没有对各个事件的到点时间进行排序,也就是说,每次都要遍历链表检查每个事件是否需要到点执行。个人猜想是因为redis目前并没有太多的定时事件需要管理,redis以数据库服务器角色运行时,定时任务回调函数只有位于redis/src/redis.c下的serverCron函数,所有的定时任务都在这个函数下执行,也就是说,链表里面其实目前就一个节点元素,所以目前也无需实现高性能定时器。