介绍
我们将数据类型和函数的线程安全性定义为:在多个线程使用它们时(无论这些线程如何执行)表现正确,不需要额外的协调。
这是一般原则:并发程序的正确性不应取决于事件发生时间。
为了实现这种正确性,我们列举了四种使代码并发安全的策略:
- 限制:不要在线程之间共享数据,方法是保持变量和指向的数据只能被一个线程访问。
- 不可变性:通过使用final变量和不可变类型,使共享数据不可变。
- 使用现有的线程安全数据类型:使用为线程安全协调好的数据类型。
- 同步:防止线程同时访问共享数据。这是我们用来实现线程安全类型的,但我们当时没有讨论它。
在本文中,我们将讨论策略4,使用同步来实现对共享内存并发安全的自己的数据类型。
同步Synchronization
并发程序的正确性不应取决于事件发生时间。
由于共享可变数据的并发操作引起的竞争条件是灾难性的错误(难以发现,难以重现,难以调试),因此我们需要一种并发模块的方法:共享内存来与彼此同步。
锁是一种同步技术。锁是一种抽象,它允许一次最多一个线程拥有它。 拿着锁是一个线程告诉其他线程的方式:“我正在处理这个问题,现在不要触摸它。”
锁有两个操作:
-
acquire 允许线程获取锁的所有权。如果一个线程试图获取另一个线程当前拥有的锁,它将阻塞,直到另一个线程释放锁。此时,它将与试图获取锁的任何其他线程竞争。一次最多只有一个线程拥有锁。
-
release 放弃锁的所有权,允许另一个线程获得它的所有权。
使用锁还会告诉编译器和处理器你正在同时使用共享内存,以便将寄存器和高速缓存在共享存储内更新。这避免了重新排序的问题,确保锁的所有者始终查看最新数据。
通常,阻塞意味着线程在事件发生之前等待(不做进一步的工作)。
如果一个线程(线程2)拥有着锁l,那么另一个线程(线程1)的一个acquire(l)操作会阻塞。它等待的事件是线程2执行release(l)。此时,如果线程1可以获取l,它将继续运行其代码,并具有锁的所有权。有可能另一个线程(比如线程3)的acquire(l)也被阻塞了。线程1或3将锁定l并继续。另一个将继续阻塞,再次等待release(l)。
银行账户示例
我们的共享内存并发性的第一个例子是一个带有自动提款机的银行。该示例如下图所示:
银行有几台自动提款机,所有这些都可以在内存中读写相同的帐户对象。
当然,如果帐户余额之间的读取和写入没有任何协调,可怕的错误就会发生。
要使用锁解决此问题,我们可以添加一个保护每个银行帐户的锁。现在,在他们可以访问或更新帐户余额之前,自动提款机必须首先获得该帐户的锁。
在下图中,A和B都试图访问帐户1。假设B首先获取锁。然后A必须等到B完成并释放锁,才能读取或写入余额。这可以确保A和B同步,但另一台现金机器C能够在不同的帐户上独立运行(因为该帐户受不同的锁保护)。
死锁
如果使用得小心且适当,锁可以防止竞争条件。但这样又产生了一个问题。因为使用锁要求线程等待(当另一个线程持有锁时,acquire被阻塞),所以可能会遇到两个线程正在等待彼此的情况 - 因此两者都无法取得进展。
在下图中,假设A和B在我们银行的两个账户之间进行同时转账。
帐户之间的转移需要锁定两个帐户,以便资金不会从系统中消失。A和B各自获取其各自“来自”帐户的锁:A获取帐户1的锁,B获取帐户2的锁。现在,每个人必须获取其“到”帐户的锁:所以A正在等待B释放帐户2的锁,而B正在等待A释放帐户1的锁。相持!A和B被冻结在“deadly embrace”中,帐户被锁定。
当并发模块等待彼此执行某些操作时会发生死锁。死锁可能涉及两个以上的模块:死锁的信号特征是循环的依赖,例如A正在等待B,B正在等待C,而C正在等待A,它们都没有进展。
你也可能在不使用任何锁的情况下发生死锁。例如,消息缓冲区填满时,消息传递系统可能会遇到死锁。如果客户端用请求填满服务器的缓冲区,然后阻塞等待添加的另一个请求,而服务器可能会用请求结果填充客户端的缓冲区,然后阻塞自己。所以客户端正在等待服务器,服务器正在等待客户端,并且对方执行之前都不能取得进展。死锁再次地随之而来。
在Java Tutorials中,阅读:
- 死锁(1页)
开发线程安全的抽象数据类型
让我们看看如何使用同步来实现线程安全的ADT。
你可以在GitHub上看到此示例的所有代码:编辑缓冲区示例。不必阅读和理解所有代码。所有相关部分摘录如下。
假设我们正在构建一个多用户在线编辑器,例如腾讯在新文档,它允许多个人连接到它并同时编辑它。我们需要一个可变数据类型来表示文档中的文本。这是一个接口; 基本上它代表一个带有插入和删除操作的字符串:
/** An EditBuffer represents a threadsafe mutable
* string of characters in a text editor. */
public interface EditBuffer {
/**
* Modifies this by inserting a string.
* @param pos position to insert at
(requires 0 <= pos <= current buffer length)
* @param ins string to insert
*/
public void insert(int pos, String ins);
/**
* Modifies this by deleting a substring
* @param pos starting position of substring to delete
* (requires 0 <= pos <= current buffer length)
* @param len length of substring to delete
* (requires 0 <= len <= current buffer length - pos)
*/
public void delete(int pos, int len);
/**
* @return length of text sequence in this edit buffer
*/
public int length();
/**
* @return content of this edit buffer
*/
public String toString();
}
这个数据类型的一个非常简单的rep只是一个字符串:
public class SimpleBuffer implements EditBuffer {
private String text;
// Rep invariant:
// true
// Abstraction function:
// represents the sequence text[0],...,text[text.length()-1]
这个rep的缺点是每次我们进行插入或删除时,我们必须将整个字符串复制到一个新字符串中。这样的操作代价非常昂贵。我们可以使用的另一个rep是字符数组,以多个空格结尾。如果用户只是在文档的末尾键入新文本(我们不需要复制任何内容),这很好,但如果用户在文档的开头键入,那么我们将复制整个文档。
许多在实践中使用的文本编辑有着更有趣的rep,这些rep被称为间隙缓冲区gap buffer。它基本上是一个带有额外空间的字符数组,但不是所有额外的空间都在最后,额外的空间是一个可以出现在缓冲区中任何位置的空隙。每当需要执行插入或删除操作时,数据类型首先将间隙移动到操作的位置,然后执行插入或删除操作。如果间隙已经存在,则不需要复制任何东西 - 插入只消耗部分间隙,删除只会扩大间隙!间隙缓冲区特别适合表示由用户使用光标编辑的字符串,因为插入和删除往往聚焦在光标周围,因此间隙很少移动。
/** GapBuffer is a non-threadsafe EditBuffer that is optimized
* for editing with a cursor, which tends to make a sequence of
* inserts and deletes at the same place in the buffer. */
public class GapBuffer implements EditBuffer {
private char[] a;
private int gapStart;
private int gapLength;
// Rep invariant:
// 0 <= gapStart <= a.length
// 0 <= gapLength <= a.length - gapStart
// Abstraction function:
// represents the sequence a[0],...,a[gapStart-1],
// a[gapStart+gapLength],...,a[a.length-1]
在多用户场景中,我们需要多个间隙,即每个用户的光标分配一个,不过我们目前使用单个间隙。
开发数据类型的步骤
回想一下我们设计和实现ADT的方法:
-
指定。 定义操作(方法的签名和规范)。我们在EditBuffer接口中做到了。
-
测试。 为操作开发测试用例。请参阅EditBufferTest提供的代码。Test case包括基于对操作的参数空间进行分区的测试策略。
-
Rep。 选择一个rep。我们为EditBuffer选择两个rep,这通常是一个好主意:
a. 首先实现一个简单的暴力rep。 编写起来比较容易,你更有可能保证正确,它会验证你的测试用例和你的规范,这样你就可以在进行更难的实现之前修复它们中的问题。这就是我们在继续实现GapBuffer之前实现SimpleBuffer的原因。不要丢弃你的简单版本 - 保留它,以便你有一些东西可以测试和比较,以防更复杂的问题发生。
b. 写下表示不变量和抽象函数,并实现checkRep()。 checkRep()在每个构造函数,producer和mutator方法的末尾断言表示不变量。(通常没有必要在观察者的末尾调用它,因为rep没有改变。)实际上,断言对于测试复杂的实现非常有用,所以在一个复杂方法的结束时断言后置条件并不是一个坏主意。你将在这篇Reading的GapBuffer.moveGap()代码中看到一个示例。
在所有这些步骤中,我们首先完全使用单线程。当我们编写规范并选择rep时,多线程客户端应始终处于我们的脑海中(我们稍后会看到,为了避免你的数据类型的客户端中的竞争条件,谨慎选择操作可能很必要)。不过首先要在顺序的单线程环境中使其可行并进行全面测试。
现在我们已准备好进行下一步:
- 同步。 证明你的rep是线程安全的。将它作为注释显式地写在你的类中,就在表示不变量旁边,以便维护者知道你如何在类中设计线程安全性。
这部分内容是关于如何执行第4步的。我们已经看到了如何证明线程安全性,但这一次,我们将依赖于该证明中的同步。
接下来是我们在上面提到的额外步骤:
- 迭代。你可能会发现,你选择的操作使得编写具有客户端要求的线程安全类型很难。你可能会在步骤1中发现这种情况,或者在编写测试时在步骤2中发现,或在实现时的步骤3或4中发现。如果是这种情况,请返回之前的步骤并优化ADT提供的操作集。
锁定
锁很常用,因此java将它作为内置语言功能提供。
在Java中,每个对象都有一个与其隐式关联的锁 - 一个String,一个数组,一个ArrayList,以及您创建的每个类,它们的所有对象实例都有一个锁。即使是一个卑微(humble)的Object也有锁,所以裸露的Objects通常用于显式锁定:
Object lock = new Object();
但是你不能调用java固有的锁的acquire和release方法。相反,你应该使用
synchronized关键字获取语句块持续时间的锁定:
synchronized (lock) { // thread blocks here until lock is free
// now this thread has the lock
balance = balance + 1;
// exiting the block releases the lock
}
像这样的同步区域提供了互斥:一次只有一个线程可以位于由给定对象的锁保护的同步区域中。换句话说,你回到了顺序编程世界,一次只运行一个线程,至少相对于引用同一对象的其他同步区域。
锁保护对数据的访问
锁用于保护共享数据变量,正如这里展示的帐户余额。如果所有对数据变量的访问(由同步块包围)都被同一个锁对象保护,那么这些访问将保证是基础的 - 不被其他线程中断。
锁仅提供与获取同一个锁的其他线程的互斥。对数据变量的所有访问必须由同一个锁保护。你可以在单个锁后面保护整个变量集合,但所有模块必须就此达成一致:它们将获取和释放的锁是同一个。
因为Java中的每个对象都有一个与其隐式关联的锁,所以你可能认为拥有对象的锁会自动阻止其他线程访问该对象。 事实并非如此。 当线程t使用synchronized (obj) { … }时获取对象的锁定时,它只做一件事:它阻止其他线程进入它们自己的synchronized(expression)块,其中expression引用相同的对象obj,直到线程t完成其同步块。就是这样。即使t在同步块中,另一个线程也可能危险地改变对象,只是忽略使用synchronized本身。为了使用对象的锁进行同步,你必须明确并谨慎地保护每次对该类的访问:使用适当的synchronized块或方法关键字。
监控模式
在编写类的方法时,最方便的锁是对象实例本身,即this。作为一种简单的方法,我们可以通过以下方法保护整个类的rep:将所有对rep的访问包装到synchronized (this)内部。
/** SimpleBuffer is a threadsafe EditBuffer with a simple rep. */
public class SimpleBuffer implements EditBuffer {
private String text;
...
public SimpleBuffer() {
synchronized (this) {
text = "";
checkRep();
}
}
public void insert(int pos, String ins) {
synchronized (this) {
text = text.substring(0, pos) + ins + text.substring(pos);
checkRep();
}
}
public void delete(int pos, int len) {
synchronized (this) {
text = text.substring(0, pos) + text.substring(pos+len);
checkRep();
}
}
public int length() {
synchronized (this) {
return text.length();
}
}
public String toString() {
synchronized (this) {
return text;
}
}
}
请注意这里非常谨慎的规则。每个触及rep的方法必须由锁保护——即使是小而琐碎的方法,例如length() and toString()。这是因为读取同写入一样必须受到保护——如果读取没有保护,那么他们可能能够看到处于部分修改状态的rep。
这种方法称为监视器模式。监视器是一个方法相斥的类,因此一次只能有一个线程位于类的实例中。
Java提供了一些有助于监视器模式的语法"糖"。如果将关键字添加synchronized到方法签名中,那么Java将像你编写synchronized (this)方法体一样。所以下面的代码是实现synchronized的等效方法SimpleBuffer:
/** SimpleBuffer is a threadsafe EditBuffer with a simple rep. */
public class SimpleBuffer implements EditBuffer {
private String text;
...
public SimpleBuffer() {
text = "";
checkRep();
}
public synchronized void insert(int pos, String ins) {
text = text.substring(0, pos) + ins + text.substring(pos);
checkRep();
}
public synchronized void delete(int pos, int len) {
text = text.substring(0, pos) + text.substring(pos+len);
checkRep();
}
public synchronized int length() {
return text.length();
}
public synchronized String toString() {
return text;
}
}
请注意,SimpleBuffer构造函数没有synchronized关键字。Java在语法上实际上禁止这样做,因为正在构造的对象被期望被限制在单个线程中,直到它从构造函数返回。因此,同步构造函数应该是不必要的。synchronized(this)如果愿意,您仍然可以通过将其主体包装在块中来同步构造函数。
在Java Tutorials中,阅读:
p.s. 锁是可以反复获取的。acquire和release成对出现,同一对象上的synchronized块可以安全地嵌套在一起。这意味着锁实际上存储了一个计数器,其中包含其所有者已acquire它而尚未release它的次数。线程继续拥有它,直到每个获取都有相应的release,并且计数器已降至零。
同一锁上的嵌套同步经常发生,例如,如果synchronized方法是递归的,或者如果一个synchronized方法调用另一个synchronized方法(同一对象,this)。
同步的线程安全论证
现在我们用锁来保护SimpleBuffer的rep,我们可以写一个线程安全论证:
/** SimpleBuffer is a threadsafe EditBuffer with a simple rep. */
public class SimpleBuffer implements EditBuffer {
private String text;
// Rep invariant:
// true
// Abstraction function:
// represents the sequence text[0],...,text[text.length()-1]
// Safety from rep exposure:
// text is private and immutable
// Thread safety argument:
// all accesses to text happen within SimpleBuffer methods,
// which are all guarded by SimpleBuffer's lock
GapBuffer相同的论证依然适用:如果我们使用监视器模式来同步其所有方法。
请注意,类的封装,没有rep暴露,对于提出这个论点非常重要。如果text是public:
public String text;
然后外面的SimpleBuffer客户端在不知道他们应该首先获得锁的情况下,将读取和写入它,由此SimpleBuffer不再是线程安全的。
锁定准则
锁定准则是确保同步代码是线程安全的策略。我们必须满足两个条件:
-
每个共享的可变变量必须由一些锁保护。可能无法读取或写入数据,除非在获取该锁的同步块内。
-
如果一个不变量涉及多个共享的可变变量(甚至可能在不同的对象中),那么所涉及的所有变量必须由同一个锁保护。一旦某个线程acquire这个锁,不变量必须在release锁之前被重新建立。
这里使用的监视器模式满足这两个准则。rep中的所有共享可变数据(表示不变量所依赖)都由同一个锁保护。
原子操作
考虑对EditBuffer数据类型的查找和替换操作:
/** Modifies buf by replacing the first occurrence of s with t.
* If s not found in buf, then has no effect.
* @return true if and only if a replacement was made
*/
public static boolean findReplace(EditBuffer buf, String s, String t) {
int i = buf.toString().indexOf(s);
if (i == -1) {
return false;
}
buf.delete(i, s.length());
buf.insert(i, t);
return true;
}
此方法对buf进行三种不同的调用- 将其转换为字符串以便搜索s、删除旧文本、然后在原位置插入t。即使这些调用中的每一个都是原子的,整个findReplace方法也不是线程安全的,因为其他线程可能在findReplace工作时改变buf,导致它删除错误的区域或将替换放回错误的位置。
为了防止这种情况,findReplace需要与所有其他客户端同步buf。
为客户提供锁的访问权限
将数据类型的锁用于客户端有时很有用,这样他们就可以用它来使你的数据类型实现更高级别的原子操作。
因此,解决findReplace问题的一种方法是记录客户端可以使用EditBuffer的锁来相互同步:
/** An EditBuffer represents a threadsafe mutable string of characters
* in a text editor. Clients may synchronize with each other using the
* EditBuffer object itself. */
public interface EditBuffer {
...
}
然后findReplace可以同步buf:
public static boolean findReplace(EditBuffer buf, String s, String t) {
synchronized (buf) {
int i = buf.toString().indexOf(s);
if (i == -1) {
return false;
}
buf.delete(i, s.length());
buf.insert(i, t);
return true;
}
}
这样做的效果是:将监视器模式已经包装的个体方法(toString,delete和insert)的同步区域,放大成确保所有三种方法不受其他线程干扰执行单原子区域。即把整个findPlace方法包装成原子操作。
synchronized关键字放在哪呢?
那么线程安全只是将synchronized关键字放在程序中的每个方法上吗?很不幸,不是。
首先,你实际上不希望无情地同步方法。同步会给您的程序带来很大的成本。进行同步方法调用可能需要更长的时间,因为需要获取锁(以及刷新缓存并与其他处理器通信)。出于这些性能原因,Java默认情况下会使其许多可变数据类型不同步。如果不需要同步,请不要使用它。
以更深思熟虑的方式使用synchronized的另一个理由是:它最小化了对锁的访问范围。在每个方法中添加synchronized意味着你的锁是对象本身,并且每个具有对象引用的客户端都会自动引用你的锁,它可以随意获取和释放。因此,你的线程安全机制是公开的,可能会受到客户端的干扰。对比一下使用一个锁(该锁是你的rep的一个内部对象),并使用synchronized()块适当且谨慎地获取。
最后,在所有地方放置synchronized也不够。毫不犹豫地给一个方法添加synchronized关键字意味着你在以下情况下获取锁:不考虑哪个锁,或者是否是那个正确的锁(用于保护你即将访问的共享数据)。假设我们试图简单地通过在findReplace的声明中添加synchronized关键字来解决它的同步问题:
public static synchronized boolean findReplace(EditBuffer buf, ...) {
这不会达到我们想要的效果。它确实会获得一个锁 - 因为findReplace是一个静态方法,它会为findReplace获取一个恰好存在的整个类的静态锁,而不是实例对象的锁。因此,一次只有一个线程能调用findReplace- 即使其他线程想要在不同的缓冲区上运行(这应该是安全的),它们仍然会被阻塞,直到单个锁是空闲的。因此,我们的性能会遭受重大损失,因为我们的大型多用户编辑器中一次只有一个用户可以进行查找和替换,即使他们都在编辑不同的文档。
然而,更糟糕的是,它不会提供有用的保护,因为触及文档的其他代码可能不会获得相同的锁。它实际上不会消除我们的竞争条件。
该synchronized关键字也不是万能的。线程安全需要一个准则—— 使用限制,不变性或锁来保护共享数据。这个准则需要写下来,否则维护者就不会知道它是什么。
设计并发的数据类型
findReplace问题可以用另一种方式解释:EditBuffer接口对多个同时发生的客户端实际上并不友好。它依赖于整数索引来指定插入和删除位置,这些位置对其他变化(mutations)非常脆弱。如果其他人在索引位置之前插入或删除,则索引将变为无效。
因此,如果我们正在设计专门用于并发系统的数据类型,我们需要考虑提供在交错时具有更好定义语义的操作。例如,最好将EditBuffer与Position配对,或者甚至和Selection配对,其中Position是表示缓冲区中光标位置的数据类型,Selection是表示所选范围的数据类型。一旦获得,a Position可以在文本中保持其位置,防止其周围的插入和删除,直到客户端准备好使用这个Position。如果某些其他线程删除了Position周围的所有文本,那么Position能够通知后续客户端发生了什么(可能有例外),并允许客户决定做什么。在设计并发数据类型时,这些考虑因素会起作用。
另一个例子,考虑Java中的ConcurrentMap接口。此接口扩展了现有Map接口,添加了一些通常需要作为共享可变映射上的原子操作的关键方法,例如:
- map.putIfAbsent(key,value) 是 if ( ! map.containsKey(key)) map.put(key, value); 的一个原子版本的操作
- map.replace(key, value) 是 if (map.containsKey(key)) map.put(key, value); 的一个原子版本的操作
死锁令人头疼
线程安全的锁定方法是强大的,但(不同于限制和不变性)它将阻塞引入程序。线程有时必须等待其他线程退出同步区域才能继续。阻塞增加了死锁的可能性。
正如我们上面所看到的,当线程同时获得多个锁时会发生死锁,并且两个线程在以下情况下最终会被阻塞:两个线程互相等待着对方释放锁。不幸的是,监视器模式使这种情况很可能发生。下面是一个例子。
假设我们正在为一系列书籍中的社交网络建模:
public class Wizard {
private final String name;
private final Set<Wizard> friends;
// Rep invariant:
// friend links are bidirectional:
// for all f in friends, f.friends contains this
// Concurrency argument:
// threadsafe by monitor pattern: all accesses to rep
// are guarded by this object's lock
public Wizard(String name) {
this.name = name;
this.friends = new HashSet<Wizard>();
}
public synchronized boolean isFriendsWith(Wizard that) {
return this.friends.contains(that);
}
public synchronized void friend(Wizard that) {
if (friends.add(that)) {
that.friend(this);
}
}
public synchronized void defriend(Wizard that) {
if (friends.remove(that)) {
that.defriend(this);
}
}
}
与Facebook一样,这个社交网络是双向的:如果x是y的朋友,那么y也是x的朋友。friend()和defriend()两个方法通过修改这两个对象的rep来保证that的不变量,这是因为它们使用了监视器模式(即同时获取两个对象的锁)。
让我们创建几个wizards:
Wizard harry = new Wizard("Harry Potter");
Wizard snape = new Wizard("Severus Snape");
然后考虑两个独立线程重复运行时会发生什么:
// thread A // thread B
harry.friend(snape); snape.friend(harry);
harry.defriend(snape); snape.defriend(harry);
我们将很快陷入死锁。原因如下。假设线程A即将执行harry.friend(snape),线程B即将执行snape.friend(harry)。
- 线程A获取harry的锁(因为friend方法是synchronized)。
- 然后线程B获取snape的锁(出于同样的原因)。
- 它们都独立地更新它们各自的rep,然后尝试调用另一个对象的friend() - 这需要它们获取另一个对象的锁。
所以A拿着Harry的锁等着Snape的锁,而B正拿着Snape的锁等着Harry的锁。两个线程都在friend()处被卡住了,因此没有哪个线程会设法退出同步区域并将锁释放到另一个区域。这是一个经典deadly embrace(即死锁,见# 死锁 一节)。该程序只会停止。
问题的实质是获取多个锁,并在等待另一个锁变为空闲时仍持有一些锁。
请注意,线程A和线程B可能交错, 从而不会发生死锁:在线程B获取第一个锁之前,线程A可能已经获取并释放这两个锁。如果死锁中涉及的锁也涉及竞争条件(并且总是这样),那么死锁将同样难以重现或调试。
死锁解决方案1:锁的顺序
防止死锁的一种方法是对需要同时获取的锁进行排序,并确保所有代码按该顺序获取锁。
在我们的社交网络示例中,我们可能总是按Wizard的name的字母顺序,获取Wizard对象上的锁。由于线程A和线程B都需要Harry和Snape的锁,所以他们都会按顺序获取它们:先是Harry的锁,然后再是Snape的。如果线程A在B之前获得了Harry的锁,它也将在B之前获得Snape的锁,因为B无法继续直到A再次释放Harry的锁定。锁上的顺序强制线程获取它们的顺序,因此无法在等待图中产生循环。
这是代码的样子:
public void friend(Wizard that) {
Wizard first, second;
if (this.name.compareTo(that.name) < 0) {
first = this; second = that;
} else {
first = that; second = this;
}
synchronized (first) {
synchronized (second) {
if (friends.add(that)) {
that.friend(this);
}
}
}
}
(请注意,对于这本哈利波特的书的情况来说,根据人的名字按字母顺序对锁进行排序的决定会很好,但它在现实生活中的社交网络中不起作用。为什么不呢?比这种名字顺序有什么更好的方法来对锁排序吗?)
虽然锁的顺序很有用(特别是在像操作系统内核这样的代码中),但它在实践中有许多缺点。
- 首先,它不是模块化的 - 代码必须知道系统中的所有锁,至少是其子系统中的。
- 其次,代码可能很难或不可能确切地知道在获得第一个锁之前它将需要哪些锁。它可能需要做一些计算来弄清楚。例如,考虑在社交网络图上进行深度优先搜索 - 在你开始寻找之前,你如何知道需要锁定哪些节点?
死锁解决方案2:粗粒度锁定
比锁的顺序更常见的方法,特别是对于应用程序编程(与操作系统或设备驱动程序编程相反),是使用更粗略的锁定——使用单个锁来保护许多对象实例,甚至是程序的整个子系统。
例如,我们可能只有一个锁用于整个社交网络,并且在其任何组成部分上的所有操作都在该锁上同步。在下面的代码中,所有Wizard对象s都属于一个Castle,我们只是使用该Castle对象的锁来保持同步:
public class Wizard {
private final Castle castle;
private final String name;
private final Set<Wizard> friends;
...
public void friend(Wizard that) {
synchronized (castle) {
if (this.friends.add(that)) {
that.friend(this);
}
}
}
}
粗粒度锁可能会显着降低性能。如果用一个锁来保护大量可变数据,那么你就放弃了同时访问任何数据的能力。在最坏的情况下,使用单个锁保护所有内容,你的程序可能基本上是顺序的 - 只允许一个线程一次进行。
实践中的并发性
目标
现在是一个提升水平并看看我们正在做什么的好时机。回想一下,我们的主要目标是创建安全、易于理解、并随时可以进行更改的软件。
显然,构建并发软件是这三个目标的挑战。我们可以将问题分成两大类。当我们询问并发程序是否可以防止bug时,我们关心两个属性:
-
安全。 并发程序是否满足其不变量及其规范?访问可变数据的竞争威胁到安全性。安全问题:你能证明一件坏事永远不会发生吗?
-
活力。 程序是否继续运行并最终按照你的意愿行事,或者它是否会在某个地方永远等待永远不会发生的事件?你能证明好事最终总会发生吗?
死锁威胁到了活力。活力也可能需要公平性,这意味着并发模块具有处理能力以在其计算上取得进展。公平性主要是操作系统的线程调度程序的问题,但你可以通过设置线程优先级来影响它(对于好或坏)。
策略
实际项目通常采用哪些策略?
-
库数据结构要么不使用同步(为单线程客户端提供高性能,而将其留给多线程客户端以在顶部添加锁定),要么使用监视器模式。
-
具有许多部分的可变数据结构通常使用粗粒度锁定或线程限制。大多数图形用户接口工具包都遵循这些方法之一,因为图形用户接口基本上是可变对象的可变树。Java Swing是一个图形用户接口工具包,它使用线程限制。只允许一个专用线程访问Swing的树。其他线程必须将消息传递给该专用线程才能访问该树。
-
搜索通常使用不可变数据类型。我们的布尔公式可满足性搜索(参见mit课件)很容易制作多线程,因为所涉及的所有数据类型都是不可变的。没有种族或死锁的风险。
-
操作系统通常使用细粒度锁以获得高性能,并使用锁的顺序来处理死锁问题。
我们省略了一种可变共享数据的重要方法,因为它超出了本课程的范围,但值得一提:数据库。数据库系统广泛用于分布式客户端/服务器系统,如Web应用程序。数据库使用事务(transactions)来避免竞争条件,这些事务类似于同步区域,因为它们的影响是原子的,但是它们不必获取锁,尽管事务可能会失败并且如果发现竞争发生则回滚(roll back)。数据库还可以管理锁,并自动处理锁的顺序。有关如何在系统设计中使用数据库的更多信息,参见6.170 Software Studio; 有关数据库如何在内部工作的更多信息,请参阅6.814数据库系统。
如果你对并发程序的性能感兴趣- 因为性能通常是我们首先在系统中添加并发性的原因之一 - 那么6.172 Performance Engineering就是你的选择。
摘要
构造一个安全、易于理解并随时可以进行更改的并发程序需要仔细思考。一旦你试图将它们固定下来,Heisenbugs就会消失,因此调试不是一种有效的方法来实现正确的线程安全代码。并且线程可以以多种不同的方式交织它们的操作,即使是所有可能的执行中的一小部分,你也永远无法测试它们。
-
创建有关数据类型的线程安全性论证,并在代码中记录它们。
-
获取锁允许线程对该锁保护的数据具有独占访问权,从而强制其他线程阻塞 - 只要这些线程也试图获取相同的锁。
-
所述监视器模式守卫与由每一个方法获取的单个锁定一数据类型的代表。
-
获取多个锁导致的阻塞会导致死锁。