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
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 操作
操作流程:
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
#include
#include
#include
#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; 对两个事件同时监控采用 |
操作流程:
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 适用于大量描述符的事件监控场景。