【问题标题】:thread_local at block scope块范围内的 thread_local
【发布时间】:2019-03-15 21:57:49
【问题描述】:

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 在块范围内呢?

参考文献

【问题讨论】:

    标签: c++ multithreading thread-local-storage


    【解决方案1】:

    我发现thread_local 只在三种情况下有用:

    1. 如果您需要每个线程拥有唯一的资源,这样它们就不必共享、互斥锁等来使用所述资源。即便如此,这仅在资源很大和/或创建成本很高或需要在函数调用之间持续存在(即函数内的局部变量不够用)时才有用。

    2. (1) 的一个分支 - 当调用线程最终终止时,您可能需要运行特殊逻辑。为此,您可以使用函数中创建的thread_local 对象的析构函数。对于进入带有thread_local 声明的代码块的每个线程(在线程生命周期结束时),都会调用一次此类thread_local 对象的析构函数。

    3. 您可能需要为调用它的每个唯一线程执行一些其他逻辑,但只需执行一次。例如,您可以编写一个函数来注册每个调用函数的唯一线程。这听起来可能很奇怪,但我发现它可以用于管理我正在开发的库中的垃圾收集资源。此用法与 (1) 密切相关,但在构造后未使用。实际上是线程整个生命周期的哨兵对象。

    【讨论】:

    • 您的回答显示了专业知识。它很有启发性。值得赞赏。它给了我一个我没有的观点,+1,但不是所有三个案例都对 namespace 范围内的 thread_local 对象感到满意吗?如果您知道 block 范围内 thread_local 对象的任何用途,我也有兴趣阅读这些内容。
    • @thb 当然。如果你把它放在命名空间范围内,所有创建的线程都会实例化它,这可能会损害性能。如果将它放在块范围内,则每个线程在控件第一次进入该范围时仅将其实例化一次。即你不会创造任何你不使用的东西。
    • 这不是很有趣吗?你碰巧知道这样的物品存放在哪里吗?也就是说,您知道它的存储存在于哪个链接器段(如果这是正确的术语)吗?对我来说,将这样的对象保留在线程堆栈上并不明显。因为,如果它保留在那里,我不知道执行代码将如何找到该对象。
    • (请随意忽略最后一条评论。显然,我可以自己试验我的编译器,并检查它生成的目标文件。我只是好奇你有什么有趣的东西要添加。)
    • @thb 通常thread_local 对象分配在堆上(因此不涉及链接器)。但是,编译器会确保为它们调用析构函数并清理资源。
    【解决方案2】:

    首先注意块本地线程本地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明确表示,如果函数在同一个线程中多次执行,缓存将被重用,但这是一个口味问题。如果你放弃static,它会没有任何区别。)


    关于您的示例代码的评论。也许这是故意的,但线程并没有真正访问 thread_local 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;
    }
    

    输出:

    n=1 (main)
    n=100 (main)
    t executing...
    n=1 (thread 1)      # the thread used the "n = 1" init code
    n_=100 (thread 1)   # the passed reference, not the thread_local
    n=2 (thread 1)      # write to the thread_local
    n_=100 (thread 1)   # did not change the passed reference
    t executing...DONE
    n=100 (main, after t.join())
    n=200 (main)
    t2 executing...
    n=1 (thread 2)
    n_=200 (thread 2)
    n=2 (thread 2)
    n_=200 (thread 2)
    t2 executing...DONE
    n=200 (main, after t2.join())
    ---
    n=200 (main)        # second execution: old state is reused
    n=100 (main)
    t executing...
    n=1 (thread 1)
    n_=100 (thread 1)
    n=2 (thread 1)
    n_=100 (thread 1)
    t executing...DONE
    n=100 (main, after t.join())
    n=200 (main)
    t2 executing...
    n=1 (thread 2)
    n_=200 (thread 2)
    n=2 (thread 2)
    n_=200 (thread 2)
    t2 executing...DONE
    n=200 (main, after t2.join())
    

    【讨论】:

      【解决方案3】:

      static thread_localthread_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(包括块范围的变量)中以用于ELF,将未初始化的变量存储在.tbss 用于ELF,或者全部存储在.tls 用于PE 格式。我假设线程库在创建线程时将访问.tls 段并执行Windows API 调用(TlsAllocTlsSetValue),它们为堆上的每个.exe.dll 分配变量和在 GS 段中线程的 TEB 的 TLS 数组中放置一个指针,并返回分配的索引,以及为动态库调用 DLL_THREAD_ATTACH 例程。据推测,指向_tls_start_tls_end 定义的空间中的值的指针是作为值指针传递给TlsSetValue 的。

      文件范围static/extern thread_local和块范围(extern) thread_local之间的区别与文件范围static/extern和块范围static/extern之间的一般区别相同,因为块范围thread_local变量将超出范围定义它的函数的末尾,尽管由于线程存储持续时间,它仍然可以通过地址返回和访问。

      编译器知道.tls段中数据的索引,所以它可以直接替代访问GS段,如godbolt所示。

      MSVC

      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:88gs:[0x58],这是线程本地存储数组的线性地址)加载一个64位指针,然后使用TLS array pointer + _tls_index*8加载一个64位指针(这显然是定位索引在数组 * 指针大小)。然后,Int a; 从此指针 + 偏移量加载到 .tls 段中。看到两个变量都使用相同的_tls_index,这表明每个.exe 都有一个索引,即每个.tls 部分,实际上.rdata 中的每个TLS 目录都有一个_tls_index,并且变量被打包在一起TLS 数组指向的地址。 static thread_local 不同翻译单元中的变量将被合并到 .tls 中,并在同一个索引处打包在一起。

      我相信mainCRTStartup,链接器总是包含在最终的可执行文件中,如果它作为控制台应用程序被链接,它会使其成为入口点,引用_tls_used变量(因为每个.exe都需要它自己的索引) 并且在libcmt.lib 中定义它的任何目标文件中的.rdata 的T 片段中进行编译(因为mainCRTStartup 引用它,链接器会将它包含在最终的可执行文件中)。如果链接器找到对_tls_used 变量的引用,它将确保包含它并确保PE 标头TLS 目录指向它。

      #pragma section(".rdata$T", long, read)    //creates a read only section called `.rdata` if not created and a fragment T in the section
      #define _CRTALLOC(x) __declspec(allocate(x))
      #pragma data_seg()   //set the compilers current default data section to `.data`
      
      _CRTALLOC(".rdata$T")  //place in the section .rdata, fragment T
      const IMAGE_TLS_DIRECTORY _tls_used =
      {
       (ULONG)(ULONG_PTR) &_tls_start, // start of tls data in the tls section
       (ULONG)(ULONG_PTR) &_tls_end,   // end of tls data
       (ULONG)(ULONG_PTR) &_tls_index, // address of tls_index
       (ULONG)(ULONG_PTR) (&__xl_a+1), // pointer to callbacks
       (ULONG) 0,                      // size of tls zero fill
       (ULONG) 0                       // characteristics
      };
      

      http://www.nynaeve.net/?p=183

      _tls_used是一个IMAGE_TLS_DIRECTORY结构类型的变量,上面初始化的内容,实际上是在tlssup.c中定义的。在此之前,它定义了_tls_index_tls_start_tls_end,将_tls_start 放在.tls 部分的开头,将_tls_end 放在.tls 部分的末尾,方法是将其放在该部分中fragmentZZZ 使其按字母顺序出现在该部分的末尾:

      #pragma data_seg(".tls") //set the compilers current default data section to `.tls`
      
      #if defined (_M_IA64) || defined (_M_AMD64)
      _CRTALLOC(".tls")   //place the following in the section named `.tls`
      #endif
      char _tls_start = 0;   //if not defined, place in the current default data section, which is also `.tls`
      
      #pragma data_seg(".tls$ZZZ")
      
      #if defined (_M_IA64) || defined (_M_AMD64)
      _CRTALLOC(".tls$ZZZ")
      #endif
      char _tls_end = 0;
      

      这些地址随后被用作_tls_used TLS 目录中的标记。只有当.tls 部分完成并且它具有固定的相对lea 位置时,链接器才会解析该地址。

      GCC(TLS 直接在 FS 基础之前;原始数据而不是指针)

       mov    edx,DWORD PTR fs:0xfffffffffffffff8 //access thread_local int1 inside function
       mov    eax,DWORD PTR fs:0xfffffffffffffffc //access thread_local int2 inside function
      

      将一个、两个或一个变量都设为本地会产生相同的代码。

      当线程执行终止时,windows 上的线程库将使用TlsFree() 调用释放存储空间(它还必须释放指向TlsGetValue() 返回的指针的堆上的内存)。

      【讨论】:

        【解决方案4】:

        撇开 Cruz Jean 已经给出的很好的例子(我认为我不能再添加这些例子),还要考虑以下几点:没有理由禁止它。我认为您不会怀疑thread_local 的有用性,也不会质疑为什么它应该在一般语言中使用。 thread_local 块作用域变量具有明确定义的含义,这仅仅是因为存储类和作用域在 C++ 中的工作方式。仅仅因为人们无法想到与语言特征的每一种可能组合有关的“有趣”事物,并不意味着必须明确禁止所有没有至少一个已知“有趣”应用程序的语言特征组合。按照这种逻辑,我们还必须继续并禁止没有私人成员的班级有朋友等等。至少对我来说,特别是 C++ 似乎遵循“如果没有特定的技术原因导致功能 X 在情况 Y 中无法工作,那么就没有理由禁止它”的理念,我认为这是一种非常健康的方法。无缘无故禁止事情意味着无缘无故地增加复杂性。而且我相信每个人都会同意,C++ 已经足够复杂了。它还可以防止意外事故,例如,仅在多年之后,突然发现某种语言功能具有以前未曾想到的应用程序。这种情况最突出的例子可能是模板(至少据我所知)最初不是为了元编程而构思的。后来才发现它们也可以用于那个……

        【讨论】:

        • 这是一个有趣的观点,+1。我完全没有这样想过。为什么要禁止,真的?正如你所说,已经足够复杂了。
        猜你喜欢
        • 1970-01-01
        • 2022-01-17
        • 1970-01-01
        • 1970-01-01
        • 2020-12-18
        • 1970-01-01
        • 1970-01-01
        • 2018-11-25
        • 1970-01-01
        相关资源
        最近更新 更多