线程局部存储(TLS)

来源:互联网 发布:贝茨训练软件 编辑:程序博客网 时间:2024/05/19 17:07
线程安全
堆栈中定义的局部变量,对多线程来说是安全的,因为不同的线程各有自己的堆栈。而通常定义的全局变量,所有线程都可以作读写访问,这样它就不是线程安全的,为安全就有必要加锁互斥访问。


而何为线程局部存储(TLS),简单的说,就是一个变量(通常是指针,指向具体的类型),每个线程都有一个副本,而在程序中可以按照相同的方式来访问,(比如使用相同的变量名,又或者都调用TlsGetValue),既然是都有副本,自然线程中互不影响。


打个比方,就如同一个人,被克隆出三个,其中一个被砍了一刀,其它两人都不会受伤。


定义TLS
隐式定义
VC编译器可以隐式定义线程局部变量,只要定义的时候加上__declspec (thread)前缀。比如


    __declspec (thread) int iGlobal_1 = 1;
    __declspec (thread) double iGlobal_2 = 2.0;
iGlobal_1,iGlobal_2就都有自己的副本。


显式定义
另外windows也提供了几个api, 来显式定义线程局部变量。这几个API为


TlsAlloc
TlsFree
TlsSetValue
TlsGetValue
用法自己查查。


好,说了TLS的用法,可以入正题。来说说它的实现。


显式TLS实现
操作系统会使用一个结构来描述线程,这结构通常称为TEB(线程环境块,Thread Environment Block)。每个线程有一个对应的TEB,系统维护一个指针值指向当前线程的TEB。切换线程的时候就改变这个指针值。这样需要查找线程相关的信息的时候,可以统一从这个指针值找起。


在windows中,这个线程指针值放在fs寄存器。


TEB的结构里面存放什么了,更具体可以查资料。这里我们只分析其中的一项,_tls_array。


_tls_array
TEB结构中,有个指针指向线程TLS数组,称为_tls_array,利用这个数组指针可以管理线程相关的局部变量。


windows系统中,_tls_array_指针在处于TEB偏移0x2h的地方,结合上面说的fs寄存器指向当前TEB。汇编代码


    mov ecx, dowrd ptr fs:[2ch]
其实就是取当前线程的_tls_array,放在ecx寄存器中。


索引
现在,我们在不同的线程中已经可以取得各自的_tls_array,要访问数组的元素,还差索引。回头再看看Windows API中TLS的相关函数,含义就很明显了。


TlsAlloc,是说,请为我分配一个索引号,表示相应的数组项已被使用。
TlsFree,就是释放索引号,表示相应的数组项可以被再次使用。
TlsSetValue,就是拿个索引号,向相应的数组项设值。
TlsGetValue,就是拿个索引,取出向相应的数组项的值。
同一个索引号,不同的数组
好好想想,为什么我有个相同索引号,在不同的线程中调用TlsGetValue,取出来的值会不同呢?因为数组的起始指针已经变了。线程切换引起了_tls_array数组的切换, 因此取值可以不同。


这样用TlsAlloc分配了一个索引号,所有线程中_tls_array的索引号对应的元素都已经归你管理,并非只是当前线程。* 再次强调,分配出一个索引,所有线程的_tls_array数组中的索引对应项都已经被分配,有5个线程,你可以管理的已经有5个格子,并非只是当前线程的一个格子。


索引号是一样的,可以用相同的方式来使用这些数组的小格子。比如你拿了个索引号为3,在所有线程你统一在索引号为3的格子放字符串,就算线程怎么切换,你总可以再次取出字符串,并且每个线程的字符串都各自不同。


现在你已经有小格子了,可以往里面放东西了,放什么东西你可以自己确定,你可以放指针,或者放整数,或者放字符。因为是自己放的,自己可以知道意思,取出来对你就是有用的。


插入题外话
基址+偏移
在计算机中反反复复都会出现 基址+偏移的模式。基址不变,偏移变,取的值不同;基址变,偏移不变,取的值也不同。这看起来很简单,但很可能没有意识到这点。
内存放什么
另一个很简单但又很容易忽略的问题,内存中放的究竟是什么?数字?可以说是,但更准确的是放状态,只不过这状态可以用数字来编码(任何东西都可以用数字来编码,只要你懂得解码的方式,这串数字对你就是有意义的)。 2bit, 可以表示4个状态,4bit可以表示16个状态,32bit可以表示4G个状态。
上面其实已经说完了显式TLS分配,也就是调用TlsAlloc等方式。那隐式的TLS分配,又是怎么实现的呢?


隐式TLS实现分析
.tls段
在代码中第一次调用TlsAlloc, 检查返回值,多数会发现返回1。为什么是1,而不是0呢? 因为0已经被使用了,谁在使用? 编译器。 比如你定义


    __declspec (thread) int iGlobal_1 = 1;
    __declspec (thread) double iGlobal_2 = 2.0;
时候,其实已经生成了一个段.tls,这个段中有这两个数据,保持下来放在执行文件中。当程序运行,每个线程会将.tls复制一份。线程_tls_array的0索引号被占据,对应的格子放着指向这份.tls数据的指针。上面的语句,你可以想象成编译器自动定义了一个结构


    struct TLS_Data
    {
        int iGlobal_1;
        double iGlobal_2;edx
    };
每个线程运行时,生成出这个结构,结构指针被设置到线程各自的_tls_array,0索引对应的位置。可以取得线程各自的TLS_Data结构,以相同的方式访问结构中的变量。


汇编分析
关闭优化,看看代码


    __declspec (thread) int iGlobal = 1;


    int main()
    {
        int i = iGlobal;
        return 0;
    }
的汇编代码输出。


    // 将索引放在eax中,通常为0
    mov         eax,[__tls_index]      


    // 将线程对应的_tls_array指针放在ecx中      
    mov         ecx,dword ptr fs:[2Ch]       


    // 每个格有4byte, 取出_tls_array数组元素,放在edx中。
    // 这数组元素放着的是我们假象的TLS_Data结构指针
    mov         edx,dword ptr [ecx+eax*4]


     // 指针加上变量在结构中的偏移,取得iGlobal变量值
    mov         eax,dword ptr [edx+104h]   


    // 将iGlobal变量值放在栈变量,也就是i中
    mov         dword ptr [ebp-4],eax       
注意,iGlobal的偏移并不是0,因为已经有些变量定义了。比如线程各自的errno变量,strtok函数用的变量等等。


可以这样说,编译器拿出_tls_array的一个格子,自己管理,又再实现出另一种风格的TLS。


我们也可以自己用TlsAlloc取得一个索引,跟着生成另一个子数组,子数组指针放在_tls_array元素中。真正的数据指针放在子数组中。这样,我们就可以根据自己的需要来实现自己的线程局部存储。而又不占用多个_tls_array数组的索引。


其它讨论
再来讨论一下_tls_array数组的索引的分配跟释放。一定要有某种方式来标记着那个索引被分配了,那个索引还可以使用。


如果_tls_array一定要放指针,那我们可以将没有分配的索引元素设置为NULL, 已分配的非NULL,从前到后检查数值,取第一个NULL元素索引分配出去。


但因为数组不一定放指针,也可以放整数,整数没有所谓的无效值,就不能用这种方式。你可以创建一个同样大小的bool数组作标记,也可以采用位判断方式来替代bool数组。


如果有需要,还可以定义自己的结构作标记用。但一定要有种方式来区别分配的索引号跟没有分配的索引号。