线程局部存储(TLS):写起来像全局变量,用起来像局部变量
从一个常见的坑说起
刚开始写多线程程序时,几乎所有人都会踩同一个坑。
cpp
int counter = 0; // 全局变量
void* work(void*) {
for (int i = 0; i < 1000; i++) counter++;
return nullptr;
}
开两个线程各跑一遍 work,你期望 counter 是 2000,实际跑出来常常是 1500 左右,甚至每次都不一样。
原因很简单:全局变量是所有线程共享的 。两个线程同时读、同时加、同时写回,中间的结果互相覆盖------这就是所谓的竞态条件(race condition)。
解决办法之一当然是加锁。但有时候我们要的其实不是"共享",而是 "每个人都有一份,各算各的" 。这时候该请出的就是线程局部存储。
TLS 是什么
线程局部存储(Thread-Local Storage,TLS):每个线程各有一份的全局变量。
写法上它像个全局变量,作用范围也是整个程序,但每个线程看到的是自己那份 ,互相看不见、改不着。
把上面的例子改一行就解决了:
cpp
thread_local int counter = 0;
void* work(void*) {
for (int i = 0; i < 1000; i++) counter++;
printf("%d\n", counter); // 每个线程都打印 1000
return nullptr;
}
两个线程都打印 1000,因为它们是两份完全独立的变量。没有锁,没有竞态,也不需要任何同步。
三种变量的对比
理解 TLS 最快的办法,是把它和熟悉的两种变量摆在一起:
| 作用域 | 生命周期 | 线程之间 | |
|---|---|---|---|
| 全局变量 | 整个程序 | 程序运行期间 | 共享,一个改了全变 |
| 局部变量 | 函数内 | 函数调用期间 | 各是各的(在各自的栈上) |
| TLS 变量 | 看声明位置 | 线程存活期间 | 各是各的 |
一句话记法:写起来是全局变量,效果上像局部变量。
局部变量之所以天然线程安全,是因为每个线程有自己的栈;TLS 的思路其实一样------给每个线程都准备一份,只是这份数据是"具名"的,可以跨函数访问。
三种写法:thread_local、__thread、_Thread_local
在代码里你可能会看到三种不同的写法,它们干的是同一件事:
cpp
thread_local int a = 0; // C++11 标准
__thread int b = 0; // GCC 扩展(C 里也能用)
_Thread_local int c = 0; // C11 标准
为什么会有三种?因为出身不同:
__thread:GCC 在 2000 年代初就提供的扩展,比 C++11 早了近十年。很多老代码、老教材、glibc 和内核模块里大量使用。_Thread_local:C11 才把它写进标准,C 语言终于有了官方的 TLS。thread_local:C++11 引入,C23 起 C 语言也可以直接用。
双下划线是有讲究的:C/C++ 规定双下划线开头的标识符保留给编译器实现 ,所以__thread一看就是"编译器扩展",不会和用户代码撞名。同类的还有__attribute__、__declspec。
该用哪个
新代码一律用 thread_local:
cpp
thread_local int x = 0;
只有在两种情况下才用 __thread:
- 你写的是 C 代码,且不想依赖 C11;
- 你在维护老项目 ,原有风格就是
__thread。
一个实际的能力差异
__thread 有明确限制:不能用于带动态初始化的类型。
cpp
__thread std::string s; // 编译错误:不能有非平凡构造函数
thread_local std::string s; // 首次访问时构造,线程退出时析构
这是 thread_local 比 __thread 强的地方------它支持带构造/析构函数的类型,而且初始化时机是"该线程第一次用到它时",而不是程序启动时。
底层是怎么做到的
每个线程在内核里都有一块线程控制块(TCB,Thread Control Block)。在 x86-64 上,fs 寄存器始终指向当前线程的 TCB。
TLS 变量就存放在 TCB 附近。访问它的时候,编译器生成的指令大致是:
asm
mov eax, dword ptr fs:[0x28] ; 通过 fs 加偏移访问 TLS 变量
线程切换时,fs 寄存器一换,同一行代码访问到的就是另一个线程的那份数据。
这就是为什么 TLS 不需要加锁:隔离是由"每线程一个段"这个硬件机制保证的,而不是靠软件同步。
存放位置也有讲究:已初始化的 TLS 变量放在 .tdata 段,未初始化的放在 .tbss 段------和普通的 .data / .bss 一一对应,只不过每个线程各拷贝一份。
最经典的例子:errno
errno 就是 TLS 变量,而且几乎是教科书级的使用场景。
想象一下如果 errno 是普通全局变量:线程 A 的 read() 失败了,写入错误码 EIO;还没等 A 检查,线程 B 也失败了,写入了 ENOENT。A 再去读 errno,读到的就是别人的错误码------排查起来能让人怀疑人生。
所以 C 标准库把它做成了每线程一份。头文件里其实是个宏:
c
#define errno (*__errno_location())
__errno_location() 每次返回当前线程那一份的地址。
用之前要知道的几件事
1. 初值是按线程跑的
cpp
thread_local int x = 5;
不是程序启动时初始化一次,而是每个线程创建时各初始化一遍 。
2. 有构造函数的对象,构造时机是"首次使用"
cpp
thread_local Logger log("worker"); // 该线程第一次访问 log 时才构造
所以如果某个线程从头到尾没碰过这个对象,它的构造函数根本不会执行。
3. 不是免费的,但很便宜
访问 TLS 比访问普通全局变量多一次寻址(要走 fs 段),但相比加锁的代价,这点开销可以忽略不计。
4. 它不是通信手段
别的线程拿不到你那份数据。TLS 解决的是"各算各的",不是"互相传递"。
总结
回到开头那个 counter 跑不对的例子。问题的本质不是"多线程很危险",而是"你用错了共享粒度"------你根本不需要两个线程共享一个计数器。
线程局部存储提供的正是这种粒度选择:
全局变量是"所有人共用一块黑板",局部变量是"每个人手里的草稿纸",而 TLS 是"每个人一块刻着自己名字的黑板"。
新代码写
thread_local,看到__thread不必惊讶,那是 GCC 的老前辈写法。至于它为什么跑得快又不用加锁------想想那个fs寄存器就明白了。