线程局部存储是指对象内存在线程开始后分配,线程结束时回收且每个线程有该对象自己的实例,简单地说,线程局部存储的对象都是独立于各个线程的。实际上,这并不是一个新鲜的概念,虽然C++一直没有在语言层面支持它,但是很早之前操作系统就有办法支持线程局部存储了。
由于线程本身是操作系统中的概念,因此线程局部存储这个功能是离不开操作系统支持的。而不同的操作系统对线程局部存储的实现也不同,以至于使用的系统API也有区别,这里主要以Windows和Linux为例介绍它们使用线程局部存储的方法。
在Windows中可以通过调用API函数TlsAlloc来分配一个未使用的线程局部存储槽索引(TLS slot index),这个索引实际上是Windows内部线程环境块(TEB)中线程局部存储数组的索引。通过API函数TlsGetValue与TlsSetValue可以获取和设置线程局部存储数组对应于索引元素的值。API函数TlsFree用于释放线程局部存储槽索引。
Linux使用了pthreads(POSIX threads)作为线程接口,在pthreads中我们可以调用pthread_key_create与pthread_key_delete创建与删除一个类型为pthread_key_t的键。利用这个键可以使用pthread_setspecific函数设置线程相关的内存数据,当然,我们随后还能够通过pthread_getspecific函数获取之前设置的内存数据。
在C++11标准确定之前,各个编译器也用了自定义的方法支持线程局部存储。比如gcc和clang添加了关键字__thread来声明线程局部存储变量,而Visual Studio C++则是使用__declspec(thread)。虽然它们都有各自的方法声明线程局部存储变量,但是其使用范围和规则却存在一些区别,这种情况增加了C++的学习成本,也是C++标准委员会不愿意看到的。于是在C++11标准中正式添加了新的thread_local说明符来声明线程局部存储变量。
thread_local说明符可以用来声明线程生命周期的对象,它能与static或extern结合,分别指定内部或外部链接,不过额外的static并不影响对象的生命周期。换句话说,static并不影响其线程局部存储的属性:
struct X {
thread_local static int i;
};
thread_local X a;
int main()
{
thread_local X b;
}从上面的代码可以看出,声明一个线程局部存储变量相当简单,只需要在普通变量声明上添加thread_local说明符。被thread_local声明的变量在行为上非常像静态变量,只不过多了线程属性,当然这也是线程局部存储能出现在我们的视野中的一个关键原因,它能够解决全局变量或者静态变量在多线程操作中存在的问题,一个典型的例子就是errno。
errno通常用于存储程序当中上一次发生的错误,早期它是一个静态变量,由于当时大多数程序是单线程的,因此没有任何问题。但是到了多线程时代,这种errno就不能满足需求了。设想一下,一个多线程程序的线程A在某个时刻刚刚调用过一个函数,正准备获取其错误码,也正是这个时刻,另外一个线程B在执行了某个函数后修改了这个错误码,那么线程A接下来获取的错误码自然不会是它真正想要的那个。这种线程间的竞争关系破坏了errno的准确性,导致不可确定的结果。为了规避由此产生的不确定性,POSIX将errno重新定义为线程独立的变量,为了实现这个定义就需要用到线程局部存储,直到C++11之前,errno都是一个静态变量,而从C++11开始errno被修改为一个线程局部存储变量。
在了解了线程局部存储的意义之后,让我们回头仔细阅读其定义,会发现线程局部存储只是定义了对象的生命周期,而没有定义可访问性。也就是说,我们可以获取线程局部存储变量的地址并将其传递给其他线程,并且其他线程可以在其生命周期内自由使用变量。不过这样做除了用于诊断功能以外没有实际意义,而且其危险性过大,一旦没有掌握好目标线程的声明周期,就很可能导致内存访问异常,造成未定义的程序行为,通常情况下是程序崩溃。
值得注意的是,使用取地址运算符&取到的线程局部存储变量的地址是运行时被计算出来的,它不是一个常量,也就是说无法和constexpr结合:
thread_local int tv;
static int sv;
int main()
{
constexpr int *sp = &sv; // 编译成功,sv的地址在编译时确定
constexpr int *tp = &tv; // 编译失败,tv的地址在运行时确定
}在上面的代码中,由于sv是一个静态变量,因此在编译时可以获取其内存常量地址,并赋值到常量表达式sp。但是tv则不同,它在线程创建时才可能确定内存地址,所以这里会产生编译错误。
最后来说明一下线程局部存储对象的初始化和销毁。在同一个线程中,一个线程局部存储对象只会初始化一次,即使在某个函数中被多次调用。这一点和单线程程序中的静态对象非常相似。相对应的,对象的销毁也只会发生一次,通常发生在线程退出的时刻。下面来看一个例子:
#include <iostream>
#include <string>
#include <thread>
#include <mutex>
std::mutex g_out_lock;
struct RefCount {
RefCount(const char* f) : i(0), func(f) {
std::lock_guard<std::mutex> lock(g_out_lock);
std::cout << std::this_thread::get_id()
<< "|" << func
<< " : ctor i(" << i << ")" << std::endl;
}
~RefCount() {
std::lock_guard<std::mutex> lock(g_out_lock);
std::cout << std::this_thread::get_id()
<< "|" << func
<< " : dtor i(" << i << ")" << std::endl;
}
void inc()
{
std::lock_guard<std::mutex> lock(g_out_lock);
std::cout << std::this_thread::get_id()
<< "|" << func
<< " : ref count add 1 to i(" << i << ")" << std::endl;
i++;
}
int i;
std::string func;
};
RefCount *lp_ptr = nullptr;
void foo(const char* f)
{
std::string func(f);
thread_local RefCount tv(func.append("#foo").c_str());
tv.inc();
}
void bar(const char* f)
{
std::string func(f);
thread_local RefCount tv(func.append("#bar").c_str());
tv.inc();
}
void threadfunc1()
{
const char* func = "threadfunc1";
foo(func);
foo(func);
foo(func);
}
void threadfunc2()
{
const char* func = "threadfunc2";
foo(func);
foo(func);
foo(func);
}
void threadfunc3()
{
const char* func = "threadfunc3";
foo(func);
bar(func);
bar(func);
}
int main()
{
std::thread t1(threadfunc1);
std::thread t2(threadfunc2);
std::thread t3(threadfunc3);
t1.join();
t2.join();
t3.join();
}上面的代码并发3个工作线程,前两个线程threadfunc1和threadfunc2分别调用了3次foo函数。而第三个线程threadfunc3调用了1次foo函数和2次bar函数。其中foo和bar函数的功能相似,它们分别声明并初始化了一个线程局部存储对象tv,并调用其自增函数inc,而inc函数会递增对象成员变量i。为了保证输出的日志不会受到线程竞争的干扰,在输出之前加了互斥锁。下面是在Windows上的运行结果:
27300|threadfunc1#foo : ctor i(0)
27300|threadfunc1#foo : ref count add 1 to i(0)
27300|threadfunc1#foo : ref count add 1 to i(1)
27300|threadfunc1#foo : ref count add 1 to i(2)
25308|threadfunc3#foo : ctor i(0)
25308|threadfunc3#foo : ref count add 1 to i(0)
25308|threadfunc3#bar : ctor i(0)
25308|threadfunc3#bar : ref count add 1 to i(0)
25308|threadfunc3#bar : ref count add 1 to i(1)
10272|threadfunc2#foo : ctor i(0)
10272|threadfunc2#foo : ref count add 1 to i(0)
10272|threadfunc2#foo : ref count add 1 to i(1)
10272|threadfunc2#foo : ref count add 1 to i(2)
27300|threadfunc1#foo : dtor i(3)
25308|threadfunc3#bar : dtor i(2)
25308|threadfunc3#foo : dtor i(1)
10272|threadfunc2#foo : dtor i(3)从结果可以看出,线程threadfunc1和threadfunc2分别只调用了一次构造和析构函数,而且引用计数的递增也不会互相干扰,也就是说两个线程中线程局部存储对象是独立存在的。对于线程threadfunc3,它进行了两次线程局部存储对象的构造和析构,这两次分别对应foo和bar函数里的线程局部存储对象tv。可以发现,虽然这两个对象具有相同的对象名,但是由于不在同一个函数中,因此也应该认为是相同线程中不同的线程局部存储对象,它们的引用计数的递增同样不会相互干扰。
多线程已经成为现代程序应用中不可缺少的技术环节,但是在C++11标准出现之前,C++语言标准对多线程的支持是不完善的,无法创建线程局部存储对象就是其中的一个缺陷。幸好C++11的推出挽救了这种尴尬的局面。本章中介绍的thread_ local说明符终于让C++在语言层面统一了声明线程局部存储对象的方法。当然,想要透彻地理解线程局部存储,只是学习thread_local说明符的内容是不够的,还需要深入操作系统层面,探究系统处理线程局部存储的方法。