线程对性能的影响

线程对性能的影响

随着多核芯片的广泛使用,线程是提升性能的首选方案。适当提升一点线程数会很好;事实上,拥有太多线程可能会使程序陷入困境。过多的线程主要对两个方面有影响:

  • 首先,在太多线程之间划分固定数量的工作会使每个线程分到的工作太少,以至于启动和终止线程的开销会影响有用的工作。
  • 其次,运行太多线程会因为共享有限硬件资源,从而导致产生过多的开销。

区分软件线程和硬件线程很重要。软件线程是程序创建的线程。硬件线程是真实的物理资源。芯片上每个内核可能有一个硬件线程,或者更多,例如英特尔超线程技术。当软件线程多于硬件线程时,操作系统通常采用循环调度。每个软件线程都会在硬件线程上运行,称为时间片。当时间片用完时,调度程序挂起该线程并允许下一个线程等待其在硬件线程上运行。时间切片可确保所有软件线程都取得一些进展。否则,某些软件线程可能会占用所有硬件线程并使其他软件线程资源匮乏。但是,硬件线程的公平分配会产生开销:

  • 最明显的开销是在挂起线程时保存线程的寄存器状态,并在恢复时恢复状态。您可能会对现代处理器的状态感到惊讶。但是,调度程序通常会分配足够大的时间片,因此保存/恢复开销无关紧要,因此这种明显的开销实际上并不是很重要。
  • 时间切片的一个更微妙但重要的开销是保存和恢复线程的缓存状态,可以是兆字节。现代处理器严重依赖于高速缓冲存储器,其速度可比主存储器快10到100倍。在缓存中访问的访问不仅快得多; 它们也不消耗内存总线的带宽。缓存很快,但有限。当缓存已满时,处理器必须从缓存中逐出数据,为新数据腾出空间。通常,是选择最近最少使用的数据,其通常是来自较早时间片的数据。因此,软件线程倾向于驱逐彼此的数据,而来自太多线程的缓存可能会损害性能。在不同的级别上,类似的开销也在虚拟内存上发生。大多数计算机使用虚拟内存 虚拟内存驻留在磁盘上,经常使用的部分保存在实际内存中。与缓存类似,最近最少使用的数据在需要腾出空间时从内存驱逐到磁盘。每个软件线程都需要其堆栈和私有数据结构的虚拟内存。与缓存一样,时间切片会导致线程相互争夺真实内存,从而损害性能。在极端情况下,可能有很多线程,程序甚至会耗尽虚拟内存。
  • 当持有锁的线程的时间片到期时,会出现另一个问题。等待锁的所有线程现在必须等待保持线程获得另一个时间片并释放锁。如果锁实现是公平的,则问题更严重,其中锁是以先到先得的顺序获取的。如果等待的线程被挂起,那么在它后面等待的所有线程都被阻止获取锁。这就像有人在结账时睡着了。没有硬件线程来运行它们的软件线程越多,这就越有可能成为问题。

组织线程

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

可运行的线程,而不是被阻塞的线程,会导致时间切片开销。当线程阻塞外部事件(例如鼠标单击或磁盘I / O请求)时,操作系统将其从循环计划中取消,因此该线程不再产生时间分片开销。程序可能拥有比硬件线程更多的软件线程,并且如果大多数软件线程被阻止,它们仍然可以高效运行。

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

如何计算并发线程数

Nthreads=NcpuUcpu(1+w/c)N_{threads} = N_{cpu}U_{cpu}(1+w/c),其中:

  • NcpuN_{cpu}: CPU核心数
  • UcpuU_{cpu}: cpu使用率,0~1
  • w/cw/c: 等待时间与计算时间的比率

IO密集型

一般情况下,如果存在IO,那么肯定 w/c>1w/c>1 (阻塞耗时一般都是计算耗时的很多倍),但是需要考虑系统内存有限(每开启一个线程都需要内存空间),这里需要上服务器测试具体多少个线程数适合(CPU占比、线程数、总耗时、内存消耗)。如果不想去测试,保守点取1即,Nthreads=Ncpu(1+1)=2NcpuN_{threads}=N_{cpu}*(1+1)=2N_{cpu}。这样设置一般都OK。

计算密集型

假设没有等待w=0,则 w/c=0w/c=0Nthreads=NcpuN_{threads}=N_{cpu}

对于计算密集型的任务,在拥有N个处理器的系统上,当线程池的大小为N+1时,通常能实现最优的效率。(即使当计算密集型的线程偶尔由于缺失故障或者其他原因而暂停时,这个额外的线程也能确保CPU的时钟周期不会被浪费。)

基于任务的编程

因为最有效数量的计算线程取决于特定的硬件,所以根据线程进行编程可能是多线程编程的一种不好的方法。通常根据逻辑任务而不是线程来设计程序会更好,让任务调度程序负责将任务映射到线程上。任务与逻辑线程的关键优势在于任务比逻辑线程重量轻得多。这是因为线程拥有自己的许多资源副本,例如寄存器状态和堆栈。在Linux上,线程甚至有自己的进程ID。相反,任务通常是一个小任务,不能在任务级别被抢占。它只能通过抢占运行它的软件线程来抢占(java 上的runnable和thread)。

另一个改进是优先级的调整。如前所述,线程调度程序通常会公平地分配时间片,通常情况下,如果没有特殊的设置,它是最安全的策略。在基于任务的编程中,任务调度程序会根据优先级进行调度。实际上,为了减少内存消耗,在极端情况下,直到有可以利用的线程,才会启动任务。

调度程序执行负载平衡,在线程间分发工作以使它们保持繁忙。要达到良好的负载平衡可能很棘手,因为细微的缓存,分页和中断效果可能会导致某些线程比其他线程更早完成,即使分发相同的工作时也是如此。在基于任务的编程中,将程序分解为许多小任务,并让调度程序向线程发出任务以使它们保持忙碌状态。

一个基于任务的编程的例子,就是java的fork-join线程池,通过

  • 每个线程有自己的任务队列
  • 分治法拆分任务
  • job窃取设计

实现了线程的高效利用。

参考