Skip to content

这里是《Clean Code》(代码整洁之道)第13章 "Concurrency" 的中英对照翻译。


💡 总结与小计 (Summary & Key Takeaways)

本章核心主旨: 本章探讨了并发编程(Concurrency)的复杂性及其在软件架构中的作用。并发是一种解耦策略(将“做什么”与“何时做”分开),虽然能提高吞吐量和响应速度,但也带来了死锁、竞态条件等难以复现的 Bug。作者强调,编写整洁的并发代码需要极其严谨的纪律,遵循单一职责原则(SRP),严格限制数据共享,并采用专门的测试策略。

关键知识点 (Key Takeaways):

  1. 解耦与结构:并发不仅仅为了性能,更是为了解耦程序的结构,使系统看起来像是一组协作的小型计算机。
  2. 防御原则
    • 单一职责原则 (SRP):将并发逻辑与业务逻辑彻底分离。
    • 限制数据作用域:严格封装共享数据,尽量减少临界区。
    • 使用数据副本:能复制数据就不要共享,避免同步开销。
    • 线程独立:尽量让每个线程独立运行,不与其他线程共享数据。
  3. 了解类库:熟悉 Java 5+ 提供的 java.util.concurrent 包(如 ConcurrentHashMap),使用现成的线程安全集合而非手动同步。
  4. 执行模型:理解经典的并发模型(生产者-消费者、读者-作者、哲学家就餐),因为大多数并发问题都是这些模型的变体。
  5. 测试策略
    • 不要忽略“偶发”故障,它们往往是真正的线程问题。
    • 先在非线程环境下保证逻辑正确。
    • 插桩 (Instrumentation):通过添加 wait(), sleep(), yield() 或使用自动化工具来“扰动”代码,强迫隐藏的并发错误暴露出来。

📖 中英对照翻译

第 13 章 Concurrency (并发编程)

by Brett L. Schuchert

“Objects are abstractions of processing. Threads are abstractions of schedule.” “对象是对过程的抽象。线程是对调度的抽象。”

—James O. Coplien1

  1. Private correspondence.
  2. 私人通信。

Writing clean concurrent programs is hard—very hard. It is much easier to write code that executes in a single thread. It is also easy to write multithreaded code that looks fine on the surface but is broken at a deeper level. Such code works fine until the system is placed under stress. 编写整洁的并发程序很难——非常难。编写单线程执行的代码要容易得多。编写那些表面上看没问题但深层却存在问题的多线程代码也很容易。这样的代码在系统面临压力之前都能正常工作。

In this chapter we discuss the need for concurrent programming, and the difficulties it presents. We then present several recommendations for dealing with those difficulties, and writing clean concurrent code. Finally, we conclude with issues related to testing concurrent code. 在本章中,我们将讨论并发编程的必要性及其带来的困难。然后,我们将提出一些处理这些困难和编写整洁并发代码的建议。最后,我们将总结与测试并发代码相关的问题。

Clean Concurrency is a complex topic, worthy of a book by itself. Our strategy in this book is to present an overview here and provide a more detailed tutorial in “Concurrency II” on page 317. If you are just curious about concurrency, then this chapter will suffice for you now. If you have a need to understand concurrency at a deeper level, then you should read through the tutorial as well. 整洁的并发是一个复杂的话题,足以单独写一本书。我们在本书中的策略是在这里提供一个概述,并在第 317 页的“并发编程 II”中提供更详细的教程。如果你只是对并发感到好奇,那么本章现在就足够了。如果你需要更深层次地理解并发,那么你应该同时也通读那篇教程。

WHY CONCURRENCY?

为什么要并发?

Concurrency is a decoupling strategy. It helps us decouple what gets done from when it gets done. In single-threaded applications what and when are so strongly coupled that the state of the entire application can often be determined by looking at the stack backtrace. A programmer who debugs such a system can set a breakpoint, or a sequence of breakpoints, and know the state of the system by which breakpoints are hit. 并发是一种解耦策略。它帮助我们将“做什么”与“何时做”解耦。在单线程应用程序中,“做什么”和“何时做”紧密耦合,以至于整个应用程序的状态通常可以通过查看堆栈回溯来确定。调试此类系统的程序员可以设置一个断点或一系列断点,并通过命中哪些断点来了解系统的状态。

Decoupling what from when can dramatically improve both the throughput and structures of an application. From a structural point of view the application looks like many little collaborating computers rather than one big main loop. This can make the system easier to understand and offers some powerful ways to separate concerns. 将“做什么”与“何时做”解耦可以极大地提高应用程序的吞吐量和结构。从结构的角度来看,应用程序看起来像是许多小型协作计算机,而不是一个巨大的主循环。这可以使系统更容易理解,并提供了一些分离关注点的强大方法。

Consider, for example, the standard “Servlet” model of Web applications. These systems run under the umbrella of a Web or EJB container that partially manages concurrency for you. The servlets are executed asynchronously whenever Web requests come in. The servlet programmer does not have to manage all the incoming requests. In principle, each servlet execution lives in its own little world and is decoupled from all the other servlet executions. 例如,考虑 Web 应用程序的标准“Servlet”模型。这些系统运行在 Web 或 EJB 容器的保护伞下,这些容器为你部分地管理并发。每当 Web 请求进入时,Servlet 就会被异步执行。Servlet 程序员不必管理所有的传入请求。原则上,每个 Servlet 执行都存在于它自己的小世界中,并与所有其他 Servlet 执行解耦。

Of course if it were that easy, this chapter wouldn’t be necessary. In fact, the decoupling provided by Web containers is far less than perfect. Servlet programmers have to be very aware, and very careful, to make sure their concurrent programs are correct. Still, the structural benefits of the servlet model are significant. 当然,如果事情那么简单,本章就没有必要存在了。事实上,Web 容器提供的解耦远非完美。Servlet 程序员必须非常清醒、非常小心,以确保他们的并发程序是正确的。尽管如此,Servlet 模型的结构优势还是显著的。

But structure is not the only motive for adopting concurrency. Some systems have response time and throughput constraints that require hand-coded concurrent solutions. For example, consider a single-threaded information aggregator that acquires information from many different Web sites and merges that information into a daily summary. Because this system is single threaded, it hits each Web site in turn, always finishing one before starting the next. The daily run needs to execute in less than 24 hours. However, as more and more Web sites are added, the time grows until it takes more than 24 hours to gather all the data. The single-thread involves a lot of waiting at Web sockets for I/O to complete. We could improve the performance by using a multithreaded algorithm that hits more than one Web site at a time. 但结构并非采用并发的唯一动机。有些系统具有响应时间和吞吐量的限制,需要手工编写并发解决方案。例如,考虑一个单线程的信息聚合器,它从许多不同的网站获取信息,并将这些信息合并成每日摘要。因为这个系统是单线程的,它依次访问每个网站,总是在开始下一个之前完成前一个。每日运行需要在 24 小时内完成。然而,随着越来越多的网站被添加进来,所需时间会增加,直到收集所有数据花费超过 24 小时。单线程涉及在 Web 套接字上进行大量的等待,等待 I/O 完成。我们可以通过使用多线程算法来提高性能,该算法一次访问多个网站。

Or consider a system that handles one user at a time and requires only one second of time per user. This system is fairly responsive for a few users, but as the number of users increases, the system’s response time increases. No user wants to get in line behind 150 others! We could improve the response time of this system by handling many users concurrently. 或者考虑一个系统,它一次处理一个用户,每个用户只需要一秒钟的时间。对于少数用户来说,这个系统的响应相当快,但随着用户数量的增加,系统的响应时间也会增加。没有用户愿意排在 150 人后面!我们可以通过并发处理许多用户来改善该系统的响应时间。

Or consider a system that interprets large data sets but can only give a complete solution after processing all of them. Perhaps each data set could be processed on a different computer, so that many data sets are being processed in parallel. 或者考虑一个解释大型数据集的系统,它只有在处理完所有数据后才能给出完整的解决方案。也许每个数据集可以在不同的计算机上处理,以便并行处理许多数据集。

Myths and Misconceptions

迷思与误解

And so there are compelling reasons to adopt concurrency. However, as we said before, concurrency is hard. If you aren’t very careful, you can create some very nasty situations. Consider these common myths and misconceptions: 因此,采用并发有令人信服的理由。然而,正如我们之前所说,并发很难。如果你不非常小心,可能会制造出一些非常糟糕的情况。考虑以下常见的迷思和误解:

  • Concurrency always improves performance.并发总是能提高性能。 Concurrency can sometimes improve performance, but only when there is a lot of wait time that can be shared between multiple threads or multiple processors. Neither situation is trivial. 并发有时可以提高性能,但只有在存在大量等待时间且可以由多个线程或多个处理器分担时才行。这两种情况都不简单。

  • Design does not change when writing concurrent programs.编写并发程序时设计不需要改变。 In fact, the design of a concurrent algorithm can be remarkably different from the design of a single-threaded system. The decoupling of what from when usually has a huge effect on the structure of the system. 事实上,并发算法的设计可能与单线程系统的设计截然不同。“做什么”与“何时做”的解耦通常对系统结构有巨大影响。

  • Understanding concurrency issues is not important when working with a container such as a Web or EJB container.在使用 Web 或 EJB 容器等容器时,理解并发问题并不重要。 In fact, you’d better know just what your container is doing and how to guard against the issues of concurrent update and deadlock described later in this chapter. 事实上,你最好知道你的容器在做什么,以及如何防范本章稍后描述的并发更新和死锁问题。

Here are a few more balanced sound bites regarding writing concurrent software: 以下是关于编写并发软件的一些更客观的观点:

  • Concurrency incurs some overhead, both in performance as well as writing additional code. 并发会带来一些开销,包括性能上的以及编写额外代码上的。
  • Correct concurrency is complex, even for simple problems. 正确的并发是复杂的,即使对于简单的问题也是如此。
  • Concurrency bugs aren’t usually repeatable, so they are often ignored as one-offs2 instead of the true defects they are. 并发 Bug 通常不可复现,因此它们经常被当作“偶发事件”忽略,而不是被视为真正的缺陷。
  1. Cosmic-rays, glitches, and so on.
  2. 宇宙射线、小故障等等。
  • Concurrency often requires a fundamental change in design strategy. 并发通常需要设计策略上的根本性改变。

CHALLENGES

挑战

What makes concurrent programming so difficult? Consider the following trivial class: 是什么让并发编程如此困难?考虑下面这个简单的类:

java
   public class X {
      private int lastIdUsed;

      public int getNextId() {
           return ++lastIdUsed;
       }
   }

Let’s say we create an instance of X, set the lastIdUsed field to 42, and then share the instance between two threads. Now suppose that both of those threads call the method getNextId(); there are three possible outcomes: 假设我们创建了一个 X 的实例,将 lastIdUsed 字段设置为 42,然后在两个线程之间共享该实例。现在假设这两个线程都调用 getNextId() 方法;有三种可能的结果:

  • Thread one gets the value 43, thread two gets the value 44, lastIdUsed is 44. 线程一得到值 43,线程二得到值 44,lastIdUsed 是 44。

  • Thread one gets the value 44, thread two gets the value 43, lastIdUsed is 44. 线程一得到值 44,线程二得到值 43,lastIdUsed 是 44。

  • Thread one gets the value 43, thread two gets the value 43, lastIdUsed is 43. 线程一得到值 43,线程二得到值 43,lastIdUsed 是 43。

The surprising third result3 occurs when the two threads step on each other. This happens because there are many possible paths that the two threads can take through that one line of Java code, and some of those paths generate incorrect results. How many different paths are there? To really answer that question, we need to understand what the Just-In-Time Compiler does with the generated byte-code, and understand what the Java memory model considers to be atomic. 令人惊讶的第三种结果发生在两个线程互相踩踏时。这是因为这两个线程在执行那一行 Java 代码时有许多可能的路径,而其中一些路径会产生错误的结果。有多少种不同的路径?要真正回答这个问题,我们需要了解即时编译器(Just-In-Time Compiler)如何处理生成的字节码,并了解 Java 内存模型认为什么是原子操作。

  1. See “Digging Deeper” on page 323.
  2. 参见第 323 页的“深入挖掘”。

A quick answer, working with just the generated byte-code, is that there are 12,870 different possible execution paths4 for those two threads executing within the getNextId method. If the type of lastIdUsed is changed from int to long, the number of possible paths increases to 2,704,156. Of course most of those paths generate valid results. The problem is that some of them don’t. 仅就生成的字节码而言,一个快速的答案是,对于在 getNextId 方法中执行的那两个线程,有 12,870 种不同的可能执行路径。如果 lastIdUsed 的类型从 int 更改为 long,可能的路径数量将增加到 2,704,156。当然,大多数路径都会产生有效的结果。问题在于其中一些不会。

  1. See “Possible Paths of Execution” on page 321.
  2. 参见第 321 页的“可能的执行路径”。

CONCURRENCY DEFENSE PRINCIPLES

并发防御原则

What follows is a series of principles and techniques for defending your systems from the problems of concurrent code. 接下来是一系列原则和技术,用于保护你的系统免受并发代码问题的影响。

Single Responsibility Principle

单一职责原则

The SRP5 states that a given method/class/component should have a single reason to change. Concurrency design is complex enough to be a reason to change in it’s own right and therefore deserves to be separated from the rest of the code. Unfortunately, it is all too common for concurrency implementation details to be embedded directly into other production code. Here are a few things to consider: SRP 规定,给定的方法/类/组件应该只有一个引起变化的原因。并发设计本身就足够复杂,足以成为一个变化的原因,因此值得与其余代码分离。不幸的是,并发实现细节直接嵌入到其他生产代码中太常见了。这里有几件事需要考虑:

  1. [PPP]
  • Concurrency-related code has its own life cycle of development, change, and tuning. 并发相关代码有其自己的开发、更改和调优生命周期。
  • Concurrency-related code has its own challenges, which are different from and often more difficult than nonconcurrency-related code. 并发相关代码有其自身的挑战,这些挑战不同于非并发相关代码,通常也更困难。
  • The number of ways in which miswritten concurrency-based code can fail makes it challenging enough without the added burden of surrounding application code. 编写错误的并发代码可能导致失败的方式之多,使其本身就具有足够的挑战性,无需再加上周围应用程序代码的负担。

Recommendation: Keep your concurrency-related code separate from other code.6 建议:将并发相关代码与其他代码分开。

  1. See “Client/Server Example” on page 317.
  2. 参见第 317 页的“客户端/服务器示例”。

Corollary: Limit the Scope of Data

推论:限制数据作用域

As we saw, two threads modifying the same field of a shared object can interfere with each other, causing unexpected behavior. One solution is to use the synchronized keyword to protect a critical section in the code that uses the shared object. It is important to restrict the number of such critical sections. The more places shared data can get updated, the more likely: 正如我们所见,两个线程修改共享对象的同一个字段可能会相互干扰,导致意外行为。一种解决方案是使用 synchronized 关键字来保护使用共享代码的临界区(critical section)。限制此类临界区的数量非常重要。共享数据能被更新的地方越多,就越可能发生以下情况:

  • You will forget to protect one or more of those places—effectively breaking all code that modifies that shared data. 你会忘记保护其中一个或多个地方——从而破坏了所有修改该共享数据的代码。
  • There will be duplication of effort required to make sure everything is effectively guarded (violation of DRY7). 为了确保一切都得到有效保护,需要重复付出努力(违反 DRY 原则)。
  1. [PRAG].
  • It will be difficult to determine the source of failures, which are already hard enough to find. 将很难确定故障的来源,而故障本来就已经很难找到了。

Recommendation: Take data encapsulation to heart; severely limit the access of any data that may be shared. 建议:牢记数据封装;严格限制对任何可能共享的数据的访问。

Corollary: Use Copies of Data

推论:使用数据副本

A good way to avoid shared data is to avoid sharing the data in the first place. In some situations it is possible to copy objects and treat them as read-only. In other cases it might be possible to copy objects, collect results from multiple threads in these copies and then merge the results in a single thread. 避免共享数据的一个好方法是一开始就避免共享数据。在某些情况下,可以复制对象并将其视为只读。在其他情况下,可以复制对象,从多个线程的这些副本中收集结果,然后在单个线程中合并结果。

If there is an easy way to avoid sharing objects, the resulting code will be far less likely to cause problems. You might be concerned about the cost of all the extra object creation. It is worth experimenting to find out if this is in fact a problem. However, if using copies of objects allows the code to avoid synchronizing, the savings in avoiding the intrinsic lock will likely make up for the additional creation and garbage collection overhead. 如果有简单的方法来避免共享对象,由此产生的代码就不太可能导致问题。你可能会担心所有额外对象创建的成本。值得试验一下,看看这是否真的是一个问题。然而,如果使用对象副本可以让代码避免同步,那么避免内部锁所节省的开销很可能会弥补额外的创建和垃圾收集开销。

Corollary: Threads Should Be as Independent as Possible

推论:线程应尽可能独立

Consider writing your threaded code such that each thread exists in its own world, sharing no data with any other thread. Each thread processes one client request, with all of its required data coming from an unshared source and stored as local variables. This makes each of those threads behave as if it were the only thread in the world and there were no synchronization requirements. 尝试编写你的线程代码,使每个线程都存在于它自己的世界中,不与任何其他线程共享数据。每个线程处理一个客户端请求,其所有所需数据都来自未共享的源并存储为局部变量。这使得每个线程表现得好像它是世界上唯一的线程,没有同步要求。

For example, classes that subclass from HttpServlet receive all of their information as parameters passed in to the doGet and doPost methods. This makes each Servlet act as if it has its own machine. So long as the code in the Servlet uses only local variables, there is no chance that the Servlet will cause synchronization problems. Of course, most applications using Servlets eventually run into shared resources such as database connections. 例如,继承自 HttpServlet 的类通过传递给 doGetdoPost 方法的参数接收所有信息。这使得每个 Servlet 表现得好像拥有自己的机器。只要 Servlet 中的代码仅使用局部变量,Servlet 就没有机会导致同步问题。当然,大多数使用 Servlet 的应用程序最终都会遇到共享资源,如数据库连接。

Recommendation: Attempt to partition data into independent subsets than can be operated on by independent threads, possibly in different processors. 建议:尝试将数据划分为可以由独立线程(可能在不同的处理器中)操作的独立子集。

KNOW YOUR LIBRARY

了解你的类库

Java 5 offers many improvements for concurrent development over previous versions. There are several things to consider when writing threaded code in Java 5: Java 5 为并发开发提供了许多优于旧版本的改进。在 Java 5 中编写线程代码时,有几件事需要考虑:

  • Use the provided thread-safe collections. 使用提供的线程安全集合。
  • Use the executor framework for executing unrelated tasks. 使用执行器(executor)框架来执行不相关的任务。
  • Use nonblocking solutions when possible. 尽可能使用非阻塞解决方案。
  • Several library classes are not thread safe. 一些库类不是线程安全的。

Thread-Safe Collections

线程安全集合

When Java was young, Doug Lea wrote the seminal book8 Concurrent Programming in Java. Along with the book he developed several thread-safe collections, which later became part of the JDK in the java.util.concurrent package. The collections in that package are safe for multithreaded situations and they perform well. In fact, the ConcurrentHashMap implementation performs better than HashMap in nearly all situations. It also allows for simultaneous concurrent reads and writes, and it has methods supporting common composite operations that are otherwise not thread safe. If Java 5 is the deployment environment, start with ConcurrentHashMap. 当 Java 还年轻的时候,Doug Lea 写了一本开创性的书《Java 并发编程》。伴随着这本书,他开发了几个线程安全的集合,后来这些集合成为了 JDK 中 java.util.concurrent 包的一部分。该包中的集合在多线程情况下是安全的,并且性能良好。事实上,ConcurrentHashMap 的实现在几乎所有情况下的表现都优于 HashMap。它还允许同时进行并发读写,并且拥有支持常见复合操作的方法,而这些操作原本不是线程安全的。如果 Java 5 是部署环境,请从 ConcurrentHashMap 开始。

  1. [Lea99].

There are several other kinds of classes added to support advanced concurrency design. Here are a few examples: 还有几种其他类型的类被添加进来以支持高级并发设计。以下是一些示例:

ClassDescription
ReentrantLockA lock that can be acquired in one method and released in another.
可以在一个方法中获取并在另一个方法中释放的锁。
SemaphoreAn implementation of the classic semaphore, a lock with a count.
经典信号量的实现,即带有计数的锁。
CountDownLatchA lock that waits for a number of events before releasing all threads waiting on it. This allows all threads to have a fair chance of starting at about the same time.
一种锁,它等待一定数量的事件发生后,才释放所有等待它的线程。这允许所有线程有公平的机会在大致相同的时间开始。

Recommendation: Review the classes available to you. In the case of Java, become familiar with java.util.concurrent, java.util.concurrent.atomic, java.util.concurrent.locks. 建议:审查你可以使用的类。在 Java 的情况下,熟悉 java.util.concurrentjava.util.concurrent.atomicjava.util.concurrent.locks

KNOW YOUR EXECUTION MODELS

了解你的执行模型

There are several different ways to partition behavior in a concurrent application. To discuss them we need to understand some basic definitions. 在并发应用程序中,有几种不同的方法来划分行为。为了讨论它们,我们需要理解一些基本定义。

DefinitionDescription
Bound Resources
限定资源
Resources of a fixed size or number used in a concurrent environment. Examples include database connections and fixed-size read/write buffers.
在并发环境中使用的固定大小或数量的资源。例子包括数据库连接和固定大小的读/写缓冲区。
Mutual Exclusion
互斥
Only one thread can access shared data or a shared resource at a time.
一次只有一个线程可以访问共享数据或共享资源。
Starvation
饥饿
One thread or a group of threads is prohibited from proceeding for an excessively long time or forever. For example, always letting fast-running threads through first could starve out longer running threads if there is no end to the fast-running threads.
一个线程或一组线程被禁止在过长的时间内或永远无法继续执行。例如,如果快速运行的线程源源不断,总是让快速运行的线程先通过可能会使运行时间较长的线程挨饿。
Deadlock
死锁
Two or more threads waiting for each other to finish. Each thread has a resource that the other thread requires and neither can finish until it gets the other resource.
两个或多个线程互相等待对方完成。每个线程都拥有另一个线程所需的资源,并且在获得另一个资源之前都无法完成。
Livelock
活锁
Threads in lockstep, each trying to do work but finding another “in the way.” Due to resonance, threads continue trying to make progress but are unable to for an excessively long time—or forever.
线程步调一致,每个都试图做工作但发现另一个“挡路”。由于共振,线程继续尝试取得进展,但在过长的时间内或永远无法做到。

Given these definitions, we can now discuss the various execution models used in concurrent programming. 鉴于这些定义,我们现在可以讨论并发编程中使用的各种执行模型。

Producer-Consumer9

生产者-消费者

  1. http://en.wikipedia.org/wiki/Producer-consumer

One or more producer threads create some work and place it in a buffer or queue. One or more consumer threads acquire that work from the queue and complete it. The queue between the producers and consumers is a bound resource. This means producers must wait for free space in the queue before writing and consumers must wait until there is something in the queue to consume. Coordination between the producers and consumers via the queue involves producers and consumers signaling each other. The producers write to the queue and signal that the queue is no longer empty. Consumers read from the queue and signal that the queue is no longer full. Both potentially wait to be notified when they can continue. 一个或多个生产者线程创建一些工作并将其放入缓冲区或队列中。一个或多个消费者线程从队列中获取该工作并完成它。生产者和消费者之间的队列是一个限定资源。这意味着生产者必须等待队列中的空闲空间才能写入,而消费者必须等待直到队列中有东西可供消费。生产者和消费者通过队列进行的协调涉及生产者和消费者互相发送信号。生产者写入队列并发出队列不再为空的信号。消费者从队列读取并发出队列不再为满的信号。两者都可能等待通知以便继续。

Readers-Writers10

读者-作者

  1. http://en.wikipedia.org/wiki/Readers-writers_problem

When you have a shared resource that primarily serves as a source of information for readers, but which is occasionally updated by writers, throughput is an issue. Emphasizing throughput can cause starvation and the accumulation of stale information. Allowing updates can impact throughput. Coordinating readers so they do not read something a writer is updating and vice versa is a tough balancing act. Writers tend to block many readers for a long period of time, thus causing throughput issues. 当你有一个共享资源,主要作为读者的信息源,但偶尔会被作者更新时,吞吐量就是一个问题。强调吞吐量可能会导致饥饿和陈旧信息的积累。允许更新会影响吞吐量。协调读者使他们不读取作者正在更新的内容,反之亦然,这是一项艰难的平衡工作。作者往往会长时间阻塞许多读者,从而导致吞吐量问题。

The challenge is to balance the needs of both readers and writers to satisfy correct operation, provide reasonable throughput and avoiding starvation. A simple strategy makes writers wait until there are no readers before allowing the writer to perform an update. If there are continuous readers, however, the writers will be starved. On the other hand, if there are frequent writers and they are given priority, throughput will suffer. Finding that balance and avoiding concurrent update issues is what the problem addresses. 挑战在于平衡读者和作者的需求,以满足正确的操作,提供合理的吞吐量并避免饥饿。一个简单的策略是让作者等待,直到没有读者时才允许作者执行更新。然而,如果有持续不断的读者,作者就会挨饿。另一方面,如果有频繁的作者并且给予他们优先权,吞吐量就会受损。找到这种平衡并避免并发更新问题是该问题要解决的。

Dining Philosophers11

哲学家就餐

  1. http://en.wikipedia.org/wiki/Dining_philosophers_problem

Imagine a number of philosophers sitting around a circular table. A fork is placed to the left of each philosopher. There is a big bowl of spaghetti in the center of the table. The philosophers spend their time thinking unless they get hungry. Once hungry, they pick up the forks on either side of them and eat. A philosopher cannot eat unless he is holding two forks. If the philosopher to his right or left is already using one of the forks he needs, he must wait until that philosopher finishes eating and puts the forks back down. Once a philosopher eats, he puts both his forks back down on the table and waits until he is hungry again. 想象一下,许多哲学家围坐在一张圆桌旁。每个哲学家的左边都放着一把叉子。桌子中央有一大碗意大利面。哲学家们花时间思考,除非他们饿了。一旦饿了,他们就拿起两边的叉子吃面。除非手里拿着两把叉子,否则哲学家不能吃面。如果他右边或左边的哲学家已经在使用他需要的叉子,他必须等待那个哲学家吃完并把叉子放回原处。一旦哲学家吃完了,他就把两把叉子放回桌子上,等待再次感到饥饿。

Replace philosophers with threads and forks with resources and this problem is similar to many enterprise applications in which processes compete for resources. Unless carefully designed, systems that compete in this way can experience deadlock, livelock, throughput, and efficiency degradation. 将哲学家替换为线程,将叉子替换为资源,这个问题类似于许多进程争夺资源的企业应用程序。除非经过精心设计,否则以这种方式竞争的系统可能会经历死锁、活锁、吞吐量和效率下降。

Most concurrent problems you will likely encounter will be some variation of these three problems. Study these algorithms and write solutions using them on your own so that when you come across concurrent problems, you’ll be more prepared to solve the problem. 你可能会遇到的大多数并发问题都是这三个问题的某种变体。研究这些算法并自己编写解决方案,这样当你遇到并发问题时,你会更有准备去解决它。

Recommendation: Learn these basic algorithms and understand their solutions. 建议:学习这些基本算法并理解其解决方案。

BEWARE DEPENDENCIES BETWEEN SYNCHRONIZED METHODS

警惕同步方法之间的依赖

Dependencies between synchronized methods cause subtle bugs in concurrent code. The Java language has the notion of synchronized, which protects an individual method. However, if there is more than one synchronized method on the same shared class, then your system may be written incorrectly.12 同步方法之间的依赖会导致并发代码中出现微妙的 Bug。Java 语言有 synchronized 的概念,它保护单个方法。然而,如果在同一个共享类上有多个同步方法,那么你的系统可能写得不正确。

  1. See “Dependencies Between Methods Can Break Concurrent Code” on page 329.
  2. 参见第 329 页的“方法之间的依赖可能破坏并发代码”。

Recommendation: Avoid using more than one method on a shared object. 建议:避免在共享对象上使用多个方法。

There will be times when you must use more than one method on a shared object. When this is the case, there are three ways to make the code correct: 有时你必须在共享对象上使用多个方法。在这种情况下,有三种方法可以使代码正确:

  • Client-Based Locking—Have the client lock the server before calling the first method and make sure the lock’s extent includes code calling the last method. 基于客户端的锁定——让客户端在调用第一个方法之前锁定服务器,并确保锁的范围包括调用最后一个方法的代码。

  • Server-Based Locking—Within the server create a method that locks the server, calls all the methods, and then unlocks. Have the client call the new method. 基于服务器的锁定——在服务器内创建一个方法,该方法锁定服务器,调用所有方法,然后解锁。让客户端调用这个新方法。

  • Adapted Server—create an intermediary that performs the locking. This is an example of server-based locking, where the original server cannot be changed. 适配服务器——创建一个执行锁定的中间层。这是基于服务器锁定的一个例子,用于原始服务器无法更改的情况。

KEEP SYNCHRONIZED SECTIONS SMALL

保持同步区域微小

The synchronized keyword introduces a lock. All sections of code guarded by the same lock are guaranteed to have only one thread executing through them at any given time. Locks are expensive because they create delays and add overhead. So we don’t want to litter our code with synchronized statements. On the other hand, critical sections13 must be guarded. So we want to design our code with as few critical sections as possible. synchronized 关键字引入了一个锁。所有由同一个锁保护的代码段都保证在任何给定时间只有一个线程在其中执行。锁是昂贵的,因为它们会产生延迟并增加开销。所以我们不想让我们的代码充斥着 synchronized 语句。另一方面,临界区必须被保护。所以我们希望设计我们的代码时,临界区尽可能少。

  1. A critical section is any section of code that must be protected from simultaneous use for the program to be correct.
  2. 临界区是指为了程序正确运行而必须防止同时使用的任何代码段。

Some naive programmers try to achieve this by making their critical sections very large. However, extending synchronization beyond the minimal critical section increases contention and degrades performance.14 一些天真的程序员试图通过使临界区非常大来实现这一点。然而,将同步扩展到最小临界区之外会增加争用并降低性能。

  1. See “Increasing Throughput” on page 333.
  2. 参见第 333 页的“增加吞吐量”。

Recommendation: Keep your synchronized sections as small as possible. 建议:保持你的同步区域尽可能小。

WRITING CORRECT SHUT-DOWN CODE IS HARD

编写正确的关闭代码很难

Writing a system that is meant to stay live and run forever is different from writing something that works for awhile and then shuts down gracefully. 编写一个旨在长期运行且永不停歇的系统,与编写一个运行一段时间然后优雅关闭的系统是不同的。

Graceful shutdown can be hard to get correct. Common problems involve deadlock,15 with threads waiting for a signal to continue that never comes. 优雅的关闭很难做对。常见的问题涉及死锁,即线程等待一个永远不会到来的信号以继续运行。

  1. See “Deadlock” on page 335.
  2. 参见第 335 页的“死锁”。

For example, imagine a system with a parent thread that spawns several child threads and then waits for them all to finish before it releases its resources and shuts down. What if one of the spawned threads is deadlocked? The parent will wait forever, and the system will never shut down. 例如,想象一个系统,其中有一个父线程生成几个子线程,然后等待它们全部完成后才释放资源并关闭。如果生成的线程中有一个死锁了怎么办?父线程将永远等待,系统将永远不会关闭。

Or consider a similar system that has been instructed to shut down. The parent tells all the spawned children to abandon their tasks and finish. But what if two of the children were operating as a producer/consumer pair. Suppose the producer receives the signal from the parent and quickly shuts down. The consumer might have been expecting a message from the producer and be blocked in a state where it cannot receive the shutdown signal. It could get stuck waiting for the producer and never finish, preventing the parent from finishing as well. 或者考虑一个类似的系统,它被指示关闭。父线程告诉所有生成的子线程放弃任务并结束。但是,如果其中两个子线程是作为生产者/消费者对运行的呢?假设生产者接收到来自父线程的信号并迅速关闭。消费者可能一直在等待来自生产者的消息,并且被阻塞在一个无法接收关闭信号的状态。它可能会卡在等待生产者的过程中永远无法完成,从而也阻止了父线程的完成。

Situations like this are not at all uncommon. So if you must write concurrent code that involves shutting down gracefully, expect to spend much of your time getting the shutdown to happen correctly. 像这样的情况并不少见。因此,如果你必须编写涉及优雅关闭的并发代码,请预计会花费大量时间来确关闭操作正确执行。

Recommendation: Think about shut-down early and get it working early. It’s going to take longer than you expect. Review existing algorithms because this is probably harder than you think. 建议:尽早考虑关闭问题并尽早使其工作。这将比你预期的要花更多时间。审查现有的算法,因为这可能比你想象的要难。

TESTING THREADED CODE

测试线程代码

Proving that code is correct is impractical. Testing does not guarantee correctness. However, good testing can minimize risk. This is all true in a single-threaded solution. As soon as there are two or more threads using the same code and working with shared data, things get substantially more complex. 证明代码是正确的几乎是不切实际的。测试并不能保证正确性。然而,良好的测试可以最小化风险。在单线程解决方案中这一切都是正确的。一旦有两个或更多线程使用相同的代码并处理共享数据,事情就会变得复杂得多。

Recommendation: Write tests that have the potential to expose problems and then run them frequently, with different programatic configurations and system configurations and load. If tests ever fail, track down the failure. Don’t ignore a failure just because the tests pass on a subsequent run. 建议:编写有潜力暴露问题的测试,然后频繁运行它们,使用不同的程序配置、系统配置和负载。如果测试失败,追踪失败原因。不要因为测试在随后的运行中通过了就忽略失败。

That is a whole lot to take into consideration. Here are a few more fine-grained recommendations: 这需要考虑很多东西。以下是一些更细粒度的建议:

  • Treat spurious failures as candidate threading issues. 将伪失败(偶发失败)视为候选的线程问题。
  • Get your nonthreaded code working first. 先让你非线程的代码工作起来。
  • Make your threaded code pluggable. 让你的线程代码可插拔。
  • Make your threaded code tunable. 让你的线程代码可调优。
  • Run with more threads than processors. 在多于处理器的线程下运行。
  • Run on different platforms. 在不同的平台上运行。
  • Instrument your code to try and force failures. 对你的代码进行插桩,试图强迫失败。

Treat Spurious Failures as Candidate Threading Issues

将伪失败视为候选的线程问题

Threaded code causes things to fail that “simply cannot fail.” Most developers do not have an intuitive feel for how threading interacts with other code (authors included). Bugs in threaded code might exhibit their symptoms once in a thousand, or a million, executions. Attempts to repeat the systems can be frustratingly. This often leads developers to write off the failure as a cosmic ray, a hardware glitch, or some other kind of “one-off.” It is best to assume that one-offs do not exist. The longer these “one-offs” are ignored, the more code is built on top of a potentially faulty approach. 线程代码会导致一些“根本不可能失败”的事情失败。大多数开发人员对线程如何与其他代码交互没有直观的感觉(包括作者在内)。线程代码中的 Bug 可能会在千次或百万次执行中表现出一次症状。试图复现系统故障可能会令人沮丧。这通常导致开发人员将失败归咎于宇宙射线、硬件故障或其他类型的“偶发事件”。最好假设不存在偶发事件。这些“偶发事件”被忽视得越久,建立在潜在错误方法之上的代码就越多。

Recommendation: Do not ignore system failures as one-offs. 建议:不要将系统故障作为偶发事件忽略。

Get Your Nonthreaded Code Working First

先让你非线程的代码工作起来

This may seem obvious, but it doesn’t hurt to reinforce it. Make sure code works outside of its use in threads. Generally, this means creating POJOs that are called by your threads. The POJOs are not thread aware, and can therefore be tested outside of the threaded environment. The more of your system you can place in such POJOs, the better. 这似乎很明显,但加强一下也没坏处。确保代码在线程使用之外也能工作。通常,这意味着创建由线程调用的 POJO。这些 POJO 不知道线程的存在,因此可以在线程环境之外进行测试。你可以放入此类 POJO 中的系统部分越多越好。

Recommendation: Do not try to chase down nonthreading bugs and threading bugs at the same time. Make sure your code works outside of threads. 建议:不要试图同时追查非线程 Bug 和线程 Bug。确保你的代码在线程之外也能工作。

Make Your Threaded Code Pluggable

让你的线程代码可插拔

Write the concurrency-supporting code such that it can be run in several configurations: 编写支持并发的代码,使其可以在多种配置下运行:

  • One thread, several threads, varied as it executes 单线程、多线程,在执行时变化。
  • Threaded code interacts with something that can be both real or a test double. 线程代码与既可以是真实的也可以是测试替身的东西交互。
  • Execute with test doubles that run quickly, slowly, variable. 使用运行速度快、慢、可变的测试替身执行。
  • Configure tests so they can run for a number of iterations. 配置测试,使它们可以运行多次迭代。

Recommendation: Make your thread-based code especially pluggable so that you can run it in various configurations. 建议:让你的基于线程的代码特别可插拔,以便你可以在各种配置下运行它。

Make Your Threaded Code Tunable

让你的线程代码可调优

Getting the right balance of threads typically requires trial an error. Early on, find ways to time the performance of your system under different configurations. Allow the number of threads to be easily tuned. Consider allowing it to change while the system is running. Consider allowing self-tuning based on throughput and system utilization. 获得正确的线程平衡通常需要试错。在早期,找到方法来计算你的系统在不同配置下的性能。允许轻松调整线程数。考虑允许在系统运行时更改它。考虑允许根据吞吐量和系统利用率进行自调优。

Run with More Threads Than Processors

在多于处理器的线程下运行

Things happen when the system switches between tasks. To encourage task swapping, run with more threads than processors or cores. The more frequently your tasks swap, the more likely you’ll encounter code that is missing a critical section or causes deadlock. 当系统在任务之间切换时,事情就会发生。为了鼓励任务切换,使用比处理器或核心更多的线程运行。你的任务切换得越频繁,你遇到缺少临界区或导致死锁的代码的可能性就越大。

Run on Different Platforms

在不同的平台上运行

In the middle of 2007 we developed a course on concurrent programming. The course development ensued primarily under OS X. The class was presented using Windows XP running under a VM. Tests written to demonstrate failure conditions did not fail as frequently in an XP environment as they did running on OS X. 在 2007 年中期,我们开发了一门关于并发编程的课程。课程开发主要是在 OS X 下进行的。课程演示使用的是在虚拟机下运行的 Windows XP。编写来演示失败条件的测试在 XP 环境中的失败频率不如在 OS X 上运行时那么高。

In all cases the code under test was known to be incorrect. This just reinforced the fact that different operating systems have different threading policies, each of which impacts the code’s execution. Multithreaded code behaves differently in different environments.16 You should run your tests in every potential deployment environment. 在所有情况下,被测试的代码都被认为是错误的。这只是加强了一个事实,即不同的操作系统有不同的线程策略,每种策略都会影响代码的执行。多线程代码在不同的环境中表现不同。你应该在每个潜在的部署环境中运行你的测试。

  1. Did you know that the threading model in Java does not guarantee preemptive threading? Modern OS’s support preemptive threading, so you get that “for free.” Even so, it not guaranteed by the JVM.
  2. 你知道 Java 中的线程模型不保证抢占式线程吗?现代操作系统支持抢占式线程,所以你“免费”获得了它。即便如此,JVM 并不保证这一点。

Recommendation: Run your threaded code on all target platforms early and often. 建议:尽早并经常在所有目标平台上运行你的线程代码。

Instrument Your Code to Try and Force Failures

对你的代码进行插桩,试图强迫失败

It is normal for flaws in concurrent code to hide. Simple tests often don’t expose them. Indeed, they often hide during normal processing. They might show up once every few hours, or days, or weeks! 并发代码中的缺陷隐藏起来是很正常的。简单的测试通常无法暴露它们。事实上,它们经常在正常处理过程中隐藏起来。它们可能每隔几个小时、几天或几周才出现一次!

The reason that threading bugs can be infrequent, sporadic, and hard to repeat, is that only a very few pathways out of the many thousands of possible pathways through a vulnerable section actually fail. So the probability that a failing pathway is taken can be star-tlingly low. This makes detection and debugging very difficult. 线程 Bug 之所以罕见、零星且难以复现,是因为在穿过脆弱区域的成千上万条可能路径中,只有极少数路径实际上会导致失败。因此,走上失败路径的概率可能低得惊人。这使得检测和调试非常困难。

How might you increase your chances of catching such rare occurrences? You can instrument your code and force it to run in different orderings by adding calls to methods like Object.wait(), Object.sleep(), Object.yield() and Object.priority(). 你要如何增加捕捉这种罕见情况的机会?你可以对代码进行插桩(instrument),通过添加对 Object.wait()Object.sleep()Object.yield()Object.priority() 等方法的调用,强迫其以不同的顺序运行。

Each of these methods can affect the order of execution, thereby increasing the odds of detecting a flaw. It’s better when broken code fails as early and as often as possible. 这些方法中的每一个都会影响执行顺序,从而增加检测到缺陷的几率。有问题的代码越早、越频繁地失败越好。

There are two options for code instrumentation: 代码插桩有两个选项:

  • Hand-coded 手工编写
  • Automated 自动化

Hand-Coded

手工编写

You can insert calls to wait(), sleep(), yield(), and priority() in your code by hand. It might be just the thing to do when you’re testing a particularly thorny piece of code. 你可以手动在代码中插入对 wait()sleep()yield()priority() 的调用。当你测试一段特别棘手的代码时,这可能正是你要做的。

Here is an example of doing just that: 这是一个这样做的例子:

java
    public synchronized String nextUrlOrNull() {
        if(hasNext()) {
            String url = urlGenerator.next();
            Thread.yield(); // inserted for testing. // 为了测试而插入
            updateHasNext();
            return url;
        }
        return null;
    }

The inserted call to yield() will change the execution pathways taken by the code and possibly cause the code to fail where it did not fail before. If the code does break, it was not because you added a call to yield().17 Rather, your code was broken and this simply made the failure evident. 插入的 yield() 调用将改变代码所采取的执行路径,并可能导致代码在以前没有失败的地方失败。如果代码确实坏了,那不是因为你添加了对 yield() 的调用。而是你的代码本身就是坏的,这只是让失败变得明显了。

  1. This is not strictly the case. Since the JVM does not guarantee preemptive threading, a particular algorithm might always work on an OS that does not preempt threads. The reverse is also possible but for different reasons.
  2. 情况并非严格如此。由于 JVM 不保证抢占式线程,特定的算法可能在不抢占线程的 OS 上总能工作。反之亦然,但原因不同。

There are many problems with this approach: 这种方法有很多问题:

  • You have to manually find appropriate places to do this. 你必须手动找到合适的地方来做这件事。
  • How do you know where to put the call and what kind of call to use? 你怎么知道在哪里放置调用以及使用什么样的调用?
  • Leaving such code in a production environment unnecessarily slows the code down. 将此类代码留在生产环境中会不必要地减慢代码速度。
  • It’s a shotgun approach. You may or may not find flaws. Indeed, the odds aren’t with you. 这是一种“乱枪打鸟”的方法。你可能会也可能不会发现缺陷。事实上,几率并不站在你这边。

What we need is a way to do this during testing but not in production. We also need to easily mix up configurations between different runs, which results in increased chances of finding errors in the aggregate. 我们需要的是一种在测试期间而非生产期间执行此操作的方法。我们还需要在不同的运行之间轻松混合配置,从而增加总体上发现错误的机会。

Clearly, if we divide our system up into POJOs that know nothing of threading and classes that control the threading, it will be easier to find appropriate places to instrument the code. Moreover, we could create many different test jigs that invoke the POJOs under different regimes of calls to sleep, yield, and so on. 显然,如果我们把系统划分为对线程一无所知的 POJO 和控制线程的类,就更容易找到合适的地方对代码进行插桩。此外,我们可以创建许多不同的测试夹具,在不同的 sleepyield 等调用制度下调用 POJO。

Automated

自动化

You could use tools like an Aspect-Oriented Framework, CGLIB, or ASM to programmatically instrument your code. For example, you could use a class with a single method: 你可以使用像面向切面框架(Aspect-Oriented Framework)、CGLIB 或 ASM 这样的工具来以编程方式对代码进行插桩。例如,你可以使用一个只有一个方法的类:

java
   public class ThreadJigglePoint {
       public static void jiggle() {
       }
   }

You can add calls to this in various places within your code: 你可以在代码的各个地方添加对它的调用:

java
   public synchronized String nextUrlOrNull() {
     if(hasNext()) {
         ThreadJiglePoint.jiggle();
         String url = urlGenerator.next();
         ThreadJiglePoint.jiggle();
         updateHasNext();
         ThreadJiglePoint.jiggle();
         return url;
     } 
     return null;
   }

Now you use a simple aspect that randomly selects among doing nothing, sleeping, or yielding. 现在你可以使用一个简单的切面,随机选择什么也不做、睡眠或让步。

Or imagine that the ThreadJigglePoint class has two implementations. The first implements jiggle to do nothing and is used in production. The second generates a random number to choose between sleeping, yielding, or just falling through. If you run your tests a thousand times with random jiggling, you may root out some flaws. If the tests pass, at least you can say you’ve done due diligence. Though a bit simplistic, this could be a reasonable option in lieu of a more sophisticated tool. 或者想象 ThreadJigglePoint 类有两个实现。第一个实现 jiggle 什么也不做,用于生产环境。第二个生成一个随机数,在睡眠、让步或只是直接通过之间进行选择。如果你用随机晃动(jiggling)运行你的测试一千次,你可能会找出一些缺陷。如果测试通过,至少你可以说你已经尽职尽责了。虽然有点简单,但这可以作为一个合理的选项,代替更复杂的工具。

There is a tool called ConTest,18 developed by IBM that does something similar, but it does so with quite a bit more sophistication. IBM 开发了一个名为 ConTest 的工具,它做类似的事情,但要复杂得多。

  1. http://www.alphaworks.ibm.com/tech/contest

The point is to jiggle the code so that threads run in different orderings at different times. The combination of well-written tests and jiggling can dramatically increase the chance finding errors. 重点是晃动/扰动代码,使线程在不同的时间以不同的顺序运行。编写良好的测试和晃动相结合可以显著增加发现错误的机会。

Recommendation: Use jiggling strategies to ferret out errors. 建议:使用晃动/扰动策略来搜出错误。

CONCLUSION

结论

Concurrent code is difficult to get right. Code that is simple to follow can become nightmarish when multiple threads and shared data get into the mix. If you are faced with writing concurrent code, you need to write clean code with rigor or else face subtle and infrequent failures. 并发代码很难做对。当多线程和共享数据混合在一起时,简单易懂的代码可能会变成噩梦。如果你面临编写并发代码的任务,你需要严谨地编写整洁的代码,否则将面临微妙且罕见的失败。

First and foremost, follow the Single Responsibility Principle. Break your system into POJOs that separate thread-aware code from thread-ignorant code. Make sure when you are testing your thread-aware code, you are only testing it and nothing else. This suggests that your thread-aware code should be small and focused. 首先也是最重要的是,遵循单一职责原则。将你的系统分解为 POJO,将线程感知代码与线程无关代码分开。确保在测试线程感知代码时,你只测试它而不测试其他东西。这意味着你的线程感知代码应该短小且专注。

Know the possible sources of concurrency issues: multiple threads operating on shared data, or using a common resource pool. Boundary cases, such as shutting down cleanly or finishing the iteration of a loop, can be especially thorny. 了解并发问题的可能来源:多个线程操作共享数据,或使用公共资源池。边界情况,如干净地关闭或完成循环迭代,可能特别棘手。

Learn your library and know the fundamental algorithms. Understand how some of the features offered by the library support solving problems similar to the fundamental algorithms. 学习你的类库并了解基本算法。了解库提供的一些功能如何支持解决类似于基本算法的问题。

Learn how to find regions of code that must be locked and lock them. Do not lock regions of code that do not need to be locked. Avoid calling one locked section from another. This requires a deep understanding of whether something is or is not shared. Keep the amount of shared objects and the scope of the sharing as narrow as possible. Change designs of the objects with shared data to accommodate clients rather than forcing clients to manage shared state. 学习如何找到必须锁定的代码区域并锁定它们。不要锁定不需要锁定的代码区域。避免从一个锁定区域调用另一个锁定区域。这需要深入理解某些东西是否被共享。保持共享对象的数量和共享范围尽可能小。更改带有共享数据的对象的设计以适应客户端,而不是强迫客户端管理共享状态。

Issues will crop up. The ones that do not crop up early are often written off as a onetime occurrence. These so-called one-offs typically only happen under load or at seemingly random times. Therefore, you need to be able to run your thread-related code in many configurations on many platforms repeatedly and continuously. Testability, which comes naturally from following the Three Laws of TDD, implies some level of plug-ability, which offers the support necessary to run code in a wider range of configurations. 问题会出现。那些没有在早期出现的问题通常被当作一次性事件忽略。这些所谓的一次性事件通常只在负载下或看似随机的时间发生。因此,你需要能够在许多平台上以许多配置重复且持续地运行你的线程相关代码。遵循 TDD 三定律自然会带来可测试性,这也意味着某种程度的可插拔性,这为你提供了在更广泛的配置中运行代码所需的支持。

You will greatly improve your chances of finding erroneous code if you take the time to instrument your code. You can either do so by hand or using some kind of automated technology. Invest in this early. You want to be running your thread-based code as long as possible before you put it into production. 如果你花时间对代码进行插桩,你将大大提高发现错误代码的机会。你可以手动进行,也可以使用某种自动化技术。尽早在这方面投资。你希望在将基于线程的代码投入生产之前,尽可能长时间地运行它。

If you take a clean approach, your chances of getting it right increase drastically. 如果你采取整洁的方法,你做对的机会将大大增加。

基于 MIT 许可发布