计算机用户想当然地认为他们的系统在同一时间可以做多件事。比如,用户一边用浏览器下载视频文件,一边可以继续在浏览器上浏览网页。可以做这样的事情的软件称为并发软件(concurrent software)。
计算机实现多个程序的同时执行,主要基于以下原因:
资源利用率 。在某些情况下,程序必须等待其他外部的某个操作完成,才能往下继续执行,而在等待的过程中,该程序无法执行其他任何工作。因此,如果在等待的同时可以运行另外一个程序,则无疑提高了资源的利用率。
公平性 。不同的用户和程序对于计算机上的资源有着同等的使用权。一种高效的运行方式是通过粗粒度的时间分片(time slicing)使这些用户和程序能共享计算机资源,而不是一个程序从头运行到尾,然后启动下一个程序。
便利性 。通常来说,在计算多个任务时,应该编写多个程序,每个程序执行一个任务并在必要时相互通信,这比只编写一个程序来计算所有任务更容易实现。
在早期的分时系统中,每个进程以串行化方式执行指令,并通过一组 I/O 指令与外部设备通信。每条被执行的指令都有相应的“下一条指令”,程序中的控制流就是按照指令集的规则确定的。
串行编程模型的优势是直观性和简单性,因为它模仿了人类的工作方式:每次只做一件事,做完再做其他事情。例如,早上起床后,先穿衣,然后下楼,吃早饭。在编程语言中,这些现实世界的动作可以进一步被抽象为一组粒度更细的动作。例如,喝茶的动作可以被细化为打开橱柜,挑选茶叶,将茶叶倒入杯中,查看茶壶的水是否够,不够要加水,将茶壶放在火炉上,点燃火炉,然后等水烧开,等等。在等水烧开这个过程中包含了一定程度的异步性。例如,在烧水过程中,你可以干等,也可以做其他事情,比如开始烤面包,或者看报纸(这就是另一个异步任务),同时留意水是否烧开了。但凡做事高效的人,总能在串行性和异步性之间找到合理的平衡点,程序也是如此。
线程允许在同一个进程中同时存在多个线程控制流。线程会共享进程范围内的资源,例如内存句柄和文件句柄,但每个线程都有各自的程序计数器、栈和局部变量。线程还提供了一种直观的分解模式来充分利用操作系统中的硬件并行性,而在同一个程序中的多个线程也可以被同时调度到多个CPU上运行。
毫无疑问,多线程编程使得程序任务并发成为可能。而并发控制主要是为了解决多个线程之间资源争夺等问题的。并发一般发生在数据聚合的地方,只要有聚合,就有争夺发生,传统解决争夺的方式采取线程锁机制,这是强行对CPU管理线程进行人为干预,线程唤醒成本高;新的无锁并发策略来源于异步编程、非阻塞I/O等编程模型。
The Practice of Programming的作者Rob Pike对并发与并行做了如下描述:
并发是同一时间应对(dealing with)多件事情的能力;并行是同一时间动手做(doing)多件事情的能力。
并发(concurrency)属于问题域(problem domain),并行(parallelism)属于解决域(solution domain)。并行与并发的区别在于有无状态,并行计算适合无状态应用,而并发解决的是有状态的高性能问题;有状态要着力并发计算,无状态要着力并行计算,云计算要能做到这两种计算的自动伸缩。
多线程并发会带来如下的问题:
安全性问题 。在没有充分同步的情况下,多个线程中的操作执行顺序是不可预测的,甚至会产生奇怪的结果。线程间的通信主要是通过共享访问字段及其字段所引用的对象来实现的。这种形式的通信非常有效,但可能导致两种错误:线程干扰(thread interference)和内存一致性错误(memory consistency error)。
活跃度问题 。一个并行应用程序的及时执行能力被称为它的活跃度(liveness)。安全性的含义是“永远不发生糟糕的事情”,而活跃度则关注另外一个目标,即“某件正确的事情最终会发生”。当某个操作无法继续执行下去时,就会发生活跃度问题。在串行程序中,活跃度问题之一就是无意中造成的无限循环(死循环)。而在多线程程序中,常见的活跃度问题主要有死锁、饥饿和活锁。
性能问题 。在设计良好的并发应用程序中,线程能提升程序的性能,但无论如何,线程总是带来某种程度的运行时开销。这种开销主要发生在线程调度器临时关起活跃线程并转而运行另外一个线程的上下文切换操作(context switch)时,因为执行上下文切换,需要保存和恢复执行上下文,丢失局部性,并且CPU时间将更多地花在线程调度而不是线程运行上。当线程共享数据时,必须使用同步机制,这些机制往往会抑制某些编译器优化,使内存缓存区中的数据无效,并增加贡献内存总线的同步流量。所有这些因素都会带来额外的性能开销。
死锁是指两个或两个以上的线程永远被阻塞,一直等待对方的资源。
下面是一个用Java编写的死锁的例子。
Alphonse和Gaston是朋友,都很有礼貌。礼貌的一个严格的规则是,当你给一个朋友鞠躬时,你必须保持鞠躬状态,直到你的朋友也给你鞠躬。不幸的是,这条规则有个缺陷,那就是如果两个朋友同一时间向对方鞠躬,就永远不会结束。在这个示例应用程序中,死锁模型是这样的:
当它们尝试调用bowBack时两个线程将被阻塞。无论哪个线程,都永远不会结束,因为每个线程都在等待对方鞠躬。这就是死锁。
上面例子的代码可以在 https://github.com/waylau/distributed-systems-technologies-and-casesanalysis的distributed-systems-java-demos程序的com.waylau.essentialjava.concurrency包下找到。
饥饿描述了一个线程由于访问太多共享资源导致不能执行程序的现象。这种情况一般出现在共享资源被某些“贪婪”线程占用而导致资源长时间不能被其他线程使用时。例如,假设一个对象提供一个同步的方法,往往需要很长时间返回,一个线程频繁调用该方法,其他线程若也需要频繁地同步访问同一个对象,则通常会被阻塞。
一个线程常常响应另一个线程的动作,如果其他线程也常常响应该线程的动作,那么就可能出现活锁。与死锁的线程一样,程序无法进一步执行。然而,线程是不会阻塞的,它们只是会忙于应对彼此的恢复工作。现实中的例子是,两人面对面试图通过一条走廊:Alphonse移动到他的左侧给Gaston让路,而Gaston移动到他的右侧想让Alphonse过去,两个人同时让路,但其实两人都挡住了对方,他们仍然彼此阻塞。
下面介绍几种解决并发问题的常用方法。
同步是避免线程干扰和内存一致性错误的常用手段。下面就用Java代码来演示几种问题,以及如何用同步解决问题。
下面描述当多个线程访问共享数据时错误是如何出现的。
考虑下面的一个简单的类Counter:
其中的increment方法用来对c加1;decrement方法用来对c减1。然而,在多个线程中都存在对某个Counter对象的引用,线程间的干扰就可能产生我们不想要的结果。
线程间的干扰出现在多个线程对同一个数据进行多个操作的时候,也就是出现了“交错”(interleave),意味着操作是由多个步骤构成的,此时在这多个步骤的执行上出现了叠加。
Counter类对象的操作貌似不可能出现这种“交错”,因为其中的两个关于c的操作都很简单,只有一条语句。然而,即使是一条语句,也会被虚拟机翻译成多个步骤。这里我们不深究虚拟机具体将上面的操作翻译成了什么样的步骤,只需要知道即使简单的C++这样的表达式也会被翻译成三个步骤:
(1)获取 c 的当前值。
(2)对其当前值加 1。
(3)将增加后的值存储到 c 中。
表达式c--也会被按照同样的方式进行翻译,只不过第二步变成了减1,而不是加1。
假定线程A中调用increment方法,线程B中调用decrement方法,而调用时间基本相同。如果 c 的初始值为 0,那么这两个操作的“交错”顺序可能如下所示。
(1)线程A:获取c的值。
(2)线程B:获取c的值。
(3)线程A:对获取到的值加1,其结果是1。
(4)线程B:对获取到的值减1,其结果是-1。
(5)线程A:将结果存储到c中,此时c的值是1。
(6)线程B:将结果存储到c中,此时c的值是-1。
这样线程 A 计算的值就丢失了,也就是被线程 B 的值覆盖了。上面的这种“交错”只是其中的一种可能。在不同的系统环境中,有可能是 B 线程的结果丢失了,或者根本就不会出现错误。由于这种“交错”不可预测,线程间相互干扰造成的bug是很难定位和修改的。
下面介绍通过共享内存出现的不一致的错误。
内存一致性错误发生在不同线程对同一数据产生不同的“看法”时。导致内存一致性错误的原因很复杂,超出了本书的描述范围。庆幸的是,程序员并不需要知道这些原因的细节,我们需要的是一种可以避免这种错误的方法。
避免出现内存一致性错误的关键在于理解 happens-before 关系。这种关系是一种简单的方法,能够确保一条语句对内存的写操作对于其他特定的语句都是可见的。为了理解这一点,我们可以考虑如下示例。假设定义了一个简单的int类型的字段并对其进行初始化:
int counter = 0;
该字段由两个线程共享:A和B。假定线程A对counter进行了自增操作:
counter++;
然后,线程B打印counter的值:
System.out.println(counter);
如果以上两条语句是在同一个线程中执行的,那么输出的结果自然是1。如果这两条语句是在两个不同的线程中,那么输出的结果有可能是0。这是因为没有保证线程 A对counter 的修改对线程B来说是可见的。除非程序员在这两条语句间建立了一定的happens-before关系。
我们可以采取多种方式建立 happens-before 关系,使用同步就是其中之一,这点我们将会在下面的节中看到。
到目前为止,我们已经看到了两种建立happens-before的方式:
当一条语句中调用了Thread.start方法,那么每一条和该语句已经建立了happens-before的语句都和新线程中的每一条语句有这种happens-before关系。引入并创建这个新线程的代码产生的结果对该新线程来说都是可见的。
如果一个线程终止并导致另外的线程中调用Thread.join的语句返回,那么此时这个终止的线程中执行的所有语句都与随后的 join 语句的所有语句建立了这种 happens-before 关系。也就是说,终止了的线程中的代码效果对调用join方法的线程来说是可见的。
关于哪些操作可以建立happens-before关系,更多的信息请参阅“java.util.concurrent包的概要说明”(https://docs.oracle.com/javase/8/docs/api/java/util/concurrent/package-summary.xhtml# MemoryVisibility)。
Java编程语言中提供了两种基本的同步用语:同步方法(synchronized method)和同步语句(synchronized statement)。同步语句相对而言更复杂一些,我们将在下一节中进行描述,这里重点讨论同步方法。
我们只需要在声明方法的时候增加关键字synchronized即可:
如果count是SynchronizedCounter类的实例,则设置其方法为同步方法会有两个效果:
首先,不可能出现对同一对象的同步方法的两个调用的“交错”。当一个线程在执行一个对象的同步方法的时候,其他所有调用该对象的同步方法的线程都会被挂起,直到第一个线程对该对象操作完毕。
其次,当一个同步方法退出时,会自动与该对象的同步方法的后续调用建立happens-before关系。这就确保了对该对象的修改对其他线程是可见的。
注意: 构造函数不能使用synchronized——在构造函数前使用synchronized关键字将导致语义错误。同步构造函数是没有意义的。这是因为只有创建该对象的线程才能调用其构造函数。
警告: 在创建多个线程共享的对象时,要特别小心对该对象的引用不能过早地“泄露”。例如,我们想要维护一个保存类的所有实例的列表instances,则可能会在构造函数中这样写:
instances.add(this);
但是,其他线程可在该对象的构造完成之前就访问该对象。
同步方法是一种简单的避免线程相互干扰和内存一致性错误的策略:如果一个对象对多个线程都是可见的,那么所有对该对象的变量的读写都应该是通过同步方法完成的(一个例外就是final字段,它在对象创建完成后是不能被修改的,因此,在对象创建完毕后,可以通过非同步的方法对其进行安全的读取)。这种策略是有效的,但可能导致“活跃度问题”。这一点我们会在后面描述。
同步是构建在被称为“内部锁(intrinsic lock)”或“监视锁(monitor lock)”的内部实体上的。在API中通常被称为“监视器(monitor)”。内部锁在两个方面扮演着重要的角色:保证对对象状态访问的排他性,建立对象可见性相关的happens-before关系。
每一个对象都有一个与之相关联动的内部锁。按照传统的做法,当一个线程需要对一个对象的字段进行排他性访问并保持访问的一致性时,它必须在访问前先获取该对象的内部锁,然后才能访问,最后释放该内部锁。在线程获取对象的内部锁到释放对象的内部锁的这段时间,我们说该线程拥有该对象的内部锁。只要有一个线程已经拥有了一个内部锁,其他线程就不能再拥有该锁了。其他线程在试图获取该锁的时候被阻塞了。
当一个线程释放了一个内部锁,就会建立起该动作和后续获取该锁之间的 happens-before 关系。
当一个线程调用一个同步方法的时候,它就自动获得了该方法所属对象的内部锁,并在方法返回的时候释放该锁。即使由于出现了没有被捕获的异常而导致方法返回,该锁也会被释放。
我们可能会感到疑惑:当调用一个静态的同步方法的时候会怎样?静态方法是和类相关的,而不是和对象相关的。在这种情况下,线程获取的是该类的类对象的内部锁。这样对于静态字段的方法来说,这是由和类的实例的锁相区别的另外的一个锁来进行操作的。
另外一种创建同步代码的方式就是使用同步语句。和同步方法不同,使用同步语句必须指明要使用哪个对象的内部锁:
在上面的示例中,方法addName需要对lastName和nameCount的修改进行同步,还要避免同步调用其他对象的方法(在同步代码段中调用其他对象的方法可能导致“活跃度”中描述的问题)。如果没有使用同步语句,那么将不得不使用一个单独、未同步的方法来完成对nameList.add的调用。
在改善并发性时,巧妙地使用同步语句能起到很大的帮助作用。例如,我们假定类MsLunch有两个实例字段,c1 和 c2,这两个变量绝不会被一起使用。所有对这两个变量的更新都需要进行同步,但没有理由阻止对 c1 的更新和对 c2 的更新出现交错——这样做会创建不必要的阻塞,进而降低并发性。此时,我们没有使用同步方法,或者使用和this相关的锁,而是创建了两个单独的对象来提供锁。
采用这种方式时需要特别小心,必须绝对确保相关字段的访问交错是完全安全的。
前面提到,线程不能获取已经被别的线程获取的锁,但是线程可以获取自身已经拥有的锁。允许一个线程能重复获得同一个锁就称为重入同步(Reentrant Synchronization)。它是这样的一种情况:在同步代码中直接或间接地调用了还有同步代码的方法,两个同步代码段中使用的是同一个锁。如果没有重入同步,则在编写同步代码时需要额外小心,以免线程将自己阻塞。
下面介绍另外一种可以避免被其他线程干扰的做法的总体思路——原子访问。
在编程中,原子性动作就是指一次性有效完成的动作。原子性动作是不能在中间停止的:要么一次性执行完毕,要么就不执行。在动作没有执行完毕之前,是不会产生可见结果的。
通过前面的示例,我们已经发现了诸如C++这样的自增表达式并不属于原子操作。即使是非常简单的表达式也包含了复杂的动作,这些动作可以被解释成许多别的动作。然而,的确存在一些原子操作:
对几乎所有的原生数据类型变量(除了 long 和 double)的读写及引用变量的读写都是原子的。
对所有声明为volatile的变量的读写都是原子的,包括long和double类型。
原子性动作是不会出现交错的,因此使用这些原子性动作时不用考虑线程间的干扰。然而,这并不意味着可以移除对原子操作的同步。因为内存一致性错误还是有可能出现的。使用volatile 变量可以降低内存一致性错误的风险,因为任何对 volatile 变量的写操作都和后续对该变量的读操作建立了happens-before关系。这就意味着对volatile类型变量的修改对于别的线程来说是可见的。更重要的是,这意味着当一个线程读取一个volatile类型的变量时,它看到的不仅是对该变量的最后一次修改,还看到了导致这种修改的代码带来的其他影响。
使用简单的原子变量访问比通过同步代码来访问变量更高效,但是需要程序员更多细心的考虑,以避免内存一致性错误。这种额外的付出是否值得完全取决于应用程序的大小和复杂度。
加锁是为了避免在并发环境下,同时访问共享资源产生的风险问题。那么,在并发环境下,是否必须加锁?答案是否定的。并非所有的并发都需要加锁。适当地降低锁的粒度,甚至采用无锁化的设计,更能提升并发能力。
比如,JDK中的ConcurrentHashMap,巧妙地采用了桶粒度的锁,避免了put和get中对整个map的锁定,尤其在get中,只对一个HashEntry做锁定操作,性能提升是显而易见的。
又比如,在程序中可以合理考虑业务数据的隔离性,实现无锁化的并发。例如,程序中预计会有两个并发任务,每个任务可以对所需要处理的数据进行分组。任务1去处理尾数为0到4的业务数据,任务2处理尾数为5到9的业务数据。那么,这两个并发任务所要处理的数据天然是隔离的,也就不需要加锁。
有时为了提升整个网站的性能,我们会将经常访问的数据缓存起来,这样在下次查询时能快速地找到这些数据。缓存系统往往有比传统的数据存储设备(如关系型数据库)更快的访问速度。
缓存的使用与系统的时效性有非常大的关系。当对系统时效性要求不高时,选择使用缓存是极好的。当对系统的时效性要求比较高时,则并不适合使用缓存。
线程是操作系统内核级别最小的并发单元。为了提供并发能力,某些编程语言提供了更细颗粒度的并发单元,比如纤程。相比于线程,纤程可以轻松实现百万的并发量,而且占用更少的硬件资源。
Java语言虽然没有定义纤程,但仍有一些第三方库可选择,比如Quasar。感兴趣的读者可以参阅Quasar的在线手册(http://docs.paralleluniverse.co/quasar/)。