线程本地存储又叫线程局部存储,英文名称为Thread Local Storage,简称TLS,实质上是线程私有的全局变量而已。
普通全局变量在多线程中是共享的,一个线程对其进行了修改,其它所有线程都可以看到,而线程私有的全局变量与普通全局变量不同,线程私有的全局变量是线程的私有财产,每个线程都有自己的一个副本,某个线程对其做出的修改只会修改到自己的副本,并不会修改到其它线程的副本。
来看个普通的全部变量的例子:
#include <stdio.h>
#include <pthread.h>
int g = 0; // 1,定义全局变量g并赋初值0
void *start(void *arg)
{
printf("start, g[%p] : %d\n", &g, g); // 4,子线程中打印全局变量g的地址和值
g++; // 5,修改全局变量
return NULL;
}
int main(int argc, char *argv[])
{
pthread_t tid;
g = 100; // 2,主线程给全局变量g赋值为100
pthread_create(&tid, NULL, start, NULL); // 3, 创建子线程执行start()函数
pthread_join(tid, NULL); // 6,等待子线程运行结束
printf("main, g[%p] : %d\n", &g, g); // 7,打印全局变量g的地址和值
return 0;
}
上述程序注释1的地方定义了一个普通全局变量g并设置其初始值为0,程序运行后主线程首先将g修改为100(注释2),然后创建了一个子线程来执行start函数(注释3),start先打印出g的值(注释4)来确认在子线程中可以看到主线程对g的修改,之后修改g的值(注释5),然后start结束运行,主线程在注释6处等待start运行完毕后,在注释7处打印g的值来确认start对g的修改可以影响到主线程对g的读取。
编译并运行上述程序:
$ gcc thread.c -o thread -lpthread
$ ./thread
start, g[0x601064] : 100
main, g[0x601064] : 101
可以看到,g在两个线程中的内存地址都是一样的,任何一个线程都可以读取到另外一个线程对g的修改,实现了全局变量在多线程中的共享。
之后再来看线程本地存储(TLS)实现的私有的全局变量。
下例代码与上述例子大体相似,差别就是在定义g时新增__thread关键字,如此就g就变成了线程私有的全局变量,案例如下:
#include <stdio.h>
#include <pthread.h>
__thread int g = 0; // 1,这里增加了__thread关键字,把g定义成私有的全局变量,每个线程都有一个g变量
void *start(void *arg)
{
printf("start, g[%p] : %d\n", &g, g); // 4,打印本线程私有全局变量g的地址和值
g++; // 5,修改本线程私有全局变量g的值
return NULL;
}
int main(int argc, char *argv[])
{
pthread_t tid;
g = 100; // 2,主线程给私有全局变量赋值为100
pthread_create(&tid, NULL, start, NULL); // 3,创建子线程执行start()函数
pthread_join(tid, NULL); // 6,等待子线程运行结束
printf("main, g[%p] : %d\n", &g, g); // 7,打印主线程的私有全局变量g的地址和值
return 0;
}
执行结果如下:
$ gcc -g thread.c -o thread -lpthread
$ ./thread
start, g[0x7f0181b046fc] : 0
main, g[0x7f01823076fc] : 100
可看出g在两线程中内存地址是不一致的,main对g的赋值并未影响到start线程g的值,start对g做的修改也并未影响到main主线程,说明各线程都有个私有的全局变量g。
接下来使用调试工具gdb来调试程序,看看编译器gcc是如何实现这个特性的。
$ gdb ./thread
先在源码中的g = 100处下一个断点,运行程序之后反汇编main,得到如下内容:
(gdb) b thread.c:20
Breakpoint 1 at 0x400793: file thread.c, line 20.
(gdb) r
Starting program: /home/bobo/study/c/thread
Breakpoint 1, at thread.c:20
20g = 100;
(gdb) disass
Dump of assembler code for function main:
0x0000000000400775 <+0>:push %rbp
0x0000000000400776 <+1>:mov %rsp,%rbp
0x0000000000400779 <+4>:sub $0x20,%rsp
0x000000000040077d <+8>:mov %edi,-0x14(%rbp)
0x0000000000400780 <+11>:mov %rsi,-0x20(%rbp)
0x0000000000400784 <+15>:mov %fs:0x28,%rax
0x000000000040078d <+24>:mov %rax,-0x8(%rbp)
0x0000000000400791 <+28>:xor %eax,%eax
=> 0x0000000000400793 <+30>:movl $0x64,%fs:0xfffffffffffffffc
0x000000000040079f <+42>:lea -0x10(%rbp),%rax
0x00000000004007a3 <+46>:mov $0x0,%ecx
0x00000000004007a8 <+51>:mov $0x400736,%edx
0x00000000004007ad <+56>:mov $0x0,%esi
0x00000000004007b2 <+61>:mov %rax,%rdi
0x00000000004007b5 <+64>:callq 0x4005e0 <pthread_create@plt>
0x00000000004007ba <+69>:mov -0x10(%rbp),%rax
0x00000000004007be <+73>:mov $0x0,%esi
0x00000000004007c3 <+78>:mov %rax,%rdi
0x00000000004007c6 <+81>:callq 0x400620 <pthread_join@plt>
0x00000000004007cb <+86>:mov %fs:0xfffffffffffffffc,%eax
0x00000000004007d3 <+94>:mov %eax,%esi
0x00000000004007d5 <+96>:mov $0x4008df,%edi
0x00000000004007da <+101>:mov $0x0,%eax
0x00000000004007df <+106>:callq 0x400600 <printf@plt>
......
程序停在了g = 100这一行,对应上述指令中有=>标识的一行,这条指令的含义就是将常量100(0x64)复制到内存地址为%fs:0xfffffffffffffffc,fs是段寄存器,0xfffffffffffffffc是有符号的数字-4,所以全局变量g的地址就是fs段基地址减去4。
之前聊《聊一聊基础的CPU寄存器~》时提到过,段基地址就是段的起始地址,为了验证g的地址确实是fs段基地址减去4,需先知道fs段基地址是多少。
虽然可用gdb指令查看fs中的值,但fs存放的是段选择子(segment selector),而不是该段的起始地址,为了拿到该段基地址,就需要调整一下代码了,如下:
#include <stdio.h>
#include <unistd.h>
#include <pthread.h>
#include <asm/prctl.h>
#include <sys/prctl.h>
__thread int g = 0;
void print_fs_base()
{
unsigned long addr;
int ret = arch_prctl(ARCH_GET_FS, &addr); //获取fs段基地址
if (ret < 0) {
perror("error");
return;
}
printf("fs base addr: %p\n", (void *)addr); //打印fs段基址
return;
}
void *start(void *arg)
{
print_fs_base(); //子线程打印fs段基地址
printf("start, g[%p] : %d\n", &g, g);
g++;
return NULL;
}
int main(int argc, char *argv[])
{
pthread_t tid;
g = 100;
pthread_create(&tid, NULL, start, NULL);
pthread_join(tid, NULL);
print_fs_base(); //main线程打印fs段基址
printf("main, g[%p] : %d\n", &g, g);
return 0;
}
上述代码中主线程和子线程都调用了print_fs_base()函数用于打印fs栈基地址,运行结果如下:
fs base addr: 0x7f36757c8700
start, g[0x7f36757c86fc] : 0
fs base addr: 0x7f3675fcb700
main, g[0x7f3675fcb6fc] : 100
可以看到,子线程fs栈基地址为0x7f36757c8700,g地址为0x7f36757c86fc,正好是fs段基地址减去4,主线程fs栈基地址为0x7f3675fcb700,g地址为0x7f3675fcb6fc,正好也是fs段基地址减去4。
到这里就可以推断出,编译器gcc(还有线程库和内核的支持)使用了CPU的fs段寄存器来实现线程本地存储(TLS),不同线程中fs栈基地址也是不一样的,如此看似是同一个全局变量在不同的线程中,却拥有不同的内存地址,实现了线程私有的全局变量。
本文简单介绍的是AMD64 Linux平台下gcc对本地线程存储的实现。
至此,理解Go里的runtime包中goroutine调度器需要准备的基础知识就结束了,后面的文章会结合实际来慢慢的聊主题内容,也就是goroutine调度器。
以上仅为个人观点,不一定准确,能帮到各位那是最好的。
好啦,到这里本文就结束了,喜欢的话就来个三连击吧。
扫码关注公众号,获取更多优质内容。