Infinite Games

Less is More.

IO 多路复用的设计思路

在这篇文章中只谈思路,首页,我们明确 IO 多路复用要解决的问题是:

如何从监听的多个 socket 中筛选出能够执行读写操作(有事件触发)的 socket?

要解决这个问题,我们需要知道从硬件网卡到软件逻辑的过程:

硬件网卡收到网络包,网卡向 CPU 发送中断,CPU 执行中断函数,根据端口找到 socket,触发对应 socket 逻辑,例如把数据写入 socket 读缓冲区。

实现 IO 多路复用的关键点就在 socket 被中断函数主动触发的过程中

select

select 作为第一个解决方案比较粗糙(又不是不能用),但总比没有好,在计算机发展史上总是先有一个可用的方案,然后在此基础上优化迭代。

select 的实现思路是(以 read 为例):

  1. 把需要监听的 socket 集合全部复制到内核中
  2. 遍历 socket 集合,如果 socket 的接收缓冲区有数据,就是可读的,把可读的 socket 返回用户进程
  3. 如果没有可读的 socket,遍历 socket 集合,在每个 socket 上预先设置被触发时需要调用的逻辑:唤醒进程的逻辑和遍历 socket 集合的逻辑
  4. 阻塞进程
  5. 当有一个 socket 接收到数据被触发时,会调用预先设置好的唤醒进程的逻辑和遍历 socket 集合的逻辑,找出可读的 socket,移除每个 socket 上的触发逻辑,返回用户进程

select 的核心原理就是不断地遍历、遍历、遍历,遍历哪些 socket 可读,遍历在 socket 增加、删除触发逻辑,性能自然不咋样

因此,为了性能考虑,减少复制到内核和遍历的 socket 数量,内核只允许使用 select 同时监听 1024 个 socket。

接下来就是优化了:

  1. 希望能精准地知道哪个 socket 可读,不需要遍历
  2. 不要每次都拷贝 socket 集合到内核

poll

poll 和 select 原理一样,只是去除了 1024 的限制。

epoll

epoll 采用了新的设计思路,计算机世界里,有两大解决问题的方式:

  1. 计算机科学领域的任何问题, 都可以通过添加一个中间层来解决
  2. 变集中处理为分散处理(小块处理,只处理需要改变的部分,性能更好)

对于 IO 多路复用,有两件事是必须要做的:

  1. 需要监听的 fds 集合
  2. 探测集合中的哪些 fd 可读

咋办,加个中间层啥都解决了

我们在内核中间加一个 ep 对象,把所有需要监听的 socket 都放到 ep 对象中,这样增加操作只需要执行一次,需要监听的 socket 集合就一直在内核中了,对于这些 socket 我们需要快速地查找、增加、删除操作,我们使用红黑树来组织 socket。

这样每次拷贝 socket 集合到内核的问题就解决了

下面解决遍历问题,如果不想遍历所有的 socket,可以换个思路,当 socket 被触发时,把自己加入某个地方,这样我们只要去这个地方查找,就能找到所有可读的 socket。

因此在 ep 对象中还有一个可读队列(双向链表),当 socket 被触发时,就把自己加入到可读队列中,然后唤醒对应的进程(需要唤醒的进程会挂在 ep 对象的另一个数据结构上),进程只要读取可读队列就知道哪些 socket 可读,不需要遍历了


延伸一点代码,epoll 按照上面的设计思路分为:

  • epoll_ctl 负责把 socket 增加、删除到内核红黑树
  • epoll_wait 负责检测可读队列,没有可读 socket 则阻塞进程

这样,每次调用 epoll_wait 性能很高,不会有遍历、复制的操作。