執行緒區域性儲存(TLS)

whatday發表於2013-04-10
執行緒區域性儲存,Part 1:概述

執行緒區域性儲存,Part 2:顯式TLS

執行緒區域性儲存,Part 3:編譯器和連結器對隱式TLS的支援

執行緒區域性儲存,Part 4:訪問__declspec(thread)變數

執行緒區域性儲存,Part 5:載入器對__declspec(thread)變數的支援(程式初始化階段)

執行緒區域性儲存,Part 6:Windows Server 2003中隱式TLS支援方法設計中的問題


執行緒區域性儲存,Part 1:概述

和其它主流多執行緒作業系統一樣,Windows為大家提供一個機制,該機制允許程式設計師實現基於執行緒的區域性狀態儲存。這種能力通常稱為執行緒區域性儲存(Thread Local Storage,TLS),這對於那些需要儲存執行緒相關資訊但需要全域性可見的應用場景非常有用。

儘管TLS的介紹有很好的文件可參考,但關於其實現細節的介紹並不多(儘管也有一些非官方文件進行比較表面的介紹)。

至少從高層來將,TLS在概念上並不複雜。常規設計是將所有對TLS的訪問都通過TEB中的指標來進行間接訪問,TEB為作業系統定義的每個執行緒一份的資料結構,用於儲存一些執行緒相關的資訊。

TEB相對有比較多的文件介紹,為了對使用者透明,一般使用一個段暫存器(X86上使用fs,X64上使用gs)指向當前執行緒的TEB地址。這樣通過fs:[0x0](gs:[0x0] X64)將始終訪問當前執行緒的TEB結構。TEB實際可以不出現在程式的平坦地址空間當中(TEB有一個域存放本身的平坦線性地址_TEB._NT_TIB.Self),但是這裡段機制僅僅用來提供快速訪問TEB結構,從而不需要搜尋執行緒ID來確定TEB的地址(或其它相對較慢的機制來查詢執行緒對應的TEB地址)。(%我猜作者這裡想表達的意思是TEB並不需要在平坦地址空間中出現,所以想通過線性地址訪問TEB會出錯%)

在非X86和非X64架構中,訪問TEB的底層機制是不同的,但是通常也是使用暫存器存放TEB的地址,從而使其更易於訪問。

TEB本身可算是Windows中文件化最好的未公開結構,這主要是因為在最近構建的ntdll和ntoskrnl中該結構為偵錯程式提供了一些型別資訊(type information)。通過這些資訊和一點反彙編工作,即可很容易的理解TLS的背後的實現細節。

在著手瞭解TLS如何工作之前,有必要看一下其文件化的使用方法。首先是通過kernel32中的一組用於顯示使用TLS的函式:TlsGetValue、TlsSetValue、TlsAlloc、TlsFree。這些函式的使用非常直觀。TlsAlloc為所有執行緒預留一個指標大小的空間,TlsGetValue用於讀取執行緒相關的變數。(其它兩個函式完成功能類似)。

其次,是由載入器、編譯器和連結器所支援的隱式使用執行緒區域性儲存的方法,方法是在變數上加__declspec(thread)修飾。這比使用TLS APIs要方便的多,因為不需要每次都使用TLS函式去訪問執行緒區域性儲存變數。這種方式同樣解除了需要顯示呼叫TlsAlloc和TlsFree的困擾,提供了一種有效的使用執行緒區域性儲存的方式(隱式TLS通過分配一大塊記憶體來實現,記憶體大小由所有執行緒區域性變數佔用空間總和,對於一個模組中的所有執行緒區域性變數僅需要在TLS陣列中的一個索引就可以了)。

既然隱式TLS具有這些優點,那為何我們需要顯示TLSAPI了?原因是在Vista之前,在載入器的TLS實現中含有一些討厭的限制。尤其是隱式TLS在模組不是在程式初始化時載入會不起作用(即不能動態載入或延遲載入)。這意味著在實際應用當中除了exe檔案和保證會靜態連線的dll庫,其它都無法使用。


執行緒區域性儲存,Part 2:顯式TLS

前一篇,我概述了windows中TLS的一些總體設計原則。大家可以從MSDN中得到關於TLS的高層介面和設計方法,但是有意思的卻是其底層的實現。

從實現來看,顯式TLS API是目前兩類實現TLS方法中較簡單的一種,因此這種方法很少涉及內部實現的可變部分。正如我上次提到的,顯式TLSAPI主要是4個函式。其中最重要的兩個是TlsGetValue和TlsSetValue,分別負責設定和獲取執行緒相關的資料。

這兩個函式非常簡單。其背後的核心機制是他們是使用dwTlsIndex為索引來訪問TEB中兩個陣列的“dumb accessors”(其實內部使用2個陣列來實現:TlsSlots和TlsExpansionSlots,這兩個函式用於根據索引訪問這兩個陣列)。Vista(32-bit)下這兩個函式的為實現程式碼如下:

LPVOID __stdcall xTlsGetValue(_In_ DWORDdwTlsIndex)

{

       PTEBTeb = xNtCurrentTeb();

       Teb->LastErrorValue= 0;

       if(dwTlsIndex< 64)

              //64個指標大小的空間

              returnTeb->TlsSlots[dwTlsIndex];

 

       if(dwTlsIndex>= 1088){//440h

              //總共有1088個slot,超出就錯誤了

              xSetLastError(ERROR_INVALID_PARAMETER);

              return0;

       }

 

       if(Teb->TlsExpansionSlots)

              returnTeb->TlsExpansionSlots[dwTlsIndex - 64];

       else

              return0;

}

 

BOOL __stdcall xTlsSetValue(_In_ DWORD dwTlsIndex,_In_ LPVOID lpTlsValue)

{

       PTEB Teb= xNtCurrentTeb();

       if(dwTlsIndex< 64){

              Teb->TlsSlots[dwTlsIndex]= lpTlsValue;

              returnTRUE;

       }

 

       if(dwTlsIndex>= 1088){

              xSetLastError(ERROR_INVALID_PARAMETER);

              return0;

       }

 

       //處理擴充套件Slot的情況

       if(!Teb->TlsExpansionSlots){

              //第一次進入需要為擴充套件Slot分配記憶體

              xRtlAcquirePebLock();

              if(!Teb->TlsExpansionSlots){

                     LPVOIDTmp = xRtlAllocateHeap(Teb->Peb->ProcessHeap, 8, 1024*sizeof(LPVOID));

                     if(!Tmp){

                            //資源不足

                            xRtlReleasePebLock();

                            xSetLastError(0);

                            returnFALSE;

                     }

                     Teb->TlsExpansionSlots= (PVOID*)Tmp;

              }

              xRtlReleasePebLock();

       }

       Teb->TlsExpansionSlots[dwTlsIndex- 64] = lpTlsValue;

       returnTRUE;

}

TlsSlots是TEB結構中一個64個指標大小的陣列,它保證每個執行緒最低具有64個執行緒區域性儲存空間。後來,微軟覺得供應64個TLS槽(Slot)太少,於是在PEB中增加了TlsExpansionSlots指標,該指標指向額外的1024個TLS槽。且TlsExpansionSlots指向是空間是按需分配的,即在前64個槽用完之後才會分配使用。

(PS:這也是MSDN中所提到的64和1088TLS槽限制的原因吧)

從所有這些考慮和目的來說,TlsAlloc和TlsFree的實現正如你想象的那樣:它們獲得一個鎖,查詢未分配的Tls槽(如果找到就返回槽的索引,否則告訴呼叫者沒有空餘的槽了)。如果最初的64個槽用完了(TlsSlots用完)且TlsExpansionSlots指標為NULL,則TlsAlloc將會分配1024個TLS槽(每個槽為指標大小),將這塊記憶體清0,然後更新TlsExpansionSlots,使其引用這塊記憶體。

在內部,TlsAlloc和TlsFree利用Rtl Bitmap來記錄Tls槽的使用情況;bitmap中的每個位記錄一個槽的使用情況(使用或未被使用)。這樣既可以快速查詢TLS槽的使用對映情況,同時節省了記憶體空間。

如果您一直看到這裡,您可能會疑惑:當程式中已經存在多個執行緒之後,呼叫TlsAlloc會發生些什麼事情了?乍一看這裡會出現問題,TlsAlloc僅為當前執行緒分配了TlsExpansionSlots記憶體,其它執行緒訪問已分配的槽應該會出現訪問違例錯誤。當程式中不止一個執行緒時,對於索引大於等於64的TLS槽將無法正常工作。但事實並不是這樣的。在TlsGetValue和TlsSetValue中使用了一個trick,它補償了TlsAlloc僅為當前執行緒分配TlsExpansionSlots的限制。

假定,使用的dwTlsIndex》=64時呼叫TlsGetValue,此時訪問的記憶體位於TlsExpansionSltos所指空間,若該空間對於當前執行緒沒有被分配,函式返回0.(未初始化的TLS槽的預設值,此時完全合理合法)。同樣,呼叫TlsSetValue時,如果TlsExpansionSlts沒有分配記憶體,該函式將按需分配記憶體,並初始化分配的記憶體塊(全部置0)。

在多執行緒中,還遺留最後一個苦惱:釋放TLS槽。潛在的問題是,當一個執行緒釋放了TLS槽然後又從新分配了它,如此其它執行緒中該槽的內容將遺留下來(這樣預設值就不是0了)。TlsFree採用ThreadZeroTlsCell執行緒資訊類求助核心來解決該問題。當核心接到以ThreadZeroTlsCell為引數的NtSetInformationTHread呼叫,它列舉當前程式中的所有執行緒,對於指定的槽寫一個指標長度的0值,這樣將沖掉舊的值,使該槽處於未分配的初始狀態。(嚴格來說,核心不是必須這樣做,但是設計者選擇採用這種方式(我沒想到有其它方法,各位可以想想))

當一個執行緒正常退出,如果TlsExpansionSlots指標已經分配了記憶體,它將被釋放。(當然,如果執行緒呼叫TerminateThread結束,該塊記憶體就leak了。這也是無數為什麼你要遠離TerminateThread的原因之一)。


執行緒區域性儲存,Part 3:編譯器和連結器對隱式TLS的支援

上次,我們探討了顯式TLS操作所採用的機制(包括TlsGetValue、TlsSetValue和其它相關例程)。

儘管顯式TLS被大量使用,但是TLS機制的更有意思的部分卻是載入器對隱式TLS的支援或是編譯器中的__declspec(thread)變數。雖然兩種TLS機制設計用於實現相似的功能,即提供執行緒相關的資料儲存,但是它們的實現具有非常大的差別。

當你使用__declspec(thread)擴充套件儲存類宣告一個變數時,編譯器和連結器合作為變數在映像檔案的一個特殊區域(一個特殊段)中分配儲存空間。通常,所有__declspec(thread)儲存類變數被放置在PE檔案的.tls節中,技術上來說不用非得這樣(事實上,執行緒區域性變數都不用放在自己單獨的節內,從連結器的角度來看,僅要求把它們放在連續的空間上即可)。在硬碟上,某一PE檔案中,這塊空間包含所有執行緒區域性變數的初始化資料。這塊資料區永遠不會被修改或是被執行緒區域性變數引用;這裡的資料僅僅用於線上程剛被建立時用於初始化為執行緒區域性變數新分配的記憶體空間。

編譯器和連結器使用幾個特殊的變數來支援隱式TLS。具體來說,變數_tls_used(變數型別為IMAGE_TLS_DIRECTORY)由C執行時庫建立,靜態連結時該變數表示TLS目錄結構並被最終的映像檔案使用(由於名字修飾的原因,在C++中需要使用extern “C”連結,儲存型別為外部引入,因為CRT程式碼已經建立了該變數)。TLS目錄是PE檔案頭的一部分,用於告訴載入器如何管理執行緒區域性變數,連結時,連結器查詢變數_tls_used,並確保其與最終PE檔案中的TLS目錄重疊。(這裡不太確定是什麼意思)

C執行時庫中宣告變數_tls_used的原始碼位於tlssup.c檔案中(與Visual Studio一起釋出)。_tls_used標準的宣告方式如下所示:

_CRTALLOC(".rdata$T")

const IMAGE_TLS_DIRECTORY _tls_used =

{

       (ULONG)(ULONG_PTR) &_tls_start, // start of tls data

       (ULONG)(ULONG_PTR) &_tls_end,  // end of tls data

       (ULONG)(ULONG_PTR) &_tls_index, // address of tls_index

        (ULONG)(ULONG_PTR)(&__xl_a+1), // pointer to call back array

       (ULONG) 0,                      //size of tls zero fill

       (ULONG) 0                       //characteristics

};

同樣,CRT程式碼提供了一種機制,該機制允許程式註冊一系列與DllMain具有類似簽名的TLS回撥函式。(這些回撥函式可以在主映像檔案中存在,而DllMain則不可以)。回撥函式型別為PIMAGE_TLS_CALLBACK,TLS目錄指向一個以NULL結尾的callbacks陣列(這些函式將按順序呼叫)。

對於一般的PE檔案不會使用TLS回撥(實際中,大部分使用DllMain來完成獨立於執行緒的初始化工作)。但是TLS回撥支援卻是完全可以工作的。為了使用CRT提供的TLS回撥支援,需要我們宣告一個存放在以“.CRT$XLx“為名的節裡面,這裡x是一個位於A和Z之間的字母。例如,如下的程式碼片段:

#pragma section(“.CRT$XLY”,long,read)

extern “C” __declspec(allocate(“.CRT$XLY”))

PIMAGE_TLS_CALLBACK _xl_y = MyTlsCallback;

需要如此奇怪的節名是因為TLS回撥指標需要進行記憶體排序的原因。為了理解這種特殊宣告的作用,需要首先明白編譯器和連結器是如何組織PE檔案中的資料的。

PE檔案中,除了頭部資料,其它均是分不同節儲存的,節就是具有相同屬性(也保護屬性)集合的記憶體區域。關鍵字__declspec(allocate(“section-name”))告訴編譯器(這裡應該是連結器,原文有錯,下同,但仍然按原文翻譯)在最終PE檔案中其作用域內的內容放在指定的節內。編譯器額外支援將相似名字的節合併為一個大節的功能。該功能通過使用節名字首+$+任意字串 的形式來啟用。編譯器將合併具有相同節名字首的節為一個大節。

編譯器對於相似節採用字典順序進行合併(對$後的字串進行排序)。這意味著在記憶體中,位於節“.CRT$XLB”中的變數將在位於節“.CRT$XLA”中變數位置的後面,但是在位於節“.CRT$XLZ”中的變數的前面。C執行時庫利用編譯器的這一特性來建立一個以NULL結尾的TLS回撥陣列(將節“.CRT$XLZ”中放置一個NULL指標)。因此為了保證宣告的函式指標位於TLS回撥陣列內部,必須將它放在節“.CRT$XLx”中。

但是,建立TLS目錄只是編譯器和連結器支援__declspec(thread)變數的一部分工作。下一次,我將討論編譯器和連結器通過何種機制來支援對執行緒區域性變數的訪問。



執行緒區域性儲存,Part 4:訪問__declspec(thread)變數

昨天,我大致說了下編譯器和連結器如何合作來支援TLS,但是並沒有講當訪問__declspec(thread)變數時具體底層是個什麼樣子,或者說是怎麼來做到的。

在解釋如何訪問__declspec(thread)變數的內部工作原理之前,有必要了解下tlssup.c中的幾個特殊變數。這些變數被_tls_used引用來建立TLS目錄結構。

第一個感興趣的變數是_tls_index,線上程區域性儲存解析機制中,幾乎每次執行緒區域性變數被訪問時改變數都由編譯器隱式引用,從而解析出當前執行緒區域性變數的地址(存在不引用該變數的一個例外,後面說)。_tls_index也是tlssup.c中唯一一個採用預設儲存類的變數(普通全域性變數)。內部,該變數代表本模組(exe或dll等pe檔案)的TLS索引。概念上TLS索引和由TlsAlloc分配的索引相似。但是它們並不相容(即這兩個TLS索引不能混用的,因為底層支撐機制不同),模組獨有的TLS索引具有更多的支撐程式碼。稍後將會講到這些,現在大家先稍等一下。

在tlssup.c檔案中,_tls_start和_tls_end變數的定義如下:

/* Special symbols to mark start and end of ThreadLocal Storage area. */

 

#pragma data_seg(".tls")

 

#if defined (_M_IA64) || defined (_M_AMD64)

_CRTALLOC(".tls")

#endif  /*defined (_M_IA64) || defined (_M_AMD64) */

char _tls_start = 0;

 

#pragma data_seg(".tls$ZZZ")

 

#if defined (_M_IA64) || defined (_M_AMD64)

_CRTALLOC(".tls$ZZZ")

#endif  /*defined (_M_IA64) || defined (_M_AMD64) */

char _tls_end = 0;

 

#pragma data_seg()

這段程式碼建立了兩個變數,並將它們放在.tls段的開頭和結尾。編譯器和連結器將會自動為所有的__declspec(thread)變數放置在預設段.tls段,在最終的PE檔案中這些變數將會被放置在_tls_start和_tls_end中間。這兩個變數用於告訴連結器TLS儲存模板節的邊界位置(首地址和結束地址)。映像檔案的TLS目錄儲存了該資訊。

現在我們知道了在語言層次上__declspec(thread)是如何來工作的,接下來有必要了解下編譯器產生的訪問__declspec(thread)變數的支援程式碼。幸運的是這些支援程式碼非常直觀。考慮如下測試程式:

__declspec(thread) int threadedint = 0;

int __cdecl wmain(int ac,

   wchar_t**av)

{

  threadedint = 42;

   return 0;

}

對於x64系統,編譯器將產生如下程式碼:

mov ecx,DWORD PTR _tls_index

mov rax,QWORD PTR gs:58h

mov edx,OFFSET FLAT:threadedint

mov rax,QWORD PTR [rax+rcx*8]

mov DWORD PTR[rdx+rax], 42

想想前面有介紹gs段暫存器在x64系統中用於引用TEB的首地址。88(0x58)是TEB的ThreadLocalStoragePointer成員的偏移:

+0x058 ThreadLocalStoragePointer : Ptr64 Void

但是,如果我們在執行時檢視程式碼將會是下面這個樣子:

mov     ecx,cs:_tls_index

mov     rax,gs:58h

mov     edx,4

mov     rax,[rax+rcx*8]

mov     dwordptr [rdx+rax], 2Ah ; 42

xor     eax,eax

可以發現“threadedint”變數被解析成了一個小值(4)。回憶在單獨編譯時,mov edx,4指令對應mov edx,OFFSET FLAT:threadedint。

現在,4不是一個平坦地址(我們希望的是一個範圍位於可執行檔案使用範圍的地址)發生了什麼事情了?

ok,原來這裡連結器玩了一個小把戲。當連結器解析對__declspec(thread)變數的引用時,將偏移假定為相對於.tls節的起始位置。如果檢查PE檔案中的.tls段,事情將變得更清晰:

0000000001007000 _tls segment para public 'DATA'use64

0000000001007000      assume cs:_tls

0000000001007000    ;org 1007000h

0000000001007000 _tls_start        dd 0

0000000001007004 ; int threadedint

0000000001007004 ?threadedint@@3HA dd 0

0000000001007008 _tls_end          dd 0

“threadedint”相對於.tls節起始位置的偏移確實是4。但是這些仍然沒有解釋編譯器產生的指令如何訪問執行緒區域性變數。

這裡訣竅就藏在接下來的三條指令當中:

mov     ecx,cs:_tls_index

mov     rax,gs:58h

mov     rax,[rax+rcx*8]

這三條指令獲取TEB中ThreadLocalStoragePointer的值並用_tls_index來索引其指向的空間。獲得指標代表的地址在使用threadedint進行索引來合成一個完成的訪問該執行緒所有threadedint變數的地址。

(其實可以這樣認為:對於每個執行緒都有新分配了一塊和.tls同樣大小的記憶體,用ThreadLocalStoragePointer引用,這樣該變數的值和偏移加起來就是變數的地址了)

採用C語言,編譯器產生的程式碼將是下面的樣子:

// This represents the ".tls" section

struct _MODULE_TLS_DATA

{

   inttls_start;

   intthreadedint;

   inttls_end;

} MODULE_TLS_DATA, * PMODULE_TLS_DATA;

 

PTEB Teb;

PMODULE_TLS_DATA TlsData;

 

Teb     =NtCurrentTeb();

TlsData = Teb->ThreadLocalStoragePointer[_tls_index ];

 

TlsData->threadedint = 42;

如果之前你使用過顯式TLS,這裡看起來是非常熟悉的。顯式TLS典型正規化就是在TLS槽中放置一個結構體的指標,然後在訪問執行緒區域性狀態,每個執行緒的結構體例項都通過結構體指標進行訪問。這裡不同的地方是編譯器和連結器合作(載入器)將你從顯式進行這些操作中解脫出來;所有你需要做的就是使用__declspec(thread)宣告一個變數,然後一切背後的事情就自然發生了。

從程式碼生成角度來看,隱式TLS變數的工作機制存在一條額外的曲線。你可能注意到示例中為X64版本中訪問__declspec(thread)變數的程式碼;這是因為預設情況下,X86在構建exe檔案時包含一個特殊的優化選項(/GA,Optimize for Windows Application,也許是有史以來編譯器選項名字中最爛的一個),該優化假定_tls_index為0從而消除了對其的引用過程(這樣加快了對執行緒區域性變數的訪問)。

該優化僅僅對程式的主模組起作用(一般是exe檔案)。該假定成立的原因是載入器按照模組載入順序為_tls_index指定序列值,而主模組將在第二個被載入,ntdll是第一個載入的模組(顯然ntdll中不能使用__declspec(thread)變數,否則該模組將是0索引,即_tls_index值為0)。值得注意的是,在exe具有匯出函式且使用了__declspec(thread)變數時,該優化將會導致應用程式隨機崩潰。

以備參考,當/GA選項開啟時,X86版編譯生成如下指令:

mov     eax,large fs:2Ch

mov     ecx,[eax]

mov     dwordptr [ecx+4], 2Ah ; 42

記得在X86系統中,fs的基地址引用TEB的首地址,ThreadLocalStoragePointer所在的偏移為0x2C。

注意這裡並沒有對_tls_index的引用;編譯器假定使用0值。如果是X86平臺下構建dll,該優化始終是關閉的,_tls_index將如之前那樣來使用。

但是,__declspec(thread)變數背後的事情遠不是編譯器和連結器能搞定的。某某仍然需要為每個執行緒分配儲存空間,這個某某就是載入器。更多關於載入器在這裡所扮演的角色將在下次進行探討。



執行緒區域性儲存,Part 5:載入器對__declspec(thread)變數的支援(程式初始化階段)

上次,我描述了編譯器和連結器為訪問__declspec(thread)擴充套件類變數所使用的生成程式碼的機制。儘管此時它們已經為隱式TLS佈置了舞臺,但為了使整體能夠工作,仍然需要載入器這個元件來提供必需的執行時支援。

具體的,載入器將負責為每個模組分配TLS索引值,為每個執行緒的TEB中的ThreadLocalStoragePointer分配記憶體空間。此外載入器還需要為每個模組分配TLS儲存空間。

概念上,載入器中和TLS相關的分配和管理職責可以被劃分為四個方面:(注意這是在Windows Server 2003版本和比其早的版本;之後將分析下Vista中所做的修改)

1.    程式初始化階段,為變數_tls_index分配索引值,確定每個模組所需的TLS空間記憶體的大小,然後呼叫TLS和DLL初始化函式(同一模組,先呼叫TLS初始化函式,後呼叫DllMain初始化函式)。

2.    線上程初始化階段,為每一個使用了TLS的模組分配TLS記憶體並初始化,根據使用TLS的模組數目為當前執行緒分配ThreadLocalStoragePointer陣列,然後將各個模組的TLS記憶體和ThreadLocalStoragePointer陣列中的對應項相關聯。然後為當前執行緒呼叫TLS初始化函式和DLLMain初始化函式。

3.    線上程終止的時候,呼叫TLS初始化函式和DLLMain函式(根據引數確定是執行緒終止),釋放當前執行緒中每個模組對應的TLS記憶體,然後釋放ThreadLocalStoragePointer陣列。

4.    在程式終止時,呼叫TLS和DLlmain初始化函式。

當然,載入器在完成上面所列工作的同時也做了其他事情;以上所列的只是TLS支援中的關鍵部分。

除了程式初始化以外,其它大部分操作都非常直觀。程式初始化主要是由ntdll中的LdrpInitializeTls和LdrpAllocateTls兩個例程來完成的。

當所有靜態連線的dll檔案被載入之後,所有其它初始化例程被呼叫之前,LdrpInitializeTls被呼叫(說明優先順序比較高,是關鍵的部分)。基本上,該函式要遍歷所有載入模組,為每一個具有有效TLS目錄的模組統計出它使用的TLS記憶體的大小。對每一個使用了TLS的模組,會分配一個資料結構來記錄該模組所使用的TLS記憶體大小併為其分配的索引號(_tls_used)。(早在Xp系統中,LDR_DATA_TABLE_ENTRY結構中的TlsIndex域貌似就沒有使用了。而在WINME系統中將該值誤用為模組的TLS索引,因此假定該值為-1在WINME系統中是不可靠的)

使用了TLS的模組在呼叫LdrpInitializeProcess的過程中將被標記為始終位於記憶體當中(這種模組的LoadCount值為0xFFFF)。實際中,這個不是什麼問題,因為這種模組必須是靜態連結的或是被主模組隱式依賴,不可能中途退場。

在函式LdrpInitializeTls為模組分類了TLS索引之後,將呼叫LdrpAllocateTls為初始執行緒初始化TLS值。

這時,程式繼續初始化,最後每個模組的TLS初始化和DLLmain初始化函式會被呼叫。(注意應用程式主模組可以有多個TLS回撥函式,但是沒有DLLmain函式)

一個有意思的事情是同一個DLL模組的TLS初始化函式始終在DLL初始化函式之前呼叫。(這個過程按順序進行,例如先A.dll的TLS初始化,A.dll的DLLmain初始化,B.dll的TLS初始化,B.dll的Dllmain初始化,以此類推)。這意味著在TLS初始化函式中要慎重使用CRT的函式((as the C runtime is initialized before the user’s DllMain routineis called, by the actual DLL initializer entrypoint, such that the CRT will notbe initialized when a TLS initializer for the module is invoked).)。這將非常危險,因為全域性資料還沒有被建立;除非匯入被跳過,否則模組將處於一個完全未初始化的狀態。

另一個值得一提的有關載入器對TLS支援的方面是PE檔案格式標準中,IMAGE_TLS_DIRECTORY結構中的SizeOfZeroFill域並沒有被連結器和載入器使用。這意味著在現實中,所有TLS模板資料都將初始化,TLS記憶體塊的大小不像PE檔案格式標準所陳述的的那樣包含域SizeOfZeroFill。

一些軟體濫用TLS回撥來用於反除錯的目的(通過建立一個TLS回撥項來在入口函式獲得執行權之前執行程式碼),雖然可以,但是實際中這點將非常明顯,因為大部分PE檔案都不會使用TSL回撥。

直到Windows Server 2003,上述就是載入器對__declspec(thread)儲存類的所有支援。這個方法看起來工作的很好,但事實上存在些問題(如果你一直看到現在,你也許會發現是什麼問題)。更多關於以上方法的限制的討論將在下一週為大家講述。



執行緒區域性儲存,Part 6:Windows Server 2003中隱式TLS支援方法設計中的問題

上週,我描述了在WindowsServer 2003中載入器如何處理隱式TLS支援。儘管TLS支援對於最初的要求支援的挺好,但是仍然存在一些讓人不悅的地方。如果你一直看到這裡,你可能已經注意到隱式TLS支援中設計方面的問題。這些缺陷最終鞭策微軟在vista版本中對隱私TLS進行了重要的修正。

WindowsServer 2003及其早期版本中,隱式TLS實現的主要問題是對動態載入的DLL檔案將完全不起作用(使用LoadLibrary和LdrLoadDll動態載入)。事實上,動態載入使用TLS的DLL將會產生巨大的災難。

最終發生的事情是,對於動態載入的DLL其TLS將不會被處理。就目前所瞭解的TLS內部機制,這樣很明顯會產生不幸的後果。

當一個使用了隱式TLS的DLL被動態載入時,由於載入器不會處理TLS目錄,其_tls_index的值沒有被初始化,模組對應當前執行緒的TLS儲存空間和ThreadLocalStoragePointer陣列都沒有分配。但是DLL會被載入成功,看起來也可以工作,直到你第一次訪問__declspec(thread)變數。

具有代表性的,編譯器預設將_tls_index初始化為0,因此在程式初始化之後動態載入的DLL其_tls_index將保持為0.當訪問__declspec(thread)變數時,會進行隱式TLS變數解析過程。換言之,ThreadLocalStoragePointer的值將會被獲取並用_tls_index進行索引(該值對動態載入的dll來說始終為0),索引得到的結果指標被認為是當前執行緒中對應本模組中的TLS變數的地址。不幸的是,載入器並沒有設定該模組的_tls_index值為一個有效的值,因此該模組將會引用那個_tls_index值被賦予0的模組的執行緒區域性儲存變數。一般該模組將會是主模組,如果主模組沒有使用tls,那麼將是某一個靜態連結的使用了TLS的DLL模組。

在除錯時,這將導致一個非常難以被發現的問題。現在你有了一個任意蹂躪其它模組狀態的模組,而導致問題的模組將會認為其修改是屬於自己的執行緒狀態。如果幸運,應用程式完全沒有使用隱式TLS功能(程式初始化時),ThreadLocalStoragePointer陣列將不存在,在第一次訪問__declspec(thread)變數時將會引發一個NULL指標解析。但是更多的是程式中存在其它模組使用隱式TLS,這種情況下,TLS索引為0的模組的執行緒資料將會被新載入的模組破壞。

這種情況下,程式崩潰將會延遲到模組發現資料出現了問題。也可能你足夠幸運,新載入的模組的TLS記憶體空間比TLS索引為0的模組佔用的TLS記憶體空間大很多,這樣在訪問__declspec(thread)變數時如果超出了堆分配的界限,也會立即出現訪問錯誤。當然,如果訪問的資料剛好位於堆記憶體中記錄堆分配的記憶體區,那麼會導致堆溢位。(載入器使用程式堆來分配TLS儲存空間)

也許載入器關於隱式TLS和按需載入DLL的限制的一個補救措施就是由於載入器對這兩種情況的不支援,大多數的程式設計師都知道在與DLL進行合作時何時需要遠離使用隱式TLS。

這些可怕的按需載入使用了__declspec(thread)變數的dll所造成的後果大概是MSDN中關於在按需載入dll中使用隱式TLS的警告出現的原因吧。

很明顯,從除錯角度來看,按需載入使用了隱式TLS的DLL所出現的錯誤很難發現。這個問題嚴重限制了__declspec(thread)變數的使用。

幸運的是,Vista的載入器使用了一些辦法解決這個問題,這樣就可以安全的使用__declspec(thread)變數了。新的載入器支援按需載入DLL使用隱式TLS,但是實現相對複雜(由於考慮相容性的原因)


相關文章