线程局部存储TLS

1. 运行库支持多线程操作的更改

多线程编程时,其实线程的访问权限是很高的,可以访问进程内存里的所有数据,甚至包括其他线程的堆栈,但是这种毫无边界的感觉就和不知边界的亲戚一样,显然是需要制衡的。所以线程需要配备只有本线程专属的 线程专属存储空间(Thread Local Storage, TLS),即系统为线程单独开辟的存储空间。

对于C/C++标准库来说,线程相关的部分是不属于标准库的内容的,它跟网络、图形图像等一样,属于标准库之外的系统相关库。

由于多线程在现代的程序设计中占据非常重要的地位,主流的C运行库在设计时都会考虑到多线程相关的内容,这里说到的“多线程相关”主要两方面:
1.提供那些多线程操作的接口,比如创建线程、退出线程、设置线程优先级等函数接口;
2.C运行库本身要能够在多线程的环境下正确运行。

关于第一方面glibc提供了pthread库,而MSVC提供了_beginthread()_endthread()等系列API。但是难在第二方面,因为最初运行库编写时多线程的概念还没出现,现在随着多核CPU的普及和对计算速度的要求越来越高,故而C语言运行库的很多核心函数得重新编写以实现线程安全:

(1) errno: C运行提供errno全局变量,但是线程A处处的errno值可能在被读取之前就已经被线程B的error信息给覆盖掉;
(2)printf等IO函数:流输出函数是线程不安全的,早期的CRT运行库并会出现多线程交错输出的情况;
(3)malloc/free等堆处理函数:因为堆是由运行库管理的,如果运行库不支持多线程,则很可能出现堆空间分配错乱的问题。

也正是因为这个问题,所以CRT后期补充了多线程专用的运行库比如msvcprtd.lib等,在cl期间通过添加/MT、/MTd或/MDd等便可以启用相应的多线程运行库。

解决运行库在多线程情形下的不适用性,核心是将存在线程不安全的函数实现为线程安全的,显然有如下几种方式:
1. 锁
在多线程版本的运行库中,线程不安全的函数都被加上了锁,从而实现互斥操作,比如malloc/new这类操作共同堆空间的函数。使用多线程版本的运行库,可以直接调用malloc等函数,而不必要程序员再额外加锁。

2. 改进函数调用方式

char* strtok(char* strToken, const char* strDelimit);
char* strtok_s(char* strToken, const char* strDelimit, char **context);

直接在运行库中实现一个线程安全版的替代函数,如将strtok()换成线程安全版的strtok_s()函数,但是这种方式不是很推荐,因为标准库应该在保证API接口标准的前提下,尽可能地提供较少的API数量,否则会让调用者使用难度。

3. 使用TLS
前面说到glibc下errno是全局变量,多线程环境下可能导致errno被误覆盖。所以可以使用TLS机制,在每个线程内部实现errno,如在多线程版本的glibc中,errno被定义为
#define errno (*__errno_location () )
函数__errno_location()在多线程版本下,将会返回不同的线程专属的errno存储地址。

1. 隐式TLS

在编写多线程程序时很自然的想要为每个线程保存一些私有的数据,而我们知道属于线程私有的数据包括线程的栈和当前的寄存器,前者一直处于动态伸缩的过程中不可靠;寄存器容量小且珍贵,不能用来干这种粗活。所以这时候就需要TLS机制了,TLS的用法很简单,如果定义一个全局变量为TLS类型的:
1.GCC: __thread int number;
2.MSVC: __declspec(thread) int number;

一旦一个全局变量被定义成TLS类型的,那么每个线程都会拥有这个变量的一个副本,任何线程对该变量的修改都不会影响其他线程中该变量的副本。

Windows下TLS的实现
对于Windows系统而言,正常情况下一个全局变量或静态变量会被放到.data或.bss段中,但当我们使用_declspec(thread)定义一个线程私有变量的时候,编译器会把这些变量放到PE文件的“.tls”段中,当系统启动一个新的线程时,它会从进程的堆中分配一块足够大小的空间,然后把“.tls”段中的内容复制到这块空间中,于是每个线程都有自己独立的一个“.tls”副本。

TLS变量可能是一个C++的全局对象,那么每个线程在启动时不仅仅是复制.tls的内容那么简单,还需要负责把这TLS对象初始化,必须逐个调用它们的全局构造函数,而且当线程退出时,还要逐个地将它们析构。Windows PE文件结构下的Datadirectory数据目录结构数组共16个元素,其中有一个元素Image_direct_entry_TLS,该元素便是指向TLS表,TLS表中每项便对应一个TLS变量的构造函数和析构函数的地址。TLS表本身位于PE文件的.rdata段中。

而每个线程可能有的TLS变量并不相同,显然Image_direct_entry_TLS支出的全局TLS变量集合对于单个线程而言过于宽泛。其实每个线程都配备了一个线程环境块的数据结构(TEB, thread environment block),这个结构里面保存了线程的堆栈地址、线程ID等信息,其中便有一个域是指向该线程专属的TLS变量地址的指针数组。

2. 显式TLS

前面提到的使用 __thread__declspec(thread)关键字定义全局变量为TLS变量的方法往往被称为隐式TLS,即程序员无需关心TLS变量(托管)的申请、分配赋值和释放,编译器、运行库还有操作系统已经在背后将一切处理好了。

显式TLS则是是通过Windows系统API 共4个函数TlsAlloc(), TlsGetValue(), TlsSetValue(), TlsFree()用来手动地控制TLS变量的申请和处理。Linux系统下是pthread_key_create(), pthread_getspecific(), pthread_setspecific(), pthread_key_delete()

前面提到过TEB(线程环境模块)中有个TLS数组,实际上显式TLS也是利用这个数组来保存TLS数据的。线程专属的TLS变量空间显然不能过分大,一般TEB中都限制了TLS变量地址数组大小为64各表项,如果第一批TLS变量个数超过了64个,则系统还可以再批发4KB空间供以存放更多的TLS变量地址(32位系统),这意味着Windows XP下最多可以拥有(1024+64=1088)个TLS变量。但是考虑到显式使用TLS变量过于复杂,一般都很少用,毕竟通过__declspec(thread)修饰后,由操作系统直接统一分配,并且进行读取时的路由导向,而不需要通过显式调用TlsGetValue(), TlsSetValue()等函数来操作TLS变量。但是隐式TLS有一点不好,就是使用在DLL中的TLS变量在通过LoadLibrary()显式加载DLL时会无法正确初始化,这意味着在实际应用当中除了exe文件和保证会静态连接的dll库,其它都无法使用。

使用全局共享的资源:如全局变量或者静态变量等数据,是导致多线程编程中非线程安全的常见原因。在多线程程序中,保障非线程安全的常用手段是使用互斥锁来做保护,但这显然会导致并发处理速度下降。如果有些数据只能有一个线程可以访问,那么这一类数据就可以使用线程局部存储机制来处理,虽然使用这种机制会给程序执行效率上带来一定的影响,但对于使用锁机制来说,这些性能影响将可以忽略。

一点想法:虽然暂且并没有解除到太多的使用__declspec(thread)创建隐式TLS的场景,但是在多线程场景下,将多线程共享的资源组(比如对象池),则每个线程在每次操作时都会对对象池加锁申请一个对象然后解锁离开,这样子其实是人为地导致各处理线程在对象池外侧的等待阻塞,其实”完全可以借鉴运行库从系统中批发一块大内存零售给程序中各种需求“的思想,完全可以让各线程事先从全局的资源组总批发小批量资源,然后本线程专属使用,这样采用分而治之的策略可以减少对象池外waiting-to-do的阻塞情况。根据使用场景调整参数应该可以取得不错的加速效果。根据这样的想法搜到一篇类似的文章,可以参考https://www.cnblogs.com/sniperHW/p/3575816.html

  • 2
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值