jadestoner

导航

 

原文地址:https://www.codeguru.com/cpp/sample_chapter/article.php/c13533/Why-Too-Many-Threads-Hurts-Performance-and-What-to-do-About-It.htm

线程太多

线程是从多核芯片中提取性能的当前选择方法。似乎如果有一点线程是好的,那么很多线程必须更好。实际上,线程太多会使程序陷入瘫痪。本文讨论了为什么以及如何基于任务的编程可以避免该问题。英特尔®线程构建模块(Intel®TBB)任务计划程序就是一个示例。

线程过多的影响有两种。首先,在太多线程之间分配固定数量的工作会使每个线程的工作量太少,以至于启动和终止线程的开销浪费了有用的工作。其次,过多线程正在运行会从共享有限硬件资源的方式中产生开销。
区分软件线程和硬件线程很重要。软件线程是程序创建的线程。硬件线程是真正的物理资源。芯片上每个内核可能有一个或多个硬件线程,例如使用英特尔超线程技术。


当软件线程多于硬件线程时,操作系统通常求助于循环调度。每个软件线程都有一个短暂的时间片,称为时间片,可以在硬件线程上运行。当时间片用完时,调度程序将挂起线程,并允许等待该线程的下一个线程在硬件线程上运行。


时间分片可确保所有软件线程都取得一定进展。否则,某些软件线程可能会占用所有硬件线程而使其他软件线程饿死。但是,公平分配硬件线程会产生开销。开销有多种,这有助于了解罪魁祸首,以便您在罪魁祸首出现时将它们发现。


最明显的开销是挂起线程时保存线程的寄存器状态,并在恢复线程时恢复状态。您可能会惊讶于现代处理器上有多少状态。但是,调度程序通常会分配足够大的时间片,以至于保存/恢复的开销微不足道,因此,实际上,这个明显的开销并不是什么大问题。


时间切片的一个更微妙但显着的开销是保存和还原线程的缓存状态,该状态可能是兆字节。现代处理器严重依赖高速缓存,其速度可能是主内存的10到100倍。命中缓存的访问不仅更快。它们也不会占用内存总线的带宽。缓存速度快,但数量有限。当高速缓存已满时,处理器必须从高速缓存中逐出数据,以便为新数据腾出空间。通常,驱逐的选择是最近最少使用的数据,通常是来自较早时间片的数据。因此,软件线程趋向于驱逐彼此的数据,而由于线程过多而导致的缓存冲突可能会损害性能。


在不同级别上,类似的开销也影响了虚拟内存。大多数计算机使用虚拟内存。虚拟内存驻留在磁盘上,而经常使用的部分则保留在实际内存中。与高速缓存类似,在需要腾出空间时,会将最近最少使用的数据从内存逐出到磁盘。每个软件线程都需要为其堆栈和专用数据结构提供虚拟内存。与高速缓存一样,时间分片会导致线程相互争夺真实内存,从而损害性能。在极端的情况下,可能有太多的线程,导致程序甚至耗尽虚拟内存。


当持有锁的线程的时间片到期时,会出现另一个问题。现在,所有等待锁的线程都必须等待保持线程获得另一个时间片并释放锁。如果锁的实现是公平的,则问题更加严重,在这种情况下,锁是按照先到先得的顺序获取的。如果等待线程被挂起,则阻止在其后等待的所有线程获取该锁。这就像有人在结帐行中入睡。没有硬件线程来运行它们的软件线程越多,出现问题的可能性就越大。

 

组织线程

一个好的解决方案是将可运行线程的数量限制为硬件线程的数量,如果高速缓存争用是一个问题,则可以将其限制为外部级别高速缓存的数量。由于目标平台的硬件线程数量不同,因此请避免将程序硬编码为固定数量的线程。让您的程序的线程化程度适应硬件。

可运行的线程而不是阻塞的线程会导致时间分配开销。当线程在外部事件(例如,鼠标单击或磁盘I / O请求)上阻塞时,操作系统会将其从循环调度中删除,因此该线程不再产生时间分割开销。与硬件线程相比,程序可能具有更多的软件线程,并且如果其中大多数软件线程被阻止,程序仍然可以高效运行。

一个有用的组织原则是将计算线程与I / O线程分开。计算线程应该是大多数时候都可以运行的线程,并且理想情况下永远不要阻塞外部事件。计算线程的数量应与处理器资源匹配。 I / O线程是大多数时间都在等待外部事件的线程,因此不会导致线程过多。

 

基于任务的编程

由于最有效的计算线程数取决于特定的硬件,因此就线程而言进行编程可能是进行多线程编程的较差方法。通常最好是按照逻辑任务而不是线程来制定程序,并让任务调度程序负责将任务映射到线程上。本文的其余部分将以英特尔®TBB任务为例。

与逻辑线程相比,任务的主要优势是任务比逻辑线程轻得多。在Linux上,启动和终止Intel®TBB任务比启动和终止线程快大约18倍。在Windows上,该比率大于100。这是因为线程拥有自己的许多资源副本,例如寄存器状态和堆栈。在Linux上,线程甚至具有其自己的进程ID。相反,任务通常是一个小的例程,无法在任务级别抢先。只有抢占运行它的软件线程才能抢占它。

另一个改进是不公平的调度。如前所述,线程调度程序通常公平地分配时间片,因为这是最安全的策略,而无需了解程序的更高层次的组织。在基于任务的编程中,任务调度程序确实具有一些更高级别的信息,因此会牺牲公平性以提高效率。的确,为了减少内存消耗,通常甚至不开始执行任务,直到它们能够取得有益的进展为止。

调度程序进行负载平衡;也就是说,将工作分散到各个线程中,以使它们保持繁忙状态。良好的负载平衡可能很棘手,因为微妙的缓存,分页和中断效果可能导致某些线程比其他线程更早完成,即使分派了相当数量的工作。在基于任务的编程中,您将程序分为许多小任务,并让调度程序将任务发布给线程以使它们保持忙碌状态。

使用任务而不是线程的最大好处是编程更容易。基于线程的编程迫使您考虑在较低级别的硬件线程上获得良好的效率,因为每个硬件线程需要一个可运行的软件线程,而不是太多或太多。您还必须处理相对粗糙的线程。使用任务,您可以集中任务之间的逻辑依赖关系,而将高效的调度留给调度程序。

 

示例:对树求和

我们将以树上的值求和为例,因为它涉及一个通用的递归模式,该模式演示了任务库的基础。如果您不喜欢递归,请不要失望。英特尔®TBB具有高级算法模板,可隐藏递归并让您进行迭代查看。例如,库模板parallel_for进行并行迭代,模板parallel_reduce进行求和之类的约简。两者都在通用迭代空间上工作。但是,本文将深入研究为这些模板提供动力的任务计划程序的“内幕”,因为了解任务计划程序可让您处理算法模板之外的问题,甚至编写自己的算法模板。

清单1显示了用于对树进行递归求和的串行代码。字段node_count未使用,但由于在并行版本中是必需的而被声明。清单2显示了并行代码。与serial_sum_tree相比,它相对较大,因为它无需任何对标准C ++的语言扩展即可表达并行性。不依赖于语言扩展,可以简化与现有生产环境的集成。

清单2中的顶级例程parallel_sum_tree执行三个操作:

1、检查树是否很小,以至于串行执行会更快。如果是这样,请使用清单1中的serial_sum_tree。

2、否则,请使用继承的方法allocate_child()和重载的运算符new为每个非空子树创建一个子任务。将每个孩子放在列表中。

3、调用set_ref_count指示创建的子代数,再加上一个等待完成。任务计划程序使用非常轻量级的同步机制,该机制在每个孩子完成或等待时自动减少引用计数。

4、调用spawn_and_wait_for_all生成子任务并等待它们完成。

5、将最终的总和存储在* sum中

6、返回,这将隐式导致调度程序销毁并重新分配任务对象。在此示例中,返回值为NULL。在更复杂的用途中,它是指向下一个要运行的任务的指针。

在并行编程中,第一步是使用串行算法解决一个小问题。即使任务比线程轻,但与函数相比它们仍然有一些开销,因此对于小问题,使用串行函数更快。找到串行执行的理想阈值通常需要进行一些实验。较低的阈值会创建更多任务,因此会产生更多潜在的并行性。但是将任务设置得太小会导致任务管理的开销过大。在将要生成的任务多于线程的程序中,将阈值设置得过高不会有什么坏处,因为仍然有足够的潜在并行性来保持所有硬件线程的繁忙。

 

工作偷窃(Work Stealing)

乍一看,清单2中的并行性似乎受到限制,因为该任务最多创建两个子任务。这里的窍门是递归并行性。每个子任务都会创建更多的子任务,依此类推,直到达到小的子树为止。如果每个任务创建两个子任务,则第N个递归级别将创建2N个子任务。这提供了很多潜在的并行性。

诀窍是有效利用潜在的并行性。结构不良的任务池可能会导致性能下降。对于初学者来说,池可以成为集中的争用源。此外,池的结构会严重影响性能。让我们看两个极端来看看效果。

一种极端情况是使池成为先进先出队列,从而最大程度地提高了并行度,因为执行将倾向于首先遍历树的广度,如图1所示。当执行遍历树的每个级别时,执行次数将加倍。可用任务。缺点是它可能破坏高速缓存或虚拟内存,因为在某个时刻,树中的每个节点同时存在一个任务!这是自欺欺人的过度杀伤力,因为我们只需要足够的并行度就可以保持硬件线程繁忙。

另一个极端是使池成为后进先出堆栈。然后执行将倾向于首先遍历树的深度,如图2所示。现在,空间与树的深度成比例。此外,单线程上的缓存行为通常非常好,因为子代通常正在处理已由父代拉入缓存的数据。缺点是并行度被最小化。更糟糕的是,多个线程往往会互相干扰,因为每个线程都会抓取其他线程最近创建的任务,从而导致线程之间的缓存流量。、

总结起来,这里要解决三个问题:

1)避免出现中央瓶颈(Avoid having a central bottleneck)

2) 创建足够的并行度以使线程繁忙。(Create enough parallelism to keep threads busy)

3)保持合理的内存消耗。(Keep memory consumption reasonable)

解决这三个问题的现代选择方法是为每个线程分配自己的任务双端队列。因此,消除了对中央池的争用。每个线程将自己的双端队列视为后进先出堆栈。这样做可以获得深度优先遍历的空间和缓存效率。

但是并行性呢?当一个线程自己的双端队列为空时,该线程从另一个线程的双端队列窃取工作。牺牲线程的选择是随机的,因此不需要集中控制。此外,它将受害者的双端队列视作队列。也就是说,它窃取了最古老的任务。最终结果是,并行度会自动调节到足够高的水平,以使硬件线程保持繁忙,而不会导致过多的内存消耗。

“工作深度优先”的策略;广度优先”具有进一步的好处:它减少了线程之间的争用。最古老的任务往往是最接近树根的任务,因此,小偷要承担相对较大的工作。更好的是,被盗作品的数据通常离受害者的数据最远,因此与窃贼偷走了最年轻的任务相比,它倾向于减少对内存和锁的争用。

上述策略是由麻省理工学院的Cilk语言项目[2]开发的。它不仅适用于树,而且还适用于可以递归分为子问题的任何问题。英特尔®TBB使您能够在标准ISO C ++中使用此策略。

 

结论

拥有正确数量的线程对于多核性能至关重要。基于任务的编程使您可以根据逻辑任务编写程序,而任务计划程序负责选择何时何地运行这些任务。递归任务模式与工作窃取相结合,可以将并行性限制在正确的水平。英特尔®线程构建模块教程[1]更深入地研究了基于任务的编程以及其他功能,例如高级算法模板和并发容器。

进一步阅读

[1] Parts of this article were adapted with permission from the tutorial included in Intel® Threading Building Blocks.

[2] The Cilk home page is http://supertech.csail.mit.edu/cilk.

[3] Parts of this article were adapted with permission from Chapter 7 of Multi-Core ProgrammingIncreasing Performance through Software Multi-threading by Shameem Akhter and Jason Roberts, Intel Press, 2006. http://www.intel.com/intelpress/sum_mcp.htm.

*Other names and brands may be claimed as the property of others.

Figure 1: Breadth-first maximizes available parallelism, but maximizes memory consumption.

Figure 2: Depth-first minimizes memory consumption, but minimizes available parallelism.

  1. struct tree_node {
  2. tree_node* left; // Pointer to left subtree
  3. tree_node* right; // Pointer to right subtree
  4. unsigned node_count; // Number of nodes in this subtree
  5. value value; // Value associated with the node.
  6. };
  7.  
  8. value serial_sum_tree( tree_node* root ) {
  9. value result = root->value;
  10. if( root->left )
  11. result += serial_sum_tree(root->left);
  12. if( root->right )
  13. result += serial_sum_tree(root->right);
  14. return result;
  15. }

Listing 1: Serial code for summing values in a tree.

  1. class sum_task: public tbb::task {
  2. value* const sum;
  3. tree_node* root;
  4. public:
  5. sum_task( tree_node* root_, value* sum_ ) : root(root_),
  6. sum(sum_) {}
  7. task* execute() {
  8. if( root->node_count<1000 ) {
  9. // For small trees, use the serial code from Listing 1.
  10. *sum = serial_sum_tree(root);
  11. } else {
  12. value x, y;
  13. int count = 1;
  14. tbb::task_list list;
  15. if( root->left ) {
  16. ++count;
  17. list.push_back( *new( allocate_child() )
  18. sum_task(root->left,&x) );
  19. }
  20. if( root->right ) {
  21. ++count;
  22. list.push_back( *new( allocate_child() )
  23. sum_task(root->right,&y) );
  24. }
  25. // Argument to set_ref_count is one more than size of the
  26. // list, because spawn_and_wait_for_all expects an
  27. // augmented ref_count.
  28. set_ref_count(count);
  29. spawn_and_wait_for_all(list);
  30. *sum = root->value;
  31. if( root->left ) *sum += x;
  32. if( root->right ) *sum += y;
  33. }
  34. return NULL;
  35. }
  36. };
  37.  
  38. value parallel_sum_tree( tree_node* root ) {
  39. value sum;
  40. sum_task& a = *new(tbb::task::allocate_root())
  41. sum_task(root,&sum);
  42. tbb::task::spawn_root_and_wait(a);
  43. return sum;
  44. }

Listing 2: Parallel version of Listing 1, based on task-based programming.

posted on 2020-08-24 11:01  jadestoner  阅读(2311)  评论(0编辑  收藏  举报