多线程编程的目标与挑战

本文摘抄自《Java 多线程编程实战指南》核心篇 第二章小结

本章通过一些具体概念介绍了多线程编程的目标及其面临的挑战。

  • 单线程程序所进行的计算本质上是串行。多线程编程的目标是将原本串行的计算改为并发乃至并行。

  • 竞态(Race Condition)是指计算的正确性依赖于相对时间顺序(Relative Timing)或者线程的交错(Interleaving)。竞态表现为计算的结果时而正确时而错误,它并不意味着计算的结果一定是错误的,其往往伴随着读脏数据/丢失更新的问题。竞态是访问(读取/更新)同一组共享变量的多个线程所执行的操作相互交错(Interleave)而导致的干扰(读取脏数据)或者冲突(丢失更新)的结果。二维表分析法是分析和解释竞态的有效和常用工具。一个类能够导致竞态,那么它就不是线程安全的。线程安全意味着不存在竞态,但是不存在竞态却未必意味着线程安全。

  • 线程安全问题表现为原子性/可见性和有序性这三个方面。这几个方面既相互区别,又相互联系。原子性的保障能够消除竞态。可见性描述了一个线程对共享变量的更新对于另外一个线程而言是否可见,或者说什么情况下可见的问题。原子性和可见性一同得以保障了一个线程能够共享变量的相对新值,而不是一个“半成品”的值。有序性描述了一个处理器上运行的一个线程对共享变量所做的更新,在另外一个处理器上运行的其他线程看来,这些线程是以什么样的顺序观察到这些更新的问题。可见性是有序性的基础,而有序性又可能影响可见性。

  • 原子操作是“不可分割”的操作。所谓“不可分割”包括两层含义:其一,访问(读/写)某个共享变量的操作从其执行线程以外的任何线程来看,该操作要么已经执行结束,要么尚未发生,即其他线程不会“看到”该操作执行了部分的中间效果;其二,访问同一组共享变量的原子操作是不能够被交错的,这通常意味着互斥(Mutual Exclusion),即对于访问同一组共享变量的多个原子操作,一个线程执行其中一个操作的时候其他线程无法访问这组共享变量中的任意一个变量。将 read-modify-write 操作和 check-then-act 转换为原子操作能够消除竞态。在 Java 语言中,对 long/double 型以外的任何变量的写操作都是原子的。volatile 关键字修饰的 long/double 型写操作也具有原子性。针对任何变量的读操作都是原子操作。

  • 可见性问题不是必然出现的,而一旦出现则可能导致灾难性后果。导致可见性问题的因素既有软件因素(JIT 编译器)也有硬件因素(处理器和内存等存储设备)。可见性的保障仅仅意味着一个线程能够读取到共享变量的相对新值,而不能保障更新对这个子线程可见,子线程执行期间对共享变量所做的更新对该线程的 join() 执行线程可见(从 join() 返回处开始才是可见的)。

  • 编译器/处理器/存储子系统(写缓冲器和高速缓存等)和运行时(JIT 编译器)都可能导致重排序。重排序是出于性能的需要并在满足“貌似串行语义”的前提下进行的,它可能导致线程安全问题。于可见性问题类似,重排序也不是必然出现的。有序性的保障是通过部分地从逻辑上禁止重排序实现的。可见性是有序性的基础,而有序性反过来又可能影响可见性。

  • 上下文切换可以被看作多线程编程的必然产物,一方面它使得充分利用极其有限的处理器资源成为可能;另一方面它也增加了系统的开销。因此,多线程编程未必比单线程的计算效率要高。程序运行过程中发生的上下文切换既有自发性上下文切换,也有非自发性上下文切换。Linux 内核提供的 perf 命令可以帮助我们测量程序运行过程中发生的上下文切换的次数和频率。

  • 多线程程序可能由于资源稀缺性或者程序自身的错误和缺陷而一直处于非 RUNNABLE 状态,或者即使是处于 RUNNABLE 状态,但是其要执行的任务一直无法进展,即产生了活性故障。

  • 非公平调度策略是我们多数情况下的首选资源调度策略。其优点是吞吐率较大;缺点是资源申请者申请资源所需的时间偏差可能较大,并可能导致饥饿现象。公平调度策略适合在资源的持有线程占用资源的时间相对长或资源的平均申请时间间隔相对长的情况下,或者对资源申请所需的时间偏差有所要求的情况下使用。其优点是线程申请资源所需的时间偏差较小,并且不会导致饥饿现象;其缺点是吞吐率较小。

本章知识结构图

文章作者: DoubleFJ
文章链接: http://putop.top/2019/07/08/Multithreading-Chapter-Two/
版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 DoubleFJ の Blog