ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

网络编程(5)—— Reactor实现(v3)

网络编程(5)—— Reactor实现(v3) 网络编程5—— Reactor实现(v3)回顾前面两个版本我们做了两件事v1把socket / bind / listen / accept / read / write这些系统调用用 C 的类封装了一遍Socket、InetAddress、Acceptor、SocketIO、TcpConnectionv2封装了 epoll 的三个接口写了EventLoop这个事件循环 事件分发的核心并给每条连接注册了三个回调——连接建立、消息到达、连接断开TCP 的三个半事件里的三个剩下半个是消息发送完毕。v2 最后的测试代码长这样voidtest0(){Acceptoracceptor(0.0.0.0,8080);acceptor.ready();EventLoopeloop(acceptor);eloop.setNewConnectionCallback(onNewConnection);eloop.setMessageCallback(onMessage);eloop.setCloseCallback(onClose);eloop.loop();}能跑但有两个问题内部实现细节漏到了调用方。先ready()、再构造EventLoop、最后loop()这套顺序是服务器内部的约定使用者不该知道也不该有机会写错。注册回调太啰嗦。三个回调要一个一个 set而这三个回调在语义上是一组的都属于一条连接的生命周期。顺带说一句仓库里还有一个4.Reactor_V2.2那是加回调之前的中间版本——只有一个光秃秃的事件循环不做任何回调。有兴趣可以翻着看本系列不展开。所以v3 只做一件事把服务器的组装封装成一个TcpServer类。这也正是 v2 结尾留下的那句话——“为了更好的使用可以对当前进行一次封装”。v3 的目标把Acceptor监听、accept和EventLoop事件循环组装起来对外只暴露三个接口接口作用setAllCallback(cb1, cb2, cb3)一次性注册三个回调start()让服务器跑起来内部完成 ready loopstop()让事件循环退出调用方从此只需要三行。类图与设计思路【图 1Reactor_v3 类图】—— 在 v2 的类图上加一个TcpServer它组合持有Acceptor和EventLoop两个成员即可。懒得画图的话下面这段 mermaid 可以直接粘进 CSDN 的 mermaid 代码块组合组合引用TcpServer-Acceptor _acceptor-EventLoop _loopTcpServer(ip, port)start()stop()setAllCallback(cb1, cb2, cb3)Acceptor-Socket _sock-InetAddress _addrready()accept() : intfd() : intEventLoop-int _epfd-vectorepoll_event _evtList-bool _isLooping-Acceptor _acceptor-mapint,TcpConnectionPtr _connsloop()unloop()setNewConnectionCallback(cb)setMessageCallback(cb)setCloseCallback(cb)几个需要想清楚的点1为什么TcpServer里持的是Acceptor/EventLoop的对象而EventLoop里持的是Acceptor的引用EventLoop只是要借用Acceptor的fd()和accept()两个函数它不负责Acceptor的生死所以用引用v2 里就是这么写的也没必要改。而TcpServer是这两个东西的拥有者用值成员靠TcpServer的生命周期去兜住它们不需要自己写new/delete。2成员声明顺序有讲究。private:Acceptor _acceptor;// 必须写在前面EventLoop _loop;// _loop(_acceptor) 依赖它C 的成员初始化顺序由声明顺序决定跟初始化列表里写的顺序无关。EventLoop的构造函数体里要用_acceptor.fd()去注册 listenfd如果_loop声明在前面它就会先构造此时_acceptor还没构造完拿到的是未初始化的 fd——这是那种偶尔能跑、换个编译器就炸的 bug。写初始化列表的时候顺手对齐声明顺序是个好习惯。3ready()为什么放进start()里因为ready()决定的是监听套接字什么时候真正可用而服务器启动这件事的语义就是它。调用方不用再关心 bind/listen 的时机。这里有个细节值得说一下Acceptor里的Socket是在构造时就调::socket()创建好 fd 的bind/listen 是后面ready()才做的。所以EventLoop构造时把这个还没 listen 的 fd 注册进 epoll 是合法的epoll 对普通套接字一视同仁只是它暂时不会可读等start()里ready()执行完并listen()之后新连接一来这个 fd 就会可读。整个顺序是安全的。4回调用右值引用传递。setAllCallback的参数是Callback 也就是functionvoid(const TcpConnectionPtr) 内部std::move转交给EventLoop的三个 setterEventLoop的 setter 也是右值引用——一路移动不拷贝std::function内部的那块内存也不把EventLoop自己的回调搬空。代码实现TcpServer.h#ifndef__TCP_SERVER_H#define__TCP_SERVER_H#includeEventLoop.husingCallbackTcpConnectionCallback;classTcpServer{public:TcpServer(conststringip,unsignedshortport);~TcpServer();voidstart();voidstop();voidsetAllCallback(Callbackcb1,Callbackcb2,Callbackcb3);private:Acceptor _acceptor;EventLoop _loop;};#endifusing Callback TcpConnectionCallback;只是给上层一个好念的名字——对使用者来说回调就是回调不需要知道它内部是std::functionvoid(const TcpConnectionPtr)。TcpServer.cpp/* ************************************************************************ File Name: TcpServer.cpp Author: hyj mail: 1683958261qq.com Created Time: Mon 03 Aug 2026 03:42:40 PM CST Description: ************************************************************************/#includeTcpServer.hTcpServer::TcpServer(conststringip,unsignedshortport):_acceptor(ip,port),_loop(_acceptor){}TcpServer::~TcpServer(){}voidTcpServer::start(){_acceptor.ready();_loop.loop();}voidTcpServer::stop(){_loop.unloop();}voidTcpServer::setAllCallback(Callbackcb1,Callbackcb2,Callbackcb3){_loop.setNewConnectionCallback(std::move(cb1));_loop.setMessageCallback(std::move(cb2));_loop.setCloseCallback(std::move(cb3));}start()里就两步先把监听套接字准备好再进事件循环。loop()是阻塞的所以start()之后的代码要等服务器停下来才会执行——这一点在下面说stop()的时候会用到。Test.cpp测试文件#includeTcpServer.h#includeiostreamusingstd::cout;usingstd::endl;voidonNewConnection(constTcpConnectionPtrcon){coutcon-toString() connected!endl;}voidonMessage(constTcpConnectionPtrcon){string msgcon-receive();coutrecv msg from client:msgendl;msgmsg:msg;con-send(msg);}voidonClose(constTcpConnectionPtrcon){coutcon-toString() closed endl;}voidtest0(){TcpServerserver(0.0.0.0,8080);server.setAllCallback(onNewConnection,onMessage,onClose);server.start();}intmain(){test0();return0;}跟 v2 的测试代码对比一下少了一行accept、少了一次ready()、少了三次set而且再也不可能写错顺序了。这就是封装的意义——不是少打字是让错误变得不可能。编译与运行g *.cpp-oserver-stdc11-llog4cpp-lpthread./server另开两个终端用nc或者前几篇文章里自己写的客户端连上来看nc127.0.0.18080hello msg:hello服务器端recv msg from client: hello recv msg from client: world【图 2两个客户端同时连接、服务器日志截图】功能上跟 v2 完全一样——v3 是重构不是加功能。别指望这里有性能提升它只是把代码的边界划清楚了。关于 stop() 的一个说明voidTcpServer::stop(){_loop.unloop();// 只是把 _isLooping 置成 false}unloop()只是改了个标志位而loop()正阻塞在epoll_wait我们设的超时是 3000ms里所以stop()生效最多要等 3 秒。而且在单线程模型里start()已经把线程占住了你根本没机会在同一条线程里调stop()——只能从其它线程、或者从回调里调。这算是个半成品的退出机制。等 v4 把多线程引进来EventLoop有了唤醒的能力这个问题顺手就解决了unloop()里加一次wakeup()让epoll_wait立刻返回。小结与遗留问题到这里服务器的骨架算是搭完了v1 封装系统调用 → v2 事件驱动 → v3 把骨架打包成一个好用的壳。但核心问题一个都没解决我们的服务器是串行执行的。事件循环是单线程的onMessage回调也在同一个线程里执行。如果onMessage里做的是读数据库“算一个大矩阵”写文件这类耗时操作那么在它执行完之前整个反应堆是停摆的——所有其它连接的读写都要排队等它。voidonMessage(constTcpConnectionPtrcon){string msgcon-receive();// 如果这里 sleep(1) 或者查一次数据库……// 这 1 秒里所有其它客户端都是卡住的con-send(msg:msg);}这就极大的降低了我们服务器的性能所以下一版要解决的是把计算从事件循环里剥出去交给线程池。【图 3v3 串行处理 vs v4 计算/IO 分离的示意图】但事情没那么简单——业务线程算完要回发数据如果它直接send()就会和事件循环线程同时往同一个 fd 上写而且它还得知道这条连接是不是已经被关掉了。所以 v4 除了线程池还要给EventLoop加一套跨线程投递任务 唤醒的机制runInLoopeventfd。这些就是下一篇文章的内容。
返回列表