一、IO的本质
IO = 等 + 拷贝
等
- 读事件就绪 : 内核缓冲区内有数据
- 写事件就绪:发送缓冲区内有空间
什么叫高效IO
减小等待的比重
二、五种IO模型
例子:钓鱼

我们将钓鱼的步骤抽象 等+钓
现在有五个人在钓鱼
- 张三:鱼竿不动,我不动
- 李四:检测鱼竿,没有就绪就立马返回做其他的事
- 王五:铃铛一响,我就钓
- 赵六:使用一百个鱼竿,循环检测是否有鱼竿在动
- 田七:花钱让雇人钓鱼
他们分别是阻塞式IO,非阻塞式IO,信号驱动IO,多路转接式IO,异步IO
谁的钓鱼/IO效率最高
赵六/多路转接式IO
阻塞式IO和非阻塞式IO的区别
在等的期间,会把等待的时间利用上,做其他的事情
异步IO和同步IO的区别
- 异步 IO:你只需要 "发起请求",然后就可以去做别的事了,不用管 IO 是怎么等数据、怎么拷贝的,等操作系统把所有事情都做完,它会主动通知你结果。
- 同步 IO:只要你需要参与 "等待数据就绪" 或者 "数据拷贝" 中的任何一步,哪怕只是等一下,都属于同步 IO。
阻塞IO
在内核将数据准备好之前,系统调用会一直等待,所有的套接字,默认都是阻塞方式

非阻塞IO
如果内核还未将数据准备好,系统调用仍然会直接返回,并且返回EWOULDBLOCK错误码

信号驱动IO
内核将数据准备好的时候,使用SIGIO信号通知应用程序进行IO操作

IO多路转接
虽然从流程图上看起来和阻塞IO类似,实际上最核心在于IO多路转接能够同时等待多个文件描述符的就绪状态

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

如何设置阻塞和非阻塞IO
可以通过设置fd的状态

fcntl函数有5种功能

我们这里使用它的第三种方式

如下图这份代码我们不输入数据的时候一直在报错但是都条件报错不属于真的出错了

我们知道如果读条件不满足errno会被设置为EAGAIN或者EWOULDBLOCK

三、select
是什么?核心定位?解决什么问题
selct是一个多路复用,多路转接的一种具体方案
一次等待多个fd,任意一个或者多个fd就绪,都会通知fd

返回值
>0 : 有多少个fd就绪
==0 : 超时,没有就绪,也没有报错
<0 : select自己调用出错
timeout

NULL: 阻塞等待
{0,0} : 非阻塞等待
{5,0} : 5s内阻塞,到了5s timeout一次
int nfds
最大fd值+1(因为会轮询判断)
fd_set
文件描述符集(位图结构)
readfds为例
输入的时候:用户告诉内核,内核需要帮我关心fd_set集合中上那种fd上面的读事件
比特位的位置:标识文件描述符编号
比特位的而内容:是否关心
返回(输出的时候):内核告诉用户,用户让我关心的fd_set集合中,那些fd上面的读事件已经就绪了 比特位的位置:标识文件描述符编号比特位的内容:是否就绪
- 细节1:select能同时等待多个fd
- 细节2:fd上面的事件通常有那些:读,写,异常
- 细节3:4个参数,即是输入,又是输出
- 细节4:fd_set是一个数据类型,大小固定,比特位个数必须固定,所以select能够管理fd总数,一定是有上限的
- 细节5:我们通过函数改变位图

代码演示
cpp
#pragma once
#include <iostream>
#include <string>
#include <memory>
#include <sys/select.h>
#include "Logger.hpp"
#include "InetAddr.hpp"
#include "Socket.hpp"
const static int gsize = sizeof(fd_set)*8;
const static int gdefaultfd = -1;
class SelectServer
{
public:
SelectServer(uint16_t port)
:_listensock(std::make_unique<TcpSocket>())
{
_listensock->BuildListenSocketMethod(port);
for(int i = 0; i<gsize; i++)
{
fd_array[i] = -1;
}
fd_array[0] = _listensock->sockfd();
}
void Accpter()
{
InetAddr clientadder;
int sockfd = _listensock->Accpet(&clientadder);
if(sockfd >0)
{
LOG(Loglevel::INFO) << "get new sockfd" << sockfd << " client adder:" <<clientadder.ToString();
int pos = 0;
for(; pos<gsize; pos++)
{
if(fd_array[pos]==gdefaultfd)
{
fd_array[pos] == sockfd;
break;
}
}
if(pos == gsize)
{
LOG(Loglevel::WARNING) << "server is full";
close(sockfd);
}
}
}
void Recver(int index)
{
int sockfd = fd_array[index];
char buffer[1024];
ssize_t n = recv(sockfd,buffer,sizeof(buffer)-1,0);
if(n>0)
{
buffer[n]=0;
std::cout << "client say ###" << buffer << std::endl;
std::string echo_string = "server echo#";
echo_string += buffer;
send(sockfd,echo_string.c_str(),echo_string.size(),0);
}
else if(n==0)
{
LOG(Loglevel::INFO) << "client quit, me too" << fd_array[index];
fd_array[index] = gdefaultfd;
close(sockfd);
}
else
{
LOG(Loglevel::WARNING) << "recv error:" << fd_array[index];
fd_array[index] = gdefaultfd;
close(sockfd);
}
}
void EventDispatcher(fd_set &rfds)
{
for(int i = 0; i<gsize; i++)
{
if(fd_array[i]==gdefaultfd)
continue;
if(FD_ISSET(fd_array[i],&rfds))
{
if(fd_array[i]==_listensock->sockfd())
{
Accpter();
}
else{
Recver(i);
}
}
}
}
void Run()
{
while(true)
{
struct timeval timeout = {0,0};
int maxfd = gdefaultfd;
fd_set rfds;
FD_ZERO(&rfds);
for(int i = 0; i<gsize; i++)
{
if(fd_array[i] == gdefaultfd)
continue;
FD_SET(fd_array[i],&rfds);
if(fd_array[i]>maxfd)
{
maxfd = fd_array[i];
}
LOG(Loglevel::DEBUG) << "添加fd:" << fd_array[i];
}
int n = select(maxfd+1,&rfds,nullptr,nullptr,nullptr);
switch(n)
{
case 0:
LOG(Loglevel::DEBUG) << "timeout...." << timeout.tv_sec << ":" << timeout.tv_usec;
break;
case -1:
LOG(Loglevel::ERROR) << "select error";
break;
default:
//一定有事件发生
EventDispatcher(rfds);
break;
}
}
}
~SelectServer()
{}
private:
std::unique_ptr<Socket> _listensock;
int fd_array[gsize];
};
我们如果只读取不处理就会一直通知我

select 的特点
- 可监控的文件描述符个数取决于sizeof(fd_set)的值。我这边服务器上sizeof(fd_set)=512,每bit表示一个文件描述符,则我服务器上支持的最大文件描述符是512*8=4096。
- 将fd加入select监控集的同时,还要再使用一个数据结构array保存放到select监控集中的fd,
- 一是用于在select返回后,array作为源数据和fd_set进行FD_ISSET判断。 二是select返回后会把以前加入的但并无事件发生的fd清空,则每次开始select前都要重新从array取得fd逐一加入(FD_ZERO最先),扫描array的同时取得fd最大值maxfd,用于select的第一个参数。
select 缺点
- 每次调用select, 都需要手动设置fd集合, 从接口使用角度来说也非常不便
- 每次调用select,都需要把fd集合从用户态拷贝到内核态,这个开销在fd很多时会很大
- 同时每次调用select都需要在内核遍历传递进来的所有fd,这个开销在fd很多时也很大
- select支持的文件描述符数量太小
四、poll
poll定位
是一个多路复用,多路转接的一种具体方案,一次等待多个fd 任意一个或者多个fd就绪,通知用户,通过等待多个fd(手段),通知用户那些fd就绪了(目的)

返回值
>0 : 有多少个fd就绪
==0 : 超时,没有就绪,也没有报错
<0 : poll自己调用出错

short events short revents 输入输出参数分离的,解决了输入输出参数需要被重置的问题
调用poll,传递struct pollfd[]
1、输入数组的时候,用户告诉内核,你要帮我关心那些fd上的那些事
2、poll成功返回的时候,内核告诉用户,你让我关心的那些fd上面的那些事情,已经发生了


nfds
fds 数组的长度
timeout
poll函数的超时时间,单位是毫秒
五、epoll
epoll是为处理大批句柄而做了改进的poll
定位
是一个多路复用,多路转接的一种具体方案,一次等待多个fd,任意一个或者fd就绪,等待多个fd(手段),通知用户那些fd就绪了(目的)
epoll的相关系统调用
epoll_create

创建一个epoll句柄
- size 参数是被忽略的
- 用完之后,必须调用close关闭
- 返回值是一个文件描述符
epoll_ctl

epoll的事件注册函数
epoll的事件注册函数与select()不同,select()是在监听事件时告知内核要监听的事件类型,而该函数是预先注册需要监听的事件类型。
第一个参数为epoll_create()的返回值(即epoll的句柄);
第二个参数表示动作,通过三个宏来指定;
第三个参数是需要监听的文件描述符(fd);
第四个参数用于告知内核需要监听的具体事件类型。
第二个参数的取值包括:
EPOLL_CTL_ADD:将新的fd注册到epfd中;
EPOLL_CTL_MOD:修改已注册的fd对应的监听事件;
EPOLL_CTL_DEL:将一个fd从epfd中删除。

events可以的值
EPOLLIN:表示对应的文件描述符可以读(包括对端 SOCKET 正常关闭);
EPOLLOUT:表示对应的文件描述符可以写;
EPOLLPRI:表示对应的文件描述符有紧急的数据可读(这里应该表示有带外数据到来);
EPOLLERR:表示对应的文件描述符发生错误;
EPOLLHUP:表示对应的文件描述符被挂断;
EPOLLET:将 EPOLL 设为边缘触发 (Edge Triggered) 模式,这是相对于水平触发 (Level Triggered) 来说的;
EPOLLONESHOT:只监听一次事件,当监听完这次事件之后,如果还需要继续监听这个 socket 的话,需要再次把这个 socket 加入到 EPOLL 红黑树里。

epoll_wait

- 参数events是分配好的epoll_event结构数组
- epoll将会把将会发生的事件赋值到events数组中(events不可以是空指针,内核只负责把数据复制到这个events数组中,不会去帮助我们在用户态中分配内存)
- maxevents告述内核这个events有多大,这个maxevents的值不能大于创建epoll_create时的size
- 参数timeout 是超时时间(毫秒,0会立即返回,-1是永久阻塞)
- 如果函数调用成功,返回对应I/O上已准备好的文件描述符数目
- 返回值同poll
六、epoll工作原理
底层逻辑
底层创建了一棵红黑树

当某一进程调用epoll_create方法时,Linux内核会创建一个eventpoll结构体
cpp
struct eventpoll{
....
/*红⿊树的根节点,这颗树中存储着所有添加到epoll中的需要监控的事件*/
struct rb_root rbr;
/*双链表中则存放着将要通过epoll_wait返回给⽤⼾的满⾜条件的事件,就绪列表*/
struct list_head rdlist;
....
}
每一个epoll对象都有一个独立的eventpoll结构体,通过epoll_ctl向epoll对象中添加进来的节点
这些事件都会挂载在红黑树中,如此,重复添加的事件就可以通过红黑树而高效的识别出来(红黑树的插入时间效率logn)
而所添加到epoll中的事件都会设备驱动程序建立回调关系,也就是说当响应的事件发生的时候都会调用这个回调方法
这个回调方法在内核中叫ep_poll_callback,它会将发生的事件添加到rdist双链表中
在epoll中,对于每一个事件都会建立一个epitem结构体
cpp
struct epitem{
struct rb_node rbn;//红⿊树节点
struct list_head rdllink;//双向链表节点
struct epoll_filefd ffd; //事件句柄信息
struct eventpoll *ep; //指向其所属的eventpoll对象
struct epoll_event event; //期待发⽣的事件类型
}
当调用epoll_wait检查是否有事件发生时,只需要检查eventpoll对象中的rdlist双链表是否epitem元素
如果rdlist不为空,则把发生的事件复制到用户态,同时将事件数量返回给用户,这个操作事件复杂度是O(1)

回调机制在哪
struct epitem中存在文件描述符表,文件描述符指向structfile ,structfile中有一个private_data指向struct_sock* 成员,中包含回调函数

为什么epoll_creat会有文件描述符
struct file 中的private_data指向struct epoll
基于epoll的IO代码
cpp
#pragma once
#include <iostream>
#include <memory>
#include <sys/epoll.h>
#include "InetAddr.hpp"
#include "Socket.hpp"
static const int gsize = 64;
class echoepollserver
{
public:
echoepollserver(uint16_t port):_listensock(std::make_unique<TcpSocket>())
{
_listensock->BuildListenSocketMethod(port);
epfd = epoll_create(128);
struct epoll_event ev;
ev.events = EPOLLIN;
ev.data.fd = _listensock->sockfd();
epoll_ctl(epfd,EPOLL_CTL_ADD,_listensock->sockfd(),&ev);
}
void Accept()
{
InetAddr client;
int n = _listensock->Accpet(&client);
if(n>0)
{
LOG(Loglevel::INFO) << "获取一个新链接, fd:" << n << "客户端地址是" << client.ToString();
struct epoll_event ev;
ev.events = EPOLLIN | EPOLLET;
ev.data.fd = n;
epoll_ctl(epfd,EPOLL_CTL_ADD,n,&ev);
LOG(Loglevel::INFO) << "添加新链接到epoll中:" << n;
}
else
{
LOG(Loglevel::ERROR) << "accpet error";
}
}
void Recv(int sockfd)
{
char buffer[1024];
int n = recv(sockfd,buffer,sizeof(buffer)-1,0);
if(n>0)
{
buffer[n] = 0;
std::cout << " client say @" << buffer ;
std::string echo_string = "server say @@";
echo_string += buffer;
send(sockfd,echo_string.c_str(),echo_string.size(),0);
}
else if(n==0)
{
LOG(Loglevel::INFO) << "client quit, me too, fd is:" << sockfd;
int n = epoll_ctl(epfd,EPOLL_CTL_DEL,sockfd,nullptr);
close(sockfd);
}
else
{
LOG(Loglevel::INFO) << "recv error, fd is:" << sockfd;
int n = epoll_ctl(epfd,EPOLL_CTL_DEL,sockfd,nullptr);
close(sockfd);
}
}
void Dispatcher(int num)
{
for(int i = 0; i<num; i++)
{
int fd = _revs[i].data.fd;
int event = _revs[i].events;
if(fd == _listensock->sockfd())
{
Accept();
}
if(event|EPOLLIN)
{
Recv(fd);
}
}
}
void Start()
{
int timeout = -1;
while (true)
{
int n = epoll_wait(epfd, _revs, gsize, -1);
switch (n)
{
case 0:
LOG(Loglevel::DEBUG) << "time out ..." << "epfd: " << epfd;
break;
case -1:
LOG(Loglevel::ERROR) << "epoll_wait error" << "epfd" << epfd;
break;
default:
Dispatcher(n);
break;
}
}
}
private:
std::unique_ptr<Socket> _listensock;
int epfd;
struct epoll_event _revs[gsize];
};
epoll的ET和LT工作模式

我们发现如果我们一直不处理数据数据会一直提醒我
例子
张三和李四送快递, 张三只要你没有来取快递,每隔一段时间就会给你打一个电话,而李四只会给你打一次电话,你不去他也不管你,知道你的快递增多才会再次打给你,张三就是水平工作模式,李四就是边缘触发模式
水平触发Level Triggered工作模式
- 当epoll检测到socket上事件就绪的时候,可以不立刻进行处理,或者只处理一部分。
- 如上⾯的例⼦,由于只读了1K数据,缓冲区中还剩1K数据,在第⼆次调⽤epoll_wait时,epoll_wait仍然会⽴刻返回并通知socket读事件就绪。
- 直到缓冲区上所有的数据都被处理完,epoll_wait才不会⽴刻返回。 ⽀持阻塞读写和⾮阻塞读写
边缘触发模式Edge Triggered工作模式
- 当epoll检测到socket上事件就绪时, 必须⽴刻处理.
- 如上⾯的例⼦, 虽然只读了1K的数据, 缓冲区还剩1K的数据, 在第⼆次调⽤ epoll_wait 的时候, epoll_wait 不会再返回了.
- 也就是说, ET模式下, ⽂件描述符上的事件就绪后, 只有⼀次处理机会.
- ET的性能⽐LT性能更⾼( epoll_wait 返回的次数少了很多). Nginx默认采⽤ET模式使⽤epoll.
- 只⽀持⾮阻塞的读写
ET和LT模式相比,谁的效率更高
ET ---》从无到有,从有到多,-》要求程序员必须一次把本轮数据全部读取完毕
为什么ET工作模式只支持非阻塞读写
为了保证本轮将数据全部读完,必须循环度,即使最后一次读取完毕,也要读确保完全读取,这是如果时非阻塞的读取,fd会阻塞,recv会被卡住