【软件构造】课件精译(二十二) 并发和线程安全
前言
根据课程安排,第四章《面向可理解性的构造》和第九章《代码重构》均为自学内容,有时间将在后续章节补充,这里直接进入第十章。
什么是并发编程
并发
并发意味着多个运算同时发生
为什么要并发
处理器时钟速度不再增加
新一代芯片都会有更多的内核
为了让计算更快运行,我们必须将计算分解为并发模块
两种常见的并发模型
共享内存:并发程序通过读写内存中的共享对象交互
例如:两个处理器共享物理内存;两个程序共享文件;两个线程共享对象
消息传递:在消息传递模型中,并发模块通过通信信道相互发送消息进行交互。 模块发送消息,并将每个模块的传入消息排队等待处理。
例如:网络中两台计算机通信;web浏览器和web server;即时消息的客户端和服务器;通过管道连接两个程序的输入和输出
二、进程、线程、时间切片
进程和线程
并发模块本身主要分为两种类型:进程和线程
进程是正在运行程序的一个实例,拥有自己私有专用的内存空间
线程是正在运行程序的一个执行路径(一个进程可对应多个线程),线程有自己的堆栈和局部变量,但是多个线程共享内存空间
(1)进程
进程可抽象为虚拟计算机,拥有独立的执行环境和完整的资源
进程间通常不共享内存
进程不能访问其他进程的内存或对象
需要特殊机制才能实现进程间共享内存
进程通信采用的是消息传递方式(因采用标准I/O 流)
应用程序实际上可能是一组协作进程
为了实现进程间通信,大多数操作系统都支持”进程间通信(IPC)资源”,例如pipe和socket
Java虚拟机本身的大多数实现都是作为单个进程运行的
(2)线程
线程和多线程编程
线程可抽象为一个虚拟处理器,线程有时也称为轻量级进程
线程与进程中的其他线程共享相同的资源(内存,打开的文件等),即“线程存在于进程内”
进程内的线程共享内存
线程需要特殊处理才能实现消息传递和访问私有内存
为什么使用线程
解决阻塞活动时的性能
为了利用多核处理器,必须编写多线程代码
Java提供了并发编程的库函数
需要理解基本概念
线程安全
每个应用程序至少有一个线程
站在程序员角度,main线程是开始线程,可以通过它创建其他的线程
创建线程的方式
所有的线程都需要实现Runnable接口,并实现run()方法
方法1:创建Thread类的子类
调用Thread.start()
方法2:实现Runnable接口,作为参数传递给 new Thread(…)构造函数
惯用法:用一个匿名的Runnable启动一个线程,它避免了创建命名的类
下面是lambda表达式的方式:
new Thread(() -> System.out.println(“Hello from a thread!”)).start();
匿名类
(略
三、交织和Race条件
时间切片
在某时刻,一个运行核心上只有一个线程可以运行
进程/线程等采用OS提供的时间片机制共享处理时间
当线程数多于处理器数量时,并发通过时间片来模拟,处理器切换处理不同的线程
时间切片的例子
时间片的使用是不可预知和非确定性的,这意味着线程可能随时暂停或恢复。
共享内存的例子
共享内存可能会导致微妙的错误,看下面这个例子:
可以看到,存取款都是根据当余额进行的,所以多个ATM同时操作时很容易出问题
交叉/交错
由于低层的指令间存在交叉,很容易出现错误的结果
竞争条件
竞争条件:程序的正确性(后置条件和不变性的满足)取决于并发计算A和B中事件的相对时间
在有些情况下会导致违反后置条件和不变性
对于这种情况,调整代码起不到什么作用。不能仅仅从Java代码中看出处理器将如何执行它,本质原因是它们不是原子操作
银行账户的这个例子中的竞争情况,可以用顺序操作在不同处理器上的不同交叉执行解释,但是事实上,当使用多个变量和多个处理器时,甚至不能指望这些变量的变化以相同的顺序出现
消息传递的例子
不仅自动取款机是处理模块,账户也是处理模块,模块间通过消息传递进行交互
消息接收方将收到的消息形成队列逐一处理(先进先出) ,消息发送者并不停下来等待结果而是继续执行(异步方式)
消息传递的方式并不能消除竞争条件的可能性
A和B首先查询余额,然后取款,避免透支
交叉的问题仍然会出现,但是这次交叉信息发生在银行账户,而不是A和B执行的指令
happens-before关系
前一个事件的结果可以被后续的事件获取(即使出于优化的目的,实际运行中并不是按照指定顺序执行)
在Java中,采用happened-before机制,保证了语句A对内存的写入对语句B是可见的,也就是在B开始读数据之前,A已经完成了数据的写入
四、线程安全
数据类型或静态方法在多线程中执行时,无论如何执行,不需调用者做额外的协作,仍然能够行为正确,则称为线程安全的
行为正确意味着满足规格说明和保持不变性
不能在前置条件中对调用者增加时间性要求
注意,迭代器并非线程安全的
线程安全的四种方式
(1)限制可变变量的共享
通过将数据限制在单个线程中,可以避免线程在可变数据上进行竞争
局部变量保存在 线程栈中,每个调用都有自己的变量副本
局部变量如果是对象的引用,则要确保不能引用任何其他线程可访问 的对象(针对可变对象)
避免使用全局变量
全局静态变量不会自动受到线程访问限制
如果使用了全局静态变量,则应说明只允许一个线程使用它们
在多线程环境中,取消全局变量
一般来说,静态变量对于并行是非常危险的,它们可能隐藏在似乎没有副作用或改变的无害功能之后
例如:
当有多个线程同时执行cache.put()操作时,map可能会遭到破坏
(2)用不可变的共享变量
使用不可变的引用和数据类型
不可变解决了因为共享可变数据造成的竞争,并简单地通过使共享数据不可变来解决它
final变量不允许再赋值,所以声明为final的变量可以安全地从多个线程访问
因为这种安全性只适用于变量本身,仍然必须确保变量指向的对象是不可变的
不可变性的强定义
没有改变数据的操作
所有的字段均为private 和 final
没有表示泄露
表示中的任何可变对象都不能变化
(3)将共享数据封装在线程安全的数据类型中
将共享的可变数据存储在线程安全的数据类型中
Java类库中线程安全的数据类型,会显式地声明
因为线程安全的数据类型性能较差,所以Java对一些可变数据类型提供两种形式供选择:线程安全的和线程不安全的
举例:StringBuffer和StringBuilder
StringBuffer 和 StringBuilder都是可变数据类型,功能基本相同
StringBuffer是线程安全的,StringBuilder不是
StringBuilder性能更好,推荐在单线程程序中使用
线程安全的集合
Java提供了线程安全 的Collections类型版本,会确保方法是原子的
原子方法指的是动作的内部操作不会同其他操作交叉, 不会产生部分完成的情况
例如:private static Map<Integer,Boolean> cache = Collections.synchronizedMap(new HashMap<>());
线程安全包装类
包装的实现是将所有的实际工作委托给指定的容器,但在容器的基础上添加额外的功能
是装饰者模式的一个例子
注意:
不要绕开包装类,统一采用包装类的形式
确保抛弃对底层非线程安全容器类的引用,并只通过同步的包装类来访问它
新的HashMap只传递给synchronizedMap,并且永远不会存储在其他地方
因为底层的容器仍然是可变的,引用它的代码可以规避不变性,失去了包装的意义
迭代器不是线程安全的,解决方案将是在需要迭代collection 时获取它的锁
原子操作不足以完全防止竞争
例如检查列表是否至少有一个元 素,然后获取该元素,找到操作时某个线程可能已经将其移除了
Synchronied map 能够保证各操作是原子的,但是操作间的交叉仍然会破坏不变性
(4)使用同步机制来防止线程同时使用变量
同步和锁
并发模块彼此间采用同步的方式共享内存,以解决竞争带来的问题
锁是一种抽象,某时刻最多只允许一个线程拥有锁
获取锁的拥有权,如果锁被其他线程拥有,将进入阻塞状态,等待锁释放后,再同其他线程竞争获取锁的拥有权
锁机制可以确保锁的拥有者始终查看最新的数据,避免了 reordering问题
阻塞一般意味着线程等待(不再继续工作),直到一个事件发生(其他线程释放锁)
银行账户例子
通过加锁的方式解决此问题,在访问银行账号之前,ATM需要获取到银行账号锁的拥有权
同步块和方法
Java将锁定机制作为内置语言特性提供
每个类及其所有对象实例都有一个锁
Object类也有锁,经常用于显示地定义锁
同步语句/同步代码块
同步区域提供了互斥功能: 一次只能有一个线程处于由给定对象的锁保护的同步区域中,锁定时,遵循顺序执行模式
synchronized (obj) { … }
在线程t中,会阻止其他线程进入保护块中,直到语句块中代码执行完
锁只能确保与其他请求获取相同对象锁的线程互斥访问
错误: 拥有对象的锁会自动阻止其他线程访问同步区域
锁只能确保与其他请求获取相同对象锁的线程互斥访问,如果其他线程没有使用synchronized(obj)或者利用了不同object的锁,则同步会失效,需要仔细检查和设计同步块和同步方法
对比可以发现,第一个例子共用同一个lock,所以按顺序输出,先后进行,而第二个例子每个线程有自己的lock,会出现交替的现象
监控模式
使用this变量作为锁,保护所有的Rep,某时刻只能有一个线程在类的一个实例中
同步方法
当把synchronized关键词加在方法签名上时,相当于用synchronized(this)环绕了整个方法体
当线程调用同步方法时,它会自动获取该方法所在对象的内部锁,并在方法返回时释放它。即使返回是由未捕获的异常引起的,也会释放锁
同一对象上的同步方法的两次调用不会有交叉现象
当一个线程在执行一个对象的同步方法时,所有其他线程如果调用同一对象的同步方法块,则会挂起执行,直到第一线程针对此对象的操作完成
当一个同步方法退出时,它会自动建立一个与之后调用同一个对象的同步方法的happens-before关系,这保证对象状态的更改对所有线程都是可见的
happens-before关系
前一个事件的结果可以被后续的事件获取(即使出于优化的目的,实际运行中并不是按照指定顺序执行)
在Java中,采用happened-before机制,保证了语句A对内存的写入对语句B是可见的,也就是在B开始读数据之前,A已经完成了数据的写入
确保内存一致性
对比上面两个例子,第二个是同一个对象,所以没有出现交错问题,保证了方法堵塞,不过要明白这里为什么不是从0到4,而是随机顺序
静态同步方法
由于静态方法与类关联,而不是对象,此时线程获取与该类关联的Class对象的内部锁
对类的静态字段的访问由与该类的任何实例的锁截然不同的锁来控制
可以看到,这个锁是属于类的
同步方法/块
同步方法不用指定锁,但同步语句/块必须指定锁
Synchronized语句有利于实现具
有细粒度同步的并发性
锁规则
锁规则是确保同步代码是线程安全的策略
每个共享的可变变量必须由某个锁保护
如果一个不变量涉及多个共享的可变变量(甚至可能在不同的对象中),那么涉及的所有变量都必须由相同的锁保护
监视器模式满足这两个规则,rep中所有共享的可变数据(表示不变量依赖的)都被相同的锁保护
原子操作
之前通过monitor pattern已经确保了每个方法(toString、delete和insert)的原子性;此时扩大了同步的区域,确保了方法间调用的互不干扰,实现了findReplace的原子性
volatile关键词用于原子数据获取
private volatile int counter;
使用volatile变量可以降低内存一致性错误的风险
volatile变量的更改,对其他线程总是可见的
基本原理:每次使用此类变量时都到主存中进行读取,而且当成员变量发生变化时,强迫线程将变化值回写到共享内存。这样在任何时刻,两个不同的线程总是看到某个成员变量的同一个值。
注意:volatile 不能提供必须的原子特性,只能在有限的一些情形下使用 volatile变量替代锁:对变量的写操作不依赖于当前值,变量的有效值独立于任何程序的状态,包括变量的当前状态。
使用synchronized的坏处
同步开销大
需要时间申请锁,刷新缓存,同其他线程通讯
当不需要同步时,不要使用
将同步添加到每个方法意味着锁是对象本身,并且每个引用对象的客户端都会自动拥有锁的引用,导致他们可以随意获取和释放锁
线程安全机制因此是公开的,可能会受到客户的干扰
相比而言,使用某内部对象的锁,并使用synchronized语句块进行适当的获取和使用,更加合适
同步方法意味着正在获取一个锁,而不考虑它是哪个锁,或者它是否是保护你将要执行的共享数据访问的正确锁
上面的例子, findReplace是一个静态方法,声明为synchronized方法后,将使用其所在类的锁,将对所有对象生效。这样导致一次只有一个线程可以调用findReplace,即使操作不同的buffer也不行。性能损失严重!
所以应当适当缩小其限制范围,保证线程安全的同时减少性能的损耗
活跃度: 死锁, 饥饿和活锁
并发应用程序的及时执行能力被称为活跃度
死锁
由于使用锁需要线程等待(当另一个线程持有锁时),因此可能会陷入两个线程都在等待对方的情况 – 此时都无法继续
死锁描述了两个或更多线程 永远被阻塞的情况,都在等待对方
死锁可能涉及两个以上的模块:线程间的依赖关系环是出现死锁的信号
T1: synchronized(a){ synchronized(b){ … } }
T2: synchronized(b){ synchronized(a){ … } }
上面这段代码是死锁的典型情况,相当于进入了一个等待的死循环,a在等待b完成,b在等待a完成
防止死锁的一种方法是对需要同时获取的锁进行排序,并确保所有代码按照该顺序获取锁定
这段修改后,锁根据字母顺序排序,避免了死锁的发生
解决方法1:锁顺序
需要知道子系统/系统中所有的锁,无法模块化
需要知道需要用到哪些锁
解决方法2:粗粒度锁
粗粒度的锁,用单个锁来监控多个对象实例
对整个社交网络设置一个锁,并且对其任何组成部分的所有操作都在该锁上进行同步。 例如:所有的Wizards都属于一个Castle, 可使用castle实例的锁
饥饿
饥饿描述了线程无法获得对共享资源的访问,而无法取得进展的情况
当共享资源由“贪婪”线程导致长时间不 可用时,会发生这种情况
活锁
如果一个线程的行为也是对另一个线程的行为的响应,则可能导致活锁
与死锁一样,活锁线程无法取得进一步进展
线程并未被阻止 - 他们只是忙于响应对方恢复工作
相向而行,同时让路
线程调度的方法
sleep()
让当前线程暂停指定时间的执行,期间不参与CPU的调度,不释放所拥有的监视器资源(锁)
join()
Join()方法用于保持当前正在运行的线程的执行,直到该线程死亡(执行完毕),才能继续执行后续线程
存在多个线程时,线程调度器并不能保证线程执行顺序
让一个线程等待另一个线程结束。(可能需要前面线程的输出结果作为输入)
防护块
在语句块开始放一个轮询条件(一直检测),当条件为真才能执行后续的语句
始终循环,浪费处理器时间
在防护块中使用wait()
执行wait()后,当前线程会等待,直到其他线程调用此对象的notify( ) 方法或 notifyAll( ) 方法
调用wait()时,当前线程需要拥有对象的锁。执行wait()后,线程释放锁并等待。当被唤醒后,重新参与锁所有权的竞争,成功后从之前开始wait的点继续执行
只有获得了对象锁所有权的线程可以调用对象的wait()方法,否则会抛出异常
当wait()被调用时,线程释放锁并暂停执行
虚假唤醒:一个线程可以从挂起状态变为运行状态(被唤醒),即使该线程没有被其他线程调用notify()、notifyAll()方法进行通知,或者被中断,或者等待超时。
为了避免虚假唤醒,则采用循环的方法测试被唤醒的条件是否满足,不满足则继续等待。
经典的调用共享变量wait()方法的实例
每个特殊事件发生时,循环只执行一次
有些时候,另一个线程将获取相同的锁并调用Object.notifyAll(),通知等待该锁的所有线程发生了重要的事情。
被唤醒的资源会重新参与到锁的竞争中,获取后,从wait的地方继续执行。
阻塞方法
阻塞方法不同于一般的要运行较长时间的方法
一般方法的完成只取决于它所要做的事情,以及是否有足够多可用的计算资源,而阻塞方法的完成还取决于一些外部的事件,例如计时器到期,I/O 完成,或者另一个线程的动作(释放一个锁,设置一个标志,或者将一个任务放在一个工作队列中)
一般方法在它们的工作完成后即可结束,而阻塞方法较难于预测,因为它们取决于外部事件
Java中的一些低级可中断阻塞方法:Thread.sleep(),Thread.join()或Object.wait()
阻塞方法可能因为等不到所等的事件而无法终止,因此令阻塞方法可取消就非常有用(如果长时间运行的非阻塞方法是可取消的,通常也非常有用)
可取消操作是指能从外部使之在正常完成之前终止的操作
interrupt()
每个线程都有一个与之相关联的 Boolean 属性,用于表示线程的中断状态,中断状态初始时为 false
当另一个线程通过调用Thread.interrupt() 中断一个线程时,会出现以下两种情况之一:
如果被中断线程在执行一个低级可中断阻塞方法,例如Thread.sleep()、 Thread.join() 或Object.wait(),那么它将取消阻塞并抛出InterruptedException,否则,interrupt() 只是设置线程的中断状态
被中断线程中运行的代码,可以轮询中断状态,看看它是否被请求停止正在做的事情
中断状态可以通过 Thread.isInterrupted()来读取,并且可以通过一个名为 Thread.interrupted() 的操作读取和清除
中断是一种协作机制。当一个线程中断另一个线程时,被中断的线程不一定要立即停止正在做的事情。相反,中断是礼貌地请求另一个线程在它愿意并且方便的时候停止它正在做的事情
有些方法,例 如Thread.sleep(),很认真地对待这样的请求,但每个方法不是一定要对中断作出响应。对于中断请求,不阻塞但是仍然要花较长时间执行的方法可以轮询中断状态,并在被中断的时候提前返回。 可以随意忽略中断请求,但是这样做的话会影响响应
中断的协作特性所带来的一个好处是,它为安全地构造可取消活动提供更大的灵活性
通常,我们很少希望一个活动立即停止。如果活动在正在进行更新的时候被取消,那么程序数据结构可能处于不一致状态。中断允许一个可取消活动来清理正在进行的工作,恢复不变量,通知其他活动它要被取消,然后才终止
五、如何编写Safety Argument
这个例子中,因为不会从cache中删除元素,所以竞争无害,无论哪一个获胜者执行了put(),结果都是正确的
需要对安全性进行这种仔细的论证 - 即使在使用线程安全数据类型时 - 也是并发性很难的主要原因
在Safety Argument中阐述使用了哪种技术, 使用threadsafe data types, or synchronization时,还需要论证所有对数据的访问都是具有原子性的(表示不变性不会受到交叉的影响)
不可变类型的线程安全参数
反例
String是不可变的和线程安全的,但是rep对其引用却是可变的
采用了线程安全的数据类型,但是不能避免所有的竞争,因为两个数据结构间存在交叉现象
交叉:更新了边集,但还没有更新点集,其他线程此时访问会导致错误结果
可以看到,线程安全的数据类型只能保证其上操作的原子性,不能避免类型间操作的原子性。因此,当存在类型间关系的时候,仅采用不可变类型和线程安全的数据类型是不够的