关于Linux 线程局部存储的认识

线程局部存储(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:

  1. 你写的是 C 代码,且不想依赖 C11;
  2. 你在维护老项目 ,原有风格就是 __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 寄存器就明白了。

相关推荐
GlobalInfo1 小时前
全球及中国风电叶片检测机器人行业研究报告(2026版):市场现状、行业前景与占有率分析
运维·网络·人工智能·机器人
隔窗听雨眠3 小时前
KingbaseES平替全链路实践:从迁移评估到可持续运维
运维
skywalk81633 小时前
登到192.168.1.5那台机器,看看今天白天,大约从早晨到晚上,都卡住了。看看因为什么卡住,为什么卡住而没有杀死那个导致卡死的进程,为什么
运维·系统·freebsd
运维行者_3 小时前
网络监控工具怎么选?从设备监控到智能运维的能力清单
运维·服务器·开发语言·网络·人工智能·支持向量机·php
小小de风呀3 小时前
de风——【从零开始学习Linux】(九):进程基础 —— 从冯诺依曼体系到 fork 创建进程
linux·学习
晚风吹北巷5 小时前
Linux复习篇(自用)
linux·运维·服务器·1024程序员节
风哥2号5 小时前
数据库教程FGMT69:PostgreSQL运维诊断与监控分析
运维·数据库·postgresql
by209996 小时前
Linux 压缩传输、进程查看与权限管理
linux·经验分享·1024程序员节