背景
在GameServer使用 exit进行优雅关服时,还是coredump,资源回收异常,问题节点在 NFDataProcessModule。
前述(以windows环境为例)
1、在win32环境下,一个【进程】代表一个正在运行的应用程序,或者说代表一个应用程序的实例,而一个【线程】代表进程里代码的一条执行线路。【进程】本身是惰性二不执行任何代码的,每个进程至少有一个【主线程】,由主线程在进程的环境里执行代码(当然,也可以有多个线程)。所以从这个角度来看,在win32下【进程】与【模块】没有区别。
2、在win32环境下,模块分为两种:【进程内模块】和【进程外模块】。【进程内模块】共享进程的内存空间,比如在进程内加载的诸多dll,每个dll可被看作一个独立的模块;【进程外模块】与进程一样,独立运行,可包含自己的诸多dll。
3、一个模块代表的是一个运行中的exe文件或者dll文件,用来代表这个文件中的所有代码和资源(尤其是堆),所以在磁盘上的文件不是模块,载入内存后运行时才叫模块。同样的,一个应用程序调用其他dll中的api时,这些dll文件被载入内存,就产生了不同的【进程内模块】。
逐项技术分析
(一)
使用Dll编程中, 有一种情况是模块Dll-A 有一个消费者Class-Customer 是要使用模块Dll-B 生产(即调用CreateRes进行new 创建)的资源Class-Res:

在class-customer的对象析构时,需要释放类成员mRes的资源,这是不能直接在Dll-A内进行delete mRes; 因为mRes资源并不在模块Dll-A的堆内创建,只能由被创建(进行new)的模块Dll-B进行回收。
总结来说,保持资源创建与释放要保持一致性,申请资源调用 Dll-B::CreateRes(), 释放资源调用 Dll-B::ReleaseRes()。
(二)
考虑到用户未必能够合理地调用ReleaseRes(),进而造成资源泄漏或重复释放,可采用智能指针的方式,即Dll-B不再返回Res的原始指针,而是std::shared_ptr<Res>对象:

使用这种方式是不需要进行“显示地”ReleaseRes()。
在 Dll-B::CreateRes()里创建栈变量时std::shared_ptr<Res>,会通过new内建一个【引用计数器】ReferenceCounter,其再包裹实际的资源new Res;在std::shared_ptr<Res>作为函数值参数传递时,ReferenceCounter不会被重复创建,而是指针被传递;随着栈变量std::shared_ptr<Res>作为函数参数的传递,引用计数随着增加与减少引用数,这些过成功唯一的ReferenceCount对象不会被释放,唯一的Res也不会被释放;
(就当前情景而言)最后只在Dll-A::Customer 有一个作为类成员的std::shared_ptr<Res> mRes;ReferenceCounter只有一个对象并且引用数为1,唯一的Res对象也只有一个,被包裹在ReferenceCount,到此资源没有泄露,也没有多余。
后来Dll-A 移除了customer对象,会调用customer的析构函数,自动释放成员 std::shared_ptr<Res> mRes,mRes在被释放时时,调用ReferenceCounter::DecreaseRefCount()以减少引用数,这个是很关键理解,因为ReferenceCounter::DecreaseRefCount()的实现是在Dll-B模块内!!!所以ReferenceCounter 的引用数为0时会在Dll-B模块内做两件事:
1、在Dll-B模块内,调用对实际资源 class-Res的释放:delete res;
2、在Dll-B模块内,调用ReferenceCount的释放:delete self;
这样,ReferenceCounter和class-Res的 创建与析构都发生在 Dll-B内,资源回收正确。
(三)
以上讨论都是基于一个前提,class-customer和 class-res 的释放与析构都是在 Dll-A和Dll-B 模块卸载前进行。在【背景】里提到的“近期在GameServer使用exit进行优雅关服时,还是coredump,资源回收异常,问题节点在 NFDataProcessModule”,则没有确保这个“前提”。
在GameServer 进行关服时,Dll-A还未“显示地”释放class-custmoer对象前,Dll-B就被卸载了,导致后面释放析构class-custmoer,会调用customer的析构函数,自动释放成员std::shared_ptr<Res> mRes,mRes在被释放时时,调用ReferenceCounter::DecreaseRefCount()以减少引用数,这个是很关键理解,因为ReferenceCounter::DecreaseRefCount()的实现是在Dll-B模块内!!!所以ReferenceCounter 的引用数为0时会在Dll-B模块内做两件事:
1、在Dll-B模块内,调用对实际资源 class-Res的释放:delete res:但是,Dll-B已经卸载了,此处实际就是对野指针进行了delete,进而coredump;
2、(无法继续)在Dll-B模块内,调用ReferenceCount的释放:delete self;
总结:虽然std::shared_ptr 的引入有利确保dll之间的资源的管理,但是如果dll的卸载顺序没有维护好,依然会产生异常。
(四)
采用模块化资源管理的思想进行dll资源的管理:

思想是,将一个应用或功能设计成module,module至少包含两个操作步骤:资源释放 与 对象析构:【资源释放】,任何module在被析构前,都要先调用ReleaseRes()进行资源的释放,一般例如写入数据、关闭网络连接、关闭数据连接、做好logger等等功能逻辑相关的操作;【对象析构】,因为已经上一步已经做好了数据的管理、资源的释放,这里不会再进行功能逻辑的操作,一般只进行delete self操作。
这种做法是,第一步在所有dll现在之前,都必须先调用 IModule::ReleaseRes()进行资源的释放与回收;第二步安心地进行dll卸载。
(五)
其实GameServer 进行已经使用模块化的思想进行设计,其有两个dll模块进行交互:

模块GameLogicPlugin.dll 往 模块 DataProcessPlugin.dll 注册了一个资源Res,但是在DataProcessPlugin.dll::DataProcessModule::OnShutdown()没有进行资源的释放,最后GameLogicPlugin.dll 先卸载,接着DataProcessPlugin.dll 卸载进行析构时mResList 不为空,尝试移除mResList时,由于GameLogicPlugin.dll已经卸载,不能继续释放res资源,导致出现问题。如果程序员能够在各个module的OnShutdown() 正确地进行资源释放,则不会出现这些问题。
(六)
为了避免由于某些程序员由于疏忽而忘记在 module::OnShutdown() 进行资源释放,在此引入【模板模式设计模式】和借鉴【boost::signal2::connection】在观察者模式的实现机制。

该设计模式的核心思想是,在接口只提供对外的 Start()/Shutdown() 接口,并且由这两个接口定制好具体的步骤,置于这些步骤的具体实现,则由子类实现(多态)。
用boost::signal2 实现的观察者模式中,采用了connection对象的引入,其作用是: 1、“显示地”解除观察者与被观察者之间的 “观察关系”; 2、只要任何一方被析构了,都会自动进行“观察关系”的解绑:

置于connection的工作原理和详细设计,此处不再详述(可自行百度boost::signal观察者模式实现、或精灵项目的 Observable.hpp的实现),另外为了呼应前述的“要放在对象/模块释放前进行资源回收”,这一小节采用connection的【“显示地”解除观察者与被观察者之间的“观察关系”机制】:

在这种设计中,模块进行资源注册时,直接调用AddRes(),LogicModuleBase会自动建立连接connection并绑定;在dll进行卸载前,由模块基类提供的 shutdown自动进行connection的断开、同时因为connection维护了连接双方和资源的信息,此时connection的断开会自动进行资源的回收(例如std::vector<Res>的清空)。可以看到,随着新模块的增多,负责新模块的程序员只需要进行资源的注册AddRes(),就行了,module框架会自动进行shutdown 和 资源的回收。
网友评论