前言
本节主要是讲 网络编程中, 常用的I/O 多路复用. 全文大概分为以下几点.
- 为什么需要I/O多路复用?
- I/O多路复用的使用场景?
- 为什么都是与非阻塞I/O进行搭配, 而不是与阻塞I/O进行搭配呢?
- select 的 优缺点 及 内核实现
- poll 的 优缺点 及 内核实现
- epoll 的 优缺点 及 内核实现
为什么需要 I/O 多路复用?
首先 I/O 模型 最主要分为以下几种
- 阻塞I/O
- 非阻塞I/O
- I/O 复用
- 信号驱动I/O
- 异步I/O
在这里只谈 阻塞与非阻塞 I/O.
阻塞I/O
比如说 Socket send 一段数据给对端机器 如果TCP发送缓冲区不够大, 则会产生阻塞, 产生阻塞之后, 调度器会将CPU资源让给其他进程, 这样对于一个服务器进程来说实在是难以接受, 因此有没有什么办法, 让内核通知我们 缓冲区什么时候足够大了, 再通知我们, 我们这时候再去写入数据到TCP缓冲区呢?
这就引出了 I/O 多路复用, 它就是用来做这类事情, 只要你将描述符给到它们, 当可读或可写为你所关心的事件的时候, 它就会来通知你, 这时候去读就不会阻塞(注意不是一定, 原因会在下面)
非阻塞I/O
那非阻塞I/O send 的时候不就不会阻塞了吗? 为什么 非阻塞I/O 也要用 I/O多路复用呢? 其实原因很简单, 非阻塞I/O 你想你调用 send 的函数, 发送出去, 它是不阻塞的, 但是有可能TCP发送缓冲区不够大, 它虽然立即返回结果, 但有可能并没有发送成功, 这个时候你就要想, 我应该在什么时候再试试呢? 不知晓I/O多路复用的人 很可能写出以下 伪代码
1 | while (Socket::send(buf) != succ) {} |
换句话说就是 不停地 while 循环 检测 TCP发送缓冲区是否足以成功发送了, 但是这种做法会引来一个新的问题 那就是, 服务端没办法做其他任何的事情, 它就一直在这傻傻的不停地问.
这时候 I/O多路复用再次出场了, 它通知你这时候可读或可写, 你再去读, 这样就不会一直处于 busy loop 中.
I/O多路复用的使用场景?
其实从上面举的例子来看, 已经讲清楚了I/O多路复用的使用场景, 当你想要高效的知道一个文件描述符是否可读/可写的时候, 就可以采用I/O多路复用模型.
为什么都是与非阻塞I/O进行搭配, 而不是与阻塞I/O进行搭配呢?
在第一个问题中, 我分别描述了 I/O多路复用与非阻塞I/O和阻塞I/O的搭配使用, 但是有些基础的同学, 可能会想, 为什么我在网络上看到的都是说 I/O多路复用与非阻塞I/O进行搭配, 几乎没有说到和阻塞I/O进行搭配的?
首先假设 此时 阻塞I/O与I/O多路复用搭配使用, 内核通知到我们说 这个阻塞 I/O 可以读了, 我们就去读, 那这时候就有一个问题, 数据有多大, 我们要读多少次呢? 如果我们只读一次, 那效率又太低了, 如果我们读多次, 你怎么保证下一次读的时候, 一定不会阻塞呢? 要知道I/O多路复用只保证你当前读了一次不阻塞, 不代表读多次不阻塞, 如果阻塞了, I/O多路复用的机制就完全停住了, 因为程序一直阻塞在读中, 这就回退到了 阻塞I/O的版本.
还有一个问题就是说, 内核通知你去读, 但是有可能被其他线程读走了, 然后你并不知道, 再去读 阻塞了, 这就是惊群现象.
而与非阻塞I/O搭配, 没这么多烦恼, 反正我就一直读 读到返回 EWOULDBLOCK为止就是了, 反正不阻塞, 爱读多少读多少, 就算被别人拿走了, 我也不会阻塞.
可以看出 与非阻塞I/O进行搭配确实可以减轻我们不少的烦恼啊.
再举两个例子
accept() 阻塞版 与 I/O多路复用搭配, 内核通知我们可以 去建立一个连接, 但是如果服务器这时候很迟钝, 一直等到客户那边发送RST后才去连接, 这时候TCP会将客户的连接从队列中删除, 很明显 之后调用accept() 会发生阻塞.
connect() 阻塞版, 在调用之后 TCP完成三次握手前, 突然被中断了, 会直接返回 EINTR, 从中断服务例程回来之后, 是应该重新调用 connect() 吗? 显然是不行的 因为握手的过程还在继续, 只不过中途被中断了而已, 重新调用 connect() 如果对方已经接受连接, 则这一次connect会被拒绝, 返回 EADDRINUSE 错误, 只能通过 I/O多路复用, 等连接建立成功时, 再返回套接字可写条件.
题外话, 如果建立连接的过程中被中断的话, 有以下几种做法
- 你自己重启被中断的系统调用
- 对信号设置 SA_RESTART 属性, 让被中断的系统调用自行恢复
- 忽略信号
select 的 优缺点 及 内核实现
最多只支持到 1024个描述符, 不够用
select 使用方法
1 | int select(int maxfd, fd_set *readset, fd_set *writeset, fd_set *exceptset, const struct timeval *timeout); |
- maxfd: 描述符数量
- readset: 读描述符集合
- writeset: 写描述符集合
- exceptset: 异常描述符集合
- timeout: 如果为NULL 表示一直等, 如果为0则不等立即返回, 如果非0则一直等到超时
每次调用 select 都要重新覆盖 那三个集合, 因为监听的事件会改变着三个集合中的位, 此外, 每次返回的时候 如果select 不为0, 说明有事件发生, 如果想知道是哪个描述符的事件, 就要通过遍历 三个集合的内容, 来找到那个描述符.
select 优缺点
通过以上可知, 优点 实现简单.
缺点:
- 只支持 1024 个文件描述符
- 每次都要给内核重新传递三个集合, 用户态拷贝到内核态 开销大
- 每次都要遍历三个集合, 才能知道是哪个文件描述符发送了事件, 而且因为是位图, 遍历还是用的线性遍历
select 内核实现
就是一个简单的 bitmap
1 |
|
SYSCALL_DEFINE5 ⇒ sys_select() 在这里
sys_select() 主要是对超时时间做处理, 从用户态copy到内核态, 然后将超时时间 转化为 纳秒
1 | SYSCALL_DEFINE5(select, int, n, fd_set __user *, inp, fd_set __user *, outp, |
接下来调用 core_sys_select()
创建一个 256位 的数组, 然后获取当前进程的文件描述符表, 主要是做判断 不能让 select 监控的最大 文件描述符超过 该进程的文件描述符的上限, 接下来开辟空间 要开6个bitmap, in, out, ex 和其余三个对应的结果集合, 最后将其清空, 然后从用户态中拷贝到新创建的集合中, 调用 do_select() 去做真正的操作.
1 | int core_sys_select(int n, fd_set __user *inp, fd_set __user *outp, |
do_select()
1 | int do_select(int n, fd_set_bits *fds, struct timespec *end_time) |
可以看到 do_select 主要遍历 集合, 通过集合中的文件描述符找到文件结构体, 然后调用 poll 函数, 最后将结果写入集合中, 比较蠢, 需要把集合中的全部扫一遍, 效率很低.
分三层遍历, 第一层死循环直到满足 超时, 文件描述符有监听事件发生, 中断, 第二层循环遍历文件描述符, 第三层遍历 集合中的每一个 bit.
- 当有描述符发生所要监控的事件, 则会将其存下来, 返回到用户态
- 如果没有发生所要监控的事件, 如果已超时, 或者有待处理的信号, 也会回到用户态
- 即没有监控的事件, 又没有超时 或者 没有待处理的信号, 则会让出CPU, 等待被唤醒, 唤醒后再次进入循环
mask = (*f_op->poll)(f.file, wait);
对于 socket 来说 应该是 sock_poll 会将当前进程 放入等待队列中(但并不是去睡眠), 等到这个 f_op 可读或可写时, 就会唤醒当前进程, 如果一直没人唤醒的话, 就会自己去睡眠, 并设置超时时间, 时间到了之后, 就会重新唤醒去重新遍历 fd.
poll 的 优缺点 及 内核实现
1 | struct pollfd { |
poll 使用方法
1 | int poll(struct pollfd *fds, unsigned long nfds, int timeout); |
- fds: 监听事件数组
- nfds: fds有多大(突破了select 1024的限制)
- timeout: 超时事件 < 0 则一直等待, 0立即返回, >0 到时再返回
poll 优缺点
优点自然是 解决了 select 1024个文件描述符的限制
缺点和 select 一样
- 每次都要从用户态拷贝 pollfd数组到 内核态
- 寻找发生事件的描述符也是 和 select 一样 进行遍历, 线性扫描.
poll 内核实现
sys_poll()
可以看到 poll 的系统调用 和 select 类似 都是先设置好超时时间
然后调用 do_sys_poll()
1 | SYSCALL_DEFINE3(poll, struct pollfd __user *, ufds, unsigned int, nfds, |
do_sys_poll()
1 | int do_sys_poll(struct pollfd __user *ufds, unsigned int nfds, |
do_poll()
1 | static int do_poll(unsigned int nfds, struct poll_list *list, |
和 select 类似 来个无限循环, 然后 遍历 用户传进来的数组, 对数组中的文件描述符 进行 poll操作, 一样会将当前进程放入等待队列, 当文件描述符所代表的”文件”可读或者可写就会唤醒等待队列的进程, 最后将其放入 revents, 拷贝回用户态. 如果一直没有 文件可读或者可写, 就和select一样, 开个定时器, 让自己睡过去, 直到超时.
离开无限循环的条件 1. 有新事件 2. 超时, 3. 有信号发生, 发生中断
epoll 的 优缺点 及 内核实现
1 | typedef union epoll_data { |
这里的 events 和 poll的 一样, 此外 epoll_data 中一般只用 fd.
epoll 使用方法
1 | int epoll_create(int size); |
1 | int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event); |
- epfd 就是 epoll_create 的返回值
- op 就是 operator 操作
- EPOLL_CTL_ADD: 向 epoll 注册文件描述符的事件
- EPOLL_CTL_DEL: 向 epoll 删除文件描述符的事件
- EPOLL_CTL_MOD: 修改文件描述符的事件
- fd 监听的文件描述符
- event 监听事件类型 和 用户自定义信息(大部分时候只放 fd)
1 | int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout); |
- events: 数组 返回需要处理的I/O事件
- maxevents: 可以返回的最大事件值
- timeout -1 不超时, 0 立即返回
条件触发(水平触发) 与 边缘触发
通过设置 event | EPOLLET 设置为 边缘触发, 默认为水平触发.
用一句话来解释就是 条件触发的话 只要缓冲区有东西 就一直 从 epoll_wait 中提醒, 而边缘触发 只有在第一次 满足条件的时候才触发, 因此 边缘触发的效率比水平触发高, 不过 边缘触发的代码就不是很好写了.
题外话: select 和 poll 都是水平触发的模式.
epoll 内核实现
sys_epoll_create()
epoll_create 会创建 匿名文件 和 文件描述符 同时将其绑定起来, 而且会将 该匿名文件 存入 eventpoll 结构体当中, 方便通过 epollfd 来找到 eventpoll 实例.
1 | SYSCALL_DEFINE1(epoll_create, int, size) |
1 | SYSCALL_DEFINE1(epoll_create1, int, flags) |
1 | static int ep_alloc(struct eventpoll **pep) |
1 | /* |
1 | // 其实就是 红黑树的 一个节点 |
sys_epoll_ctl()
先是根据 epollfd 来找到 匿名文件, 即 epoll 实例, 接着获取真正的 文件(fd传进来的), 然后取出 epoll,
接着 在红黑树中 找这个 fd.
1 | SYSCALL_DEFINE4(epoll_ctl, int, epfd, int, op, int, fd, |
epi = ep_find(ep, tf.file, fd);
红黑树的 排序规则 采用 文件地址排序, 如果相同 就按照文件描述符进行排序
1 | struct epoll_filefd { |
ep_insert()
1 | static int ep_insert(struct eventpoll *ep, struct epoll_event *event, |
ep_poll_callback()
1 | static int ep_poll_callback(wait_queue_t *wait, unsigned mode, int sync, void *key) |
sys_epoll_wait()
1 | SYSCALL_DEFINE4(epoll_wait, int, epfd, struct epoll_event __user *, events, |
1 | static int ep_poll(struct eventpoll *ep, struct epoll_event __user *events, |
ep_send_events() 还会在里面再次检测是否真的就绪, 因为很有可能在窗口时间(就是处理的过程中) 被用户处理掉.
LT 与 ET 是怎么实现的?
其实很简单, 如果是 LT 的话, 每次 处理完 都将 epoll_item 重新加入 eventpoll 就绪队列中, 这样就能再次被重新处理.
总结
经过以上洗礼, 我们可以得出结论, epoll 效率比 select 或者 poll 高的原因是, 因为, 它内部采用了红黑树 来存储 事件, 这样就不需要每次都从 用户态拷贝到内核态 节约了一层的性能开销.
此外, 红黑树能用来快速搜索 fd, fd 又直接关联 eventpoll 对象, 可以直接将 fd加入到 eventpoll的就绪队列中, 不用像 select 或者 poll 一样, 发生了事件, 傻傻的去遍历 到底是哪个 fd 发生了事件.
同时, 返回给用户的事件的方式又有很大改善, select 和 poll 一股脑全反回去, 你还要自己进行遍历, 筛选掉很多没用的事件, 而 epoll 则不同, 它直接给你发生了事件的, 没发生事件的不给你, 省的你去遍历, 以上三层, 奠定了 epoll 在 I/O多路复用中的地位.