欢迎光临
我们一直在努力

深入高级IO:poll/epoll原理、Reactor模式与实战解析----《Hello Linux!》(28)

文章目录

  • 前言
    • poll
      • 接口介绍
      • 基于poll实现的服务端
      • poll的缺点
    • epoll
      • 接口介绍
      • epoll的工作原理
      • epoll的优点
      • 基于epoll实现的服务端
      • epoll的工作方式
  • Reactor
  • 作业部分

前言

在Linux网络编程中,高并发IO场景的性能瓶颈往往集中在“等待事件就绪”与“数据拷贝”的效率上——这也是高级IO技术的核心优化方向。此前我们已经梳理了五种IO模型的本质的区别,明确了同步与异步IO的核心边界,也初步探讨了select多路转接机制的用法与局限。而在实际工程应用中,面对更高的并发需求、更灵活的事件管理,select的不足逐渐凸显,由此诞生了poll、epoll等更高效的IO多路转接技术,搭配Reactor设计模式,成为高并发网络编程的主流解决方案。

本文将围绕高级IO的核心知识点展开,从select的改进方案poll入手,拆解其接口设计、实战实现与固有局限;再深入epoll的底层原理——包括三大核心接口的用法、eventpoll结构体的工作机制、红黑树与就绪队列的协同逻辑,以及LT(水平触发)与ET(边缘触发)两种工作模式的差异与应用场景;随后引入Reactor设计模式,解读其“异步监控事件、同步处理事件”的核心思想,适配高并发IO场景的设计思路;最后结合典型练习题与解析,巩固核心知识点,帮大家理清select、poll、epoll的优缺点对比。

poll

接口介绍

在这里插入图片描述

返回值:>0表示就绪的文件描述符数量 =0表示超时,没有fd就绪 =-1表示调用出错,此时会设置errno

作用:跟select一样

形参:

fds:输入输出型参数

nfds:表示需要监控的文件描述符总数

timeout:单位是毫秒,其他的跟select的差不多–外加一个:如果传的是-1的话,是无限阻塞,直到至少有一个fd的事件就绪才返回

poll这里使用的是struct pollfd的结构体,所以解决了select里面fd的上限的问题

在这里插入图片描述

fd:需要监控的文件描述符

events:需要监控fd的哪些事件(用户告诉内核的)

POLLIN:表示普通的读事件 POLLOUT:表示写事件 POLLPRI:高优先级的数据的读事件(比如:TCP的带外数据)

eg:可以POLLIN|POLLOUT

revents:输出型参数(内核告诉用户的)–表示这个fd的哪些事件就绪了

–也是用的上面那套宏

基于poll实现的服务端

static const uint16_t defaultport = 8888;
static const int fd_num_max = 64;
int defaultfd = 1;
int non_event = 0;

class PollServer
{
public:
PollServer(uint16_t port = defaultport) : _port(port)
{
for (int i = 0; i < fd_num_max; i++)
{
_event_fds[i].fd = defaultfd;
_event_fds[i].events = non_event;
}
}
bool Init()
{
_listensock.Socket();
_listensock.Bind(_port);
_listensock.Listen();

return true;
}
void Accepter()
{
// 我们的连接事件就绪了
std::string clientip;
uint16_t clientport = 0;
int sock = _listensock.Accept(&clientip, &clientport);
if (sock < 0) return;

int pos = 1;
for (; pos < fd_num_max; pos++) // 第二个循环
{
if (_event_fds[pos].fd != defaultfd)
continue;
else
break;
}
if (pos == fd_num_max)
{
lg(Warning, "server is full, close %d now!", sock);
close(sock);
// 扩容
}
else
{
_event_fds[pos].fd = sock;
_event_fds[pos].events = POLLIN;
_event_fds[pos].revents = non_event;
PrintFd();
// TODO
}
}
void Recver(int fd, int pos)
{
// demo
char buffer[1024];
ssize_t n = read(fd, buffer, sizeof(buffer) 1);
if (n > 0)
{
buffer[n] = 0;
cout << "get a messge: " << buffer << endl;
}
else if (n == 0)
{
close(fd);
_event_fds[pos].fd = defaultfd; // 这里本质是从select中移除
}
else
{
close(fd);
_event_fds[pos].fd = defaultfd; // 这里本质是从select中移除
}
}
void Dispatcher()
{
for (int i = 0; i < fd_num_max; i++) // 这是第三个循环
{
int fd = _event_fds[i].fd;
if (fd == defaultfd)
continue;

if (_event_fds[i].revents & POLLIN)
{
if (fd == _listensock.Fd())
{
Accepter(); // 连接管理器
}
else // non listenfd
{
Recver(fd, i);
}
}
}
}
void Start()
{
_event_fds[0].fd = _listensock.Fd();
_event_fds[0].events = POLLIN;
int timeout = 3000; // 3s
for (;;)
{
int n = poll(_event_fds, fd_num_max, timeout);
switch (n)
{
case 0:
cout << "time out… " << endl;
break;
case 1:
cerr << "poll error" << endl;
break;
default:
// 有事件就绪了,TODO
cout << "get a new link!!!!!" << endl;
Dispatcher();
break;
}
}
}

~PollServer()
{
_listensock.Close();
}

private:
Sock _listensock;
uint16_t _port;
struct pollfd _event_fds[fd_num_max]; // 数组, 用户维护的!
};

poll的缺点

如果需要监控的文件描述符太多了的话,遍历就成主要矛盾了–但是poll还是需要遍历所有的fd,所以需要epoll

epoll

实际应用中最常用的是epoll,但是如果公司的平台太老了可能就只支持select这些

接口介绍

在这里插入图片描述

作用:创建一个epoll实例

形参:size:现在版本没啥用了,随便传个大于0的数都行

返回值:>=0表示的是epoll实例对应的文件描述符;=-1表示创建失败,错误原因由errno标识

注意:使用完之后要用close去关闭

在这里插入图片描述

作用:等待事件就绪

形参:

epfd:epoll实例对应的文件描述符

events:用于存储内核返回的就绪事件

maxevents:表示events数组的最大元素数量,但是不超过events数组的实际长度

timeout:单位是毫秒,跟poll的一样

返回值:>0表示就绪事件的数量;=0表示超时,没有事件就绪;=-1表示调用出错,并设置了errno

关于struct_epoll_event:

在这里插入图片描述

events也是通过位图的方式去标记事件类型的

data是用户用来关联事件对应信息的变量

在这里插入图片描述

在这里插入图片描述

作用:管理 epoll 实例内文件描述符及事件

形参:

epfd:epoll实例对应的文件描述符

op:常用的有三个

EPOLL_CTL_ADD:把fd添加到epfd对应的 epoll 实例的监控列表中

EPOLL_CTL_MOD:修改已经被添加到 epoll 实例中的fd对应的监控事件

EPOLL_CTL_DEL:把fd从epfd对应的 epoll 实例的监控列表中移除

fd:文件描述符

event:执行EPOLL_CTL_ADD和EPOLL_CTL_MOD时,表示fd里的什么事件要被监控;执行EPOLL_CTL_DEL时,传NULL就行了

返回值:0表示操作成功,-1操作失败并设置errno

epoll_ctl的本质其实就是对红黑树进行增删查改

epoll使用的三部曲:

调用epoll_create创建一个epoll实例

调用epoll_ctl, 将要监控的文件描述符进行注册

调用epoll_wait, 等待文件描述符就绪

epoll的工作原理

当某一进程调用epoll_create方法时,Linux内核会创建一个eventpoll结构体

struct eventpoll{
struct rb_root rbr; //红黑树
struct list_head rdlist; //就绪队列
....
};

网卡收到数据后,会调用内核的callback函数

这个回调函数会干4件事:

1.把收到的数据交给TCP的接收队列

2.去红黑树里找到这个数据对应的fd和他要监控的事件

3.把这个已经就绪的fd和他要监控的事件打包成就绪队列的节点

4.把这个节点插入到就绪队列中

他的这个工作原理让用户在调用epoll_wait时,不需要遍历所有监控的fd,从就绪队列中取出就绪的节点就行了

引申:操作系统在硬件层面,怎么知道网卡上有数据–因为硬件中断

注意:epoll并没有使用内存映射机制

因为内存映射机制可以避免内存需要拷贝内存到用户层

但是struct epoll_event是在用户空间中分配好的内存;势必还是需要将内核的数据拷贝到这个用户空间的内存中的

epoll的优点

1.检测就绪是O(1),获取就绪是O(n)(因为要一个一个拷贝过去)

2.fd和event没用上限

3.就绪事件是连续的(在event参数指向的数组里面连续)

注意:

select, poll, epoll之间的优点和缺点 –非常重要,面试很常考

基于epoll实现的服务端

注释:nocopy是一个自己写的禁止拷贝的一个基类

下面这些是对epoll的一个封装

class Epoller : public nocopy
{
static const int size = 128;

public:
Epoller()
{
_epfd = epoll_create(size);
if (_epfd == 1)
{
lg(Error, "epoll_create error: %s", strerror(errno));
}
else
{
lg(Info, "epoll_create success: %d", _epfd);
}
}
int EpollerWait(struct epoll_event revents[], int num)
{
int n = epoll_wait(_epfd, revents, num, 1);
return n;
}
int EpllerUpdate(int oper, int sock, uint32_t event)
{
int n = 0;
if (oper == EPOLL_CTL_DEL)
{
n = epoll_ctl(_epfd, oper, sock, nullptr);
if (n != 0)
{
lg(Error, "epoll_ctl delete error!");
}
}
else
{
// EPOLL_CTL_MOD || EPOLL_CTL_ADD
struct epoll_event ev;
ev.events = event;
ev.data.fd = sock; // 目前,方便我们后期得知,是哪一个fd就绪了!

n = epoll_ctl(_epfd, oper, sock, &ev);
if (n != 0)
{
lg(Error, "epoll_ctl error!");
}
}
return n;
}
~Epoller()
{
if (_epfd >= 0)
close(_epfd);
}

private:
int _epfd;
int _timeout = 3000;
};

服务端:
uint32_t EVENT_IN = (EPOLLIN);
uint32_t EVENT_OUT = (EPOLLOUT);

class EpollServer : public nocopy
{
static const int num = 64;

public:
EpollServer(uint16_t port)
: _port(port),
_listsocket_ptr(new Sock()),
_epoller_ptr(new Epoller())
{
}
void Init()
{
_listsocket_ptr->Socket();
_listsocket_ptr->Bind(_port);
_listsocket_ptr->Listen();

lg(Info, "create listen socket success: %d\\n", _listsocket_ptr->Fd());
}
void Accepter()
{
// 获取了一个新连接
std::string clientip;
uint16_t clientport;
int sock = _listsocket_ptr->Accept(&clientip, &clientport);
if (sock > 0)
{

_epoller_ptr->EpllerUpdate(EPOLL_CTL_ADD, sock, EVENT_IN);
lg(Info, "get a new link, client info@ %s:%d", clientip.c_str(), clientport);
}
}

void Recver(int fd)
{
// demo
char buffer[1024];
ssize_t n = read(fd, buffer, sizeof(buffer) 1);
if (n > 0)
{
buffer[n] = 0;
std::cout << "get a messge: " << buffer << std::endl;
//write
std::string echo_str = "server echo $ ";
echo_str += buffer;
write(fd, echo_str.c_str(), echo_str.size());
}
else if (n == 0)
{
lg(Info, "client quit, me too, close fd is : %d", fd);
//细节3
_epoller_ptr->EpllerUpdate(EPOLL_CTL_DEL, fd, 0);
close(fd);//这里要先移除再关闭–来保证fd是合法的
}
else
{
lg(Warning, "recv error: fd is : %d", fd);
_epoller_ptr->EpllerUpdate(EPOLL_CTL_DEL, fd, 0);
close(fd);
}
}
void Dispatcher(struct epoll_event revs[], int num)
{
for (int i = 0; i < num; i++)
{
uint32_t events = revs[i].events;
int fd = revs[i].data.fd;
if (events & EVENT_IN)
{
if (fd == _listsocket_ptr->Fd())
{
Accepter();
}
else
{
// 其他fd上面的普通读取事件就绪
Recver(fd);
}
}
else if (events & EVENT_OUT)
{
}
else
{
}
}
}
void Start()
{
_epoller_ptr->EpllerUpdate(EPOLL_CTL_ADD, _listsocket_ptr->Fd(), EVENT_IN);
struct epoll_event revs[num];
for (;;)
{
int n = _epoller_ptr->EpollerWait(revs, num);
if (n > 0)
{
// 有事件就绪
lg(Debug, "event happened, fd is : %d", revs[0].data.fd);
Dispatcher(revs, n);
}
else if (n == 0)
{
lg(Info, "time out …");
}
else
{
lg(Error, "epll wait error");//lg是自己写的一个日志
}
}
}
~EpollServer()
{
_listsocket_ptr->Close();
}

private:
std::shared_ptr<Sock> _listsocket_ptr;
std::shared_ptr<Epoller> _epoller_ptr;
uint16_t _port;
};

epoll的工作方式

epoll有两种工作方式

1.LT(水平触发)

2.ET(边缘触发)

epoll默认状态(也就是缺省工作状态)下是LT工作模式

select和poll的触发机制跟epoll的LT模式一样

LT工作模式:就是有就绪事件的话就一直通知

ET工作模式:就绪事件从无到有或者从有到多才会通知(数据内容变了,但是数据量没变的话也是不会通知的)

–LT就是次次都添加到就绪队列里面,ET就是变化时才添加一次

但是相比于LT,ET的通知效率和IO效率更高

–因为ET的通知次数远少于LT

–LT就算改成所有fd都是非阻塞并且循环读取,跟ET的差距也是很大的

ET的话,所有的fd必须是非阻塞,然后循环读取:

因为ET的通知触发机制倒逼程序员在设计时必须把本轮的数据全部读走,但是循环读取的话,fd默认是阻塞的

–这样搞了之后,还会让TCP向对方通告一个更大的窗口,让对方一次发送更多的数据的概率变大,效率进一步提高

Reactor

一种专门用来处理高并发 I/O 场景的网络编程设计模式

是一种半同步半异步模型

–异步监控事件 + 同步处理事件(有点像打地鼠)

反应堆相当于是玩家

地鼠洞是多个客户端连接 / 待处理的 I/O 事件

地鼠冒头相当于事件就绪了

用锤子打地鼠就是回调函数(提前定义好的处理逻辑)

还有一种设计就是一个线程一个Reactor,然后主线程采用负载均衡的策略,把收到的连接分配给其他的线程

引申:

1.把事件异常转化成读写问题可以简化代码

2.定时任务的超时管理逻辑:

用最小堆来维护一批定时任务;如果堆顶任务超时了,执行任务绑定的回调函数,并从堆中删除;如果堆顶任务还没超时,就更新所剩时间

作业部分

以下关于异步IO说法不正确的是(D)
A.System V的异步IO信号是SIGPOLL
B.POSIX异步IO接口使用AIO控制块来描述IO操作
C.当使用异步IO的时候,由内核在数据拷贝完成时, 通知应用程序
D.当使用异步IO的时候,由内核告诉应用程序何时可以开始拷贝数据//这是同步IO的特性

以下关于ET模式说法错误的是(B)
A.epoll_wait只有在客户端每次发数据时才会返回,除此以外即使接收缓冲区里还有数据也不会触发事件返回
B.在使用ET模式的时候描述符必须设置为阻塞模式
//在边缘模式下,通常是尽量设置为非阻塞操作,而并非阻塞操作
C.使用ET模式的时候最好使用循环读取,将自己需要处理的数据全部处理完毕。
D.ET事件发生仅通知一次的原因是只被添加到rdlist中一次,而LT可以有多次添加的机会

以下关于事件放入epoll等待队列说法不正确的是(D)
A.当LT模式下,有新数据到来才会加入到epoll等待队列中
B.有老数据,并且通过epoll_ctl设置EPOLL_CTL_MOD(ET模式)
//没有新数据,所以不会添加到就绪队列里面
C.数据可写,并且通过epoll_ctl设置EPOLL_CTL_MOD(ET模式)
//一直都是可写,没有从不可写变成可写,所以不会加入到就绪队列
D.以上说法中都不正确

赞(0)
未经允许不得转载:171主机测评 » 深入高级IO:poll/epoll原理、Reactor模式与实战解析----《Hello Linux!》(28)
分享到: 更多 (0)

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址