6.实现 Reactor 模式的 EventLoop 和 Server 类

目录

EventLoop类

EvenLoop::loop()的实现

[Channel class的修改](#Channel class的修改)

Server类

Server构造函数

Server的newConnection(Socket*)

主要的逻辑流程

有了IO多路复用,为什么还需要reactor模式?

IO多路复用与事件驱动

IO同步与异步的判断

reactor模式的优点

当前代码的需要继续改进之处


在上一节中,我们添加了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类还不能进行复用,这将在接下来中修改。

相关推荐
TDD_062828 分钟前
【运维】Centos硬盘满导致开机时处于加载状态无法开机解决办法
linux·运维·经验分享·centos
牵牛老人32 分钟前
C++设计模式-责任链模式:从基本介绍,内部原理、应用场景、使用方法,常见问题和解决方案进行深度解析
c++·设计模式·责任链模式
x66ccff35 分钟前
vLLM 启动 GGUF 模型踩坑记:从报错到 100% GPU 占用的原因解析
linux
神经毒素36 分钟前
WEB安全--文件上传漏洞--一句话木马的工作方式
网络·安全·web安全·文件上传漏洞
swift开发pk OC开发1 小时前
如何轻松查看安卓手机内存,让手机更流畅
websocket·网络协议·tcp/ip·http·网络安全·https·udp
序属秋秋秋1 小时前
算法基础_基础算法【高精度 + 前缀和 + 差分 + 双指针】
c语言·c++·学习·算法
William.csj1 小时前
Linux——开发板显示器显示不出来,vscode远程登录不进去,内存满了的解决办法
linux·vscode
慵懒学者1 小时前
15 网络编程:三要素(IP地址、端口、协议)、UDP通信实现和TCP通信实现 (黑马Java视频笔记)
java·网络·笔记·tcp/ip·udp
KeithTsui2 小时前
GCC RISCV 后端 -- 控制流(Control Flow)的一些理解
linux·c语言·开发语言·c++·算法
森叶2 小时前
linux如何与windows进行共享文件夹开发,不用来回用git进行拉来拉去,这个对于swoole开发者来说特别重要
linux·git·swoole