`
阿尔萨斯
  • 浏览: 4108406 次
社区版块
存档分类
最新评论

安全的线程同步

 
阅读更多
<iframe align="center" marginwidth="0" marginheight="0" src="http://www.zealware.com/csdnblog.html" frameborder="0" width="728" scrolling="no" height="90"></iframe>

安全的线程同步

Jeffrey Richter

刘未鹏

[msdn.2003.01]

到目前为止,线程同步最为普遍的用途是确保多线程对共享资源的互斥访问。对于同步单个进程中的多个线程以实现互斥访问,Win32 API中的CRITICAL_SECTION结构以及与它相关的函数提供了最为快速和高效的途径,Microsoft .NET Framework并没有提供CRITICAL_SECTION结构,但提供了一个类似的机制——该机制通过System.Threading.Monitor类和SyncBlock得以实现。

在这个专栏里,我会解释.NET Framework如何支持线程同步的这个普遍用途。另外,我还要解释SyncBlockMonitor被设计成现在这个样子的动机以及它们是如何工作的。最后我还要解释为什么这个设计是糟糕的,以及如何用正确和安全的方式去使用该机制。

绝妙的主意

.NET Framework采用了OOP式的结构。这就意味着:开发者构造对象,然后调用类型的成员来操纵它。然而有时候这些对象也会被多个线程所操纵,因此,为了确保对象的状态不被破坏,必须进行线程同步。在设计.NET Framework时,Microsoft的设计师们决定创造一个新的机制来让开发者们轻易地同步一个对象。

基本想法是这样的:堆上的每个对象都关联着一个可以被用于线程同步的数据结构(非常类似于Win32CRITICAL_SECTION)。然后,FCLframework class library,框架类库)再提供一些方法(method)——当你将对象的引用传递过去时——使用这个数据结构来同步线程。

如果将这个设计运用在Win32下的非托管的C++类上,则该类看起来像这样:

1 A CRITICAL_SECTION for Every Object

class SomeType {

private:

// 为每个对象关联一个私有的CRITICAL_SECTION字段

CRITICAL_SECTION m_csObject;

public:

SomeType() {

// 在构造函数中初始化CRITICAL_SECTION字段

InitializeCriticalSection(&m_csObject);

}

~SomeType() {

// 在析构函数中delete CRITICAL_SECTION字段

DeleteCriticalSection(&m_csObject);

}

void SomeMethod() {

// 在该函数中,我们使用了对象的CRITICAL_SECTION字段

// 来同步多个线程对该对象的访问

EnterCriticalSection(&m_csObject);

// 在这里可以执行线程安全的代码了...

LeaveCriticalSection(&m_csObject);

}

void AnotherMethod() {

// 在该函数中,我们使用了对象的CRITICAL_SECTION字段

// 来同步多个线程对该对象的访问

EnterCriticalSection(&m_csObject);

// 在这里可以执行线程安全的代码了...

LeaveCriticalSection(&m_csObject);

}

};

本质上,.NET Framework为每个对象关联一个类似CRITICAL_SECTION的字段,并且负责对它进行初始化和删除。开发者要做的唯一一件事情是:在需要线程同步的方法中加入一些代码以进入(Enter)和离开(Leave)该字段。

实现绝妙的主意

现在,很明显,为堆上的每个对象都关联一个CRITICAL_SECTION字段是一件很浪费的事情,特别是由于大多数对象从不需要线程安全的访问。因此,.NET Framework小组设计了一个更高效的途径来提供前面描述的功能。下面就来说说它是如何工作的:

CLR(公共语言运行时)初始化时,它分配一个SyncBlock的缓存区。一个SyncBlock就是一个可以根据需要关联到任何对象的内存块。SyncBlock具有与Win32 CRITICAL_SECTION相同的字段。

堆上的每个对象在创建时都关联了两个额外的字段——第一个是MethodTablePointer(方法表指针),包含了该类型的方法表的地址。基本上,这个指针使你能够获取堆上的每个对象的类型信息。事实上,当你调用System.ObjectGetType方法时,该方法通过对象的MethodTablePointer字段来确定对象的类型。另一个额外的字段——称为SyncBlockIndex——包含一个32位的有符号整数,在SyncBlock缓存区中索引一个SyncBlock

当一个对象被构造时,它的SyncBlockIndex字段会被初始化为一个负值——表示根本不指向任何SyncBlock。然后,当某个方法被调用以同步对该对象的访问时CLRSyncBlock缓存区中寻找一个空闲的SyncBlock,并且让该对象的SyncBlockIndex字段指向它。换句话说,SyncBlock只在某个对象需要同步字段时才会被关联到该对象。而当不再有任何线程对该对象进行同步访问时,该对象的SyncBlockIndex字段就会被重置为一个负值,并且相应的SyncBlock也被释放,以便在将来可以被重新关联到其它对象。

2. .NET中的线程同步

<shapetype id="_x0000_t75" coordsize="21600,21600" o:spt="75" o:preferrelative="t" path="m@4@5l@4@11@9@11@9@5xe" filled="f" stroked="f"><stroke joinstyle="miter"></stroke><formulas><f eqn="if lineDrawn pixelLineWidth 0"></f><f eqn="sum @0 1 0"></f><f eqn="sum 0 0 @1"></f><f eqn="prod @2 1 2"></f><f eqn="prod @3 21600 pixelWidth"></f><f eqn="prod @3 21600 pixelHeight"></f><f eqn="sum @0 0 1"></f><f eqn="prod @6 1 2"></f><f eqn="prod @7 21600 pixelWidth"></f><f eqn="sum @8 21600 0"></f><f eqn="prod @7 21600 pixelHeight"></f><f eqn="sum @10 21600 0"></f></formulas><path o:extrusionok="f" gradientshapeok="t" o:connecttype="rect"></path><lock v:ext="edit" aspectratio="t"></lock></shapetype><shape id="_x0000_i1025" style="WIDTH: 193.5pt; HEIGHT: 135.75pt" type="#_x0000_t75"><imagedata src="file:///G:/Temp/msohtml1/01/clip_image001.gif" o:title="fig02"></imagedata></shape>

2是这个思想的具体表现形式。在该图的CLR Data Structures部分,你可以看到,系统所知道的每个类型都有一个对应的数据结构。你也可以看到SyncBlock结构的集合。在该图的Managed Heap部分你可以看到有三个对象(ObjectA,ObjectB,ObjectC)被创建了。每个对象的MethodTablePointer字段都指向相应类型的方法表。通过方法表,你可以获得每个对象的类型。所以,我们可以很容易的看到,ObjectAObjectB都是SomeType类型的实例,而ObjectCAnotherType类型的实例。

ObjectASyncBlockIndex字段被设为0,这表示SyncBlock #0目前正被ObjectA使用。另一方面,ObjectBSyncBlockIndex被设为-1,表示并没有任何SyncBlock与它关联。最后,ObjectCSyncBlockIndex2,表明它正在使用SyncBlock #2。在本例中,SyncBlock #1没有被使用,但是将来可能会与某个对象关联。

因此,从逻辑上说,堆上的每个对象都关联着一个SyncBlock——它可以被用于快速互斥的线程同步。然而,从物理上说,SyncBlock仅当被需要时才会与某个对象关联,并且在对象不再需要它时从对象上脱离。这意味着内存的使用是有效率的。另外,如果有必要,SyncBlock缓存可以创建更多的SyncBlock,因此你不用担心它会由于同一时刻同步对象过多而被用尽。

使用Monitor来操纵SyncBlock

既然你已经理解了SyncBlock,就让我们来看看如何锁定一个对象。要锁定或解锁一个对象,你可以使用System.Threading.Monitor类。该类型的所有方法都是静态的。调用下面的方法可以锁定一个对象:

public static void Enter(object obj);

当你调用Enter方法时,它首先检查指定的对象的SyncBlockIndex是否为负值,如果是的,则该方法从SyncBlock缓存中找一个空闲的SyncBlock并且将它的索引保存到对象的SyncBlockIndex中。一旦对象上已经关联了SyncBlock,则Enter方法就会检查对象的SyncBlock,看看当前是否有另一个线程正拥有这个SyncBlock。如果当前没有任何进程拥有它,则当前调用Enter的线程成为该对象的所有者。另一方面,如果另一个线程已经拥有了该SyncBlock,则当前调用Enter的线程会挂起,直到那个线程释放对SyncBlock的所有权。

如果你想写更具防御性的代码,则可以调用下面的TryEnter方法之一:

public static Boolean TryEnter(object obj);

public static Boolean TryEnter(object obj,

int millisecondsTimeout);

public static Boolean TryEnter(object obj,

TimeSpan timeout);

第一个TryEnter只是简单的检查调用它的线程能否获得对象的SyncBlock的所有权,如果成功则返回true。另外两个方法则允许你指定一个等待时间,表示你允许线程等待所有权多久。如果不能获得所有权,则三个方法都会返回false

一旦获得了所有权,代码就可以安全地访问对象的字段了。访问完毕之后,线程还应该调用Exit来释放SyncBlock

public static void Exit(object obj);

如果线程并没有获得指定对象的SyncBlock的所有权,则调用Exit会抛出一个SynchronizationLockException。同时还要注意,线程可以递归式获取一个SyncBlock的所有权,每次成功的Enter必须对应一个Exit,这样最终SyncBlock才会被释放(这一点与Win32CRITICAL_SECTION是一样的——译者)。

Synchronizing the Microsoft Way

现在让我们看看3的示例代码,它展示了如何使用MonitorEnterExit方法来锁定和解锁一个对象。注意,LastTransaction属性(property)的实现需要调用EnterExit以及一个临时变量dt。这是非常重要的,这样可以避免返回一个已经被破坏的值——如果一个线程调用PerformTransaction时另一个线程在访问该属性就会发生这种情况。

C#Lock语句来简化代码

因为这种“调用Enter——访问受保护资源——调用Exit”的模式是如此普遍,所以C#干脆提供了特殊的语法来简化这种代码。4的两段C#代码片断功能相同,但是后者更为简洁。使用C#lock语句,你可以将Transaction类充分简化。特别要注意5中所展示的改进后的LastTransaction属性,它不再需要临时变量了。

除了能够简化代码外,lock语句还确保了Monitor.Exit一定会被调用,从而即使在try块中产生了一个异常,SyncBlock也会被释放。你应该始终将异常处理与同步机制结合起来使用,以确保锁被正确释放。然而,如果你使用C#lock语句,则编译器会自动帮你生成正确的代码。另外,Visual Basic .NET也有一个类似于C#lock语句的SyncLock语句,它们做同样的事情。

Synchronizing Static Members the Microsoft Way

Transaction类示范了如何同步访问一个对象的实例字段。但是,如果你的类型定义了一些静态字段以及访问这些字段的静态函数又当如何呢?在这种情况下,堆上并没有该类型的实例,因而也就没有可用的SyncBlock或者传递给Monitor.EnterMonitor.Exit方法的对象引用。

事实上,包含某个类型的类型描述符的内存块是位于堆上的对象。2中并没有表现出这一点,但是SomeType的类型描述符和AnotherType的类型描述符所占的内存块其实本身都是对象,并且同样也有MethodTablePointer字段和SyncBlockIndex字段。这就意味着SyncBlock可以被关联到一个类型,并且类型对象(type object,指“描述一个类型”的对象)的引用可以被传递给MonitorEnterExit方法。在6所示的Transaction类中,所有的成员都改成了静态的,并且PerformTransaction方法和LastTransaction属性也作了改动以展示Microsoft希望开发者如何同步对静态成员的访问。

PerformTransaction方法和LastTransaction属性中,你不再会看到this关键字,因为在静态成员中不能使用它。我将类型的类型描述符对象的引用传给lock语句。这个引用是通过C#typeof操作符得到的,typeof操作符返回指定对象的对象描述符的引用。在Visual Basic .NET中,GetType操作符具有同样的功能。

为什么绝妙的主意并不那么绝妙

如你所见,使堆上的每个对象逻辑上关联一个用于同步的数据结构的主意听起来很不错。但是实际上这是一个糟糕的主意。听我解释原因。还记得在本文开头展示的非托管的C++代码吗?如果由你来写,你会将CRITICAL_SECTION字段的访问权限设为public吗?当然不会——那简直是荒谬的。将这个字段设为public会允许程序中的任何代码操纵该CRITICAL_SECTION结构,这样恶意代码很容易就能够将使用该类型的实例的任何线程死锁住。

...猜猜看发生了什么——SyncBlock正如一个public字段一样!任何一段代码在任何时候都可以将任何对象的引用传给MonitorEnterExit方法。事实上,任何类型描述符的引用也同样可以被传给Monitor的方法。

7的代码显示了这种情况是多么糟糕。这里,Main方法中创建了一个App对象,然后锁定该对象,并在某个时刻发生一次垃圾收集(在这段代码中,强制一次垃圾收集),当AppFinalize方法被调用时,它也会试图锁定该对象,但是由于程序的主线程已经锁定了该对象,所以CLRFinalize线程无法锁定它。这就导致CLRFinalize线程停止了——于是在当前进程(可能包含多个AppDomain)中再也不会有其它对象可以被finalize,也不再有其它可finalize的对象在堆上的内存会被回收。

幸运的是有一个解决方案,只不过那意味着你得抛开Microsoft的设计和建议。取而代之的是定义一个privateSystem.Object字段作为你的类型的成员,构造它,然后将其引用传递给C#lock语句或Visual Basic .NETSyncLock语句。8展示了如何重写Transaction类以便让用于同步的对象成为该类的私有成员。同样地,9展示了当Transaction类的成员全为静态时如何去重写它。

看起来,仅仅为了同步而构造一个System.Object对象是比较怪异的。我感觉MicrosoftMonitor类的设计并不恰当。应该让你为每个想要同步的类型(原文为type,疑为object)构造一个Monitor类型的实例。这样,静态方法(Monitor类的静态方法)就会成为不需要System.Object参数的实例方法。这将解决所有的问题,并且充分简化开发者的编程模型。

另外,如果你创建具有许多字段的复杂类型,则在任何时候,你的方法和属性可能只需要锁定这些字段的一个子集。你始终可以通过将指定字段的引用传给lockMonitor.Enter来锁定它。当然,如果字段为私有(我始终建议如此),则我只会考虑这样做。如果你想要将几个字段一起锁定,那么你可以始终将其中的一个传给lockEnter。或者,你还可以构造一个System.Object对象——它的唯一意图就是用于锁定一集字段。lock段(临界段)越细化,代码的性能和可测性就越好[1]

[1] 译注:作者的意思是,应该保持lock(临界)段的短小,换句话说,一个lock(临界)段应该执行尽量少的代码,这样才能保证其它线程在lock(临界)区上的等待时间尽量短,并且死锁的可能性也更小。

未装箱的(unboxed)值类型实例

在结束这个专栏之前,我想要指出一个有关同步的bug,我第一次遇到它时花了好几个小时来跟踪。下面的代码片断示范了这个问题:

class AnotherType {

// 一个 未装箱的(unboxed Boolean 值类型实例

private Boolean flag = false;

public Boolean Flag {

set {

Monitor.Enter(flag); // flag装箱并锁定装箱后的对象

flag = value; // 而实际的值却未受保护

Monitor.Exit(flag); // flag装箱并试图unlock装箱后的对象

}

}

}

你可能会惊讶于在这段代码中并没有发生任何线程同步!原因是:flag是个未装箱的值类型实例,而并非一个引用类型。未装箱的值类型实例并没有MethodTablePointerSyncBlockIndex这两个额外的字段。这就意味着一个未装箱的值类型实例不可能有一个与它关联的SyncBlock

MonitorEnterExit方法要求一个指向堆上的对象的引用。当C#Visual Basic .NET和许多其它编译器看到一段代码试图将未装箱的值类型实例传给需要对象引用的方法时,它们会自动生成代码来将该值类型的实例装箱(box)。装箱后的实例(位于堆上)将会拥有一个MethodTablePointer和一个SyncBlockIndex,因而可以被用于线程同步。然而,每调用这样的函数一次就会进行一次新的装箱,即产生一个新的装箱后的实例,这个实例与以前装箱的实例都不相同,也就是说,我们每次lockunlock的都是不同的对象。

例如,在上面代码片断中,当Flag属性的set方法被调用时,它调用了MonitorEnter方法。Enter需要一个引用类型,因此flag被装箱,并且装箱后的对象的引用被传递给Enter,该对象的SyncBlock现在归调用线程所有。如果另一个线程现在也要访问这个属性,那么flag将会被再次装箱,产生一个新的对象,它拥有自己的SyncBlock。另外,对Exit的调用也会导致一次装箱操作。

正如我前面所说,我花了好几个小时才发现问题所在。如果你想要同步对一个未装箱的值类型实例的访问,那么你必须分配一个System.Object对象,并利用它来进行同步。10中的代码是正确的。

另外,如果你使用C#lock语句来代替Monitor.EnterMonitor.Exit,那么C#编译器会帮你避免意外地试图去lock一个值类型。当你将一个未装箱的值类型实例传给lock语句时,C#编译器会报错。例如,如果你试图将一个BooleanC#中的bool)传给lock语句,那么你将看到如下的错误:“error CS0185'bool' is not a reference type as required by the lock statement”。而在Visual Basic .NET中,如果你对SyncLock语句使用未装箱的值类型实例,编译器也会报错:“error BC30582: 'SyncLock' operand cannot be of type 'Boolean' because 'Boolean' is not a reference type”。

3. Using Enter and Exit Methods

class Transaction {

// Private field holding the time of

// the last transaction performed

private DateTime timeOfLastTransaction;

public void PerformTransaction() {

// Lock this object

Monitor.Enter(this);

// Perform the transaction...

// Record time of the most recent transaction

timeOfLastTransaction = DateTime.Now;

// Unlock this object

Monitor.Exit(this);

}

// Public read-only property returning

// the time of the last transaction

public DateTime LastTransaction {

get {

// Lock this object

Monitor.Enter(this);

// Save the time of the last transaction

// in a temporary variable

DateTime dt = timeOfLastTransaction;

// Unlock this object

Monitor.Exit(this);

// Return the value in the temporary variable

return(dt);

}

}

}

4. Regular and Simple Lock and Unlock

// Regular function

public void SomeMethod() {

// Lock the object

Object oTemp = this;

Monitor.Enter(oTemp);

try {

// Access the object

...

// Unlock the object

}

finally {

Monitor.Exit(oTemp);

}

// Return

}

// Simple function

public void SomeMethod() {

// Lock the object

lock (this) {

// Access the object

...

// Unlock the object

}

// Return

}

5. Transaction Class

class Transaction {

// Private field holding the time of

// the last transaction performed

private DateTime timeOfLastTransaction;

public void PerformTransaction() {

lock (this) {

// Perform the transaction...

// Record time of the most recent transaction

timeOfLastTransaction = DateTime.Now;

}

}

// Public read-only property returning

// the time of the last transaction

public DateTime LastTransaction {

get {

lock (this) {

// Return the time of the last transaction

return timeOfLastTransaction;

}

}

}

}

6. New Transaction Class

class Transaction {

// Private field holding the time of

// the last transaction performed

private static DateTime timeOfLastTransaction;

public static void PerformTransaction() {

lock (typeof(Transaction)) {

// Perform the transaction...

// Record time of the most recent transaction

timeOfLastTransaction = DateTime.Now;

}

}

// Public read-only property returning

// the time of the last transaction

public static DateTime LastTransaction {

get {

lock (typeof(Transaction)) {

// Return the time of the last transaction

return timeOfLastTransaction;

}

}

}

}

7. Threads Banging Heads

using System;

using System.Threading;

class App {

static void Main() {

// Construct an instance of the App object

App a = new App();

// This malicious code enters a lock on

// the object but never exits the lock

Monitor.Enter(a);

// For demonstration purposes, let's release the

// root to this object and force a garbage collection

a = null;

GC.Collect();

// For demonstration purposes, wait until all Finalize

// methods have completed their execution - deadlock!

GC.WaitForPendingFinalizers();

// We never get to the line of code below!

Console.WriteLine("Leaving Main");

}

// This is the App type's Finalize method

~App() {

// For demonstration purposes, have the CLR's

// Finalizer thread attempt to lock the object.

// NOTE: Since the Main thread owns the lock,

// the Finalizer thread is deadlocked!

lock (this) {

// Pretend to do something in here...

}

}

}

8. Transaction with Private Object

class Transaction {

// Private Object field used

// purely for synchronization

private Object objLock = new Object();

// Private field holding the time of

// the last transaction performed

private DateTime timeOfLastTransaction;

public void PerformTransaction() {

lock (objLock) {

// Perform the transaction...

// Record time of the most recent transaction

timeOfLastTransaction = DateTime.Now;

}

}

// Public read-only property returning

// the time of the last transaction

public DateTime LastTransaction {

get {

lock (objLock) {

// Return the time of the last transaction

return timeOfLastTransaction;

}

}

}

}

9. Transaction with Static Members

class Transaction {

// Private, static Object field

// used purely for synchronization

private static Object objLock = new Object();

// Private field holding the time of

// the last transaction performed

private static DateTime timeOfLastTransaction;

public static void PerformTransaction() {

lock (objLock) {

// Perform the transaction...

// Record time of the most recent transaction

timeOfLastTransaction = DateTime.Now;

}

}

// Public read-only property returning

// the time of the last transaction

public static DateTime LastTransaction {

get {

lock (objLock) {

// Return the time of the last transaction

return timeOfLastTransaction;

}

}

}

}

10. Now There's Synchronization

class AnotherType {

// An unboxed Boolean value type

private Boolean flag = false;

// A private Object field used to

// synchronize access to the flag field

private Object flagLock = new Object();

public Boolean Flag {

set {

Monitor.Enter(flagLock);

flag = value;

Monitor.Exit(flagLock);

}

}

}






分享到:
评论

相关推荐

Global site tag (gtag.js) - Google Analytics