先说点啥
在写第一篇CBE的源码博客—— 角色账号登录和管理 时,注意到CBE里面到处充斥着诸如_g_objPool对象,显而易见,那肯定是个对象池,今天闲来无事,准备读一读CBE的对象池是咋实现的,然后就有了这篇小文章。
同时,在读源码时,发现这里面有个叫createObject的泛化接口存在一个隐藏的致命BUG,并做了相关实验验证,具体的会在下面提到。
何为对象池
顾名思义,对象池,就是用来放对象的池子。在面向对象编程里,万物都可以抽象成对象,可以是一个网络处理对象,可以是一个具体的游戏实体对象。在C++中,一个堆对象的出生需要靠new,消亡需要靠delete。在堆上申请空间,创建出一个对象出来,是需要一定的CPU消耗的,如果某一类对象会非常高频的被创建和销毁(比如网络包等),那这部分创建对象和销毁对象的CPU消耗是不能忽视的。
如何解决这个问题呢?可以考虑在一开始就先申请一块足够大的内存空间来存放某类对象实例,当需要创造一个新对象时,不用直接问操作系统,先看看这个预申请的空间中是否还有闲置的对象可以复用,以此避免频繁让操作系统进行开辟/销毁内存空间。这个预申请的空间就是“对象池”。
对象池是应用层面的东西,通过对象复用的方式来避免高频、重复创建对象,它会事先创建一定数量的对象放到池中,当需要创建对象时,直接从对象池中获取,用完对象之后再放回到对象池中,以便复用,如果池子空了,则开次创建一定量的对象投入池子中。
CBE中的对象池
CBE中对象池实现的代码是lib/common/objectpool.h,具体详细的自行打开阅读~
先上一张结构UML图,如下:
通过上图可以一目了然,所有能放进池子里面的对象都必须是PoolObject的子类,PoolObject里面的主要虚函数(需要子类重写的)如下:
- destructorPoolObject:析构前的处理
- onReclaimObject:对象回收时触发
- getPoolObjectBytes:获取对象的实际占位大小
对象池PoolObject的基本成员变量:
template< typename T, typename THREADMUTEX = KBEngine::thread::ThreadMutexNull >
class ObjectPool
{
public:
typedef std::list<T*> OBJECTS;
protected:
OBJECTS objects_;
size_t max_;
bool isDestroyed_;
// 一些原因导致锁还是有必要的
// 例如:dbmgr任务线程中输出log,cellapp中加载navmesh后的线程回调导致的log输出
THREADMUTEX* pMutex_;
std::string name_;
size_t total_allocs_;
// Linux环境中,list.size()使用的是std::distance(begin(), end())方式来获得
// 会对性能有影响,这里我们自己对size做一个记录
size_t obj_count_;
// 最后一次瘦身检查时间
// 如果长达OBJECT_POOL_REDUCING_TIME_OUT大于OBJECT_POOL_INIT_SIZE,则最多瘦身OBJECT_POOL_INIT_SIZE个
uint64 lastReducingCheckTime_;
// 记录的创建位置信息,用于追踪泄露点
std::map<std::string, ObjectPoolLogPoint> logPoints_;
};
代码注释其实写的很详细了,对象池中的所有对象都放在一个名为objects_的对象列表中,obj_count是用来记录当前池子中闲置对象的个数,total_allocs_是指整个池子总共创建的对象个数。
- 池子内存管理
在某些处理高峰期,可能会造成池子的短暂性扩容,之后系统并不需要那么大的池子,对于这种池子,如果不对其进行“瘦身”,其实是白占地土的。CBE里面,对池子瘦身是发生在对一个池对象或者池对象列表的回收流程中。每次回收对象时,会判断当前池子的大小是否大于了初始的大小OBJECT_POOL_INIT_SIZE,如果两次检测时发现大小都超过,并且检测时长大于OBJECT_POOL_REDUCING_TIME_OUT值,则对未使用对象进行回收,以此缩小池子大小。void reclaimObject_(T* obj) { if(obj != NULL) { if(size() >= max_ || isDestroyed_) { delete obj; --total_allocs_; } else { objects_.push_back(obj); ++obj_count_; } } uint64 now_timestamp = timestamp(); if (obj_count_ <= OBJECT_POOL_INIT_SIZE) { // 小于等於则刷新检查时间 lastReducingCheckTime_ = now_timestamp; } else if (now_timestamp - lastReducingCheckTime_ > OBJECT_POOL_REDUCING_TIME_OUT) { // 长时间大于OBJECT_POOL_INIT_SIZE未使用的对象则开始做清理工作 size_t reducing = std::min(objects_.size(), std::min((size_t)OBJECT_POOL_INIT_SIZE, (size_t)(obj_count_ - OBJECT_POOL_INIT_SIZE))); //printf("ObjectPool::reclaimObject_(): start reducing..., name=%s, currsize=%d, OBJECT_POOL_INIT_SIZE=%d\n", // name_.c_str(), (int)objects_.size(), OBJECT_POOL_INIT_SIZE); while (reducing-- > 0) { T* t = static_cast<T*>(*objects_.begin()); objects_.pop_front(); delete t; --obj_count_; } //printf("ObjectPool::reclaimObject_(): reducing over, name=%s, currsize=%d\n", // name_.c_str(), (int)objects_.size()); lastReducingCheckTime_ = now_timestamp; } }
- 对象创建来源记录
为了便于内存泄露等排查,通过对象池创建的对象,都需要传入一个字符串用来标识来源(通常是请求创建的函数名+行号),这个信息会被记录在对象池的 logPoints_这个map中,key就是来源的字符串信息描述,value是请求次数。
隐藏的BUG
在阅读源码时看到如下这段代码:
/**
强制创建一个指定类型的对象。 如果缓冲里已经创建则返回现有的,否则
创建一个新的, 这个对象必须是继承自T的。
*/
template<typename T1>
T* createObject(const std::string& logPoint)
{
pMutex_->lockMutex();
while(true)
{
if(obj_count_ > 0)
{
T* t = static_cast<T1*>(*objects_.begin());
objects_.pop_front();
--obj_count_;
incLogPoint(logPoint);
t->poolObjectCreatePoint(logPoint);
t->onEabledPoolObject();
t->isEnabledPoolObject(true);
pMutex_->unlockMutex();
return t;
}
assignObjs();
}
pMutex_->unlockMutex();
return NULL;
}
void assignObjs(unsigned int preAssignVal = OBJECT_POOL_INIT_SIZE)
{
for(unsigned int i=0; i<preAssignVal; ++i)
{
T* t = new T();
t->isEnabledPoolObject(false);
objects_.push_back(t);
++total_allocs_;
++obj_count_;
}
}
这段代码的意图是,希望从T类型的对象池中创造一个T1类型的对象,代码上面特别标注着T1必须继承自T。仔细一看,貌似有点不对劲,只要保证T1继承与T,返回的结果和预期就一定一致吗??
注意看,assignObjs里面在堆上申请空间用的是T类型,而createObject<T1>接口的调用者的目的肯定是想开辟一块内存大小为T1的对象出来,T1是继承自T的,也就是sizeof(T1) >= sizeof(T)。这就会导致一个严重的问题,一个对象池里面返回的对象实际可用的合法空间可能会小于预期值,导致两个对象之间的部分数据地址是重叠的,或者会访问到一个未知的内存空间。如果访问未知地址,程序crash反而容易查bug,如果是对象地址有重叠,这种bug就很难排查了!!
为了方便说明,可以参见下面这张图,这个Unknown区域就是出问题的地方。
BUG验证
为了验证自己的这个想法,我仿照着写了个简单的对象池,并进行了数据填充模拟,完整的测试Demo可以参考我github上面的例子——链接点这里
测试的核心代码如下:
class PoolObjectBase
{
public:
PoolObjectBase(){}
int base_value = 0;
};
struct Fill
{
int a = 0;
int b = 0;
int c = 0;
int d = 0;
int e = 0;
int f = 0;
int g = 0;
};
class PoolObjectDrived : public PoolObjectBase
{
public:
PoolObjectDrived(): PoolObjectBase() {}
Fill f1;
Fill f2;
int a = 0;
int drived_value = 0;
};
int main()
{
ObjectPool<PoolObjectBase> object_pool;
PoolObjectBase* obj1 = object_pool.createObject<PoolObjectDrived>();
obj1->base_value = 1;
PoolObjectDrived* real_obj1 = static_cast<PoolObjectDrived*>(obj1);
real_obj1->drived_value = 2;
PoolObjectBase* obj2 = object_pool.createObject();
obj2->base_value = 3;
cout << "obj1.base_value = " << real_obj1->base_value << " " << "drived_value = " << real_obj1->drived_value << endl;
cout << "obj2.base_value = " << obj2->base_value << endl;
cout << "obj1.base_value's address: " << &real_obj1->base_value << " drived_value's address: " << &real_obj1->drived_value << endl;
cout << "obj2.base_value's address: " << &obj2->base_value << endl;
return 0;
}
上述测试代码的意图很简单,就是从一个PoolObjectBase的对象池中获取两个对象obj1和obj2,只是obj1的类型是PoolObjectDrived,这是个继承于PoolObjectBase的对象,获取到obj1后将其转为“真实的”PoolObjectDrived类型的对象real_obj1,然后对real_obj1的drived_value赋值为2。
按照预期,real_obj1的base_value值应该是1,real_obj1的drived_value值应该是2,obj2的base_value值应该是3。
可是,跑出来的实际结果却是1、3、3。见下图
(测试环境:CentOS Linux release 7.3.1611;g++ 6.3.1 )
通过看打印出来的地址数据,其实就很清楚解释了上面的问题,obj2的首地址和obj1中drived_value成员变量的地址重叠了!导致数据被覆盖。测试代码里面的Fill结构体就是我用来做占位字节填充的,目的就是让PoolObjectDrived的内存布局刚好和PoolObjectBase发生首位重叠,如此方便说明问题。
BUG修复
回到CBE源码部分,问题验证后,如何修复呢?
- 直接删掉这个createObject<T1>的接口,因为在T的对象池中搞个T1出来,我觉得真没啥意义,而且该接口目前项目内也好像没有真实使用,那就别留坑。
- 池对象统一添加一个身份标识接口,在createObject<T1>根据身份标识返回对象,同时偏特化assignObjs这个方法。