线程局部存储

简介: TLS:Thread Local Storage,线程局部存储声明为TLS的变量在每个线程都会有一个副本,各个副本完全独立,每个副本的生命期与线程的生命期一样,即线程创建时创建,线程销毁时销毁。 C++11起可以使用thread_local关键字声明TLS变量,变量可以是任意类型。

介绍

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

wiki上没有具体说这个寄存器是干嘛的。不过这篇文章中说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变量的地址开销更大,其差异可看这篇文章
我运行了一下,结果如下

# 没有使用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变量是指针类型,需要自行分配内存。那么这块内存如何在线程退出时释放呢?
方法如下:

  1. 通过pthread_create_key()创建一个key,并指定该key的销毁函数f(),f()就是释放value指向的内存。
  2. 通过pthread_setspecific()将TLS指向的内存地址设为该key的value。
    这样的话,当线程退出时,就会执行f(),从而释放申请的TLS变量空间。

为了避免创建太多的key,可以只创建一个全局的专用于线程退出清理资源的key,该key的value是一个可以注册函数的对象的地址,而其销毁函数就是调用该对象中保存的函数,然后释放该对象的内存(这两步可以直接依赖析构机制实现)。其他TLS变量的释放函数注册到这个对象中。这个对象实际也是一个TLS变量。

相关实践学习
阿里云图数据库GDB入门与应用
图数据库(Graph Database,简称GDB)是一种支持Property Graph图模型、用于处理高度连接数据查询与存储的实时、可靠的在线数据库服务。它支持Apache TinkerPop Gremlin查询语言,可以帮您快速构建基于高度连接的数据集的应用程序。GDB非常适合社交网络、欺诈检测、推荐引擎、实时图谱、网络/IT运营这类高度互连数据集的场景。 GDB由阿里云自主研发,具备如下优势: 标准图查询语言:支持属性图,高度兼容Gremlin图查询语言。 高度优化的自研引擎:高度优化的自研图计算层和存储层,云盘多副本保障数据超高可靠,支持ACID事务。 服务高可用:支持高可用实例,节点故障迅速转移,保障业务连续性。 易运维:提供备份恢复、自动升级、监控告警、故障切换等丰富的运维功能,大幅降低运维成本。 产品主页:https://www.aliyun.com/product/gdb
目录
相关文章
|
7月前
|
存储 缓存 安全
【C/C++ 关键字 存储类说明符 】 线程局部变量的魔法:C++ 中 thread_local的用法
【C/C++ 关键字 存储类说明符 】 线程局部变量的魔法:C++ 中 thread_local的用法
143 0
|
6月前
|
存储 Java C++
Java虚拟机(JVM)管理内存划分为多个区域:程序计数器记录线程执行位置;虚拟机栈存储线程私有数据
Java虚拟机(JVM)管理内存划分为多个区域:程序计数器记录线程执行位置;虚拟机栈存储线程私有数据,如局部变量和操作数;本地方法栈支持native方法;堆存放所有线程的对象实例,由垃圾回收管理;方法区(在Java 8后变为元空间)存储类信息和常量;运行时常量池是方法区一部分,保存符号引用和常量;直接内存非JVM规范定义,手动管理,通过Buffer类使用。Java 8后,永久代被元空间取代,G1成为默认GC。
68 2
|
5月前
|
存储 设计模式 监控
Java面试题:如何在不牺牲性能的前提下,实现一个线程安全的单例模式?如何在生产者-消费者模式中平衡生产和消费的速度?Java内存模型规定了变量在内存中的存储和线程间的交互规则
Java面试题:如何在不牺牲性能的前提下,实现一个线程安全的单例模式?如何在生产者-消费者模式中平衡生产和消费的速度?Java内存模型规定了变量在内存中的存储和线程间的交互规则
52 0
|
7月前
|
存储 安全 Python
什么是Python中的线程局部存储(Thread Local Storage)?
【2月更文挑战第3天】【2月更文挑战第6篇】
160 0
|
7月前
|
存储 Linux API
C++新特性 线程局部存储
C++新特性 线程局部存储
179 0
|
存储 Linux 调度
【Linux】线程分离 | 线程库 | C++调用线程 | 线程局部存储
【Linux】线程分离 | 线程库 | C++调用线程 | 线程局部存储
146 0
|
存储 Python
Python threading Local()函数用法:返回线程局部
Python threading Local()函数用法:返回线程局部
104 0
|
存储 Windows 编译器
线程局部存储tls的使用
线程局部存储(Thread Local Storage,TLS)主要用于在多线程中,存储和维护一些线程相关的数据,存储的数据会被关联到当前线程中去,并不需要锁来维护。
1243 0
|
2月前
|
存储 消息中间件 资源调度
C++ 多线程之初识多线程
这篇文章介绍了C++多线程的基本概念,包括进程和线程的定义、并发的实现方式,以及如何在C++中创建和管理线程,包括使用`std::thread`库、线程的join和detach方法,并通过示例代码展示了如何创建和使用多线程。
51 1
C++ 多线程之初识多线程