设为首页收藏本站

LUPA开源社区

 找回密码
 注册
文章 帖子 博客
LUPA开源社区 首页 业界资讯 技术文摘 查看内容

线程本地存储的使用场景

2014-12-28 14:43| 发布者: joejoe0332| 查看: 1050| 评论: 0|原作者: 泥牛, gones945|来自: oschina

摘要: 尽管 TLS(线程本地存储)技术早在数十年以前就被用来轻松的解决顽固的并发问题,但仍然有许多人质疑它的实用性,就在 UIUC(美国伊利诺伊大学香槟分校)于前不久举行的 2014 年度 C++ 标准委员会会议上,这种质疑再 ...

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 的一些挑战,最终提供若干可能的解决方案。

  

TLS 的常用场景

  本次调查包括了 Linux 内核中 CPU 本地变量(per-CPU variables),因为他们的使用方式和 TLS 十分相似。内核中一共有 500 多个静态分配的 CPU 本地变量,还有 100 多个是动态分配的。


  TLS 最常见的应用也许就是统计计数了吧。一般方法是将计数器变量分散到所有线程(CPU 或者其他什么地方)中。当要更新计数时,每个线程都只更新自己的那一份。当要读取计数时,就必须把所有的线程里的计数器的值累加起来。对于偶尔的从频繁更 新的事件中收集统计信息这类常见情况来说,上述做法提速明显(译者注:因为大量频繁的写入操作都不再受竞争条件的阻碍,直接写入,线程阻塞的频率大大降 低)。“并行编程很难么,如果是这样,那我们该如何应对呢?” 的计数章节详细讨论此类方案的多个变种,其中的一些实现不仅提供了快速更新的特性,还加速了读取操作。尤其是当你创建了好几十个线程,每个线程都在处理一连串的短周期任务时,此类方案的应用变得极其重要。比如,内核中的网络模块里就经常出现这样的需求。


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

  TLS 还常常被用来实现内存分配器的线程本地缓存机制, 对 1993 年发表在 USENIX 上的关于内存分配的文章的修正 对此有详细论述,并在 tcmallocjemalloc 的实现中被采用。在此方案中,每个线程都维护着一个最近释放了的的内存块缓冲池(译者注:这些内存块并非真的被内核内存模块回收,他们依然还属于当前进程 占用的资源,他们只是被线程标记为“空闲”而已),这样下次需要分配内存时就可以直接从缓冲池里取出“空闲”的内存块来使用(而不用再通过系统调用向内核 申请内存了),从而避免了耗时耗力的同步操作和缓冲失效的情况。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,而它只是重新指出瓶颈所在.


  在这种情况下,构造函数和析构函数的开销是主要问题,这可能值得考虑提供TLS存储,但是简单的拒绝运行任意的non-trivial构造函数或析构函数,可能导致一个判断,即是否一个带有non-trivial构造函数的TLS变量被使用过


  "不运行non-trivial构造函数"的少一 些偏激的方案是,用std::thread关联TLS变量的生存周期,这样,与指定的SIMD通道或GPGPU线程相对应的non-trivial构造函 数,只在程序启动时运行.同时,分配到SIMD通道和GPGPU线程的连续代码段,可以重复使用此变量.更常见的是,如果有一些不同级别的执行代理,则分 离所有权和生命周期的考量就可能变得合情合理,这样,一个指定的TLS变量集合,在指定的时间,归属于指定级别的执行代理.但是这些变量的生命周期则由低 级别的执行代码设定,例如,通过一个隐含的std::thread来设定。


  另一个选择表明的实际情况是,传递到SIMD通道或GPGPU线程的代码片段,只使用了TLS 数据的极小部分.另外,代码通常必须分别为SIMD通道和GPGPU线程单独生成,这可能意味着,各个TLS偏移不需要为这些std::thread保持 相同.这表明,转移到SIMD或GPGPU的代码片段,只占据那些它们自身实际所使用的TLS数据项.在大多数情况下,只是极小百分比的数据项实际被使 用,这个百分比实际常常为0%.


  同样的策略可能也会被应用到std::thread.在某些情况下,TLS偏移可能需要保持不变,但是在开始和结束范围内的TLS数据项可能会被安全的删除,同时,构造函数可能只运行在那些实际被使用的TLS数据项之上.


  当然,这个策略也不是完全没有问题,下面是其中的一些: 



1.某些TLS数据项可能会被其它线程所用。例如,C++的RCU实现可能使用构造函数来维护一个所有任务的链表,它可能会在过渡期的计算中使用.一个幼 稚的分析,可能忽略在链表中使用的TLS节点,因为它没有被rcu_read_lock()和rcu_read_unlock()所引用,这可能导致 RCU查找运行在SIMD单元和GPGUP的读临界区失败.以下是解决这个问题的一些可能的方式:

    1.如前面所说,但是使用属性或其它标注,来表明从一个TLS数据项到任意其它数据项可能的依赖.

    2.如前面所说,但是通过构造函数来实现这些依赖,代替使用属性或其它标注的方法.例如,由rcu_read_lock()和 rcu_read_unlock()所使用的TLS计数器的构造函数可以引用当前任务的链表节点,这样,强制它包含于TLS数据项的集合之中.

2.一个给定的库可能在头文件中包含内联函数,它们会编译为SIMD或者GPGPU代码,同时,单独编译的函数总是委派给关联的 std::thread.这个库会理所当然的认为,由内联函数更新的同样的TLS数据项,可以由对应的单独编译的函数访问,否则,可能失望之至.这在某些 情况下,可以通过转换相关的来自SIMD单元或GPGPU线程的TLS数据到std::thread的方式来处理,尽管远离TLS数据项的链表数据结构需 要特别的处理.这个问题存有争议,它不是一个真正的TLS数据问题,更可能是在一群有关系的执行代理间静默切换的征兆之一,这意味着切换是有迹可循的.背 着开发人者干的事情常常是问题的根源.

3.在为给定的库初始化大量TLS数据,或者委派库中所有函数的执行给std::thread之间,可能存在一些艰难的决定.或许,开发者可以通过注解的方式,来协助(或者,在这种情况下可能,阻碍)做决定的过程.


  另一个可能,是由Robert Geva提供的建议,即对于任意一个定义在循环之外的对象(包括TLS对象),对所有的循环来说,应当认为是公有的.这意味着,多个循环之中,任意一个尝试修改定义在循环之外的对象,都会被认为是未定义的行为.


  终极方案在于as-if法则.这个方案是,从SIMD单元或GPGPU卸载的代码,正在执行的时候,就像运行在封闭的std::thread上下文之中,这样,任意卸载的执行便对应到一个有效的std::thread执行.这里有些情况需要考虑:

    1.TLS数据,仅仅只是用作封闭的std::thread上下文之中,常规的非原子变量,而且,一个给定的TLS变量的所有使用都是有序的,例如,通过 依赖.在这种情况下,SIMD或者GPGPU代码必须遵从这些依赖,一如实际的情况,赋予SIMD代码生成和循环依赖之间长期存在的相互关系.

    2.TLS数据,仅仅只是用作封闭的std::thread上下文之中,常规的非原子变量,但是,一些给定的TLS变量的使用是无序的,例如,它们在给定 函数调用代码的不同实参中使用.在这种情况下,SIMD或GPGPU代码可以自由的重新组织访问,但是,如果是由不同的SIMD通道或GPGPU线程并行 访问的话,编译器将认为这个变量是,带有memory_order_relaxed访问的原子变量.当然,对于太大而不合适于一个机器字长的TLS变量而 言,这会产生"有意思"的结果.

    3.TLS数据的原子的,是由至少一个std::thread访问 和/或 更新的.在这种情况下,SIMD或GPGPU代码可以并行更新这个TLS数据.但是,只有采用了使用原子变量的原则--或者,至少,最终的程序运行时,就 像采用了这个原则.对于太大而不合适于一个机器字长的TLS变量而言,这依然会产生"有意思"的结果.在这种情况下,默认的 memory_order_seq_cst可能就不太便利了.

  SIMD厂商愿意暴露使用者代码主动提供未定义行为的事实,可能暗示着,这个方案被认为是受到限制的.


总结

  本文展示了TLS使用的一些情形,讨论了除TLS之外的一些替代选择,列举了使用TLS结合SIMD单元或GPGPU所面临的一些挑战,以及探讨了战胜这些挑战的一些可能的方法.


鸣谢

  这篇文章受益于Jens Maurer, Robert Geva, Olivier Giroux, Matthias Kretz, 以及 Hans Boehm的观点和评论.


酷毙

雷人

鲜花

鸡蛋

漂亮
  • 快毕业了,没工作经验,
    找份工作好难啊?
    赶紧去人才芯片公司磨练吧!!

最新评论

关于LUPA|人才芯片工程|人才招聘|LUPA认证|LUPA教育|LUPA开源社区 ( 浙B2-20090187 浙公网安备 33010602006705号   

返回顶部