介绍
TLS:Thread Local Storage,线程局部存储
声明为TLS的变量在每个线程都会有一个副本,各个副本完全独立,每个副本的生命期与线程的生命期一样,即线程创建时创建,线程销毁时销毁。
- C++11起可以使用
thread_local
关键字声明TLS变量,变量可以是任意类型。 - GCC内置的
__thread
关键字也可以用来声明TLS变量,但是只能修饰POD类型,修饰非POD类型时编译会报错。 - pthread_key_create()创建key,通过pthread_setspecific()和pthread_getspecific()设置和获取与key绑定的TLS变量值
程序验证
主程序中使用TLS
代码清单
#include <iostream>
#include <thread>
using namespace std;
class Adder
{
public:
Adder()
{
m_num = 0;
cout << "Adder tid:" << std::this_thread::get_id() << " num=" << m_num << endl;
}
~Adder()
{
cout << "~Adder tid:" << std::this_thread::get_id() << " num=" << m_num << endl;
}
Adder& operator<<( int num )
{
m_num += num;
}
private:
int m_num;
};
thread_local Adder num;
void worker()
{
int i = 10;
while( --i ){
num << i;
}
return;
}
int main()
{
num << 1;
thread a( worker );
a.join();
cout << "worker destroy" << endl;
return 0;
}
该程序定义了一个TLS变量,用来累加整数,拥有两个线程
- 主线程往TLS变量里加1
- worker线程往TLS持续累加9...1
g++ tls.cpp -std=c++11 -pthread -g
编译后,执行结果如下
[root@localhost thread_local]# ./tls
Adder tid:139769130821504 num=0
Adder tid:139769113929472 num=0
~Adder tid:139769113929472 num=45
worker destroy
~Adder tid:139769130821504 num=1
4.8.5的g++;必须加-pthread,否则运行时会异常
Adder tid:thread::id of a non-executing thread num=0 terminate called after throwing an instance of 'std::system_error' what(): Enable multithreading to use std::thread: Operation not > permitted Aborted
由上可知
- TLS变量确实在每个线程中都有一个副本,且互不影响,主线程为1,worker线程为45
- TLS变量随着线程销毁而销毁,如worker线程销毁时,其线程中的Adder对象也销毁了。
如果将使用Adder对象的代码注释掉,重新执行会发现两个Adder对象并未被构造,由此可知TLS变量并非在线程创建时就构造好,而是在使用时才构造。
gdb该程序可以看到Adder对象的信息
- 在主线程中
(gdb) p num
$3 = {m_num = 0}
(gdb) p &num
$2 = (Adder *) 0x7ffff7fec778
- 在worker线程中
(gdb) p num
$3 = {m_num = 0}
(gdb) p &num
$4 = (Adder *) 0x7ffff6fd26f8
综上可知,TLS变量的空间是在线程创建时就分配的,但其构造函数只有在使用时才调用。相同变量名,不同的地址。
将注释去掉重新编译,然后使用objdump -S tls
反汇编,主线程中num<<1
的汇编代码如下
4010b6: e8 f7 12 00 00 callq 4023b2 <_ZTW3num> // 获取num的地址,内部将地址放在了寄存器rax中
4010bb: be 01 00 00 00 mov $0x1,%esi // 将1塞入esi寄存处器,作为_ZN5AdderlsEi的参数
4010c0: 48 89 c7 mov %rax,%rdi // 将num的地址放入rdi寄存器,作为_ZN5AdderlsEi的参数
4010c3: e8 dc 03 00 00 callq 4014a4 <_ZN5AdderlsEi>
<_ZTW3num>
的汇编
00000000004023b2 <_ZTW3num>:
4023b2: 55 push %rbp
4023b3: 48 89 e5 mov %rsp,%rbp
4023b6: e8 b9 ed ff ff callq 401174 <_ZTH3num> // 获取本线程的num的地址
4023bb: 64 48 8b 04 25 00 00 mov %fs:0x0,%rax // 这应该是取出某个起始地址
4023c2: 00 00
4023c4: 48 05 f8 ff ff ff add $0xfffffffffffffff8,%rax // 0xfffffffffffffff8应该是补码形式,代表-8。这说明fs[-8]的位置是TLS变量的地址
4023ca: 5d pop %rbp
4023cb: c3 retq
_ZTH3num
的汇编,gdb时函数名叫__tls_init()
0000000000401174 <_ZTH3num>:
401174: 55 push %rbp
401175: 48 89 e5 mov %rsp,%rbp
401178: 64 0f b6 04 25 fc ff movzbl %fs:0xfffffffffffffffc,%eax // 0xfffffffffffffffc是-12,这个偏移位置应该是TLS变量的初始化标志
40117f: ff ff
401181: 83 f0 01 xor $0x1,%eax
401184: 84 c0 test %al,%al
401186: 74 41 je 4011c9 <_ZTH3num+0x55> // 如果已经构造过了,则跳到4011c9 ,直接返回了
401188: 64 c6 04 25 fc ff ff movb $0x1,%fs:0xfffffffffffffffc
40118f: ff 01
thread_local Adder num; // 以下是num的构造过程
401191: 64 48 8b 04 25 00 00 mov %fs:0x0,%rax
401198: 00 00
40119a: 48 05 f8 ff ff ff add $0xfffffffffffffff8,%rax // 取出TLS变量的地址,开始调用构造函数
4011a0: 48 89 c7 mov %rax,%rdi
4011a3: e8 2a 02 00 00 callq 4013d2 <_ZN5AdderC1Ev> // 调用Adder的构造函数
4011a8: 64 48 8b 04 25 00 00 mov %fs:0x0,%rax
4011af: 00 00
4011b1: 48 05 f8 ff ff ff add $0xfffffffffffffff8,%rax
4011b7: ba 08 27 40 00 mov $0x402708,%edx
4011bc: 48 89 c6 mov %rax,%rsi
4011bf: bf 40 14 40 00 mov $0x401440,%edi // 0x401440就是Adder的析构函数的地址
4011c4: e8 87 fb ff ff callq 400d50 <__cxa_thread_atexit@plt> // 注册线程退出时的销毁函数
4011c9: 5d pop %rbp
4011ca: c3 retq
从上面的分析可知,线程的TLS变量信息是放在FS寄存器里的。但从这里好像看不出来这个TLS变量的空间是怎么分配出来的。
从这篇文章中能知道编译后的TLS变量信息存储在tbss段(未初始化)或tdata段(已初始化)。
修改线程数并不会改变tbss段的大小
[root@localhost thread_local]# objdump -h tls|grep tbss
19 .tbss 00000005 0000000000604dc8 0000000000604dc8 00004dc8 2**2
在Adder中新增一个int成员,tbss段的大小发生变化。
[root@localhost thread_local]# objdump -h tls|grep tbss
19 .tbss 00000009 0000000000604dc8 0000000000604dc8 00004dc8 2**2
当新增一个int的TLS变量时,tbss段的大小也会发生变化
[root@localhost thread_local]# objdump -h tls|grep tbss
19 .tbss 0000000d 0000000000604dc8 0000000000604dc8 00004dc8 2**2
由此猜测,所有线程启动时会根据tbss或tdata的大小分配整块TLS空间,然后根据每个TLS变量的大小偏移到具体位置取出地址。
定义两个TLS变量,反汇编查看发现其偏移确实不同,可以证实以上猜测。
n1 = 1;
400541: 64 c7 04 25 f8 ff ff movl $0x1,%fs:0xfffffffffffffff8
400548: ff 01 00 00 00
n2 = 1;
40054d: 64 c7 04 25 fc ff ff movl $0x1,%fs:0xfffffffffffffffc
动态库中使用TLS
代码清单
thread_local int n;
int foo()
{
n = 10;
return n;
}
通过g++ -fPIC -shared -std=c++11 -pthread tls.cpp -o libtls.so
编译为动态库。
可以看到TLS变量的大小还是记录在tbss段中。
[root@localhost thread_local]# objdump -h libtls.so |grep tbss
15 .tbss 00000004 0000000000200d98 0000000000200d98 00000d98 2**2
反汇编后可以看到,在动态库中取TLS变量地址的方式和主程序里明显不同。在动态库中,TLS变量的地址是通过__tls_get_addr()
获取到的,而不是通过FS寄存器获取到的。
0000000000000765 <_Z3foov>:
765: 55 push %rbp
766: 48 89 e5 mov %rsp,%rbp
769: 66 48 8d 3d 7f 08 20 data16 lea 0x20087f(%rip),%rdi # 200ff0 <n@@Base+0x200ff0>
770: 00
771: 66 66 48 e8 e7 fe ff data16 data16 callq 660 <__tls_get_addr@plt>
778: ff
779: c7 00 0a 00 00 00 movl $0xa,(%rax)
77f: 66 48 8d 3d 69 08 20 data16 lea 0x200869(%rip),%rdi # 200ff0 <n@@Base+0x200ff0>
786: 00
787: 66 66 48 e8 d1 fe ff data16 data16 callq 660 <__tls_get_addr@plt>
78e: ff
78f: 8b 00 mov (%rax),%eax
791: 5d pop %rbp
792: c3 retq
代码清单
#include <thread>
int foo();
extern thread_local int n;
int main()
{
n++;
std::thread a( foo );
a.join();
return 0;
}
g++ main.cpp -g -pthread -std=c++11 -L./ -ltls
生成a.out。通过objdump -h a.out
发现在a.out中并没有tbss段。通过gdb调试,发现主线程里的TLS变量还是通过FS寄存器获取到的。
0x0000000000401e2e <+0>: push %rbp
0x0000000000401e2f <+1>: mov %rsp,%rbp
0x0000000000401e32 <+4>: mov $0x0,%eax
0x0000000000401e37 <+9>: test %rax,%rax
0x0000000000401e3a <+12>: je 0x401e41 <_ZTW1n+19>
0x0000000000401e3c <+14>: callq 0x0
=> 0x0000000000401e41 <+19>: mov %fs:0x0,%rdx
0x0000000000401e4a <+28>: mov 0x2021a7(%rip),%rax # 0x603ff8
0x0000000000401e51 <+35>: add %rdx,%rax
0x0000000000401e54 <+38>: pop %rbp
0x0000000000401e55 <+39>: retq
从《 glibc TLS变量初始化问题分析》知道TLS有4种访问模型,也可以看到__tls_get_addr()
的源码。可知在动态库里使用TLS,每次取TLS变量的地址会比在主程序中取TLS变量的地址开销更大,其差异可看这篇文章。
我运行了一下,结果如下
# 没有使用TLS
real 0m38.976s
user 0m38.803s
sys 0m0.036s
# 在主程序中使用TLS
real 0m37.364s
user 0m37.191s
sys 0m0.046s
# 在动态库中使用TLS
real 1m4.137s
user 1m3.968s
sys 0m0.034s
常见应用
在一些内存管理库中会使用TLS变量,提升多线程内存申请释放的性能,比如tcmalloc。其原理就是由TLS变量指向从主分配区申请的内存管理对象,这样每次申请和释放时都无需加锁,只需从TLS变量指向的对象中分配内存。
在写多读少的场景下,所有写入都写在TLS变量中,避免锁竞争,只需在读取时加锁,汇聚所有写入值即可。
因为__thread
不能修饰非POD类型,所以一般TLS变量是指针类型,需要自行分配内存。那么这块内存如何在线程退出时释放呢?
方法如下:
- 通过pthread_create_key()创建一个key,并指定该key的销毁函数f(),f()就是释放value指向的内存。
- 通过pthread_setspecific()将TLS指向的内存地址设为该key的value。
这样的话,当线程退出时,就会执行f(),从而释放申请的TLS变量空间。
为了避免创建太多的key,可以只创建一个全局的专用于线程退出清理资源的key,该key的value是一个可以注册函数的对象的地址,而其销毁函数就是调用该对象中保存的函数,然后释放该对象的内存(这两步可以直接依赖析构机制实现)。其他TLS变量的释放函数注册到这个对象中。这个对象实际也是一个TLS变量。