文章目录
第 1 章 第一步:并发设计原理
计算机系统的用户总是希望自己的系统具有更好的性能。他们想要获得质量更高的视频、更好的视频游戏和更快的网络速度。几年前,提高处理器的速度可以为用户提供更好的性能。但是如今,处理器的速度并没有加快。取而代之的是,处理器增加了更多核心,这样操作系统就可以同时执行多个任务。这就是所谓的并发处理 。并发编程涵盖了在一台计算机上同时运行多个任务或进程所需的所有工具和技术,以及任务或进程之间为消除数据丢失或不一致而进行的通信和同步。本章将探讨如下主题。
- 基本的并发概念。
- 并发应用程序中可能出现的问题。
- 设计并发算法的方法论。
- Java并发API。
- 并发设计模式。
- 设计并发算法的提示和技巧。
1.1 基本的并发概念
首先介绍一下并发的基本概念。要理解本书其余的内容,必须先理解这些概念。
1.1.1 并发与并行
并发和并行是非常相似的概念,不同的作者会给这两个概念下不同的定义。关于并发,最被人们认可的定义是,在单个处理器上采用单核执行多个任务即为并发。在这种情况下,操作系统的任务调度程序会很快从一个任务切换到另一个任务,因此看起来所有任务都是同时运行的。对于并行来说也有同样的定义:同一时间在不同的计算机、处理器或处理器核心上同时运行多个任务,就是所谓的“并行”。
另一个关于并发的定义是,在系统上同时运行多个任务(不同的任务)就是并发。而另一个关于并行的定义是:同时在某个数据集的不同部分之上运行同一任务的不同实例就是并行。
关于并行的最后一个定义是,系统中同时运行了多个任务。关于并发的最后一个定义是,一种解释程序员将任务和它们对共享资源的访问同步的不同技术和机制的方法。
正如你看到的,这两个概念非常相似,而且这种相似性随着多核处理器的发展也在不断增强。
1.1.2 同步
在并发中,我们可以将同步 定义为一种协调两个或更多任务以获得预期结果的机制。同步方式有两种。
- 控制同步 :例如,当一个任务的开始依赖于另一个任务的结束时,第二个任务不能在第一个任务完成之前开始。
- 数据访问同步 :当两个或更多任务访问共享变量时,在任意时间里,只有一个任务可以访问该变量。
与同步密切相关的一个概念是临界段 。临界段是一段代码,由于它可以访问共享资源,因此在任何给定时间内,只能够被一个任务执行。互斥 是用来保证这一要求的机制,而且可以采用不同的方式来实现。
请记住,同步可以帮助你在完成并发任务的同时避免一些错误(本章稍后将详述),但是它也为你的算法引入了一些开销。你必须非常仔细地计算任务的数量,这些任务可以独立执行,而无须并行算法中的互通信。这就涉及并发算法的粒度 。如果算法有着粗粒度 (低互通信的大型任务),同步方面的开销就会较低。然而,也许你不会用到系统所有的核心。如果算法有着细粒度 (高互通信的小型任务),同步方面的开销就会很高,而且该算法的吞吐量可能不会很好。
并发系统中有不同的同步机制。从理论角度来看,最流行的机制如下。
- 信号量 (semaphore):一种用于控制对一个或多个单位资源进行访问的机制。它有一个用于存放可用资源数量的变量,并且可以采用两种原子操作来管理该变量的值。互斥 (mutex,mutual exclusion的简写形式)是一种特殊类型的信号量,它只能取两个值(即资源空闲 和资源忙 ),而且只有将互斥设置为忙 的那个进程才可以释放它。互斥可以通过保护临界段来帮助你避免出现竞争条件。
- 监视器 :一种在共享资源之上实现互斥的机制。它有一个互斥、一个条件变量、两种操作(等待条件和通报条件)。一旦你通报了该条件,在等待它的任务中只有一个会继续执行。
在本章中,你将要学习的与同步相关的最后一个概念是线程安全 。如果共享数据的所有用户都受到同步机制的保护,那么代码(或方法、对象)就是线程安全 的。数据的非阻塞的CAS (compare-and-swap,比较和交换)原语是不可变的,这样就可以在并发应用程序中使用该代码而不会出任何问题。
1.1.3 不可变对象
不可变对象 是一种非常特殊的对象。在其初始化后,不能修改其可视状态(其属性值)。如果想修改一个不可变对象,那么你就必须创建一个新的对象。
不可变对象的主要优点在于它是线程安全的。你可以在并发应用程序中使用它而不会出现任何问题。
不可变对象的一个例子就是Java 中的String
类。当你给一个String
对象赋新值时,会创建一个新的String
对象。
1.1.4 原子操作和原子变量
与应用程序的其他任务相比,原子操作 是一种发生在瞬间的操作。在并发应用程序中,可以通过一个临界段来实现原子操作,以便对整个操作采用同步机制。
原子变量 是一种通过原子操作来设置和获取其值的变量。可以使用某种同步机制来实现一个原子变量,或者也可以使用CAS以无锁方式来实现一个原子变量,而这种方式并不需要任何同步机制。
1.1.5 共享内存与消息传递
任务可以通过两种不同的方法来相互通信。第一种方法是共享内存 ,通常用于在同一台计算机上运行多任务的情况。任务在读取和写入值的时候使用相同的内存区域。为了避免出现问题,对该共享内存的访问必须在一个由同步机制保护的临界段内完成。
另一种同步机制是消息传递 ,通常用于在不同计算机上运行多任务的情形。当一个任务需要与另一个任务通信时,它会发送一个遵循预定义协议的消息。如果发送方保持阻塞并等待响应,那么该通信就是同步的;如果发送方在发送消息后继续执行自己的流程,那么该通信就是异步的。
1.2 并发应用程序中可能出现的问题
编写并发应用程序并不是一件容易的工作。如果不能正确使用同步机制,应用程序中的任务就会出现各种问题。本节将介绍一些此类问题。
1.2.1 数据竞争
如果有两个或者多个任务在临界段之外对一个共享变量进行写入操作,也就是说没有使用任何同步机制,那么应用程序可能存在数据竞争 (也叫作竞争条件 )。
在这些情况下,应用程序的最终结果可能取决于任务的执行顺序。请看下面的例子。
package com.packt.java.concurrency;
public class Account {
private float balance;
public void modify (float difference) {
float value=this.balance;
this.balance=value+difference;
}
}
假设有两个不同的任务执行了同一个Account
对象中的modify()
方法。由于任务中语句的执行顺序不同,最终结果也会有所不同。假设初始余额为1000,而且两个任务都调用了modify()
方法并采用1000作为参数。最终的结果应该是3000,但是如果两个任务都在同一时间执行了第一条语句,然后又在同一时间执行了第二条语句,那么最终的结果将是2000。正如你看到的,modify()
方法不是原子的,而Account
类也不是线程安全的。
1.2.2 死锁
当两个(或多个)任务正在等待必须由另一线程释放的某个共享资源,而该线程又正在等待必须由前述任务之一释放的另一共享资源时,并发应用程序就出现了死锁 。当系统中同时出现如下四种条件时,就会导致这种情形。我们将其称为Coffman条件 。
- 互斥 :死锁中涉及的资源必须是不可共享的。一次只有一个任务可以使用该资源。
- 占有并等待条件 :一个任务在占有某一互斥的资源时又请求另一互斥的资源。当它在等待时,不会释放任何资源。
- 不可剥夺 :资源只能被那些持有它们的任务释放。
- 循环等待 :任务1正等待任务2所占有的资源,而任务2又正在等待任务3所占有的资源,以此类推,最终任务n 又在等待由任务1所占有的资源,这样就出现了循环等待。
有一些机制可以用来避免死锁。
- 忽略它们 :这是最常用的机制。你可以假设自己的系统绝不会出现死锁,而如果发生死锁,结果就是你可以停止应用程序并且重新执行它。
- 检测 :系统中有一项专门分析系统状态的任务,可以检测是否发生了死锁。如果它检测到了死锁,可以采取一些措施来修复该问题,例如,结束某个任务或者强制释放某一资源。
- 预防 :如果你想防止系统出现死锁,就必须预防Coffman条件中的一条或多条出现。
- 规避 :如果你可以在某一任务执行之前得到该任务所使用资源的相关信息,那么死锁是可以规避的。当一个任务要开始执行时,你可以对系统中空闲的资源和任务所需的资源进行分析,这样就可以判断任务是否能够开始执行。
1.2.3 活锁
如果系统中有两个任务,它们总是因对方的行为而改变自己的状态,那么就出现了活锁 。最终结果是它们陷入了状态变更的循环而无法继续向下执行。
例如,有两个任务:任务1和任务2,它们都需要用到两个资源:资源1和资源2。假设任务1对资源1加了一个锁,而任务2对资源2加了一个锁。当它们无法访问所需的资源时,就会释放自己的资源并且重新开始循环。这种情况可以无限地持续下去,所以这两个任务都不会结束自己的执行过程。
1.2.4 资源不足
当某个任务在系统中无法获取维持其继续执行所需的资源时,就会出现资源不足 。当有多个任务在等待某一资源且该资源被释放时,系统需要选择下一个可以使用该资源的任务。如果你的系统中没有设计良好的算法,那么系统中有些线程很可能要为获取该资源而等待很长时间。
要解决这一问题就要确保公平原则 。所有等待某一资源的任务必须在某一给定时间之内占有该资源。可选方案之一就是实现一个算法,在选择下一个将占有某一资源的任务时,对任务已等待该资源的时间因素加以考虑。然而,实现锁的公平需要增加额外的开销,这可能会降低程序的吞吐量。
1.2.5 优先权反转
当一个低优先权的任务持有了一个高优先级任务所需的资源时,就会发生优先权反转 。这样的话,低优先权的任务就会在高优先权的任务之前执行。
1.3 设计并发算法的方法论
本节,我们将提出一个五步骤的方法论来获得某一串行算法的并发版本。该方法论基于Intel公司在其“Threading Methodology: Principles and Practices”文档中给出的方法论。
1.3.1 起点:算法的一个串行版本
我们实现并发算法的起点是该算法的一个串行版本。当然,也可以从头开始设计一个并发算法。不过我认为,算法的串行版本有两个方面的好处。
- 我们可以使用串行算法来测试并发算法是否生成了正确的结果。当接收同样的输入时,这两个版本的算法必须生成同样的输出结果,这样我们就可以检测并发版本中的一些问题,例如数据竞争或者类似的条件。
- 我们可以度量这两个算法的吞吐量,以此来观察使用并发处理是否能够改善响应时间或者提升算法一次性所能处理的数据量。