线程本地存储及实现原理

本文是《go调度器源代码情景分析》系列 第一章 预备知识的第十小节,也是预备知识的最后一小节。

线程本地存储又叫线程局部存储,其英文为Thread Local Storage,简称TLS,看似一个很高大上的东西,其实就是线程私有的全局变量而已。

有过多线程编程的读者一定知道,普通的全局变量在多线程中是共享的,一个线程对其进行了修改,所有线程都可以看到这个修改,而线程私有的全局变量与普通全局变量不同,线程私有全局变量是线程的私有财产,每个线程都有自己的一份副本,某个线程对其所做的修改只会修改到自己的副本,并不会修改到其它线程的副本。

下面用例子来说明一下多线程共享全局变量以及线程私有全局变量之间的差异,并对gcc的线程本地存储做一个简单的分析。

首先来看普通的全局变量

#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)后线程结束运行,主线程在注释6处等待子线程结束后,在注释7处打印g的值确定子线程对g的修改同样可以影响到主线程对g的读取。

编译并运行程序:

[email protected]:~/study/c$ gcc thread.c -o thread -lpthread
[email protected]:~/study/c$ ./thread
start, g[0x601064] : 100
main, g[0x601064] : 101

从输出结果可以看出,全局变量g在两个线程中的地址都是一样的,任何一个线程都可以读取到另一个线程对全局变量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;
}

运行程序看一下效果:

[email protected]:~/study/c$ gcc -g thread.c -o thread -lpthread
[email protected]:~/study/c$ ./thread
start, g[0x7f0181b046fc] : 0
main, g[0x7f01823076fc] : 100

从输出结果可以看出:首先,全局变量g在两个线程中的地址是不一样的;其次main函数对全局变量 g 赋的值并未影响到子线程中 g 的值,而子线程对g都做了修改,同样也没有影响到主线程中 g 的值,这个结果正是我们所期望的,这说明,每个线程都有一个自己私有的全局变量g。

这看起来很神奇,明明2个线程都是用的同一个全局变量名来访问变量但却像在访问不同的变量一样。

下面我们就来分析一下gcc到底使用了什么黑魔法实现了这个特性。对于像这种由编译器实现的特性,我们怎么开始研究呢?最快最直接的方法就是使用调试工具来调试程序的运行,这里我们使用gdb来调试。

[email protected]:~/study/c$ gdb ./thread

首先在源代码的第20行(对应到源代码中的 g = 100)处下一个断点,然后运行程序,程序停在了断点处,反汇编一下main函数:

(gdb) b thread.c:20
Breakpoint1at0x400793:filethread.c, line 20.
(gdb) r
Startingprogram:/home/bobo/study/c/thread

Breakpoint1, at thread.c:20
20 g=100;
(gdb) disass
Dumpofassemblercodeforfunctionmain:
  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 <[email protected]>
  0x00000000004007ba<+69>:mov   -0x10(%rbp),%rax
  0x00000000004007be<+73>:mov   $0x0,%esi
  0x00000000004007c3<+78>:mov   %rax,%rdi
  0x00000000004007c6<+81>:callq 0x400620 <[email protected]>
  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 <[email protected]>
  ......

程序停在了g = 100这一行,看一下汇编指令,

=> 0x0000000000400793 <+30>:movl   $0x64,%fs:0xfffffffffffffffc

这句汇编指令的意思是把常量100(0x64)复制到地址为%fs:0xfffffffffffffffc的内存中,可以看出全局变量g的地址为%fs:0xfffffffffffffffc,fs是段寄存器,0xfffffffffffffffc是有符号数-4,所以全局变量g的地址为:

fs段基址 - 4

前面我们在讲段寄存器时说过段基址就是段的起始地址,为了验证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,它正好是基地址 - 4
  • 主线程fs段基地址为0x7f3675fcb700,g的地址为0x7f3675fcb6fc,它也是基地址 - 4

由此可以得出,gcc编译器(其实还有线程库以及内核的支持)使用了CPU的fs段寄存器来实现线程本地存储,不同的线程中fs段基地址是不一样的,这样看似同一个全局变量但在不同线程中却拥有不同的内存地址,实现了线程私有的全局变量。

这里我们简要的分析了AMD64 Linux平台下gcc对线程本地存储的实现,后面的章节我们还会看到go的runtime是如何利用线程本地存储来把正在运行的goroutine和工作线程关联在一起的。

原文地址:https://www.cnblogs.com/abozhang/p/10800332.html

时间: 2024-10-07 07:50:55

线程本地存储及实现原理的相关文章

windows TLS (线程本地存储)

windows TLS (线程本地存储) 一.TLS简述和分类 我们知道在一个进程中,所有线程是共享同一个地址空间的.所以,如果一个变量是全局的或者是静态的,那么所有线程访问的是同一份,如果某一个线程对其进行了修改,也就会影响到其他所有的线程.不过我们可能并不希望这样,所以更多的推荐用基于堆栈的自动变量或函数参数来访问数据,因为基于堆栈的变量总是和特定的线程相联系的. 不过如果某些时候(比如可能是特定设计的dll),我们就是需要依赖全局变量或者静态变量,那有没有办法保证在多线程程序中能访问而不互

[并发并行]_[C/C++]_[使用线程本地存储Thread Local Storage(TLS)-win32和pthread比较]

场景: 1.  需要统计某个线程的对象上创建的个数. 2. 当创建的堆空间需要根据线程需要创建和结束时销毁时. 3. 因为范围是线程只能看到自己的存储数据,所以不需要临界区或互斥量来维护自己的堆内存. 加入如果用全局std::map实现,那么必须在put和get时加锁,这是很损耗资源的. 4. 可以用在维护一个连接,比如socket,database连接. 说明: 1. Java也有自己的线程本地存储ThreadLocal 2. pthread的win32版本: http://sourcewar

C# 线程本地存储 调用上下文 逻辑调用上下文

线程本地存储 using System; using System.Threading; using System.Threading.Tasks; namespace ConsoleAppTest { class Program { static void Main(string[] args) { ThreadDataSlotTest.Test(); } } /// <summary> /// 线程本地存储 /// </summary> class ThreadDataSlot

java线程 在其他对象上同步、线程本地存储ThreadLocal:thinking in java4 21.3.6

package org.rui.thread.concurrency; /** * 在其他对象上同步 * synchronized 块必须给定一个在其上进行同步的对象,并且最合理的方式是,使用其方法正在被调用的当前对象 * :synchronized(this), 在 这种方式中,如果获得了synchronized块上的锁, * 那么该对象其他的synchronized方法和临界区就不能被调用了. * 因此,如果在this上同步,临界区的效果就会直接缩小在同步的范围内. * * 有时必须在另一个

聊聊Linux中的线程本地存储(1)——什么是TLS

从本篇开始进入另一个话题:线程本地存储(Thread Local Storage),在介绍这个概念前先说说变量和多线程的相关知识. 多线程下的变量模型 在单线程模型下,变量定义有两个维度,那就是在何处定义,以及它的修饰属性(static, extern,auto,register等).extern属性表示声明一个变量 ,与定义无关,在此不作讨论:而register是将变量优化成寄存器里面,不作讨论.与变量定义相关的修饰属性就只有auto和static了.这两个维度可以得到变量的类型,以及它们的行

PE线程本地存储

1.静态tls将变量定义在PE文件内部. 使用.tls节存储 .tls节中包含: 初始化数据 用于每个线程初始化和终止的回调函数 TLS索引 2.代码访问tls数据时经过的步骤: (1) 链接时, 链接器设置tls目录中的AddressOfIndex字段. 该字段指向一个位置,该位置保存了程序用到的tls索引 (2) 创建线程时, 将TEB的地址放入fs寄存器来传递tls数组地址. teb+0x2c处字段指向tls数组 (3) 将tls索引值保存到AddressOfIndex字段指向的位置 (4

[并发并行]_[C/C++]_[使用线程本地存储Thread Local Storage(TLS)调用复制文件接口的案例]

使用场景: 1. 在复制文件时,一般都是一个线程调用一个接口复制文件,这时候需要缓存数据,如果每个文件都需要创建独立的缓存,那么内存碎片是很大的. 如果创建一个static的内存区,当多线程调用同一个接口时,多个线程同时使用同一个static缓存会造成数据污染.最好的办法是这个缓存只对这个线程可见, 当线程创建时创建缓存区,当线程结束时销毁缓存区. 2. 代码里有注释: test.cpp #include <iostream> #include "pthread.h" #i

TLS 线程本地存储

TLS (Thread Local Storage) XP系统上的Portable executable不支持动态加载. https://reverseengineering.stackexchange.com/questions/14171/thread-local-storage-access-on-windows-xp/14186#14186 http://www.cnblogs.com/wuyuan2011woaini/p/6124385.html http://www.cnblogs.

.Net - 线程本地变量(存储)的使用

关于C#多线程的文章,大部分都在讨论线程的开始与停止或者是多线程同步问题.多线程同步就是在不同线程中访问同一个变量或共享资源,众所周知在不使用线程同步的机制下,由于竞争的存在会使某些线程产生脏读或者是覆盖其它线程已写入的值(各种混乱). 而另外一种情况就是多线程时我们想让每个线程所访问的变量只属于各自线程自身所有,这就是所谓的线程本地变量. 线程本地变量不是用于解决共享变量的问题的,不是为了协调线程同步而存在,而是为了方便每个线程处理自己的状态而引入的一个机制,理解这点对正确使用线程本来变量至关