聊天系統中的用戶列表併發問題分析

1.問題描述

上週末一個做視頻直播的朋友向我諮詢他們遇到的一個關於大量內存對象併發的問題,具體問題描述是這樣的,在遊戲視頻直播的時候,需要向觀看直播的人提供一個可以自由聊天的功能(相當於QQ羣),這就要涉及到在服務器端實現一個管理用戶列表的功能,這個用戶列表可能很大(最大可以容納300萬人觀看和聊天)。他們的做法是在後端服務分爲兩層,如圖:

圖-1
gate用來做客戶端連接和消息分發的服務,chat是用來做用戶認、管理和消息轉發。那麼需要在chat上維護一下用戶
列表。他們遇到的問題就是當用戶列表比較大的情況下,chat的處理能力急劇下降。我詳細詢問了他關於用戶列表維
護的數據結構和併發控制,初步定位到了問題所在。

2.問題分析

我們先來分析一下他們的實現,他們採用的是C++和STL,熟悉C++/STL的朋友很快就會想到使用std::map 來實現管理,對的,這正是他們的思路,下面是他們實現的簡單描述:
class user{
public:
	uint64_t	user_id;
	/*todo:用戶信息基本信息*/
pthread_mutex_t mutex;		/*用於保護user的多線程併發*/
}

std::map<uint64_t, user*> user_map;
pthread_mutex_t	user_map_mutex; /*多線程操作時保護user_map*/

對map管理的用戶列表需要提供增、刪、改、查和遍歷。例如向某一個用戶進行操作:

LOCK(user_map_mutex);
std::map<uint64_t, user*>::iterator it = user_map.find(id);
if(it != user_map.end()){
UNLOCK(user_map_mutex);

	LOCK(it->second->mutex);
	operator(it->second); /*可能時間比較長,可能是發送網絡報文、信息寫盤、RPC調用等*/
	UNLOCK((it->second->mutex);
}
else
UNLOCK(user_map_mutex);


其他操作類似。這個實現有幾個嚴重的併發問題:

1.每次操作都需要對user_map進行LOCK

                2.每次對某個用戶操作都需要對用戶LOCK

                3.每次對用戶操作的函數可能時間會比較長,例如socket發包、RPC調用等。

3.user併發競爭優化

由於chat是個單點多線程併發系統,在網絡事件多的情況下,會發生大量的線程鎖競爭問題。最爲明顯的就是第二三個問題,其實這兩個是一個問題。解決這個我們只要把user中的mutex去掉即可。怎麼去?我的想法是採用user對象引用計數來實現。例如:
class user{
public:
	string	user_id;
	/*其他的一些用戶信息*/
	Int ref;	/*引用計數爲0時,free掉這個對象*/
}
 void add_ref(user* u){
	u->ref ++;
}
void release_ref(user* u){
	u->ref --;
	if(u->ref <= 0) delete u;
}

引用計數的操作規則:

在用戶信息加入用戶列表的時候,add_ref

在用戶從用戶列表中刪除的時候,release_ref

在用戶信息被引用的時候,add_ref

在用戶信息引用完畢的時候,release_ref

那麼對某個用戶的操作就會變成:

LOCK(user_map_mutex);
std::map<uint64_t, user*>::iterator it = user_map.find(id);
if(it != user_map.end()){
	user* u = it->second;
	add_ref(u);
	UNLOCK(user_map_mutex);

	operator(it->second); /*有可能時間比較長*/
	release_ref(u);
}
else
	UNLOCK(user_map_mutex);
User對象引用計數很好的解決的User加鎖的問題,但引用計數的引入了一個新的問題就是在多個線程同時修改某一個用戶信息時,會引發數據無法保護的問題。我們處理裏這個問題很簡單。不管是增加操作、修改操作和刪除操作,都遵循先必須將user_map中已存在的對應的user信息從map中刪除,再做信息新增。例如修改操作:
LOCK(user_map_mutex);
std::map<uint64_t, user*>::iterator it = user_map.find(id);
if(it != user_map.end()){
/*將舊的信息拷貝出來*/
	user* u =it->second;
	user_map.erase(it);
	copy(update_u, u);
	release_ref(u);	   /*解除引用*/

	update(update_u); /*修改用戶數據*/
	Add_ref(update_u);
	user_map.insert(update_u);
UNLOCK(user_map_mutex);
	
}
else
	UNLOCK(user_map_mutex);
增加和刪除的實現類似。對象引用計數很好的解決的用戶數據鎖競爭的問題,但在user_map的用戶數小於1萬以下,使用引用計數可以把增刪改查操作的併發問題避免掉。不能解決全map掃描併發問題,也不能解決在user_map很大時大量需要操作用戶信息的併發問題。問題出在不管是全map掃描還是對單個用戶都需要對user_map進行上鎖,這就是第一個問題了。在高併發請求下,這個user_map鎖會產生大量的競爭,造成資源損耗。

4.放棄std::map

要去掉這個鎖,這就回到了在問題分析中的第一個問題上,衆所周知,std::map是不支持多線程併發的,而且std::map操作對CPU cache並不友好。去掉這個全局鎖改用更小粒度的鎖,那就需要放棄std::map。在大量數據的情況下,一般會採用hash table或則btree來組織數據(研究過數據庫存儲引擎的人都知道,呵呵!)。簡單起見,這裏就以hash table爲例來展開分析。

圖-2


圖-2是一個hash table的結構圖,其中hash buckets是個數組。數組內有一個指向user結構的指針。好,瞭解了hash table的結構我們再回到前面縮小鎖粒度的問題上來。例如我們定義了一個hash table,它的buckets個數爲1024,我們再定義一個pthread_mutex_t數組,長度爲256。縮小鎖的粒度很簡單。

第一個mutex數組單元負責0 256 512 768序號bucket的互斥,第二個負責1 257 512 769序號的併發互斥,類推。計算一個bucket序號是由哪個mutex負責互斥的其實就是:
mutex下標 = bucket_seq % mutex_array_size;

這樣實現很容易理解,在內部的user對象操作我們還是採用引用計數的方法。細分了鎖粒度,能讓整個用戶列表具有非常好的併發性,同時因爲buckets是個連續的數組,對CPU L1/L2 cahce也非常的友好,也大大提高了CPU Cache的命中率問題。一般優化到此,基本上可以說做到了90%的工作。但還是有幾個疑問:

Ø  爲什麼要用pthread_mutex_t?在高併發下它會不會引起不必要的操作系統上下文切換?

Ø  除了hash table之外還有什麼數據結構能支持細粒度的鎖?

針對上面第一個疑問,我們可以使用CPU原子操作來實現一個簡單的mutex。例子如下:
void LOCK(int* q){
	int i;
	while(__sync_lock_test_and_set(q, 1)){
		for(i = 0; i < 32; i ++) cpu_pause();
		sched_yield(); /*釋放CPU執行權,讓操作系統重新調度本線程*/
	}
};

#define UNLOCK(q) __sync_lock_release((q))

那麼就可以將pthread_mutex_t數組去掉,由一個int數組來代替他的工作。

爲什麼可以這樣實現?在lock函數裏面難道空轉不耗CPU麼?這個可以結合我們的hash table來分析,一次hash table的增刪改查操作,一般幾百個CPU指令週期就可以完成(不計算hash函數運行時間,因爲計算hash(key)無需等待鎖),也就是說在LOCK等待的時間不長,而且CPU的指令執行速度遠遠大於CPU從內存中載入數據的速度,所以用CPU spin等待來換取操作系統因爲pthread lock造成的上下文切換損耗是值得的。這個可以自行去測試,呵呵。


對於這種hashtable結構併發量,我做了個初步的測試,測試機配置:4核2.4GCPU,內存16G,程序啓動8個線程進行測試,hashtable存有800萬個用戶信息,每秒可以支持100萬個左右查詢,50萬左右的增刪改。


5.思考

回到最初的問題,其實就是在內存中管理一個海量內存對象的問題,這不是什麼新技術,在數據庫存儲引擎中,隨處可以看到這樣的解決方案,例如:memcache的索引實現、innodb的自適應hash索引實現和btree實現、lsm樹的memtable實現,無一不是解決此類問題的。通過這個問題的分析,可以得到以下幾個認識:

1.     C++從業人員在高併發設計上應該慎用stl/boost,它們的很多數據結構對多核併發並不友好,這裏僅僅是針對C++說的。

 

2.     很多看似非常難的問題,其實很多其他領域的系統有很好的解決方案。作爲C/C++從業人員,應該多去了解數據庫內核、操作系統內核或者編程語言內核(JVM/GOruntime)。這三個地方有挖不完的技術寶藏。

 

3.     C/C++語言在多核併發控制上可以說很原始,作爲C/C++從業人員的我們,應該多去了解CPU的工作機制、C/C++的內存模型等,這樣有利於我們去分析系統瓶頸和優化系統。

 

4. 放棄意味着收穫更多,放棄C++,選用更容易編寫併發程序的語言編寫此類系統,例如go、scala、erlang。

遺留的思考題

1.     用CPU CAS + memory barrier怎麼實現hash table的無鎖併發?可以嘗試去實現一下看看。

2.     除了用hash table解決海量用戶列表問題,還可以用skip list、btree等數據結構來實現,怎麼實現?skip list、btree和hash table對比優劣勢在什麼地方?

3.     hashtable在管理海量用戶列表時,它有缺點麼?有什麼樣的缺點?



發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章