11.3.3 線程局部存儲實現(1)安全
不少時候,開發者在編寫多線程程序的時候都但願存儲一些線程私有的數據。咱們知道,屬於每一個線程私有的數據包括線程的棧和當前的寄存器,可是這兩種存儲都是很是不可靠的,棧會在每一個函數退出和進入的時候被改變;而寄存器更是少得可憐,咱們不可能拿寄存器去存儲所須要的數據。假設咱們要在線程中使用一個全局變量,但但願這個全局變量是線程私有的,而不是全部線程共享的,該怎麼辦呢?這時候就需要用到線程局部存儲(TLS,Thread Local Storage)這個機制了。TLS的用法很簡單,若是要定義一個全局變量爲TLS類型的,只須要在它定義前加上相應的關鍵字便可。對於GCC來講,這個關鍵字就是__thread,好比咱們定義一個TLS的全局整型變量:數據結構
__thread int number; |
__declspec(thread) int number; |
在Windows Vista和2008以前的操做系統,若是TLS的全局變量被定義在一個DLL中,而且該DLL是使用LoadLibrary()顯式裝載的,那麼該全局變量將沒法使用,若是訪問該全局變量將會致使程序發生保護錯誤。致使這個狀況的主要緣由是在Windows Vista以前的操做系統下,DLL在使用LoadLibrary()裝載時沒法正確初始化由__declspec(thread)定義的變量,具體請參照MSDN。多線程
一旦一個全局變量被定義成TLS類型的,那麼每一個線程都會擁有這個變量的一個副本,任何線程對該變量的修改都不會影響其餘線程中該變量的副本。函數
Windows TLS的實現spa
對於Windows系統來講,正常狀況下一個全局變量或靜態變量會被放到".data"或".bss"段中,但當咱們使用__declspec(thread)定義一個線程私有變量的時候,編譯器會把這些變量放到PE文件的".tls"段中。當系統啓動一個新的線程時,它會從進程的堆中分配一塊足夠大小的空間,而後把".tls"段中的內容複製到這塊空間中,因而每一個線程都有本身獨立的一個".tls"副本。因此對於用__declspec(thread)定義的同一個變量,它們在不一樣線程中的地址都是不同的。操作系統
咱們知道對於一個TLS變量來講,它有多是一個C++的全局對象,那麼每一個線程在啓動時不只僅是複製".tls"的內容那麼簡單,還須要把這些TLS對象初始化,必須逐個地調用它們的全局構造函數,並且當線程退出時,還要逐個地將它們析構,正如普通的全局對象在進程啓動和退出時都要構造、析構同樣。線程
Windows PE文件的結構中有個叫數據目錄的結構,咱們在第2部分已經介紹過了。它總共有16個元素,其中有一元素下標爲IMAGE_DIRECT_ENTRY_TLS,這個元素中保存的地址和長度就是TLS表(IMAGE_TLS_DIRECTORY結構)的地址和長度。TLS表中保存了全部TLS變量的構造函數和析構函數的地址,Windows系統就是根據TLS表中的內容,在每次線程啓動或退出時對TLS變量進行構造和析構。TLS表自己每每位於PE文件的".rdata"段中。指針
另一個問題是,既然同一個TLS變量對於每一個線程來講它們的地址都不同,那麼線程是如何訪問這些變量的呢?其實對於每一個Windows線程來講,系統都會創建一個關於線程信息的結構,叫作線程環境塊(TEB,Thread Environment Block)。這個結構裏面保存的是線程的堆棧地址、線程ID等相關信息,其中有一個域是一個TLS數組,它在TEB中的偏移是0x2C。對於每一個線程來講,x86的FS段寄存器所指的段就是該線程的TEB,因而要獲得一個線程的TLS數組的地址就能夠經過FS:[0x2C]訪問到。
TEB這個結構不是公開的,它可能隨着Windows版本的變化而變化,咱們這裏所說的TEB結構都是指在x86版的Windows XP。
這個TLS數組對於每一個線程來講大小是固定的,通常有64個元素。而TLS數組的第一個元素就是指向該線程的".tls"副本的地址。因而要獲得一個TLS的變量地址的步驟爲:首先經過FS:[0x2C]獲得TLS數組的地址,而後根據TLS數組的地址獲得".tls"副本的地址,而後加上變量在".tls"段中的偏移即該TLS變量在線程中的地址。下面看一個簡單的例子:
__declspec(thread) int t = 1; int main() { t = 2; return 0; } |
_main: 00000000: 55 push ebp 00000001: 8B EC mov ebp,esp 00000003: A1 00 00 00 00 mov eax,dword ptr [__tls_index] 00000008: 64 8B 0D 00 00 00 mov ecx,dword ptr fs:[__tls_array] 00 0000000F: 8B 14 81 mov edx,dword ptr [ecx+eax*4] 00000012: C7 82 00 00 00 00 mov dword ptr _t[edx],2 02 00 00 00 0000001C: 33 C0 xor eax,eax 0000001E: 5D pop ebp 0000001F: C3 ret |
代碼中有兩個符號__tls_index和__tls_array,它們被定義在MSVC CRT中,對於MSVC 2008來講,它們的值分別是0和0x2C,分別表示TLS數組下的第一個元素和TLS數組在TEB中的偏移。因爲這兩個數值有可能隨着Windows系統的變化而變化,因此它們被保存在CRT中,若是程序以DLL方式連接,那麼在不一樣版本的Windows平臺上運行就不會有問題;若是是靜態連接,那麼當新版的Windows更改TEB結構時而致使TLS數組在TEB中的偏移改變,程序運行就可能出錯。固然出於Windows多年來的"良好表現",這種隨意更改核心數據結構的事情發生的可能性仍是比較小的。
顯式TLS
前面提到的使用__thread或__declspec(thread)關鍵字定義全局變量爲TLS變量的方法每每被稱爲隱式TLS,即程序員無須關心TLS變量的申請、分配賦值和釋放,編譯器、運行庫還有操做系統已經將這一切悄悄處理穩當了。在程序員看來,TLS全局變量就是線程私有的全局變量。相對於隱式TLS,還有一種叫作顯式TLS的方法,這種方法是程序員需要手工申請TLS變量,而且每次訪問該變量時都要調用相應的函數獲得變量的地址,而且在訪問完成以後須要釋放該變量。在Windows平臺上,系統提供了TlsAlloc()、TlsGetValue()、TlsSetValue()和TlsFree()這4個API函數用於顯式TLS變量的申請、取值、賦值和釋放;Linux下相對應的庫函數爲pthread庫中的pthread_key_create()、pthread_getspecific()、pthread_setspecific()和pthread_key_delete()。
顯式的TLS實現其實很是簡單,咱們前面提到過TEB結構中有個TLS數組。實際上顯式的TLS就是使用這個數組保存TLS數據的。因爲TLS數組的元素數量固定,通常是64個,因而顯式TLS在實現時若是發現該數組已經被使用完了,就會額外申請4096個字節做爲二級TLS數組,使得在WindowsXP下最多能擁有1088(1024+64)個顯式TLS變量(固然隱式的TLS也會佔用TLS數組)。相對於隱式的TLS變量,顯式的TLS變量的使用十分麻煩,並且有諸多限制,顯式TLS的諸多缺點已經使得它愈來愈不受歡迎了,咱們並不推薦使用它。
Q&A: CreateThread()和_beginthread()有什麼不一樣
咱們知道在Windows下建立一個線程的方法有兩種,一種就是調用Windows API CreateThread()來建立線程;另一種就是調用MSVC CRT的函數_beginthread()或_beginthreadex()來建立線程。相應的退出線程也有兩個函數Windows API的ExitThread()和CRT的_endthread()。這兩套函數都是用來建立和退出線程的,它們有什麼區別呢?
不少開發者不清楚這二者之間的關係,他們隨意選一個函數來用,發現也沒有什麼大問題,因而就忙於解決更爲緊迫的任務去了,而沒有對它們進行深究。等到有一天突然發現一個程序運行時間很長的時候會有細微的內存泄露,開發者絕對不會想到是由於這兩套函數用混的結果。
根據Windows API和MSVC CRT的關係,能夠看出來_beginthread()是對CreateThread()的包裝,它最終仍是調用CreateThread()來建立線程。那麼在_beginthread()調用CreateThread()以前作了什麼呢?咱們能夠看一下_beginthread()的源代碼,它位於CRT源代碼中的thread.c。咱們能夠發現它在調用CreateThread()以前申請了一個叫_tiddata的結構,而後將這個結構用_initptd()函數初始化以後傳遞給_beginthread()本身的線程入口函數_threadstart。_threadstart首先把由_beginthread()傳過來的_tiddata結構指針保存到線程的顯式TLS數組,而後它調用用戶的線程入口真正開始線程。在用戶線程結束以後,_threadstart()函數調用_endthread()結束線程。而且_threadstart還用__try/__except將用戶線程入口函數包起來,用於捕獲全部未處理的信號,而且將這些信號交給CRT處理。
因此除了信號以外,很明顯CRT包裝Windows API線程接口的最主要目的就是那個_tiddata。這個線程私有的結構裏面保存的是什麼呢?咱們能夠從mtdll.h中找到它的定義,它裏面保存的是諸如線程ID、線程句柄、erron、strtok()的前一次調用位置、rand()函數的種子、異常處理等與CRT有關的並且是線程私有的信息。可見MSVC CRT並無使用咱們前面所說的__declspec(thread)這種方式來定義線程私有變量,從而防止庫函數在多線程下失效,而是採用在堆上申請一個_tiddata結構,把線程私有變量放在結構內部,由顯式TLS保存_tiddata的指針。
瞭解了這些信息之後,咱們應該會想到一個問題,那就是若是咱們用CreateThread()建立一個線程而後調用CRT的strtok()函數,按理說應該會出錯,由於strtok()所須要的_tiddata並不存在,但是咱們好像歷來沒碰到過這樣的問題。查看strtok()函數就會發現,當一開始調用_getptd()去獲得線程的_tiddata結構時,這個函數若是發現線程沒有申請_tiddata結構,它就會申請這個結構而且負責初始化。因而不管咱們調用哪一個函數建立線程,均可以安全調用全部須要_tiddata的函數,由於一旦這個結構不存在,它就會被建立出來。