13-17¶
13. 并发编程¶
“对象是过程的抽象。线程是调度的抽象。”
- 编写整洁的并发程序很难——非常难。编写在单线程中执行的代码简单得多。编写表面上看来不错、深入进去却支离破碎的多线程代码也简单。系统一旦遭受压力,这种代码就扛不住了。
13.1 为什么要并发¶
- 并发是一种解耦策略。它帮助我们把做什么(目的)和何时(时机)做分解开。在单线程应用中,目的与时机紧密耦合,很多时候只要查看堆栈追踪即可断定应用程序的状态。
- 解耦目的与时机能明显地改进应用程序的吞吐量和结构。从结构的角度来看,应用程序看起来更像是许多台协同工作的计算机,而不是一个大循环。系统因此会更易于被理解,给出了许多切分关注面的有力手段。
- 但结构并非采用并发的唯一动机。有些系统对响应时间和吞吐量有要求,需要手工编写并发解决方案。
以下常见的迷思和误解:
(1)并发总能改进性能并发有时能改进性能,但只在多个线程或处理器之间能分享大量等待时间的时候管用。事情没那么简单。
(2)编写并发程序无需修改设计事实上,并发算法的设计有可能与单线程系统的设计极不相同。目的与时机的解耦往往对系统结构产生巨大影响。
(3)在采用 Web 或 EJB 容器的时候,理解并发问题并不重要。实际上你最好了解容器在做什么,了解如何对付本章后文将提到的并发更新、死锁等问题。
下面是一些有关编写并发软件的中肯说法:
(1)并发会在性能和编写额外代码上增加一些开销;
(2)正确的并发是复杂的,即便对于简单的问题也是如此;
(3)并发缺陷并非总能重现,所以常被看做偶发事件而忽略,未被当做真的缺陷看待;
(4)并发常常需要对设计策略的根本性修改。
13.2 挑战¶
13.3 并发防御原则¶
1. 单一权责原则
- 单一权责原则(SRP)认为,方法/类/组件应当只有一个修改的理由。并发设计自身足够复杂到成为修改的理由,所以也该从其他代码中分离出来。
- 并发实现细节常常直接嵌入到其他生产代码中。下面是要考虑的一些问题:
- 并发相关代码有自己的开发、修改和调优生命周期;
- 开发相关代码有自己要对付的挑战,和非并发相关代码不同,而且往往更为困难;
- 即便没有周边应用程序增加的负担,写得不好的并发代码可能的出错方式数量也已经足具挑战性。
建议:分离并发相关代码与其他代码。
2. 推论:限制数据作用域
- 两个线程修改共享对象的同一字段时,可能互相干扰,导致未预期的行为。解决方案之一是采用 synchronized 关键字在代码中保护一块使用共享对象的临界区(critical section)。
- 更新共享数据的地方越多,就越可能:
- 你会忘记保护一个或多个临界区——破坏了修改共享数据的代码;
- 得多花力气保证一切都受到有效防护(破坏了 DRY 原则);
- 很难找到错误源,也很难判断错误源。
建议:谨记数据封装;严格限制对可能被共享的数据的访问。
3. 推论:使用数据复本
- 避免共享数据的好方法之一就是一开始就避免共享数据。在某些情形下,有可能复制对象并以只读方式对待。在另外的情况下,有可能复制对象,从多个线程收集所有复本的结果,并在单个线程中合并这些结果。
- 如果有避免共享数据的简易手段,结果代码就会大大减少导致错误的可能。你可能会关心创建额外对象的成本。值得试验一下看看那是否真是个问题。然而假使使用对象复本能避免代码同步执行,则因避免了锁定而省下的价值有可能补偿得上额外的创建成本和垃圾收集开销。
4. 推论:线程应尽可能地独立
- 让每个线程在自己的世界中存在,不与其他线程共享数据。每个线程处理一个客户端请求,从不共享的源头接纳所有请求数据,存储为本地变量。
建议:尝试将数据分解到可被独立线程(可能在不同处理器上)操作的独立子集。
13.4 了解Java库¶
- 在用 Java 5 编写线程代码时,要注意以下几点:
- 使用类库提供的线程安全群集;
- 使用 executor 框架(executor framework)执行无关任务;
- 尽可能使用非锁定解决方案;
- 有几个类并不是线程安全的。
建议:检读可用的类。对于 Java,掌握 java.util.concurrent 、java.util.concurrent.atomic 和 java.util.concurrent.locks。
13.5 了解执行模型¶
1. 生产者-消费者模型
- 一个或多个生产者线程创建某些工作,并置于缓存或队列中。一个或多个消费者线程从队列中获取并完成这些工作。生产者和消费者之间的队列是一种限定资源。
2. 读者-作者模型
- 当存在一个主要为读者线程提供信息源,但只偶尔被作者线程更新的共享资源,吞吐量就会是个问题。增加吞吐量,会导致线程饥饿和过时信息的累积。更新会影响吞吐量。
- 挑战之处在于平衡读者线程和作者线程的需求,实现正确操作,提供合理的吞吐量,避免线程饥饿。
3. 宴席哲学家
- 用线程代替哲学家,用资源代替叉子,就变成了许多企业级应用中进程竞争资源的情形。如果没有用心设计,这种竞争式系统就会遭遇死锁、活锁、吞吐量和效率降低等问题。
建议:学习这些基础算法,理解其解决方案。
13.6 警惕同步方法之间的依赖¶
- 同步方法之间的依赖会导致并发代码中的狡猾缺陷。Java 语言有 synchronized 概念,可以用来保护单个方法。
建议:避免使用一个共享对象的多个方法。
- 有时必须使用一个共享对象的多个方法。在这种情况发生时,有3种写对代码的手段:
(1)基于客户端的锁定——客户端代码在调用第一个方法前锁定服务端,确保锁的范围覆盖了调用最后一个方法的代码;
(2)基于服务端的锁定——在服务端内创建锁定服务端的方法,调用所有方法,然后解锁。让客户端代码调用新方法;
(3)适配服务端——创建执行锁定的中间层。这是一种基于服务端的锁定的例子,但不修改原始服务端代码。
13.7 保持同步区域微小¶
- 关键字 synchronized 制造了锁。同一个锁维护的所有代码区域在任一时刻保证只有一个线程执行。锁是昂贵的,因为它们带来了延迟和额外开销。所以我们不愿将代码扔给 synchronized 语句了事。另一方面,临界区应该被保护起来。所以,应该尽可能少地设计临界区。
- 如果将同步延展到最小临界区范围之外,会加剧资源争用,降低执行效率。
建议:尽可能减小同步区域。
13.8 很难编写正确的关闭代码¶
- 编写永远运行的系统,与编写运行一段时间后平静地关闭的系统是两码事。平静关闭很难做到,常见问题与死锁有关。
- 如果你要编写涉及平静关闭的并发代码,请多预留一些时间搞对关闭过程。
建议:尽早考虑关闭问题,尽早令其工作正常。这会花费比你预期更多的时间。检视既有算法,因为这可能会比想象中难得多。
13.9 测试线程代码¶
- 证明代码的正确性不切实际。测试并不能确保正确性。然而,好的测试却能尽量降低风险。这对于所有单线程解决方案都是对的。当有两个或多个线程使用同一代码段和共享数据,事情就变得非常复杂了。
建议:编写有潜力曝露问题的测试,在不同的编程配置、系统配置和负载条件下频繁运行。如果测试失败,跟踪错误。别因为后来测试通过了后来的运行就忽略失败。
有一大堆问题要考虑。下面是一些精练的建议:
1. 将伪失败看作可能的线程问题
- 线程代码导致“不可能失败的”失败。多数开发者缺乏有关线程如何与其他代码(可能由其他作者编写)互动的直觉。线程代码中的缺陷可能在一千或一百万次执行中才会显现一次。
- “偶发事件”被忽略得越久,代码就越有可能搭建于不完善的基础之上。
建议:不要将系统错误归咎于偶发事件。
2. 先使非线程代码可工作
- 确保线程之外的代码可工作。通常,这意味着创建由线程调用的 POJO。
建议:不要同时追踪非线程缺陷和线程缺陷。确保代码在线程之外可工作。
3. 编写可插拔的线程代码
- 编写可在数个配置环境下运行的线程代码:
- 单线程与多个线程在执行时不同的情况;
- 线程代码与实物或测试替身互动;
- 用运行快速、缓慢和有变动的测试替身执行;
- 将测试配置为能运行一定数量的迭代。
建议:编写可插拔的线程代码,这样就能在不同的配置环境下运行。
4. 编写可调整的线程代码
- 要获得良好的线程平衡,常常需要试错。一开始,在不同的配置环境下监测系统性能。要允许线程数量可调整。在系统运行时允许线程发生变动。允许线程依据吞吐量和系统使用率自我调整。
5. 运行多于处理器数量的线程
- 系统在切换任务时会发生一些事。为了促使任务交换的发生,运行多于处理器或处理器核心数量的线程。任务交换越频繁,越有可能找到错过临界区或导致死锁的代码。
6. 在不同平台上运行
- 被测试的代码已知是不正确的。这正强调了不同操作系统有着不同线程策略的事实,不同的线程策略影响了代码的执行。
建议:尽早并经常地在所有目标平台上运行线程代码。
7. 调整代码并强迫错误发生
- 并发代码中藏有缺陷,这并不罕见。简单的测试往往无法曝露这些缺陷。实际上,缺陷经常隐藏于一般处理过程中。线程中的缺陷之所以如此不频繁、偶发、难以重现,是因为在几千个穿过脆弱区域的可能路径当中,只有少数路径会真的导致失败。
- 这些方法都会影响执行顺序,从而增加了侦测到缺陷的可能性。有问题的代码,最好尽早、尽可能多地通不过测试。有两种装置代码的方法:硬编码和自动化。
- 硬编码:我们所需要的是一种在测试中但不在生产中实现的手段。我们还需要为多次运行轻易地调整配置,从而增加总的发现错误机会。如果将系统分解为对线程及控制线程的类一无所知的 POJO,就能更容易地找到装置代码的位置。而且还能创建许多个以不同方式调用 sleep、yield 等方法的 POJO 测试。
- 自动化:想象 ThreadJigglePoint 类有两种实现。第一种实现 jiggle 什么都不做,在生产环境中使用。第二种实现生成一个随机数,在睡眠、让步或径直执行间做选择。要点是让代码“异动”,从而使线程以不同次序执行。编写良好的测试与“异动”相组合,能有效地增加发现错误的机会。
建议:使用异动策略搜出错误。
13.10 小结¶
- 并发代码很难写正确。加入多线程和共享数据后,简单的代码也会变成噩梦。要编写并发代码,就得严格地编写整洁的代码,否则将面临微细和不频繁发生的失败。
- 第一要诀是遵循单一权责原则。将系统切分为分离了线程相关代码和线程无关代码的 POJO。
- 了解并发问题的可能原因:对共享数据的多线程操作,或使用了公共资源池。类似平静关闭或停止循环之类边界情况尤其棘手。
- 学习如何找到必须锁定的代码区域并锁定之。不要锁定不必锁定的代码。避免从锁定区域中调用其他锁定区域。这需要深刻理解某物是否已共享。尽可能减少共享对象和共享范围。修改对象的设计,向客户代码提供共享数据,而不是迫使客户代码管理共享状态
- 问题会跳出来。那种在早期没跳出来的问题往往是偶发的。这种所谓偶发问题,通常仅在高负载下出现或者偶然出现。所以你要能在不同平台上、以不同配置持续重复运行线程代码。跟随 TDD 三要则而来的可测试性意味着某种程度的可插拔性,从而提供了在大量不同配置下运行代码的必要支持。
- 如果花点时间装置代码,就能极大地提升发现错误代码的机会。可以手工做,也可以使用某种自动化技术。尽早这么做。在将线程代码投入生产环境前,就要尽可能多地运行它。
只要采用了整洁的做法,做对的可能性就有翻天覆地的提高。
14. 逐步改进¶
- 如果说我们从过去几十年里面学到什么东西的话,那就是编程是一种技艺甚于科学的东西。要编写整洁代码,必须先写肮脏代码,然后再清理它。
- 多数新手程序员(就像多数小学生一样)没有特别认真地遵循这个建议。他们相信,首要任务是写出能工作的程序。只要程序“能工作”,就转移到下一个任务上,而那个“能工作”的程序就留在了最后那个所谓“能工作”的状态。多数老手程序员都知道,这是一种自毁行为。
- 代码能工作还不够。能工作的代码经常会严重崩溃。满足于仅仅让代码能工作的程序员不够专业。他们会害怕没时间改进代码的结构和设计,我不敢苟同。没什么能比糟糕的代码给开发项目带来更深远和长期的损害了。进度可以重订,需求可以重新定义,团队动态可以修正。但糟糕的代码只是一直腐败发酵,无情地拖着团队的后腿。我无数次看到开发团队蹒跚前行,只因为他们匆匆搞出一片代码沼泽,从此之后命运再也不受自己控制。
- 当然糟糕的代码可以清理,不过成本高昂。随着代码腐败下去,模块之间互相渗透,出现大量隐藏纠结的依赖关系。找到和破除陈旧的依赖关系又费时间又费劲。另一方面,保持代码整洁却相对容易。早晨在模块中制造出一堆混乱,下午就能轻易清理掉。更好的情况是,5分钟之前制造出混乱,马上就能很容易地清理掉。
- 所以,解决之道就是保持代码持续整洁和简单。永不让腐坏有机会开始。
15. JUnit内幕¶
16. 重构SerialDate¶
17. 味道与启发¶
17.1 注释¶
- C1:不恰当的信息
- C2:废弃的注释
- C3:冗余注释
- C4:糟糕的注释
- C5:注释掉的代码
17.2 环境¶
- E1:需要多步才能实现的构建
- E2:需要多步才能做到的测试
17.3 函数¶
- F1:过多的参数
- F2:输出参数
- F3:标识参数
- F4:死函数
17.4 一般性问题¶
- G1:一个源文件中存在多种语言
- G2:明显的行为未被实现
- G3:不正确的边界行为
- G4:忽视安全
- G5:重复
- G6:在错误的抽象层级上的代码
- G7:基类依赖于派生类
- G8:信息过多
- G9:死代码
- G10:垂直分隔
- G11:前后不一致
- G12:混淆视听
- G13:人为耦合
- G14:特性依恋
- G15:选择算子参数
- G16:晦涩的意图
- G17:位置错误的权责
- G18:不恰当的静态方法
- G19:使用解释性变量
- G20:函数名称应该表达其行为
- G21:理解算法
- G22:把逻辑依赖改为物理依赖
- G23:用多态替代 If/Else 或 Switch/Case
- G24:遵循标准约定
- G25:用命名常量替代魔术数
- G26:准确
- G27:结构甚于约定
- G28:封装条件
- G29:避免否定性条件
- G30:函数只该做一件事
- G31:掩蔽时序耦合
- G32:别随意
- G33:封装边界条件
- G34:函数应该只在一个抽象层级上
- G35:在较高层级放置可配置数据
- G36:避免传递浏览
17.5 Java¶
- J1:通过使用通配符避免过长的导入清单
- J2:不要继承常量
- J3:常量 vs.枚举
17.6 名称¶
- N1:采用描述性名称
- N2:名称应与抽象层级相符
- N3:尽可能使用标准命名法
- N4:无歧义的名称
- N5:为较大作用范围选用较长名称
- N6:避免编码
- N7:名称应该说明副作用
17.7 测试¶
- T1:测试不足
- T2:使用覆盖率工具
- T3:别略过小测试
- T4:被忽略的测试就是对不确定事物的疑问
- T5:测试边界条件
- T6:全面测试相近的缺陷
- T7:测试失败的模式有启发性
- T8:测试覆盖率的模式有启发性
- T9:测试应该快速
17.8 小结¶
这份启发与味道的清单很难说已完备无缺。我不能确定这样一份清单会不会完备无缺。但或许完整性不该是目标,因为该清单确实给出了一套价值体系。
那套价值体系才该是目标,也是本书的主题所在。整洁代码并非遵循一套规则写就。学习一系列启发并不足以让你成为软件匠人。专业性和技艺来自于驱动规程的价值观。