专业编程基础技术教程

网站首页 > 基础教程 正文

CPU眼里的:thread_local(cpu眼里的C/C++ 价格)

ccvgpt 2024-07-26 00:46:51 基础教程 7 ℃

“它不是全局变量、不是静态变量、不是栈变量,那它是什么?”


CPU眼里的:thread_local(cpu眼里的C/C++ 价格)

01

提出问题

C++是一门神奇语言,特别是从C++11开始的新特性,颠覆了很多大家已经习以为常的语法规则,还提出一些新的挑战常识的语法规则。

即使一些资深的同学,也会被它杀个措手不及,所以这也让它成为面试题的好素材。例如:新的变量类型thread_local就是一个典型的代表。

你说thread_local是临时变量吧,它的生命周期还很长,不会随着函数调用的结束而消失;你说thread_local是全局变量吧,但它又被各个线程私有,每个线程只能访问属于自己的thread_local。

这种神奇的属性到底是怎么实现的呢?答案可能简洁到让人难以置信。



02

代码分析

打开Compiler Explorer,先定义一个“全局”的thread_local变量a;先写一个最简单的函数func,并在里面,定义一个“局部”thread_local变量b;然后,返回变量b的值;最后,写一个main函数,用来创建两个线程t1、t2,它们都会调用函数func,如图所示。

通过代码对应的CPU指令,我们可以很容易的看出:虽然关键字thread_local中有local字样,但它并不像普通的临时变量那样,每次要使用临时变量的时候,需要临时从线程堆栈中分配内存空间,从而导致每次打印出来的临时变量的地址,都有可能是不同的。具体细节,可以参看“ CPU眼里的静态、全局、临时变量”

相反,无论thread_local变量,是定义在函数func的内部还是外部,它们的内存地址都是固定的。

至于初始值,阿布相信它在线程创建的时候,就被初始化好了。虽然也有说法声称它跟静态变量一样,都会第一次调用函数func的时候,对thread_local变量进行初始化。但如你所见,在函数func里面,CPU并不会对thread_local的初始化代码,做任何表示,几乎是视而不见。至少,我们找不到任何支持这种说法的依据。

所以,从这个角度上看,thread_local跟静态变量的属性还颇有点相似呢。那问题来了,这里有两个线程t1、t2,它们如何区分各自的thread_local变量a和b呢?其实答案非常简洁,让我们关注返回变量b的值,所对应的CPU指令,如图所示。

其中eax寄存器,已经是老演员了,用来存放函数func的返回值。所以,很明显变量b所在的内存地址,是由寄存器fs加上一个偏移量,计算而成的。

寄存器fs就记录了该线程,所有thread_local变量所在的内存块的首地址。因为不同的线程,它的上下文往往是不同的,所以,对应的寄存器fs的值,往往也是不同的。这样就非常巧妙的区分了线程t1和线程t2所私有thread_local变量b。

保证了线程t1在调用函数func时,只会操作线程t1的thread_local变量b;线程t2在调用函数func时,只会操作线程t2的thread_local变量b。

好了,让我们全面复盘一下整个过程,如图所示。

操作系统在创建线程t1的时候,操作系统会创建线程t1的上下文,并保存在左边第二个内存颗粒上。其中除了寄存器eax、eip的初值外,还有保存“堆栈”栈顶地址的寄存器esp和我们今天会用到的寄存器fs。

随后,操作系统又给线程t1,分配一块独有的内存块(Stack-t1),用来做它的线程“堆栈”。为了宣誓主权,让寄存器esp的初值等于该内存块的高端地址:0x7FFFF8。

如你所见,系统还是在“堆栈”的顶端,预留了一点点空间。没错,它们就用来存放thread_local类型的变量a和b。

同样,为了宣誓主权,让寄存器fs的初值等于这段内存块的首地址:0x800000。

同样的方法,操作系统也会为线程t2创建类似的、专属的数据结构和内存资源,如图所示。

如你所见,线程t2的上下文跟线程t1的上下文是有区别的。特别是它们都拥有自己独立的“堆栈”内存,所以二者的寄存器esp、fs的初值也是不同的。

这样,当操作系统通过线程调度,决定让线程t1运行时,就会将其上下文中的寄存器数值,加载到CPU的相关寄存器中。并由寄存器eip引导CPU跳转到函数func处执行。函数的func的内存首地址为:0x4011B6,如图所示。

忽略前面两条,用于建立函数栈帧的push和mov指令。在执行返回值操作的时候,通过寄存器fs,配合偏移量-4(0xfffffffffffffffc)就可以顺利找到线程t1专属的thread_local变量b,如图所示。

如果此时发生任务切换,操作系统会先保护好线程t1的上下文。并装载好线程t2的上下文,还是由寄存器eip引导CPU跳转到函数func处执行。

还是忽略前面两条建立函数栈帧的操作。在执行返回值操作的时候,通过寄存器fs,配合相同的偏移量-4(0xfffffffffffffffc)就可以顺利找到线程t2专属的thread_local变量b,如图所示。

至此,整个thread_local的工作原理,讲述完毕。更多关于“上下文”切换的细节,还可以参看“CPU眼里的上下文”

一般来说,由于线程之间,没有MMU的隔离,所以,一个线程想访问其他线程的thread_local变量也并非不可能。在得到其他线程的thread_local变量地址后,就可以通过指针来“远程”读、写其他线程的thread_local变量。但这可不是设计的初衷上,考虑到代码的可读性,请不要这么作。



03

总结

  1. 操作系统在创建线程的时候,除了会给每个线程创建函数“堆栈”,还会划出一部分区域,来存放一个或多个thread_local变量。
  2. 类似于用寄存器esp来标识线程当前“堆栈”栈顶的内存地址,编译器也常用寄存器fs,来标识所有thread_local变量,所在的内存首地址,配合偏移量,就可以精确寻找到每一个thread_local变量。

这是非常聪明的设计,不仅实现起来,非常简洁,也几乎没有增加任何运行成本。

  1. thread_local并非没有替代方案,例如在创建线程之前,我们可以申请一段内存块,交给该线程私用。并通过参数传递的方式,将内存首地址传递给该线程的主函数:
#include <thread>

int func(int* t_local)
{
    return *t_local;
}

int main()
{
    int* t1_local = new int(1);
    int* t2_local = new int(2);
    std::thread t1(func, t1_local);
    std::thread t2(func, t2_local);

    t1.join();
    t2.join();
}

当然,这显然没有thread_local来的简洁。不过在享受简洁代码的同时,如果不清楚底层实现的话,你就必须忍受它晦涩的语法规则。

最后,不同编译器或库函数,对thread_local的实现方式可能有所不同,但原理一致,殊途同归。



04

更多知识

如果喜欢阿布这种解读方式,希望更加系统学习这些编程知识的话,也可以考虑看看由阿布亲自编写,并有多位微软大佬联袂推荐的新书《CPU眼里的C/C++》

注意:5折优惠,本月结束。

Tags:

最近发表
标签列表