# IO模型


# IO模型

# ***5种 IO模型***

## ***从TCP发送信息流程开始讲起***

*要深入的理解各种 I/O 模型，那么必须先了解下产生各种IO的原因是什么，要知道这其中的本质问题那么我们就必须要知道一条消息是如何从一个人发送到另外一个人的*

*以两个应用程序通讯为例，我们来了解一下当“A”向"B" 发送一条消息，简单来说会经过如下流程*

1. *应用A将数据发送到TCP发送缓冲区*
2. *TCP发送缓冲区再把数据发送出去，经过网络传递后，数据会发送到服务器B的TCP接收缓冲区*
3. *B再从TCP接收缓冲区去读取属于自己的数据*

*这个流程我们了解后 我们就开始来进入主题*

## ***阻塞/非阻塞 IO模型***

*我们先来思考一个问题：*

 因为发送信息肯定是间断 他不会一直都会有信息，一直在发送，所以如果B的TCP接收缓冲区没有数据，B发送了一个接收数据的读取请求，那么这时候是会直接告诉B没有可读消息，还是让B一直等待直到有可读消息呢

### ***什么是阻塞IO***

*我们这个问题思考完 其实就已经明白了什么是阻塞IO*

***所谓阻塞IO就是当应用B发起读取数据申请时，在内核数据没有准备好之前，应用B会一直处于等待数据状态，直到内核把数据准备好了交给应用B才结束***

### ***什么是非阻塞IO***

*按照上面的思路，所谓非阻塞IO就是当应用B发起读取数据申请时，如果内核数据没有准备好会即刻告诉应用B，不会让B在这里等待*

## ***IO复用模型***

*我们依旧先来思考一个问题：*

*我们还是从 服务器B 要去从缓冲区读取数据的问题，如果在并发的场景下，可能会有N个人向服务器B发送消息，这种情况一般我们会开多个线程去处理不同的消息，每个线程都会自己调用 recvfrom 去读取数据*

*并发情况下服务器很可能一瞬间会收到几十上百万的请求，这种情况下应用B就需要创建几十上百万的线程去读取数据，同时又因为应用线程是不知道什么时候会有数据读取，为了保证消息能及时读取到，那么这些线程自己必须不断的向内核发送recvfrom 请求来读取数据*

*那么问题来了，这么多的线程不断调用 recvfrom 请求数据，先不说服务器能不能扛得住这么多线程，就算扛得住那么很明显这种方式是不是太浪费资源了，线程是我们操作系统的宝贵资源，大量的线程用来去读取数据了，那么就意味着能做其它事情的线程就会少*

![IO复用引出模型](https://raw.githubusercontent.com/vlicecream/cloudImage/main/data/202302201352591.jpg)

*有人就提出了一个思路，能不能提供一种方式，可以由一个线程监控多个网络请求（**我们后面将称为fd文件描述符，linux系统把所有网络请求以一个fd来标识**），这样就可以只需要一个或几个线程就可以完成数据状态询问的操作，当有数据准备就绪之后再分配对应的线程去读取数据，这么做就可以节省出大量的线程资源出来，这个就是IO复用模型的思路*

![IO复用模型](https://raw.githubusercontent.com/vlicecream/cloudImage/main/data/202302201359641.jpg)

*正如上图，IO复用模型的思路就是系统提供了一种函数可以同时监控多个fd的操作，这个函数就是我们常说到的select、poll、epoll函数，有了这个函数后，应用线程通过调用select函数就可以同时监控多个fd，select函数监控的fd中只要有任何一个数据状态准备就绪了，select函数就会返回可读状态，这时询问线程再去通知处理数据的线程，对应线程此时再发起recvfrom请求去读取数据*

*进程将一个fd或者多个fd传递给 select，阻塞在select操作上，select帮我们侦测多个fd是否准备就绪，当有fd准备就绪时，select返回数据可读状态，应用程序再调用recvfrom读取数据*

***复用IO的基本思路就是通过select或poll、epoll 来监控多fd ，来达到不必为每个fd创建一个对应的监控线程，从而减少线程资源创建的目的***

## ***信号驱动IO模型***

*我们继续来看一个问题：*

*select会一直轮询 这就代表着绝大多数的轮询都是无意义的 那我们能不能发出请求后 也别去管他了 数据准备好直接来通知我呢?*

*所以就衍生了信号驱动 IO模型*

*于是信号驱动IO不是用循环请求询问的方式去监控数据就绪状态，而是在调用sigaction时候建立一个SIGIO的信号联系，当内核数据准备好之后再通过SIGIO信号通知线程数据准备好后的可读状态，当线程收到可读状态的信号后，此时再向内核发起recvfrom读取数据的请求，因为信号驱动IO的模型下应用线程在发出信号监控后即可返回，不会阻塞，所以这样的方式下，一个应用线程也可以同时监控多个fd*

*首先开启套接口信号驱动IO功能，并通过系统调用sigaction执行一个信号处理函数，此时请求即刻返回，当数据准备就绪时，就生成对应进程的SIGIO信号，通过信号回调通知应用线程调用recvfrom来读取数据*

***IO复用模型里面的select虽然可以监控多个fd了，但select其实现的本质上还是通过不断的轮询fd来监控数据状态， 因为大部分轮询请求其实都是无效的，所以信号驱动IO意在通过这种建立信号关联的方式，实现了发出请求后只需要等待数据就绪的通知即可，这样就可以避免大量无效的数据状态轮询操作***

## ***异步IO***

*还是老样子，我们来看一个问题：*

*也许你一开始就有一个疑问，为什么我们明明是想读取数据，而却非得要先发起一个select询问数据状态的请求，然后再发起真正的读取数据请求,能不能有一种一劳永逸的方式，我只要发送一个请求我告诉内核我要读取数据，然后我就什么都不管了，然后内核去帮我去完成剩下的所有事情?*

*当然既然你想得出来，那么就会有人做得到，有人设计了一种方案，应用只需要向内核发送一个read 请求,告诉内核它要读取数据后即刻返回；内核收到请求后会建立一个信号联系，当数据准备就绪，内核会主动把数据从内核复制到用户空间，等所有操作都完成之后，内核会发起一个通知告诉应用，我们称这种一劳永逸的模式为异步IO模型*

***应用告知内核启动某个操作，并让内核在整个操作完成之后，通知应用，这种模型与信号驱动模型的主要区别在于，信号驱动IO只是由内核通知我们合适可以开始下一个IO操作，而异步IO模型是由内核通知我们操作什么时候完成***

***异步IO的优化思路是解决了应用程序需要先后发送询问请求、发送接收数据请求两个阶段的模式，在异步IO的模式下，只需要向内核发送一次请求就可以完成状态询问和数拷贝的所有操作。***

# ***IO多路复用机制***

## ***epoll V.S select***

***假想这么一个场景 你朋友来找你较量一场击剑，然后不知道寝室在哪 所以找了宿管***

- *select宿管带着你的朋友挨家挨户找，直到找到你*
- *epoll宿管，她会先记下每位同学的房间号，你的朋友来的时候只要告诉房间号即可*

***那如果来了10000个人 都要找你击剑，那么select效率更高还是epoll？***

- *select 如一个保姆照顾一群孩子，如果把孩子尿尿这件事必做 IO，那保姆就相当于问每一个孩子需要尿尿不*
- *epoll的机制下 保姆无需挨个询问孩子是否要尿尿，而是每个孩子若自己需要尿尿，主动站到事先约定好的地方，而保姆的职责就是看约定好的地方有无孩子。因此，epoll的这种机制，能够高效的处理成千上万的并发连接，而且性能不会随着连接数增加而下降*

|        | select                                        | epoll             |
|:------:|:---------------------------------------------:|:-----------------:|
| 性能     | 随着连接数的增加，急剧下降                                 | 随着连接数的增加，性能基本不会下降 |
| 连接数    | 连接数有限制-1024如果要处理超过1024，则需要修改FD_SETSIZE宏，并重新编译 | 连接数无限制            |
| 内在处理机制 | 轮询                                            | 回调callback        |
| 开发复杂性  | 低                                             | 中                 |

## select

***select 通过设置或检查存放fd标志位的数据结构进行下一步处理 这会带来问题：***

1. *单个进程可监控的fd数量被限制, 只允许1024，这些是由 FD_SETSIZE宏去控制的， 当然也可对其修改，然后重新编译内核，但性能可能受影响，这需要进一步测试。一般该数和系统内存关系很大*
2. *当 socket 较多的时候，每次select都要遍历FD_SETSIZE的socket，不管是否活跃都很浪费CPU的时间，若能给socket注册某个回调函数，当他们活跃时候，直接完成相关操作 即可避免轮询 这就是 **epoll**和 **kqueue***

***select 缺点***

1. *内核需要将消息传递到用户空间，都需要内核拷贝动作。需要维护一个用来存放大量fd的数据结构，使得用户控件和内核空间在传递该结构时复制开销大*
2. *每次调用select，都需把fd集合从用户态拷贝到内核态，fd很多时开销就很大*
3. *每次调用select，都需在内核遍历传递进来的所有fd*
4. *select支持的文件描述符数量太小，默认最大支持1024*
5. *主动轮询效率很低*

## poll

***和select类似，只是描述fd集合的方式不同，poll使用 pollfd 结构而非select的 fd_set 结构***

```cpp
struct pollfd {  int fd;  short events;  short revents;};
```

*管理多个描述符也是进行轮询，根据描述符的状态进行处理，**但是poll无最大文件描述符数量的限制***

*poll和select都有一个缺点 就是包含大量文件描述符的数组被整体复制于用户态和内核的地址空间之间，而不论这些文件描述符是否就绪，其开销也随着文件描述符数量增大而线性增大*

- *将用户态传入的数组拷贝到内核空间*
- 然后查询每个fd对应设备状态
  - *若设备就绪 在设备等待队列中加入一项继续遍历*
  - *若遍历完所有fd后，都没发现就绪的设备，挂起当前进程，直到设备就绪或主动超时，被唤醒后它又再次遍历fd。这个过程经历多次无意义遍历*

***无最大连接数量限制 基于链表存储 缺点：***

- *大量fd数组被整体复制于用户空间和内核空间中，而不管是否有意义*
- *若报告了fd后，没有被处理，则下次poll时会再次报告该fd*
- *链表查询时间复杂度为 O(n)*

***所以又有了 epoll***

## ***epoll***

*epoll模型修改主动轮询为被动通知，当有事件发生时，被动接收通知。所以epoll模型注册套接字后，主程序可做其他事情，当事件发生时，接收到通知后再去处理*

*可理解为**event poll**，epoll会把哪个流发生哪种I/O事件通知我们。所以epoll是事件驱动（每个事件关联fd），此时我们对这些流的操作都是有意义的。复杂度也降到O(1)*

```CPP
// 事件参数描述链接到文件描述符fd的对象
struct epoll_event {  __u32 events;  __u64 data;} EPOLL_PACKED;
```

### ***epoll触发模式***

- ***LT，默认的模式（水平触发）只要该fd还有数据可读，每次`epoll_wait` 都会返回他的事件，提醒用户程序去操作***
- ***ET，边缘触发***

*只会提示一次，直到下次再有数据流入之前都不会再提示，无论fd中是否还有数据可读。所以ET模式下，read一个fd时，一定要把它的buffer读完，即读到read返回值小于请求值或遇到EAGAIN错误*

*epoll使用“事件”就绪通知方式，通过`epoll_ctl`注册fd，一旦该fd就绪，内核就会采用类似回调机制激活该fd，`epoll_wait`便可收到通知*

### ***ET的意义***

*若用`LT`，系统中一旦有大量无需读写的就绪文件描述符，它们每次调用`epoll_wait`都会返回，这大大降低处理程序检索自己关心的就绪文件描述符的效率。 而采用`ET`，当被监控的文件描述符上有可读写事件发生时，`epoll_wait`会通知处理程序去读写。若这次没有把数据全部读写完(如读写缓冲区太小)，则下次调用`epoll_wait`时，它不会通知你，即只会通知你一次，直到该文件描述符上出现第二次可读写事件才通知你。这比水平触发效率高，系统不会充斥大量你不关心的就绪文件描述符*

### ***epoll的优点***

- *无最大并发连接的限制，能打开的FD上限远大于1024（1G内存能监听约10万个端口）*
- *效率提升，不是轮询，不会随FD数目增加而效率下降。只有活跃可用的FD才会调用callback函数 即Epoll最大优点在于它只关心“活跃”连接，而跟连接总数无关，因此实际网络环境中，Epoll效率远高于select、poll*
- *内存拷贝，利用mmap()文件映射内存加速与内核空间的消息传递；即epoll使用mmap减少复制开销。*
- *epoll通过内核和用户空间共享一块内存而实现*

*表面上看epoll的性能最好，但在连接数少且都十分活跃情况下，select/poll性能可能比epoll好，毕竟epoll通知机制需要很多函数回调*

*epoll跟select都能提供多路I/O复用。在现在的Linux内核里有都能够支持，epoll是Linux所特有，而select则是POSIX所规定，一般os均有实现*

### ***epoll提供的函数***

1. ***epoll_create***
   
   - *创建一个句柄*

2. ***epoll_ctl***
   
   - *注册要监听的事件类型*
   
   - *对于第一个缺点，epoll的解决方案在epoll_ctl.c，每次注册新事件到epoll句柄中时（在epoll_ctl中指定EPOLL_CTL_ADD），会把所有fd拷贝进内核，而非在epoll_wait时重复拷贝。epoll保证每个fd在整个过程中**只会拷贝一次**！*
   
   - ```cpp
     EPOLL_CTL_ADD：在文件描述符epfd所引用的epoll实例上注册目标文件描述符fd，并将事件事件与内部文件链接到fd
     EPOLL_CTL_MOD：更改与目标文件描述符fd相关联的事件
     EPOLL_CTL_DEL：从epfd引用的epoll实例中删除目标文件描述符fd。该事件将被忽略，并且可以为NULL
     ```

3. ***epoll_wait***
   
   - *等待事件的产生*
   
   - *对于第二个缺点，epoll解决方案不像select/poll每次都把current流加入fd对应的设备等待队列，而只在epoll_ctl时把current挂一遍（这一遍必不可少），并为每个fd指定一个回调函数*
     
     *当设备就绪，唤醒等待队列上的等待者时，就会调用该回调函数，而回调函数会把就绪fd加入一个就绪链表。*
     
     *epoll_wait实际上就是在该就绪链表中查看有无就绪fd（利用schedule_timeout()实现睡一会，判断一会的效果，和select实现中的第7步类似）。*

4. ***对于第三个缺点，epoll无此限制，其支持FD上限是最大可以打开文件的数目，一般远大于2048。1GB内存机器大约10万左右，具体数目可查看 cat /proc/sys/fs/file-max，这数目和系统内存关系很大***

## ***总结***

1. *select，poll，epoll都是I/O多路复用机制，即能监视多个fd，一旦某fd就绪（读或写就绪），能够通知程序进行相应读写操作。 但select，poll，epoll本质都是**同步I/O**，因为他们都需在读写事件就绪后，自己负责进行读写，即该读写过程是阻塞的，而异步I/O则无需自己负责进行读写，异步I/O实现会负责把数据从内核拷贝到用户空间*
2. *select，poll需自己主动不断轮询所有fd集合，直到设备就绪，期间可能要睡眠和唤醒多次交替。而epoll其实也需调用epoll_wait不断轮询就绪链表，期间也可能多次睡眠和唤醒交替，但它是设备就绪时，调用回调函数，把就绪fd放入就绪链表，并唤醒在epoll_wait中进入睡眠的进程。虽然都要睡眠和交替，但select和poll在“醒着”时要遍历整个fd集合，而epoll在“醒着”的时候只需判断就绪链表是否为空，节省大量CPU时间，这就是回调机制带来的性能提升*
3. *select，poll每次调用都要把fd集合从用户态往内核态拷贝一次，且要把current往设备等待队列中挂一次，而epoll只要一次拷贝，且把current往等待队列上挂也只挂一次（在epoll_wait开始，注意这里的等待队列并不是设备等待队列，只是一个epoll内部定义的等待队列）。这也能节省不少开销。*

