问小白 wenxiaobai
资讯
历史
科技
环境与自然
成长
游戏
财经
文学与艺术
美食
健康
家居
文化
情感
汽车
三农
军事
旅行
运动
教育
生活
星座命理

线程局部存储(TLS)详解

创作时间:
作者:
@小白创作中心

线程局部存储(TLS)详解

引用
CSDN
1.
https://blog.csdn.net/qq_42570601/article/details/137047960

线程局部存储(TLS)是一种变量的存储方法,使得变量在它所在的线程内是全局可访问的,但不能被其他线程访问。这种机制避免了全局变量需要锁来控制访问的问题,降低了控制成本和代码复杂度。本文将详细介绍TLS的概念、实现方式及其在ELF格式中的具体应用。

一、C/C++编程接口

POSIX线程库提供了以下API来管理TLS:

// 创建一个TLS变量,并设置析构函数
int pthread_key_create(pthread_key_t *key, void (*destructor)(void*));

// 回收TLS变量,但不调用TLS的析构函数
int pthread_key_delete(pthread_key_t key);

// 获取TLS变量的当前值
void *pthread_getspecific(pthread_key_t key);

// 给TLS变量赋值
int pthread_setspecific(pthread_key_t key, const void *value);

除了使用API的方式,GCC编译器也支持语言级别的用法,使用__thread关键字可以更简单地声明线程局部变量:

__thread int i;
__thread char *p;
__thread struct state s;

下面是一个使用GCC编译器支持的TLS实现的示例:

// 编译:g++ main.cc -lpthread
#include<iostream>
#include<pthread.h>
#include<unistd.h>
using namespace std;

__thread int iVar = 100;

void* Thread1(void *arg) {
    iVar += 200;
    cout<<"Thead1 Val : "<<iVar<<endl;
}

void* Thread2(void *arg) {
    iVar += 400;
    sleep(1);
    cout<<"Thead2 Val : "<<iVar<<endl;
}

int main() {
    pthread_t pid1, pid2;
    pthread_create(&pid1, NULL, Thread1, NULL);
    pthread_create(&pid2, NULL, Thread2, NULL);
    pthread_join(pid1, NULL);
    pthread_join(pid2, NULL);
    return 0;
}

__thread关键字可以应用于任何全局变量、文件作用域静态变量或函数作用域静态变量。它对于始终是线程局部变量的自动变量没有影响。

在C++中,如果初始化需要静态构造函数,将无法初始化线程局部变量。否则,可以将线程局部变量初始化为对于普通静态变量合法的任何值。无论是线程局部变量还是其他变量,都不能静态地初始化为线程局部变量的地址。

线程局部变量可以在外部声明和引用。线程局部变量遵循与普通符号相同的插入规则。

二、ELF中的TLS节

代码中的全局变量通常存储在.data(静态初始化变量)和.bss(未静态初始化的变量)这两个段。而TLS变量的存储位置有所不同:

  • 已初始化的线程局部变量分配在.tdata.tdata1节中,节类型为SHT_NOBITS,节属性为SHF_ALLOC + SHF_WRITE + SHF_TLS。此初始化可能需要重定位。
  • 未初始化的线程局部变量定义为COMMON符号,最终分配在.tbss节中,节类型为SHT_PROGBITS,节属性为SHF_ALLOC + SHF_WRITE + SHF_TLS

.data不同的是,运行时程序不会直接访问这些TLS段。在分配了任何已初始化的节后会立即分配未初始化的节,并进行填充以便正确对齐(内存中.tbss紧跟在.tdata后)。

.tdata.tbss合并的节一起构成TLS模板,每次创建新线程时,都会使用此模板分配TLS,所以每个线程启动时TLS都是相同的。此模板的已初始化部分称为TLS初始化映像。所有因已初始化的线程局部变量而生成的重定位将应用于此模板。当新线程需要初始值时,将使用重定位的值。

每个线程的TLS块都是运行时分配的,所以在链接时是不知道其地址的,要访问TLS变量必须借助动态链接器才能计算出其地址。链接时只能知道TLS变量在TLS段中的偏移。

TLS符号的符号类型为STT_TLS,这些符号表示相对于TLS模板开头的偏移量,而不是实际的虚拟地址。TLS符号指向TLS模板的开头,而不是每个数据项的每个线程副本。在exec文件和共享目标文件中,对于已定义的TLS符号,其st_value字段包含指定的TLS偏移量,而对于未定义的TLS符号,此字段通常包含零。

访问TLS符号通常需要进行重定位,以便在运行时能够正确地计算TLS数据的地址。这些重定位引用STT_TLS类型的符号,并且还可以引用与GOT项关联的局部节符号。

对于根据TLS项进行的重定位,重定位地址在TLS模板的末尾编码为负偏移。计算该偏移时,首先将模板大小舍入到32位目标文件中最接近的8字节边界,然后舍入为64位目标文件中最接近的16字节边界。此舍入操作确保静态TLS模板合理对齐以便可用于任何用途。

在exec文件和共享目标文件中,PT_TLS程序项用于描述TLS模板。此模板包含以下成员:

  • p_offset:TLS初始化映像的文件偏移
  • p_vaddr:TLS初始化映像的虚拟内存地址
  • p_paddr:0
  • p_filesz:TLS初始化映像的大小
  • p_memsz:TLS模板的总大小
  • p_flagsPF_R
  • p_align:TLS模板的对齐方式

三、TLS运行时分配

在程序的生命周期中,会在三个时间创建TLS:

  • 程序启动时
  • 创建新线程时
  • 程序启动后装入共享目标文件之后,线程第一次引用TLS块时

3.1 TLS布局结构

运行时线程局部数据存储的布局如下图所示。

线程指针

每个线程t都有一个关联的线程指针t p t tp_ttpt,该指针指向线程控制块TCB。线程指针tp始终包含当前正在运行的线程的t p t tp_ttpt值。

TLS模块偏移

动态链接器将exec文件装载之后,假设与exec文件相关联的动态库有m个(再假设每个都有TLS模块),所以也就会有m + 1个模块(一个是exec的,假设其有)。动态链接器会将这些模块合并成单个静态模板,在合并的模板中,为每个动态目标文件(exec和共享库)的TLS模板指定一个偏移t l s o f f s e t m tlsoffset_mtlsoffsetm

t l s o f f s e t 1 = r o u n d ( t l s s i z e 1 , a l i g n 1 ) tlsoffset_1 = round(tlssize_1, align_1 )tlsoffset1 =round(tlssize1 ,align1 )

t l s o f f s e t m + 1 = r o u n d ( t l s o f f s e t m + t l s s i z e m + 1 , a l i g n m + 1 ) tlsoffset_{m+1} = round(tlsoffset_m + tlssize{m+1}, align_{m+1})tlsoffsetm+1 =round(tlsoffsetm +tlssizem+1,alignm+1 )

动态线程向量

动态线程向量(Dynamic Thread Vector,dtv)是在多线程程序中用于管理线程局部存储(TLS)的数据结构之一。每个线程都有一个dtv,用于存储该线程的TLS变量的地址列表。dtv是一个数组或指针数组,其中的每个元素都指向一个TLS变量的地址,这些地址通常相对于线程基址或线程指针(TP)进行偏移(可通过tp + tlsoffset进行访问)。

线程库为当前线程t创建一个指针向量d t v t dtv_tdtvt。每个向量的第一个元素都包含一个生成编号g e n t gen_tgent,该生成编号用于确定需要扩展向量的时间。d t v t , m dtv_{t,m}dtvt,m向量中剩余的每个元素都是一个指针,指向为属于动态目标文件m的TLS的块的地址。

分配模型

有些模块的TLS块跟TCB放在一起,是程序启动时就分配的(如exec及其依赖的.so),称为静态模型;有些模块是程序运行中动态加载的(通过dlopen()动态加载),TLS块在线程第一次访问时分配,称为动态模型

对于静态模型,在程序启动时动态链接器就可以确定其相对于t p t tp_ttpt的偏移值,如t l s o f f s e t 1 、 t l s o f f s e t 2 、 t l s o f f s e t 3 tlsoffset_1、tlsoffset_2、tlsoffset_3tlsoffset1 、tlsoffset2 、tlsoffset3,编译器生成代码时可以直接使用这些偏移值来访问。

对于动态模型,线程库将延迟分配TLS块。分配将在第一次引用已装入的目标文件中的TLS变量时进行,需要调用运行时系统提供的__tls_get_addr()获取其地址,如t l s o f f s e t 4 、 t l s o f f s e t 5 tlsoffset_4、tlsoffset_5tlsoffset4 、tlsoffset5

3.2 延迟分配TLS

对于延迟分配的TLS,由于其偏移值在启动时未知,必须借助于__tls_get_addr()获取,定义类似如下:

struct tls_index {
    size_t module_id;
    size_t offset;
};

void* __tls_get_addr(struct tls_index* ti) {
    // Get the DTV of current thread.
    dtv_t* dtv = GET_CURRENT_DTV();
    // Check if the DTV is stale, and if so, update it.
    if (dtv[0].counter != dl_tls_generation) {
        update_dtv();
    }
    // Get the TLS block. If not allocated yet, allocate now.
    char* tls_block = dtv[ti->module_id];
    if (tls_block == UNALLOCATED_TLS_BLOCK) {
        tls_block = dtv[ti->module_id] = allocate_tls(module_id);
    }
    return tls_block + ti->offset;
}

module_id是模块ID,由动态链接器在加载模块时分配,从1开始(exec文件的模块ID固定是1)。

当动态加载或卸载一个模块时,动态链接器维护的dl_tls_generation会加1,表示模块信息有了变化。由于每个线程的DTV时延迟更新的,所以每个线程的dtv[0]也会维护自己的generation counter,用于在访问TLS时判断是否需要更新DTV。

四、TLS的访问模型

每个TLS引用都遵循下列访问模型之一。这些模型按照最常见、但最少优化到速度最快、但限制最大的顺序列出。要访问TLS变量需要确定两个信息:

  • 定义TLS变量的模块(可执行程序exec或动态共享库.so)
  • TLS变量在该模块的TLS段的偏移

4.1 常规动态 (General Dynamic, GD)-动态 TLS

此模型允许从共享目标文件或exec文件中引用所有TLS变量。如果是第一次从特定线程引用TLS块,此模型还支持延迟分配此块。

这种模式下不需要链接时知道模块ID和偏移值。程序启动时动态链接器通过重定向确定模块ID和TLS变量的偏移值,存储在GOT表中。在访问TLS时调用__tls_get_addr(),传入这两个参数,获取TLS变量的地址。

4.2 局部动态 (Local Dynamic, LD)-局部符号的动态 TLS

此模型是对GD模型的优化。编译器可能会确定变量在要生成的目标文件中是局部绑定或受到保护的。在这种情况下,编译器将指示链接器静态绑定动态的tlsoffset并使用此模型。与GD模型相比,此模型可提供更好的性能。每个函数只需要调用一次tls_get_addr()即可确定d t v 0 , m dtv_{0,m}dtv0,m的地址。进行链接编辑时绑定的动态TLS偏移会与每个引用的d t v 0 , m dtv_{0,m}dtv0,m地址相加。

如果链接器确定访问的TLS变量属于本模块(如文件作用域的TLS变量),则采用此模型。TLS变量的偏移值在链接时即可确定,只需要调用__tls_get_addr()确定TLS块的地址即可。由于TLS块的地址可以在不同的本地TLS变量访问时复用,所以相比于GD模型编译器可利用此模型生成有效的代码减少对__tls_get_addr()的调用次数。

4.3 初始可执行 (Initial exec文件utable, IE)-具有指定偏移的静态 TLS

此模型只能引用初始静态TLS中包含的TLS变量。此模板由进程启动时可用的所有TLS块和一个小的备份预留空间组成。在此模型中,给定变量x相对于线程指针的偏移存储在x的GOT项中。

此模型可以从初始进程启动后通过延迟装入、过滤器或dlopen()装入的共享库中引用有限数量的TLS变量。该访问可通过固定的备份预留空间来实现。此预留空间只能为未初始化的TLS数据项提供存储空间。为实现最大的灵活性,共享目标文件应使用动态的TLS模型引用线程局部变量。

如果可以确定访问的TLS变量在程序启动时就已分配好,则采用此模型。TLS变量相对于线程寄存器的偏移量可在程序启动时由动态链接器计算好存放在GOT表中。访问TLS变量相当于一次间接地址访问,不需要调用__tls_get_addr()

4.4 局部可执行 (Local exec文件utable, LE)-静态 TLS

此模型只能引用exec文件的TLS块中包含的TLS变量。链接器静态地计算相对于线程指针的偏移,而不需要进行动态重定位或额外引用GOT。此模型不能用于引用exec文件外部的变量。

如果可以确定在exec文件中访问exec文件定义的TLS变量,则采用此模型。链接时即可知道TLS变量相对于线程寄存器的偏移量,计算其地址相当于寄存器加上一个常量,因此访问TLS变量与访问局部变量没有区别。

4.5 模式转换

链接器可以将代码从更常规的访问模型转换为更优化的模型(如果确定适合进行转换)。这种转换可以使用独特的TLS重定位来实现。这些重定位不仅请求执行更新,还会标识要使用的TLS访问模型。

链接器在了解TLS访问模型和要创建的目标文件类型后,便可执行转换。例如,如果一个可重定位目标文件使用GD访问模型,被链接到一个exec文件中。在这种情况下,链接器可以适当地使用IE或LE访问模型转换引用。然后执行模型所需的重定位。

下图说明了不同的访问模型,以及从一个模型到另一个模型的转换。

© 2023 北京元石科技有限公司 ◎ 京公网安备 11010802042949号