内存安全性#

又到了关于如何让C++别崩溃的话题。没办法啊必须要仔细研究一下才行

freekill-asio主要基于std+asio库开发。其中内存安全与线程安全需要特别留意关心。

线程安全#

在游戏中最最常见的多线程场景如图。当调用 asio::post 时,问题转为异步安全, 当直接调用某某方法时,需要探讨那个方法的线程安全性。

@startuml
!theme plain

title 常见的跨线程场景1

participant MainThread as MT
participant RoomThread as RT

MT -> RT: thread->foo()

activate RT

MT --> MT: 不关心结果\n继续执行

RT ->]: rpc call(foo)

RT <-]: rpc call(bar)

RT --> MT: asio::post(baz)\nawait
...
MT --> RT: return

RT --> RT: 或者直接baz()

RT ->]: rpc response(bar)

== Lua还会发出更多rpc call\n但服务端不会再向Lua发起call ==

RT <-]: rpc response(foo)

RT -> RT: 回到poll()

deactivate RT

@enduml

异步内存安全#

freekill-asio采用的内存管理策略:

  • 基于shared_ptr+weak_ptr的方式处理内存安全,尽量避免悬垂指针的问题。

  • 规定动态分配的类型必须在主线程完成构造和析构。

但是实际操练下来,疏漏的点还是不少。 主要容易出问题的点集中在主线程处理异步callback上,这个经常与对象生命周期相关, 处理不慎会出现use-after-free问题(主要是捕获的this指针变成悬垂指针了)。

@startuml
!theme plain

title 主线程异步场景

|io_ctx.run()|
start

|#AntiqueWhite|MainThread|
:处理某个回调;
note right
回调都是lambda表达式
以及通过std::bind创建的闭包
end note

|io_ctx.run()|
stop

@enduml

io_ctx.run() 差不多可以比照Qt的事件循环理解, 图中会触发异步回调的事件很多,稍微列举一些:

  • Socket有新数据可读

  • Socket读取到EOF(也就是断开连接)

  • Acceptor收到新连接请求

  • asio::steady_timer 时间到了

  • 某处调用了 asio::post

  • 以及其他

要明白异步调用带来的内存问题,我们首先要明确Functor的概念。Functor是一个匿名类, 它重载了`operator()`,并将“捕获”到的参数作为类的私有成员,返回这个类的实例。 这个捕获变量自然是通过拷贝的方式,再加上我们根本无法预测异步回调的执行顺序,因此注意事项有:

  • 若捕获了 std::shared_ptr ,那个指针的引用计数会随之+1 - 这会导致几个用来销毁对象的函数失效,对象因为这个引用没有释放 - 也可能导致对象在意料之外的情境下调用析构函数

  • 若捕获了 this ,无法保证执行时 this 是否还有效(除非是那几个单例)

  • 若捕获了 std::string_view ,那么执行到的时候它指向的data基本已经被释放了

例如:

asio::post([this](){ this->func(100); });

这个函数不会在主线程立刻执行,主线程甚至可能执行了某个会析构掉this的回调,再来调用上面这个。 这样会导致访问悬空的this导致程序崩溃(有时候不会崩溃,魅力C++)。 很遗憾 std::bind 和lambda都存在这个问题,毕竟this是个裸指针,本身不提供内存管理辅助。

解决方案一般是引入weak_ptr,在使用this之前先检查内存是否被释放。上面的代码一般会被改成:

asio::post([this, weak = weak_from_this()](){
  if (weak->lock()) {
    this->func(100);
  }
});

这样既不会因为lambda的存在延长this的生命周期,又能做到检测。

但是如果在另一个线程调用 weak->lock() 的话,若成功则返回 std::shared_ptr , 若对生命周期管理不当可能会在weak产出的指针引用计数减一之前主线程先把对象销了, 导致析构函数执行在主线程之外的线程。

其他注意点#

使用STL容器时,要尤其注意迭代器失效的问题。当遍历某个容器的时候, 循环体中的代码不能调用到任何可能导致修改这个容器的方法。

注意方法的语义。写的是什么名字就执行什么功能,不要隐含的搞特殊。

项目中实际的处理#

以上属于穿插着项目实际叙述了一些安全的注意点,接下来的文章结合具体类详细说明它们的处理逻辑。 只能说还好freekill-asio也就只是个5000行左右的小服务器项目,至少核心逻辑目前是这么点量级。