第十三章 并发编程
"对象是过程的抽象。线程是调度的抽象。"
--James O Coplien
13.1 为什么要并发
并发是一种解耦策略。它帮助我们把做什么(目的)和何时(时机)做分解开。在单线
程应用中,目的与时机紧密耦合,很多时候只要查看堆栈追路即可断定应用程序的状态。
解耦目的与时机能明显地改进应用程序的吞吐量和结构。从结构的角度来看,应用程序看起来更像是许多台协同工作的计算机,而不是一个大循环。系统因此会更易于被理解,给出了许多切分关注面的有力手段。
迷思与误解
- 并发总能改进性能
- 编写并发程序无需修改设计
- 在采用Web或EJB容器的时候,理解并发问题并不重要
中肯说法
- 并发会在性能和编写额外代码上增加一些开销;
- 正确的并发是复杂的,即便对于简单的问题也是如此;
- 并发缺陷并非总能重现,所以常被看做偶发事件而忽略,未被当做真正的缺陷看待;
- 并发常常需要对设计策略的根本性修改。
13.2 挑战
public class X{
private int lastIdUsed;
public int getNextId(){
return ++lastIdUsed;
}
}
比如,创建x的一个实体,将lastIdUsed设置为42,在两个线程中共享这个实体。假设这两个线程都调用getNextId()方法,结果可能有三种输出:
- 线程一得到值43,线程二得到值44,lastIdUsed为44;
- 线程一得到值44,线程二得到值43,lastIdUsed为44;
- 线程一得到值43,线程二得到值43,lastIdUsed为43。
就生成的字节码而言,对于在getNextId方法中执行的那两个线程,有12870种不同的可能执行路径。如果lastIdUsed的类型从int变为long,则可能路径的数量将增至2704156种。当然,多数路径都得到正确结果。问题是其中一些不能得到正确结果。
13.3 并发防御原则
13.3.1 单一权责原则
问题:
- 并发相关代码有自己的开发、修改和调优生命周期;
- 开发相关代码有自己要对付的挑战,和非并发相关代码不同,而且往往更为困难;
- 即便没有周边应用程序增加的负担,写得不好的并发代码可能的出错方式数量也已经足具挑战性。
建议:分离并发相关代码与其他代码。
13.3.2 推论:限制数据作用域
两个线程修改共享对象的同一字段时,可能互相干扰,导致未预期的行为。解决方案之一是采用synchronized关键字在代码中保护一块使用共享对象的临界区(criticalsection)。
可能出现的问题:
- 你会忘记保护一个或多个临界区——破坏了修改共享数据的代码码;
- 得多花力气保证一切都受到有效防护(破坏了DRY原则);
- 很难找到错误源,也很难判断错误源。
建议:谨记数据封装;严格限制对可能被共享的数据的访问。
13.3.3 推论:使用数据复本
避免共享数据的好方法之一就是一开始就避免共享数据。在某些情形下,有可能复制对象并以只读方式对待。在另外的情况下,有可能复制对象,从多个个线程收集所有复本的结果,并在单个线程中合并这些结果。
13.3.4 推论:线程应尽可能地独立
让每个线程在自己的世界中存在,不与其他线程共享数据。每个线程处理一个客户端请求,从不共享的源头接纳所有请求数据,存储为本地变量。这样一来,每个线程都像是世界中的唯一线程,没有同步需要。
建议:尝试将数据分解到可被独立线程(可能在不同处理器上)操作的独立子集。
13.4 了解Java库
- 使用类库提供的线程安全群集;
- 使用executor框架(executorframework)执行无关任务;
- 尽可能使用非锁定解决方案;
- 有几个类并不是线程安全的。
13.5 了解执行模型
13.5.1 生产者-消费者模型
生产者和消费者之间的队列是一种限定资源。
13.5.2 读者-作者模型
当存在一个主要为读者线程提供信息源,但只偶尔被作者线程更更新的共享资源,吞吐量就会是个问题。增加吞吐量,会导致线程饥饿和过时信息的累积。更新会影响吞吐量。
挑战之处在于平衡读者线程和作者线程的需求,实现正确操作,提供合理的吞吐量,避免线程饥饿。
13.5.3 宴席哲学家
如果没有用心设计,这种竞争式系统就会遭遇死锁、活锁、吞吐量和效率降低等问题。
可能遇到的并发问题,大多数都是这三个问题的变种。
建议:学习这些基础算法,理解其解决方案。
13.6 警惕同步方法之间的依赖
建议:避免使用一个共享对象的多个方法。
必须使用一个共享对象的多个方法的3种手段:
- 基于客户端的锁定——客户端代码在调用第一个方法前锁定服务端,确保锁的范围覆盖了调用最后一个方法的代码;
- 基于服务端的锁定——在服务端内创建锁定服务端的方法,调用所有方法,然后解锁。让客户端代码调用新方法;
- 适配服务端——创建执行锁定的中间层。这是一种基于服务端的的锁定的例子,但不修改原始服务端代码。
13.7 保持同步区域微小
关键字synchronized制造了锁。锁是昂贵的,因为它们带来了延迟和额外开销。
另一方面,临界区应该被保护起来。所以,应该尽可能少地设计临界区。
将同步延展到最小临界区范围之外,会增加资源争用、降低执行效率。
13.8 很难编写正确的关闭代码
平静关闭很难做到。常见问题与死锁有关,线程一直等待永远不会到来的信号。
建议:尽早考虑关闭问题,尽早令其工作正常。这会花费比你预期更多的时间。检视既有算法,因为这可能会比想象中难得多。
13.9 测试线程代码
建议:编写有潜力曝露问题的测试,在不同的编程配置、系统配置和负载条件下频繁运行。如果测试失败,跟踪错误。别因为后来测试通过了后来的运行就忽略失败。
- 将伪失败看作可能的线程问题 => 不要将系统错误归咎于偶发事件
- 先使非线程代码可工作 => 不要同时追踪非线程缺陷和线程缺陷。
- 编写可插拔的线程代码
- 编写可调整的线程代码
- 运行多于处理器数量的线程
- 在不同平台上运行
- 调整代码并强迫错误发生。
13.10 小结
第一要诀是遵循单一权责原则。
了解并发问题的可能原因。
学习类库,了解基本算法。
学习如何找到必须锁定的代码区域并锁定之。不要锁定不必针锁定的代码。
要能在不同平台上、以不同配置持续重复运行线程代码。
如果花点时间装置代码,就能极大地提升发现错误代码的机会。