tbb::concurrent_queue 高性能的奧祕

在 如今的多線程開發的滾滾浪潮中,線程安全會是一個充滿正面色彩的廣告語,還是一個隱含性能低下令人不安的信息?衆所周知,STL庫所提供的容器均不能保證 線程安全,所有的工作都要需要開發者來承擔。最簡單的實現線程安全的手段便是使用鎖來同步對容器的訪問,只需要lock和unlock兩行語句,容器就變 成線程安全了,很簡單,不是嗎?不過,這時候"線程安全"就成了性能低下的同名詞,期望的併發操作成了對容器的串行訪問,我們不僅僅需要安全,還需要高性 能。

TBB::concurrent_queue令人驚異地同時做到了這兩點,在讓開發者放心地得到線程安全的同時,還可以心安理得的享受高性能的併發訪問。這其中會有什麼奧祕?作爲普通的開發者,我們可以從中學到什麼東西?

Herb Sutter在DDJ 一文中拋出並行編程的三個簡單論點,一是分離任務,使用更細粒度的鎖或者無鎖編程;二是儘量通過並行任務使用CPU資源,以提高系統吞吐量及擴展性;三是保證對共享資源訪問的一致性。

傳統的STL:queue 加鎖的方案,Intel Thread Profilec測量,圖的上半部分 fully utilized 只有9%(綠色部分),下半部分有大段時間處於串行執行狀態(黃色),並行運算度很低

這三個論點各有側重,我們看看concurrent_queue是怎麼做的。

所謂分解任務,以儘可能細的粒度執行,這樣就可以讓每個任務運行而不互相干擾。並行編程中有一個對應的重要概念,就是儘量使用thread的 private/local數據,例如ptmalloc中採用了好幾個private heap,以降低多個線程同時請求分配內存,造成訪問global heap時的contend。

針對一個concurrent_queue,內部預先分配了8個micro_queue,並保存在名爲array數組中,然後通過 concurrent_queue_rep::choose函數來選擇其中一個隊列,這樣做的好處是能把Multi Thread對1個queue的訪問平均分佈到多個內部的micro_queue上,從而儘可能避免衝突。

micro_queue& choose( ticket k ) {

// The formula here approximates LRU in a cache-oblivious way.

return array[index(k)];

}

static size_t index( ticket k ) {

return k*phi%n_queue;//phi=3,n_queue=8

}

具體選擇哪個micro_queue,取決於一個爲ticket類型的變量k,這個變量實質上是push操作次數的計數。

例如

k=0, 那麼選擇 array[0*3%8]=array[0]

k=1,array[1*3%8]=array[3]

...

k=7,array[5]

k=8,array[0] //又回到了與k=0時一樣的結果。

無鎖的push操作

即使能把queue分佈到內部多個micro_queue上,那還是不可避免的會有衝突存在。例如,有可能2個不同的thread都是在訪問同一個 micro_queue。由上面結果可以看出,假設thread A在進行push操作時,k=0,那麼使用的是array[0];而thread B同時在進行第8個push操作,k=8,使用的也是array[0],這種情況下怎麼辦?

concurrent_queue用這段代碼來解決共享資源的訪問衝突:

void micro_queue::push( const void* item, ticket k, concurrent_queue_base& base ) {

...

push_finalizer finalizer( *this, k+concurrent_queue_rep::n_queue );

if( tail_counter!=k ) {

ExponentialBackoff backoff;

do {

backoff.pause();

// no memory. throws an exception

if( tail_counter&0x1 ) throw bad_last_alloc();

} while( tail_counter!=k ) ;

}

...

}

對於每一次push操作,micro_queue使用了一個tail_counter來作爲阻塞標記。例如,array[0]被thread A佔用時,該標記會阻止thread B更新array[0] (此時tail_counter!=k),程序會在do{}while循環裏反覆測試等式是否成立,不然去執行backoff.pause()以進入睡眠 狀態。

MorganStanley的Petru marginean 在DDJ上的文章上曾經提出過幾種阻塞的同步方法,分別是NO_WAIT(循環查詢),SLEEP(睡眠),TIMED_WAIT(超時等待)以及 LOCK_NO_WAIT(鎖),TBB使用的這種類似於TIMED_WAIT的方法,通過pause指令讓線程暫停一會後,再重試。從網上得到的資 料,pause指令在這種spin_lock模式的阻塞中,可以提高25倍效率。

保證共享資源訪問的一致性,及使用new placement 改善內存分配的性能

通過以上兩點,TBB::concurrent_queue已經基本解決並行處理數據的問題,然而還有些細節可以提高性能。有關內存的操作永遠都是性能測試中的熱點(hotspot),改善性能也可以從內存管理上入手

例如,每次push操作都需要爲插入的元素分配內存,malloc是一個昂貴的操作,如果能夠預先分配好一大段連續內存,或者是從內存池取得一段內存呢?

TBB中,首先分配一個可以容納多個對象的page,然後在具體發生push操作時,使用C++的new placement語法,再從已分配的page上取得一個對象大小的內存。

/*overide*/ virtual page *allocate_page() {

size_t n = sizeof(page) + items_per_page*item_size;

page *p = reinterpret_cast(my_allocator.allocate( n ));

if( !p ) internal_throw_exception();

return p;

}

此外,concurrent_queue利用了TBB庫的成果:atomic來聲明一些原子變量,如tail_counter.

結論:

測試表明,並行度達到50%(圖上半部分的藍色柱),時間上也比STL:queue加鎖的版本快了2-3倍。

concurrent_queue是一個典型的高性能並行容器實現,相信concurrent_haspmap和其它的容器,內存池及算法,都可以採用類似的方法去提高性能。只不過,仍然有些對concurrent_queue不解的地方:

爲什麼不採用Lock-Free的算法直接實現一個micro_queue?而是仍然使用一種類似spin_lock的做法去同步線程?

不過,目前的方案很有可能是Intel開發人員的折中選擇,隊列負載均衡,再加上預分配內存後,push入隊操作應該很快,發生碰撞的機率並不大。畢竟Lock-Free還不夠足夠成熟。

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