《Java 并发编程实践》阅读笔记(一):线程重要性
文章目录
- 一. 并发历史
- 二. 线程优势
- 三. 线程带来的风险
- 1. 安全性问题
- 2. 活跃性问题
- 3. 性能问题
- 四. 线程无处不在
- 示例1: Timer
- 示例2: 远程方法调用(Remote Method Invocation, RMI)
- 示例3: GUI 程序
一. 并发历史
- 操作系统的出现
大型机时代, 没有操作系统, 一台主机只能执行一段预先规划好的程序. 对于昂贵并且稀有的计算机资源来说也是一种浪费, 这促使操作系统出现
操作系统的出现使得计算机每次能运行多个程序,并且不同的程序都在单独的进程中运行
- 进程的出现
资源利用率、公平性以及便利性等因素, 促使进程出现
操作系统为各个独立执行的进程分配各种资源, 如果需要的话,在不同的进程之间可通过粗粒度的通信机制来交换数据
- 资源包括:内存,文件句柄, 以及 安全证书 等
- 通信机制包括:套接字、信号处理器、共享内存、信号量, 以及 文件 等。
- 串行编程模型
在早期的分时系统中,每个进程相当于一台虚拟的冯·诺依曼计算机
它拥有存储指令和数据的内存空间,根据机器语言的语义以串行方式执行指令,并通过一组I/O指令与外部设备通信。
对每条被执行的指令,都有相应的“下一条指令”,程序中的控制流是按照指令集的规则来确定的。当前,几乎所有的主流编程语言都遵循这种串行编程模型,并且在这些语言的规范中也都清晰地定义了在某个动作完成之后需要执行的“下一个动作”。
串行编程模型的优势在于其直观性和简单性,因为它模仿了人类的工作方式:每次只做一件事情,做完之后再做另一件。
- 现实世界中的动作可以被进一步抽象为一组粒度更细的动作
例如喝早茶的动作: 首先起床,穿上睡衣,然后下楼,喝早茶。
喝早茶的动作可以被进一步细化为:打开橱柜,挑选喜欢的茶叶,将一些茶叶倒入杯中,看看茶壶中是否有足够的水,如果没有的话加些水,将茶壶放到火炉上,点燃火炉,然后等水烧开等等。
在最后一步等水烧开的过程中包含了一定程度的异步性。当正在烧水时,你可以干等着,也可以做些其他事情,例如开始烤面包(这是另一个异步任务)或者看报纸,同时留意茶壶水是否烧开。
茶壶和面包机的生产商都很清楚:用户通常会采用异步方式来使用他们的产品,因此当这些机器完成任务时都会发出声音提示。但凡做事高效的人,总能在串行性与异步性之间找到合理的平衡,对于程序来说同样如此。
- 线程出现
促使进程出现的因素, 同样也促使着线程的出现。
线程允许在同一个进程中同时存在多个程序控制流。线程会共享进程范围内的资源,例如内存句柄和文件句柄
但每个线程都有各自的 1.程序计数器(Program Counter)、2.栈, 以及 3.局部变量等。
线程还提供了一种直观的分解模式来充分利用多处理器系统中的硬件并行性,而在同一个程序中的多个线程也可以被同时调度到多个CPU上运行
线程也被称为轻量级进程。在大多数现代操作系统中,都是以线程为基本的调度单位,而不是进程。
如果没有明确的协同机制,那么线程将彼此独立执行。由于同一个进程中的所有线程都将共享进程的内存地址空间,因此这些线程都能访问相同的变量并在同一个堆上分配对象,这就需要实现一种比在进程间共享数据粒度更细的数据共享机制。如果没有明确的同步机制来协同对共享数据的访问,那么当一个线程正在使用某个变量时,另一个线程可能同时访问这个变量,这将造成不可预测的结果。
二. 线程优势
- 同类合并
只需要执行一种类型的任务(例如修改12个错误)时,在时间管理方面比执行多种类型的任务要简单。
当只有一种类型的任务需要完成时,只需埋头工作,直到完成所有的任务(或者你已经精疲力尽),你不需要花任何精力来琢磨下一步该怎么做。而另一方面,如果需要完成多种类型的任务,那么需要管理不同任务之间的优先级和执行时间,并在任务之间进行切换,这将带来额外的开销。
- 异步事件简化处理
远程调用如果为每个连接都分配其各自的线程并且使用同步I/O,会降低这类程序的开发难度, 但读操作将一直阻塞,直到有数据到达。在单线程应用程序中,这不仅意味着在处理请求的过程中将停顿,而且还意味着在这个线程被阻塞期间,对所有请求的处理都将停顿。为了避免这个问题,单线程服务器应用程序必须使用非阻塞I/O,这种I/O的复杂性要远远高于同步I/O,并且很容易出错。
然而,如果每个请求都拥有自己的处理线程,那么在处理某个请求时发生的阻塞将不会影响其他请求的处理。
上面只是举一个简单的处理方法. 早期的操作系统会将进程可创建的线程数量限制在一个较低的阈值,大约在数百个(甚至更少)左右。
因此,操作系统提供了一些高效的方法来实现多路I/O,例如Unix的select和poll等系统调用,要调用这些方法,Java类库需要获得一组实现非阻塞I/O的包(java.nio)
然而,在现代操作系统中,线程数量已得到极大的提升,这使得在某些平台上,即使有更多的客户端,为每个客户端分配一个线程也是可行的
- 更灵敏的 GUI
大多数GUI框架都是单线程子系统,通常通过一个“主事件循环(Main Event Loop)” 来间接地执行应用程序的所有代码。
如果在主事件循环中调用的代码需要很长时间才能执行完成,那么用户界面就会“冻结”,直到代码执行完成。这是因为只有当执行控制权返回到主事件循环后,才能处理后续的用户界面事件。
在事件线程中执行的任务如果都是短暂的,那么界面的响应灵敏度就较高,因为事件线程能够很快地处理用户的动作。如果用户在执行这类任务时触发了某个动作,那么必须等待很长时间才能获得响应,因为事件线程要先执行完该任务。更糟糕的是,即使界面上包含了“取消”按钮,也无法取消这个长时间执行的任务,因为事件线程只有在执行完该任务后才能响应“取消”按钮的点击事件。
如果将这个长时间运行的任务放在一个单独的线程中运行,那么事件线程就能及时地处理界面事件,从而使用户界面具有更高的灵敏度。(现代的主事件循环大多处于GUI工具的控制下并在其自己的线程中运行,采用一个事件分发线程(Event Dispatch Thread, EDT), 而不是在应用程序的控制下)
三. 线程带来的风险
风险之一: 难以分析, 因为它们依赖于不同线程的事件发生时序,因此在开发或者测试中并不总能够重现。
1. 安全性问题
在没有充足同步的情况下,多个线程中的操作执行顺序是不可预测的,甚至会产生奇怪的结果
产生未预测的结果, 比如钱算错, 程序保证不了正确性
安全性的含义是 “永远不发生糟糕的事情”
2. 活跃性问题
活跃性则关注于另一个目标,即 “某件正确的事情最终会发生”。当某个操作无法继续执行下去时,就会发生活跃性问题。
死锁 / 饥饿. 例如,如果线程A在等待线程B释放其持有的资源,而线程B永远都不释放该资源,那么A就会永久地等待下去。
单线程-串行程序也会有, 比如无意中造成的死循环,从而使循环之后的代码无法得到执行
在开发并发代码时,注意线程安全性是不可破坏的。安全性不仅对于多线程序很重要,对于单线程程序同样重要。
3. 性能问题
解决完活跃性问题, 意味着某件正确的事情最终会发生. 但发生了但却不够好, 这就是性能问题,因为我们通常希望正确的事情尽快发生。
性能问题包括多个方面,例如:
- 服务时间过长
- 响应不灵敏
- 吞吐率过低
- 资源消耗过高
- 可伸缩性较低
- 等等。
与安全性和活跃性一样,在多线程程序中不仅存在与单线程程序相同的性能问题,而且还存在由于使用线程而引入的其他性能问题。
- 关注点
使用线程本质上是想提升性能, 但使用后却额外增加了性能问题 (上下文切换, 线程调度, 同步机制抑制编译器优化等), 这就是关注点.
- 上下文切换 (Context Switch)
无论如何,线程总会带来某种程度的运行时开销。在多线程程序中,当线程调度器临时挂起活跃线程并转而运行另一个线程时,就会频繁地出现上下文切换操作 (Context Switch),下面这种操作将带来极大的开销:
- 保存和恢复执行上下文,丢失局部性,并且CPU时间将更多地花在线程调度而不是线程运行上。
- 当线程共享数据时,必须使用同步机制,而这些机制往往会抑制某些编译器优化,使内存缓存区中的数据无效
- 增加共享内存总线的同步流量。
所有这些因素都将带来额外的性能开销
四. 线程无处不在
- 框架中会创建线程. 在被这些线程调用的代码, 同样也必须是线程安全的
- 每个 Java 应用程序都会使用线程. JVM 启动后会创建后线程, 来为 JVM 内部任务 (垃圾收集, 终结操作等) 服务
- 并发不局限, 线程的安全性需求会在程序中蔓延. 当某个框架在应用程序中引入并发性时,通常不可能将并发性仅局限于框架代码. (例如: 框架会回调(Callback)应用代码,而这些代码将访问应用程序的状态) 对线程安全性的需求也不局限于被调用的代码,而是延伸到访问到程序状态的所有代码路径
框架通过在框架线程中调用应用程序代码将并发性引入到程序中。在代码中将不可避免地访问应用程序状态,因此所有访问这些状态的代码路径都必须是线程安全的。
示例1: Timer
Timer 类的作用是使任务在稍后的时刻运行,或者运行一次,或者周期性地运行。
引入Timer可能会使串行程序变得复杂,因为TimerTask将在Timer管理的线程中执行,而不是由应用程序来管理。
如果某个TimerTask访问了应用程序中其他线程访问的数据,那么不仅TimerTask需要以线程安全的方式来访问数据,其他类也必须采用线程安全的方式来访问该数据。通常实现这个目标,最简单的方式是确保TimerTask访问的对象本身是线程安全的,从而就能把线程安全性封装在共享对象内部
示例2: 远程方法调用(Remote Method Invocation, RMI)
RMI 使代码能够调用在其他JVM中运行的对象。当通过RMI调用某个远程方法时,传递给方法的参数必须被打包(也称为列集[Marshaled])到一个字节流中,通过网络传输给远程JVM,然后由远程JVM拆包(或者称为散集[Unmarshaled])并传递给远程方法。
当RMI代码调用远程对象时,这个调用将在哪个线程中执行?你并不知道,但肯定不会在你创建的线程中,而是将在一个由RMI管理的线程中调用对象。RMI会创建多少个线程?同一个远程对象上的同一个远程方法会不会在多个RMI线程中被同时调用? (会的) 远程对象必须注意两个线程安全性问题:
- 正确地协同在多个对象中共享的状态
- 对远程对象本身状态的访问(由于同一个对象可能会在多个线程中被同时访问)
与Servlet相同,RMI 对象应该做好被多个线程同时调用的准备,并且必须确保它们自身的线程安全性。
示例3: GUI 程序
GUI应用程序的一个固有属性是异步性, 例如有专门的名词 UI 线程 (或 事件线程). 用户可以在任意时刻选择一个菜单项或者按下一个按钮,应用程序就会及时响应,即使应用程序当时正在执行其他的任务
Swing和AWT很好地解决了这个问题,它们创建了一个单独的线程来处理用户触发的事件,并对呈现给用户的图形界面进行更新。
Swing的一些组件并不是线程安全的,例如JTable。相反,Swing程序通过将所有对GUI组件的访问局限在事件线程中以实现线程安全性。
如果某个应用程序希望在事件线程之外控制GUI,那么必须将控制GUI的代码放在事件线程中运行。当用户触发某个UI动作时,在事件线程中就会有一个事件处理器被调用以执行用户请求的操作。如果事件处理器需要访问由其他线程同时访问的应用程序状态(例如编辑某个文档),那么这个事件处理器,以及访问这个状态的所有其他代码,都必须采用一种线程安全的方式来访问该状态