目录
多路转接poll
在学习epoll之前,简单讲讲poll。poll跟select很相似且改善了select的一些不足之处。
这里开始有一点疑惑,为什么select在参数设置上会是输入输出混合,而在Poll这里就不是了呢?
经过相关的资料查看明白了select在调用之前,你将感兴趣的文件描述符放入对应的集合中(输入),然后在返回的时候它是在原集合中修改当前已就绪的事件(输出)。
而在poll就实现了输入输出分离,输入希望监听fd的事件类型,输出已就绪的事件。
总结:Poll改良了select中集合大小有限制的缺点以及实现输入输出分离,不用每次都手动设置。
poll并不是我们本次学习的重点,下面我们来谈谈epoll~
———————————————————————————————————————————
多路转接epoll
epoll_create
int epoll_create(int size);
size可以忽略,调用完之后要记得用close关闭。
epoll_ctl
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event); //epoll 的事件注册函数.
用户告诉内核,你要帮助我关心哪些fd上的哪些事件
epfd就是epoll_create返回的文件描述符
op就是该注册函数要进行的动作:
- EPOLL_CTL_ADD:注册新的 fd 到 epfd 中;
- EPOLL_CTL_MOD:修改已经注册的 fd 的监听事件;
- EPOLL_CTL_DEL:从 epfd 中删除一个 fd;
fd与event就是该动作要执行到目标文件描述符的特定事件中
- EPOLLIN : 表示对应的文件描述符可以读 (包括对端 SOCKET 正常关闭);
- EPOLLOUT : 表示对应的文件描述符可以写;
- EPOLLPRI : 表示对应的文件描述符有紧急的数据可读 (这里应该表示有带外数据到来);
- EPOLLERR : 表示对应的文件描述符发生错误;
- EPOLLHUP : 表示对应的文件描述符被挂断;
- EPOLLET : 将 EPOLL 设为边缘触发(Edge Triggered)模式, 这是相对于水平触发(Level Triggered)来说的.
- EPOLLONESHOT:只监听一次事件, 当监听完这次事件之后, 如果还需要继续监听这个 socket 的话, 需要再次把这个 socket 加入到 EPOLL 队列里.
epoll_wait
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout); //收集在 epoll 监控的事件中已经发送的事件.
内核告诉用户,你要我关心的事件中有哪些已经就绪了
- 参数 events 是分配好的 epoll_event 结构体数组.
- epoll 将会把发生的事件赋值到 events 数组中 (events 不可以是空指针,内核只负责把数据复制到这个 events 数组中,不会去帮助我们在用户态中分配内存).
- maxevents 告之内核这个 events 有多大,这个 maxevents 的值不能大于创建 epoll_create()时的 size.
- 参数 timeout 是超时时间 (毫秒,0 会立即返回,-1 是永久阻塞).
- 如果函数调用成功,返回对应 I/O 上已准备好的文件描述符数目,如返回 0 表示已超时, 返回小于 0 表示函数失败.

有需要监视的事件就会被加入到红黑树中。
我们使用epoll_wait返回时,会把已就绪的事件从就绪队列中提取出来
神奇的是红黑树和队列用的都是同样的节点,这归根于Linux数据结构的特性了。我们也可以这么理解,就绪队列其实就是在红黑树上扒拉已就绪的事件~
那么就绪队列又是谁往里面放数据的呢?——已注册事件通过硬件中断提醒OS已就绪,所有添加到 epoll 中的事件都会与设备(网卡)驱动程序建立回调关系,也就是说,当响应的事件发生时会调用这个回调方法.最后形成新的就绪节点加入到就绪队列中~
epoll 的优点
- 没有数量限制,文件描述符无上限
- 做到了输入输出参数分离
- 做到轻量拷贝,不像select需要循环遍历加入fd,epoll只需要epoll_ctl操作即可。
- select需要遍历去寻找已就绪的事件,而epoll是利用回调的机制自动把就绪事件加入到就绪队列中。
epoll 使用示例: 检测标准输入输出
基本框架
Main.cc
#include "EpollServer.hpp"
#include <memory>
// ./epollserver port
int main(int argc,char *argv[])
{
if(argc!=2)
{
std::cout<<"输入格式错误"<<std::endl;
}
uint16_t port = std::stoi(argv[1]);
EnableScreen();
std::unique_ptr <EpollServer> svr = std::make_unique<EpollServer>(port);
svr->Loop();
return 0;
}
epollserver
#pragma once
#include <iostream>
#include <string>
#include <memory>
#include <sys/epoll.h>
#include "InetAddr.hpp"
#include "Socket.hpp"
#include "Log.hpp"
using namespace socket_ns;
class EpollServer
{
const static int gnum = 64;
public:
EpollServer(uint16_t port = 8888)
: _port(port),
_listensocket(std::make_unique<TcpSocket>()),
_epfd(-1)
{
// 1.创建listensocket
InetAddr addr("0", _port);
_listensocket->BuildListenSocket(addr);
// 2.创建epoll
_epfd = epoll_create(128);
if (_epfd < 0)
{
LOG(FATAL, "epoll_create error\n");
exit(5);
}
LOG(DEBUG, "epoll_create success, epfd: %d\n", _epfd);
// 3.加入事件:最开始的listensock,其关心事件:读事件
struct epoll_event ev;
ev.events = EPOLLIN;
ev.data.fd = _listensocket->SockFd();
epoll_ctl(_epfd, EPOLL_CTL_ADD, _listensocket->SockFd(), &ev);
}
~EpollServer()
{
_listensocket->Close();
if (_epfd >= 0)
{
close(_epfd);
}
}
void Loop()
{
int timeout = 1000;
while (true)
{
int n = epoll_wait(_epfd,revs,gnum,timeout);
switch (n)
{
case 0:
LOG(DEBUG,"epoll_wait timeout...\n");
break;
case -1:
LOG(DEBUG,"epoll_wait failed...\n");
break;
default:
LOG(DEBUG,"epoll_wait have event ready...\n");
break;
}
}
}
private:
uint16_t _port;
std::unique_ptr<Socket> _listensocket;
int _epfd;
struct epoll_event revs[gnum];//接收就绪事件数组
};
目前为止都很select的模拟过程一样,闲置时就不断等待就绪事件,而就绪事件到来却不处理时就一直提醒~
handlerevent函数
void handlerEvent(int num)
{
for (int i = 0; i < num; i++)
{
// 提取就绪事件
uint32_t revents = _revs[i].events;
// 提取就绪事件Fd
int sockfd = _revs[i].data.fd;
// 判定情况 只针对读事件
if (revents & EPOLLIN)
{
// 第一个要处理的肯定是listentsocket的读事件
if (sockfd == _listensocket->SockFd())
{
InetAddr clientaddr;
int newfd = _listensocket->Accepter(&clientaddr);
if (newfd < 0)
continue;
// 获取新连接成功 那么我们要立即对newfd进行读写吗?
// 不行,无法确定newfd事件是否就绪,加入epoll监视
struct epoll_event ev;
ev.events = EPOLLIN;
ev.data.fd = newfd;
epoll_ctl(_epfd, EPOLL_CTL_ADD, newfd, &ev);
LOG(DEBUG, "normal fd %d ready, recv begin...\n", sockfd);
}
// 正常读事件
else
{
char buffer[1024];
// 这里肯定不会被阻塞,但只能recv一次
ssize_t n = recv(sockfd, buffer, sizeof(buffer), 0);
if (n > 0)
{
LOG(DEBUG, "normal fd %d ready, recv begin...\n", sockfd);
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(DEBUG, "normal fd %d close, me too!\n", sockfd);
// 这里有两个小细节:
// 细节一:读完关闭fd时也要记得及时去除fd在epoll上的标记
// 细节二:在去除标记时该fd必须是处于合法的状态(在close之前)
epoll_ctl(_epfd, EPOLL_CTL_DEL, sockfd, nullptr);
close(sockfd);
}
else
{
epoll_ctl(_epfd, EPOLL_CTL_DEL, sockfd, nullptr); // 要删除的fd,必须是合法的!
close(sockfd);
}
}
}
}
}
目前我们遇到一个很大的问题:
如何保证recv能读取到完整的数据,从应用层来看可以去制定协议来划分报文边界以保证能读取到完整的报文。
但更大的问题是,每次recv都只读一部分,然后事件并不会从就绪队列消失,而是作为未处理再次送达,可是重新回到这里的时候buffer之前的数据早就没了。我个人想到的一种方法就是根据读取到的n大小进行判定,只要>0都作为没读完,直到让n=0为止,通过这个循环让recv慢慢积累读完完整数据。
此话题结束,为的就是引出epoll的工作模式
epoll 工作方式
你正在吃鸡 , 眼看进入了决赛圈 , 你妈饭做好了 , 喊你吃饭的时候有两种方式 :1. 如果你妈喊你一次 , 你没动 , 那么你妈会继续喊你第二次 , 第三次 ...( 亲妈 ,水平触发)2. 如果你妈喊你一次 , 你没动 , 你妈就不管你了 ( 后妈 , 边缘触发 )
epoll默认是水平触发模式,因此在前面的例子中会有读取数据不完整的风险,如果你只处理了一部分那么它只会不厌其烦地慢慢催你全部读完。
这里可以从wait返回次数来对比出ET性能更高效一些,但这还不是核心。
理解 ET 模式和非阻塞文件描述符
所以ET模式性能真正高效的地方其实就是倒逼程序员必须要关注读取问题,确保一次性读完,而这里读完可以采用上面提出的循环读取的方案,但按照ET的特性,即使你缓冲区还有数据可是它再次去调用wait已经不会返回结果了,需要读取的就绪事件没法获取到,recv就这样阻塞了。
我们前面也提到了,epollfd往往默认都是阻塞的,所以我们需要把其变为非阻塞,这样就可以解决上述的全部问题了。
至于解决方案我们在下篇博客为大家进行解析~