代码改变世界

监视程序中的死锁及其他

2009-09-10 00:08  Jeffrey Zhao  阅读(14269)  评论(30编辑  收藏

每天回家路上总有一段比较长的路一片漆黑无法看书。这种时候,如果我有兴致则会用手机上上网,但是大部分情况下还是用来想问题的。刚才在路上想起今天的工作之一是让一个类型中的所有方法对多线程“完全互斥”——我不知道如何为它命名,我的意思是一个类中任意两个方法A或B,在A没有退出前,另一个线程是无法访问B的(当然也无法访问A)。最简单的方式应该是把每个方法标记为:

[MethodImpl(MethodImplOptions.Synchronized)]
public void SomeMethod() { ... }

但是这意味着每进入一个方法,都会自动lock(方法所在的类型),锁定这样一个公开对象(甚至还是跨AppDomain的)自然不是一个好的做法。更好的做法是声明一个私有变量,然后对它进行lock。但是这意味着每个方法都需要用lock包含,我嫌麻烦,不知怎么又想尝试着使用一个公用的Lock方法,并传入一个Action对象,这样lock语句就只出现一次了:

private object m_mutex = new object();
private void Lock(Action action) { lock (this.m_mutex) action(); }

但是,这又意味着每个公开方法内部都要使用Lock方法,这和直接使用lock(this.m_mutex)又有什么区别呢?区别当然是有的,硬要说起来,使用Lock方法意味着“如果某一天”我要把“互斥”这个条件去掉的话,我只要修改Lock方法一个就可以了——否则我需要修改所有的公开方法。

当然,我觉得就这点理论上的“优势”是不足以修改代码的,那么我还是继续使用MethodImplOptions.Synchronized方式吧。

经过了上面这一圈没有带来多大价值的思考之后,我又回忆起今天园子首页的一篇文章谈到死锁。死锁很容易出现,例如下面的代码引发死锁的概率几乎是100%:

var mutexA = new object();
var mutexB = new object();

ThreadPool.QueueUserWorkItem(_ =>
{
    lock (mutexA)
    {
        Console.WriteLine("Mutex A acquired.");
        Thread.Sleep(1000);

        Console.WriteLine("Trying to acquire mutex B.");
        lock (mutexB)
        {
            Console.WriteLine("Mutex B acquired.");
        }
    }
});

ThreadPool.QueueUserWorkItem(_ =>
{
    lock (mutexB)
    {
        Console.WriteLine("Mutex B acquired.");
        Thread.Sleep(1000);

        Console.WriteLine("Trying to acquire mutex A.");
        lock (mutexA)
        {
            Console.WriteLine("Mutex A acquired.");
        }
    }
});

这种情况下两个内层lock中的代码都无法执行,因为每个线程都在等待对方释放才能继续下去,这种mutex锁定顺序不一致的情况导致死锁。那么概括下来,什么情况下会出现死锁呢?其实就是:“如果线程A正持有对象a而请求锁定b,同时线程B持有b而请求锁定c,同时线程C持有c而请求……锁定a”,无论这个循环有多长,其中涉及到多少个线程,一旦出现这种循环,则进入死锁。其实我想任何一本讲操作系统的书都会谈到到如何检查死锁——以及解开死锁。既然lock语句只能让我们静悄悄地等待下去,那么不如由我们自己提供一个实现,避免发生死锁的情况。例如:

public static class Lock
{
    public static void With(object mutex, Action action) { ... }
}

于是原本使用lock的语句现在就可以变成:

//lock (mutex)
//{ 
//    ...
//}

Lock.With(mutex, () =>
{
    ...
});

而在Lock.With方法中,我们除了调用Monitor.Enter/Exit方法来实现真正的锁之外,还需要在Enter之前判断这个mutex能否正确获得。其实就是查看一点:于此同时是否有另一个线程正持有当前mutex对象,并且(经过一个“链”)也在等待当前线程正持有的其他mutex对象。如果出现了这样的情况,则Lock.With不会调用Monitor.Enter,而是抛出异常。这样做肯定是可行的,问题的关键在于如何设计一个方便使用,性能优越,并且线程安全的数据结构。

可惜,等我兴冲冲地回到家,打开电脑,在搜索引擎敲入“.NET Deadlock Detect”之后,却找到了MSDN Magazine上的两篇文章——原来又是别人的二手货。《Advanced Techniques To Avoid And Detect Deadlocks In .NET Apps》中讲述了检查和打破死锁的算法,而《Deadlock monitor》一文中甚至将我想要做的东西完全实现了出来。简单的说,这儿已经没我什么事情了。感兴趣的朋友们可以阅读这两篇文章,提到了实践中我考虑到和没有考虑到的各种细节。仔细研究一遍,相信会有很大帮助的。

当然,这种做法只适合在测试环境中“检查”是否有可能出现死锁情况,在实际情况下这种做法还是非常消耗性能的。不过,我们可以在编译产品环境的时候使用特别的编译选项,把用于检查死锁的代码给短路掉,这自然就没有任何问题了。