介绍
TLS:Thread Local Storage, 线程局部存储
声明为 TLS 的变量在每个线程都会有一个副本, 各个副本完全独立, 每个副本的生命期与线程的生命期一样, 即线程创建时创建, 线程销毁时销毁.
C++11 起可以使用关键字声明 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
https://en.wikipedia.org/wiki/X86#32-bit 上没有具体说这个寄存器是干嘛的. 不过这篇文章中说 FS 寄存器指向当前活动线程的 TEB 结构.
_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 变量的地址开销更大, 其差异可看这篇文章 https://www.jianshu.com/p/2c5921dad6a0 .
我运行了一下, 结果如下
- # 没有使用 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 变量.
来源: https://yq.aliyun.com/articles/691215