ISO/IEC JTC1 SC22 WG21 N4324 - 2014-11-20
Paul E. McKenney, paulmck@linux.vnet.ibm.com
JF Bastien, jfb@google.com
TBD
尽管 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 的一些挑战,最终提供若干可能的解决方案。
本次调查包括了 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 的替代方案被提了出来,包括使用函数调用堆栈,把状态通过传参给一个专门的函数,在这个函数里通过另一个某种形式的线程 ID 的函数来求得一个与当前线程一一对应的(全局)数组里的位置,进而存放状态。尽管这些方案在某些特定情况下非常有用,但是他们仍然还无法全面的取代 TLS 的角色。
当 TLS 数据的生命周期不超过其对应的栈帧(stack frame)的生命周期时,上述提到的函数调用堆栈确实是一个优秀的(也已经被广泛的使用)替代方案。但是当 TLS 数据的寿命需要超过该栈帧的寿命时,此方案就无能为力了。
生命周期问题在某些情况下是可以克服的:通过在一个长生命期的(即靠近栈底的)栈帧中开辟 TLS 数据,并把它的地址传参给所有需要访问它的函数。但是这些被传递进去的地址还是容易出问题,特别是当他们被传递给某些库函数时(译者注:这些库函数可能会保存这些地址,而在数据寿命终结之后还企图访问他们)。这类 TLS 数据的传递也严重违反了模块化原则。
使用一个数组,使用某种函数将每个线程的 ID 一一映射到该数组的每个元素上,通过这种方法能提能提供线程本地数据的存储,但是这样的设计有些很严重的缺陷。比方说:如果要静态的分配这个数组,那么就得实现确定程序所需的线程的数量,情况往往并非如此。当然对此类不确定性的常见应对就是超量供应(估计出可能的线程数上限,然后预先开辟出足够的空间来),这自然会导致内存浪费。软件工程的模块化需求会导致出现很多这样的数组,加上对性能和可升缩性的考虑,他们要求数组元素的在内存布局上需要良好的对齐与填充,这将导致更多的内存浪费。此外,用数组索引其他数组(需要的额外的查找,跳转之类的操作)明显慢于 TLS 访问。
总而言之,尽管已经出现了一些方案,他们在某些场合能够可靠的替代 TLS,但是仍然还有大量的 TLS 的应用场景找不到可靠的替代方案。
其中一个问题是在大型 C++ 程序中会有大量的 TLS 数据,其中很多数据项都会配有构造器和析构器。如果仅仅只是为了几十微秒的 SIMD 计算而需要耗费数毫秒的时间来开辟和构造数兆的 TLS 数据的话,这肯定是得不偿失的。SIMD 开发者因此让 TLS 访问指向包含的线程,这样自然带来了可怕的数据竞争问题。尽管 GPGPU 计算比 SIMD 单元所需的时间段长,上述问题依然存在。
此外, GPGPUs 会创建大量的线程,这就意味着满载运行的 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,而它只是重新指出瓶颈所在.
本文中的所有译文仅用于学习和交流目的,转载请务必注明文章译者、出处、和本文链接。 2KB翻译工作遵照 CC 协议,如果我们的工作有侵犯到您的权益,请及时联系我们。2KB项目(www.2kb.com,源码交易平台),提供担保交易、源码交易、虚拟商品、在家创业、在线创业、任务交易、网站设计、软件设计、网络兼职、站长交易、域名交易、链接买卖、网站交易、广告买卖、站长培训、建站美工等服务