在之前章节,我们已经讨论过线程在开发多用户应用程序时扮演的重要角色。我们已经使用线程来解决一些重要的问题,比如让多个用户或者客户端在同一时间访问同一个资源。然而,在学习过程中我们忽略了一个问题,现在到了处理这个问题的时候了:如果一个用户改变了资源的状态,同时另外一个用户也想改变同一个资源的状态的话,会发生什么?
举个例子,假设有一台ATM, 丹尼尔和他夫人决定通过ATM从他们的共同支票账户中取出1000美元。不幸的是他们忘了谁要做这件事情。不凑巧的是他们俩同时从两台不同的ATM 机器访问同一个账户,如果运行在银行后台的程序不是线程安全的话,那么很有可能每台ATM机在检测账户时都认为余额足够并因此分别向夫妻俩各支付1000美元。丹尼尔夫妻俩导致银行后台程序在同一时间生成两个访问账户数据库的线程。在一个理想情况下,当一个用户在更新他们的账户时,任何其他人都不可以访问同一个账户。简而言之,当一个用户访问数据库账户来做一些账户信息更新操作时,系统必须将响应账户锁住。
.NET Framework 提供了一套特殊架构来处理类似问题。在任意时间允许有且仅有一个线程访问一个资源的技术称作同步。同步是一种程序员用来对重要资源进行线程安全访问的技术。
.NET 开发人员在设计一个多线程应用程序时需要仔细考虑同步问题主要是基于以下两个原因:
1. 竞争条件
2. 保证线程安全
由于.NET Framework 内部支持线程, 所以可能你开发的所有类都最终要用在多线程应用程序中。你不需要(也不应该)把每个类都设计成线程安全,因为线程安全不是免费的。但是你至少应该在每次设计一个.NET类时考虑一下线程安全,考虑一下你的类会不会同时被多个线程访问。线程安全的开销以及如何让一个类成为线程安全的会在本章的后续部分讨论。不要担心多线程访问本地变量,方法参数以及返回值,因为这些变量存储在堆栈中,而堆栈本身就是线程安全的。实例和类变量不是存储在堆栈中,所以除非你为自己的类设计了线程安全,否则它们不是线程安全的。
在我们对线程同步作深入介绍之前,我们再了解一下本章开始时提到的ATM的例子。图1 描绘了丹尼尔夫妇在同一时间从同一个账户中取出1000美元。当一个线程访问一个资源并使其状态非法,同时另外一个线程使用这个状态非法的对象而导致不可预知的结果,这种行为称作竞争条件。为了避免竞争条件,我们需要让Withdraw()方法是线程安全的,以便于在任意时间有且仅有一个线程访问这个方法。
图1
至少有三种方法可以使一个对象是线程安全的:
1. 在代码中对关键部分进行同步
2. 让对象状态不变,或者说让对象称为常量
3. 使用一个线程安全的包装
为了避免多个线程在同一时间更新同一个资源而导致的不可预知的影响,我们需要严格限制对资源的访问,比如在任意时间只允许一个线程更新资源,换句话说,让资源成为线程安全的。让一个对象或者一个实例变量成为线程安全的最直观方式是确定其关键部分并对其关键部分进行同步。程序中的关键部分是指可能在同一时间被多个线程调用来更新某个对象状态的代码。例如,在上面提到过的场景中丹尼尔夫妇想要在同一时间访问Withdraw()方法,则Withdraw()方法就是关键部分且需要是线程安全的。实现这个方案的最简单方式就是对Withdraw()方法进行同步以便于在任意时间仅有一个线程可以访问它。在执行过程中不可以被中断的过程称作原子。原子是一个不可再分的单位,原子操作是以一个完整的单元执行的代码部分-看起来像一条单一的处理器指令。通过让Withdraw()方法变成原子的,我们可以确定在一个线程对账户状态修改完之前其他线程不可能改变同一个账户状态。下面是以伪码表示的非线程安全Account类:
class NonThreadSafeAccount { public ApprovedOrNot Withdraw(Amount) { //1. Make sure that the user has enough cash(Check the Balance) //2. Update the Account with the new balance //3. Send approval to the ATM } }
下面是由伪码表示的线程安全的Account类:
class NonThreadSafeAccount { public ApprovedOrNot Withdraw(Amount) { lock this section(access for only one thread) { //1. Check the Account Balance //2. Update the Account with the new balance //3. Send approval to the ATM } } }
在第一个代码片段中,可能有多于两个线程在同一时间进入关键部分,所以有可能在同一时间有两个线程检查账户余额,而两个线程都会得到账户余额1000美元。由于这个原因,两个用户可能都从ATM机取出1000美元,这导致账户异常性地透支。
在第二个代码片段中,在任意时间只允许有一个线程访问关键部分。假设丹尼尔首先获得时间片,那么他就会在索菲亚之前进入Withdraw()方法。所以当丹尼尔的线程开始执行Withdraw()方法时,索菲亚的线程不被允许进入关键部分且必须等待丹尼尔的线程离开关键部分。丹尼尔的线程检查账户余额,然后把账户余额更新为0,并向ATM机返回允许的指令以指示ATM 出钞。钞票取走之前,其他线程不可以访问丹尼尔夫妇的共同账户。当丹尼尔取到钱以后,他的妻子进入到Withdraw()方法。现在,当这个方法检查账户余额时,返回值是0美元,接下来就会告诉ATM机账户余额不足不能取钱。
另外一种可以让一个对象线程安全的方式是让对象状态不可变。一个状态不可变的对象是指那种一经创建状态就不可以更改的对象。可以通过当账户对象创建以后不允许任何线程来修改其状态。采用这种方案,我们把变量只读操作和变量可写操作分开。读取实例变量的关键部分不变,而对改变实例变量的关键部分进行修改。比如,不返回当前对象的状态,而是通过创建一个当前对象的副本并将其引用返回。在这个方法中,我们不需要锁住关键部分,因为事实上没有方法修改对象实例变量,所以一个不可变的对象是线程安全的。
第三种让一个对象线程安全的方案是为对象写一个包装类,并让包装类成为线程安全而非让对象本身线程安全。对象本身不变,新的包装类包含线程安全代码的同步部分。下面代码片段是Account对象的一个包装类:
class AccountWrapper { private Account a; public AccountWrapper(Account a) { this.a = a; } public bool Withdraw(double amount) { lock(a) { return this.a.Withdraw(amount); } } }
AccountWrapper 类是Account类的一个线程安全包装。AccountWrapper类有一个私有的Account实例变量以便于没有其他对象或者线程可以访问Account变量。在这个方法中,Account对象没有任何线程安全特性,而所有的线程安全都由AccountWrapper类提供。
当你使用一些第三方类库中的非线程安全的类时这个方法非常有用。例如,假设银行已经有了一个在大型机系统上开发软件的Account类,为了一致性,现在银行想使用同样的Account类来实现ATM软件。从银行提供给我们的关于Account 类的文档中,它清楚地说明Account类是非线程安全的。同时由于安全原因我们不能访问Account源代码。在这种情况下,我们不得不采用线程安全包装方法来开发一个线程安全的AccountWrapper类作为Account类的扩展。包装类用来向非线程安全资源中添加同步。所有的同步逻辑都在包装类中且不影响非线程安全类的完整性。
下一篇将介绍.NET 中对同步的支持…