目录
[Channel class的修改](#Channel class的修改)
在上一节中,我们添加了Channel类,这已经进入到开始实现Reactor模式。这时我们为添加到epoll上的文件描述符都添加了一个Channel。每个Channel都可以拥有自己的回调函数,即用户可以按照自己的想法去往epoll中注册事件,之后可以根据不同的事件类型调用指定的回调函数。
但上一章节还没有实现增添回调函数和调用回调函数,这一节来完成其实现。
EventLoop类
来看看上一章节的main函数中的关键部分。
cpp
int main() {
// 初始化必要的部分,例如创建 EventLoop、设置服务器地址等
// ........
// 创建一个 Epoll 实例,用于管理 I/O 事件
Epoll poll;
// 进入事件处理循环
while (1) {
// 创建一个向量,用于存储当前活跃的 Channel
vector<Channel*> active;
// 调用 Epoll 的 Wait 函数,等待事件并填充 active 向量
// nums 表示返回的活跃事件数量
nums = poll.Epoll_wait(active);
// 遍历所有活跃的 Channel
for (int i = 0; i < active.size(); ++i) {
// 在这里处理激活的事件
// 例如检查哪个文件描述符被激活并相应地调用处理函数
// 可能的处理逻辑如下:
// 1. 检查是否是监听文件描述符 (即服务器的fd),
// 如果是,调用 accept() 方法处理新连接。
// 2. 如果是其他客户端的文件描述符,
// 检查其是否可读或可写,以及调用相应的处理逻辑。
// 示例伪代码
int fd = active[i]->fd(); // 获取当前 Channel 关联的文件描述符
if (fd == server_fd) {
// 如果是监听 fd,处理新连接
handleNewConnection();
} else {
// 处理客户端的读写事件
if (active[i]->Event() & EPOLLIN) {
handleReadEvent(fd);
}
if (active[i]->Event() & EPOLLOUT) {
handleWriteEvent(fd);
}
}
}
}
return 0; // 程序正常结束
}
显然,重点就是while(1)循环这部分了。这部分我们该怎么把它封装好呢?
看到是Epoll相关的,那很容易想到,把这个循环封装在Epoll类中。
假如说封装在Epoll类中,那来看个情况,这也是很常见的情况(比如redis),在epoll_wait前后处理一些事情,这些事情可以由用户设置的。要是这个封装在Epoll类中,就会让Epoll不伦不类,功能散乱。
cpp
while (1) {
// 在 epoll_wait 开始之前,可以执行一些特定的操作,例如日志记录、状态更新或其他需要在处理事件之前完成的任务
beforeEvent(); // 在 epoll_wait 开始前处理某些事
// 调用 Epoll 的等待函数,这里会阻塞直到有事件发生
// active 是一个向量,用于存储所有活跃的 Channel
nums = poll.Epoll_wait(active);
// 遍历所有活跃的 Channel,这里处理所有返回的事件
for (int i = 0; i < active.size(); ++i) {
// 在这里,你可以获取当前 Channel,并根据其状态做出相应的处理
// 例如,对于每个活跃的 Channel,提取文件描述符 fd
int fd = active[i]->Fd(); // 这里可以访问每个 Channel 的文件描述符
// 然后检查该文件描述符的具体事件类型并进行处理
// 可能的处理逻辑:
// - 如果是监听 fd,调用 accept() 处理新连接
// - 如果是客户端 fd,检查是否是可读事件,调用相应处理函数
if (fd == server_fd) {
// 当前 Channel 是服务器的监听 FD,处理客户端连接
handleNewConnection();
} else {
// 当前 Channel 是客户端 FD,处理读写事件
if (active[i]->Event() & EPOLLIN) {
// 当前 Channel 有可读事件,调用处理读取数据的函数
handleReadEvent(active[i]);
}
if (active[i]->Event() & EPOLLOUT) {
// 当前 Channel 有可写事件,调用处理写入数据的函数
handleWriteEvent(active[i]);
}
}
}
// 在 epoll_wait 结束后,可以执行一些清理或状态更新的操作
afterEvent(); // 在 epoll_wait 结束后处理某些事
}
而且Epoll就是负责处理网络IO,就只处理这部分就行。
所以我们需要再封装一个类EventLoop(事件循环),这也很符合while(1)循环内部的内容。
我们可以认为
类 **EventLoop是Epoll的进一层封装,**后续我们的版本中就会很少直接接触到Epoll类。
一个EventLoop类对象只有一个Epoll类对象。
cpp
class EventLoop {
public:
using channelList = std::vector<Channel*>; // 为 Channel 指针的向量定义别名
public:
EventLoop(); // 构造函数
~EventLoop(); // 析构函数
void loop(); // 事件循环,处理活跃的事件
void updateChannel(Channel *ch); // 更新通道的事件
void removeChannel(Channel *ch); // 移除通道
private:
Epoll* ep_; // 指向 Epoll 对象的指针
channelList activeChannels_; // 保存当前活跃事件的 Channel 列表
};
EvenLoop::loop()的实现
这是一个while(1)循环,也是整体的核心部分。
cpp
void EventLoop::loop() {
// 进入事件循环,持续处理活跃的事件
while (1) {
// 清空上一轮中剩余的活跃 Channel 列表,以便在这一轮填充新的活跃通道
activeChannels_.clear();
// 调用 epoll 的等待函数,阻塞并等待活跃事件到来
ep_->Epoll_wait(activeChannels_);
// 遍历所有活跃的 Channel,并执行相应的事件处理
for (auto& active : activeChannels_) {
active->handleEvent(); // 执行各自的回调函数
}
}
}
void EventLoop::updateChannel(Channel* channel) {
// 更新指定的 Channel,即在 epoll 实例中修改该通道的事件特性
ep_->updateChannel(channel);
}
void EventLoop::removeChannel(Channel* channel) {
// 从 epoll 实例中移除指定的 Channel
ep_->del(channel);
}
EventLoop::
updateChannel(Channel *ch)函数就是调用了成员变量ep_的updateChannel(Channel *ch)函数,这样是为了方便EventLoop类的使用。
Channel class的修改
一个Channel类对象对应一个客户端或者服务端(服务器的监听fd),那该Channel类对象会调用哪一种handleEvent()呢。
假如是服务器的监听fd 被激活,那handleEvent()就是要进行accept(),建立连接;
若是客户端的fd 被激活,那handleEvent()就是要进行read()/write()。
所以需要提前设置好,也就是要有设置回调函数setCallback(const func& cb)。
添加设置回调函数setCallback(const func& cb)和执行回调函数handleEvent()。详细的参看源代码。
cpp
class Channel {
public:
// 定义回调函数类型,采用 std::function,以便灵活使用各种可调用对象
using ReadEventCallback = std::function<void()>;
public:
// 构造函数,接受一个 EventLoop 指针和文件描述符 fd 进行初始化
Channel(EventLoop* loop, int fd); // 构造函数和之前的不同,使用 EventLoop 而不是 Epoll
// 设置该 Channel 关注的事件类型
void setEvents(int events);
// 获取该 Channel 关注的事件类型
int Event() const;
// 设置该 Channel 实际发生的事件类型
void setRevents(int events);
// 获取该 Channel 实际发生的事件类型
int Revent() const;
// 启用可读事件
void enableReading();
// 检查该 Channel 是否在 Epoll 实例中
bool isInEpoll();
// 设置该 Channel 的在 Epoll 中的状态
void setInEpoll(bool in);
// 获取该 Channel 关联的文件描述符
int Fd() const;
// 设置读回调函数
void SetCallback(ReadEventCallback cb) { readCallback_ = std::move(cb); }
// 执行与该 Channel 相关联的事件处理逻辑
void handleEvent(); // Channel 的执行事件
private:
// Epoll* ep_; // 注释掉的,当前不使用 Epoll 指针
EventLoop* loop_; // 指向 EventLoop 实例的指针
int fd_; // 该 Channel 关联的文件描述符
int events_; // 该 Channel 关注的事件类型(事件掩码)
int revents_; // 该 Channel 实际发生的事件类型(返回事件掩码)
bool isInEpoll_; // 标记该 Channel 是否在 Epoll 中
ReadEventCallback readCallback_; // 读回调函数
};
// 目前只有读回调函数的实现
void Channel::handleEvent() {
// 如果 readCallback_ 被设置,则调用该回调函数
if (readCallback_) {
readCallback_(); // 执行与该 Channel 相关的读事件处理
}
}
最终所有的回调函数是在handleEvent()内执行,而这个handleEvent()会在EventLoop::loop()内被调用。
好了,有了 设置回调函数后,谁去调用它呢?
可以让接下来的Server类去调用。
Server类
而在用户写代码使用层面,也不想再写bind(),listen()等操作,我们就需要再进行一个整体的抽象,可以把整个服务器抽象成Server类,里面会有一个核心EventLoop。
可以把Server类想象成一个管理员,他管理着所有的客户端连接。
cpp
class Server {
public:
// 构造函数,创建一个服务器对象,并进行初始化
Server(const InetAddr& listenAddr, EventLoop* loop);
// 析构函数,用于释放服务器资源
~Server();
// 处理读取事件的函数,接收 Channel,进行 read/write 操作
void handleReadEvent(Channel* channel);
// 处理新的连接
void newConnection(Socket *serv_sock);
private:
EventLoop* loop_; // 指向事件循环的指针,管理事件的处理
Socket* serv_socket_; // 服务器的监听套接字,用于接受连接
Channel* serv_channel_; // 包装服务器套接字的 Channel,用于 epoll 事件管理
};
先看看我们的整体用法,用法和muduo的已经很相似了,用户使用时可以不用考虑太多细节。
cpp
EventLoop loop; // 创建一个事件循环对象,负责管理 I/O 事件和事件循环
InetAddr servAddr(10000); // 创建一个 InetAddr 对象,指定服务器监听的端口为 10000
Server server(servAddr, &loop); // 创建服务器对象,绑定监听地址和事件循环
loop.loop(); // 进入事件循环,开始处理 I/O 事件
Server构造函数
那些bind(),listen()操作去哪了呢,这些都在Server类的构造函数中了,用户使用的时候就不需要再写这些操作了。
cpp
Server::Server(const InetAddr& listenAddr, EventLoop* eventloop)
: loop_(eventloop) // 绑定一个 EventLoop 指针,即绑定一个 epoll 实例
, serv_socket_(new Socket) // 创建一个 Socket 对象,调用 socket() 创建文件描述符
{
serv_socket_->bind(listenAddr); // 将指定的地址和端口绑定到服务器的套接字上
serv_socket_->listen(); // 开始监听来自客户端的连接请求
serv_socket_->setNonblock(); // 设置该文件描述符为非阻塞模式,允许异步处理
// 创建一个 Channel,对象并绑定到 EventLoop 和服务器套接字的文件描述符
serv_channel_ = new Channel(loop_, serv_socket_->fd());
// 设置回调函数,当监听的 fd 被激活(有新连接)时执行
auto cb = [this](){ newConnection(serv_socket_); };
serv_channel_->SetCallback(cb); // 将回调函数设置给 Channel
serv_channel_->enableReading(); // 将监听的 fd 添加到 epoll 实例中,开始接收可读事件
}
这里还有个重点:是在Server构造函数内部调用了 设置回调函数 。
Server的newConnection(Socket*)
当**loop()中的ep->Epoll_wait(activeChannels_)返回活跃的channel,**当其中有监听的channel后,就执行回调函数,即执行newConnection()。那newConnection()会主要执行什么操作呢,很明显,那就是使用accpet()进行建立新连接。
cpp
void Server::newConnection(Socket* serv_sock) {
// 定义一个 InetAddr 对象,用于存储客户端的地址信息
InetAddr cliaddr;
// 接收客户端的连接,并返回一个新的 Socket 对象用于与客户端通信;同时填充 cliaddr 以存储客户端的地址
Socket* cli_socket = new Socket(serv_sock->accept(&cliaddr)); // 在这版本中没有对 cli_socket 进行 delete,可能会造成内存泄漏,后续版本将进行修改
// 将客户端 Socket 设置为非阻塞模式,以便在 I/O 操作时不会阻塞当前线程
cli_socket->setNonblock();
// 创建一个新的 Channel 对象,绑定到刚刚创建的客户端 Socket 的文件描述符 fd
// 这个 Channel 用于监控与该客户端的通信事件
Channel* channel = new Channel(loop_, cli_socket->fd()); // 在这版本中没有对 channel 进行 delete,可能会造成内存泄漏,后续版本将进行修改
// 设置客户端 Channel 的回调函数
// 使用 lambda 表达式捕获当前 Server 对象(通过 this 指针)和当前 channel 指针
// 当该 channel 有可读事件时,将会调用 handleEvent() 方法,并传入 channel 作为参数
auto cb = [this, channel]() {
handleEvent(channel);
};
// 将设置好的回调函数绑定到 channel 中
// 当 channel 的文件描述符被激活(例如有数据可读时),将会执行这个回调函数
channel->SetCallback(cb); // 客户端的 fd 被激活时,将会执行回调函数,进而调用 handleEvent()
// 将 channel 启用可读事件处理
// 将 channel 添加到 epoll 实例中,开始监控该文件描述符的读事件
channel->enableReading();
}
Server类中设置回调函数的两处地方很重要,要理解这两个设置回调的操作。
只有先设置好的回调函数,那被激活的channel才能执行对应自己的handleEvent()函数。
Server::newConnection(Socket *serv_sock) 会绑定到监听的channel(服务器端) 对应的Channel上,而Server::hanleReadEvent(Channel* ch) 会绑定到连接成功的客户端Channel上。(这里绑定的函数使用c++11/14的lambda表达式)
主要的逻辑流程
在当前的版本,我们只有一个EventLoop。在调用函数loop()中,当有活跃的文件描述符时,我们会拿到对应的Channel,并会调用该Channel内对应的回调函数。在我们新建Channel对象时,我们会设置好对应的回调函数。
当监听的文件描述符 有可读事时**,** handleEvent()就会执行newConnection()进行连接, 而当客户端的文件描述符 有可读事件时**,** handleEvent()就会执行hanleReadEvent()进行通信。

有了IO多路复用,为什么还需要reactor模式?
IO多路复用与事件驱动
首先要明确一点,reactor模式就是基于IO多路复用的。事件驱动也是IO多路复用的,不是说使用了reactor模式才是使用了事件驱动。
以事件为连接点,当有IO事件准备就绪时,就会通知用户,并且告知用户返回的是什么类型的事件,进而用户可以执行相对应的任务。这样就不用在IO等待上浪费资源,这便是事件驱动的核心思想。
比如你点了两份外卖,外卖A,外卖B。之后你无需时刻打电话去问外卖到了没。外卖到的时候,外卖员会打电话通知你。这中途你就可以做自己的事,不用纯纯等待。还有可以知道是外卖A到了还是外卖B到了,外卖员会告知是哪个外卖到的。
这个就是事件驱动。IO事件准备就绪时,会自动通知用户,并会告知其事件类型。
所以应该是,IO多路复用 + 回调机制 构成了 reactor模式。
IO同步与异步的判断
还有reactor模式是同步的。因为其是使用IO多路复用的,而IO多路复用是同步的。
IO操作是同步还是异步,关键看数据在内核空间与用户空间的拷贝过程(数据读写的IO操作)

reactor模式的优点
网上很多说其可以很好处理高并发,但是我觉得IO多路复用也可以处理的。
还有说可扩展性,通过增加Reactor实例个数来充分利用CPU资源;那通过在其他线程再创建epoll也行的。
所以,我觉得一个很大的优势是:
- 应用处理请求的逻辑,与事件分发框架完全分离,即是解耦,有很高的复用性
在使用 Reactor模式 开发网络应用时,用户只需关注如何实现业务逻辑,而无需关心事件框架本身的细节。
换句话说,Reactor框架负责处理事件(如连接和数据接收),用户则专注于业务处理。
假设我们将 Reactor模式 封装成一个网络库,用户在使用这个库时只需要关注如何处理请求的业务逻辑。网络库提供了一个 setCallback
函数,用户可以通过这个函数设置自己想要执行的处理逻辑,而不需要担心事件处理机制的具体实现。这种设计使得开发者可以方便地实现不同的功能,无需具体了解底层的事件管理。
例如用户正在编写一个服务器,当服务器接收到客户端发送的一串数字时,用户可以选择对这些数字执行加法或乘法等操作。用户只需通过 setCallback
函数设置处理请求的逻辑即可。这样,处理请求的业务逻辑就与事件分发框架完全分离,使得开发过程更加简单和灵活。
当前代码的需要继续改进之处
这次,我们封装了EventLoop类和Server类,也使用了回调函数。这里已经是构成了Reactor模式的核心内容。但是我们与客户端进行通信的业务逻辑还在Server类内,这就不符合我们的抽象,Server类还不能进行复用,这将在接下来中修改。