30dayMakeCppServer(day06):Reactor 模式落地——EventLoop 事件驱动核心与 Server 抽象类实战
示例工程【免费下载链接】30dayMakeCppServer30天自制C服务器包含教程和源代码项目地址https://gitcode.com/GitHub_Trending/30/30dayMakeCppServer点击查看免费下载本文是「30天自制C服务器」教程第 6 天的配套技术指南。上一篇day05为每个加入 epoll 的文件描述符引入了Channel实现了事件 → 回调的基础封装本篇将把服务器进一步抽象为Server类并引入事件驱动模型的核心EventLoop事件循环使服务器从顺序化、流程化的程序结构升级为最简易的 Reactor 模式。读完本文你将理解事件驱动与 Reactor/Proactor 两种经典模型的区别掌握EventLoop、Channel、Epoll三者如何协作驱动服务器运行并能基于 code/day06 的完整源码亲手编译运行一个最简 Reactor 服务器。一、从流程化编程到事件驱动为什么要抽象在 day05 中我们已经为每一个添加到 epoll 的文件描述符都创建了一个Channel。用户可以为Channel自由注册各种事件并根据不同事件类型设置不同的回调函数当前源码中只支持目前所需的可读事件后续教程会逐渐完善。但此时整个服务器程序的结构仍然是顺序化、流程化的从新建 socket、接受客户端连接到处理客户端事件可以用一张简单的流程图完整表示。流程化程序设计的缺点之一是不够抽象——当服务器结构越来越庞大、功能越来越复杂、模块越来越多顺序程序设计的思想显然无法满足需求。对于服务器开发我们需要更抽象的设计模式。观察代码可以发现无论是接受客户端连接还是处理客户端事件都是围绕 epoll 来编程的epoll 是整个程序的核心服务器要做的事情就是监听 epoll 上的事件然后针对不同事件类型做不同处理。这种以事件为核心的模式又叫事件驱动Event-Driven。事实上几乎所有的现代服务器都是事件驱动的。和传统的请求驱动模型有很大不同事件驱动模型中事件的捕获、通信、处理和持久保留是解决方案的核心结构。libevent 就是著名的 C 语言事件驱动库。需要特别注意的是事件驱动并不是服务器开发的专利。它是一种设计应用的思想、开发模式服务器是根据客户端的不同请求提供不同服务的一个实体应用可以采事件驱动模型也可以不采用。事件驱动模型同样可以出现在服务器之外的其他应用类型中例如进程通信、k8s 调度、V8 引擎、Node.js 等。二、两种经典模型Reactor 与 Proactor理解了事件驱动的概念就很容易理解服务器开发的两种经典模式——Reactor 模式和 Proactor 模式。关于这两种模式的详细讨论可参考游双《Linux高性能服务器编程》第八章第四节以及陈硕《Linux多线程服务器编程》第六章第六节。为什么我们的服务器选择 Reactor由于 Linux 内核系统调用的设计更加符合 Reactor 模式绝大部分高性能服务器都采用 Reactor 模式进行开发。本项目的服务器同样使用这种模式Reactor 模式事件驱动、同步处理——由事件循环负责监听事件并分发处理动作由应用程序自己同步完成本 day06 版本即属于此类但还不完整Proactor 模式异步处理——内核完成读写操作后通知应用程序应用程序只处理结果。在接下来的改造中我们正是要把服务器逐步重构成 Reactor 模式。三、核心抽象Server 类与 EventLoop 事件循环改造的第一大步是将整个服务器抽象成一个Server类其中包含一个 main-Reactor本版本还没有 sub-Reactor。这个 Reactor 的核心是一个EventLooplibevent 中对应的概念叫 EventBase它是一个事件循环我们把需要监听的事务添加进这个事件循环每次有事件发生时事件循环就会通知我们在程序中表现为返回给我们Channel然后根据不同的描述符、事件类型进行不同的处理以回调函数的方式。3.1 EventLoop 类定义EventLoop类的定义见 code/day06/src/EventLoop.hclass EventLoop { private: Epoll *ep; bool quit; public: EventLoop(); ~EventLoop(); void loop(); void updateChannel(Channel*); };从源码实现code/day06/src/EventLoop.cpp可以看到它的具体行为构造函数EventLoop::EventLoop() : ep(nullptr), quit(false)中创建了Epoll对象即每个事件循环内部持有一个 epoll 实例loop()调用它即可启动事件驱动本质上是原来程序中调用epoll_wait()的死循环updateChannel(Channel*)将Channel的注册/更新请求透传给内部的Epoll对象。3.2 loop() 的实现事件驱动循环void EventLoop::loop(){ while(!quit){ std::vectorChannel* chs; chs ep-poll(); for(auto it chs.begin(); it ! chs.end(); it){ (*it)-handleEvent(); } } }这个循环的工作流程可以拆解为三步阻塞等待事件调用ep-poll()内部执行epoll_wait直到有事件发生获取活跃 ChannelEpoll::poll()返回活跃事件对应的std::vectorChannel*注意这里返回的是Channel*而不是原始的epoll_event——Epoll::updateChannel在注册时通过ev.data.ptr channel将Channel指针直接挂在了 epoll 事件上事件返回时再通过(Channel*)events[i].data.ptr还原见 code/day06/src/Epoll.cpp分发处理逐个调用(*it)-handleEvent()而Channel::handleEvent()实际上只是执行其内部绑定的callback见 code/day06/src/Channel.cpp。3.3 以 muduo 风格启动服务器有了EventLoop之后服务器启动方式变得极其简洁和 muduo 的代码已经非常接近。入口见 code/day06/server.cppEventLoop *loop new EventLoop(); Server *server new Server(loop); loop-loop();整个过程只做三件事创建事件循环、把服务器挂到事件循环上、启动事件循环——事件驱动模型就此运转起来。四、Server 类把回调绑定到正确的 socket 上Server类定义见 code/day06/src/Server.hclass Server { private: EventLoop *loop; public: Server(EventLoop*); ~Server(); void handleReadEvent(int); void newConnection(Socket *serv_sock); };这个版本的服务器内只有一个EventLoop。当其中有可读事件发生时我们可以拿到该描述符对应的Channel。在新建Channel时根据Channel描述符的不同分别绑定了两个回调函数newConnection()被绑定到服务器 socket 上当监听 socket 有可读事件即有新连接到达时Channel里的handleEvent()实际上会调用Server::newConnection()新建连接handleReadEvent()被绑定到新接受的客户端 socket 上当客户端 socket 有可读事件即客户端发来数据时Channel里的handleEvent()实际上会调用Server::handleReadEvent()响应客户端请求。4.1 构造函数注册监听 socketcode/day06/src/Server.cpp 的构造函数完成了监听 socket 的建立与回调注册Server::Server(EventLoop *_loop) : loop(_loop){ Socket *serv_sock new Socket(); InetAddress *serv_addr new InetAddress(127.0.0.1, 8888); serv_sock-bind(serv_addr); serv_sock-listen(); serv_sock-setnonblocking(); Channel *servChannel new Channel(loop, serv_sock-getFd()); std::functionvoid() cb std::bind(Server::newConnection, this, serv_sock); servChannel-setCallback(cb); servChannel-enableReading(); }关键点默认监听地址为127.0.0.1:8888监听 socket 被设置为非阻塞setnonblocking内部通过fcntl设置O_NONBLOCK见 code/day06/src/Socket.cpp通过std::bind(Server::newConnection, this, serv_sock)将成员函数绑定为std::functionvoid()回调enableReading()内部将事件设为EPOLLIN | EPOLLET可读 边缘触发并调用loop-updateChannel(this)把该Channel注册进 epoll见 code/day06/src/Channel.cpp。4.2 newConnection接受新客户端连接void Server::newConnection(Socket *serv_sock){ InetAddress *clnt_addr new InetAddress(); //会发生内存泄露没有delete Socket *clnt_sock new Socket(serv_sock-accept(clnt_addr)); //会发生内存泄露没有delete printf(new client fd %d! IP: %s Port: %d\n, clnt_sock-getFd(), inet_ntoa(clnt_addr-addr.sin_addr), ntohs(clnt_addr-addr.sin_port)); clnt_sock-setnonblocking(); Channel *clntChannel new Channel(loop, clnt_sock-getFd()); std::functionvoid() cb std::bind(Server::handleReadEvent, this, clnt_sock-getFd()); clntChannel-setCallback(cb); clntChannel-enableReading(); }每个新连接的处理流程accept得到客户端 fd → 设为非阻塞 → 创建新的Channel并绑定handleReadEvent回调 →enableReading()注册进 epoll。从此该客户端 fd 的事件就由事件循环统一驱动而不是靠服务器主流程逐个轮询。注意源码注释中明确标注clnt_addr与clnt_sock是new出来的对象且没有delete会发生内存泄露。这是当前版本遗留的简化写法day08 引入Connection类、day16 引入智能指针后才逐步解决——这也体现了教程逐步完善的演进思路。4.3 handleReadEvent非阻塞读取与回显void Server::handleReadEvent(int sockfd){ char buf[READ_BUFFER]; while(true){ //由于使用非阻塞IO读取客户端buffer一次读取buf大小数据直到全部读取完毕 bzero(buf, sizeof(buf)); ssize_t bytes_read read(sockfd, buf, sizeof(buf)); if(bytes_read 0){ printf(message from client fd %d: %s\n, sockfd, buf); write(sockfd, buf, sizeof(buf)); } else if(bytes_read -1 errno EINTR){ //客户端正常中断、继续读取 printf(continue reading); continue; } else if(bytes_read -1 ((errno EAGAIN) || (errno EWOULDBLOCK))){//非阻塞IO这个条件表示数据全部读取完毕 printf(finish reading once, errno: %d\n, errno); break; } else if(bytes_read 0){ //EOF客户端断开连接 printf(EOF, client fd %d disconnected\n, sockfd); close(sockfd); //关闭socket会自动将文件描述符从epoll树上移除 break; } } }这段读取逻辑是理解非阻塞 IO 的绝佳范例四种分支分别对应返回值 / errno含义处理bytes_read 0成功读到数据打印消息并原样写回回显服务errno EINTR系统调用被信号中断continue继续读取errno EAGAIN/EWOULDBLOCK非阻塞 IO 下数据已全部读完打印提示并break结束本次读取bytes_read 0EOF客户端已断开打印断开信息并close(sockfd)关闭 socket 会自动将 fd 从 epoll 树上移除while(true)循环配合非阻塞 IO确保一次epoll_wait返回后把缓冲区中的数据全部读完READ_BUFFER定义为 1024 字节。五、抽象完成最简易的 Reactor 模式服务器至此我们已经抽象出了EventLoop和Channel构成了事件驱动模型。这两个类与服务器核心Server已经没有任何关系EventLoop不关心 fd 是监听 socket 还是客户端 socketChannel也不知道回调背后是谁的业务逻辑——它们经过完善后可以被任何程序复用达到了事件驱动的设计思想。现在我们的服务器已经可以看成一个最简易的 Reactor 模式服务器。整个事件驱动链条可以总结为epoll_wait 阻塞等待 │ 事件就绪 ▼ Epoll::poll() 返回活跃 Channel 列表data.ptr 还原 Channel* │ ▼ EventLoop::loop() 逐个调用 handleEvent() │ ▼ Channel::handleEvent() 执行绑定的回调 │ ▼ Server::newConnection / Server::handleReadEvent 处理业务六、编译运行与验证day06 的工程文件位于 code/day06包含Makefile、server.cpp、client.cpp以及src/目录下的 8 个源文件Channel、Epoll、EventLoop、InetAddress、Server、Socket、util。使用 Makefile 一键编译make server等价于如下两条编译命令g src/util.cpp client.cpp -o client \ g src/util.cpp server.cpp src/Epoll.cpp src/InetAddress.cpp src/Socket.cpp src/Channel.cpp src/EventLoop.cpp src/Server.cpp -o server运行验证./server # 终端 1启动服务器监听 127.0.0.1:8888 ./client # 终端 2连接并发送消息观察服务器回显服务器端会输出new client fd ...的连接信息以及每次收到消息时的message from client fd ...回显日志客户端断开时服务器打印EOF, client fd ... disconnected。七、局限与后续演进方向需要清醒认识到当前这个 Reactor 模式并不是一个完整的 Reactor 模式事件处理仍在事件驱动线程内完成EventLoop::loop()直接调用handleEvent()业务处理read、write与事件监听跑在同一个线程里这显然违背了 Reactor 的IO 事件就绪后交给其他线程处理的核心概念存在内存泄露newConnection中new出来的InetAddress、Socket、Channel均未释放EventLoop尚未考虑多 Reactor 拆分main-Reactor 与 sub-Reactor 的分工要在 day12 才引入Channel只支持可读事件写事件、错误事件等需要后续完善。在接下来几天的教程中这些不足都会被逐一补齐day08 引入Connection类管理连接、day10 加入ThreadPool、day12 改写为主从 Reactor 多线程模式、day13 起进入 C 工程化与代码质量阶段、day16 全面使用智能指针重构核心库。本篇文章搭建的EventLoop Channel Server骨架正是后续所有演进的基石。参考资料完整源代码code/day06前序基础day05 的Channel机制详见 code/day05进阶理论游双《Linux高性能服务器编程》第八章第四节Reactor/Proactor、陈硕《Linux多线程服务器编程》第六章第六节赞分享示例工程【免费下载链接】30dayMakeCppServer30天自制C服务器包含教程和源代码项目地址https://gitcode.com/GitHub_Trending/30/30dayMakeCppServer点击查看免费下载相关推荐JCSprout Netty核心Reactor模式与事件驱动架构终极指南JCSprout Netty核心Reactor模式与事件驱动架构终极指南 JCSproutJava Core Sprout是一个专注于Java核心技术的开文档知识库后端教程tbox事件驱动Reactor模式的实现与应用tbox事件驱动Reactor模式的实现与应用 引言从阻塞到非阻塞的跨越 在传统的网络编程中阻塞I/O模型常常导致资源浪费和性能瓶颈。你是否也曾面临过服务跨平台并发编程异步编程深入理解electron-context-menu源码核心原理与实现逻辑深入理解electron context menu源码核心原理与实现逻辑 electron context menu是一个为Electron应用提供上下文菜单上一篇Isaac Sim 二进制安装验证指南从 isaac-sim.sh 启动到独立 Python 脚本运行下一篇dnscrypt-server-docker常见问题解决从日志分析到Unbound配置验证创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
上一篇/下一篇内容由系统自动关联
返回资讯列表 →