Facebook Folly原始碼分析

發表於2012-07-21

來源:《程式設計師

Folly 是 Facebook 的一個開源C++11元件庫,它提供了類似 Boost 庫和 STL 的功能,包括雜湊、字串、向量、記憶體分配、位處理等,用於滿足大規模高效能的需求。

6月初,Facebook 宣佈將其內部使用的底層 C++ 元件庫 Folly 開源,本文嘗試對 Folly 庫中的幾個重要的資料結構程式碼進行分析,包括一些實現細節的討論、特點和不足的分析,以及在工程上的應用。本文將首先分析 RWSpinlock.h 和 ThreadLocal.h 的原始碼。

Facebook logo

RWSpinlock.h

顧名思義,RWSpinlock 就是使用自旋方式進行臨界區資源等待的讀寫鎖,它與 pthread_rwlock_t有三個比較重要的區別。

●通常情況下等待鎖的執行緒不會主動讓出 CPU,而是迴圈中不斷地嘗試獲取鎖。

●使用原子操作處理讀者計數或寫者狀態,避免 pthread_rwlock_t無論讀寫的加鎖解鎖都要在互斥鎖的保護下進行。

●提供類似資料庫中”更新鎖”的機制,在儘量提供更高併發的情況下,避免死鎖。

Facebook Folly原始碼分析

表 1 讀寫鎖狀態相容矩陣

RWSpinlock 實現中幾處值得關注的地方如下。

自旋鎖在鎖粒度較小的情況下,使用自選等待方式等鎖,可以避免較高的上下文切換代價。而為了自適應多次獲取鎖失敗的情況,可以主動讓出 CPU。Folly 的實現比較簡單,硬編碼為在自旋 1000 次後仍無法獲取鎖的情況下,以後的每次迴圈都呼叫 sched_yield 主動讓出 CPU 以排程其他執行緒上來執行(要研究更復雜的自適應鎖機制,可以參考 Solaris 內部實現的 Adaptive locks 或注 1 提到的論文)。但在獲取讀鎖次數遠大於寫鎖次數的情況下,RWSpinlock 的讀優先機制可能造成寫者的飢餓,而主動讓出 CPU 的機制則可能加重寫者的飢餓程度。因此 Folly 中同時實現了可配置為寫者優先的 RWTicketSpinLockT 鎖。

與通常對讀計數器加 1 的思路不同,RWSpinlock 使用 int32_t的高 30 位儲存讀計數,而使用最低兩位儲存 upgrade 和 write 標記。在加解讀鎖時直接對 int32_t的鎖狀態原子加減0×4,直接避開了對最低兩位的修改,執行原子加0×4後再根據原子操作前的最低兩位是否有效,來決定是否需要回滾(減 0×4)。在多數只獲取讀鎖的情況下,不需要回滾,一次 ATOMIC_ADD 即完成讀鎖的加解鎖。

Facebook Folly原始碼分析

圖 1 使用比 ATOMIC_CAS 更高效的 ATOMIC_ADD 處理讀鎖的加鎖和解鎖

Folly 中的 RWSpinlock 還提供了類似資料庫中“更新鎖”的 upgrade 鎖,用於對鎖保護物件先讀後改時避免死鎖的需求,它與讀寫鎖的狀態相容矩陣如表 1 所示。

Write/Read/Upgrade 三種鎖狀態除了可以和初始狀態進行加解鎖的雙向轉換外,也可以在某些鎖狀態之間進行轉換,即原子的釋放原來的鎖並獲取到新的鎖,鎖狀態轉換如圖 2 所示。

Facebook Folly原始碼分析

圖 2 鎖狀態轉換

可以看出,除讀寫狀態外,其他任意兩個狀態之間都是雙向的轉換,只有讀寫之間是單向轉換,即在持有寫鎖的情況下,可以降級為讀鎖,而在持有讀鎖的情況下卻不能升級為寫鎖。原因很簡單,在兩個及以上執行緒都持有讀鎖,並嘗試獲取寫鎖的情況下,由於釋放讀鎖和獲取寫鎖必須原子性的完成,而要獲取寫鎖就要等待其他執行緒釋放讀鎖,在這種情況下執行緒將進入死鎖狀態。

因此某些對鎖保護物件需要先讀取再決定是否修改的情況,只能在讀取之前就加上寫鎖, 而在讀取後需要修改的情況很少時,這種方式代價就比較大,因為它阻塞了其他執行緒獲取讀鎖。Upgrade 就應運而生,從相容性矩陣可以看到,Upgrade 鎖與讀鎖相容,而與其他 upgrade 和寫鎖不相容,執行緒在讀取資料之前可以先獲取 upgrade,讀取資料之後,如果決定需要修改, 就升級為寫鎖。

一個具體的使用示例如下,如圖 3 所示,考慮實現一個在結點上加鎖的B+tree。

Facebook Folly原始碼分析

圖 3 結點加鎖B+tree 查詢

查詢操作,按照如下操作順序從根節點開始加鎖和查詢:

1. 對父節點加讀鎖;

2. 獲取子節點的讀鎖;

3. 釋放父節點讀鎖。

繼續在當前節點上執行第二步,直到查詢到葉子節點。

更新操作,按照如下操作順序從根節點開始加鎖和查詢:

1. 對父節點加 upgrade 鎖;

2. 獲取子節點的 upgrade 鎖;

3. 判斷子節點如果可能需要分裂或合併,升級父子節點為寫鎖;

4. 執行子節點分裂或合併,並修改父節點內容;

5. 釋放父節點鎖;

6. 繼續在當前節點上執行第二步,直到葉子節點,對葉子節點執行插入/刪除/修改。

因此在B+tree 的應用中,由於索引節點的分裂合併操作比較少見,使用 upgrade 鎖,避免與讀鎖的競爭,只有在必要時才升級為寫鎖。

關於C++0x 中 atomic 物件中的 memory_order,RWSpinlock 使用 std::atomic 儲存上面提到的讀鎖計數、寫鎖標記、 upgrade 鎖標記,使用了 fetch_add、fetch_and、fetch_or、compare_exchange_strong 這幾個原子操作函式來修改鎖狀態。作者在不同的場景下使用了三種不同的 memory_order,與作者溝通,他的解釋如下:

For example, unlock_shared () can be delayed to other memory_order_release (or memory_order_relaxed), but not memory_order_acquire, which means it ok for the compiler (and machine) to reordering unlock_shared () from different threads.

但從 gcc4.6.3 中 std::atomic 的實現和生成的彙編程式碼來看,上面提到的幾個原子操作函式,直接使用了 gcc 提供的幾個以__sync 開頭的內建原子操作,忽略掉了傳入的 memory_order 引數。而只有 store 函式的行為針對不同的 memory_order 只有是否增加 mfence 指令的差別。最後,筆者的建議是在效能影響不大的的情況下,直接使用 std::atomic 預設的高階別的 memory_order,因為通過分析複雜的原子操作指令優化時序,來決定 memory_order,收益可能不及它帶來的風險。

寫者優先的 RWTicketSpinLockT 鎖,提供寫鎖優先的排程機制,在有執行緒等待獲取寫鎖的情況下,不再授予讀鎖,避免在大量加讀鎖的場景下,寫鎖很難獲取的問題。使用了 gcc 內建的原子操作__sync_fetch_and_add 和__sync_bool_compare_and_swap 替代 std::atomic,並且也沒有用到其他C++0x 特性,使用舊版本 gcc 的專案可以使用這個鎖。

Folly 注重效率,因此 RWTicketSpinLockT 中也有幾處值得關注的細節。

●在等待獲取鎖的自旋中使用 pause 指令,一方面可以降低 CPU 的功耗,另一方面還可以幫助 CPU 優化指令流效率,具體可以參考注 2 的 Intel 白皮書。 而在寫鎖不優先的情況下,由於 pause 帶來的延遲可能導致寫鎖更不容易被獲取,因此獲取非優先的寫鎖不使用 pause 指令。

●使用 SSE 並行指令,對多個地址連續的整數一個指令完成++操作。

●減少分支判斷。見原始碼 try_lock_shared ()的 old.users = old.read。將 users 與 read 是否相等的邏輯延遲到 CAS 操作時順便判斷,儘管在加不上讀鎖的情況下,要多執行兩個自加和一個 CAS 操作,但在加讀鎖成功的多數情況下,省去了一次分支判斷。

●使用__sync_fetch_and_add 代替 __sync_bool_compare_and_swap。RWTicketSpinLockT 使用了名為 Write、Read、User 的三個計數器用來儲存讀鎖計數和寫鎖標記,方法比較巧妙。讀鎖需要在 user 等於 read 的情況下才可以加上,而寫鎖則需要滿足 user 等於 write,加解鎖邏輯如下。

1. 通過對 read 和 user 原子加一,獲取讀鎖,同時封鎖了加寫鎖的條件。

2. 通過對 read 原子加一,獲取寫鎖,同時也就封鎖了加讀鎖的條件;這裡通過先對 read 加一,封鎖了後續讀鎖的條件,然後再等待寫鎖的條件被滿足,實現了寫鎖優先的邏輯。

3. 通過對 write 原子加一,釋放讀鎖,同時恢復寫鎖的加鎖條件。

4. 通過對 read 和 write 原子加一,釋放寫鎖,同時恢復讀鎖的加鎖條件。

可以看出寫鎖的獲取和讀鎖的釋放可以避免使用 CAS,而用一個原子加即可實現。

ThreadLocal.h

在伺服器程式設計中,通常會遇到需要為每個執行緒都分配不同物件的情況,如執行緒處理一個請求需要使用的臨時記憶體、遠端呼叫需要臨時構造的引數等等。在需要的時候臨時構造,不僅要付出構造成本,還會有記憶體申請釋放的代價,而使用執行緒主函式的棧物件,每一層都要傳遞引數也讓程式碼很不便維護。

Folly 中實現的 ThreadLocal.h 提供了物件的執行緒區域性儲存和訪問,其功能與 pthread_getspecific 相似,提供了更方便友好的呼叫方式, 執行緒退出後自動析構本執行緒內所欲的私有物件,並且提供遍歷所有執行緒私有物件的介面。實現上使用了 GCC 內建特性,實現比 pthread 庫更快的執行緒私有物件訪問。

ThreadLocal 記憶體佈局如圖 4 所示,主要由 StaticMeta、ThreadEntry 和 ElementWrapper 三者組成。

 

圖 4 ThreadLocal 記憶體佈局

●StaticMeta 為全域性唯一結構,主要包括各個執行緒管理結構組成的連結串列的頭指標和物件 ID 生成器,用於全域性析構和遍歷所有執行緒的私有物件。

●ThreadEntry 為執行緒私有結構,每執行緒對應一個,主要包括執行緒執行緒私有物件的指標陣列,管理所有執行緒私有物件的指標,通過 ID 獲取指定物件的指標。

●ElementWrapper 是執行緒私有物件管理器,每個物件例項對應一個,主要包括指向物件例項的指標和物件的析構方法。

假設要管理的執行緒私有型別為T,初始化和訪問執行緒私有物件的流程如下。

1. ThreadLocalPtr 物件構造時即從 StaticMeta 為它管理的型別申請了唯一 ID。

2. 使用 TheadLocalPtr::get 方法通過 ID 從 ThreadEntry 管理的 ElementWrapper 陣列中獲取一個 ElementWrapper 物件。

3. 如果 ElementWrapper 中T的指標為空,則構造一個T的物件,指標和析構方法儲存在 ElementWrapper 物件中。

4. 如果 ElementWrapper 中的T指標不為空,則直接返回。

在 Folly 的實現中值得注意的是:ThreadEntry 物件在每個執行緒中有一個,使用 gcc 內建的 static __thread 方式宣告 ThreadEntry,即可實現同一個名字在不同執行緒訪問到的是不同物件,需要注意的是這種方式僅適用於 POD 型別。由於直接訪問物件,這種方式比呼叫 pthread 庫的 pthread_getspecific 函式呼叫方式效率要更高。

但由於 ThreadEntry 是 POD 型別,線上程退出時不能自動析構釋放它管理的執行緒私有物件,因此在 StaticMeta 構造時會申請一個 pthread_key_t註冊執行緒退出時的回撥函式,在回撥函式中遍歷當前執行緒 ThreadEntry 管理的所有私有物件,依次呼叫它們的析構方法。因此在 Folly 的實現中,對 pthread 庫函式僅僅使用了它線上程退出時呼叫回撥函式的功能。

此外還有兩處細節值得借鑑。

●ThreadLocal 還區分了單個執行緒退出和整體析構情況下,傳給析構方法不同的引數,以便使用者在必要的情況下區別這兩種情況下析構方法的實現。

●提供了指定立即析構釋放當前執行緒私有物件的方法,而不必等待執行緒退出時才釋放,這一點在單元測試多個 case 的情況下可能會使測試變得比較方便。

註釋

注1: Adaptive Locks: Combining Transactions and Locks for Efficient Concurrency

注2: Using Spin-Loops on Intel® Pentium® 4 Processor and Intel® XeonTM Processor

注3: A Provably Correct Scalable Concurrent Skip

注4: Hazard Pointers: Safe Memory Reclamation for Lock-Free Objects

注5: Doug Lea. ConcurrentSkipListMap. In java.util.concurrent

作者李凱,現任淘寶核心系統部儲存組技術專家,花名鬱白。2007-2010年曾參與百度分散式檔案系統研發,2010年至今參與淘寶 Oceanbase 專案研發。

 

相關文章