ComblockEngine(原KBEngine)源碼剖析2——對象池及其存在的BUG

先說點啥

在寫第一篇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源碼部分,問題驗證後,如何修復呢?

  1. 直接刪掉這個createObject<T1>的接口,因爲在T的對象池中搞個T1出來,我覺得真沒啥意義,而且該接口目前項目內也好像沒有真實使用,那就別留坑。
  2. 池對象統一添加一個身份標識接口,在createObject<T1>根據身份標識返回對象,同時偏特化assignObjs這個方法。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章