1. 程式人生 > >Linux中的執行緒區域性儲存

Linux中的執行緒區域性儲存

在Linux系統中使用C/C++進行多執行緒程式設計時,我們遇到最多的就是對同一變數的多執行緒讀寫問題,大多情況下遇到這類問題都是通過鎖機制來處理,但這對程式的效能帶來了很大的影響,當然對於那些系統原生支援原子操作的資料型別來說,我們可以使用原子操作來處理,這能對程式的效能會得到一定的提高。那麼對於那些系統不支援原子操作的自定義資料型別,在不使用鎖的情況下如何做到執行緒安全呢?本文將從執行緒區域性儲存方面,簡單講解處理這一類執行緒安全問題的方法。

一、資料型別

在C/C++程式中常存在全域性變數、函式內定義的靜態變數以及區域性變數,對於區域性變數來說,其不存線上程安全問題,因此不在本文討論的範圍之內。全域性變數和函式內定義的靜態變數,是同一程序中各個執行緒都可以訪問的共享變數,因此它們存在多執行緒讀寫問題。在一個執行緒中修改了變數中的內容,其他執行緒都能感知並且能讀取已更改過的內容,這對資料交換來說是非常快捷的,但是由於多執行緒的存在,對於同一個變數可能存在兩個或兩個以上的執行緒同時修改變數所在的記憶體內容,同時又存在多個執行緒在變數在修改的時去讀取該記憶體值,如果沒有使用相應的同步機制來保護該記憶體的話,那麼所讀取到的資料將是不可預知的,甚至可能導致程式崩潰。
如果需要在一個執行緒內部的各個函式呼叫都能訪問、但其它執行緒不能訪問的變數,這就需要新的機制來實現,我們稱之為Static memory local to a thread (執行緒區域性靜態變數),同時也可稱之為執行緒特有資料(TSD: Thread-Specific Data)或者執行緒區域性儲存(TLS: Thread-Local Storage)。這一型別的資料,在程式中每個執行緒都會分別維護一份變數的副本(copy),並且長期存在於該執行緒中,對此類變數的操作不影響其他執行緒。如下圖:

二、一次性初始化

在講解執行緒特有資料之前,先讓我們來了解一下一次性初始化。多執行緒程式有時有這樣的需求:不管建立多少個執行緒,有些資料的初始化只能發生一次。列如:在C++程式中某個類在整個程序的生命週期內只能存在一個例項物件,在多執行緒的情況下,為了能讓該物件能夠安全的初始化,一次性初始化機制就顯得尤為重要了。——在設計模式中這種實現常常被稱之為單例模式(Singleton)。Linux中提供瞭如下函式來實現一次性初始化:

#include <pthread.h>

// Returns 0 on success, or a positive error number on error
int pthread_once (pthread_once_t *once_control, void (*init) (void)); 利用引數once_control的狀態,函式pthread_once()可以確保無論有多少個執行緒呼叫多少次該函式,也只會執行一次由init所指向的由呼叫者定義的函式。init所指向的函式沒有任何引數,形式如下: void init (void) { // some variables initializtion in here } 另外,引數once_control必須是pthread_once_t型別變數的指標,指向初始化為PTHRAD_ONCE_INIT的靜態變數。在C++0
x以後提供了類似功能的函式std::call_once (),用法與該函式類似。使用例項請參考https://github.com/ApusApp/Swift/blob/master/swift/base/singleton.hpp實現。

三、執行緒區域性資料API

在Linux中提供瞭如下函式來對執行緒區域性資料進行操作
#include <pthread.h>

// Returns 0 on success, or a positive error number on error
int pthread_key_create (pthread_key_t *key, void (*destructor)(void *));

// Returns 0 on success, or a positive error number on error
int pthread_key_delete (pthread_key_t key);

// Returns 0 on success, or a positive error number on error
int pthread_setspecific (pthread_key_t key, const void *value);

// Returns pointer, or NULL if no thread-specific data is associated with key
void *pthread_getspecific (pthread_key_t key);

函式pthread_key_create()為執行緒區域性資料建立一個新鍵,並通過key指向新建立的鍵緩衝區。因為所有執行緒都可以使用返回的新鍵,所以引數key可以是一個全域性變數(在C++多執行緒程式設計中一般不使用全域性變數,而是使用單獨的類對執行緒區域性資料進行封裝,每個變數使用一個獨立的pthread_key_t)。destructor所指向的是一個自定義的函式,其格式如下:

void Dest (void *value)
{
    // Release storage pointed to by 'value'
}

只要執行緒終止時與key關聯的值不為NULL,則destructor所指的函式將會自動被呼叫。如果一個執行緒中有多個執行緒區域性儲存變數,那麼對各個變數所對應的destructor函式的呼叫順序是不確定的,因此,每個變數的destructor函式的設計應該相互獨立。

函式pthread_key_delete()並不檢查當前是否有執行緒正在使用該執行緒區域性資料變數,也不會呼叫清理函式destructor,而只是將其釋放以供下一次呼叫pthread_key_create()使用。在Linux執行緒中,它還會將與之相關的執行緒資料項設定為NULL。

由於系統對每個程序中pthread_key_t型別的個數是有限制的,所以程序中並不能建立無限個的pthread_key_t變數。Linux中可以通過PTHREAD_KEY_MAX(定義於limits.h檔案中)或者系統呼叫sysconf(_SC_THREAD_KEYS_MAX)來確定當前系統最多支援多少個鍵。Linux中預設是1024個鍵,這對於大多數程式來說已經足夠了。如果一個執行緒中有多個執行緒區域性儲存變數,通常可以將這些變數封裝到一個數據結構中,然後使封裝後的資料結構與一個執行緒區域性變數相關聯,這樣就能減少對鍵值的使用。

函式pthread_setspecific()用於將value的副本儲存於一資料結構中,並將其與呼叫執行緒以及key相關聯。引數value通常指向由呼叫者分配的一塊記憶體,當執行緒終止時,會將該指標作為引數傳遞給與key相關聯的destructor函式。當執行緒被建立時,會將所有的執行緒區域性儲存變數初始化為NULL,因此第一次使用此類變數前必須先呼叫pthread_getspecific()函式來確認是否已經於對應的key相關聯,如果沒有,那麼pthread_getspecific()會分配一塊記憶體並通過pthread_setspecific()函式儲存指向該記憶體塊的指標。

引數value的值也可以不是一個指向呼叫者分配的記憶體區域,而是任何可以強制轉換為void*的變數值,在這種情況下,先前的pthread_key_create()函式應將引數
destructor設定為NULL。

函式pthread_getspecific()正好與pthread_setspecific()相反,其是將pthread_setspecific()設定的value取出。在使用取出的值前最好是將void*轉換成原始資料型別的指標。

四、深入理解執行緒區域性儲存機制

  1. 深入理解執行緒區域性儲存的實現有助於對其API的使用。
    在典型的實現中包含以下陣列:一個全域性(程序級別)的陣列,用於存放執行緒區域性儲存的鍵值資訊pthread_key_create()返回的pthread_key_t型別值只是對全域性陣列的索引,該全域性陣列標記為pthread_keys,其格式大概如下:

    陣列的每個元素都是一個包含兩個欄位的結構,第一個欄位標記該陣列元素是否在用,第二個欄位用於存放針對此鍵、執行緒區域性儲存變的解構函式的一個副本,即destructor函式。

    每個執行緒還包含一個數組,存有為每個執行緒分配的執行緒特有資料塊的指標(通過呼叫pthread_setspecific()函式來儲存的指標,即引數中的value)。

  2. 在常見的儲存pthread_setspecific()函式引數value的實現中,大多數都類似於下圖的實現。圖中假設pthread_keys[1]分配給func1()函式,pthread API為每個函式維護指向執行緒區域性儲存資料塊的一個指標陣列,其中每個陣列元素都與圖執行緒區域性資料鍵的實現(上圖)中的全域性pthread_keys中元素一一對應。

五、總結

使用全域性變數或者靜態變數是導致多執行緒程式設計中非執行緒安全的常見原因。在多執行緒程式中,保障非執行緒安全的常用手段之一是使用互斥鎖來做保護,這種方法帶來了併發效能下降,同時也只能有一個執行緒對資料進行讀寫。如果程式中能避免使用全域性變數或靜態變數,那麼這些程式就是執行緒安全的,效能也可以得到很大的提升。如果有些資料只能有一個執行緒可以訪問,那麼這一類資料就可以使用執行緒區域性儲存機制來處理,雖然使用這種機制會給程式執行效率上帶來一定的影響,但對於使用鎖機制來說,這些效能影響將可以忽略。Linux C++的執行緒區域性儲存簡單實現可參考https://github.com/ApusApp/Swift/blob/master/swift/base/threadlocal.h,更詳細且高效的實現可參考Facebook的folly庫中的ThreadLocal實現。更高效能的執行緒區域性儲存機制就是使用__thread。

六、__thread 定義區域性變數

在Linux中還有一種更為高效的執行緒區域性儲存方法,就是使用關鍵字__thread來定義變數。__thread是GCC內建的執行緒區域性儲存設施(Thread-Local Storage),它的實現非常高效,與pthread_key_t向比較更為快速,其儲存效能可以與全域性變數相媲美,而且使用方式也更為簡單。建立執行緒區域性變數只需簡單的在全域性或者靜態變數的宣告中加入__thread說明即可。列如:

    static __thread char t_buf[32] = {'\0'};
    extern __thread int t_val = 0;

凡是帶有__thread的變數,每個執行緒都擁有該變數的一份拷貝,且互不干擾。執行緒區域性儲存中的變數將一直存在,直至執行緒終止,當執行緒終止時會自動釋放這一儲存。__thread並不是所有資料型別都可以使用的,因為其只支援POD(Plain old data structure)[1]型別,不支援class型別——其不能自動呼叫建構函式和解構函式。同時__thread可以用於修飾全域性變數、函式內的靜態變數,但是不能用於修飾函式的區域性變數或者class的普通成員變數。另外,__thread變數的初始化只能用編譯期常量,例如:

    __thread std::string t_object_1 ("Swift");                   // 錯誤,因為不能呼叫物件的建構函式
    __thread std::string* t_object_2 = new std::string (); // 錯誤,初始化必須用編譯期常量
    __thread std::string* t_object_3 = nullptr;                // 正確,但是需要手工初始化並銷燬物件

除了以上之外,關於執行緒區域性儲存變數的宣告和使用還需注意一下幾點:

  • 如果變數宣告中使用量關鍵字static或者extern,那麼關鍵字__thread必須緊隨其後。
  • 與一般的全域性變數或靜態變數一樣,執行緒區域性變數在宣告時可以設定一個初始化值。
    可以使用C語言取地址符(&)來獲取執行緒區域性變數的地址。

七、windows下的執行緒區域性變數的實現

windows下使用 __declspec 關鍵字宣告 thread 變數。例如,以下程式碼聲明瞭一個整數執行緒區域性變數,並用一個值對其進行初始化:

__declspec( thread ) int tls_i = 1;

使用VC關鍵字實現TLS需要注意:
宣告靜態繫結執行緒的本地物件和變數時必須遵守下列原則:

  • thread 屬性只能應用於資料宣告和定義。它不能用於函式宣告或定義。例如,以下程式碼將生成一個編譯器錯誤:
#define Thread __declspec( thread ) Thread void func(); // This will generate an error.
  • 只能在具有 static 作用域的資料項上指定 thread 修飾符。包括全域性資料物件(包括 static 和 extern)、本地靜態物件和 C++ 類的靜態資料成員。不可以用 thread 屬性宣告自動資料物件。以下程式碼將生成編譯器錯誤:
#define Thread __declspec( thread ) void func1() { Thread int tls_i; // This will generate an error. } int func2( Thread int tls_i ) // This will generate an error. { return tls_i; }
  • 執行緒本地物件的宣告和定義必須全都指定 thread 屬性。例如,以下程式碼將生成錯誤:
#define Thread __declspec( thread ) extern int tls_i; // This will generate an error, since the int Thread tls_i; // declaration and definition differ.
  • thread 屬性不能用作型別修飾符。例如,以下程式碼將生成一個編譯器錯誤:
char __declspec( thread ) *ch; // Error
  • C++ 類不能使用 thread 屬性。但是,可以使用 thread 屬性將 C++ 類物件例項化。例如,以下程式碼將生成一個編譯器錯誤:
#define Thread __declspec( thread ) class Thread C // Error: classes cannot be declared Thread. { // Code }; C CObject;
因為允許使用 thread 屬性的 C++ 物件的宣告,因此下面兩個示例在語義上是等效的:
#define Thread __declspec( thread ) Thread class B { // Code } BObject; // OK--BObject is declared thread local. class B { // Code }; Thread B BObject; // OK--BObject is declared thread local.
  • 不將執行緒本地物件的地址視為常數,並且涉及此類地址的任何表示式都不視為常數。在標準 C 中,這種作法的效果是禁止將執行緒本地變數的地址用作物件或指標的初始值設定項。例如,C 編譯器將以下程式碼標記為錯誤:
#define Thread __declspec( thread ) Thread int tls_i; int *p = &tls_i; //This will generate an error in C.

但是,此限制不適用於 C++。因為 C++ 允許動態初始化所有物件,因此可以用使用執行緒本地變數地址的表示式初始化物件。實現此操作的方式與實現執行緒本地物件結構的方式相同。例如,以上顯示的程式碼 在作為 C++ 原始檔編譯時不會生成錯誤。請注意:只有在其中獲取地址的執行緒仍然存在的情況下,執行緒本地變數的地址才有效。

  • 標準 C 允許使用涉及引用自身的表示式初始化物件或變數,但只適用於非靜態作用域的物件。雖然 C++ 通常允許使用涉及引用自身的表示式動態初始化物件,但是這種型別的初始化不允許用於執行緒本地物件。例如:
#define Thread __declspec( thread ) Thread int tls_i = tls_i; // Error in C and C++ int j = j; // OK in C++, error in C Thread int tls_i = sizeof( tls_i ) // Legal in C and C++

請注意:包含正在初始化的物件的 sizeof 表示式不建立對自身的引用且在 C 和 C++ 中都是合法的。

  • C++ 不允許此類對執行緒資料的動態初始化,因為將來可能要對執行緒本地儲存功能進行增強。
  • 如果 DLL 將任何非本地資料或物件宣告為 __declspec(執行緒),動態載入該 DLL 時會導致保護錯誤。使用 LoadLibrary 載入所有 DLL 後,每當程式碼引用非本地 __declspec(執行緒)資料時,將導致系統故障。由於執行緒的全域性變數空間是在執行時分配的,因此此空間的大小是以應用程式的需求和所有靜態連結的 DLL 的需求相加為基礎計算出來的。使用 LoadLibrary 時,無法擴充套件此空間以允許放置用 __declspec(執行緒)宣告的執行緒本地變數。如果 DLL 可能是用 LoadLibrary 載入的,請在 DLL 中使用 TLS API(如 TlsAlloc)來分配 TLS。