在块范围内thread_local
变量的用途是什么?
如果可编译的示例有助于说明问题,则为:
#include <thread>
#include <iostream>
namespace My {
void f(int *const p) {++*p;}
}
int main()
{
thread_local int n {42};
std::thread t(My::f, &n);
t.join();
std::cout << n << "\n";
return 0;
}
输出:43
在示例中,新线程获得了自己的n
,但是(据我所知)对此无能为力,那么为什么还要打扰呢?新线程自己的n
有什么用吗?如果没有用,那有什么意义?
很自然地,我认为这里有一个要点。我只是不知道重点在哪。这就是为什么我问。
如果新线程自己的n
(在我看来)要在运行时由CPU进行特殊处理-可能是因为在机器代码级别上,正常情况下无法访问自己的n
通过与新线程堆栈的基本指针的预先计算的偏移量的方式-那么,我们不仅浪费机器周期和电力就没有收益吗?即使不需要特殊处理,仍然没有收获!我看不到。
那么,为什么thread_local
在块范围内?
参考
答案 0 :(得分:3)
我发现thread_local
仅在以下三种情况下有用:
如果您需要每个线程具有唯一的资源,以使它们不必共享,互斥等即可使用所述资源。即便如此,这仅在资源很大且/或创建起来很昂贵或需要在函数调用之间保持持久(即函数内部的局部变量不足)的情况下才有用。
(1)的分支-当调用线程最终终止时,您可能需要特殊的逻辑才能运行。为此,可以使用在函数中创建的thread_local
对象的析构函数。对于每个使用thread_local
声明进入代码块的线程(在线程生存期结束时),都将调用此类thread_local
对象的析构函数。
对于每个调用它的唯一线程,您可能需要执行一些其他逻辑,但只能执行一次。例如,您可以编写一个函数来注册每个称为函数的唯一线程。这听起来可能很奇怪,但是我发现这种方法在管理我正在开发的库中管理垃圾收集的资源时有用。此用法与(1)密切相关,但在构造之后不会被使用。在线程的整个生命周期中都是有效的哨兵对象。
答案 1 :(得分:1)
撇开克鲁兹·吉恩(Cruz Jean)已经给出的出色例子(我想我不能补充这些例子),还请考虑以下几点:没有理由禁止这样做。我认为您不会怀疑thread_local
的用途,也不会质疑为什么它应该在一般的语言中使用。 thread_local
块范围变量的定义很明确,这仅仅是因为存储类和范围在C ++中的工作方式所致。仅仅因为无法想到与语言功能的每种可能组合有关的“有趣”功能,并不意味着必须明确禁止没有至少一个已知“有趣”应用程序的语言功能的所有组合。按照这种逻辑,我们还必须继续前进,禁止没有私人成员的课堂上交朋友或其他。至少对我而言,特别是C ++似乎遵循的哲学是“如果没有特定的技术原因导致功能X在情况Y下无法工作,那么就没有理由禁止它”,我认为这是一种非常健康的方法。无缘无故地禁止事物意味着无缘无故地增加复杂性。我相信每个人都会同意C ++已经足够复杂。它还可以防止发生意外事故,例如仅在多年之后突然发现某种语言功能具有以前未曾考虑过的应用程序。这种情况最突出的例子可能是模板(至少据我所知)最初并不是出于元编程的目的而构思的。后来才发现它们也可以用于此……
答案 2 :(得分:1)
首先请注意,块本地线程本地is implicitly static thread_local。换句话说,您的示例代码等效于:
int main()
{
static thread_local int n {42};
std::thread t(My::f, &n);
t.join();
std::cout << n << "\n"; // prints 43
return 0;
}
在函数内部用thread_local
声明的变量与全局定义的thread_locals没有太大区别。在这两种情况下,您都将创建每个线程唯一的对象,并且其生存期与线程的生存期绑定。
区别仅在于全局定义的thread_locals将被初始化when the new thread is run before you enter any thread-specific functions。相反,块局部线程局部变量被初始化the first time control passes through its declaration。
一个用例是通过定义在线程生存期内重用的本地缓存来加速功能:
void foo() {
static thread_local MyCache cache;
// ...
}
(我在这里使用static thread_local
来明确表示,如果在同一线程中多次执行该函数,则缓存将被重用,但这是一个问题。如果您删除{{1} },没有任何区别。)
关于示例代码的注释。也许是故意的,但是线程实际上并没有访问thread_local static
。相反,它对由运行n
的线程创建的指针的副本进行操作。因此,两个线程都引用相同的内存。
换句话说,更冗长的方式应该是:
main
如果更改代码,则线程访问int main()
{
thread_local int n {42};
int* n_ = &n;
std::thread t(My::f, n_);
t.join();
std::cout << n << "\n"; // prints 43
return 0;
}
,它将在其自己的版本上运行,并且属于主线程的n
将不会被修改:
n
这是一个更复杂的示例。它两次调用该函数以显示两次调用之间的状态被保留。其输出还显示线程在其自己的状态下运行:
int main()
{
thread_local int n {42};
std::thread t([&] { My::f(&n); });
t.join();
std::cout << n << "\n"; // prints 42 (not 43)
return 0;
}
输出:
#include <iostream>
#include <thread>
void foo() {
thread_local int n = 1;
std::cout << "n=" << n << " (main)" << std::endl;
n = 100;
std::cout << "n=" << n << " (main)" << std::endl;
int& n_ = n;
std::thread t([&] {
std::cout << "t executing...\n";
std::cout << "n=" << n << " (thread 1)\n";
std::cout << "n_=" << n_ << " (thread 1)\n";
n += 1;
std::cout << "n=" << n << " (thread 1)\n";
std::cout << "n_=" << n_ << " (thread 1)\n";
std::cout << "t executing...DONE" << std::endl;
});
t.join();
std::cout << "n=" << n << " (main, after t.join())\n";
n = 200;
std::cout << "n=" << n << " (main)" << std::endl;
std::thread t2([&] {
std::cout << "t2 executing...\n";
std::cout << "n=" << n << " (thread 2)\n";
std::cout << "n_=" << n_ << " (thread 2)\n";
n += 1;
std::cout << "n=" << n << " (thread 2)\n";
std::cout << "n_=" << n_ << " (thread 2)\n";
std::cout << "t2 executing...DONE" << std::endl;
});
t2.join();
std::cout << "n=" << n << " (main, after t2.join())" << std::endl;
}
int main() {
foo();
std::cout << "---\n";
foo();
return 0;
}
答案 3 :(得分:0)
static thread_local
和thread_local
是等效的; thread_local
具有线程存储持续时间,不是静态或自动的;因此,静态和自动说明符,即thread_local
,即auto thread_local
和static thread_local
对存储持续时间没有影响;从语义上讲,由于thread_local
的存在,它们是无用的,只是隐式地表示线程存储的持续时间; static甚至不会在块范围内修改链接(因为它始终没有链接),因此除了修改存储持续时间外,它没有其他定义。 extern thread_local
在块范围内也是可能的。文件范围内的static thread_local
提供了thread_local变量内部链接,这意味着TLS中每个翻译单元将有一个副本(每个翻译单元将在.exe的TLS索引处解析为其自己的变量,因为汇编器会在.o文件的rdata $ t节中插入变量,并由于符号上缺少.global指令而将其在符号表中标记为本地符号)。 extern thread_local
在文件范围内是合法的,就像在块范围内一样,并使用在另一个翻译单元中定义的thread_local
副本。文件范围内的thread_local
不是隐式静态的,因为它可以为另一个转换单元提供全局符号定义,而本地变量无法完成此定义。
编译器会将所有已初始化的thread_local
变量存储在.tdata中(包括块本地变量),并将未初始化的变量存储在.tbss中,格式为ELF,对于PE格式则全部存储为.tls。我假设线程库在创建线程时将访问.tls段并执行Windows API调用(TlsAlloc
和TlsSetValue
),这将为堆上的每个.exe和.dll分配变量。并将指针放置在GS段中线程的TEB的TLS数组中,并返回分配的索引,并为动态库调用DLL_THREAD_ATTACH
例程。大概是指向_tls_start
和_tls_end
定义的空间的指针是作为值传递给TlsSetValue
文件范围static/extern thread_local
和块范围(extern) thread_local
之间的区别与文件范围static/extern
和块范围static/extern
之间的一般区别相同,在于块范围{{ 1}}变量将在定义该函数的末尾超出范围,尽管由于线程存储的持续时间仍可以按地址返回和访问该变量。
编译器知道.tls段中数据的索引,因此它可以替代直接访问GS段,就像在Godbolt上看到的那样。
MSVC
thread_local
thread_local int a = 5;
int square(int num) {
thread_local int i = 5;
return a * i;
}
这将从_TLS SEGMENT
int a DD 05H ; a
_TLS ENDS
_TLS SEGMENT
int `int square(int)'::`2'::i DD 05H ; `square'::`2'::i
_TLS ENDS
num$ = 8
int square(int) PROC ; square
mov DWORD PTR [rsp+8], ecx
mov eax, OFFSET FLAT:int a ; a
mov eax, eax
mov ecx, DWORD PTR _tls_index
mov rdx, QWORD PTR gs:88
mov rcx, QWORD PTR [rdx+rcx*8]
mov edx, OFFSET FLAT:int `int square(int)'::`2'::i
mov edx, edx
mov r8d, DWORD PTR _tls_index
mov r9, QWORD PTR gs:88
mov r8, QWORD PTR [r9+r8*8]
mov eax, DWORD PTR [rcx+rax]
imul eax, DWORD PTR [r8+rdx]
ret 0
int square(int) ENDP ; square
(gs:88
,这是线程本地存储数组的线性地址)中加载64位指针,然后使用gs:[0x58]
(这显然是在数组*指针大小中找到索引)。然后从该指针+偏移量将TLS array pointer + _tls_index*8
加载到.tls段中。看到两个变量都使用相同的Int a;
,则表明每个.exe(即每个.tls节)都有一个索引,并且变量在TLS数组指向的地址处打包在一起。不同翻译单元中的_tls_index
变量将合并为.tls,并全部打包到同一索引处。
我相信链接器始终将其包含在最终可执行文件中并使其成为入口点的mainCRTStartup总是会初始化结构(因为每个.exe都需要自己的索引),因此进入T组需要进行编译libcmt.lib中的.rdata的文件(并且因为mainCRTStartup引用了它,所以链接程序会将其包括在最终的可执行文件中)。链接器将查找static thread_local
变量,并确保PE标头TLS目录指向该变量。
GCC (TLS直接位于FS基础之前;原始数据而非指针)
_tls_used
将一个,两个或两个变量都不是局部变量会产生相同的代码。
当线程执行终止时,Windows上的线程库将使用 mov edx,DWORD PTR fs:0xfffffffffffffff8 //access thread_local int1 inside function
mov eax,DWORD PTR fs:0xfffffffffffffffc //access thread_local int2 inside function
调用释放存储空间(它还必须释放指向TlsFree()
返回的指针的堆上的内存)。