线程是Java语言中不可或缺的重要功能,它能使复杂的异步任务变得简单。想要充分利用多核处理器的计算能力,就需要高效合理地使用线程。
1 并发简史
早期的计算机并无操作系统,它只执行一个程序,计算资源浪费且运行代价高昂。
操作系统出现后,计算机每次能运行多个程序,不同的程序都在单独的进程中运行。操作系统负责为每个独立执行的进程分配各种资源,包含内存、文件句柄以及安全证书等。不同进程之间可以通过一些粗粒度的通信机制来交换数据,如 套接字、信号处理器、共享内存、信号量以及文件等。
操作系统的出现可以解决一些问题:
- 资源利用率。某些情况下,程序必须等待某个外部操作执行完成,此刻若整个系统处于等待状态则显得浪费。
- 公平性。不同的用户和程序对于计算资源有同等的使用权,操作系统可以提供粗粒度的时间分片规则来平衡用户和程序对这些资源的使用。
- 便利性。复杂任务情况,多个程序同时计算比单一程序计算更快更方便。
早期的分时系统中,每个进程拥有存储指令和数据的内存空间,根据机器语言的语义以串行方式执行命令,并通过一组IO指令与外部设备通信。对每条被执行的指令,都有相应的“下一条指令”,程序中控制流是按照指令集的规则来确定的,当前,几乎所有的主流编程语言都遵循这种串行编程模型,并且在这些语言规范中都清晰定义了在某个动作完成之后需要执行的“下一个动作”。
串行编程模型的优势在于其直观性和简单性,因为它模仿了人类的工作方式:一次只做一件事情,在等待某件事情执行结束时,可以去做其他事情,如何平衡串行性与异步性,也是程序需要解决的事情。
这些促使进程出现的因素同时也促使线程出现,线程允许在同一个进程中同时存在多个程序控制流。线程会共享进程范围内的资源,例如局部变量等。线程还提供了一种直观的分解模式来充分利用多处理器系统中的硬件并行性,在同一个程序中的多个线程也可以同时调度到多个cpu上运行。
线程也被称为轻量级进程,在大多数现代操作系统中,都是以线程为基本的调度单位,而不是进程。如果没有明确的协同机制,那么线程将彼此独立执行,由于同一个进程中的所有线程都将要共享进程的内存地址空间,因此这些线程都能访问相同的变量并在同一个堆上分配对象,这就需要实现一种比在进程间共享数据粒度更细的数据共享机制。如果没有明确的同步机制来协同堆共享数据的访问,那么在当一个线程正在使用某个变量时,另一个线程可能同时访问这个变量,这将造成不可预测的结果。
2 线程的优势
使用得当的情况下,线程可以有效的降低程序的开发和维护等成本,同时提升复杂应用程序的性能。线程能够将大部分的异步工作流转换成串行工作流,因此能更好地模拟人类的工作方式和交互方式。线程还可以降低代码的复杂度,使代码更容易编写、阅读和维护。
在GUI应用程序中,线程可以提高用户界面的响应灵敏度,而在服务器应用程序中,可以提高资源利用率与系统吞吐率。线程可以简化JVM的实现,垃圾回收器通常在一个或多个专门的线程中运行。
2.1 发挥多处理器的强大能力
多核处理器是提高计算性能的主流方式。由于cpu的基本调度单位是线程,因此如果在程序中只有一个线程,那么最多同时只能在一个处理器上运行,对于多核系统来说,其他计算资源相当于大量浪费了。多线程程序可以通过提高处理器资源的利用率来提升系统吞吐率。
单处理器系统的多线程程序也可以提高系统吞吐率。比如某个IO等待操作在执行,其他线程此时可以使用CPU。
2.2 建模的简单性
通常来说,执行一种类型的任务比执行多种类型的任务要简单。如果需要完成多种类型的任务,需要管理不同任务之间的优先级和执行时间,并在任务之间进行切换,这将带来额外的开销。
如果程序中只包含一种类型的任务,那么比包含多种不同类型任务的程序要更易于编写,错误更少,也更容易测试。如果为模型中每种类型的任务都分配一个专门的线程,那么可以形成一种串行执行的假象,并将程序的执行逻辑与调度机制的细节,交替执行的操作,异步io以及资源等待等问题分离开。通过使用线程,可以将复杂并且异步的工作流进一步分解为一组简单并且同步的工作流,每个工作流在一个单独的线程中运行,并在特定的同步位置进行交互。
2.3 异步事件的简化处理
服务器应用程序在接受来自多个远程客户端的套接字连接请求时,如果为每个连接都分配其各自的线程并且使用同步IO,那么会降低这类程序的开发难度。
如果某个应用程序对套接字执行读操作而此时还没有数据到来,那么这个读操作将一直阻塞,直到有数据到达。在单线程应用程序中,这不仅意味着在处理请求的过程中将停顿,而且还意味着在这个线程被阻塞期间,对所有请求的处理都将停顿。为了避免这个问题,单线程服务器应用必须使用非阻塞io,这种io的复杂性要远远高于同步io,并且很容易出错,如果每个请求都有自己的处理线程,那么在处理某个请求时发生的阻塞将不会影响其他请求的处理。
早期的操作系统会将进程中可创建的线程数量限制在一个较低的阈值内,操作系统提供了一些高效的方法实现多路io,要调用这些方法,java提供了非阻塞io的程序包。
2.4 响应更灵敏的用户页面
如果在事件线程中执行的任务都是短暂的,那么界面的响应灵敏度就较高,因为事件线程能够很快的处理用户的动作。如果事件线程中的任务需要很长的执行时间,那么界面的响应灵敏度就会降低。如果讲这个长时间运行的任务放在一个单独的线程中运行,那么时间线程就能及时地处理界面事件,从而使用户界面具有更高的灵敏度。
3 线程带来的风险
3,1 安全性问题
在没有充足同步的情况下,多个线程中的操作执行顺序是不可预测的,甚至会产生奇怪的结果。
public class UnsafeSequence {
private int value;
public int getNext(){
return value ++;
}
}
UnsafeSequence 在单线程程序中能正确的执行,但是在多线程程序中可能得到不正确的值,因为value++包含3个独立的操作,读取value,value+1,计算结果保存到value。由于运行时可能多个线程之间交互运行,因此这两个线程可能同时执行读操作,从而使它们得到相同的值,并都将这个值+1。
上述情况说明了一种常见的并发问题,称为竞态条件(Race Condition),在多线程环境下,getValue是否会返回唯一的值,取决于运行时对线程中操作的交替执行方式。
由于多个线程要共享相同的内存地址空间,并且是并发运行,因此它们可能会访问或修改其他线程中正在使用的变量。当然,这是一种极大的便利,因为这种方式比其他线程间通信机制更容易实现数据共享。但它同时也带来了巨大的风险,线程会由于无法预料的数据变化而发生错误。当多个线程同时访问和修改相同的变量时,将会在串行模型中引入非串行因素,而这种非串行是很难分析的。要使用多线程的行为可以预测,必须对共享变量的访问操作进行协同,这样才不会在线程之间发生彼此干扰。
public class UnsafeSequence {
private int value;
public synchronized int getNext(){
return value++;
}
}
使用synchronized保证执行同步。
3.2 活跃性问题
安全性是“永远不会发生糟糕的事情”,活跃性是“某件正确的事情最终会发生”,当某个操作无法继续执行下去时,就会发生活跃性问题。例如单线程,如果有死循环程序,对于后续的操作无法进行,这种情况就是活跃性问题。对于多线程程序,A线程等待B线程释放资源,B一直不释放,这种类型就是活跃性问题。
3.3 性能问题
活跃性意味着某件正确的事情最终会发生,性能是我们希望正确的事情尽快发生。
但是多线程程序面临着程序切换的开销,线程维护、共享内存同步等等耗时问题,这种上下文切换(Context Switch)可能比线程执行更花时间。
4 线程无处不在
即使在程序中没有显示的创建线程,但在框架中仍可能会创建线程,因此在这些线程中调用的代码同样必须是线程安全的。开发线程安全的类比开发费线程安全的类要更加谨慎和细致。每个java应用程序都会使用线程,当jvm启动时,它将为jvm的内部任务创建后台线程,并创建一个主线程来运行main方法。
编写程序时必须熟悉并发性和线程安全性。无论是框架代码还是应用程序,临界资源的代码访问路径必须是线程安全的。Timer、servlet 、jsp、rmi、Swing、AWT等,线程无处不在。