【Linux】IO操作

典型 IO 模型

IO 操作指的就是数据的输入输出操作;IO 过程可以分为两个步骤:等待 IO 就绪、数据拷贝

阻塞 IO

发起 IO 操作,若当前不具备 IO 条件,则等待直到条件满足完成 IO 后返回

在这里插入图片描述
优点:流程简单
缺点:资源利用率低,效率相对低下

非阻塞 IO

发起 IO 操作,若当前能够 IO ,则执行完 IO 操作后返回;若当前不具备 IO 条件则报错返回

在这里插入图片描述
优点:对资源利用率提高了
缺点:程序流程复杂–因为要进行循环操作;不是实时的

信号驱动 IO

定义 IO 信号处理方式,IO 就绪通过信号通知进程,然后发起 IO 调用

在这里插入图片描述
优点:对资源利用率更充分;IO 操作更实时
缺点:操作流程更加复杂–添加信号通知部分

异步 IO

发起异步 IO 操作,IO 的等待以及数据的拷贝都由系统完成,完成后通知进程

在这里插入图片描述
优点:对资源利用率提升;
确定:程序流程也更复杂了

常见问题

阻塞 vs 非阻塞
阻塞:发起一个操作,若当前操作条件不满足则一直等待
非阻塞:发起一个操作,若当前操作条件不满足则报错返回

阻塞与非阻塞关联:通常都是操作接口特性
阻塞与非阻塞区别:发起一个接口调用后,接口是否会立即返回

同步 vs 异步
同步:功能由进程自身来完成,且通常是串行化的
异步:功能并不由进程自身来完成,而是由系统完成的,完成不一定是串行的

同步与异步关联:通常用于讨论一个任务的完成流程
同步与异步区别:功能是否有当前执行流自身完成

异步阻塞:发起操作后, 功能由系统来完成,进程执行流自身等待系统完成
异步非阻塞:发起操作后,功能由系统来完成,操作会直接返回,并不会等待

多路转接模型

常用于高并发服务器中技术的使用
作用:针对大量描述符进行 IO 就绪事件监控

优点:
1:让进程能够仅针对就绪的描述符进行 IO 操作,提高了任务处理效率
2:避免进程因为未就绪描述符进行操作而导致阻塞

具体技术实现:select、poll、epoll

select 模型

IO 事件:可读事件、可写事件、异常事件

流程思想:
1:定义指定 IO 事件的描述符集合;

2:将需要对指定事件进行监控的描述符添加到指定集合中;

3:将事件的描述符集合拷贝到内核中,进行事件监控:
1)对集合中所有描述符进行遍历,若没有就绪则将描述符挂到内核的 IO 事件队列;
2)若监控过程中,有某个描述符就绪了所要监控的事件,则会唤醒进程的阻塞;
3)唤醒后,select 会再次遍历描述符集合,将集合中没有就绪的描述符移除

4:select 监控返回后,只需要判断哪个描述符还在集合中,哪个描述符就就绪了哪个事件;

5:进程可以根据就绪的不同事件对描述符进行不同的 IO 操作

接口介绍

1、定义集合:
fd_set set;

本质上这个集合是一个比特位图,默认拥有 1024 个比特位,取决于 __FD_SETSIZE;因此,select 对描述符进行 IO 事件监控是有最大描述符限制的

2、先初始化集合,然后将需要监控的描述符添加到集合中

初始化清空集合:
void FD_ZERO(fd_set *set);

将 fd 描述符添加到集合中:
void FD_SET(int fd, fd_set *set);

将 fd 描述符从 set 集合移除:
void FD_CLR(int fd, fd_set *set);

3、开始监控:
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);

nfds:将所有需要监控的集合中,最大描述符+1,提高监控遍历效率
readfds:可读事件的描述符集合
writefds:可写事件的描述符集合
exceptfds:异常事件的描述符集合
timeout:设置本次监控的阻塞时长; NULL-一直阻塞,直到描述符就绪或被信号打断; 0-非阻塞
返回值:返回实际就绪的描述符事件个数;出错返回 -1;0-监控超时

select 接口一旦返回,就意味着三个集合中,就只保留了就绪了指定事件的描述符

4、判断哪个描述符还在集合中,哪个描述符就绪了哪个事件
int FD_ISSET(int fd, fd_set *set);
返回值:非0-描述符还在集合中;0-描述符不在集合中

Demo:

  1 #include<stdio.h>
  2 #include<unistd.h>
  3 #include<stdlib.h>
  4 #include<time.h>
  5 #include<sys/select.h>
  6 
  7 /*对标准输入进行可读事件监控,有数据则读取,没有数据则阻塞*/
  8 
  9 int main()
 10 {
 11   fd_set rfds;    //定义可读事件集合
 12   while(1){
 13      int maxfd=0;    //集合中最大描述符个数 --- 因为只对标准输入进行可读事件监控,因此最大描述符 1 个
 14      struct timeval tv;    //定义时间结构体
 15      tv.tv_sec=3;        //阻塞 3s;因为 select每次监控都会重置阻塞时间为0,所有每次循环都需要重新设置
 16      tv.tv_usec=0;                                                                                                      
 17      
 18      FD_ZERO(&rfds);    //初始化集合        因为select每次监控都会重置描述符集合,因此每次循环都需要重新添加描述符到集合中
 19      FD_SET(0,&rfds); //将标准输入-0,添加到可读集合
 20      
 21      int nfds=select(maxfd+1,&rfds,NULL,NULL,&tv);   //开始监控,只有可读集合 rfds 
 22      if(nfds<0){        //监控失败
 23         perror("select error~!n");
 24         return -1;
 25      }else if(nfds==0){        //返回值为0,表示没有描述符存在
 26         perror("select timeout!n");        //等待超时
 27         continue;
 28      }
 29         
 30      //for 循环遍历集合
 31      for(int i=0;i<=maxfd;++i){
 32          if(FD_ISSET(i,&rfds)!=0){
 33              //判断 i 号描述符在可读集合中,说明就绪了可读事件
 34              char buf[1024]={0};
 35              read(i,buf,1024);          //从 i 号描述符中读取数据放入 buf
 36              printf("buf:[%s]n",buf);
 37          }   
 38          //else if(FD_ISSET(i,&efds))  {
 39                //判断 i 号描述符在可写事件中,说明就绪了可写事件
 40          //} 
 41      }   
 42   }            
 43   return 0;
 44 }    

运行结果:
在这里插入图片描述

将 select 应用在 TCP 服务器搭建上

搭建一个 TCP 服务器,会涉及到服务器为每一个客户端都建立一个新的套接字进行通信,需要对大量的描述符进行 IO 操作;之前介绍 TCP 服务器时候的解决方案是:使用多执行流来处理 – 为每一个客户端的通讯都创建执行流

这里,我们可以使用 多路转接模型+线程池 进行应用

思想:
封装一个 Select 类,每一个实例化的对象,都是一个能够针对大量描述符进行 IO 事件监控的对象 添加链接描述

在这里插入图片描述

封装 TCP:添加链接描述

客户端代码:添加链接描述

服务端代码:添加链接描述

在这里插入图片描述

select 总结:
优点:遵循 posix 标准,跨平台移植性好; 监控超时可以细微到微妙
缺点:
1.能够监控的描述符数量是有上线限制的 — 取决于 _FD_SETSIZE,默认1024;
2.监控过程中需要多次遍历描述符集合,因此监控的描述符越多,性能就越低;
3.因为每次监控都会修改描述符集合,因此每次监控都需要重新条件描述符到集合中;
4.监控返回的是就绪的描述符集合(位图),因此监控调用返回后,无法直接针对就绪的描述符进行操作,需要遍历一遍描述符看哪个还在集合中才能确定是否就绪了事件

高并发服务器中,有一种并发模型 :reactor 模型

思想:使用多路转接模型对大量描述符进行事件监控,谁触发了事件就处理谁

分类:
1.单 reactor 单线程 :在一个线程中,进行事件监控以及事件处理;
2.单 reactor 多线程 :在一个线程中进行 reactor 事件监控,触发事件后交给其他线程进行事件处理;
3.多 reactor 多线程 :在一个线程中进行新连接到来事件监控,有事件触发则获取新建连接,将新建连接分发给其他 reactor 线程,其他的 reactor 进行描述符的事件监控以及 IO 操作

在这里插入图片描述

poll 模型

操作流程:

1.定义一个事件结构体数组

struct poollfd{
	int fd;     //要监控的文件描述符
	short events;    //想要监控的事件,POLLIN-可读,POLLOUT-可写
	short revents;   //监控返回后,存储实际就绪的事件
}

//定义事件结构体数组
struct pollfd fds[MAX];

2.若哪个描述符需要监控什么事件,就在数组中进行设置

fds[0].fd=0;
fds[0].events=POLLIN;      //对标准输入描述符进行可读事件监控
fds[1].fd=1;
fds[1].events=POLLOUT;  //对标准输出描述符进行可写事件监控

3.开始监控
原理:将数组中有效数据拷贝到内核中,进行多次轮询遍历

第一次遍历:判断有没有就绪的事件,没有则挂起到监控队列中;
第二次遍历:进程的阻塞被唤醒后进行遍历,对每个元素的 revents 设置实际就绪的事件

int poll(struct pollfd*fds,nfds_t maxevents,int timeout);

fds:定义的时间结构体数组首地址
maxevents:数组中有效元素个数
timeout:监控阻塞的超时时间,以毫秒为单位

返回值:>0 表示实际就绪的事件个数; ==0 表示超时; <0 表示出错了

4.调用返回后,遍历事件结构体数组,根据 revents 成员确定描述符是否就绪了某个事件,进而对描述符进行操作

Demon:


#include<stdio.h>
#include<string.h>
#include<unistd.h>
#include<poll.h>

#define MAX_POLL_SIZE 10

int main()
{
  //定义事件结构体数组
  struct pollfd fds[MAX_POLL_SIZE];

  //添加要监控的描述符事件信息
  fds[0].fd=0;      //标准输入描述符
  fds[0].events=POLLIN;  //监控可读事件

  while(1){
    int ret=poll(fds,1,3000);     //超时时间为 3000 毫秒 --- 3s
    if(ret<0){
      perror("poll error!n");
      continue;
    }
    else if(ret==0){
      printf("poll timeout!n");
      continue;
    }
    int i=0,valid_count=1;       //只监控可读事件,因此有效监控个数 valid_count=1
    for(i=0;i<valid_count;++i){
      if(fds[i].revents & POLLIN){    //就绪可读事件
         char buf[1024]={0};
         read(fds[i].fd,buf,1023);     //从 fds[i].fd 描述符中读取数据到 buf 中
         printf("buf:[%s]n",buf);
      }else if(fds[i].revents & POLLOUT)  {
       //就绪可写事件
         printf("POLLOUT EVENTS!n");  
      }
    }
  }
  return 0;
}

运行结果:

在这里插入图片描述

poll 总结:

优点:
1.使用事件结构体替代了事件集合,相较于 select 操作,简便性提高了很多;
2.所能监控的描述符数量不在上限限制;

缺点:
1.每次监控需要将信息拷贝到内核;
2.监控原理涉及到多次对事件数组的遍历,因此性能会随着描述符的增多而下降;
3.每次监控完毕后,依然需要遍历整个事件数组才能确定哪个描述符就绪了哪个事件

events = POLLIN | POLLOUT; 对两个事件同时监控采用 |

epoll 模型

操作流程:
1.在内核中创建 epoll 句柄 eventpoll 结构

int epoll_create(int size);

size: 所能监控的描述符上限 ,在Linux 2.6.8 后被忽略,但必须大于 0
返回值:返回 epoll 描述符;出错返回 -1

struct eventpoll{
	...
	list_head rdllist;   //双向链表
	rbtree rbr;     //红黑树
	...
}

2.向内核的句柄中,添加/移除/修改所要监控的描述符及其对应的事件结构

int epoll_ctl(int epfd,int op,int fd,struct epoll_event* ev);

epfd: epoll_create 返回的 epoll 描述符
op: 对 epoll 要进行的操作:EPOLL_CTL_ADD / EPOLL_CTL_DEL / EPOLL_CTL_MOD
fd: 要操作的描述符,对 fd 描述符进行 op 操作

struck epoll_event* ev;   //对描述符要进行操作的详细信息


struct epoll_event{
	uint32_t events;  //想要监控的事件以及监控后存放实际就绪的事件
	union{        //可以监控的事件 : EPOLLIN-可读,EPOLLOUT-可写
      void* ptr;
      int fd; 
    }data;        //额外信息
};

3.开始监控

epoll 的监控是一个异步阻塞操作

发起监控调用是为了告诉系统,可以开始监控了,监控由系统完成 (而系统内部为 epoll 的每个描述的就绪事件挂了一个回调函数)
回调函数功能:描述符一旦就绪了指定事件,将事件信息拷贝一份到 rdllist 中,其实 rdllist 双向链表的作用:存放就绪的描述符对应的事件结构

一旦系统监控有描述符就绪了,则唤醒进程的阻塞,进程一旦被唤醒,查看 rellist 双向链表中是否有数据就可以确定是否有描述符就绪

监控调用返回的数据就是一个事件结构体数组 – 就绪的描述符对应的事件

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

epfd: epoll 描述符
evs: epoll_event 结构体数组的空间首地址,接收就绪事件
maxevents: 数组的最大元素个数,也表示了当前想要获取的最大事件个数
timeout: 要设置的监控超时时间 -- 以毫秒为单位
返回值: >0 实际就绪的事件个数;==0 超时 ; <0 出错了

在这里插入图片描述

封装一个 Epoll 类

在这里插入图片描述
将封装的 epoll 应用于 TCP 通信的操作与 select 应用相同,感兴趣可以自己琢磨琢磨,这里就不放代码咯~

epoll 事件触发方式

水平触发:select 与 poll 只有水平触发,epoll 默认水平触发
可读:缓冲区中数据大小小于高水平标记 (默认1字节)就会触发可读事件
可写:缓冲区中剩余空间大小小于高水平标记,就会触发可写事件
思想:只要满足触发条件就会触发对应事件

边缘触发: EPOLLET
可读:每当套接字有新数据到来时,则会触发一次事件
可写:缓冲区剩余空间从无到有的时候,才会触发一次事件
思想:尽量让用户在一次事件触发中,将能处理的数据都处理完毕,尽量减少事件触发次数,减少运行态切换次数

因为有新数据到来才触发一次事件,因此若一次事件触发后的处理中没有将所有数据进行处理,则在下一次新数据到来前,这些剩余数据都得不到处理

场景:http请求接收 – 在一次请求的接收处理中,发现缓冲区中数据不足以进行一次处理,取出来则需要额外存储,不取出来则水平触发就会一直触发可读事件,这种情况下希望能够在有新数据到来时再去进行数据处理,则使用边缘触发。

Q:如何将数据在一次处理中全部取出进行处理?

若想要将缓冲区中数据全部取出就只能循环取出
但循环读取数据,在套接字 recv 时候,有可能因为 socket 没有数据而阻塞

解决:将套接字的阻塞属性设置为非阻塞
将属性进行设置之后,则套接字的所有操作将变为非阻塞操作

int fcntl(int fd,int op,int arg);

op:F_GETFL 获取文件访问属性以及状态标志
    F_SETFL 设置文件的访问属性或状态标志 -- O_NONBLOCK--非阻塞


int flag=fcntl(fd,F_GETFL,0);  //F_GETFL 获取文件原有属性--第三个参数被忽略
fcntl(fd,F_SETFL,flag|O_NONBLOCK); //在原有属性基础上添加非阻塞属性

epoll 总结:

优点:
1.所能监控的描述符没有数量上限
2.监控性能并不会随着描述符的增多而下降
3.直接返回就绪描述符对应的事件结构,减少外界空遍历
4.描述符监控的事件信息,只需要向内核中添加一次,不需要每次监控都添加

缺点:
跨平台移植性不好,只能在类unix平台下使用

Q:select 、poll、epoll 哪个好?

不管是哪种模型,多路转接模型针对的都是对大量描述符进行 IO 事件监控,但是同一时间少量活跃的场景
若活跃连接也较多,则一定要搭配多执行流进行处理,充分利用系统资源

相较之下,select & poll 比较适用于单个描述符的事件监控以及超时管理,而 epoll 适用于大量描述符的事件监控场景。

在这里插入图片描述