一、TLS的由来

在多程线程序设计中,很多时候对数据进行划分,不同的数据区域交给不同的线程处理,可以避免多线程中竞争访问数据。但是有一个问题得考虑,那就是:各个独立线程在执行函数时(通常是不同线程执行相同的函数,只是他们访问的数据不同),这些函数的中间输出结果能否已是线程独立的呢?如果能完全独立,则多线程之间完全不需要互斥,伸缩性最优。

这个问题有两个解决办法,首先想到的是这些函数的输入和输出全部用参数传递。但它对编程要求很高, 有时这样的设计往往并不是最优的。很多时候函数的中间输出结果需要使用*全局变量来保存*(当然我们不推荐全局变量满天飞,但在一个模块内的静态全局变量还是常有的),这样就需要加锁保护,效率低下。

在这个场景下,每个线程都希望自己看到的全局变量是自己的,它也不想看到别人的,也不想别人对它有修改,它也不想修改别人的,全局变量也是个形式而已,最终目的是它可以独立拥有一份,linux系统中最典型的应用就是errno的实现

那么要解决多线程下的高效编程问题,必须对原来的变量模型稍作修改,支持一个额外的属性,那就是变量是多执行单元(线程)共享还是独立拥有一份。

变量类型

何处定义

修饰属性

多执行单元共享or独享

生命周期

可见性

全局变量

在函数外

auto

共享

整个程序

全局可见

全局线程变量

在函数外

auto

独享

整个程序

全局可见

全局静态变量

在函数外

static

共享

整个程序

同一.c文件可见

全局静态线程变量

在函数外

static

独享

整个程序

同一.c文件可见

局部变量

在函数内

auto

独享

只在函数开始执行到结束这段时间

函数内

静态局部变量

在函数内

static

共享

整个程序

函数内可见

静态局部线程变量

在函数内

static

独享

整个程序

函数内可见

注意,局部变量本来就是线程函数内独享的,所以它没有共享这个属性值,整个程序生命周期的变量都有共享和独享这两个属性值。

如果一个变量需要在线程内部的各个函数调用都能访问、但其它线程不能访问,这就需要新的机制来实现,我们称之为Static memory local to a thread (线程局部静态变量),同时也可称之为线程特有数据(TSD: Thread-Specific Data)或者线程本地存储(TLS: Thread-Local Storage)。这一类型的数据,在程序中每个线程都会分别维护一份变量的副本(copy),并且长期存在于该线程中,对此类变量的操作不影响其他线程。如下图:

linux 创建线程后 res内存一直递增 linux线程本地存储_数据

2、TLS实现方式

Linux下支持两种方式定义和使用TLS变量,具体如下表:

定义方式

支持层次

访问方式

__thread关键字

语言层面

与全局变量完全一样

pthread_key_create函数

运行库层面

pthread_get_specific和pthread_set_specific对线程变量进行映射和读取(通过键值)

2.1、线程局部数据API

在Linux中提供了如下函数来对线程局部数据进行操作

#include <pthread.h>

int pthread_key_create(pthread_key_t *key, void (*destructor)(void*));
// Returns 0 on success, or a positive error number on error

int pthread_key_delete(pthread_key_t key);
// Returns 0 on success, or a positive error number on error

int pthread_setspecific(pthread_key_t key, const void *value);
// Returns 0 on success, or a positive error number on error

void* pthread_getspecific(pthread_key_t key);
// Returns pointer, or NULL if no thread-specific data is assciated with key

2.1.1、一次性初始化

在讲解线程特有数据之前,先让我们来了解一下一次性初始化。多线程程序有时有这样的需求:不管创建多少个线程,有些数据的初始化只能发生一次。列如:在C++、java等程序中某个类在整个进程的生命周期内只能存在一个实例对象,在多线程的情况下,为了能让该对象能够安全的初始化,一次性初始化机制就显得尤为重要了。——在设计模式中这种实现常常被称之为单例模式(Singleton)。Linux中提供了如下函数来实现一次性初始化:

#include <pthread.h>
int pthread_once(pthread_once_t *once_control, void (*init_routine)(void));
pthread_once_t once_control = PTHREAD_ONCE_INIT;

/*
Upon successful completion, pthread_once() shall return zero; otherwise, an error number shall be returned to indicate the error.
利用参数 once_control 的状态, 函数 pthread_once()可以确保无论有多少个线程调用多少次该函数,也只会执行一次由init_routine
所指向的由调用者定义的函数。init_routine所指向的函数没有任何参数,形势如下:
*/
void init_routine(void) {
  // some variables initializtion in here
}

注意:参数 once_control 必须是 pthread_once_t 类型变量的指针,指向初始化为 PTHRAD_ONCE_INIT 的静态变量。

2.1.2、pthread_key_create/pthread_key_delete

函数pthread_key_create()为线程局部数据创建一个新键,并通过key指向新创建的键缓冲区。因为所有线程都可以使用返回的键,所以参数key可以是一个全局变量(在C++多线程编程中一般不使用全局变量,而是使用单独的类对线程局部数据进行封装,每个变量使用一个独立的pthread_key_t)。destructor 所指向的是一个自定义的函数 destructor_func,其格式如下:

void destructor_func(void *value) {
  	......
    free(value);
}

只要线程终止时与key关联的值不为NULL,则 destructor 所指的函数将会自动被调用。如果一个线程中有多个线程局部存储变量,那么对各个变量所对应的destructor函数的调用顺序是不确定的,因此,每个变量的destructor函数的设计应该相互独立。

函数pthread_key_delete()并不检查*当前是否有线程正在使用该线程局部数据变量*,*也不会调用清理函数destructor*,而只是将其释放以供下一次调用pthread_key_create()使用。在Linux线程中,它还会将与之相关的线程数据项设置为NULL。

由于系统对每个进程中 pthread_key_t 类型的个数是有限制的,所以进程中并不能创建无限个的 pthread_key_t 变量。Linux中可以通过PTHREAD_KEY_MAX(定义于limits.h文件中)或者系统调用sysconf(_SC_THREAD_KEYS_MAX)来确定当前系统最多支持多少个键。Linux中默认是1024个键,这对于大多数程序来说已经足够了。如果一个线程中有多个线程局部存储变量,通常可以将这些变量封装到一个数据结构中,然后使封装后的数据结构与一个线程局部变量相关联,这样就能减少对键值的使用。

2.1.3、pthread_setspecific/pthread_getspecific

线程本地数据存储采用一键多值的技术,即一个键对应多个值。访问数据时都是通过键值来访问,表面上看上去好像是对同一个变量进行访问,但是实际上访问的是不同的数据空间,即每一个线程的私有空间数据。具体实现:当线程中需要存储特殊值的时候,可以调用pthread_setspcific()。该函数有两个参数,第一个为前面声明的pthread_key_t变量,第二个为void变量即value,这样你可以存储任何类型的值。线程调用该接口系统就会将 键值key、线程信息、变量value三者映射起来。**参数 value *通常指向由调用者(线程)分配的一块内存**,当线程终止时,会将该指针作为参数传递给与 key 相关联的 destructor 函数**。当线程被创建时,会将所有的线程局部存储变量即value初始化为 NULL,因此第一次使用此类变量前必须先调用 pthread_getspecific()函数来确认value是否已经于对应的 key 相关联 ? 如果没有,则该接口返回NULL,此时需要为value分配内存且调用pthread_setspecific()将value、key、线程信息三者相关联起来;是否发现key已经存在关联的value,则直接返回value指针。

注意:

  1. 假设各个线程value值并非自己分配的内存而是由主线程传过来的相同指针、或者是静态区地址,那么此处的value就并非线程所私有了,还是属于共享数据。pthread_setspecific接口仅仅是做一个键值、地址的映射操作而已。
  2. 参数value的值也可以不是一个指向调用者分配的内存区域,而是任何可以强制转换为void*的变量值,在这种情况下,先前的pthread_key_create()函数应将参数,destructor设置为NULL,因为当线程结束时我们不需要调用free将其释放,由系统自行调用默认析构函数进行处理。

函数pthread_getspecific()正好与pthread_setspecific()相反,其是通过键值key作为参数,将pthread_setspecific()设置的value取出。在使用取出的值前最好是将void*转换成原始数据类型的指针。

2.1.4、深入理解tls机制

1、深入理解线程局部存储的实现有助于对其API的使用。在典型的实现中包含以下数组:

  • 一个全局(进程级别)的数组,用于存放线程局部存储的键值信息

pthread_key_create()返回的pthread_key_t类型值只是对全局数组的索引,该全局数组标记为pthread_keys,其格式大概如下:


linux 创建线程后 res内存一直递增 linux线程本地存储_局部存储_02

数组的每个元素都是一个包含两个字段的结构,第一个字段标记该数组元素是否在用,第二个字段用于存放针对此键、线程局部存储变的解构函数的一个副本,即destructor函数。

  • 每个线程还包含一个数组,存有为每个线程分配的线程特有数据块的指针(通过调用pthread_setspecific()函数来存储的指针,即参数中的value)

2、在常见的存储pthread_setspecific()函数参数value的实现中,大多数都类似于下图的实现。图中假设pthread_keys[1]分配给func1()函数,pthread API为每个函数维护指向线程局部存储数据块的一个指针数组,其中每个数组元素都与图线程局部数据键的实现(上图)中的全局pthread_keys中元素一一对应。


linux 创建线程后 res内存一直递增 linux线程本地存储_数据_03

2.1.5、代码演练

#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <unistd.h>

pthread_key_t  key;
struct  test_struct  {
     int  i;
     float  k;
};

void  * child1 ( void  * arg)
{
     struct  test_struct  struct_data;
     struct_data.i  =  10;
     struct_data.k  =  3.1415;
     pthread_setspecific ( key ,  & struct_data);
     printf ( "结构体struct_data的地址为 0x%p \n " ,  &( struct_data));
     printf ( "child1 中 pthread_getspecific(key)返回的指针为:0x%p \n " , ( struct  test_struct  *) pthread_getspecific( key));
     printf ( "利用 pthread_getspecific(key)打印 child1 线程中与key关联的结构体中成员值: \n struct_data.i:%d \n struct_data.k: %f \n " , (( struct  test_struct  *) pthread_getspecific ( key)) -> i , (( struct  test_struct  *) pthread_getspecific( key)) -> k);
    
    return NULL;
};
void  * child2 ( void  * arg)
{
     int  temp  =  20;
     sleep ( 2);
     printf ( "child2 中变量 temp 的地址为 0x%p \n " ,   & temp);
     pthread_setspecific ( key ,  & temp);
     printf ( "child2 中 pthread_getspecific(key)返回的指针为:0x%p \n " , ( int  *) pthread_getspecific( key));
     printf ( "利用 pthread_getspecific(key)打印 child2 线程中与key关联的整型变量temp 值:%d \n " ,  *(( int  *) pthread_getspecific( key)));
    
    return NULL;
}
int  main ( void)
{
     pthread_t tid1, tid2;
     pthread_key_create ( & key ,  NULL);
     pthread_create(&tid1, NULL, child1, (void*)0);
     pthread_create(&tid2, NULL, child2, (void*)0);
     pthread_join ( tid1 ,  NULL);
     pthread_join ( tid2 ,  NULL);
     pthread_key_delete ( key);
     return 0;
}

执行结果:

结构体struct_data的地址为 0x0x70000c723fa0 
 child1 中 pthread_getspecific(key)返回的指针为:0x0x70000c723fa0 
 利用 pthread_getspecific(key)打印 child1 线程中与key关联的结构体中成员值: 
 struct_data.i:10 
 struct_data.k: 3.141500 
 child2 中变量 temp 的地址为 0x0x70000c7a6fa4 
 child2 中 pthread_getspecific(key)返回的指针为:0x0x70000c7a6fa4 
 利用 pthread_getspecific(key)打印 child2 线程中与key关联的整型变量temp 值:20 
 Program ended with exit code: 0

2.2、__thread关键字

在Linux中还有一种更为高效的线程局部存储方法,就是使用关键字__thread来定义变量。__thread是GCC内置的线程局部存储设施(Thread-Local Storage),它的实现非常高效,与pthread_key_t向比较更为快速,其存储性能可以与全局变量相媲美,而且使用方式也更为简单。创建线程局部变量只需简单的在全局或者静态变量的声明中加入__thread说明即可。列如:

static __thread char t_buf[32] = {'\0'};
extern __thread int t_val = 0;

凡是带有__thread的变量,每个线程都拥有该变量的一份拷贝,且互不干扰。线程局部存储中的变量将一直存在,直至线程终止,当线程终止时会自动释放这一存储。

2.2.1、代码演练

#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <unistd.h>

// var 定义为线程变量,每个线程拥有一份
__thread int var = 0;

void* task_entry(void* arg);

int main() {
    pthread_t pid1, pid2;
    
    int step1 = 1, step2 = 2;
    pthread_create(&pid1, NULL, task_entry, (void*)(&step1));
    pthread_create(&pid2, NULL, task_entry, (void*)(&step2));
    
    pthread_join(pid1,NULL);
    pthread_join(pid2,NULL);
    
    return 0;
}

void* task_entry(void* arg) {
    int idx = *((int*)arg);
    for (int i = 0; i < 5; ++i) {
        printf("thread:%d var=%d\n", idx, var += idx);
        sleep(1);
    }
    return NULL;
}

执行结果:

thread:1 var=1
thread:2 var=2
thread:1 var=2
thread:2 var=4
thread:1 var=3
thread:2 var=6
thread:2 var=8
thread:1 var=4
thread:1 var=5
thread:2 var=10
Program ended with exit code: 0