- 本博客为哈工大计算机科学与技术学院大二软件构造课程的课件翻译。同时记录了部分本人上课时的学习笔记和感悟
- 该博客8800字左右,主题为7-2线程安全,已经全部更新完成
- 由于水平有限,翻译可能不是特别流畅、通顺,并且存在一定错误,观点、笔记不一定完全正确,敬请各位批评指正!
1什么是线程安全
线程安全
- 竞争条件:多个线程共享同一个可变变量,但不协调它们正在做的事情。
- 这是不安全的,因为程序的正确性可能依赖于低级操作的定时事故。
- 线程之间的“竞争条件”:作用于同一个mutable数据上的多个线程, 彼此之间存在对该数据的访问竞争并导致interleaving(交错),导致postcondition可能被违反,这是不安全的。
线程安全意味着什么
- 无论线程是如何执行的,如果数据类型或静态方法在多个线程中使用时行为正确,并且不需要调用代码的额外协调,则该方法是线程安全的。
- 如何理解这个定义:
- “行为正确”意味着满足其规范并保持其rep invariant;(不违反spec、保持RI )
- “不管线程是如何执行的”意味着线程可能在多个处理器上或在同一处理器上计时;(与多少处理器、 OS如何调度线程,均无关 )
- “没有额外的协调”意味着数据类型不能给它的调用者设置与计时相关的先决条件,比如“在set()进行时,您不能调用get()”。( 不需要在spec中强制要求client满足某种“线程 安全”的义务)
- 还记得迭代器吗?这不是线程安全的。
- Iterator的规范规定,不能在对集合进行迭代的同时修改集合。
- 这是放置在调用者上的一个与时间相关的前置条件,如果您违反了它,Iterator不会保证正确地行为。
- 作为这种非本地契约现象的症状,请考虑Java集合类,它们通常在客户端和类的实现者上以非常清晰的契约记录。
- 尝试找出它在何处记录了客户机上的关键需求,即在迭代集合时不能修改集合。
- 线程安全的四种方式
2 策略一:Confinement
- 线程限制是一个简单的想法:
- 通过将数据限制在单个线程中,可以避免对可变数据的竞争。
- 不要让其他线程直接读写数据。
- 由于共享的可变数据是竞争条件的根本原因,Confinement通过不共享可变数据来解决这个问题
- 局部变量始终是线程限制的。一个局部变量存储在堆栈中,并且每个线程都有自己的堆栈。一个方法可能有多个调用同时运行,但是每个调用都有自己的变量私有副本,因此变量本身是限制的。
- 如果局部变量是对象引用,则需要检查它指向的对象。如果对象是可变的,那么我们需要检查对象是否也被限制了——不能有任何其他线程可访问(非别名)的对它的引用。
- 这个类在getInstance()方法中有一个竞争——两个线程可以同时调用它,并最终创建PinballSimulator对象的两个副本,这违反了 rep invariant
- 要使用线程限制方法修复这种竞争,可以指定只允许某个线程调用getInstance()。
- 但是Java不能帮助您保证这一点。
- 假设有两个线程正在运行getInstance()。
- 对于两个线程对应执行的每一对可能的行号,是否会违反不变式?
- Java不能保证在一个线程中对simulator的赋值会立即在其他线程中可见;它可能被临时缓存。
- 全局静态变量不会自动被线程限制。
- 如果你的程序中有静态变量,那么你必须证明只有一个线程会使用它们,并且你必须清楚地记录这个事实。
- 更好的是,应该完全消除静态变量。
- 如果我们想证明PinballSimulator ADT是线程安全的,我们不能使用 confinement。
- 我们不知道客户端是否或者如何从多个线程创建一个PinballSimulator实例的别名
- 如果有,那么对该类方法的并发调用将并发访问其字段及其值。
- 如果一个ADT的rep中包含 mutable的属性且多线程之间 对其进行mutator操作,那么 就很难使用confinement策略 来确保该ADT是线程安全的
2 策略二:Immutability
- 实现线程安全性的第二种方法是使用不可变的引用和数据类型。
- 不可变性解决了共享可变性数据导致竞态条件的问题,并通过使共享数据不可变性来解决这个问题。
- 声明为final的变量是不可重新分配的和不可变的引用,所以声明为final的变量可以安全地从多个线程访问。
- 只能读取变量,不能写入。
- 因为这种安全性只应用于变量本身,而且我们仍然必须证明变量指向的对象是不可变的。
- 不可变对象通常也是线程安全的。
- 我们说“通常”是因为当前对不变性的定义对于并发编程来说太松了。
- 如果类型的对象在其整个生命周期中始终表示相同的抽象值,则类型是不可变的。
- 但这实际上允许类型自由地改变它的代表,只要这些变化对客户端是不可见的,比如有利的突变(见3.3章)。
- 比如缓存、延迟计算和数据结构再平衡
- 对于并发性,这种隐藏的变异是不安全的。
- 使用有利突变的不可变数据类型必须使用锁使自己是线程安全的
更强的不变性定义
- 为了确信不可变数据类型在没有锁的情况下是线程安全的,我们需要一个更强的不可变性定义:
- 没有mutator方法
- 所有字段都是私有的和final的
- 没有表示公开-在rep中没有任何可变对象的突变
- 甚至没有有益的突变
- 如果您遵循这些规则,那么您就可以确信您的不可变类型也是threadsafe的。
不变性和线程安全的
- 假设您正在检查一个指定为不可变的ADT,以决定它的实现是否实际上是不可变的和线程安全的。
- 以下哪一个元素是你必须考虑的?
策略二怎么样
- 相比起策略1 Confinement,该策略2 Immutability允许有全局rep, 但是只能是immutable的。
- 如果一定需要mutable的ADT,怎么办?
4 策略三:使用线程安全的数据类型
- 实现线程安全的第三个主要策略是在现有的线程安全数据类型中存储共享的可变数据。(如果必须要 用mutable的数据类型在多线程之间共享数据,要使用线程安全的数 据类型。 )
- 当Java库中的数据类型是threadsafe时,它的文档将显式地声明这一事实。(在JDK中的类,文档中明确指明了是否threadsafe)
- 在Java API中,找到两种做同样事情的可变数据类型变得很常见,一种是线程安全的,另一种不是
- 原因就像上面引用的那样:与不安全类型相比,threadsafe数据类型通常会导致性能损失。
- 一般来说, JDK同时提供两个相同功能的类,一个是threadsafe,另一个不是。 原因:threadsafe的类一般性能上受影响
An example: StringBuffer vs. StringBuilder
线程安全的集合
- Java中的集合接口——List、Set、Map——具有非线程安全的基本实现。
- 集合类都是线程不安全的
- ArrayList、HashMap和HashSet的实现不能在多个线程中安全地使用。
- Collections API提供了一组包装器方法,使集合成为线程安全的,但仍然是可变的。
- Java API提供了进一步的 decorator
- 这些包装器有效地使每个方法相对于其他方法具有原子性。(对它们的每一个操作调用,都以原子方式执行 )
- 原子操作实际上是同时发生的——它不会将其内部操作与其他操作的操作交织在一起,而且在整个操作完成之前,其他线程都看不到该操作的效果,所以它看起来永远不会是部分完成的。(不会与其他操作interleaving)
private static Map<Integer,Boolean> cache = Collections.synchronizedMap(new HashMap<>());
线程安全包装器
public static <T> Collection<T> synchronizedCollection(Collection<T> c);
public static <T> Set<T> synchronizedSet(Set<T> s);
public static <T> List<T> synchronizedList(List<T> list);
public static <K,V> Map<K,V> synchronizedMap(Map<K,V> m);
public static <T> SortedSet<T> synchronizedSortedSet(SortedSet<T> s);
public static <K,V> SortedMap<K,V> synchronizedSortedMap(SortedMap<K,V> m);
- 包装器实现将其所有实际工作委托给指定的集合,但在该集合提供的功能之上添加额外的功能。
- 这是装饰器模式的一个示例(参见5 - 3)
- 这些实现是匿名的;该库没有提供公共类,而是提供了一个静态工厂方法。
- 这些实现可以在Collections类中找到,该类仅由静态方法组成。
- 同步包装器(synchronization wrappers )向任意集合添加自动同步(线程安全)。
例子
- 全局变量cache会导致线程之间的竞争。
- private static Map<Integer,Boolean> cache = Collections.synchronizedMap(new HashMap<>());
不要绕过包装器
- 确保丢弃对底层非线程安全collection的引用,并仅通过同步包装器访问它。(synchronized wrapper.)
- 新的HashMap只传递给synchronizedMap(),不会存储在其他任何地方。
- 基础集合仍然是可变的,引用它的代码可以绕过不变性。
- 在使用synchronizedMap(hashMap)之后,不要再把参数hashMap共 享给其他线程,不要保留别名,一定要彻底销毁
private static Map<Integer,Boolean> cache = Collections.synchronizedMap(new HashMap<>());
迭代器仍然不是线程安全的
- 即使对集合本身的方法调用(get()、put()、add()等)现在是线程安全的,从集合创建的迭代器仍然不是线程安全的。
- 所以你不能使用iterator()或者for循环语法
for (String s: lst) { ... } // not threadsafe, even if lst is a synchronized list wrapper
- 不是线程安全的,即使lst是一个同步列表包装器
- 这个迭代问题的解决方案是在需要对集合进行迭代时获取集合的锁。
List<Type> c = Collections.synchronizedList(new ArrayList<Type>());
synchronized(c) { // to be introduced later (the 4-th threadsafe way)
for (Type e : c)
foo(e);
}
- 后面将介绍(第4种线程安全方法)
原子操作不足以防止竞争
- 使用同步集合( synchronized collection )的方式仍然可能存在竞争条件。
- 即使是线程安全的collection类,仍可能产生竞争
- 执行其上某个操作是threadsafe的,但如果多个操作放在一起,仍旧不安全
- 考虑这段代码,它检查列表是否至少有一个元素,然后获取该元素:
if ( ! lst.isEmpty()) {
String s = lst.get(0);
...
}
- 两行语句可能产 生interleaving
- 即使您使lst成为一个同步列表,这段代码仍然可能具有竞争条件,因为另一个线程可能会删除isEmpty()调用和get()调用之间的元素。
Collections.synchronizedMap
if (cache.containsKey(x))
return cache.get(x);
boolean answer = BigInteger.valueOf(x).isProbablePrime(100);
cache.put(x, answer);
- 这些语句之间的 interleaving有无可能 产生race condition?
- synchronized map 确保containsKey()、get()和put()现在是原子的,因此在多个线程中使用它们不会损害映射的rep不变量。
- 但这三种操作现在可以以任意方式相互交错,这可能会打破isPrime需要从cache中得到的不变量:如果cache将一个整数x映射到一个值f,那么当且仅当f为真时,x是素数。
- 如果cache失败了这个不变量,那么我们可能返回错误的结果。
几个要点
- 我们必须指出,containsKey()、get()和put()之间的竞争不会威胁到这个不变量。
- containsKey()和get()之间的竞争并不有害,因为我们从不从cache中删除条目——一旦它包含了x的结果,它就会继续这样做。
- containsKey()和put()之间存在竞争。结果,两个线程可能会同时测试同一个x的初始值,并且都将争着调用put()来得到结果。但是它们都应该以相同的答案调用put(),因此谁赢并不重要—结果将是相同的。
- …在注释中自证threadsafe
- 即使在使用线程安全数据类型时,也需要对安全性进行仔细的讨论,这是并发性很难实现的主要原因。
Problem
一个简短的摘要
- 在共享的可变数据上实现安全的三种主要方法:
- Confinement: 不共享数据
- Immutability共享,但保持数据 immutable. 。
- Threadsafe data types:以单一线程安全数据类型存储共享的可变数据。
- Safe from bugs.
- 我们试图消除一类主要的并发错误、竞态条件,并通过设计来消除它们,而不仅仅是通过偶然的时机。
- Easy to understand.
- 应用这些通用的、简单的设计模式要比讨论哪些线程交错可能、哪些线程交错不可能的复杂得多。
- Ready for change.
- 我们在线程安全参数中明确地写下这些理由,以便维护程序员知道代码的线程安全依赖于什么。
如何进行安全论证
- 回忆:开发ADT的步骤
- 指定:定义操作(方法签名和规范)。
- 测试:为操作开发测试用例。测试套件包括一个基于操作的参数空间划分的测试策略。
- Rep: 选择一个rep.
- 首先实现一个简单的、粗暴的Rep。
- 写下rep不变式和抽象函数,并实现checkRep(),checkRep()在每个构造函数、生成器和mutator方法的末尾断言rep不变式。
- +++ Synchronize
- 说明你的rep是线程安全的。
- 将其作为注释显式地写在类中,就在rep不变式的右侧,以便维护人员知道您是如何将线程安全设计到类中的。
提出安全问题
- 并发性很难测试和调试!
- 因此,如果您想让自己和其他人相信您的并发程序是正确的,最好的方法是明确说明它没有竞争,并将其写下来。
- 在代码中以注 释的形式增加说明:该ADT采取了什么设计决策来保证线程安全
- 一个安全论证需要登记存在于你的模块或程序的所有线程,和他们使用的数据,并论证您正在使用的四个技术来为每个数据对象或变量防止竞争条件:confinement, immutability, threadsafe data types, or synchronization.
- 当您使用后两个措施时,您还需要说明所有对数据的访问都是适当的原子性的—也就是说,您所依赖的不变量不会因交错而受到威胁。
线程安全 Confinement的论证
- 当我们仅仅就数据类型进行争论时,限制通常是不可行的,因为您必须知道系统中存在哪些线程以及它们被允许访问哪些对象。
- 如果数据类型创建了它自己的一组线程,那么您可以讨论关于这些线程的限制。
- 否则,线程从外部进入,携带客户端调用,数据类型可能无法保证哪些线程引用了什么。
- 所以Confinement在这种情况下不是一个有用的论点。
- 通常我们在更高的层次上使用限制,将系统作为一个整体来讨论,并讨论为什么我们不需要某些模块或数据类型的线程安全,因为它们不会被设计为在线程之间共享。
线程安全 Immutability的论证
- 我们得避免rep exposure.。Rep暴露对任何数据类型都是有害的,因为它会威胁到数据类型的Rep不变式。
- 这对线程安全也是致命的。
- 它真的安全吗?
Bad Safety Arguments
- 为什么这个论证不成立?
- String确实是不可变的和线程安全的;但是指向该字符串(特别是文本变量)的rep不是不可变的。
- text不是final变量,实际上它在这个数据类型中也不能是final变量,因为我们需要数据类型来支持插入和删除操作。
- 因此text变量本身的读写不是线程安全的。
- 这个论点是错误的。
6总结
- 本文讨论了在共享可变状态下实现安全避免竞争条件的三种主要方法:
- Confinement: 不共享数据
- Immutability共享,但保持数据 immutable. 。
- Threadsafe data types:以单一线程安全数据类型存储共享的可变数据。
- 这些想法与我们的好软件的三个关键特性联系在一起如下:
- safe from bugs 我们试图消除一类主要的并发错误、竞态条件,并通过设计来消除它们,而不仅仅是通过偶然的时机。
- Easy to understand. 应用这些通用的、简单的设计模式要比讨论哪些线程交错可能、哪些线程交错不可能的复杂得多。
- Ready for change 我们在线程安全参数中明确地写下这些理由,以便维护程序员知道代码的线程安全依赖于什么。