线程的性能问题

灿烂一生

共 7649字,需浏览 16分钟

 · 2021-03-13

在并发编程中我们要注意的线程风险主要有三个方面:安全性问题、活跃性问题和性能问题。其中安全性问题和活跃性问题我们在前面已经介绍过了,这里我们主要介绍下使用线程所带来的性能问题。

一、对性能问题的思考

1.为什么使用多线程

线程的最主要目的是提高程序的运行性能,使得程序更加充分的发挥系统的可用处理能力,从而提高系统的资源利用率。此外,线程还可以使程序在运行现有任务的情况下立即开始处理新的任务,从而提高系统的响应性。多线程所带来的优势如下所示。

  • 提高系统的吞吐率。多线程可以使得在一个进程中有多个线程进行并发操作。当一个线程由于 I/O 阻塞操作处于等待时,其他线程仍然可以执行其操作。
  • 提高响应性。在使用多线程编程的情况下,对于 web 应用程序而言,一个请求处理响应慢了并不会影响其他请求的处理。
  • 充分利用多核处理器资源。如今多核处理器的设备越来越普及,使用恰当的多线程有利于充分利用多核处理器的资源,从而避免了浪费资源。
  • 最小化对系统资源的利用。一个进程中多个线程可以共享其所在进程所申请的资源,因此使用多个线程相比于使用多个进程进行编程,节约了对系统资源的使用。
  • 简化程序的结构。线程可以简化复杂应用程序的结构。

2.多线程与性能问题

应用程序的性能可以采用多个指标来衡量,例如服务时间、延迟时间、吞吐率、可伸缩性以及容量等。其中一些指标如服务时间、等待时间用于衡量程序的“运行速度”,即某个指定的任务单元需要“多快”才能处理完成,属于时间维度。另一些指标如生产量、吞吐量用于程序的“处理能力”,即在计算资源一定的情况下,能完成“多少”的工作,属于空间维度

性能问题包含多个方面,例如服务时间过长、响应不灵敏、吞吐率过低、资源消耗过高或可伸缩性较低等。与安全性和活跃性一样,在多线程程序中不仅存在与单线程程序相同的性能问题,而且还存在由于使用线程而引入的其他性能问题。

在设计良好的多线程应用程序中,线程能提升程序的性能。但不管怎样,使用线程所带来的开销是不可避免的。在多线程程序中,当线程调度器临时挂起活跃线程并转而运行另一个线程时,就会频繁的出现上下文切换操作,这种操作将会带来极大的开销:保存和恢复执行上下文,丢失局部性,并且 CPU 时间将更多地花在线程调度而不是线程运行上。当线程共享数据时,必须使用同步机制,而这些机制往往会抑制某些编译器优化,使内存缓冲区中的数据无效,以及增加共享内存总线的同步流量。

二、多线程带来的开销

尽管我们使用多线程的目的是为了提升程序的整体性能,但是与单线程相关的方法相比,使用多线程也引入了额外的性能开销。造成这些开销的操作有:线程之间的协调同步、线程的上下文切换、线程的创建与销毁以及线程的调度等。如果过度的创建并使用多线程,那么这些多线程所带来的性能开销甚至可能会远超过由于提高吞吐量、响应性或者计算能力所带来的性能提升。因此,对于为了提升性能而引入的线程来说,并发所带来的性能提升必须超过并发导致的开销。这里我们了解下多线程情况下线程的上下文切换、内存同步以及阻塞所带来的开销。

1.上下文切换

上下文切换在某种程度上可以被看作多个线程共享同一个处理器的产物,它是多线程编程中的一个概念。这里我们所讲的上下文切换都是指线程的上下文切换。

1.1 上下文切换及其产生原因

在单处理器上,我们也可以通过使用多线程的方式实现并发,即一个处理器可以在同一时间段内运行多个线程,线程每次占用处理器所运行的时间称为时间片。单处理器上的多线程就是通过这种时间片轮转的方式实现的。时间片决定了一个线程可以连续占用处理器运行的时间长度。当一个进程中的一个线程由于其时间片用完或其自身的原因被迫或者主动暂停运行时,另外一个线程(可能是同一个进程或者其他进程中的线程)可以被操作系统的线程调度器选中占用处理器开始运行或继续运行。这种一个线程被暂停,即被剥夺处理器的使用权,另外一个线程被选中开始或者继续运行的过程就叫作线程上下文切换

相应地,一个线程被剥夺处理器的使用权而被暂停运行就被称为切出,一个线程被操作系统选中占用处理器开始或者继续其运行就被称为切入

由此可见,我们看到的连续运行的线程,实际上是以断断续续运行的方式使其任务进展的。该方式意味着在切入和切出的时候操作系统需要保存和恢复相应进程的进度信息,即要让操作系统记得在切入和切出的那一刻各个线程都执行到哪里了(如程序执行到一半的中间结果以及执行到了哪条指令)。这些需要保存的进度信息就被称为上下文,一般包含通用寄存器的内容和程序计数器的内容。在线程切出时,操作系统需要将其上下文保存到内存中,以便被该线程在下次占用处理器切入时能够在原先从基础上继续运行。在线程切入时,操作系统需要从内存中恢复对应线程的上下文,以便该线程在原来的基础上继续运行。

JVM 的角度来看,一个线程的生命周期在 RUNNABLE 状态与非 RUNNABLE 状态(BLOCKED、WAITING 和 TIMED_WAITING)中切换的过程就是一个上下文切换的过程。当一个线程的生命周期状态由 RUNNABLE 转换为非 RUNNABLE 时,可以称这个线程被暂停。线程的暂停就是相应状态被切出的过程,这时操作系统会保存相应线程的上下文,以便该线程稍后再次进入 RUNNABLE 状态时能够在之前执行进度的基础上继续进展。而一个线程的状态由非 RUNNABLE 状态进入 RUNNABLE 状态时,我们称这个线程被唤醒。一个线程被唤醒仅代表该线程获得了一个继续运行的机会,而并不代表其可以立刻占用处理器运行。因此,当被唤醒的线程被操作系统选中占用处理器继续运行的时候,操作系统会恢复之前为其保存的上下文,以便在此基础上继续运行。

1.2 上下文切换的分类及具体诱因

可以按照导致上下文切换的因素,可以分为自发性上下文切换和非自发性上下文切换。

1.2.1 自发性上下文切换

自发性上下文切换是指线程由于其自身因素导致的切出,从 JVM 的角度看,一个线程在其运行过程中执行下列任意一个方法都会都会引起自发性的上下文切换。

  • Thread.sleep(long millis)
  • Object.wait(long timeout)/wait(long timeout, int nanos)
  • Thread.yield()  具体是否执行上下文切换还得看线程调度器的心情
  • Thread.join()/Thread.join(long timeout)
  • LockSupport.park()

另外,线程发起 I/O 操作(阻塞式 I/O)或者等待其他线程持有的锁时也会导致自发性的上下文切换。当阻塞式 I/O 完成时,硬盘会用一种中断机制来通知 CPU。

1.2.2 非自发性上下文切换

非自发性的上下文切换是指线程由于线程调度器的原因被迫切出。导致非自发性的上下文切换的因素有:切出线程的时间片被用完或者有优先级更高的线程需要执行。从 JVM 的角度看,JVM 在 GC 过程中也可能导致非自发性的上下文切换。这是因为垃圾收集器在执行 GC 过程中可能需要停止所有线程(STW,stop the world)才能完成工作。

1.3 上下文切换的开销和测量

一方面,上下文切换是必要的,就算是在多核处理器中上下文切换也是必要的,因为一个系统上运行的线程数量相对于该系统上所拥有的处理器数量是大很多的。另一方面,上下文切换带来的开销也是巨大的。

1.3.1 定性角度

  • 直接开销
    • 操作系统保存和恢复上下文所需的开销,主要是处理器时间开销
    • 线程调度器调度线程的开销
  • 间接开销
    • 处理器高速缓存重新加载的开销。一个被切出的线程可能之后在另一个处理器被切入,因为这个处理器可能之前没有运行过该线程,那么该线程运行过程中所需的变量仍然需要被该处理器从主内存或者通过缓存一致性协议从其他处理器加载到高度缓存中,这也是有一定的时间消耗的。
    • 上下文切换可能导致整个一级高速缓存中的内容被冲刷到下一级高速缓存或者主内存中。

1.3.2 定量角度

从定量的角度来看,一次线程上下文切换的时间消耗是微秒级的。线程的数量越多,它可能导致的上下文切换的开销也就越大,计算效率也就越低。在 Windows 上我们可以用自带的工具 perfom (C:\Windows\System32\perfom.exe) 来监视 Java 程序在运行过程中的上下文切换情况。

2.内存同步

同步操作的性能开销包括多个方面。在 synchronized 和 volatile 提供的可见性保证中可能会使用一些特殊指令,即内存屏障指令。内存屏障可以刷新缓存,使缓存无效,刷新硬件的写缓冲区。内存屏障也可能同样会对性能带来间接的影响,因为它们将抑制一些编译器优化操作。在内存屏障中,大多数操作是不能被重排序的。

JVM 也会通过一些优化来去掉一些不会发生竞争的锁,从而减少不必要的同步开销。如果一个锁对象只能由当前线程访问,那么 JVM 就可以通过优化来去掉这个锁的获取操作,因为另一个线程无法与当前线程在这个锁上发生同步,这个编译器优化被称为锁消除。除此之外,JVM 也会执行锁粗化的操作,将邻近的同步代码块用同一个锁合并起来,不仅减少了同步的开销,还能使优化器处理更大的代码块,从而可能实现进一步的优化。

3.阻塞

当多线程情况下在锁上发生竞争时,竞争失败的线程会进入“阻塞状态”。JVM 在实现“阻塞行为“时,可以通过自旋等待的方式直到成功获取到锁或者通过操作系统挂起被阻塞的线程。这两种方式的效率高低,取决于上下文切换的开销以及在成功获取锁之前需要等待的时间。如果等待的时间比较短,则可以采用自旋等待的方式,而如果等待的时间较长,则适合采用将等待线程挂起的方式。

当线程无法获取到锁或者在进行 I/O 操作阻塞时,需要被挂起,在此过程中会包含两次额外的上下文切换以及必要的操作系统操作和缓存操作:被阻塞的线程在其执行时间片还未用完之前就被挂起切出,而在随后当要获取锁或者其他可用资源时,又再次切入回来。由于锁竞争导致的阻塞时,线程在持有锁时会有一定的开销,当它释放锁时,必须告诉操作系统恢复运行被阻塞的线程。

三、降低多线程的开销

1.减少锁的竞争

1.1 无锁的算法与数据结构

既然使用锁会带来性能问题,那么最好的方案就是使用无锁化编程,在这方面有很多相关的技术,如线程本地存储(Thread Local Storage,TLS)、写时复制(Copy-on-write)、乐观锁等;J.U.C 包中的原子类就采用了无锁的数据结构,底层使用了处理器提供的 cmpxchg 指令;Disruptor 是一个无锁的内有界存队列,在优化并发性能方面可谓是做到了极致。

1.2 减少锁的持有时间

互斥锁本质上是将并行的程序串行化,所以要增加并行度,一定要减少锁的持有时间。

  • 缩小锁的范围:将一些与锁无关的代码移出同步代码块,尤其是那些开销较大的操作以及可能被阻塞的操作减少锁的代码块范围来减小锁的持有时间。

  • 使用细粒度的锁:如通过锁分段技术,将采用多个相互独立的锁来保护独立的状态变量,从而改变这些变量在之前由单个锁来保护的情况。这些技术能减小锁操作的粒度来减少锁的持有时间。锁分段的一个劣势:与采用单个锁来实现独占访问相比,要获取多个锁来实现独占访问将更加困难并且开销更高。通常,在执行一个操作时最多只需要获取一个锁,但在某些情况下需要加锁整个容器,ConcurrentHashMap 就是采用了所谓分段锁的技术。

2.减少上下文切换的开销

在服务器应用程序中,发生阻塞的原因之一就是在处理请求时产生各种日志消息。下面会通过对两种日志方法的调度进行分析来说明如何通过减少上下文切换的次数来提高吞吐量。

2.1 日志操作方法

大多数日志框架都是简单地对 println 进行包装,当需要记录某个消息时,只需要将其写入日志文件中。其他方法也有,如记录日志的工作由一个专门的后台线程完成,而不是由发出请求的线程完成。从开发人员角度看,这两种方法基本上是差不多的,但二者在性能上可能存在差异,这取决于日志操作的工作量,即打印日志线程的数量等其他一些因素,例如上下文切换的开销等。

2.2 日志操作的开销

日志操作的服务时间包括处理 I/O 流的计算时间,如果 I/O 操作被阻塞,那么也包含线程被阻塞的时间。操作系统会把这个被阻塞的线程从调度队列中移走,直到 I/O 操作执行结束,这会消耗比实际阻塞更长的时间。I/O 操作执行结束的时候,当前的处理器可能在执行其他线程的调度时间片,对于被阻塞的线程,在调度队列中,也可能会有其他线程在其之前,从而进一步增加服务时间。如果有多个线程在同时记录日志,为了使得日志按顺序被记录,在输入流上也会有锁竞争的开销。该情况与 I/O 阻塞一样,线程的加锁操作也会导致上下文切换的次数增多,以及服务时间的增加。

2.3 降低日志操作的开销

  • 降低请求服务时间

服务时间会影响服务质量,服务时间越长,意味着有程序在获得结果时需要等待更长的时间,也意味着存在着更多的锁竞争。因为锁被持有的时间越长,那么在这个锁上发生竞争的可能性就越大。如果一个线程由于等待 I/O 操作而阻塞,同时它还持有这个锁,那么在等待的期间可能会有另外一个线程也想要获得这个锁,从而再次进入等待。如果在大多数获得锁的操作中不存在竞争,那么该并发系统的执行效率就越高,因为获取锁的竞争意味着会发生更多的上下文切换。上下文切换的次数越多,吞吐量就越低。

  • 分离日志操作

通过将 I/O 操作从处理请求的线程中分离出来,可以缩短处理请求的平均服务时间。在调用 log 方法时将不会因为等待输出流的锁或者 I/O 完成而被阻塞,只需要将相关操作放入消息队列中进入处理,然后返回各自原来的任务即可。虽然在消息队列中可能发生竞争,但该操作相对于记录日志的 I/O 操作来说是一种更轻量级的操作,因此在实际使用过程中只要队列没有被填满,则发生阻塞的概率很小。通过把 I/O 操作从处理请求的线程转移到一个专门的线程,不仅使得处理请求的线程被阻塞的概率降低,还消除了输出流上的竞争,将会提升整体的吞吐量。因为该操作使得线程在调度中消耗的资源更少,上下文切换的次数也更少,而且对于锁的管理也更为简单了。

3.设置线程的最佳数量

提升性能意味着使用更少的资源做更多的事情,“资源”的定义和广泛,对于一个给定的操作,通常会缺乏某种特定的资源,如 CPU 时间片、内存、网络带款、I/O 带宽、数据库请求以及磁盘空间等。当操作性能由于某种特定的资源而收到限制时,我们通常将该操作称为资源密集型操作,例如 CPU 密集型、I/O 密集型、数据库密集型。

根据多线程具体的应用场景来选择合适的线程数量。我们的程序一般都是 CPU 计算和 I/O 操作交叉执行的,因为 I/O 设备的速度比起 CPU 来说是很慢的,所以大部分情况下,I/O 操作执行的时间相对于 CPU 计算时间来说都是漫长的,这种场景一般被称为 I/O 密集型,和 I/O 密集型相对的就是 CPU 密集型了,CPU 密集型大部分情况下都是纯 CPU 计算。对于 I/O 密集型和 CPU 密集型的程序,计算最佳线程的方法是不一样的。

3.1 CPU 密集型

对于 CPU 密集型,多线程的本质是提升多核 CPU  的利用率,所以对于一个 2 核的 CPU 来说,一般是每个核一个线程,理论上只需要创建 2 个线程就可以发挥 2 核 CPU 的最大处理能力了,再创建线程也只是增加线程切换的成本。因此,对于 CPU 密集型的场景,理论上“线程的数量=CPU 核数”是最合适的,不过在项目中,线程的数量一般会被设置为 CPU 核数 + 1,这样的话,当线程因为偶尔的内存页失效或其他原因导致阻塞时,第三条线程可以顶上,保证 CPU 的利用率。

3.2 I/O 密集型

对于 I/O 密集型的场景,假设 CPU 只有一个核,如果 CPU 计算和 I/O 操作的耗时是 1:1,那么 2 个线程是最合适的。如果 CPU 计算和 I/O 操作的耗时是 1:2,那么 3 个线程是最合适的,CPU 在 A、B、C 三个线程之间切换,每次执行保证有一条线程正在执行 CPU 计算,其他两条在执行 I/O 操作。

通过以上例子,可以发现,对于 I/O 密集型的计算场景,最佳的线程数量是与程序中的 CPU 计算和 I/O 操作的耗时比相关的,可以总结得出下列这个公式:

最佳线程数 = 1 + (I/O 耗时 / CPU 耗时)

针对单核 CPU,令 R = I/O 耗时 / CPU 耗时,我们可以这样理解:当一个线程在执行 CPU 操作时,另外的 R 个线程都正好在执行各自的 I/O 操作部分。因为单核 CPU  的话每一时刻只能有一条线程在执行 CPU 计算,其余线程都在执行 I/O 计算,这些其余线程的数量为 I/O 耗时 / CPU 耗时,也就 R,所以最佳线程数就是 1 + R。

针对多核 CPU,只需要等比扩大就行了,计算公式如下:

最佳线程数 = CPU 核心数 * [1 + (I/O 耗时 / CPU 耗时)]

3.3 理论 pk 经验

设置合适的线程数量目的就是为了将硬件的性能发挥到极致,其实最佳线程数最终还是要通过压测来确定的。实际工作中面临的系统,“I/O 耗时 / CPU 耗时”往往都大于 1,所以基本都是在这个初始值的基础上增加。在增加的过程中,应关注线程数是如何影响吞吐量和延迟的。一般来说,随着线程数量的增加,吞吐量会增加,延迟也会缓慢增加;但是当线程增加到一定程度时,吞吐量就会开始下降,延迟会迅速增加,此时基本上就是线程能够设置的最大值了。

实际工作中,不同 I/O 模型对最佳线程数的影响也非常大,如 Nginx 用的是非阻塞 I/O 模型,采用的是多进程单线程结构,Nginx 本来是一个 I/O 密集型系统,但是最佳线程数量设置的却是 CPU 核心数,完全参考的是 CPU 密集型的做法,因此,对于理论的最佳线程数设置,还是得根据实际情况来。

四、总结

一味的使用多线程并不一定能提高程序的性能,相反,由于使用多线程引入的额外性能开销甚至可能会远超过由于提高吞吐量、响应性或者计算能力所带来的性能提升。因此,遇到具体问题,还是得具体分析,根据特定场景选择的合适数据结构和算法、合适的线程数量等操作来提高程序的性能。

参考资料

《Java 并发编程实战》

《Java 多线程编程实战指南 核心篇》

极客时间《Java 并发编程实战》


往期精选

并发编程的理论基石

线程的启动与终止

线程的状态与属性

线程间通信 wait/notify

Thread 类的常用方法

线程的安全性分析

线程的活跃性问题


f8262809b37fff3f587bf9b5cc909aee.webp


浏览 2
点赞
评论
收藏
分享

手机扫一扫分享

举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

举报