Redis技术知识总结之七——Redis多路复用机制

接上篇《Redis技术知识总结之六——Redis持久化机制》

七. Redis 多路复用机制

参考地址:《Redis IO多路复用技术以及epoll实现原理》

redis 是一个单线程却性能非常好的内存数据库, 主要用来作为缓存系统。 redis 采用网络IO多路复用技术来保证在多连接的时候, 系统的高吞吐量。
为什么 Redis 中要使用 I/O 多路复用这种技术呢?
首先,Redis 是跑在单线程中的,所有的操作都是按照顺序线性执行的,但是由于读写操作等待用户输入或输出都是阻塞的,所以 I/O 操作在一般情况下往往不能直接返回,这会导致某一文件的 I/O 阻塞导致整个进程无法对其它客户提供服务,而 I/O 多路复用就是为了解决这个问题而出现的。
redis的io模型主要是基于epoll实现的,不过它也提供了 select和kqueue的实现,默认采用epoll。
那么epoll到底是个什么东西呢? 其实只是众多i/o多路复用技术当中的一种而已,但是相比其他io多路复用技术(select, poll等等)。

7.1 epoll 与 select/poll 的区别

select,poll,epoll都是IO多路复用的机制。I/O多路复用就通过一种机制,可以监视多个描述符,一旦某个描述符就绪,能够通知程序进行相应的操作。
select 的本质是采用 32 个整数的 32 位,即 3232= 1024 来标识,fd值为 1-1024。当 fd 的值超过 1024 限制时,就必须修改 FD_SETSIZE 的大小。这个时候就可以标识32max 值范围的 fd。
poll 与 select 不同,通过一个 pollfd 数组向内核传递需要关注的事件,故没有描述符个数的限制,pollfd 中的 events 字段和 revents 分别用于标识关注的事件和发生的事件,故 pollfd 数组只需要被初始化一次。
epoll 还是 poll 的一种优化,返回后不需要对所有的 fd 进行遍历,在内核中维持了 fd 的列表。select 和 poll 是将这个内核列表维持在用户态,然后传递到内核中;而与 poll/select 不同,epoll 不再是一个单独的系统调用,而是由 epoll_create/epoll_ctl/epoll_wait 三个系统调用组成,后面将会看到这样做的好处。

注:epoll 在 2.6 以后的内核才支持。

7.2 Epoll 的优点

epoll 有诸多优点

  1. epoll 没有最大并发连接的限制,上限是最大可以打开文件的数目,这个数字一般远大于 2048, 一般来说这个数目和系统内存关系很大 ,具体数目可以 cat /proc/sys/fs/file-max 察看。
  2. 效率提升, Epoll 最大的优点就在于它只管你“活跃”的连接 ,而跟连接总数无关,因此 IO 效率不随 FD 数目增加而线性下降,在实际的网络环境中, Epoll 的效率就会远远高于 select 和 poll 。
  3. 内存拷贝, Epoll 在这点上使用了“共享内存 ”,这个内存拷贝也省略了。
    • Epoll 使用了 mmap 加速内核与用户空间的消息传递。这点涉及了 epoll 的具体实现。无论是select, poll,还是 epoll,都需要内核把 FD 消息通知给用户空间,如何避免不必要的内存拷贝就很 重要。在这点上,Epoll 是通过内核与用户空间 mmap 同一块内存实现的。

select/poll的几大缺点

  1. 每次调用 select/poll,都需要把 fd 集合从用户态拷贝到内核态,这个开销在 fd 很多的时候会很大;
  2. 同时每次调用 select/poll 都需要在内核遍历传递进来的所有 fd,这个开销在 fd 很多时也很大;
  3. 针对 select 支持的文件描述符数量太小了,默认是 1024;
  4. select 返回的是含有整个句柄的数组,应用程序需要遍历整个数组才能发现哪些句柄发生了事件;
  5. select 的触发方式是水平触发,应用程序如果没有完成对一个已经就绪的文件描述符进行 IO 操作,那么之后每次 select 调用还是会将这些文件描述符通知进程。

相比 select模型,poll使用链表保存文件描述符,因此没有了监视文件数量的限制,但其他三个缺点依然存在。

7.3 epoll IO 多路复用模型实现机制

由于 epoll 的实现机制与 select/poll 机制完全不同,上面所说的 select 的缺点在 epoll 上不复存在。
Epoll 没有这个限制,它所支持的 FD 上限是最大可以打开文件的数目,这个数字一般远大于 2048。举个例子,在 1GB 内存的机器上大约是 10万左右,设想一下如下场景:有 100 万个客户端同时与一个服务器进程保持着 TCP 连接。而每一时刻,通常只有几百上千个 TCP 连接是活跃的(事实上大部分场景都是这种情况)。如何实现这样的高并发?
select/poll 时代,主要实现方式是从用户态复制句柄数据结构到内核态。服务器进程每次都把这 100 万个连接告诉操作系统,让操作系统内核去查询这些套接字上是否有事件发生。轮询完后,再将句柄数据复制到用户态,让服务器应用程序轮询处理已发生的网络事件,这一过程资源消耗较大,因此,select/poll一般只能处理几千的并发连接。
此外,如果没有 I/O 事件产生,我们的程序就会阻塞在 select 处。但是依然有个问题,我们从 select 那里仅仅知道了,有 I/O 事件发生了,但却并不知道是那几个流(可能有一个,多个,甚至全部),我们只能无差别轮询所有流,找出能读出数据,或者写入数据的流,对他们进行操作。但是使用 select,我们有 O(n) 的无差别轮询复杂度,同时处理的流越多,每一次无差别轮询时间就越长。
Epoll 的设计和实现与 select 完全不同。Epoll 通过在 Linux 内核中申请一个简易的文件系统(文件系统一般用 B+树实现),把原先的 select/poll 调用分成了3个部分:

  1. epoll_create():建立一个 epoll对象(在 Epoll 文件系统中,为这个句柄对象分配资源);
  2. epoll_ctl():向 epoll 对象中添加这100万个连接的套接字;
  3. epoll_wait():收集发生的事件的连接;

如此一来,要实现上面所说的场景,只需要在进程启动时建立一个 epoll 对象,然后在需要的时候向这个 epoll 对象中添加或者删除连接。同时,epoll_wait 的效率也非常高,因为调用 epoll_wait 时,并没有一股脑的向操作系统复制这100万个连接的句柄数据,内核也不需要去遍历全部的连接。

7.4 Epoll 底层实现

底层实现:
当某一进程调用 epoll_create 方法时,Linux 内核会创建一个 eventpoll 结构体,这个结构体中有两个成员与 epoll 的使用方式密切相关。eventpoll 结构体如下所示:

struct eventpoll {
  //....
  // 红黑树的根节点,这棵树中存储着所有添加到 epoll 中的需要监控的事件
  struct rb_root rbr;
  // 双链表中存放着将要通过 epoll_wait 返回给用户的满足条件的事件
  struct list_head rdlist;
  //....
}

7.4.1 socket 红黑树

每一个 epoll 对象都有一个独立的 eventpoll 结构体,用于存放通过 epoll_ctl 方法向 epoll 对象中添加进来的事件,这些事件都会挂载在用于存储上述的被监控 socket 的红黑树上,即上面源码的 rb_root。当你调用 epoll_create 时,就会在 epoll 注册的一个文件系统中创建一个 file 节点,这个 file 不是普通文件,它只服务于 epoll。epoll 在被内核初始化时(操作系统启动),同时会开辟出 epoll 自己的内核高速缓存区,用于安置每一个我们想监控的 socket,这些 socket 会以红黑树的形式保存在内核缓存里,红黑树的插入时间效率很高,对于高度为 n 的红黑树,查找、插入、删除的效率都是 lgn。如此重复添加的事件就可以通过红黑树高效的识别出来。

  • 注:这个内核高速缓存区,就是建立连续的物理内存页,然后在之上建立 slab 层,简单的说,就是物理上分配好你想要的 size 的内存对象,每次使用时都是使用空闲的已分配好的对象。

7.4.2 事件双链表

所有添加到 epoll 中的事件都会与设备(网卡)驱动程序**建立回调关系,也就是说当相应的事件发生时,会调用这个回调方法。这个回调方法在内核中叫 ep_poll_callback,它会将发生的事件添加到 rdlist 双链表中。
这个事件双链表是怎么维护的呢?当我们执行 epoll_ctl 时,除了把 socket 放到 epoll 文件系统里 file 对象对应的红黑树上之外,还会
给内核中断处理程序注册一个回调函数**。告诉内核,如果这个句柄的中断到了,就把它放到准备就绪 list 链表里。所以,当一个 socket 上有数据到了,内核在把网卡上的数据 copy 到内核中,然后就把 socket 插入到准备就绪链表里了。由此可见,epoll 的基础就是回调
epoll 的每一个事件都会包含一个 epitem 结构体,如下所示:

struct epitem {
  // 红黑树节点
  struct rb_node rbn; 
  // 双向链表节点
  struct list_head rdllink;
  // 事件句柄信息
  struct epoll_filefd ffd;
  // 指向所属的 eventpoll 对象
  struct eventpoll *ep;
  // 期待发生的事件类型
  struct epoll_event event;
}

当调用 epoll_wait 检查是否有事件发生时,只需要检查 eventpoll 对象中的 rdlist 双链表中是否有 epitem 元素即可。如果 rdlist 不为空,则把发生的事件复制到用户态,同时将事件数量返回给用户。


综上所述,epoll 的执行过程:

  1. 调用 epoll_create 时,内核帮我们在 epoll 文件系统里建立 file 结点内核缓存中建立 socket 红黑树,除此之外,还会再建立一个用于存储准备就绪事件的 list 链表
  2. 执行 epoll_ctl 时,如果增加就绪事件的 socket 句柄,则需要:
    • 检查在红黑树中是否存在,存在立即返回,不存在则添加到树干上;
    • 然后向内核注册回调函数,用于当中断事件来临时向准备就绪链表中插入数据。
  3. epoll_wait 调用时,仅仅观察这个 list 链表里有没有数据即可,有数据就返回,没有数据就 sleep,等到 timeout 时间到后,即使链表没数据也返回。
  • epoll_wait 的执行过程相当于以往调用 select/poll,但 epoll 的效率高得多。

注:
epoll 独有的两种模式 LT 和 ET。无论是 LT 和 ET 模式,都适用于以上所说的流程。区别是,LT 模式下只要一个句柄上的事件一次没有处理完,会在以后调用 epoll_wait 时次次返回这个句柄。而ET模式仅在第一次返回。
关于 LT 和 ET 有一端描述,LT 和 ET 都是电子里面的术语,ET 是边缘触发,LT 是水平触发,一个表示只有在变化的边际触发,一个表示在某个阶段都会触发。
对于 epoll 而言,当一个 socket 句柄上有事件时,内核会把该句柄插入上面所说的准备就绪链表,这时我们调用 epoll_wait,会把准备就绪的 socket 拷贝到用户态内存,然后清空准备就绪链表。最后,epoll_wait 检查这些 socket,如果不是 ET 模式(就是LT模式的句柄了),并且这些 socket 上确实有未处理的事件时,又把该句柄放回到刚刚清空的准备就绪链表了。所以,非 ET 的句柄,只要它上面还有事件,epoll_wait 每次都会返回这个句柄。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章