在硬件角度,从外设向内存里读取数据就是input,从内存向外设写入数据就叫做output,输入输出数据的角色通常是进程或线程。网络的本质也是IO。
网络读取时,数据在网卡上,当外设上有数据时,外设会向CPU发中断(可以理解为一种光电信号),CPU就会识别到硬件已经准备好了,这里会有一个中断号,每一个中断号都对应一个中断方法(由操作系统的网卡驱动预装的),然后CPU就会执行对应的方法,比如读取数据。保存中断号与中断方法映射关系的表叫做中断向量表。
在网络中。收到的数据就是报文,因为系统可能收到大量的报文,所以内核就要建立相应的数据结构对收到的报文进行管理,其伪代码如下:
struct sk_buffer{
char* mac_header;
char* net_header;
char* tcp_header;
char buffer[1024];
struct* next;
}
把数据直接拷贝进buffer,有几个报文,就有几个sk_buffer,然后用队列或列表的形式组织起来,组成一个维护大量报文的数据结构。系统在进行报文解析时,就是通过每一层的指针指向的buffer的位置,分别提取出每一层的报头,分用的过程其实buffer没变,只需要更改指针操作,用指针指向不同的区域然后进行数据分析,当识别到tcp时,把他的数据拷贝到接收缓冲区里,这个报文就被收到了。
IO一般是分两步进行的:1.等待IO就绪,2.拷贝IO数据到内核或外设
IO=等+数据搬迁
等就是说当我要从缓冲区里读数据时,只有缓冲区有数据我才可以读,这叫做读事件就绪,而要向里写的时候,只有缓冲区有空间我才能写,这叫做写事件就绪,等就是等这些IO条件就绪
IO时真正有效的一步是拷贝,这一步才是真正在工作的,真正高效的IO过程就是在特点的时间段内,减少等的比重,增加拷贝的比重。
其中阻塞,非阻塞,信号驱动三种方法的IO效率其实是一样的,对IO来说,他们都是经过了等+拷贝的过程,其中非阻塞和信号驱动只是在等的时候可以去做别的事,可以说是增加了做事的效率,但是IO的效率没变,只有多路转接才是真正提高了IO效率,因为它一次性是在等待多个IO,那么就相当于把等的时间进行了重叠。
前四种方法都需要自己等,就绪时需要自己把数据拷贝上来,这种IO就叫做同步IO。而不需要自己等,拷贝,直接拿到最终结果叫做异步IO
在内核把数据准备好之前(以tcp为里,数据从协议栈自底向上,到达tcp协议的接收缓冲区里),系统调用会一直等待,等待和拷贝期间都不返回。
执行阻塞IO的是你的进程,阻塞在应用层的现象就是进程卡住了,而其实是在等待数据就绪。阻塞本质是进程状态设置为S or T or D,放入等待队列中,当等待数据就绪(某种事件就绪),操作系统就会把你从等待队列唤醒,然后执行拷贝。被挂起的目的就是等待数据就绪。
如果操纵系统还没有准备好,系统调用仍然会直接返回,并且返回EWOULDBLOCK错误码
所谓的非阻塞轮询的本质是在做事件就绪的检测工作,事件就绪就是底层有数据。检测是否有数据这个具体操作是通过操作系统的接口做的,而检测则是由用户发起的。
当数据就绪了,操作系统会向进程发送SIGIO(29号)信号,那么进程执行时就可以建立SIGIO信号处理程序,注册对该信号的处理函数,信号的产生本身就是异步的过程,所以等待时进程可能在执行其他内容,当数据就绪了,操作系统就会在进程的pending位图中写入该信号,进程检测到信号到达后就会执行信号处理函数比如recvfrom,把数据考贝到用户。
信号驱动也是同步IO的一种,虽然信号的产生是异步的,但是IO的过程是同步的,虽然数据没有就绪是进程在做其他事,但是这也是一种等待,因为进程不可以在这时候退出,更重要的是数据就绪时进程需要自己把数据拷贝到用户,也就是自己参与拷贝。所谓的同步就是进程自己是否知道数据就绪,进程是否参与数据拷贝。
异步IO是系统把数据准备就绪之后才通知进程,进程会使用相应的异步IO的接口,直接读取数据,然后直接返回,不需要检测,挂起,等待,进程要做的就是发起IO,然后把拷贝好的数据直接拿来做数据处理,进程本身并不参与任何IO细节。
异步IO使用时需要调用对应的接口,并且你需要提供一个对应的缓冲区,系统IO时自动把数据拷到你的缓冲区,考完就会通知进程。进程即不等待,也不拷贝。
IO多路转接也叫IO多路复用,其过程如下:
首先,既然IO分为2步,我们调用的read/recv/write/send都做了着两步工作,如何让进程聚焦在拷贝的事情上,系统提供了三种接口select,poll,epoll,他们不负责拷贝,专门负责等。所有等的需求就可以交给这些多路转接函数,一旦他们检测到数据就绪时,再调用上面的4中接口,让这些接口把精力放在拷贝上,就可以各司其职。让这些接口绑进程等的差别就在于调用的读取接口一次只能穿一个文件描述符,而多路转接被设计成了一次可以等多个文件描述符,进而提高IO效率。
在打开一个文件时有一个选项O_NONBLOCK or O_NDELAY,会以非阻塞的形式打开文件描述符。网络是则需要accept上来以后再进行设置。
#include
int fcntl(int fd,int cmd,.../*arg*/);
fd:要设置的文件描述符
cmd:要做什么,可以用来设置非阻塞,但是也可以干别的
...:可变参数
cmd设置成不同的选项有不同的功能,有以下五种:
复制一个现有的描述符(cmd=F_DUPFD)
获得/设置文件描述符标记(cmd=F_GETFD或F_SETFD)
获得/设置文件状态标记 (cmd=F_GETFL或F_SETFL)
获得/设置异步I/O所有权(cmd=F_GETOWN或F_SETOWN)
获得/设置记录锁(cmd=F_GETLK,F_SETLK或F_SETLKW)
设置非阻塞的方法:
void SetNoBlock(int fd) {
int fl = fcntl(fd, F_GETFL);//获取状态标记
if (fl < 0) {
perror("fcntl");
return;
}
fcntl(fd, F_SETFL, fl | O_NONBLOCK);//在状态标记里设置非阻塞
}
当设置可非阻塞时,如果读取时数据没有就绪,read就会以出错的形似返回,但是errno里会设置出错的原因,而数据没有就绪对应的就是EAGAIN表示Try again,值是11,EWOULDBOLCK则是define的EAGAIN,所有他们两个是一样的。所有在轮询读取时,如果返回值小于0,则需要对errno进行判断,如果等于上面的两个值,则代表函数没有出错,只是数据没有就绪,如果不是才是真正的出错了。
当开始read读取时也有可能会被信号打断,这时候也会以出错形式返回,这和时候errno会被设置成EINTR,这种情况也不算出错,和数据没就绪一样,需要判断一下然后继续检测就可以
本质上select是一个就绪事件的通知机制,他的核心工作就是等,就绪了就通知上层。
对于read,底层的数据从无到有,从有到多,这叫做读事件就绪
对于write,底层缓冲区的剩余空间从无到有,从有到多,这叫做写事件就绪
底层的数据和空间是一个变化的过程,在select看来,底层只要有就叫做select就绪事件。然后就可以直接调用read,recv,write,send这样的接口。
#include
int select(int nfds,fd_set* readfds,fd_set* writefds,fd_set* exceptfds,struct timeval* timeout);
nfds:代表select在等待的多个文件描述符值的最大的那个文件描述符+1
检测方案是对多个文件描述符进行轮询检测,从0下标开始,定期的检测每个文件描述符是否就绪,所以你必须告诉我遍历到哪里结束。
fd_set:是一个位图,可以把特定的fd添加到位图中,类似于信号中的信号集,这里也有一组专门的接口可以对文件描述符集进行添加,删除等操作:
void FD_CLR(int fd, fd_set *set); // 用来清除描述词组set中相关fd 的位
int FD_ISSET(int fd, fd_set *set); // 用来测试描述词组set中相关fd 的位是否为真
void FD_SET(int fd, fd_set *set); // 用来设置描述词组set中相关fd的位
void FD_ZERO(fd_set *set); // 用来清除描述词组set的全部位
select是一个系统调用接口,那么调用它时就一定会进入内核态,所以他的参数就是用户想要告诉内核的内容,readfds代表的就是用户需要让系统检测这个文件描述符集的描述符有没有读事件就绪,如果有就绪的,那么请通知我,如果没有就绪,等待它。后面的这些文件描述符集参数属于输出输出型参数,用户传的是想让系统检测的文件描述符,如果有文件描述符的事件就绪了,还是会通过同样的参数返回给用户。
所以readfds代表读事件,writefds代表写事件,exceptfds代表异常事件。
timeout:可以设置超时时间,如果传NULL,表示用户想要select阻塞式等待,如果时间被设置成0,表示如果没有立即返回,非阻塞。设置一个时间代表在这个时间内select会阻塞住,但是如果超时就返回一次,下次重新检测。
返回值:如果成功了,会返回所有的就绪的文件描述符总数。如果时间过期,返回0。如果出错,返回值小于0.
如果用户想要select检测某些文件描述符集,当select检测到一些接口事件就绪返回时,因为他要告诉用户那些接口就绪了,所以会修改传入的文件描述符集,用户还想让他继续检测的话,就需要重新设置对应的文件描述符集。这是select的一个特点,也是它的问题,即每一次都需要对所关心的fds集进行重新设置。
因为每次都要重新设置fds集,所以select的另一个特点就是需要借用数组记录下来自己曾经打开的所有文件描述符。
除了读取时等待数据和写入时等待空间,调用accept获取连接如果没有连接时也会挂起等待,所以在多路转接看来,连接事件到来也统一当作读事件就绪
编写接收数据的服务端
#progma once
#include "sock.hpp"
#include
#include
#define BACK_LOG 5
#define NUM 1024
#define DFL_FD -1
namespace ns_select{
struct bucket
{
public:
std::string inbuffer;
std::string outbuffer;
int in_curr; //已经就收到的数据
int out_curr; //已经发送的数据
public:
bucket()
:in_curr(0)
,out_curr(0)
{}
};
class SelectServer
{
public:
SelectServer(uint16_t port)
:_port(port)
{}
void InitSelectServer()
{
_listen_sock=ns_sock::Sock::Socket();
ns_sock::Sock::Bind(_listen_sock,_port);
ns_sock::Sock::Listen(_listen_sock,BACK_LOG);
}
void Run()
{
fd_set rfds;
int fd_array[NUM]={0};
ClearArray(fd_array,NUM,DFL_FD);//初始化数组
fd_array[0]=_listen_sock;//使用select要先把监听套接字也设置进去
while(1)
{
//时间也是输入输出参数,需要对时间也重新设定
struct timeval timeout ={5,0};
//重新设置rfds
int maxfd=DFL_FD;
FD_ZERO(&rfds);//清空read文件描述符集
//遍历数组,把就绪的文件描述符添加到集里
for(int i=0;i<NUM;i++)
{
if(fd_array[i]==DFL_FD)
continue;
//这里就是就绪的fd
FD_SET(fd_array[i],&rfds);
//更新最大文件描述符
if(maxfd<fd_array[i])
maxfd=fd_array[i];
}
switch (select(maxfd+1,&rfds,NULL,NULL,/*&timeout*/nullptr))
{
case 0:
std::cout<<"timeout"<<std::endl;
break;
case -1:
std::cerr<<"select error"<<std::endl;
default://正常的处理
//std::cout<<"有事件发生... timeout: "<
HanderEvent(rfds,fd_array,NUM);
break;
}
}
}
void HanderEvent(const fd_set& rfds,int fd_array[],int num)
{
//判断特定的fd是否在集合中来判断文件描述符是否就绪
for(int i=0;i<num;i++)
{
//过滤掉我没有打开的fd
if(fd_array[i]==DFL_FD)
continue;
//打开了这个文件fd,但是不一定就绪了
if(FD_ISSET(fd_array[i],&rfds))
{
//一个已经就绪的fd
if(fd_array[i]==_listen_sock)//连接事件
{
//连接事件就绪,accept
struct sockaddr_in peer;
socklen_t len=sizeof(peer);
//因为连接事件已经就绪,所以accept不会阻塞
int sock=accept(fd_array[i],(struct sockaddr*)&peer,&len);
if(sock<0)
{
std::cerr<<"accept error"<<std::endl;
continue;
}
//查看是谁在连接我
uint16_t peer_port=htons(peer.sin_port);
std::string peer_ip=inet_ntoa(peer.sin_addr);
std::cout<<"get a new link: "<<peer_ip<<" : "<<peer_port<<std::endl;
//每一个套接字都映射一个缓冲区
struct bucket b;
buckets.insert({sock,b});
//不可以直接读,这只是获取连接,连接好了不一定数据也发来了。
//把文件描述符push到管理打开的fd数组中
if(!AddFdToArray(fd_array,num,sock))
{
close(sock);
std::cout<<"select server is full,close fd: "<<sock<<std::endl;
}
}
else//普通事件就绪
{
char buffer[1024];
//一次没读完,剩下的就没了。数据丢失,或者粘包问题
//1.定制协议,读的时候结合协议读
//2.要给每一个sock定义缓冲区
ssize_t s=recv(fd_array[i],buffer,sizeof(buffer)-1,0);
if(s>0)
{
buffer[s]=0;
buckets[fd_array[i]].inbuffer=buffer;
std::cout<<"读取的内容: "<<buffer<<std::endl;
}
else if(s==0){
std::cout<<"客户端退出"<<std::endl;
close(fd_array[i]);
fd_array[i]=DFL_FD;//清除数组中的文件描述符
}
else {
std::cerr<<"recv error"<<std::endl;
close(fd_array[i]);
}
}
}
}
}
~SelectServer(){}
private:
//初始化数组
void ClearArray(int fd_array[],int num,int default_fd)
{
for(int i=0;i<num;i++)
{
fd_array[i]=default_fd;
}
}
bool AddFdToArray(int fd_array[],int num,int sock)
{
for(int i=0;i<num;i++)
{
if(fd_array[i]==DFL_FD)//该位置没有被使用
{
fd_array[i]=sock;
return true;
}
}
return false;//数组满了
}
private:
int _listen_sock;
uint16_t _port;
std::unordered_map<int,bucket> buckets;//每个连接构建一个bucket
};
}
总结:
缺点:
1.select的参数fd_set是一个具体的类型,而它本质是一个位图,也就是说它能标识的文件描述符的个数是有上限的,这个上限是sizeof(fd_set)*8=1024个
2.select要和内核交互数据,那就一定涉及到较多数据的来回拷贝,当select面临的连接和就绪的事件也较多时,会因为数据拷贝而导致效率降低。
3.select每次调用都必须重新添加fd,这个添加的过程就是一个O(N)的循环,一定会影响程序运行的效率,而且还容易出错。
4.select的第一个参数是maxfd+1,系统在检测fd是否就绪时是需要在内核中遍历文件描述符数组的,maxfd越来越大,那么遍历的成本就会越来越高
优点:
select可以同时等待多个fd,而且只负责等待,由具体的accept,recv,send完成实际的IO操作。有fd就绪的概率就增加了,服务器在单位之间内等的比重降低,可以提高效率。这是select唯一的优点,但是poll,epoll都有这个优点。
适用场景:
连接数量越多,连接越活跃,就越不适合使用多路转接,直接recv,send就可以。
在于有大量连接,但是只有少量是活跃的场景,这种情况下等占据了主要时间,就适合使用多路转接。典型的场景就是聊天工具。
poll也是一种多路转接方案,和select的定位一样,使用场景也一样,他们唯二不同的就是poll解决了select检测文件符有上限的问题,poll将用户传给内核的文件描述符和内核返还给用户检测到的文件描述符进行了分离,也就是说不需要每次重新设定了。
#include
int poll(struct pollfd* fds,nfds_t nfds,int timeout);
fds:表示需要操作系统关心的文件,可以是一个数组
nfds:上一个参数里有几个元素
timeout:超时时间单位为毫秒
返回值:大于0表示有几个就绪,等于0表示超时,小于0表示失败
struct pollfd{
int fd; //
short events; //用户告知内核要关注的事件
short revents; //内核告知用户那些文件的哪些事件就绪了
}
events包含的事件都是用宏定义的,是一段二进制序列,只有一个bit位是1,理论上可以有16个事件,但是我们只关心以下两个:
POLLIN:读事件就绪
POLLOUT:写事件就绪
poll服务器的编写:
#include "sock.hpp"
#include
namespace ns_poll{
class PollServer{
public:
PollServer(int _port)
:port(_port)
{}
void InitServer()
{
listen_sock=ns_sock::Sock::Socket();
ns_sock::Sock::Bind(listen_sock,port);
ns_sock::Sock::Listen(listen_sock,5);
}
void Run()
{
struct pollfd rfds[64];
//初始化
for(int i=0;i<64;i++)
{
rfds[i].fd=-1;
rfds[i].events=0;
rfds[i].revents=0;
}
while(1)
{
//把listen套接字先添加进去
rfds[0].fd=listen_sock;
rfds[0].events|=POLLIN;
rfds[0].revents=0;
switch (poll(rfds,64,-1))
{
case 0:
std::cout<<"timeout"<<std::endl;
break;
case -1:
std::cout<<"poll error"<<std::endl;
break;
default:
for(int i=0;i<64;i++)
{
if(rfds[i].fd==-1)
continue;
if(rfds[i].revents&POLLIN)
{
if(rfds[i].fd==listen_sock)//accept
{
std::cout<<"get a new link ..."<<std::endl;
}
else
{
//recv
}
}
}
break;
}
}
}
~PollServer()
{}
private:
int listen_sock;
int port;
};
}
epoll是为处理大批量句柄而作了改进的poll.
它是在2.5.44内核中被引进的(epoll(4) is a new API introduced in Linux kernel 2.5.44)
它几乎解决了之前的所有问题,并且没有引入新的问题,是公认为Linux2.6下性能最好的多路I/O就绪通知方法.
epoll不像select或poll只有一个函数或接口,epoll需要使用三个接口
epoll_create
int epoll_create(int size);
创建数据模型,系统会创建相关的数据结构
参数size在2.6之后就不用了,可以随便填,一般填128
返回值是一个文件描述符,失败就返回-1,errno里会包含错误原因
epoll_ctl
int epoll_ctl(int epfd,int op,int fd,struct epoll_event* event);
向epoll模型中添加,删除或修改用户想要关心的哪些文件描述符的哪些事件。
参数op有三个取值:
EPOLL_CTL_ADD:注册新的fd到epoll模型中,向红黑树添加节点
EPOLL_CTL_MOD:修改已经注册的fd的监听事件,修改红黑树节点的数据
EPOLL_CTL_DEL:从epoll模型删除一个fd,删除一个红黑树的节点
参数event是一个结构体,表示需要监听的事件,结构如下
struct epoll_event{
uint32_t events; //事件,也是宏
epoll_data_t data;//附加参数,代表一些用户数据,是一个联合体
};
事件:
EPOLLIN : 表示对应的文件描述符可以读 (包括对端SOCKET正常关闭);
EPOLLOUT : 表示对应的文件描述符可以写;
EPOLLPRI : 表示对应的文件描述符有紧急的数据可读 (这里应该表示有带外数据到来);
EPOLLERR : 表示对应的文件描述符发生错误;
EPOLLHUP : 表示对应的文件描述符被挂断;
EPOLLET : 将EPOLL设为边缘触发(Edge Triggered)模式, 这是相对于水平触发(Level Triggered)来说的.
EPOLLONESHOT:只监听一次事件, 当监听完这次事件之后, 如果还需要继续监听这个socket的话, 需要再次把这个socket加入到EPOLL队列里
epoll的红黑树的存在就使得用户不需要再像select和poll维护一个第三方数组管理文件描述符了,只需要通过epoll_ctl对红黑树进行操作就可以。
epoll_wait
int epoll_wait(int epfd,struct epoll_event* events,int maxevents,int timeout);
让epoll去完成等的动作,内核告知用户哪些文件描述符的哪些事件就绪
epfd:在哪一个epoll模型里等
events:一个数组,内核会把就绪的事件按照顺序拷贝到这个数组中
maxevents:告知内核events数组的大小,并且不能比创建模型时的size大
timeout:以毫秒为单位,-1表示阻塞,0表示非阻塞
返回值:大于0表示有几个文件描述符就绪,等于0表示超时,小于0表示出错
调用epoll_create时,系统会创建三个相关的数据结构,一颗红黑树,一个就绪队列,建立和驱动层的回调机制。
回调机制:要检测文件描述符是否就绪,如果让操作系统定期检测,这就会消耗一定的资源,而在驱动层面提供了可以注册回调的机制,就可以通过注册这些回调方法,当硬件就绪时通知上层。调用epoll模型时会把epoll内置的一些就绪函数注册在网络中,当事件就绪,就执行对应的读就绪函数或写就绪函数。这种通过回调机制检测事件就绪的方案可以解放操作系统。
红黑树结构里保存的是用户告知内核需要关心的哪些文件描述符上的哪些事件,其节点的伪代码如下:
struct rb_node{
int fd;
int events;
struct re_node* left;
struct rb_node* right;
}
fd和events就是用户通过epoll_ctl填写的哪些文件描述符和哪些事件,所以这个红黑树结构就是用户通过epoll_ctl进行添加,删除和修改的结构
一旦在红黑树里设置过了文件描述符,底层就会对这个文件描述符进行检测,如果事件就绪,那么其执行的回调方法就是在红黑树的该节点中拿到相关的属性,然后构建出一个新的节点连接到就绪队列,节点里包含的就是文件描述符和就绪的事件有新的事件就绪就不断的连接在就绪队列后面。
用户不需要关心底层的红黑树,回调机制,只需要关注就绪队列是否为空,一旦为空就被阻塞住,一旦检测到有事件就绪,就把节点数据通过epoll_wait返回给用户。因为它检测是否有数据的方法是判断队列是否为空,所以事件复杂度为O(1)
数据就绪时,底层向队列放节点,用户不断的通过epoll_wait从队列取节点,就是一个典型的生产者消费者模型,epoll是一个少有的线程安全函数。
就绪队列,红黑树,回调机制这三部分加起来我们称为epoll模型
当某一进程调用epoll_create方法时,Linux内核会创建一个eventpoll结构体,这个结构体中有两个成员与epoll的使用方式密切相关.
struct eventpoll{
....
/*红黑树的根节点,这颗树中存储着所有添加到epoll中的需要监控的事件*/
struct rb_root rbr;
/*双链表的就绪队列,存放着将要通过epoll_wait返回给用户的满足条件的事件*/
struct list_head rdlist;
....
};
每一个epoll对象都有一个独立的eventpoll结构体,用于存放通过epoll_ctl方法向epoll对象中添加进来的事件.
这些事件都会挂载在红黑树中,如此,重复添加的事件就可以通过红黑树而高效的识别出来(红黑树的插入时间效率是log(N),其中N为树的高度).
而所有添加到epoll中的事件都会与设备(网卡)驱动程序建立回调关系,也就是说,当响应的事件发生时会调用这个回调方法.
这个回调方法在内核中叫ep_poll_callback,它会将发生的事件添加到rdlist双链表中.
在epoll中,对于每一个事件,都会建立一个epitem结构体
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).
#pragma once
#include "sock.hpp"
#include
namespace ns_epoll{
#define MAX_NUM 64
const int back_log=5;
class EpollServer
{
public:
EpollServer(int _port)
:port(_port)
{}
~EpollServer(){
if(listen_sock>=0) close(listen_sock);
if(epfd>=0) close(epfd);
}
public:
void InitEpollServer()
{
listen_sock=ns_sock::Sock::Socket();
ns_sock::Sock::Bind(listen_sock,port);
ns_sock::Sock::Listen(listen_sock,back_log);
epfd=epoll_create(256);
if(epfd<0)
{
std::cerr<<"epoll create error!"<<std::endl;
exit(4);
}
}
void AddEvent(int sock,uint32_t event)
{
struct epoll_event ev;
ev.events=event;
ev.data.fd=sock;
if(epoll_ctl(epfd,EPOLL_CTL_ADD,sock,&ev)<0)
{
std::cerr<<"epoll_ctl error, fd: "<<sock<<std::endl;
}
}
void DelEvent(int sock)
{
if(epoll_ctl(epfd,EPOLL_CTL_DEL,sock,nullptr)<0)
{
std::cerr<<"epoll_ctl error, fd: "<<sock<<std::endl;
}
}
void Run()
{
AddEvent(listen_sock,EPOLLIN);
int timeout=1000;
struct epoll_event revs[MAX_NUM];
while(true)
{
//返回值num表示有多少事件就绪,内核会把就绪事件按照顺序依次放入revs中
int num=epoll_wait(epfd,revs,MAX_NUM,timeout);
if(num>0)
{
for(int i=0;i<num;i++)
{
int sock=revs[i].data.fd;//如何判断是那个文件,这里就要用到data
if(revs[i].events&EPOLLIN){//读事件就绪
//1.连接事件
if(sock==listen_sock)
{
struct sockaddr_in peer;
socklen_t len=sizeof(peer);
int sk=accept(listen_sock,(struct sockaddr*)&peer,&len);
if(sk<0)
{
std::cout<<"accept error"<<std::endl;
continue;
}
std::cout<<"get a new link: "<<inet_ntoa(peer.sin_addr)<<":"<<ntohs(peer.sin_port)<<std::endl;
AddEvent(sk,EPOLLIN);//设置只关心读取,只有要写入才设置写
}
//2.普通事件
else
{
char buffer[1024];
ssize_t s=recv(sock,buffer,sizeof(buffer)-1,0);//bug
if(s>0)
{
buffer[s]=0;
std::cout<<buffer<<std::endl;
}
else
{
std::cout<<"client close"<<std::endl;
close(sock);
DelEvent(sock);
}
}
}
else if(revs[i].events&EPOLLOUT){//写事件就绪
}
}
//std::cout<<"有事件发生"<
//默认情况下只要有数据就通知,所以会一直打印
}
else if(num==0)
{
std::cout<<"timeout"<<std::endl;
}
else
{
std::cout<<"epoll error"<<std::endl;
}
}
}
private:
int listen_sock;
int epfd;
uint16_t port;
};
}
epoll优点:
1.接口使用方便: 虽然拆分成了三个函数, 但是反而使用起来更方便高效. 不需要每次循环都设置关注的文件描述符, 也做到了输入输出参数分离开
2.数据拷贝轻量: 只在合适的时候调用 EPOLL_CTL_ADD 将文件描述符结构拷贝到内核中, 这个操作并不频繁(而select/poll都是每次循环都要进行拷贝)
3.事件回调机制: 避免使用遍历, 而是使用回调函数的方式, 将就绪的文件描述符结构加入到就绪队列中,epoll_wait 返回直接访问就绪队列就知道哪些文件描述符就绪. 这个操作时间复杂度O(1). 即使文件描述符数目很多, 效率也不会受到影响.
4.没有数量限制: 文件描述符数目无上限
epoll有2种工作方式-水平触发(LT)和边缘触发(ET),
epoll的默认工作方式是LT水平触发,使用ET边缘触发需要在epoll_ctl的事件里设置EPOLLET。
触发是指事件就绪的通知方式
水平触发(Level Triggered)的工作方式就是只要底层有数据,还没有被取走,就会一直通知上层,让上层下来读取数据
LT会一直通知你,所以一次没有读完也没事
边缘触发(Edge Triggered)的工作方式只有数据从无到有,从有到多(变化)的时候,会通知上层一次,让上层读取数据。
一旦事件就绪,因为只通知一次,就要求程序员就要尽可能的一次把所有就绪数据全部读完。需要循环调用recv,直到通过recv的返回值判断是否读取完毕。
循环调用会有以下两种情况:
1.实际读取的数据量小于期望读到的数量表示读完了
2.如果你正好期望值就是剩余的数据量,一次性刚好读完,读完之后你不知道读完了,又来读,但是这时候已经没有数据,数据没有就绪recv就有可能被阻塞住,进程被挂起,直接导致服务器无法再响应任何外部事件。
所以ET工作模式下,recv或write必须是处于非阻塞模式下进行读取,底层不就绪,直接返回。
epoll_server.hpp
#pragma once
#include "sock.hpp"
#include
#include
#include
namespace ns_epoll{
#define MAX_NUM 64
class Epoller;
class EventItem;
//const&:输入 *:输出 &:输入输出
typedef int(*callback_t)(EventItem*);//
//事件的元素,当成一个结构体使用
class EventItem{
public:
int sock;
Reactor* R;//回指Epoller
//数据处理的回调函数,用来将应用数据等通信细节和数据处理模块的使用进行逻辑解耦
callback_t recv_hander;//读回调
callback_t send_hander;//写回调
callback_t error_hander;//错误回调
std::string inbuffer;//读取到的数据缓冲区
std::string outbuffer;//待发送数据的缓冲区
public:
EventItem()
:sock(0)
,R(nullptr)
,recv_hander(nullptr)
,send_hander(nullptr)
,error_hander(nullptr)
{}
void ManagerCallBack(callback_t _recv,callback_t _send,callback_t _error)//注册回调
{
recv_hander=_recv;
send_hander=_send;
error_hander=_error;
}
~EventItem(){}
};
//管理器
class Reactor
{
public:
Reactor()
:epfd(-1)
{}
~Reactor(){
if(epfd>=0) close(epfd);
}
public:
void InitEpollET()
{
if((epfd=epoll_create(256))<0)
{
std::cerr<<"epoll create error!"<<std::endl;
exit(4);
}
}
void AddEvent(int sock,uint32_t event,const EventItem& item)
{
struct epoll_event ev;
ev.events=event;
ev.data.fd=sock;
if(epoll_ctl(epfd,EPOLL_CTL_ADD,sock,&ev)<0)
{
std::cerr<<"epoll_ctl error, fd: "<<sock<<std::endl;
}
else{
//EventItem item;
event_items.insert({sock,item});
}
}
void DelEvent(int sock)
{
if(epoll_ctl(epfd,EPOLL_CTL_DEL,sock,nullptr)<0)
{
std::cerr<<"epoll_ctl error, fd: "<<sock<<std::endl;
}
event_items.erase(sock);
}
//打开读写
void EnableReadWrite(int sock,bool read,bool write)
{
struct epoll_event evt;
evt.data.fd=sock;
evt.events=(read?EPOLLIN:0)|(write?EPOLLOUT:0)|EPOLLET;
if(epoll_ctl(epfd,EPOLL_CTL_MOD,sock,&evt)<0)
{
std::cerr<<"epoll_ctl_mod error,fd: "<<sock<<std::endl;
}
}
//事件分派器
void Dispatch(int timeout)
{
//如果底层特定的事件就绪,就把他对应的事件分配给指定的回调函数进行统一处理
struct epoll_event revs[MAX_NUM];
//返回值num表示有多少事件就绪,内核会把就绪事件按照顺序依次放入revs中
int num=epoll_wait(epfd,revs,MAX_NUM,timeout);
for(int i=0;i<num;i++)
{
int sock=revs[i].data.fd;
uint32_t mask=revs[i].events;
if((revs[i].events&EPOLLERR)||(revs[i].events&EPOLLHUP))
mask|=(EPOLLIN|EWOULDBLOCK);//异常事件统一交给读写处理
//if(event_items[sock].error_hander) event_items[sock].error_hander(&event_items[sock]);
if(revs[i].events&EPOLLIN)//读事件就绪
//读回调被设置,就调用读回调
if(event_items[sock].recv_hander) event_items[sock].recv_hander(&event_items[sock]);
if(revs[i].events&EPOLLOUT)
if(event_items[sock].send_hander) event_items[sock].send_hander(&event_items[sock]);
// if((revs[i].events&EPOLLERR)||(revs[i].events&EPOLLHUP))
// if(event_items[sock].error_hander) event_items[sock].error_hander(&event_items[sock]);
}
}
private:
int epfd;
std::unordered_map<int,EventItem> event_items;//sock->EventItem
};
}
app_interface.hpp
#pragma once
#include
#include "epoll_server.hpp"
#include "util.hpp"
#include
#include
namespace ns_appinterface{
using namespace ns_epoll;
int recver(EventItem* item);
int sender(EventItem* item);
int errorer(EventItem* item);
int accepter(EventItem* item)
{
std::cout<<"get a new link: "<<item->sock<<std::endl;
while(true)
{
struct sockaddr_in peer;
socklen_t len=sizeof(peer);
int sock=accept(item->sock,(struct sockaddr*)&peer,&len);
if(sock<0)
{
if(errno==EAGAIN||errno==EWOULDBLOCK)
return 0;
if(errno==EINTR)
continue;
else
return -1;
}
else//读取成功
{
//立即把套接字设置成非阻塞
ns_util::SetNoBlack(sock);
EventItem tmp;
tmp.R=item->R;
tmp.ManagerCallBack(recver,sender,errorer);
tmp.sock=sock;
Reactor* epoller=item->R;
//epoll经常一定会设置读事件就绪,写事件按需打开
epoller->AddEvent(sock,EPOLLIN|EPOLLET,tmp);
}
}
return 0;
}
//0.成功 -1.失败
int recver_hekper(int sock,std::string* out)//用这个读
{
while(true)
{
char buffer[128];
ssize_t size=recv(sock,buffer,sizeof(buffer)-1,0);
if(size<0)
{
if(errno==EAGAIN||errno==EWOULDBLOCK)
return 0;//读完了
else if(errno==EINTR)
continue;//被信号中断,继续
else
return -1;//读取出错
}
else//读取成功
{
buffer[size]=0;
*out+=buffer;//把读取到的内容添加到接收缓冲区
}
}
}
int recver(EventItem* item)
{
std::cout<<"recv event ready: "<<item->sock<<std::endl;
//1.非阻塞的一直读
if(recver_hekper(item->sock,&(item->inbuffer))<0)
{
//item->error_hander
return -1;
}
std::cout<<"client: "<<item->inbuffer<<std::endl;
//2.根据发来的数据流,进行包和包之间的分离,防止粘包问题
//这里涉及到协议定制,认为分隔符是X
std::vector<std::string> messages;
ns_util::StringUtil::Split(item->inbuffer,&messages,"X");
// for(auto s:messages)
// {
// std::cout<<"##############################"<
// std::cout<<"提取出的内容: "<
// std::cout<<"##############################"<
// }
// std::cout<<"剩余的内容: "<inbuffer<
//3.针对一个一个的报文的协议反序列化decode,也是协议定制的一部分
struct data{
int x;
int y;
};
for(auto s:messages)
{
struct data d;
ns_util::StringUtil::Deserialize(s,&d.x,&d.y);
//std::cout<
//4.业务处理
int z=d.x+d.y;
//5.形成响应报文,序列化成一个字符串,encode
std::string response;
response+=std::to_string(d.x);
response+="+";
response+=std::to_string(d.y);
response+="=";
response+=std::to_string(z);
item->outbuffer+=response;//不负责发,放在发送缓冲区就行
//设置报文与报文之间的分隔符
item->outbuffer+="X";
}
//6.写回
if(!item->outbuffer.empty())
item->R->EnableReadWrite(item->sock,true,true);
return 0;
}
//不断的写,0:写完 1:缓冲区打满,下次再写 -1:写出错
int sender_helper(int sock,std::string& in)
{
size_t total=0;//累计已经写入的
while(true)
{
ssize_t s=send(sock,in.c_str()+total,in.size()-total,0);
if(s>0)
{
total+=s;//更新发送了多少
if(total>=in.size())
return 0;
}
else if(s<0){
if(errno==EAGAIN||errno==EWOULDBLOCK){
//无论是否发送完,都要把已经发送的数据都移出缓冲区
in.erase(total);
return 1;//缓冲区已经打满,不能写入了
}
else if(errno==EINTR)
continue;
else{
return -1;
}
}
}
}
int sender(EventItem* item)
{
int ret=sender_helper(item->sock,item->outbuffer);
if(ret==0)
{
item->R->EnableReadWrite(item->sock,true,false);//发完了就不再关心写事件
}
else if(ret==1)
{//设置一次就会自动触发一次,防止缓冲区数据没变化发生遗漏
item->R->EnableReadWrite(item->sock,true,true);//打开写接着写
}
else{}
return 0;
}
int errorer(EventItem* item)
{
close(item->sock);
item->R->DelEvent(item->sock);
return 0;
}
}
util.hpp
#pragma once
#include
#include
#include
#include
namespace ns_util{
void SetNoBlack(int sock)
{
int fl=fcntl(sock,F_GETFL);
fcntl(sock,F_SETFL,fl|O_NONBLOCK);
}
class StringUtil{
public:
static void Split(std::string& in,std::vector<std::string>* out,std::string sep)
{
//报文如果不完整怎么办?
while(true)
{
size_t pos=in.find(sep);
if(pos==std::string::npos)
break;
out->push_back(in.substr(0,pos));
in.erase(0,pos+sep.size());//分隔符也要删掉
}
}
static void Deserialize(std::string& in,int* x,int* y)
{
size_t pos=in.find("+");
std::string left=in.substr(0,pos);
std::string right=in.substr(pos+1);
*x=atoi(left.c_str());
*y=atoi(right.c_str());
}
};
}
使用时先创建好监听套接字,然后构建对应的item,和Reactor对象,给监听套接字的读方法设置成接收连接的方法,调用添加事件方法,让管理器首先关心监听套接字的读事件,事件就绪就会自动获取连接,然后不断的调用分派器,检测就绪。
总结:
Reactor意为反应堆模式,是基于事件触发的,各种事件就绪时会触发reactor,其核心工作为事件派发, 分派给各种管理器进行处理,生产者消费者模型。EventItem结构用来接收各种各样的事件,用来建立文件描述符和对应处理关心的业务,Dispatcher就是根据任务进行任务派发。传递到各种管理器使用的就是item里的回调完成。要使用就提前注册回调方法,当就绪了就可以直接调用对应的方法。有新连接时也注册普通文件的读方法回调,最后就会执行到内部的各种方法。
继续分层,recver可以只负责读取数据流,进行报文和报文的分离,不再进行后续的处理,把后续的业务处理形成报文,构建响应等这个工作交给后端的软件层或者线程进程池,这就叫做基于reactor的半同步半异步的工作方式,这是Linux中最常用的工作方式,没有之一。