Skip to content

第 13 章 Concurrency 并发编程

by Brett L. Schuchert

"Objects are abstractions of processing. Threads are abstractions of schedule."

"对象是处理的抽象。线程是调度的抽象。"

—James O. Coplien1

——James O. Coplien[1]

  1. Private correspondence.

[1] 私人通信。

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"中提供更详细的教程。如果你只是对并发感到好奇,那么本章现在对你来说就足够了。如果你需要更深入地理解并发,那么你也应该阅读那个教程。

13.1 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 socket 上等待 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.

或者考虑一个解释大型数据集但只能在处理完所有数据后才能给出完整解决方案的系统。也许每个数据集可以在不同的计算机上处理,以便许多数据集被并行处理。

13.1.1 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. 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.
  • 使用 Web 或 EJB 容器时,理解并发问题并不重要。 事实上,你最好知道你的容器在做什么,以及如何防范本章后面描述的并发更新和死锁问题。

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 通常不可重复,因此它们经常被当作一次性事件[2]而被忽视,而不是它们真正代表的缺陷。
  1. Cosmic-rays, glitches, and so on.

[2] 宇宙射线、故障等。

  • Concurrency often requires a fundamental change in design strategy.
  • 并发通常需要设计策略的根本改变。

13.2 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.

令人惊讶的第三个结果[3]发生在两个线程相互踩踏时。这是因为两个线程通过那一行 Java 代码有许多可能的路径,其中一些路径会产生不正确的结果。有多少条不同的路径?要真正回答这个问题,我们需要了解即时编译器(Just-In-Time Compiler)对生成的字节码做了什么,以及理解 Java 内存模型认为什么是原子的。

  1. See "Digging Deeper" on page 323.

[3] 参见第 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 条不同的可能执行路径[4]。如果 lastIdUsed 的类型从 int 改为 long,可能的路径数量增加到 2,704,156。当然,这些路径中的大多数会生成有效结果。问题在于有些不会。

  1. See "Possible Paths of Execution" on page 321.

[4] 参见第 321 页的"可能的执行路径"。

13.3 CONCURRENCY DEFENSE PRINCIPLES 并发防御原则

What follows is a series of principles and techniques for defending your systems from the problems of concurrent code.

以下是一系列用于保护系统免受并发代码问题困扰的原则和技术。

13.3.1 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[5]指出,一个给定的方法/类/组件应该只有一个修改原因。并发设计足够复杂,本身就是一个修改原因,因此值得与代码的其余部分分离。不幸的是,并发实现细节直接嵌入到其他生产代码中的情况太常见了。以下是一些需要考虑的事项:

  1. [PPP]

[5] [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

建议:将并发相关代码与其他代码分开。[6]

  1. See "Client/Server Example" on page 317.

[6] 参见第 317 页的"客户端/服务器示例"。

13.3.2 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 关键字来保护使用共享对象的代码中的临界区。限制此类临界区的数量很重要。共享数据可以被更新的地方越多,就越可能出现:

  • 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[7])。
  1. [PRAG].

[7] [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.

建议:认真对待数据封装;严格限制任何可能被共享的数据的访问。

13.3.3 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.

如果有简单的方法避免共享对象,最终的代码将远不太可能导致问题。你可能会担心所有额外对象创建的开销。值得实验以确定这是否真的是一个问题。然而,如果使用对象副本可以让代码避免同步,那么避免内置锁所节省的开销很可能会弥补额外的创建和垃圾回收开销。

13.3.4 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 继承的类接收所有信息作为传入 doGet 和 doPost 方法的参数。这使得每个 Servlet 表现得好像它有自己的机器。只要 Servlet 中的代码只使用局部变量,Servlet 就不可能引起同步问题。当然,大多数使用 Servlet 的应用程序最终会遇到共享资源,如数据库连接。

Recommendation: Attempt to partition data into independent subsets than can be operated on by independent threads, possibly in different processors.

建议:尝试将数据划分为独立的子集,可以由独立的线程操作,可能在不同的处理器上。

13.4 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.
  • Use nonblocking solutions when possible.
  • Several library classes are not thread safe.
  • 使用提供的线程安全集合。
  • 使用 executor 框架执行不相关的任务。
  • 尽可能使用非阻塞方案。
  • 一些库类不是线程安全的。

13.4.1 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 写了一本开创性的书[8]《Concurrent Programming in Java》。随书他还开发了几个线程安全集合,后来成为 JDK 中 java.util.concurrent 包的一部分。该包中的集合在多线程情况下是安全的,并且性能良好。事实上,ConcurrentHashMap 实现在几乎所有情况下都比 HashMap 性能更好。它还允许同时并发读写,并且有支持常见复合操作的方法,否则这些操作不是线程安全的。如果 Java 5 是部署环境,请从 ConcurrentHashMap 开始。

  1. [Lea99].

[8] [Lea99]。

There are several other kinds of classes added to support advanced concurrency design. Here are a few examples:

还有其他几种类被添加来支持高级并发设计。以下是一些例子:

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.concurrent、java.util.concurrent.atomic、java.util.concurrent.locks。

13.5 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.

在并发应用程序中,有几种不同的方式来划分行为。要讨论它们,我们需要理解一些基本定义。

Given these definitions, we can now discuss the various execution models used in concurrent programming.

给定这些定义,我们现在可以讨论并发编程中使用的各种执行模型。

13.5.1 Producer-Consumer 生产者-消费者

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

[9] 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.

一个或多个生产者线程创建一些工作并将其放入缓冲区或队列中。一个或多个消费者线程从队列获取工作并完成它。生产者和消费者之间的队列是有界资源。这意味着生产者在写入之前必须等待队列中的空闲空间,消费者必须等到队列中有东西可消费。通过队列进行的生产者和消费者之间的协调涉及生产者和消费者相互发信号。生产者写入队列并发出队列不再为空的信号。消费者从队列读取并发出队列不再为满的信号。两者都可能等待通知以继续。

13.5.2 Readers-Writers 读者-写者

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

[10] 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.

挑战在于平衡读者和写者的需求,以满足正确操作、提供合理吞吐量并避免饥饿。一个简单的策略是让写者等到没有读者时才允许写者执行更新。然而,如果有持续的读者,写者将被饥饿。另一方面,如果有频繁的写者并且它们被给予优先级,吞吐量将受到影响。找到那个平衡点并避免并发更新问题就是这个问题要解决的。

13.5.3 Dining Philosophers 哲学家就餐

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

[11] 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 的概念,它保护单个方法。然而,如果同一个共享类上有多个 synchronized 方法,那么你的系统可能是不正确的。[12]

  1. See "Dependencies Between Methods Can Break Concurrent Code" on page 329.

[12] 参见第 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.
  • 适配服务器——创建一个执行锁定的中介。这是基于服务器的锁的一个例子,其中原始服务器无法更改。

13.6 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 语句弄乱我们的代码。另一方面,临界区[13]必须被保护。所以我们希望设计的代码尽可能少的临界区。

  1. A critical section is any section of code that must be protected from simultaneous use for the program to be correct.

[13] 临界区是任何必须被保护免受同时使用的代码区域,以使程序正确。

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

一些天真的程序员试图通过使临界区非常大来实现这一点。然而,将同步扩展到最小临界区之外会增加竞争并降低性能。[14]

  1. See "Increasing Throughput" on page 333.

[14] 参见第 333 页的"提高吞吐量"。

Recommendation: Keep your synchronized sections as small as possible.

建议:保持同步区域尽可能小。

13.7 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.

优雅关闭可能很难正确实现。常见的问题涉及死锁[15],线程等待一个永远不会到来的继续信号。

  1. See "Deadlock" on page 335.

[15] 参见第 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.

建议:尽早考虑关闭并尽早让它工作。这将比你预期的要花更长时间。审查现有算法,因为这可能比你想象的要难。

13.8 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.

建议:不要将系统失败作为一次性事件而忽视。

13.8.1 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。确保你的代码在线程之外能工作。

13.8.2 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.

建议:使你的线程代码特别可插拔,以便你可以在各种配置中运行它。

13.8.3 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.

获得线程的正确平衡通常需要反复试验。尽早找到在不同配置下计时系统性能的方法。允许线程数量容易调优。考虑允许在系统运行时更改它。考虑允许基于吞吐量和系统利用率的自动调优。

13.8.4 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.

当系统在任务之间切换时会发生事情。为了鼓励任务交换,使用比处理器或核心更多的线程运行。你的任务交换越频繁,你就越可能遇到缺少临界区或导致死锁的代码。

13.8.5 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 下进行。课程使用在 VM 下运行的 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.

在所有情况下,被测试的代码都是已知不正确的。这只强化了一个事实:不同的操作系统有不同的线程策略,每种策略都会影响代码的执行。多线程代码在不同环境中表现不同。[16] 你应该在每个潜在的部署环境中运行测试。

  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.

[16] 你知道 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().

你如何增加捕获此类罕见事件的机会?你可以对代码进行插桩,通过添加对 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
  • 手动编码
  • 自动化

13.8.6 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() 的调用。[17] 相反,你的代码本来就有问题,这只是让失败变得明显了。

  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.

[17] 这并不严格如此。由于 JVM 不保证抢占式线程,特定算法可能在不抢占线程的操作系统上总是有效。反过来也是可能的,但原因不同。

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 和控制线程的类,将更容易找到合适的地方来对代码进行插桩。此外,我们可以创建许多不同的测试夹具,在不同的 sleep、yield 等调用机制下调用 POJO。

13.8.7 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:

你可以使用面向切面框架、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 什么都不做,用于生产。第二个生成一个随机数来选择睡眠、让步或直接通过。如果你用随机抖动运行测试一千次,你可能会根除一些缺陷。如果测试通过了,至少你可以说你已经尽了应有的努力。虽然有点简单,但作为更复杂工具的替代方案,这可能是一个合理的选择。

There is a tool called ConTest,18 developed by IBM that does something similar, but it does so with quite a bit more sophistication.

有一个叫做 ConTest[18] 的工具,由 IBM 开发,它做了类似的事情,但做得更加精细。

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

[18] 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.

建议:使用抖动策略来找出错误。

13.9 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.

如果你采取整洁的方法,你正确实现它的机会将大大增加。