当多个线程需要访问同一个名称的全局或静态变量,但每个线程必须维护自己独立的变量副本且互不干扰时,使用 TLS(Thread Local Storage,线程局部存储)。
如图,每个线程在 TLS 中维护独立的数据副本。从 C11 标准开始,C 语言正式引入了_Thread_local关键字(在<threads.h>中提供了宏thread_local)。在此之前,传统 GCC/Clang 使用__thread扩展,MSVC 使用__declspec(thread)。
TLS应用实例
使用 TLS 的典型场景
保存线程独立的错误码/状态:标准 C 库的
errno就是典型的 TLS 实现。多线程下如果errno是全局变量,线程 A 的失败会覆盖线程 B 的状态;通过 TLS 则互不影响。避免加锁(Lock-free State):例如多线程日志系统中保存线程私有的缓冲区(Buffer)或 Trace ID。如果用全局 Buffer 必须加互斥锁(Mutex),用 TLS 则完全免去锁开销。
重构非线程安全的传统 API:例如标准库中的
strtok()依赖内部静态指针记录上一次解析位置,因此不是线程安全的;使用 TLS 存放该状态(如strtok_r的思路)即可将其改造为线程安全。
代码如下:
#include <iostream> #include <thread> #include <chrono> #include <string> // 使用 thread_local 声明线程局部变量 thread_local int tls_last_error = 0; thread_local std::string tls_log_buffer; void set_thread_error(int code, const std::string& msg) { tls_last_error = code; tls_log_buffer = "[Err " + std::to_string(code) + "]: " + msg; } void worker_a() { set_thread_error(404, "Resource not found on Thread A"); std::this_thread::sleep_for(std::chrono::seconds(1)); // 模拟耗时操作 std::cout << "Thread A -> " << tls_log_buffer << std::endl; } void worker_b() { set_thread_error(500, "Internal error on Thread B"); std::cout << "Thread B -> " << tls_log_buffer << std::endl; } int main() { std::thread t1(worker_a); std::thread t2(worker_b); t1.join(); t2.join(); return 0; }执行结果:
Thread B -> [Err 500]: Internal error on Thread B Thread A -> [Err 404]: Resource not found on Thread AGCC 的 4 种 TLS 模型
4 种 TLS 模型(Global Dynamic、Local Dynamic、Initial Exec、Local Exec)时,核心关注点是:线程基址偏移量的获取方式(是运行时动态计算,还是编译/链接期直接确定)以及它们对应的汇编指令复杂度与重定位类型。
各模型在 x86_64 体系下的核心差异如下:
| TLS 模型 | GCC 编译选项 | 单变量属性写法 | 核心机制 | 汇编开销(指令数 / 寄存器) | 支持 dlopen | 适用场景 |
|---|---|---|---|---|---|---|
| Global Dynamic (GD) | -ftls-model=global-dynamic | __attribute__((tls_model("global-dynamic"))) | 每次访问通过 GOT 表项传参调用__tls_get_addr() | 最高(跨函数调用,传参并保存寄存器) | 是 | 任意 .so 动态库;‑fPIC共享库默认模型GCC |
| Local Dynamic (LD) | -ftls-model=local-dynamic | __attribute__((tls_model("local-dynamic"))) | 同模块仅一次__tls_get_addr()获取模块 TLS 基址,后续变量用偏移 | 中等(同模块多 TLS 变量共享一次函数调用) | 是 | 同一.so内部大量 TLS 变量,不跨 DSO 访问 |
| Initial Exec (IE) | -ftls-model=initial-exec | __attribute__((tls_model("initial-exec"))) | 跳过函数调用,GOT 读取静态 TLS 偏移 | 低(2 次内存寻址 + 加法) | 否(占用 static‑TLS 槽,dlopen 容易爆cannot allocate memory in static TLS block) | 程序启动阶段加载的库;无‑fPIC 主程序默认GCC |
| Local Exec (LE) | -ftls-model=local-exec | __attribute__((tls_model("local-exec"))) | 链接器硬编码相对于 FS/GS (TP) 的固定偏移 | 最低,单条内存访问指令 | 否 | 主程序内部 TLS 变量,不能用于 so |
解释下一些关键参数。
GOT
GOT 表项(Global Offset Table,全局偏移表)是ELF PIC/PIE 位置无关代码的核心数据结构,存放在数据段,是一张地址数组,每一个元素就是一个GOT 表项(GOT entry),64 位下每项 8 字节,存真实运行时地址。
GOT配合PLT (过程链接表),是 ELF动态链接的核心机制,只出现在动态可执行程序 /.so共享库;静态编译、裸机 ELF 没有 GOT。
背景:共享库.so需要位置无关代码 PIC(Position‑Independent Code)。
共享库每次加载到内存的虚拟地址是不固定的,不能把硬编码地址写死在指令里。
TLS 场景里,GOT 分为两部分:普通 GOT(`.got`) + TLS 专用 GOT(`.got.tls`,也叫 TLS GOT)。
.got:存全局变量的地址.got.plt:存外部函数的地址,和 PLT 配套使用
TLS 模型 GD/LD/IE 全部依赖 TLS‑GOT 表项;LE 完全不用 GOT。
dlopen
dlopen()是 Linux glibc 提供的动态库运行时加载 API,属于 POSIX 动态链接接口。 头文件:<dlfcn.h>,链接时必须加-ldl。
void *dlopen(const char *filename, int flag);作用:程序已经跑起来之后,运行时才去加载一个.so(DSO 动态共享对象),不是程序启动时就加载。
两种加载模式对比
- 启动时加载(静态链接依赖)编译链接时写上
-lxxx,程序一启动,ld.so 就把libxxx.so加载进进程地址空间。 👉 这种库可以使用 IE / LE(static‑TLS)。 - dlopen 晚加载(运行时加载)程序已经执行很久了,代码里主动调用
dlopen("libxxx.so", RTLD_NOW),运行时才把 so 加载进来。 👉dlopen 加载的 DSO,不能用 IE / LE TLS 模型,只能用 GD / LD。
原因:IE/LE 依赖进程启动时预先分配好的 static‑TLS 内存池;dlopen 晚加载无法扩容 static‑TLS,直接报错:cannot allocate memory in static TLS block。
最小示例:
#include <dlfcn.h> #include <stdio.h> int main(){ // 运行时才加载 libtest.so void *handle = dlopen("./libtest.so", RTLD_NOW); if(!handle){ printf("%s\n", dlerror()); return -1; } // 获取函数指针调用 void (*func)(void) = dlsym(handle, "hello"); func(); dlclose(handle); return 0; }编译:gcc main.c -o app -ldl
GCC 默认规则(重点)GCC
- 编译共享库带
-fPIC:默认 = global‑dynamic - 编译可执行程序,不带
-fPIC/-fPIE:默认 = initial‑exec - 编译器会做自动升级优化:即使你手动指定模型,如果符号可见性、编译选项不满足,gcc 会自动切换到更保守模型。例如:
- 给 so 强制
local‑exec会被 gcc 直接降级为 global‑dynamic; - 模块外部可见的 TLS 符号,
local‑dynamic会回退到 global‑dynamic。
- 给 so 强制
global‑dynamic(GD) vs local‑dynamic(LD)
共同点:
- 都属于动态 TLS 模型,支持
dlopen晚加载; - 都调用
__tls_get_addr; - 都需要两组 TLS‑GOT 重定位:
R_X86_64_DTPMOD64(模块 ID)、R_X86_64_DTPOFF64(模块内偏移); - 只能用于 PIC 共享库。
核心差异:__tls_get_addr调用次数、GOT 表是否共享、作用范围
每一个.so就是一个独立 DSO;每个 DSO 加载时,动态链接器给它分配一个唯一的dso_id(模块 ID)。
__tls_get_addr(dso_id, offset)的第一个参数就是这个 DSO 编号。
Global‑Dynamic(GD,gcc -ftls-model=global‑dynamic,共享库默认)
每一个__thread变量,独占一套 GOT1/GOT2(DTPMOD(DSO id)/DTPOFF(DynamicTlsOFFset));每一次访问该变量,都完整调用一次__tls_get_addr。
C 代码示例:
__thread int a; __thread int b; int f() { return a + b; }x86‑64 汇编示意(GD):
f: # 取a:完整调用一次 __tls_get_addr mov rdi, QWORD [rip+GOT1_a] # DTPMOD(a) mov rsi, QWORD [rip+GOT2_a] # DTPOFF(a) call __tls_get_addr mov edx, [rax] # 取b:又完整调用一次 __tls_get_addr!!两套独立GOT表项 mov rdi, QWORD [rip+GOT1_b] # DTPMOD(b) mov rsi, QWORD [rip+GOT2_b] # DTPOFF(b) call __tls_get_addr mov eax, [rax] add eax, edx ret- GOT:变量 a、变量 b各占 2 个 TLS‑GOT 项
- 开销:访问 a 调一次,访问 b 又调一次;多次访问同一个变量也会重复调用。
- 优点:可以跨 DSO 访问别的 so 的 TLS 变量。
- 缺点:大量 TLS 变量时代码膨胀、函数调用开销高。
Local‑Dynamic(LD,‑ftls-model=local‑dynamic)
约束:只允许访问本 DSO 内部的 TLS 变量。 整个.so所有 TLS 变量只共享一套 GOT1/GOT2。__tls_get_addr(dso_id, 0)只调用 1 次,拿到本 DSO 的 TLS 块起始基址;后续所有 TLS 变量直接加静态偏移,不再调用函数。
同样 C 代码:
__thread int a; __thread int b; int f() { return a + b; }x86‑64 汇编示意(LD):
f: # 整个DSO只调用一次 __tls_get_addr,获取本模块TLS基址,存入rbx mov rdi, QWORD [rip+GOT_MOD] # 本so的 dso_id(全局唯一一套) xor rsi, rsi # offset=0 call __tls_get_addr mov rbx, rax # rbx = 本DSO TLS块基址 TP + dso_tls_offset # 访问 a:直接基址 + 编译期已知静态偏移,无函数调用 mov edx, [rbx + OFFSET_A] # 访问 b:同样基址 + 静态偏移,无函数调用 mov eax, [rbx + OFFSET_B] add eax, edx ret- GOT:整个 so 全部 TLS 变量共用 2 个 TLS‑GOT 项,不是每个变量一套。
- 开销:函数内最多一次
__tls_get_addr调用;后续变量只是简单加法 + 内存访问。 - 硬限制:不能访问其他 DSO 的 TLS 变量。一旦你代码里访问别的 so 的
__thread变量,链接器 / GCC 会自动回退降级为 GD 模式。
Local Exec (LE) vs Local Exec (LE)
IE、LE 统称为静态 TLS 模型,完全不使用 DTPMOD / DTPOFF,不调用__tls_get_addr(),使用TPOFF(R_X86_64_TPOFF64),偏移是相对于 TP (% fs) 寄存器,不是相对于 DSO TLS 块起点。 核心限制:只能用于程序启动阶段就加载的模块;dlopen()晚加载会报错cannot allocate memory in static TLS block,因为要占用进程全局预分配的 static‑TLS 槽位,槽数量有限。
Initial‑Exec(IE,‑ftls-model=initial‑exec)
适用:启动时加载的 DSO(.so),不能 dlopen 晚加载。
- 每个 TLS 变量分配1 项 TLS‑GOT,重定位类型:
R_X86_64_GOTTPOFF。 - 动态链接器 ld.so 在程序启动时,把该变量相对于 TP (% fs) 的最终运行时偏移回填到 GOT 表项。
- 指令:PC 相对取 GOT 里的运行时偏移,再
fs:[reg]访问内存。
x86‑64 汇编示意:
# 变量a,IE模型 mov rdx, QWORD [rip + TLS_GOT_a] # 从GOT读出ld.so回填好的TP偏移 mov eax, fs:[rdx] # TP + rdx,直接读取变量,无函数调用关键点:
- GOT 表项存运行时 TP 偏移,编译期不知道这个值;
- 不需要
__tls_get_addr; - 允许访问本 DSO 以及其他启动加载 DSO 的 TLS 变量;
- 只要这个库用 IE,就占用 static‑TLS 槽;dlopen 加载该库直接失败。
Local‑Exec(LE,‑ftls-model=local‑exec)
适用:只能主程序(可执行文件)内部 TLS 变量,绝对不能用于.so。
- 完全不用 GOT,没有任何 GOT 表项。
- 链接阶段,链接器已经算出该 TLS 变量相对于 TP (% fs) 的最终固定偏移,直接硬编码进指令立即数。
x86‑64 汇编示意:
# 变量a,LE模型,offset是链接时算死的常数 mov eax, fs:[offset]关键点:
- 零 GOT、无重定位开销,性能最高;
- 只能访问主程序内部的 TLS 变量,不能访问任何 DSO 的 TLS;
- 如果你强行给‑shared 库传
‑ftls-model=local‑exec,GCC/ld 会直接降级回 GD; - 主程序默认模型就是 LE。
end