五种常见IO模型
- 阻塞IO: 在内核将数据准备好之前, 系统调用会一直等待. 所有的套接字, 默认都是阻塞方式.
阻塞IO是最常见的IO模型
- 非阻塞IO: 如果内核还未将数据准备好, 系统调用仍然会直接返回, 并且返回EWOULDBLOCK错误码.
非阻塞IO往往需要程序员循环的方式反复尝试读写文件描述符, 这个过程称为轮询. 这对CPU来说是较大的浪费, 一
般只有特定场景下才使用 ;
- 信号驱动IO: 内核将数据准备好的时候, 使用SIGIO信号通知应用程序进行IO操作.
- IO多路转接: 虽然从流程图上看起来和阻塞IO类似. 实际上最核心在于IO多路转接能够同时等待多个文件
描述符的就绪状态.;且fd就绪以后,才会调用读写函数直接进行拷贝;
- 异步IO: 由内核在数据拷贝完成时, 通知应用程序直接来拿(而信号驱动是告诉应用程序何时可以开始拷贝数据 )
异步IO连你调用读写函数的拷贝过程都没了,全甩给操作系统了,你直接等通知来缓冲区取走数据就行;
小结:
任何IO过程中, 都包含两个步骤. 第一是等待, 第二是拷贝.
而且在实际的应用场景中, 等待消耗的时间往往都远远高于拷贝的时间.
那么让IO更高效, 最核心的办法就是让等待的时间尽量少.
高效IO的概念
我们之前传统调用write,read等接口时,都是阻塞式等待,这显然是低效的;
从这里结合网络所了解到的缓冲区的概念能看出来IO的过程可以分为两步:等待和拷贝
-
低效IO我们阻塞式等待数据的到来,再进行拷贝;
-
高效IO在单位时间内,IO的整个周期内,等的比重特别少,一直在做拷贝
那么提高IO效率的核心理念就是:减少IO过程中等待的比重!
阻塞 vs 非阻塞
阻塞和非阻塞关注的是程序在等待调用结果(消息,返回值)时的状态.
- 阻塞调用是指调用结果返回之前,当前线程会被挂起. 调用线程只有在得到结果之后才会返回.
- 非阻塞调用指在不能立刻得到结果之前,该调用不会阻塞当前线程 ,可以继续执行之后的逻辑
非阻塞IO
fcntl函数
一个文件描述符, 默认都是阻塞IO. fcntl可以进行配置;
fcntl系统调用可以用来对已打开的文件描述符进行各种控制操作以改变已打开文件的的各种属性;
函数原型:
#include <unistd.h>
#include <fcntl.h>
int fcntl(int fd, int cmd, ... /* arg */ ); //传入的cmd的值不同, 后面追加的参数也不相同;(可变参数列表)
fcntl函数有5种功能:
我们此处只是用第三种功能, 获取/设置文件状态标记, 就可以将一个文件描述符设置为非阻塞;
实现函数SetNoBlock (设置非阻塞)
基于fcntl, 我们实现一个SetNoBlock函数, 将文件描述符设置为非阻塞
void SetNoBlock(int fd) {
int fl = fcntl(fd, F_GETFL);//文件描述符的属性取出来存入fl中
if (fl < 0) {//执行失败返回-1并报错
perror("fcntl");
return;
}
fcntl(fd, F_SETFL, fl | O_NONBLOCK);//设置fl | O_NONBLOCK 类似位图填充类型设置
}
- 使用F_GETFL将当前的文件描述符的属性取出来(fl返回值)(这是一个位图).
- 然后再使用F_SETFL将文件描述符设置回去. 设置回去的同时, 加上一个O_NONBLOCK参数
demo
#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
void SetNoBlock(int fd) {
int fl = fcntl(fd, F_GETFL);
if (fl < 0) {
perror("fcntl");
return;
}
fcntl(fd, F_SETFL, fl | O_NONBLOCK);
}
int main() {
SetNoBlock(0);
while (1) {//轮询检测
char buf[1024] = { 0 };//缓冲区
ssize_t read_size = read(0, buf, sizeof(buf) - 1);//0号fd已经设置非阻塞了
if (read_size < 0) {//出错 返回-1
perror("read");
sleep(1);
continue;
}
printf("input:%s\n", buf);
}
return 0;
}
可以看到 在未键入任何数据的时候,轮询检测过程中,因为read为非阻塞,系统自动提示read暂时没有可以用的数据;
一旦键入数据,就会正常输出;
这里要注意的是,轮询过程中处于等待read数据的时候不算出错,read会直接返回,返回值操作系统也会特殊处理(宏),之后友善的提示了Resource temporarily unavailable,并不是读取出错返回-1;
I/O多路转接之select
初识select
系统提供select函数来实现多路复用输入/输出模型
- select系统调用是用来让我们的程序监视多个文件描述符的状态变化的;
- 程序会停在select这里等待,直到被监视的文件描述符有一个或多个发生了状态改变;
有人问这不是还是阻塞等待吗,高效嘛?
答案是,高效,select集成多个io操作一起等待,单位时间出现等待结束的fd概率增加,只要有等待完毕的,他就开始发出提醒,我们就能直接对应进行拷贝了!
select函数原型
select的函数原型如下: #include <sys/select.h>
int select(int nfds, fd_set *readfds, fd_set *writefds,fd_set *exceptfds, struct timeval *timeout);
select 参数解释:
-
参数nfds是需要监视的最大的文件描述符值+1;
文件描述符顺序打开,select得知道此次检测的最大的maxfd,直到监测过这个msxfd 就可以返回了(timout为0的直接返回状态下)
-
readfds,writefds,exceptfds分别对应于需要检测的可读文件描述符的集合,可写文件描述符的集合及异常文件描
述符的集合; 是输入输出型参数 ! 下面详解;
关于他们三个的fd_set类型结构
其实这个结构就是一个整数数组, 更严格的说, 是一个 “位图”. 使用位图中对应的位来表示要监视的文件描述符;
比如从左到右为从低到高位:0101 代表2 4号文件描述符等待就绪;
- 参数timeval为结构体timeval,用来设置select()的等待时间 ;
select 返回值:
>0:有事件发生(有fd等待完毕,可以拷贝了),=0:timeout,超时,<0:出错。
抽取fd_set* readfds详解:
因此输出和输出都有很重要的不同作用;
问题:但是输入输出进行一轮了以后,之前的那些需要等待的read fd都不见了,难道代表他们只读一下就完事了吗?万一是另一个read fd出发了select 别的fd还没等好下次就不用等,输入中就不见了吗?
这里需要引入一个arr数组,记录fd的变化,我们在在后续引入select编程中体现;
参数timeout取值:
- NULL:则表示select()没有timeout, select将一直被阻塞,直到某个文件描述符上发生了事件;
- 0(timeout内的变量取0,不是直接设置0):仅检测描述符集合的状态,然后立即返回,并不等待外部事件的发生。
- 特定的时间值(timeout内的变量取特定时间值):如果在指定的时间段里没有事件发生(某个fd等待就绪), select将超时返回。
select操作接口
操作系统提供了一组操作select函数中,重要的三个输入输出型fd_set参数的接口, 来方便的操作fd_set位图 :
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的全部位
tcp_server引入selecet多路转接
下面我们将引入select搞个tcpserver端进一步认识select多路转接(进行了简单的封装):
sock.hpp
#pragma once
#include <iostream>
#include <string>
#include <cstdlib>
#include <unistd.h>
#include <strings.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
using namespace std;
namespace ns_sock
{
enum{
SOCKET_ERR = 2,
BIND_ERR,
LISTEN_ERR
};
int g_backlog = 5;
class Sock
{
public:
static int Socket()
{
int sock = socket(AF_INET,SOCK_STREAM,0);
if(sock<0){
cerr<<"socket error"<<endl;
exit(SOCKET_ERR);
}
return sock;
}
static void Bind(const int &sock, const u_int16_t &port)
{
sockaddr_in local;
bzero(&local,sizeof(local));
local.sin_family = AF_INET;
local.sin_port = htons(port);
local.sin_addr.s_addr = INADDR_ANY;
if(bind(sock,(sockaddr*)&local,sizeof(local))<0){
cout<<"bind error"<<endl;
exit(BIND_ERR);
}
}
static void Listen(const int &sock)
{
if(listen(sock,g_backlog)<0){
cout<<"listen error"<<endl;
exit(LISTEN_ERR);
}
}
static void Accept(){}
};
}
select_server.hpp
#pragma once
#include "sock.hpp"
#include <sys/select.h>
namespace ns_select
{
using namespace ns_sock;
#define NUM (sizeof(fd_set) * 8) // select 与 fd数组进行交互;
int fd_arr[NUM]; //这个数组设计的时候就不需要用位图了,x = fd_arr[i]如果不是-1代表x这个sock有效,i只是他的编号,没啥意义;
const int g_default = 8080;
class SelectServer
{
private:
u_int16_t port_;
int listen_sock_;
int fd_arr[];
public:
SelectServer(int port = g_default) : port_(port), listen_sock_(-1)
{
for (int i = 0; i < NUM; i++)
{
fd_arr[i] = -1; //开始设为-1表示该位置无sock;
}
}
void InitSelectServer()
{
listen_sock_ = Sock::Socket();
fd_arr[0] = listen_sock_; // listen_sock_默认为只读,需要检测!放在fd_arr第一个位置,等待建立连接;
Sock::Bind(listen_sock_, port_);
Sock::Listen(listen_sock_);
}
void HandlerEvent();
void Loop() //引入select进行管理
{
fd_set rfds;
while (1)
{
//每轮进来 利用fd_arr动态更新select需要监测的fd
int maxfd = -1;
FD_ZERO(&rfds);
for (int i = 0; i < NUM; i++)
{
if (fd_arr[i] > 0)
{
FD_SET(fd_arr[i], &rfds);
if (fd_arr[i] > maxfd)
maxfd = fd_arr[i];
}
}
int n = select(maxfd + 1, &rfds, nullptr, nullptr, nullptr); //暂时只考虑IO中的 input,学习select用,其他两个考虑业务就有点复杂了;
switch (n)
{
case 0:
cout << "time out" << endl;
break;
case -1:
cerr << "select error" << endl;
break;
default:
HandlerEvent(rfds); //此时rfds作为输出,select向我们传递了谁准备好的信息在里面
break;
}
}
}
void HandlerEvent(const fd_set &rfds)
{
for (int i = 0; i < NUM; i++)
{
if (fd_arr[i] == -1)
continue; //压根就没-1这个sock,肯定不需要等待了;
//能到这里的都是在fd_arr里设置需要监听的fd,但是监听成功与否还需要进一步if 用输出参数rfds配合接口进行判断;
if (FD_ISSET(fd_arr[i], &rfds))
{ //定位到了有效fd
// rfd-listen 和 其他client的rfd不一样,一个accept连接,获取sock的;一个读取数据的;
if (fd_arr[i] == listen_sock_) // listensock_等待就绪 有人连我
{
sockaddr_in peer;
bzero(&peer, sizeof(peer));
peer.sin_family = AF_INET;
socklen_t len = sizeof(peer);
int sock = accept(listen_sock_, (sockaddr *)&peer, &len); //接收到客户端的sock
if (i < 0)
{
cout << "accept error" << endl;
}
//拿到client的sock 需要插入rfd_arr呀,但是需要考虑满没满!
int index = 0;
for (; index < NUM; index++)
{
if (fd_arr[index] == -1)
break;
}
if (index == NUM)
{ //数组满了 加不进去了;
cout << "fd_arry had full!" << endl;
close(sock); //关闭连接;
}
else
{
fd_arr[index] = sock;
cout << "获取链接成功,sock: " << sock << "已加入数组!" << endl;
PrintArr();
}
}
else //除了来连接的其他rfd等待就绪,有人要读数据
{
char buffer[1024];
int n = read(fd_arr[i], buffer, sizeof(buffer));
if (n > 0)
{
buffer[n] = '\0'; //别忘了手动上'\0'
cout << "sock: " << fd_arr[i] << " say: " << buffer << endl;
}
else if (n == 0)
{ // client close链接了
cout << "close sock: " << fd_arr[i] << "i will close too" << endl;
close(fd_arr[i]);
//别忘记处理arr 事实上 大多数业务,rfd需要不断的监测,指不定client啥时候发过来消息,只有当连接关闭了,才把他从检测队列移出
fd_arr[i] = -1;
PrintArr();
}
else
cerr << "read error!" << endl;
}
}
}
}
void PrintArr()
{
cout << "当前数组有: ";
for (int i = 0; i < NUM; i++)
{
if (fd_arr[i] != -1)
cout << fd_arr[i] << " ";
}
cout << endl;
}
};
}
server.cc
#include "select_server.hpp"
using namespace ns_select;
int main()
{
SelectServer *svr = new SelectServer();
svr->InitSelectServer();
svr->Loop();
return 0;
}
我们用3个client连接select_server试试结果:
运行结果:
可以看到,select服务器不仅能捕获他们的连接请求,正常建立连接,而且在不引入多进程与多线程版本的单执行流下,能达到串式并发的效果;
各个client只有发给select_server数据以后,select捕获到以后,server才会调用对应的rfd直接进行拷贝,可见select多路转接技术雀儿八十的搞出了高效IO;
总结
根据固定设计套路,可以看到select多路转接一般**借助第三方数组arr[]**以下图模式运作:
select的特点
-
可监控的文件描述符个数取决与sizeof(fd_set)的值. 我这边服务器上sizeof(fd_set)=1024,每bit表示一个文件
描述符,则我服务器上支持的最大文件描述符是1024*8=8192个. -
将fd加入select监控集的同时,还要再使用一个数据结构array保存放到select监控集中的fd
-
一是用于再select 返回后, array作为源数据和fd_set进行FD_ISSET判断 --配合select的输入输出型参数的输出部分,告知外部那些fd等待好可以进行拷贝了
-
二是select返回后会把以前加入的但并无事件发生的fd清空,则每次开始select前都要重新从array取得
fd逐一加入(先FD_ZERO清空下),扫描array的同时取得fd最大值maxfd,用于select的第一个参数 --确定select的输入输出型参数的输入部分
-
的大小可以调整,可能涉及到重新编译内核 ,fd_set底层也是一个arr在维护fds,改改fd_set结构体底层的arr大小
select的缺点
显而易见:处理的逻辑烦死人,还得搞个全局第三方数组;
- 每次调用select, 都需要手动设置fd集合, 从接口使用角度来说也非常不便.
- 每次调用select,都需要把fd集合从用户态拷贝到内核态,(遍历数组式的arr拷贝入fd_set)这个开销在fd很多时会很大
- 同时每次调用select都需要在内核遍历传递进来的所有fd(遍历数组式的修改fd_set,突出标记等待就绪的),这个开销在fd很多时也很大
- select支持的文件描述符数量太小.
那么我们需要高效IO,也想解决上述缺点怎么搞?下面POLL来了;
I/O多路转接之poll
poll函数接口
#include <poll.h>
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
// pollfd结构
struct pollfd {
int fd; /* file descriptor */
short events; /* requested events */
short revents; /* returned events */
};
参数说明
- fds是一个poll函数监听的结构列表(相当于select中的fd_arr数组). 因为是一个结构体,则每一个元素中, 包含了三部分内容: 1.文件描述符fd; 2.监听的事件集合events 3. 返回的事件集合revents; 这也是poll解决select大部分缺点的关键所在!
- nfds表示pollfd数组的长度 ;
- timeout表示poll函数的超时时间 ,单位是ms; -1代表阻塞
events和revents的取值:
返回结果
- 返回值**<0**, 表示出错;
- 返回值**=0**, 表示poll函数等待超时;
- 返回值**>0**, 表示poll由于监听的文件描述符中有一个或多个就绪,而返回.;
tcp_server引入poll多路转接
可以将poll理解为:
-
优化了select中一直需要重复使用一个fd_arr维护需监听fd;
-
优化了select文件描述符数量太小;
其余功能和select完全类似,我们可以改造select_server.hpp中的部分逻辑得以实现;
poll_server.hpp
#pragma once
#include "sock.hpp"
#include <poll.h>
namespace ns_poll
{
using namespace ns_sock;
const int g_default = 8080;
#define NUM 1024 //假设1024个 这个NUM可以往大的设置(全局变量区得装得下) poll优化了select中fd_set对fd数量的限制
class PollServer
{
private:
u_int16_t port_;
int listen_sock_;
pollfd pollfds_[NUM];
public:
SelectServer(int port = g_default) : port_(port), listen_sock_(-1)
{
for (int i = 0; i < NUM; i++) //初始化
{
pollfds_[i].fd = -1;
pollfds_[i].events = 0;
pollfds_[i].revents = 0;
}
}
void InitSelectServer()
{
listen_sock_ = Sock::Socket();
int opt = 1;
setsockopt(listen_sock_, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));//防止bind error
Sock::Bind(listen_sock_, port_);
Sock::Listen(listen_sock_);
pollfds_[0].fd = listen_sock_; // listen_sock_ 第一个需要监听的读的sock
pollfds_[0].events = POLLIN; //数据可读,我们告诉os,这个需要监听了;
pollfds_[0].revents = 0; //空,留给os进行操作,等待完毕os会设置revents;
}
void Loop() //引入select进行管理
{
int time_out = -1;
while (1)
{
int n = poll(pollfds_, NUM, time_out);
switch (n)
{
case 0:
cout << "time out" << endl;
break;
case -1:
cerr << "select error" << endl;
break;
default:
HandlerEvent(pollfds_);
break;
}
}
}
void HandlerEvent(pollfd pollfds[])
{
for (int i = 0; i < NUM; i++)
{
if (pollfds[i].revents & POLLIN) //该sock等待就绪
{
if (pollfds[i].fd == listen_sock_) // listensock_等待就绪
{
sockaddr_in peer;
socklen_t len = sizeof(peer);
int sock = accept(listen_sock_, (sockaddr *)&peer, &len); //接收到客户端的sock
if (i < 0)
{
cout << "accept error" << endl;
}
//拿到client的sock 需要插入rfd_arr呀,但是需要考虑满没满!
int index = 0;
for (; index < NUM; index++)
{
if (pollfds[index].fd == -1)
break;
}
if (index == NUM)
{ //数组满了 加不进去了 需要扩容;
cout << "need capacity" << endl;
close(sock); //关闭连接;
}
else
{
pollfds[index].fd = sock;
pollfds[index].events = POLLIN;
cout << "获取链接成功,sock: " << sock << "已加入poll中!" << endl;
}
}
else //除了来连接的其他rfd等待就绪,有人要读数据
{
char buffer[1024];
int n = read(pollfds[i].fd, buffer, sizeof(buffer));
if (n > 0)
{
buffer[n] = '\0'; //别忘了手动上'\0'
cout << "sock: " << pollfds[i].fd << " say: " << buffer << endl;
}
else if (n == 0)
{ // client close链接了
cout << "close sock: " << pollfds[i].fd << "i will close too" << endl;
close(pollfds[i].fd);
//别忘记处理arr 事实上 大多数业务,rfd需要不断的监测,指不定client啥时候发过来消息,只有当连接关闭了,才把他从检测队列移出
pollfds[i].fd = -1;
}
else
cerr << "read error!" << endl;
}
}
}
}
};
}
运行结果和select完全一样,就不放了;
总结
poll的优点
不同与select使用三个位图来表示三个fdset的方式, poll使用一个pollfd结构体的指针实现三种管理;
-
pollfd结构包含了要监视的event和发生的revent,不再使用借助第三方fd_arr与select进行“输入参数–输出值”交互的方式.,接口使用比select更方便!
-
poll并没有最大数量限制,select中fd_set这个结构规定了maxsize,但是poll是在内存区开空间的,大小取决于你想用多少 (但是数量过大后性能也是会下降,属于下面的缺点);
poll的缺点
poll中监听的文件描述符数目增多时 :
- 和select函数一样, poll返回后,需要轮询pollfd来获取就绪的描述符.
- 每次调用poll都需要把大量的pollfd结构从用户态拷贝到内核中.
- 同时连接的大量客户端在一时刻可能只有很少的处于就绪状态, 因此随着监视的描述符数量的增长, 其效
率也会线性下降 (eg:100个fd,只有1个就绪,那么内核遍历100次 99次都是浪费的,当数量更大更浪费)
那么我们需要更高效IO,也想解决上述缺点怎么搞?下面EPOLL来了;
I/O多路转接之epoll
epoll两大件,下面详解;
初识epoll
按照man手册的说法: epoll是为处理大批量句柄而作了改进的poll.
它对比select和poll,几乎具备了之前所说的一切优点,解决了之前所说的所有缺点,被公认为Linux2.6下性能最好的多路I/O就绪通知方法.
epoll的相关系统调用
epoll 有3个相关的系统调用.
epoll_create
int epoll_create(int size);
作用:创建一个epoll的句柄,具体用法工作流程详解;
返回值:return 一个 int epfd,本质上也是一个fd,标志刚创建好的epoll的句柄;
用完之后, 必须调用close()关闭.
epoll_ctl
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
作用:epoll的事件注册函数;
它不同于select()是在监听事件时告诉内核要监听什么类型的事件, 而是在这里先注册要监听的事件类型.
第一个参数是epoll_create()的返回值(epoll的句柄).
第二个参数表示动作,用以下三个宏来表示. //增 删 改
EPOLL_CTL_ADD :注册新的fd到epfd中;
EPOLL_CTL_MOD :修改已经注册的fd的监听事件;
EPOLL_CTL_DEL :从epfd中删除一个fd;
第三个参数是需要监听等待的fd.
第四个参数结构如下,是告诉内核需要监听什么事. //读 写 异常…
struct epoll_event结构:
可以看到是一个联合结构体:1)位置存储事件需要监听的类型(如下几个宏结合位段表示):
2)位置存入事件的fd;
返回值:
成功时,epoll_ctl()返回0。发生错误时,epoll_ctl()返回-1并正确设置了errno。
epoll_wait
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
**作用:收集在epoll监控的事件中已经发送(等待就绪)**的事件.
- 参数events是ctrl中,已经分配好的,epoll_event结构体数组.
- epoll将会把发生的事件赋值到events数组中 (events不可以是空指针,内核只负责把数据复制到这个 events数组中,不会去帮助我们在用户态中分配内存).
- maxevents告知内核这个events有多大,这个 maxevents的值不能大于创建epoll_create()时的size.
- 参数timeout是超时时间 (毫秒,0会立即返回,-1是永久阻塞).
- 如果函数调用成功,返回对应I/O上已准备好的文件描述符数目,如返回0表示已超时, 返回小于0表示函 数失败.
**返回值:**epoll中此轮监控的等待就绪事件的数目;
epoll工作原理
epoll和select,poll一样,都是只负责等待,等待成功进行事件就绪通知的机制,他的接口和底层原理优化了其余两种结构的缺点;
工作原理框架
- 调用epoll_create创建一个epoll句柄;
- 调用epoll_ctl, 将要监控的文件描述符进行注册;
- 调用epoll_wait, 等待文件描述符就绪;
epoll是一个大的结构体,是该服务器唯一的epoll;
里面有存入需要监听事件等待信息的红黑树,还有用于通知用户等待就绪双向链表;
树和链表之间内核自动注册了回调机制进行交互
工作原理详解
当某一进程调用epoll_create方法时,Linux内核会创建一个eventpoll结构体,这个结构体中有两个成员与epoll的使用方式密切相关 1.红黑树 2.链表(就绪链表).
struct eventpoll{ .... /*红黑树的根节点,这颗树中存储着所有添加到epoll中的需要监控的事件*/ struct rb_root rbr; /*双链表中则存放着将要通过epoll_wait返回给用户的满足条件的事件*/ struct list_head rdlist; .... };
每一个epoll对象都有一个独立的eventpoll结构体,epoll_create出来的,用于存放通过epoll_ctl方法向epoll对象中添加进来的事件fd.
这些事件就是红黑树的每个节点,类型都基于epitem结构,都会挂载在红黑树中,如此,重复添加的事件就可以通过红黑树而高效的识别出来(fd天然顺序增长为红黑树提供了操作的索引,红黑树的插入,查找时间效率是lgn,其中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对象中的rdlis双链表中是否有epitem节点即可. —如果有发生,系统就执行该事件注册好的回调,将其加入rdlis双链表中;
- 如果rdlist不为空,则操作系统把发生的事件复制到用户态,同时将事件数量返回给用户通知用户可以对该事件拷贝了. 这个操作的时间复杂度 是O(1)
tcp_server引入epoll多路转接
其余功能和select完全类似,我们可以改造select_server.hpp中的部分逻辑得以实现;
epoll_server.hpp
#pragma once
#include "sock.hpp"
#include <sys/epoll.h>
namespace ns_select
{
using namespace ns_sock;
const int g_default = 8080;
#define NUM 10
class SelectServer
{
private:
u_int16_t port_;
int listen_sock_;
int epfd_; // epoll句柄 本质是一个文件描述符
public:
SelectServer(int port = g_default) : port_(port), listen_sock_(-1)
{
}
void InitSelectServer()
{
listen_sock_ = Sock::Socket();
int opt = 1;
setsockopt(listen_sock_, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)); //防止bind error
Sock::Bind(listen_sock_, port_);
Sock::Listen(listen_sock_);
epfd_ = epoll_create(NUM);
if (epfd_ < 0)
{
std::cerr << "create epoll model error" << std::endl;
exit(1);
}
}
void Loop() //引入select进行管理
{
epoll_event ev;
ev.events = EPOLLIN;
ev.data.fd = listen_sock_;
epoll_ctl(epfd_, EPOLL_CTL_ADD, listen_sock_, &ev);
int time_out = -1;
epoll_event revs[NUM]; //创建一个双向链表,作为wait的输出型参数;保存已经等待就绪的epoll_event 事件信息; 是内核告诉用户的桥梁;
while (1)
{
int n = epoll_wait(epfd_, revs, NUM, time_out); //返回n代表几个就绪了 已经放入双向链表了
switch (n)
{
case 0:
cout << "time out" << endl;
break;
case -1:
cerr << "epoll error" << endl;
break;
default:
HandlerEvent(revs, n);
break;
}
}
}
void T() { cout << 1111 << endl; }
void HandlerEvent(epoll_event revs[], int n)
{
//已经有就绪了,区分listensock 和普通的 直接操作;
for (int i = 0; i < n; i++)
{
int fd = revs[i].data.fd;
if (fd == listen_sock_)
{
//连接来了 接收 甩入epoll红黑树里;
sockaddr_in peer;
socklen_t len = sizeof(peer);
int sock = accept(listen_sock_, (sockaddr *)&peer, &len);
epoll_event ev;
ev.data.fd = sock;
ev.events = EPOLLIN;
epoll_ctl(epfd_, EPOLL_CTL_ADD, sock, &ev);
cout<<"new connect sock : "<<sock<<endl;
}
else//普通fd
{
if (revs[i].events & EPOLLIN) //读操作
{
//普通数据,直接读了,加入我们读完还要给他写一下的业务逻辑,但是写入缓冲区也有可能满了(网络拥塞),所以write也得epoll集中等待操作,高效IO 不知道就不就绪我们就借助多路转接,不直接调用IO;
char buffer[1024];
int n = read(fd, buffer, 1024);
if (n > 0)
{
buffer[n] = '\0'; //手动上\0
cout << "client say: " << buffer << endl;
//准备写该sock入;
epoll_event ev;
ev.data.fd = fd;
ev.events = EPOLLIN | EPOLLOUT; //上读+写 一般业务来说 read端口一直要监听!
epoll_ctl(epfd_, EPOLL_CTL_MOD, fd, &ev);
}
else if (n < 0)
{
cout << "read error!" << endl;
}
else
{ //==0
cout << "client closed,sock: " << fd << " I will close too!" << endl;
close(fd);
epoll_event ev;
ev.data.fd = fd;
epoll_ctl(epfd_, EPOLL_CTL_DEL, fd, nullptr);//DEL的时候 后面可以给nullptr
}
}
if (revs[i].events & EPOLLOUT)
{ 写操作
int n = write(fd, "OK This is my reply!", sizeof("OK This is my reply!")); //假设回复固定字符串;
if (n < 0)
cout << "write error" << endl;
//假设写了以后 暂时不需要写入了 关了Epollout
epoll_event ev;
ev.data.fd = fd;
ev.events = EPOLLIN;
epoll_ctl(epfd_, EPOLL_CTL_MOD, fd, &ev);
}
// if(....==EPOLLERR) 后续可以增加异常等fd的处理
}
}
}
};
}
epoll的优点(和 select 的缺点对应)
- 接口使用方便: 虽然拆分成了三个函数, 但是反而使用起来更方便高效. 不需要每次循环都设置关注的文件描述符, 也做到了输入输出参数分离开
- 告知内核需监听的fd轻量: 只在合适的时候调用 EPOLL_CTL_ADD 将文件描述符结构插入到内核中的红黑树, 这个操作并不频繁,(而select/poll都是无差别每次循环都要进行拷贝,还得先找下空闲的位置);
- 内核通知就绪fd事件回调机制: 避免内核使用遍历, 而是使用回调函数的方式, 就绪的文件描述符直接调用回调函数加入到就绪队列中,不用内核亲自挨个轮询找了, epoll_wait 返回直接访问就绪队列就知道哪些文件描述符就绪. 这个操作时间复杂度O(1). 即使文件描述 符数目很多, 效率也不会受到影响(poll 和 select内核底层是内核轮询遍历看谁就绪了,数量多的话因为挨个遍历,1个就绪99个没就绪那就效率太低了).
事件回调机制往就绪链表中添加就绪fd 和 epoll_wait返回给上层用户层直接拿到就绪fd开始IO拷贝,这本质上就是一个生产者消费者模型,由于fd类似的多线程性,该链表其实内置了线程安全之类的操作;
- 没有数量限制: 文件描述符数目无上限(和poll差不多的内存中存结构 而select限制了fd的个数)
- EPOLL支持ET边缘触发,更合理;
关于epoll的两种工作模式ET,LT,以及基于Epoll ET模式下的Reactor模型 请看这篇博客
虽然epoll这么优了,但是避免不了的是,epoll内存句柄中的就绪链表往用户态进行拷贝的时候,还是牵扯内核态和用户态之间的拷贝,这个没法避免呀…
本节很重要
请对比总结select, poll, epoll之间的优点和缺点(重要, 面试中常见).