编辑
2024-12-27
底层知识
0

C++11标准当中,提供了一个新的关键字thread_local,用于声明一个线程本地变量,即该变量是线程独有的。

它的效果是,对于一个被声明为thread_local的变量a,不同的线程都会持有一个单独的a实例。

假设目前有两个线程A和B,那么同时在线程A当中访问a和在线程B当中访问a,是不冲突的,不存在竞态条件。

本文不探讨如何使用thread_local变量,我们旨在研究其具体是如何实现的,它的底层原理是什么。

话先说在前头,由于涉及到了底层,那么其实现原理必然是和各种底层设施有关的,例如编译器/操作系统/指令集,以下探究的过程不保证在其他平台上一模一样,仅供参考。

接下来的探究都将基于x86_64 Ubuntu,内核版本为6.8.0-40-generic,使用的编译器为gcc version 13.2.0

首先,我们得先把使用了thread_local关键字的代码给写出来,才可以进一步探究。

cpp
#include <thread> #include <iostream> thread_local int num = 0; void fun() { num = 0x1638; printf("in fun -> num: %x, addr: %p\n", num, &num); } int main() { std::thread t(fun); num = 0x7520; printf("in main -> num: %x, addr: %p\n", num, &num); t.join(); return 0; }

这一小段代码的意图是很明显的,程序运行起来之后会在两个线程中访问一个全局变量num,改变它的值之后会输出值和所在地址。

注意到,我们并没有添加任何的手段来避免并发带来的竞争,由于thread_local的存在,实际上并没有竞争出现(即便这个num是一个全局可访问的变量)。

编译也是非常简单的,这份代码是完全符合C++标准的代码(意味着你只需要复制粘贴),只要你的C++标准支持11或更高,都能过编译。

使用g++进行编译,命令如下所示。

g++ -std=c++11 thread_local_test.cpp -o thread_local_test

执行一下看看结果是什么。

shell
% ./thread_local_test in main -> num: 7520, addr: 0x78d0ca31273c in fun -> num: 1638, addr: 0x78d0c9a006bc

你的结果应该与我不一样,或者说每次运行的结果应该都不一样,主要体现在addr的输出上,稍后我们会看到为什么不一样(in main和in fun的输出顺序也可能不一样,但这是因为操作系统对线程的调度所引发的影响)。

从运行的结果来看,我们有了一个非常直观的认识,main里面的num和fun里面的num并不存放在同一个内存位置。

那么,即便同时访问它,也不会发生竞争,因为它俩只是长得一样(都叫做num),但实际并不是一个东西。

至此,我们解开了第一个疑惑,即为什么thread_local关键字可以让一个全局变量在并发的情况下,不采取任何手段,也不会因为竞态条件出现问题。

好了,该填坑了,为什么addr的输出不一样,即num的地址为什么会不一样,这应该是直接揭露了thread_local背后的秘密。

如果目前你没有什么思路,应该是缺乏一些有关程序底层调试的经验。

我们使用了一个关键字,编译器在拿到我们写的代码后,开始读取并解析,编译器对thread_local关键字做了什么神秘的动作,使变量的地址都变得不一样了呢?

既然要探究编译器背后做的小动作,一般是有两个途径,第一就是直接把编译器的源码看明白,源码面前没有秘密;当然还有一个途径,对于C++语言而已,编译器只是一个翻译官,它会把C++代码翻译成汇编代码,这就是第二个途径,可以从汇编代码入手,看看它都翻译了一些什么东西出来。

我们自然是选择后者,因为看编译器源码并不是一件轻松的事情,很容易迷失在里面,不到万不得已当然是不建议选择的;对于解决我们的疑惑,看汇编就足够了。

要想看到汇编代码,用objdump命令就可以了,当然,我们会考虑看两份汇编代码,一份是在链接前,另一份是在链接后,原因会在后文说明。

shell
g++ -std=c++11 thread_local_test.cpp -c -o thread_local_test_beforeld.o objdump -d thread_local_test_beforeld.o -M intel > thread_local_test_beforeld.s g++ -std=c++11 thread_local_test_beforeld.o -o thread_local_test objdump -d thread_local_test -M intel > thread_local_test.s

注意到,objdump命令的参数中附加了-M intel,它指明了生成的汇编代码是什么形式的,这完全是我个人的习惯,我比较喜欢看intel形式的汇编,objdump默认输出的是AT&T形式的,我并不习惯这种形式的汇编代码。

执行上述的4条命令后,当前目录下应该会出现thread_local_test_beforeld.sthread_local_test.s两个文件,这就是我们得到的汇编代码,前者是链接前的汇编代码,后者是链接之后的。

我们先看thread_local_test_beforeld.s,汇编并不好看,所以当然不是全看,我们只看我们关心的。

那么我们关心什么呢?当然是关心num,这也是为什么我们要赋一个十六进制的特殊值(0x1638和0x7520),这非常方便在汇编当中定位。

在main函数体当中,可以定位到这:

asm
77: 64 c7 04 25 00 00 00 mov DWORD PTR fs:0x0,0x7520 7e: 00 20 75 00 00 83: 64 8b 04 25 00 00 00 mov eax,DWORD PTR fs:0x0 8a: 00 8b: 64 48 8b 14 25 00 00 mov rdx,QWORD PTR fs:0x0 92: 00 00 94: 48 81 c2 00 00 00 00 add rdx,0x0 9b: 89 c6 mov esi,eax 9d: 48 8d 05 00 00 00 00 lea rax,[rip+0x0] # a4 <main+0x5f> a4: 48 89 c7 mov rdi,rax a7: b8 00 00 00 00 mov eax,0x0 ac: e8 00 00 00 00 call b1 <main+0x6c>

多了我们都没必要看,只需要mov DWORD PTR fs:0x0,0x7520就够了,不过在这之后的汇编可以辅助你理解,后面的汇编就是准备printf需要的参数并调用printf。

这句汇编的含义也很明显,就是把0x7520放到一个内存中,这时候你可能会问,为什么是放到内存当中,DWORD PTR告诉我们的;你还会问,DWORD PTR是什么东西,这个问题的答案也许GPT回答地比我好。

那么是放到哪块内存中呢,我的回答是fs:0x0,准确来说fs:0x0是一个内存地址,也就是指针,DWORD PTR已经表明了。

等等,还有疑问,fs又是什么东西呢?不着急,先看看fun的汇编。

asm
0000000000000000 <_Z3funv>: 0: f3 0f 1e fa endbr64 4: 55 push rbp 5: 48 89 e5 mov rbp,rsp 8: 64 c7 04 25 00 00 00 mov DWORD PTR fs:0x0,0x1638 f: 00 38 16 00 00 14: 64 8b 04 25 00 00 00 mov eax,DWORD PTR fs:0x0 1b: 00 1c: 64 48 8b 14 25 00 00 mov rdx,QWORD PTR fs:0x0 23: 00 00 25: 48 81 c2 00 00 00 00 add rdx,0x0 2c: 89 c6 mov esi,eax 2e: 48 8d 05 00 00 00 00 lea rax,[rip+0x0] # 35 <_Z3funv+0x35> 35: 48 89 c7 mov rdi,rax 38: b8 00 00 00 00 mov eax,0x0 3d: e8 00 00 00 00 call 42 <_Z3funv+0x42> 42: 90 nop 43: 5d pop rbp 44: c3 ret

你可能会问,是不是看错了,这个函数名是_Z3funv,这不得不提到C++有趣的名称修饰了,有关这点,GPT同样回答地比我好。

可以看到,类似地出现了mov DWORD PTR fs:0x0,0x1638,一样的配方,fs:0x0

明显的,这是在告诉我们,num的地址就是fs:0x0

上一个疑问还没有解决,新的疑问又产生了,从代码的运行结果来看,明明num的地址不同,但这里为什么都是一样的fs:0x0呢,难道是之前想错了吗?

当然不是,可以预见的是,秘密都藏在fs:0x0当中,你越不了解的东西,秘密就越能藏得住。

这很容易推理出来,我们从运行结果已经确定两个线程中的num的地址就是不一样的,运行结果骗不了我们,那说明fs:0x0这个东西在某个时候变了,我们现在看的是链接前的代码,这时候还没变,那么只有可能是在链接之后变的了。

我们的思路又得以延续,thread_local背后的秘密我们已经揭露一半了,我们顺利的知道了,它是通过fs:0x0这个东西来使得全局变量num的地址在不同线程变得不同的。

现在是AI大展身手的时候了,在前文我总提到GPT的答案比我的答案好,这肯定是有一定道理的(

在x86_64架构中,FS寄存器是一个段寄存器,通常用于存储线程本地存储(Thread Local Storage, TLS)的基地址。TLS用于为每个线程提供独立的存储空间,例如线程局部变量、线程ID等。

当线程切换时,操作系统会更新FS寄存器的内容,使其指向新线程的TLS基地址。这样,每个线程都可以通过FS寄存器访问自己的TLS数据。

上面这一段是DeepSeek给我的有关fs是什么的答案,当然我只截了一部分。

好了,至此我们就搞清楚fs是什么东西,并且是如何导致num变量在不同线程的地址不一样了。

现在我们简单梳理一下。

  1. 各个线程都通过统一的fs:0x0来访问变量num
  2. 在切换线程的时候,改变fs寄存器当中的值,以此来实现访问不同的num

假设线程main当中的fs100,线程fun当中的fs200,那么main对应的num的地址为100+0x0,fun对应的num地址为200+0x0,这也是为什么它们看起来一样,但实际却不一样的核心原理。

假设归假设,fs里面的值具体怎么得出来的呢?操作系统可没有对我们程序的任何了解,它怎么知道在切换线程的时候fs应该对应地切换到多少?

操作系统一开始当然不知道某个线程对应的fs是多少,肯定需要我们主动告诉它,但是我们从来没写过什么和操作系统通信的代码,那又是怎么告诉操作系统的呢?

别忘了,还有编译器这个家伙。准确来说,应该是语言运行时库,我通常会把语言运行时库归类到编译器的范畴当中,广义地说这也没问题。

用户态程序和操作系统内核进行通信的手段,一个最典型的就是使用系统调用,这为我们提供了一个排查思路。

在Linux系统当中,我们可以用strace这个命令来跟踪一个进程在运行过程中所使用的所有系统调用。

shell
% strace ./thread_local_test

当然,这玩意执行之后,会得到一大堆不明所以的输出信息,会给我们带来很大的挫败感,根本没法下手。

那我们就想,尽可能地简化它的输出信息,那就从改造源码开始。

cpp
#include <iostream> thread_local int num = 0; int main() { num = 0x7520; printf("in main -> num: %x, addr: %p\n", num, &num); return 0; }

我们现在确实也不需要再开一个线程了,因为我们刚刚从汇编中也能看出,就thread_local而言,fun做的事情和main做的事情没有什么本质上的区别,只不过给num赋的值不同而已。

编译呢,还是那4条命令,没有变化,同样再用strace跟踪一下。

可以发现,strace的输出当中稍微少了一些信息,比如比之前少了clone3这个系统调用,因为我们确实没再创建线程了。

细心观察一下,注意到有个系统调用arch_prctl,这时你可能会问,为什么注意力这么好,可以刚好注意到这个系统调用而不是别的。

shell
mmap(NULL, 8192, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7a01e74b3000 arch_prctl(ARCH_SET_FS, 0x7a01e74b4500) = 0

事实上这并不是我注意到的,这是AI注意到的,在前面我提到了DeepSeek给了我一些有关fs寄存器的介绍,但我只截了一部分并没有截全,在前文那个语境下,确实不需要截全。

但是其实DeepSeek也给我介绍了fs是如何改变的,就是通过系统调用arch_prctl,从strace的输出来看,也可以证明,arch_prctl的第一个参数就是ARCH_SET_FS,这个意图就比较明显了。

这给了我们一个提示,即我们可以先通过AI对一个未知事物有一个大概的了解和印象,以此充当日后实践当中的理论指导。

这个arch_prctl并不是我们去调用的,而是语言运行时库去调用的,只不过这底层的细节被隐藏起来了,对我们来说是透明的;这是一件好事同时也是一件坏事,对于我们写程序来说确实便捷了许多,但是当我们对细节有疑问时,会带来研究上的麻烦。

注意,在arch_prctl之前还有一个系统调用mmap,这实际上是在向操作系统申请一块内存,范围是[0x7a01e74b3000,0x7a01e74b3000+8192)

arch_prctl的第二个参数,使用了0x7a01e74b4500,这是在刚刚分配这块内存的范围当中的。这实际上就是将该线程的fs寄存器设置为0x7a01e74b4500

那我们有一个很自然的想法,就是这块内存就是专门存放各个线程的thread_local变量的。

我们在strace的输出当中还可以观察到如下的信息,这实际就是printf。

shell
write(1, "in main -> num: 7520, addr: 0x7a"..., 43in main -> num: 7520, addr: 0x7a01e74b44fc ) = 43

num的地址就是0x7a01e74b44fc,不难发现,就是0x7a01e74b4500-4,或者说是fs-4,那为什么是-4呢?当然是是因为sizeof(int) = 4

我们最后来看一下刚刚这个C++代码的汇编结果,重点关注链接后的main函数。

asm
0000000000001149 <main>: 1149: f3 0f 1e fa endbr64 114d: 55 push rbp 114e: 48 89 e5 mov rbp,rsp 1151: 64 c7 04 25 fc ff ff mov DWORD PTR fs:0xfffffffffffffffc,0x7520 1158: ff 20 75 00 00 115d: 64 8b 04 25 fc ff ff mov eax,DWORD PTR fs:0xfffffffffffffffc 1164: ff 1165: 64 48 8b 14 25 00 00 mov rdx,QWORD PTR fs:0x0 116c: 00 00 116e: 48 81 c2 fc ff ff ff add rdx,0xfffffffffffffffc 1175: 89 c6 mov esi,eax 1177: 48 8d 05 87 0e 00 00 lea rax,[rip+0xe87] # 2005 <_ZStL19piecewise_construct+0x1> 117e: 48 89 c7 mov rdi,rax 1181: b8 00 00 00 00 mov eax,0x0 1186: e8 c5 fe ff ff call 1050 <printf@plt> 118b: b8 00 00 00 00 mov eax,0x0 1190: 5d pop rbp 1191: c3 ret

因为我们简化了main函数,去掉了启动线程的代码,所以main的汇编代码也变得简短许多,更便于我们阅读。

注意到这一行汇编mov DWORD PTR fs:0xfffffffffffffffc,0x7520,这与之前的不太一样,在链接之后fs:0x0变成了fs:0xfffffffffffffffc

首先你有一个疑问,为什么链接之后就变了呢,为什么链接前不是0xfffffffffffffffc而是0x0?其次是还有个疑问,0xfffffffffffffffc是什么东西?

后一个问题说简单也简单,说复杂也复杂,0xfffffffffffffffc你要以补码的视角来看,它实际就代表着-4,复杂是因为补码这个知识本身需要一定的学习成本,也就是0xfffffffffffffffc在补码当中为什么就是-4;编译器现在普遍都采用补码来生成有关代码,有一个核心原因是补码可以把减法当加法来做,从而复用加法器电路,节省了一块本该用于减法器的电路面积,用来实现其他的电路。

那么在汇编mov DWORD PTR fs:0xfffffffffffffffc,0x7520当中,就是将0x7520写入内存fs+0xfffffffffffffffc = fs-0x4 = 0x7a01e74b4500-0x4 = 0x7a01e74b44fc当中。

好了,至此我们已经把thread_local背后的所有秘密都摸清楚了,这篇文章终于可以谢幕了(

?似乎还有一个坑没有填,为什么链接前不是0xfffffffffffffffc而是0x0

既然不一致是出现在链接前后,那自然是链接上的问题了,和thread_local本身关系不大。

假如我们的项目是一个大项目,那自然会采用多个编译单元的形式进行组织,简单来说就是有多个.cpp源文件,在多个源文件当中,有可能定义多个thread_local变量。

这就导致了一个问题,我们不能在所有thread_local变量汇总之前,给它们写死一个偏移量,否则会造成冲突。

我们来看看链接前写死地址是怎么样导致冲突的,在a.cpp当中的thread_local变量num1地址是fs:0xfffffffffffffffc,在b.cpp当中的thread_local变量num2地址也是fs:0xfffffffffffffffc,在链接前,看起来很正常没有问题,但是链接后,num1num2就属于同一个可执行文件当中,那么由于它们拥有相同的地址,若是两者不在同一个线程倒还说得过去(意味fs是不同的),若是在同一个线程当中(fs是相同的),访问num1就相当于访问num2(两者地址一模一样),这就不正常了。

所以在链接前,我们不能确定其它编译单元有多少个thread_local变量,只有在链接之后,汇总了所有编译单元,才知道有多少个thread_local变量,才能保证不冲突地分配地址。

这是一个有关链接方面的小细节,如果你对此还有疑惑,应该是我讲述地不够完善,因为这不是我们的主题,你应当去系统地学习一下链接方面的知识。

现在,这篇文章终于可以谢幕了,但是谢幕之前,我们来总结一下整个thread_local实现的流程。

  • 在C++当中使用thread_local关键字来修饰一个变量num
  • 编译器将变量num的地址指定为fs:0x0
  • 链接各个编译单元,得到可执行文件后,分配具体的地址,如fs:0xfffffffffffffffc
  • 对变量num的读写都会是直接操作这个指定内存地址fs:0xfffffffffffffffc
  • 在语言运行时库创建一个线程时,通过系统调用arch_prctl指明这个线程对应的fs是多少
  • 操作系统在切换线程时,会根据系统调用arch_prctl指明的fs来进行对应地切换。
  • 假设线程A设置的fs0x7a01e74b4500,线程B设置的fs0x7a01f74b4500
  • 在线程A中访问num,实际上是0x7a01e74b4500+0xfffffffffffffffc,而在线程B中访问num,实际上是0x7a01f74b4500+0xfffffffffffffffc
  • 看起来都是用地址fs+0xfffffffffffffffc在访问内存,但实际各个线程的fs并不相同,最终访问的内存地址也不一样

一句话总结一下,为每个线程分配一块独享内存,将那些被thread_local修饰的变量移到这块内存当中,虽然外在形式是全局共享的,但实际本质是各线程独立的。

这篇文章终于可以谢幕了(事不过三

本文作者:Test

本文链接:

版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!