一、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),并且长期存在于该线程中,对此类变量的操作不影响其他线程。如下图:
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指针。
注意:
- 假设各个线程value值并非自己分配的内存而是由主线程传过来的相同指针、或者是静态区地址,那么此处的value就并非线程所私有了,还是属于共享数据。pthread_setspecific接口仅仅是做一个键值、地址的映射操作而已。
- 参数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,其格式大概如下:
数组的每个元素都是一个包含两个字段的结构,第一个字段标记该数组元素是否在用,第二个字段用于存放针对此键、线程局部存储变的解构函数的一个副本,即destructor函数。
- 每个线程还包含一个数组,存有为每个线程分配的线程特有数据块的指针(通过调用pthread_setspecific()函数来存储的指针,即参数中的value)
2、在常见的存储pthread_setspecific()函数参数value的实现中,大多数都类似于下图的实现。图中假设pthread_keys[1]分配给func1()函数,pthread API为每个函数维护指向线程局部存储数据块的一个指针数组,其中每个数组元素都与图线程局部数据键的实现(上图)中的全局pthread_keys中元素一一对应。
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