线程本地存储为什么这么慢?
我正在为D编程语言的自定义标记释放样式内存分配器工作,通过从线程本地区域分配。 看起来线程局部存储瓶颈导致了从这些区域分配内存的速度(〜50%)相比于其他相同的单线程版本的代码,甚至在devise我的代码后,每个分配/释放。 这是基于在一个循环中分配/释放内存很多次,我正在试图弄清楚它是否是我的基准testing方法的人为因素。 我的理解是线程本地存储基本上只需要通过一个额外的间接层来访问某些东西,类似于通过指针访问一个variables。 这是不正确的? 线程本地存储通常有多less开销?
注意:尽pipe我提到了D,但是我也对D中没有特定的一般答案感兴趣,因为如果D的实现比最好的实现慢,D的线程本地存储的实现可能会改进。
速度取决于TLS的实施。
是的,TLS可以像查找指针一样快。 具有内存pipe理单元的系统甚至可以更快。
对于指针查找,您需要调度程序的帮助。 调度程序必须在任务开关上更新指向TLS数据的指针。
实现TLS的另一个快速方法是通过内存pipe理单元。 这里TLS被视为与其他数据一样,除了TLSvariables被分配在一个特殊的段中。 调度程序将在任务切换时将正确的内存块映射到任务的地址空间。
如果调度程序不支持任何这些方法,则编译器/库必须执行以下操作:
- 获取当前的ThreadId
- 采取信号量
- 通过ThreadId查找指向TLS块的指针(可能使用地图左右)
- 释放信号量
- 返回指针。
显然,为每个TLS数据访问做所有这些工作需要一段时间,最多可能需要三次OS调用:获取ThreadId,获取和释放信号量。
信号量是必须的,以确保没有线程从TLS指针列表中读取,而另一个线程正在产生一个新的线程。 (如此分配一个新的TLS块并修改数据结构)。
不幸的是,在实践中看到缓慢的TLS实施并不罕见。
D中的线程本地真的很快。 这是我的testing。
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>
也许编译器可以更聪明,caching线程本地之前循环到一个寄存器,并返回到本地结束(与gdc编译器比较是有趣的),但即使现在重要的是非常好的恕我直言。
在解释基准testing结果时需要非常小心。 例如,D新闻组中的一个最近的话题从一个基准中得出结论:dmd的代码生成在循环中造成了一个重大的减速,但是实际上花费的时间被长时间分割的运行时辅助函数所支配。 编译器的代码生成与减速无关。
要查看为tls生成的代码是什么样的,编译和obj2asm这段代码:
__thread int x; int foo() { return x; }
Windows上的TLS在Linux上的实现方式与Linux上的完全不同,在OSX上又会有很大的不同。 但是,在所有情况下,这将比静态存储器位置的简单加载多得多。 相对于简单的访问,TLS总是会变得很慢。 在一个紧密的循环中访问TLS全局也会变得很慢。 尝试在临时中cachingTLS值。
我几年前写了一些线程池分配代码,并caching到池的TLS句柄,运行良好。
如果你不能使用编译器TLS支持,你可以自己pipe理TLS。 我为C ++构build了一个包装模板,所以很容易replace一个底层的实现。 在这个例子中,我已经为Win32实现了它。 注意:由于每个进程无法获得无限数量的TLS索引(至less在Win32下),所以应该指向足够大的堆块以容纳所有线程特定的数据。 这样你就有最less数量的TLS索引和相关的查询。 在“最好的情况下”,每个线程只有一个TLS指针指向一个私有堆块。
简而言之:不要指向单个对象,而应该指向特定于线程的堆内存/容器,以保持对象指针以获得更好的性能。
如果不再使用,请不要忘记释放内存。 我通过将一个线程包装到一个类中(像Java那样)并通过构造函数和析构函数来处理TLS。 此外,我存储像线程句柄和ID的常用数据作为类成员。
用法:
对于types*:tl_ptr <type>
对于consttypes*:tl_ptr <const type>
对于types* const:const tl_ptr <type>
consttypes* const:const tl_ptr <常量types>
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); } };
我为embedded式系统devise了多任务处理器,从概念上说,线程本地存储的关键要求是使用上下文切换方法保存/恢复指向线程本地存储器的指针以及CPU寄存器以及任何其他正在保存/恢复的指针。 对于一旦启动的embedded式系统,它们总是运行相同的一组代码,最简单的方法就是保存/恢复一个指向每个线程固定格式块的指针。 好,干净,简单,高效。
如果不介意为每个线程中分配的每个线程局部variables(即使那些从未实际使用它的线程variables)分配空间,并且如果线程局部存储块中的所有内容都可以定义为一个单一的结构。 在这种情况下,访问线程局部variables几乎可以像访问其他variables一样快,唯一的区别是额外的指针解引用。 不幸的是,许多PC应用程序需要更复杂的东西
在PC的一些框架中,如果使用这些variables的模块已经在该线程上运行,那么一个线程只会为线程静态variables分配空间。 虽然这有时可能是有利的,但这意味着不同的线程往往会将他们的本地存储放在不同的地方。 因此,线程可能有必要为其variables所在的位置提供某种可search索引,并通过该索引将所有访问指向这些variables。
我期望如果框架分配less量的固定格式存储,保留最后1-3个线程局部variables的caching可能会有帮助,因为在很多情况下,即使是单项caching也可以提供相当高的命中率。
我们已经看到TLS(在Windows上)类似的性能问题。 我们依靠它来进行产品“内核”中的某些关键操作。经过一番努力,我决定尝试改进。
我很高兴地说,我们现在有一个小的API,当callin线程不“知道”它的线程ID,并且如果调用线程已经减less了65%,那么为等效操作提供> 50%的CPU时间获得了它的线程ID(也许是一些其他更早的处理步骤)。
新函数(get_thread_private_ptr())总是返回一个指向我们内部使用的结构体的指针,所以我们只需要每个线程一个。
总而言之,我认为Win32 TLS的支持确实很糟糕。