线程局部存储,Part 3:编译器和链接器对隐式TLS的支持
线程局部存储,Part 4:访问__declspec(thread)变量
线程局部存储,Part 5:加载器对__declspec(thread)变量的支持(进程初始化阶段)
线程局部存储,Part 6:Windows Server 2003中隐式TLS支持方法设计中的问题
和其它主流多线程操作系统一样,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库,其它都无法使用。
前一篇,我概述了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)变量的一部分工作。下一次,我将讨论编译器和链接器通过何种机制来支持对线程局部变量的访问。
昨天,我大致说了下编译器和链接器如何合作来支持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)变量背后的事情远不是编译器和链接器能搞定的。某某仍然需要为每个线程分配存储空间,这个某某就是加载器。更多关于加载器在这里所扮演的角色将在下次进行探讨。
上次,我描述了编译器和链接器为访问__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)存储类的所有支持。这个方法看起来工作的很好,但事实上存在些问题(如果你一直看到现在,你也许会发现是什么问题)。更多关于以上方法的限制的讨论将在下一周为大家讲述。
上周,我描述了在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,但是实现相对复杂(由于考虑兼容性的原因)