IO 的概念及 IO 五种模型,epoll 工作原理

IO 是什么?

在 Linux 系统中一切皆文件,而文件指的就是一些二进制的流,这些流包括输入流和输出流,比如我们之前谈到的,进程间通信,socket 套接字,管道,read 读取数据,write 写数据等等,在信息交换的过程中,我们都是对这些流进行操作,简称 IO 操作,那计算机里面这些流,我们是通过文件描述符来进行操作的.

通常用户进程的一个完整 IO 分为两个阶段

从用户空间到内核空间,从内核空间到设备空间

内核空间存放的是内核代码和数据,用户空间存放的是用户程序代码和数据,不管是内核还是用户,他们都处于虚拟地址空间,Linux 使用两级保护机制,0 级供内核使用,3 级供用户程序使用,也就是说,用户无法直接操作内核数据,必须通过系统调用请求内核完成 IO 操作

所以对于一个输入操作来说,进程 IO 系统调用后,内核会先看缓冲区有没有数据,如果没有到设备中读取,因为设备 IO 速度比较慢,对于用户来说,这是一个等待的过程,当内核缓冲区有数据则复制到进程的空间

所以 IO 操作的流程可以分为两步,等待 IO 的操作条件具备,然后进行数据拷贝

IO 一般有三种,内存 IO 、网络 IO、磁盘 IO ,我们一般说的是后两者

例子:网络输入操作
在这里插入图片描述

重要五种的 IO 模型

阻塞 IO : 在内核将数据准备好之前, 系统调用会一直阻塞等待,什么事都不做,直到内核将数据准备好然后拷贝的用户空间,返回一个成功的指示,这种方式效率很低

在这里插入图片描述
非阻塞 IO : 如果内核还未将数据准备好, 系统调用仍然会直接返回, 并且返回 EWOULDBLOCK 错误码,在非阻塞 IO 中有一个弊端就是:轮询读写文件描述符,这对于 CPU 来说是一种浪费

如下图

信号驱动 IO : 首先在内核和进程之间会建立 SIGIO 的信号处理程序,当内核将数据准备好的时候, 使用 SIGIO 信号通知应用程序,这时候应用程序才会系统调用,进行 IO 操作,如下图

异步IO:由内核在数据拷贝完成后, 通知应用程序处理数据报(信号驱动 IO 是告诉应用程序何时可以开始拷贝数据).

IO多路转接/IO多路复用: 虽然看起来和阻塞 IO 类似. 实际上核心在于 IO 多路转接能够同时等待多个文件描述符的就绪状态.

IO 多路转接是多了一个 select 函数,select 函数有一个参数是文件描述符集合,对这些文件描述符进行循环监听,当某个文件描述符就绪时,就对这个文件描述符进行处理,其中,select 只负责等,recvfrom 只负责拷贝

IO 多路转接是属于阻塞 IO,但可以对多个文件描述符进行阻塞监听,所以效率较阻塞 IO 的高

任何 IO 过程中, 都包含两个步骤: 等待和拷贝. 在正常情况下等待的时间要比拷贝的时间长,所以提高效率最好的办法就是减少等待的时间

同步通信和异步通信

这个同步和线程中的同步不是一个概念,这里指的是:如果发出一个调用时,在没有得到结果之前,该调用就不返回,换句话说,就是由调用者主动等待这个调用的结果

当一个异步过程调用发出后,直接返回,所以调用者不会立刻得到结果; 而是在调用发出后,被调用者通过状态来通知调用者,或者通过回调函数处理这个调用

所以同步和异步的区别是请求发出后,是否需要等待结果,才能继续执行其他操作,同步是需要等待,而异步不需要

阻塞和非阻塞

阻塞和非阻塞关注的是程序在等待调用结果时的状态.

阻塞调用是指: 调用结果返回之前,当前线程会被挂起. 调用线程只有在得到结果之后才会返回

非阻塞调用指: 在不能立刻得到结果之前,该调用不会阻塞当前线程

阻塞线程意思就是当前线程被挂起,让出 CPU 资源,进入等待队列,等待下一次被调度

参考文章:https://www.cnblogs.com/mhq-martin/p/9035640.html


I/O 多路转接之 select

系统提供 select 函数用来实现多路复用输入输出模型,用户进行 select 系统调用时,会进入阻塞状态监视多个文件描述符的状态

这个状态有可读,可写,异常状态

函数原型
int select(int nfds, fd_set *readfds, fd_set *writefds,
fd_set *exceptfds, struct timeval *timeout);

第一个参数:需要监视的最大文件描述符+1

第二、三、四个参数:需要检测的可读,可写,异常文件描述符集合

第五个参数:用来设置等待的时间,如果不设置则会一直等待,直到某个文件描述符发生事件.如果在特定的时间内没有事件发生,则 select 返回 0

说明:select 第一个参数之所以要设置为最大文件描述符+1,是因为文件描述符从 0 开始,比如我们要检测文件描述符4 ,5 , 6 需要检测 7 次,也就是从0 到 6,否则 6 文件描述符没有检测到

select 特点、缺陷

可监控的文件描述符有上限,一般根据 fd_set 结构体大小决定的,比如 sizeof(fd_set) = 512 ,那么上限就是 512 * 8,因为 1 个字节占 8 位,每一个 bit 位表示一个文件描述符

将 fd 文件描述符加入 select 集合后,还需要使用一个数组来保存这些文件描述符,以便 select 返回后清空未发生事件的文件描述符,同时可以遍历出最大的文件描述符,计算出 select 第一个参数

每次调用 select 都要手动设置 fd 集合,比如你要监视读文件描述符,则输入读的参数,这样对用户来说不太友好,因为不方便

每次调用 select,都需要把文件描述符集合从用户态拷贝到内核态,开销太大,同时还需要遍历文件描述符

I/O 多路转接之 poll

在 select 的基础上,接口变得比较简单,采用事件结构的方式,将就绪文件描述符写入一个结构体,但并没有解决 select 效率低的问题

另外 poll 只能用于 Linux 下,无法跨平台使用,select 具有移植性

I/O 多路转接之 epoll (重要掌握)

是为处理大批量句柄而作了改进的 poll

主要有三个核心 apl 和两个数据结构(红黑树和链表)

在内核中创建 eventpoll 结构体

并返回一个文件描述符的操作句柄 ,size 决定 epoll 最多监控多少个描述符,默认忽略

int epoll_create(int size);

epoll 的事件注册函数

int epoll_ctl (int epfd, int op, int fd, struct epoll_event* event)

将被监听的文件描述符添加到红黑树结构中,或者对监听的事件进行修改和删除

epoll_ctl 对这棵树进行管理,红黑树的每个成员由描述符值 data 和文件描述符指向的文件表项组成

第一个参数:epoll 句柄
第二个参数:表示动作,比如向红黑数添加事件结构信息(EPOLL_CTL_ADD),从红黑树移除事件结构信息(EPOLL_CTL_DEL)、从红黑树修改事件结构信息(EPOLL_CTL_MOD)

第三个参数:需要监听的 fd

第四个参数:告诉内核需要监听什么事,比如描述符可读、可写、异常状态是被触发

收集在 epoll 监控事件中已经发生的事件

int epoll_wait (int epfd, struct epoll_event * events, int maxevents, int timeout);

第一个参数是 epoll 的句柄

第二个参数表示,epoll 将会把触发的事件写入 events 数组,内核只负责写入,但是不会分配内存

第三个参数 maxevents :返回的 events 的最大个数

第四个参数 timeout: 表示超时时间的毫秒(0 会立即返回,-1 是永久阻塞)

如果函数调用成功,返回对应 I/O 上已准备好的文件描述符数目,如返回 0 表示已超时, 返回小于 0 表示函数失败

epoll 工作原理

当某一个进程调用 epoll_create 时,Linux 内核会创建一个 eventpoll 结构体,在这个结构体中包含两个成员,一个是红黑树的根节点,一个是双向链表节点

当调用注册函数 epoll_ctl 的时候,会将用户指定事件挂载到红黑树上,也就是说红黑树上添加着 epoll 对象中所有的事件,之所以选择红黑树是因为其可以高效识别重复添加的事件;所有添加到 epoll 中的事件都会与设备驱动建立回调关系,只有响应的事件被触发,就会调用回调函数,把事件添加到双向链表中;

对于每一个事件,都会建立一个 epitem 结构体,对这个事件进行描述,比如红黑树节点信息,双向链表节点,期待发生的事件类型等

最后调用 epoll_wait 函数检查是否有事件发生时,只需要检查 event_poll 对象中的链表是否为空,如果不为空则把事件复制到用户态,同时将事件的数量返回给用户,操作复杂度为O(1),如果为空则进行等待.

epoll 和 select 、poll 比较

epoll 接口使用方便高效,不需要每次循环设置关注的文件描述符, 也做到了输入输出参数分离开,只需要一次将文件描述符拷贝到内存中,不像 select 每次循环都要进行拷贝

epoll 使用事件回调函数的机制,检测文件描述符是否就绪,将就绪的文件描述符添加到就绪队列中,epoll_wait 会直接访问就绪队列就知道哪些文件描述符就绪了,不需要向 select、poll 那样进行遍历,而且 epoll 对文件描述符的上限也没有限制

在这里插入图片描述
当把文件描述符传递给用户时,select、poll 还需要一次遍历检测哪些文件描述符就绪,对于 epoll 传入给用户的就是已经就绪的文件描述符,不需要经过遍历

epoll 两种触发方式

水平触发(LT)

当 epoll 检测到 socket 上事件就绪的时候, 可以不立刻进行处理. 或者只处理一部分,这也是 epoll 默认的处理方式

比如我们把一个 tcp socket 添加到 epoll 对象中,这个时候 socket 另一端被写入了 2k 的数据,当调用 epoll_wait 函数会返回,可能只会读取 1k 的数据,剩下的 1k 数据下一次读取时 epoll_wait 也会立刻返回,直到缓冲区数据被处理完,epoll_wait 就不会返回了

边缘触发(ET)

当 epoll 检测到 socket 上事件就绪时, 必须立刻处理.如上面的例子, 虽然只读了 1k 的数据, 缓冲区还剩 1k 的数据, 在第二次调用 epoll_wait 的时候 ,epoll_wait 不会再返回了,也就是说, 边缘触发的模式下, 文件描述符上的事件就绪后, 只有一次处理机会

对比 LT 和 ET

ET 的性能比 LT 性能更高,因为 epoll_wait 返回的次数少了很多

select 和 poll 其实也是工作在 LT 模式下. epoll 既可以支持 LT, 也可以支持 ET,在第 1 步将 socket 添加到 epoll 描述符的时候使用EPOLLET 标志,就会进入 ET 模式

Nginx 默认采用 ET 模式使用 epoll ,只支持非阻塞的读写,所以 ET 模式下的 epoll, 需要将文件描述设置为非阻塞进行轮询遍历. 这个不是接口上的要求, 而是 “工程实践” 上的要求

就像刚才的例子,如果 ET 模式使用阻塞的方式,那么第二次读取 1k 数据时,epoll_wait 就不会返回,因为只处理一次就绪事件,第二次只有在往进去写数据,才会触发事件

epoll 场景

对于多连接,且多个连接中只有一部分连接比较活跃时选用 epoll 比较合适,比如一个需要处理上万个客户端的 app 入口服务器, 这样的服务器就很适合 epoll

epoll 中的惊群问题

在多进程或多线程的环境下,我们为了提高程序的效率和稳定性,采用 epoll 的模式,让多个进程或线程同时在 epoll_wait 函数监听文件描述符,但当一个新的请求链接到来时,操作系统不知道唤醒哪个线程,就干脆一起唤醒,但是只有一个线程可以处理 accept 事件,其他线程都将失败,这种现象称为惊群效应,带来的结果就是资源的消耗和性能的影响

如何解决这个问题?

这种情况,不建议让多个线程同时在 epoll_wait 监听的 socket,而是让其中一个线程 epoll_wait 监听的 socket , 当有新的链接请求进来之后,由 epoll_wait 的线程调用 accept,建立新的连接,然后交给其他工作线程处理后续的数据读写请求,这样就可以避免了由于多线程环境下的 epoll_wait 惊群效应问题

举个栗子:就好比我们去星级酒店吃饭,门前一定有招待客人的服务生,但是它只是招待新到来的客户,至于客户进到酒店后,它只要把客户交到酒店内部的服务生就可以了,没必要自己去处理所有的事情

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