[读书笔记] 代码整洁之道(七): 并发编程
第十三章 并发编程
-
为什么要并发
并发是一种解耦策略。它帮我们把做什么(目的)和何时(时机)做分解。一般的单线程应用中,目的与时机紧密耦合,很多时候查看堆栈信息就能断定应用程序的状态。
解耦目的与时机能明显地改进应用程序的吞吐量和结构。
迷思和误解:- 并发总能改变性能(X): 但只是在多个线程或处理器之间能分享大量等待时间的时候管用。
- 编写并发程序无需修改设计(X):目的与时机的解耦往往对系统结构产生巨大影响。
- 在采用Web或者EJB容器的时候,理解并发问题并不重要(X):最好了解容器在做什么,以及怎么解决并发更新、死锁等问题。
一些中肯的说法:
- 并发会在性能和编写额外代码上增加一些开销;
- 正确的并发是复杂的,即便对于简单的问题也是如此;
- 并发缺陷并非总能重现,所以常被看做偶发事件而忽略;
- 并发常常需要对设计策略的根本性修改;
-
并发防御原则和技巧
1) 单一权责原则SRP:认为方法、类、组件应当只有一个修改的理由。然而并发设计有时会相当复杂,以至于修改代码,所以建议分离并发相关代码与其他生产代码。
2) 推论:限制数据作用域:如果两个线程修改共享对象的同一个字段时,可能会互相干扰,导致未预期的行为。建议采用synchronized关键字在代码中保护一块使用共享对象的临界区。同时要谨记数据封装:严格限制对可能被共享的数据的访问。
3) 推论:使用数据复本:避免数据共享的好方法之一就是一开始就避免共享数据。
4) 推论:线程应尽可能的独立:每个线程都不与其他线程共享数据。每个线程处理一个客户端请求,从不共享的源头接纳所有请求数据,存储为本地变量。建议尝试将数据分解到可被独立线程(可能在不同处理器上)操作的独立子集。 -
了解Java库
使用Java 5以上版本编写线程时要注意:
1) 使用类库提供的线程安全群集
2) 使用executor框架执行无关任务
3) 尽可能使用非锁解决方案
4) 有几个类并不是线程安全的
对于线程安全集群,需要掌握java.util.concurrent、java.util.concurrent.atomic、java.util.concurrent.locks等 -
了解执行模型
一些基础定义:
- 限定资源:并发环境中有着固定尺寸或数量的资源。比如数据库连接和固定尺寸读/写缓存等。
- 互斥:每一时刻仅有一个线程能访问共享数据或共享资源。
- 线程饥饿:一个或一组线程在很长时间内或永久被禁止。例如总让执行的快的线程先运行,加入执行的快的线程没完没了,则执行时间长的线程就会饥饿。
- 死锁:两个或多个线程互相等待执行结束。每个线程都拥有其他线程需要的资源,得不到其他线程拥有的资源,就无法终止。
- 活锁:执行次序一致的线程,每个都想要起步,但发现其他线程已经在路上。由于竞步的原因,线程会持续尝试起步,但在很长时间内都无法如愿,甚至永远无法启动。
1) 生产者-消费者模型:一个或多个生产者线程创建某些工作,并置于缓存或队列中。一个或多个消费者线程从队列中获取并完成这些工作。生产者和消费者之间的队列是一种限定资源。
2) 读者-作者模型:当存在一个主要为读者线程提供信息,但只是偶尔被作者线程更新的共享资源,吞吐量就会是问题。增加吞吐量,会导致线程饥饿或过时信息的积累。更新会影响吞吐量。
3) 宴席哲学家:这种竞争式的问题会遭遇死锁、活锁、吞吐量、和效率降低等问题。
建议学习及研究这些基础算法,理解解决方案。 -
警惕同步方法之间的依赖
Java的synchronized可以保护单个方法,但是如果在同一个共享类中有多个同步方法,系统就可能出错:
建议:避免使用一个共享对象的多个方法。
但有时必须使用一个共享对象的多个方法,可以使用一下3种手段:
1)基于客户端的锁定:客户端代码在调用第一个方法时,锁定服务器端,确保锁的范围覆盖了调用最后一个方法的代码。
2)基于服务端的锁定:在服务端内创建锁定服务端的方法,调用所有方法,然后解锁,让客户端代码调用新方法。
3)适配服务端:创建执行锁定的中间层。这是一种基于服务器端的锁定的例子,但不修改原始服务端的代码。 -
保持同步区域微小
锁的代价很昂贵,一个锁维护的代码区域在任一时刻都只有一个线程执行,带来了延迟和额外开销。建议尽可能的减小同步区域。
-
很难编写正确的关闭代码
尽可能考虑关闭问题,今早令其工作正常。
-
测试线程代码
当两个线程或多个线程使用同一段代码和共享数据,测试就比较复杂。
建议:编写有潜力暴露问题的测试,在不同的编程配置、系统配置和负载条件下频繁运行。
精炼建议:- 将伪失败看做可能的线程问题:千万不能将系统错误归咎于偶发事件。
- 先使非线程代码可工作:确保代码在线程之外可工作。
- 编写可拔插的线程代码
- 编写可调整的线程代码
- 运行多于处理器数量的线程:任务交换越频繁,越有可能找到错过临界区或导致死锁的代码。
- 在不同平台上运行
- 调整代码并强迫错误发生:通过wait()、sleep()、yield()、priority()等调用。