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的函数,由于一旦这个结构不存在,它就会被建立出来。