目录
设计思路
poller 模块的意义主要体现在它对 epoll 进行了更高层次的封装,让开发者在监控文件描述符(比如 socket)的事件时,操作变得更简单、更直观。咱们可以从几个方面来看看它的价值:
简化复杂性
原生的 epoll 是 Linux 提供的一种高效 I/O 事件通知机制,但它的 API 使用起来稍微有点繁琐,比如需要手动创建 epoll 实例、注册事件、处理就绪事件等。poller 模块把这些底层操作封装成了更友好的接口,让你不用直接跟 epoll_ctl、epoll_wait 这些函数打交道,减少了出错的机会。
提高代码可读性
通过 poller,你可以更专注于业务逻辑,而不是陷入底层的事件管理细节。比如,注册一个描述符、指定关心的事件(比如可读、可写),poller 通常会提供简洁的方法,代码看起来更清晰,维护起来也更容易。
统一抽象
如果你的项目需要在不同平台上运行(比如 Linux 用 epoll,其他系统可能用 select 或 poll),poller 可以作为一个抽象层,屏蔽底层的差异,让你用一套统一的接口来处理事件监控。这对跨平台开发超级友好!
1. 设计 Poller 的核心功能
- 创建和管理 epoll 实例
- 添加/修改/删除文件描述符及其关心的事件
- 等待并返回就绪的事件
- 提供简单的事件循环接口
- 基于这些需求,可以设计一个 Poller 类或者结构体,封装这些功能。
2. 基本封装思路
- 初始化:调用 epoll_create 创建一个 epoll 实例。
- 添加描述符:用 epoll_ctl 注册文件描述符和事件。
- 事件等待:用 epoll_wait 获取就绪的事件。
- 销毁:关闭 epoll 实例
对于epoll模型,要怎么封装呢?
我们知道,一个EventLoop线程也就是一个从属Reactor线程需要对应一个 epoll 模型,也就是一个 Poller对象(因为Poller就是对epoll的封装),那么这个对象中要实现事件的监控,就必须要保存一个epoll操作句柄(就是钥匙,没有这个钥匙,启动不了对应的epoll),也就是一个_epfd。
对于epoll模型来说,当使用epoll_ctl(),把文件描述符添加到红黑树模型上之后,当有事件就绪了,操作系统会去红黑树上找到该就绪事件对应的文件描述符_fd。然后会把这些_fd放在就绪队列上。而我们的Poller模块就是封装的epoll,所以肯定会获取到就绪的事件。那么这些事件就需要存储到一个就绪队列中。我们使用一个 struct epoll_event 类型的数组来模拟就绪队列。但是对于Poller的上层,也就是EventLoop来说,它并不需要得到一个 struct epoll_event 的数组,他其实只需要知道哪些Channel 中的事件就绪了,然后调用 Channel中的 HandlerEvent 方法去处理就绪事件就行了,所以我们未来设置就绪事件获取的时候,返回给外界或者说外界不需要传 struct epoll_event 数组,但是我们内部调用 epoll_wait 又必须使用这个数组,那么我们其实可以在Poller对象内部定义一个 struct epoll_event 数组便于后续使用。
同时,我们需要对Poller模型中监控的文件描述符以及对应的Channel对象进行管理,一个fd对应一个Channel对象。因为未来我们在添加某个事件的监控时,有可能该文件描述符在之前我们并没有添加到 epoll 模型中,这时候我们使用的是 EPOLL_CTL_ADD 操作,而其他的时候,对监控的事件进行修改,则是EPOLL_CTL_MOD操作,仅仅是对epoll模型中的红黑树节点中的内容进行修改,并不对节点的删除与插入做管理。
就比如在一个城市中,epfd就好比不同的饭馆,_fd就对应进入饭馆的顾客,当一个顾客来吃饭,就把该顾客对应的信息注册到记录本上(比如一对情侣坐在了A桌上),然后此时服务员(Poller)对已经注册的顾客进行监控,当_fd就绪了,这时候就需要通过Channel模块了,因为Poller模块只是监听fd,并且把就绪的fd放到就绪队列上。至于这些就绪的事件接下来要干什么,是要传给Channel中的回调函数处理的。就好比A顾客离开了(就绪了Close事件),然后服务员检测到了A顾客就绪了离开的事件,他就会在对讲机里跟老板说,老板知道了是A顾客离开了也就是知道了A顾客所在桌的路线(Channel),就会调用调用回调函数(保洁),去执行打扫卫生的工作。如果Poller和Channel不联系起来,那老板知道有顾客离开了,但是不知道是哪个顾客离开了。
类的设计
我们需要一个私有的实际进行epoll模型操作的结构,也就是上面的 Update 接口,同时也需要一个辅助接口,也就是IsInPoller ,来判断某个Channel是否已经在Poller的监控中,来决定是使用 EPOLL_CTL_ADD还是使用EPOLL_CTL_MOD操作。
class Poller
{
private:
#define REVENTS_SIZE 1024
int _epfd; //epoll模型的操作句柄
std::unordered_map<int,Channel*> _channels; //保存管理的套接字以及对应的Channel
struct epoll_event _revents[REVENTS_SIZE]; //用于从epoll模型中获取就绪的文件描述符及其就绪事件然后存储里面
private:
//判断该文件描述符是否在epoll模型中
bool HasChannel(Channel* channel); //判断文件描述符是否登记到了Channel中
void Update(Channel* channel,int op); 更新文件描述符的事件
public: //提供的接口
void UpdateEvents(Channel* channel); //添加/更新事件监控
void Remove(Channel*channel); //删除事件监控
size_t PollCtl(vector<Channel*> *active); //就绪事件放到"就绪队列"中
};
模块实现
私有接口
bool HasChannel(Channel* channel) //判断文件描述符是否登记到了Channel中
{
return _channels.find(channel->Fd()) == _channels.end() ? false : true;
}
void Update(Channel* channel, int op) //更新文件描述符的事件
{
int fd = channel->Fd();
struct epoll_event ev;
ev.data.fd = fd;
ev.events = channel->Events();
int ret = epoll_ctl(_epfd, op, fd, &ev);
if(ret < 0)
{
ERR_LOG("epoll_ctl error");
}
return;
}
公有接口
在公有接口中,有个函数 PollCtl 比较难理解,这里我给大家单独讲解一下。这个函数的目的是检测有哪些事件就绪(比如 socket 可读、可写),然后把对应的 Channel
塞进 actives
中,交给上层(比如 EventLoop
)去处理。类比epoll模型的就绪队列
size_t PollCtl(vector<Channel*> *active)//就绪事件放到"就绪队列"中
{
int cnt = epoll_ctl(_epfd, _revents, REVENTS_SIZE, -1);//阻塞等待
if(cnt < 0)
{
if(errno = EINTR)
ERR_LOG("epoll_wait failed");
abort();
}
for(int i = 0; i <cnt; i++)
{
auto it = _channels.find(_revents[i].data.fd);
if(it == _channels.end())
{
ERR_LOG("find channel failed");
abort();
}
it->second->SetRevents(_revents[i].events);
active->push_back(it->second);
}
return cnt;
}
完整公有接口
public:
Poller()
{
_epfd = epoll_create(1);
if(_epfd < 0)
{
ERR_LOG("epoll_create error");
abort(); //退出程序
}
}
void UpdateEvents(Channel* channel)//添加/更新事件监控
{
if(HasChannel(channel)) //检查下是否已经在红黑树模型上
{
Update(Channel* channel, EPOLL_CTL_MOD);
return;
}
_channels.insert(std::make_pair(channel->Fd(), channel)); //添加到红黑树模型上
Update(channel, EPOLL_CTL_ADD);
}
size_t PollCtl(vector<Channel*> *active)//就绪事件放到"就绪队列"中
{
int cnt = epoll_ctl(_epfd, _revents, REVENTS_SIZE, -1);//阻塞等待
if(cnt < 0)
{
if(errno = EINTR)
ERR_LOG("epoll_wait failed");
abort();
}
for(int i = 0; i <cnt; i++)
{
auto it = _channels.find(_revents[i].data.fd);
if(it == _channels.end())
{
ERR_LOG("find channel failed");
abort();
}
it->second->SetRevents(_revents[i].events);
active->push_back(it->second);
}
return cnt;
}
void RemoveEvent(Channel* channel)//删除事件监控
{
auto it = _channels.find(channel->Fd());
if(it ! = _channels.end())
{
_channels.erase(it);
Update(channel, EPOLL_CTL_DEL);
}
}
};
目前来说,这段代码编译起来没什么问题,等后续我们实现完EventLoop模块再来进行联合调试。实现完EventLoop之后,我们的逻辑就大概清晰了。
疑惑点
std::unordered_map<int,Channel*> _channels;为啥需要这个
那为啥map中的值不是Channel而是Channel*呢?
auto it = _channels.find(_revents[i].data.fd); 这个返回的是什么?这个it是个unorder_map?
active->push_back(it->second);这个是干嘛的,这个不是就绪队列吗,不是应该把就绪事件的文件描述符传给这个vector队列吗?怎么把channel*对象指针给填进去?
这个channel对象里面既有文件描述符也还有该文件描述符所对应的就绪事件,以及对应的回调函数