11.3.3 线程局部存储实现(1)
《程序员的自我修养:链接、装载与库》第11章运行库。本章主要介绍运行库的概念、C/C++运行库、Glibc和MSVC CRT、运行库如何实现C++全局构造和析构及以fread()库函数为例对运行库进行剖析。本节为大家介绍线程局部存储实现。
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的实现
对于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的函数,因为一旦这个结构不存在,它就会被创建出来。