day16-重构服务器、使用智能指针
至此,本教程进度已经过半,在前15天的学习和开发中,相信大家对服务器的开发原则、核心模块的组织有了一个初步的了解,也有能力写出一个“乞丐”版本的服务器。但重温之前的代码,相信大家已经遇到过无数bug。包括内存相关的,如内存泄漏、野指针、悬垂引用等,还有网络编程相关的,如无效socket、连接意外终止、TCP缓冲区满等,还有事件相关的,如epoll、kqueue返回其他错误情况,等等。这是由于之前是从零开始构建整个服务器,从C语言风格逐渐到C++风格,从单线程到多线程,从阻塞式IO到非阻塞式IO,从任务驱动到事件驱动。所以从一开始就并没有考虑到良好的编码风格、编程习惯和设计模式,自然就会带来许多问题和程序bug。如果继续这样开发下去,但项目越来越大、结构越来越复杂、模块越来越多,程序细节上与设计上的缺陷迟早会逐渐暴露。
一个优秀的程序员、尤其是面向系统编程的程序员,要时刻铭记以下准则:
程序中所有可能的异常与错误终将发生。
所以对程序进行重构是很有必要的,重构可以弥补程序之前的设计、细节缺陷,应用最新的、最先进的编程技术和经验。对服务器开发的学习者来说,重构也可以让我们对整个程序架构有更抽象、更深入的了解。此外,程序的重构越早越好,因为早期重构需要改动的代码量不大。如果程序已经逐渐成为一个大型屎山、甚至已经上线交付,此刻再进行重构将会十分困难、甚至得不偿失。
目前,我们已经掌握了高并发服务器的最小核心架构,所以可以根据这个架构重新设计服务器。在之前的入门学习阶段,我们由面向过程编程、逐渐抽象出类、最终形成整个架构,在重构阶段我们完全可以面向对象、面向系统编程,以一个更抽象、更高层、更大局观的视角来设计整个核心库。
重构的一个重点,就是内存管理。在之前的代码中,所有的内存都用裸指针来管理,在类的构造阶段分配内存、析构阶段释放内存。这种编码便于理解,可以让新手清晰地掌握各资源的生命周期,但绝不适用于大型项目,因为极易产生内存泄漏、悬垂引用、野指针等问题。在muduo库的早期设计,陈硕使用了RAII来管理内存资源,具体细节可以参考《Linux多线程服务器编程》,这个优秀的内存资源管理设计被应用于许多项目和语言(如rust)。从C++11标准后,我们也可以使用智能指针来管理内存,让程序员无需过多考虑内存资源的使用。标准中三种智能指针的使用和区别在这里不再赘述,可以参考《C++ Primer》第12章。
重构的另一个重点,就是避免资源的复制操作,尽量使用移动语义来进行所有权的转移,这对提升程序的性能有十分显著的帮助,这也是为何rust语言性能如此高的原因。
由于重构后的代码涉及到大量所有权转移、移动语义、智能指针等,如果读者现在还对栈内存与堆内存的使用十分模糊,请立刻停止阅读并打好基础,继续学习将会事倍功半!
所以在重构后的代码中,类自己所拥有的资源用std::unique_ptr<>
来管理,这样在类被销毁的时候,将会自动释放堆内存里的相关资源。而对不属于自己、但会使用的资源,采用std::unique_ptr<> &
或std::shared_ptr<>
来管理会十分麻烦、不易与阅读并且可能对新手带来一系列问题,所以我们参考Chromium的方式,依旧采用裸指针来管理。通过这样的设计,不管程序发生什么异常,资源在离开作用域的时候都会释放其使用的堆内存空间,避免了内存泄漏等诸多内存问题。
重构的第三个重点就是错误、异常的处理。在目前的程序中,由于是开发阶段,我们尽可能暴露所有的异常情况,并使用assert、exit等方式使程序在发生错误时直接崩溃,但这样会使程序不够健壮。程序中有些错误是不可恢复的,遇见此类错误可以直接退出。但对于大型项目、尤其是线上远行的网络服务器、数据库等不断提供服务的程序来说,可靠性是十分重要的一个因素,所以绝大部分错误都是可恢复的。如创建socket失败可能是文件描述符超过操作系统限制,稍后再次尝试即可。监听socket失败可能是端口被占用,切换端口或提示并等待用户处理即可。打开文件失败可能是文件不存在或没有权限,此时只需创建文件或赋予权限即可。所以在底层的编码上,对于部分错误需要进行可恢复处理,避免一个模块或资源发生的小错误影响整个服务器的运行。
以下是重构后TcpServer
类的定义:
class TcpServer {
public:
......
private:
std::unique_ptr<EventLoop> main_reactor_;
std::unique_ptr<Acceptor> acceptor_;
std::unordered_map<int, std::unique_ptr<Connection>> connections_;
std::vector<std::unique_ptr<EventLoop>> sub_reactors_;
std::unique_ptr<ThreadPool> thread_pool_;
std::function<void(Connection *)> on_connect_;
std::function<void(Connection *)> on_recv_;
};
可以看到,main_reactor_
、acceptor_
、connections_
、sub_reactors_
和thread_pool_
都是该服务器拥有的资源,在服务器实例被销毁时,这些资源也需要被销毁,所以使用智能指针std::unique_ptr<>
来管理,一旦该TcpServer
实例被销毁,不需要手动释放这些资源、程序会自动帮我们释放,避免了内存泄漏。
而对于Channel
类:
class Channel {
public:
......
private:
int fd_;
EventLoop *loop_;
short listen_events_;
short ready_events_;
bool exist_;
std::function<void()> read_callback_;
std::function<void()> write_callback_;
};
可以看到,该类中有一个成员loop_
,表示该Channel
实例所在的事件循环EventLoop
。而Channel类并不拥有该事件循环资源,仅仅是为了访问而存在的一个指针,所以在该Channel
被销毁时,也绝不可以释放loop_
,所以这里使用裸指针来表示仅需访问但不拥有的资源。
至此,今天的教程就结束了,我们对前15天的代码进行了重构,使用智能指针std::unique_ptr<>
来管理独占资源,避免了内存泄漏、内存资源的浪费,也使各组件的生命周期更加明确。我们还尽可能使用了移动语义进行所有权的转移(如针对std::function<>
),减少了资源复制带来的开销。同时对一部分代码进行了精简、重写,使其更加符合C++编码规范。同时核心库的api以及命名也发生了改变,更加清晰、易用。
完整源代码:https://github.com/yuesong-feng/30dayMakeCppServer/tree/main/code/day16