IO 模型
IO 是 Input/Output 的缩写。Unix 网络编程中有五种 IO 模型
网络 I/O 涉及到进程与操作系统内核(kernel)直接的数据拷贝,如 read 的时候在内核等待数据准备,完成之后把数据从内核拷贝到进程中。
同步/异步描述的是任务之间的执行关系。
阻塞/非阻塞描述的是对一个任务的调用关系(一般是 I/O 任务,也有个别情况是发起另一个线程,或者扔进线程池中)。
-
阻塞 IO(blocking IO)
在 IO 执行的两个阶段(等待数据和拷贝数据两个阶段)都被 block 了。 -
非阻塞 IO(nonblocking IO)
发起 IO 请求之后结束当前调用,用户进程其实是需要不断的主动询问 kernel 数据准备好了没有。 -
异步 IO(asynchronous IO)
用户进程在发起网络 IO 给 kernel 之后便可以做其他事情,kernel 在收到数据之后拷贝到用户内存,之后给用户进程发送一个 single 完成网络 IO,异步 IO 是真正的非阻塞 IO,其他四种都是属于同步 IO -
信号驱动 IO(signal driven IO)
-
多路复用 IO(IO multiplexing)
也叫事件驱动 IO(event driven IO)。select/poll/epoll 的好处就在于单个 process 就可以同时处理多个网络连接的 IO,其他方式需要程序通过多线程/多进程来实现阻塞/非阻塞 IO,它的基本原理就是 select/epoll 这个 function 会不断的轮询所负责的所有 socket,当某个 socket 有数据到达了,就通知用户进程。
poll / epoll / kevent / pollset / devpoll / select / rtsig 并不是 “高性能服务器” 的 “关键技术”,它们只是一个 API,而且是 posix 或者最早版本的 unix/bsd/systemv 的设计考虑不完善,对原有系统 API 设计不完善打的补丁,select/poll/epoll 的优势并不是对于单个连接能处理得更快,而是在于能处理更多的连接,连接少的情况下多线程阻塞 IO 的性能或许更好。与多进程和多线程技术相比,I/O 多路复用技术的最大优势是系统开销小,系统不必创建进程/线程,也不必维护这些进程/线程,从而大大减小了系统的开销。
完善的多路复用 IO,异步 reactor 框架应该就只有一个简单而统一的 selector 就足够了,所有操作系统都相同,提供:
- register: 注册
- unregister:删除
- set:设置,将一个给定的文件描述符加入集合之中
- wait:等待事件
- read:读取事件
- wake:将等待中的 wait 无条件唤醒
网络数据接收流程
- 进程在 recv 阻塞期间(不消耗 CPU),计算机收到了对端传送的数据(步骤①)
- 数据经由网卡传送到内存(步骤②)
- 网卡通过中断信号通知 CPU 有数据到达,CPU 执行中断程序(步骤③)
- 中断程序主要有两项功能,先将网络数据写入到对应 socket 的接收缓冲区里面(步骤④),再唤醒进程 A(步骤⑤),重新将进程A放入工作队列中
内核中的文件列表(fd)即对应的每一个 socket 连接的句柄,新建连接后操作系统会创建一个由文件系统管理的 socket 对象,socket 对象包含发送缓冲区、接收缓冲区、等待队列。等待队列指向所有需要等待该 socket 事件的进程。
Select
假如能够预先传入一个 socket 列表,如果列表中的 socket 都没有数据,挂起进程,直到有一个 socket 收到数据,唤醒进程。这种方法很直接,也是 select 的设计思想。
|
|
如果 fds 中的所有 socket 都没有数据,select 会阻塞,直到有一个 socket 接收到数据,select 返回,唤醒进程。用户可以遍历 fds,通过 FD_ISSET 判断具体哪个 socket 收到数据,然后做出处理。
当任何一个 socket 收到数据后,中断程序将唤起进程,所谓唤起进程,就是将进程从所有的等待队列中移除,加入到工作队列里面。当进程 A 被唤醒后,它知道至少有一个 socket 接收了数据。程序只需遍历一遍 socket 列表,就可以得到就绪的 socket。这种简单方式行之有效,在几乎所有操作系统都有对应的实现。
select 的主要缺点
其一,每次调用 select 都需要将进程加入到所有监视 socket 的等待队列,每次唤醒都需要从每个队列中移除。这里涉及了两次遍历,而且每次都要将整个 fds 列表传递给内核(内核才知道 socket 监听的哪些 fds),有一定的开销。正是因为遍历操作开销大,出于效率的考量,才会规定 select 的最大监视数量,默认只能监视 1024 个 socket。
其二,进程被唤醒后,程序并不知道哪些 socket 收到数据,还需要遍历一次。
select 将“维护等待队列”和“阻塞进程”两个步骤合二为一,每次调用select都需要这两步操作
Poll
poll 本质上和 select 没有区别,它将用户传入的数组拷贝到内核空间,然后查询每个 fd 对应的设备状态,如果设备就绪则在设备等待队列中加入一项并继续遍历,如果遍历完所有 fd 后没有发现就绪设备,则挂起当前进程,直到设备就绪或者主动超时,被唤醒后它又要再次遍历 fd。这个过程经历了多次无谓的遍历。它没有最大连接数的限制,原因是它是基于链表来存储的,但是同样有一个缺点:
- 大量的 fd 的数组被整体复制于用户态和内核地址空间之间,而不管这样的复制是不是有意义。
- poll 还有一个特点是“水平触发”,如果报告了 fd 后,没有被处理,那么下次 poll 时会再次报告该 fd。
Epoll
epoll 原理
epoll 是 event poll 的缩写,它是用来取代我们的 IO 多路复用函数 poll 和 select 的。它的主要功能是监听多个 file descriptor(fd),并且在他们有事件的时候,通知使用它的应用程序,并且它比传统的 poll 和 select 更高效。
|
|
如下图所示,计算机共有三个 socket,收到数据的 sock2 和 sock3 被 rdlist(就绪列表)所引用。当进程被唤醒后,只要获取 rdlist 的内容,就能够知道哪些 socket 收到数据。避免了像 select 遍历所有 fds 所带来的性能消耗。
|
|
创建 epoll 实例,返回 epoll 实例的文件句柄,当它调用失败时,它会返回-1,参数 size 告知内核需要分配多少内存,从 Linux 2.6.8 开始可以忽略改参数,传一个大于 0 的数值即可。
调用 epoll_create 后,操作系统在内核创建 epoll 实例,存在于操作系统内核之中!
当我们想要监听某个 fd(socket 或者 pipe 等)时,就要将其添加到一个被称之为 interest list 的列表中,这个 interest list 表示,所有要被 epoll 实例监听的 fd 集合,当我们的 interest list 中的 fd,有 IO 事件的时候(socket 收到数据后,中断程序会操作 epoll 实例,而不是直接操作进程),就会将对应的 fd 加入到一个叫做 ready list 的列表中,这个 ready list 是 interest list 的子集,它表示列表中的 fd 的 IO 已经准备就绪,需要通知应用程序去处理这些 fd 的 IO 事件
如何添加我们想要监听的 fd 到 epoll 实例中呢?实际上,linux 给我们提供了一个 epoll_ctl 函数来做这些处理,它的定义如下所示:
|
|
有 IO 事件的时候我们的应用层如何获取这个 ready list 呢?答案是通过 epoll_wait 函数,关于epoll_wait 函数的定义如下所示:
|
|
epoll 实例数据结构
程序可能随时调用 epoll_ctl 添加监视 socket,也可能随时删除。当删除时,若该 socket 已经存放在就绪列表中,它也应该被移除。所以就绪列表应是一种能够快速插入和删除的数据结构。双向链表就是这样一种数据结构,epoll 使用双向链表来实现就绪队列(对应上图的 rdllist)。
既然 epoll 将“维护监视队列”和“进程阻塞”分离,也意味着需要有个数据结构来保存监视的 socket。至少要方便的添加和移除,还要便于搜索,以避免重复添加。红黑树是一种自平衡二叉查找树,搜索、插入和删除时间复杂度都是O(log(N)),效率较好。epoll 使用了红黑树作为索引结构(对应上图的 rbr),即 interest list 的数据结构采用的是红黑树。
epoll 的 LT 和 ET 模式
epoll 使用“事件”的就绪通知方式,通过 epoll_ctl 注册 fd,一旦该 fd 就绪,内核就会采用类似 callback 的回调机制来激活该 fd,epoll_wait 便可以收到通知。系统中一旦有大量你不需要读写的就绪文件描述符,它们每次调用 epoll_wait 都会返回,这样会大大降低处理程序检索自己关心的就绪文件描述符的效率。
-
LT(Level Trigger)水平触发:LT 是默认的模式,只要这个 fd 还有数据可读,每次 epoll_wait 都会返回它的事件,提醒用户程序去操作
-
ET(Edge trigger)边缘触发:ET 是“高速”模式。在 ET 模式下,read 一个 fd 的时候一定要把它的 buffer 读光,也就是说一直读到 read 的返回值小于请求值,或者遇到 EAGAIN 错误。当被监控的文件描述符上有可读写事件发生时,epoll_wait() 会通知处理程序去读写。如果这次没有把数据全部读写完(如读写缓冲区太小),那么下次调用 epoll_wait() 时,它不会通知你,也就是它只会通知你一次,直到该文件描述符上出现第二次可读写事件才会通知你。这种模式比水平触发效率高,系统不会充斥大量你不关心的就绪文件描述符
select/poll/epoll 对比
select | poll | epoll | |
---|---|---|---|
操作方式 | 遍历 | 遍历 | 回调 |
底层实现 | 数组 | 链表 | 哈希表 |
IO 效率 | 每次调用都进行线性遍历,时间复杂度为O(n) | 每次调用都进行线性遍历,时间复杂度为O(n) | 事件通知方式,每当fd就绪,系统注册的回调函数就会被调用,将就绪 fd 放到 readyList 里面,时间复杂度 O(1) |
最大连接数 | 1024(x86)或2048(x64) | 无上限 | 无上限 |
fd 拷贝 | 每次调用 select,都需要把 fd 集合从用户态拷贝到内核态 | 每次调用 poll,都需要把 fd 集合从用户态拷贝到内核态 | 调用 epoll_ctl 时拷贝进内核并保存,之后每次 epoll_wait 不拷贝 |
epoll 在绝大多数情况下性能远超 select 和 poll,但在并发连接不高的情况下,多线程+阻塞 I/O 方式可能性能更好