linux c 线程本地存储,线程本地存储的使用场景

介绍

尽管 TLS(线程本地存储)技术早在数十年以前就被用来轻松的解决顽固的并发问题,但仍然有许多人质疑它的实用性,就在

UIUC(美国伊利诺伊大学香槟分校)于前不久举行的 2014 年度 C++ 标准委员会会议上,这种质疑再次被提出。在那些尝试将

SIMD(单指令多数据)或者 GPGPU(图形处理单元上的通用计算)整合进类线程软件模型(thread-like software

model)的开发者中,此类质疑尤为盛行。实际上许多的 SIMD 的类线程实现都是让所有的 SIMD 通道(SIMD

lanes)共用所关联的那一个线程的 TLS,这估计会让那些原本想用非导出的 TLS

变量来避免数据竞争问题的人感到惊讶。现在已经有一些 GPGPU 提供商打算采用共享 TLS 的方案,这迫使我们不得不重新审视 TLS

技术的意义和应用。

基于此目的,以及应 2014 UIUC 会议 SG1(study group #1 -- 学习小组 #1)之要求,本文将回顾

TLS 的常见用例(收集自 Linux 内核和其他一些地方),考察下 TLS 的一些替代方案,列举出 TLS 带给 SIMD 和

GPGPU 的一些挑战,最终提供若干可能的解决方案。

TLS 的常用场景

本次调查包括了 Linux 内核中 CPU 本地变量(per-CPU variables),因为他们的使用方式和 TLS

十分相似。内核中一共有 500 多个静态分配的 CPU 本地变量,还有 100 多个是动态分配的。

TLS 最常见的应用也许就是统计计数了吧。一般方法是将计数器变量分散到所有线程(CPU

或者其他什么地方)中。当要更新计数时,每个线程都只更新自己的那一份。当要读取计数时,就必须把所有的线程里的计数器的值累加起来。对于偶尔的从频繁更新的事件中收集统计信息这类常见情况来说,上述做法提速明显(译者注:因为大量频繁的写入操作都不再受竞争条件的阻碍,直接写入,线程阻塞的频率大大降低)。“并行编程很难么,如果是这样,那我们该如何应对呢?”

的计数章节详细讨论此类方案的多个变种,其中的一些实现不仅提供了快速更新的特性,还加速了读取操作。尤其是当你创建了好几十个线程,每个线程都在处理一连串的短周期任务时,此类方案的应用变得极其重要。比如,内核中的网络模块里就经常出现这样的需求。

另一类 TLS

的常用场景是实现低开销的日志与跟踪记录。这样可以避免在调试或者性能调优时出现所谓的‘heisenbugs’。每个线程都有自己私有的日志,将这些线程本地日志最后合并就得到完整的日志。如果全局时序很重要,某种形式的基于硬件时钟或者类似

Lamport clocks 的时间戳就会被加进来。

TLS 还常常被用来实现内存分配器的线程本地缓存机制, 对 1993 年发表在 USENIX 上的关于内存分配的文章的修正

对此有详细论述,并在 tcmalloc 和 jemalloc

的实现中被采用。在此方案中,每个线程都维护着一个最近释放了的的内存块缓冲池(译者注:这些内存块并非真的被内核内存模块回收,他们依然还属于当前进程占用的资源,他们只是被线程标记为“空闲”而已),这样下次需要分配内存时就可以直接从缓冲池里取出“空闲”的内存块来使用(而不用再通过系统调用向内核申请内存了),从而避免了耗时耗力的同步操作和缓冲失效的情况。Linux

内核也采用类似的 CPU 本地缓存方案来暂存安全/审计信息,新建网络连接,以及其他很多场合。

程序语言的运行时实现经常利用 TLS 来跟踪异常处理函数以高效的访问和更新当前状态而无需同步。TLS 也被频繁用于实现

errno 和跟踪 setjmp/jmplong 操作状态。一些编译器还利用 TLS 实现线程级的局部变量。由于性能稍逊的嵌入式

CPU 缺少原生的整形除法指令,该平台上的编译器,针对除数比较小的情况,会利用 TLS 来实现一个本地计算缓存。还有很多很多其他利用

TLS 的来跟踪线程本地状态的场合,难以尽述。比如在 Linux 内核中用于包交换通信的通用套接字(generic sockets

for package-based communications),线程级 I/O 的启发式状态控制(state

controlling per-thread heuristics),计时,看门狗计时器,电源管理,惰性浮点单元管理( lazy

floating-point unit management),以及其他很多场合。

上一段介绍的都是完全在线程内使用该线程的状态。但是有些时候也需要把一个线程的状态公开给其他线程。例如用户空间 RCU (

userspace RCU)里的静默状态追踪(quiescent-state tracking)。Linux 内核中的空闲线程追踪(

idle-thread tracking),Linux 内核中使用的轻量级读写锁( lightweight reader-writer

locks 同时也适用于用户空间的代码),用于 KProbes 的探测器控制块( control blocks for probes

同样也适用于用户空间的程序探测),以及数据导向的负载均衡(data guiding load-balancing

activity)。

通常线程 ID (以个不同的形式)存储在 TLS 中。他们常被用在数列索引(TLS

的一种替代形式),选举算法(election

algorithm)中的平局决胜(tie-breakers),或者,至少出现在教科书中的关于彼得森锁(Peterson

Locks)之类的论述中。

时常有这样的争论:我们需要用某种形式的控制块来替代 TLS,因此我们值的去重新审视一个基本同步设施,它包含一个动态分配的

CPU 本地变量的指针,这就是所谓的“可休眠的读取拷贝更新(sleepable read-copy updatet --

SRCU)”。每个 SRCU 控制模块(即 struct srcu_struct)代表一个 SRCU 域。给定 SRCU

域中的读取者只能阻塞同一个 SRCU 域关联的宽限期。因此 struct srcu_struct 就是一个 SRCU 域,而动态分配的

CPU 本地状态就被用来追踪该域中的读取者。这种独特的内含 CPU 本地状态的数据结构还出现在 Linux

内核的其他很多地方,包括网络任务,大容量存储 I/O,计时,虚拟化和性能监控。

TLS 的替代方案

已经有一些 TLS

的替代方案被提了出来,包括使用函数调用堆栈,把状态通过传参给一个专门的函数,在这个函数里通过另一个某种形式的线程 ID

的函数来求得一个与当前线程一一对应的(全局)数组里的位置,进而存放状态。尽管这些方案在某些特定情况下非常有用,但是他们仍然还无法全面的取代

TLS 的角色。

当 TLS 数据的生命周期不超过其对应的栈帧(stack

frame)的生命周期时,上述提到的函数调用堆栈确实是一个优秀的(也已经被广泛的使用)替代方案。但是当 TLS

数据的寿命需要超过该栈帧的寿命时,此方案就无能为力了。

生命周期问题在某些情况下是可以克服的:通过在一个长生命期的(即靠近栈底的)栈帧中开辟 TLS

数据,并把它的地址传参给所有需要访问它的函数。但是这些被传递进去的地址还是容易出问题,特别是当他们被传递给某些库函数时(译者注:这些库函数可能会保存这些地址,而在数据寿命终结之后还企图访问他们)。这类

TLS 数据的传递也严重违反了模块化原则。

使用一个数组,使用某种函数将每个线程的 ID

一一映射到该数组的每个元素上,通过这种方法能提能提供线程本地数据的存储,但是这样的设计有些很严重的缺陷。比方说:如果要静态的分配这个数组,那么就得实现确定程序所需的线程的数量,情况往往并非如此。当然对此类不确定性的常见应对就是超量供应(估计出可能的线程数上限,然后预先开辟出足够的空间来),这自然会导致内存浪费。软件工程的模块化需求会导致出现很多这样的数组,加上对性能和可升缩性的考虑,他们要求数组元素的在内存布局上需要良好的对齐与填充,这将导致更多的内存浪费。此外,用数组索引其他数组(需要的额外的查找,跳转之类的操作)明显慢于

TLS 访问。

总而言之,尽管已经出现了一些方案,他们在某些场合能够可靠的替代 TLS,但是仍然还有大量的 TLS

的应用场景找不到可靠的替代方案。

TLS 带给 SIMD 单元和 GPGPU 的难题

其中一个问题是在大型 C++ 程序中会有大量的 TLS

数据,其中很多数据项都会配有构造器和析构器。如果仅仅只是为了几十微秒的 SIMD 计算而需要耗费数毫秒的时间来开辟和构造数兆的 TLS

数据的话,这肯定是得不偿失的。SIMD 开发者因此让 TLS 访问指向包含的线程,这样自然带来了可怕的数据竞争问题。尽管 GPGPU

计算比 SIMD 单元所需的时间段长,上述问题依然存在。

此外, GPGPUs 会创建大量的线程,这就意味着满载运行的 GPGPU

关联的线程本地存储开辟的内存在某些情况下是过剩的。

鉴于其可能引入的臭名昭著的数据竞争问题,我们应该去寻求一些替代方案。我们将在下节中解决这个问题。

TLS 能够与 SIMD/GPGPU 和谐共处么?

熟话说 “如果会受伤,就不要那么样做!”,那么就应该直接禁止 SIMD 单元和 GPGPU 访问 TLS

数据,比方说规定访问 TLS 数据是不可知的行为。然而考虑到 TLS

的引用场景如此广泛,这一方案显然不能令人满意,也是短视的。errno 就是一个问题,它被用于很多库函数中:我们要么修改所有用到

errno 的 API,要么限制 SIMD 和 GPGPU 只调用那些没有使用 errno 的 STL(标准模板库)库函数。但是

STL 中内存分配器内部调用的是 C 标准库函数 malloc(),它大量使用了

errno。这样的话,上述限制就过度了,你不得不对所有用到内存分配器的 STL 容器专门定制出一个完全不适用 errno

的内存分配器,此外还得禁用所有会分配未初始化内存的 STL 算法。

在 n3487 working paper 中,Pablo Halpern 建议分别在 std::thread

级别,任务管理级别,工作线程级别(它作为任务执行的环境)使用不同形式的 TLS 数据。同样的方式在 SIMD 和 GPGPU

中或许可行。

另一种方案只是简单的记录这个问题,例如,通过提供每个通道(SIMD)和每个硬件线程(GPGPU)的TLS的方式,但是,提供大量的TLS,特别是包含构造函数的TLS,不会减缓速度.不幸的是,大型和复杂的程序排除了SIMD和GPGPU的使用,而存有争论的是,这些大多数需要加速的程序,却常常涉及到SIMD和GPGPU.

简单用每个通道(SIMD)或硬件线程(GPGPU)初始化数据,并运行构造函数的选择,在一些情况下,可能工作的极为顺畅.然而,对于大型的,拥有兆级别TLS(特别是对于短的SIMD代码段)的程序而言,内存带宽仍然是一个问题.此外,构造函数常常包含到内存分配器和其它库函数的调用,或者可能未安装好的硬件运行的系统调用.处理硬件未安装而运行的操作的常见策略是,委派此操作给std::thread,而它只是重新指出瓶颈所在.

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值