为什么线程本地存储这么慢?

时间:2009-02-03 05:28:38

标签: multithreading performance d thread-local-storage

我正在为D编程语言开发一个自定义标记释放样式的内存分配器,它通过从线程局部区域分配来工作。似乎线程本地存储瓶颈导致从这些区域分配内存的巨大(~50%)减速与相同的单线程版本的代码相比,即使在设计我的代码以使每个分配只有一个TLS查找/释放。这是基于在循环中多次分配/释放内存,我试图弄清楚它是否是我的基准测试方法的工件。我的理解是线程本地存储基本上只需要通过额外的间接层访问某些东西,类似于通过指针访问变量。这是不正确的?线程本地存储通常有多少开销?

注意:虽然我提到D,但我也对D不具体的一般答案感兴趣,因为如果它比最佳实现慢,D的线程局部存储的实现可能会有所改进。

6 个答案:

答案 0 :(得分:33)

速度取决于TLS实施。

是的,你是正确的,TLS可以像指针查找一样快。在具有内存管理单元的系统上甚至可以更快。

对于指针查找,您需要来自调度程序的帮助。调度程序必须 - 在任务切换上 - 更新指向TLS数据的指针。

实现TLS的另一种快速方法是通过内存管理单元。这里TLS被视为与任何其他数据一样,但TLS变量在特殊段中分配。调度程序将在任务切换时将正确的内存块映射到任务的地址空间。

如果调度程序不支持任何这些方法,则编译器/库必须执行以下操作:

  • 获取当前的ThreadId
  • 拿一个信号量
  • 通过ThreadId查找指向TLS块的指针(可以使用地图左右)
  • 释放信号量
  • 返回指针。

显然,为每个TLS数据访问执行所有这些操作需要一段时间,最多可能需要三次操作系统调用:获取ThreadId,获取并释放信号量。

信号量是btw所必需的,以确保没有线程从TLS指针列表中读取而另一个线程正在产生一个新线程。 (并因此分配新的TLS块并修改数据结构)。

不幸的是,在实践中看到缓慢的TLS实现并不罕见。

答案 1 :(得分:10)

D中的线程本地人真的很快。这是我的测试。

64位Ubuntu,核心i5,dmd v2.052 编译器选项:dmd -O -release -inline -m64

// this loop takes 0m0.630s
void main(){
    int a; // register allocated
    for( int i=1000*1000*1000; i>0; i-- ){
        a+=9;
    }
}

// this loop takes 0m1.875s
int a; // thread local in D, not static
void main(){
    for( int i=1000*1000*1000; i>0; i-- ){
        a+=9;
    }
}

因此,每1000 * 1000 * 1000线程本地访问,我们只丢失一个CPU内核的1.2秒。 使用%fs寄存器访问线程本地 - 因此只涉及几个处理器命令:

使用objdump -d:

进行反汇编
- this is local variable in %ecx register (loop counter in %eax):
   8:   31 c9                   xor    %ecx,%ecx
   a:   b8 00 ca 9a 3b          mov    $0x3b9aca00,%eax
   f:   83 c1 09                add    $0x9,%ecx
  12:   ff c8                   dec    %eax
  14:   85 c0                   test   %eax,%eax
  16:   75 f7                   jne    f <_Dmain+0xf>

- this is thread local, %fs register is used for indirection, %edx is loop counter:
   6:   ba 00 ca 9a 3b          mov    $0x3b9aca00,%edx
   b:   64 48 8b 04 25 00 00    mov    %fs:0x0,%rax
  12:   00 00 
  14:   48 8b 0d 00 00 00 00    mov    0x0(%rip),%rcx        # 1b <_Dmain+0x1b>
  1b:   83 04 08 09             addl   $0x9,(%rax,%rcx,1)
  1f:   ff ca                   dec    %edx
  21:   85 d2                   test   %edx,%edx
  23:   75 e6                   jne    b <_Dmain+0xb>

也许编译器可能更加聪明,并在循环到寄存器之前缓存线程本地 并在最后将它返回到本地的线程(与gdc编译器比较很有意思), 但即使现在事情也很好恕我直言。

答案 2 :(得分:8)

在解释基准测试结果时需要非常小心。例如,D新闻组中最近的一个帖子从一个基准测试得出结论,dmd的代码生成导致了一个运算循环的主要减速,但实际上花费的时间主要是执行长分割的运行时辅助函数。编译器的代码生成与减速无关。

要查看为tls生成了什么类型的代码,请编译和obj2asm此代码:

__thread int x;
int foo() { return x; }

TLS在Windows上的实现方式与在Linux上的实现方式截然不同,在OSX上再次有所不同。但是,在所有情况下,它将比简单加载静态内存位置更多的指令。相对于简单访问,TLS总是会变慢。在紧密循环中访问TLS全局变量也会很慢。尝试在临时缓存TLS值。

我在几年前编写了一些线程池分配代码,并将TLS句柄缓存到池中,运行良好。

答案 3 :(得分:4)

如果您不能使用编译器TLS支持,您可以自己管理TLS。 我为C ++构建了一个包装器模板,因此很容易替换底层实现。 在这个例子中,我已经为Win32实现了它。 注意:由于每个进程无法获得无限数量的TLS索引(至少在Win32下), 你应该指向足够大的堆块来容纳所有特定于线程的数据。 这样,您就拥有了最少数量的TLS索引和相关查询。 在“最佳情况”中,每个线程只有一个TLS指针指向一个私有堆块。

简而言之:不要指向单个对象,而是指向特定于线程的堆内存/容器,以便获得更好的性能。

如果不再使用,请不要忘记释放内存。 我通过将一个线程包装到一个类(如Java)中并通过构造函数和析构函数处理TLS来实现。 此外,我将频繁使用的数据(如线程句柄和ID)存储为类成员。

用法:

  

表示类型*:   tl_ptr&LT;类型&GT;

     

表示const类型*:   tl_ptr&lt; const type&gt;

     

表示类型* const:   const tl_ptr&lt; type&gt;

     

const type * const:   const tl_ptr&lt; const type&gt;

template<typename T>
class tl_ptr {
protected:
    DWORD index;
public:
    tl_ptr(void) : index(TlsAlloc()){
        assert(index != TLS_OUT_OF_INDEXES);
        set(NULL);
    }
    void set(T* ptr){
        TlsSetValue(index,(LPVOID) ptr);
    }
    T* get(void)const {
        return (T*) TlsGetValue(index);
    }
    tl_ptr& operator=(T* ptr){
        set(ptr);
        return *this;
    }
    tl_ptr& operator=(const tl_ptr& other){
        set(other.get());
        return *this;
    }
    T& operator*(void)const{
        return *get();
    }
    T* operator->(void)const{
        return get();
    }
    ~tl_ptr(){
        TlsFree(index);
    }
};

答案 4 :(得分:4)

我为嵌入式系统设计了多任务程序,从概念上讲,线程本地存储的关键要求是让上下文切换方法保存/恢复指向线程本地存储的指针以及CPU寄存器和其他任何节省的东西/恢复。对于一旦启动它们将始终运行相同代码集的嵌入式系统,最简单的方法是简单地保存/恢复一个指针,指针指向每个线程的固定格式块。很好,干净,简单,高效。

如果一个人不介意为每个线程中分配的每个线程局部变量留出空间 - 即使是那些从未真正使用它的那些 - 并且如果所有内容都将在线程本地存储中block可以定义为单个结构。在这种情况下,对线程局部变量的访问几乎与访问其他变量一样快,唯一的区别是额外的指针取消引用。不幸的是,许多PC应用程序需要更复杂的东西。

在PC的某些框架上,如果在该线程上运行了使用这些变量的模块,则线程将只为线程静态变量分配空间。虽然这有时可能是有利的,但这意味着不同的线程通常会以不同的方式布置其本地存储。因此,线程可能需要具有某种可搜索的变量索引位置索引,并通过该索引指示对这些变量的所有访问。

我希望如果框架分配少量的固定格式存储,那么保留最后访问的1-3个线程局部变量的缓存可能会有所帮助,因为在许多情况下甚至是单项缓存可以提供相当高的命中率。

答案 5 :(得分:2)

我们在TLS(Windows)上看到过类似的性能问题。我们依赖它来进行产品“内核”中的某些关键操作。经过一番努力,我决定尝试对此进行改进。

我很高兴地说我们现在有一个提供&gt;的小API。当callin线程不“知道”其thread-id和&gt;时,等效操作的CPU时间减少50%。如果调用线程已经获得了其thread-id(可能用于其他一些早期的处理步骤),则减少65%。

新函数(get_thread_private_ptr())总是返回一个指向我们在内部用来保存所有排序的结构的指针,所以我们每个线程只需要一个。

总而言之,我认为Win32 TLS支持的设计非常糟糕。