IO多路复用
小白也看得懂的 I/O 多路复用解析(超详细案例)_哔哩哔哩_bilibili
基础概念
Socket
套接字,在网络通信中,就是客户端和服务端的出入口。
套接字看作不同主机间的进程进行双间通信的端点。是计算机之间进行通信的一种约定或一种方式,用于描述IP地址和端口。
fd
文件描述符,是指向资源文件的索引。
Socket通讯的过程
- 服务端通过 bind 绑定机器的端口号, 进程 listen 某个端口。
- 客户端和服务端通过 tcp 三次握手建联。
- 进行数据交互,
- 最后通过 close 断开连接。
IO模型
同步阻塞IO - BIO
单线程
单线程情况下,Socket 会阻塞其它 Socket,直到 当前 Socket 结束。
多线程
多线程情况下,假如每个客户端分一个线程,容易造成资源浪费。
比如不同时刻就绪的四个 Scoket,原本一个线程就能执行完。
同步非阻塞IO - NIO
阻塞 IO 在处理多个 Scoket 时,如果当前 Socket 无数据发送,会一直等待。
而非阻塞 IO 就是操作系统为了解决阻塞问题做出的优化。
非阻塞 IO 在处理多个 Socket 时,如果当前 Socket 无数据到达,会继续检查下一个 Socket。不会阻塞在当前 Socket。
当前 Socket 数据到达之后,进行数据交互。
数据处理也可以采用异步方式,即开启一个新线程去处理该 Socket 请求。主线程继续判断其它 Socket。
优缺点
优点
解决了Socket 阻塞问题。
缺点
需要不停的轮询,过程中的系统调用、用户态和系统态的切换都是不小的开销。
read 函数 从用户态将 fd 拷贝到内核态。
IO多路复用
多路复用就是使用一个或一组线程(线程池)处理多个TCP连接
。
- select/poll/epoll 核心是可以同时处理多个connection,而不是更快,所以连接数不高的话,性能不一定比多线程+阻塞IO 好。
select
为了解决用户态和内核态的频繁切换,select 函数将 fd 整体拷贝到内核态,在内核态进行轮询。
而轮询过程select 函数是阻塞的,直到以下条件达成。
- 有监测事件发生,此时select函数返回大于0的值。
- 超时,此时select函数返回0。
- select函数发生错误,此时返回-1。
使用 fd_set 表示监听的文件 fd。
fd_set
入参
指定需要监听的 fd,数据长度限制了监听的 fd 个数。
出参
直接修改指定位上的值,表示该位代表的 fd 是否就绪。
核心就是将事件就绪检查逻辑整体放到内核态,减少系统调用。
检查完成之后返回就绪的事件数量,但是没有返回是哪个 fd。
使用select函数进行 IO 请求和 同步阻塞模型 没有太大的区别,甚至还多了添加监视socket,以及调用select函数的额外操作,效率更差。
但是,使用select以后最大的优势是用户可以在一个线程内同时处理多个socket的IO请求。用户可以注册多个socket,然后不断地调用select读取被激活的socket,即可达到在同一个线程内同时处理多个IO请求的目的。而在同步阻塞模型中,必须通过多线程的方式才能达到这个目的。
缺点
不知道具体哪个 fd 就绪,需要遍历。
单进程监听的 fd 有限制,默认 1024。
这里的限制跟入参fd_set 的长度有关系(默认 1024)
入参的 fd_set 每次调用都被重置。
使用位图作为数据结构,出参是在入参基础上修改的。所以每次调用都需要重置 fd_set。
poll
由于 select 的 fd_set 带来的缺点,poll 针对缺点进行了优化。
入参取消 fd_set,改为可以复用的 pollfd。
可以看到 pollfd 里面包含了fd、监听的事件和就绪的事件,这样再重复调用的时候就不需要重置参数。
而且 pollfd 作为集合,拷贝到内核态之后是链表
形式,所以是没有长度限制的。
poll 和 select整体对比,其实就是改变了入参,避免了 fd 限制和重置问题。
但是 poll 还是遗留了两个问题。
- 每次需要将 fd 从用户态拷贝到内核态。
- 检测成功后不知道具体就绪的 fd,需要遍历全部的 fd。
epoll
epoll 就是针对 select 和 poll 的优化,解决 poll 遗留的两个问题。
通过 红黑树 + 链表 + 回调函数解决。
epoll_create
当进程调用 epoll_create
时候,内核会创建 eventpoll
结构体。
eventpoll
每个epoll实例有自己的 eventpoll 实例。
rbr
红黑树,保存需要监控的 fd。
rdlist
双向链表,存放就绪的 fd。
struct eventpoll{ .... /*红黑树的根节点,这颗树中存储着所有添加到epoll中的需要监控的事件*/ struct rb_root rbr; //红黑树的根节点 /*双链表中则存放着将要通过epoll_wait返回给用户的满足条件的事件*/ struct list_head rdlist; //双向列表的头结点 .... };
epoll_ctl
将需要监听的文件描述符进行注册,内核会为这次动作构建一个红黑树的节点,并插入到红黑树中;
将需要监听的 fd 进行注册,生成 epitem 并添加到 rbr(红黑树)中。
struct epitem{
struct rb_node rbn;//红黑树节点
struct list_head rdllink;//双向链表节点
struct eventpoll *ep; //指向其所属的eventpoll对象
pwqlist; //回调函数
}
将 fd 写入到 rbr 之后,内核会为 epitem 设置回调函数。
内核会为这个 fd 与网卡驱动程序建立回调关系,当事件就绪时,会调用已经建立好的回调方法。这个回调方法会将发生的事件添加到就绪链表中;
通过客户端发来的数据包通过网卡驱动找到对应的 epitem,然后进行操作。
epoll_wait
epoll_wait 会检查就绪列表里的事件。
就绪列表不为空
有就绪事件,将事件拷贝到用户态进行处理。
就绪列表为空
将 epoll 进程放到 eventpoll (epoll 实例)的等待队列中,让出 CPU 。(等待 epitem 对应的回调函数唤醒)
通过客户端发来的数据找到对应的 epitem 之后,执行 epitem 的回调函数。执行过程会唤醒 eventpoll 等待队列中的epoll 进程,然后 添加到就绪队列。最后 epoll_wait获取就绪事件。
在高并发场景下,epoll_wait 检查就绪队列,就绪事件会很多并且非常快。
- 避免了每次都需要将 fd 从用户态拷贝到内核态,epoll 只需要在注册事件的时候拷贝一次。
- 不需要遍历所有 fd来找到就绪 fd,通过 epoll_wait 检查就绪队列可以直接找到就绪的 fd。
epoll的工作方式
水平触发(LT)
LT 是 epoll 默认的通知方式。
epoll_wait 检测到事件就绪后,后续的 epoll_wait 继续检测到该事件后,该事件未完成,会继续发通知。
这样 epoll_wait 的通知次数会增多,性能比 ET 低,但是更可靠。
边缘触发(ET)
ET 是只在第一次监测到时间的时候通知,之后不再通知。epoll_wait 通知次数少,性能更高。