Hello World
Spiga

监视程序中的死锁及其他

2009-09-10 00:08 by 老赵, 12960 visits

每天回家路上总有一段比较长的路一片漆黑无法看书。这种时候,如果我有兴致则会用手机上上网,但是大部分情况下还是用来想问题的。刚才在路上想起今天的工作之一是让一个类型中的所有方法对多线程“完全互斥”——我不知道如何为它命名,我的意思是一个类中任意两个方法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》一文中甚至将我想要做的东西完全实现了出来。简单的说,这儿已经没我什么事情了。感兴趣的朋友们可以阅读这两篇文章,提到了实践中我考虑到和没有考虑到的各种细节。仔细研究一遍,相信会有很大帮助的。

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

Creative Commons License

本文基于署名 2.5 中国大陆许可协议发布,欢迎转载,演绎或用于商业目的,但是必须保留本文的署名赵劼(包含链接),具体操作方式可参考此处。如您有任何疑问或者授权方面的协商,请给我留言

Add your comment

30 条回复

  1. Galactica
    *.*.*.*
    链接

    Galactica 2009-09-10 00:20:00

    lock性能很差,Monitor和Mutex都很容易产生死锁,而且性能也不行,在以往的C++代码中,基本上是靠信号灯来同步互斥对共享资源的访问,当然编程非常麻烦,而且容易写出错误的代码。一般操作系统都实现了信号灯。

  2. 老赵
    admin
    链接

    老赵 2009-09-10 00:24:00

    @Galactica
    你说的是Monitor.Enter/Exit/Pulse吧,lock就是Monitor.Enter/Exit,性能不差的。
    你说的性能差应该是在说Mutex,每次进入critical section需要切换到内核态。

  3. pangxiaoliang[北京]流浪者
    *.*.*.*
    链接

    pangxiaoliang[北京]流浪者 2009-09-10 00:33:00

    用lock,但不够深入,好多东西浅尝辄止,膜拜下老赵

  4. eaglet
    *.*.*.*
    链接

    eaglet 2009-09-10 08:22:00

    用 Mutex,效率要比用 Monitor 低很多,因为操作系统需要转到内核态运行。
    代码死锁问题的解决方案很多,下面这篇讲如何解开临界区代码死锁的文章应该可以比较高效的解决死锁。
    http://www.microsoft.com/china/MSDN/library/enterprisedevelopment/softwaredev/ousCriticalSections.mspx?mfr=true

  5. 老赵的粉丝[未注册用户]
    *.*.*.*
    链接

    老赵的粉丝[未注册用户] 2009-09-10 08:25:00

    QueueUserWorkItem((_) =>中的=>是什么意思呢赵老师?

  6. eaglet
    *.*.*.*
    链接

    eaglet 2009-09-10 08:28:00

    另外我的这篇文章可以跟踪程序死循环或者挂起时代码的位置
    .Net 下调试跟踪线程挂起和程序死循环

  7. 老赵
    admin
    链接

    老赵 2009-09-10 09:14:00

    @老赵的粉丝
    忽略掉这个参数的意思。

  8. Angel Lucifer
    *.*.*.*
    链接

    Angel Lucifer 2009-09-10 09:14:00

    老实说,在实际使用中还真没发现有什么好的死锁检测手段。死锁的发生千差万别。

    PS : 单论 lock 的性能呢,不差。但要是滥用 lock 呢,就等着给你的程序收尸吧,呵呵。

    如何使用锁是一种奇技淫巧,虽然在并行程序设计中,这个很重要,但它仅是一个很小的部分。

  9. HOH
    *.*.*.*
    链接

    HOH 2009-09-10 09:39:00

    无敌了,看了哈发表的时间是0点,楼主又说到刚才在路上,由此得出结论,加班到了转钟

  10. Galactica
    *.*.*.*
    链接

    Galactica 2009-09-10 10:06:00

    Jeffrey Zhao:
    @Galactica
    你说的是Monitor.Enter/Exit/Pulse吧,lock就是Monitor.Enter/Exit,性能不差的。
    你说的性能差应该是在说Mutex,每次进入critical section需要切换到内核态。



    恰好相反,windows系统下,mutex 和 critical section性能要优于其他同步原语。

    通常写资源池的时候,会使用event,Semaphore来共同完成,这样能保证效率和最低限度的死锁。

    像crirical section和event,写C++多线程程序的时候,次次都用。

  11. 韦恩卑鄙
    *.*.*.*
    链接

    韦恩卑鄙 2009-09-10 10:13:00

    J R 上次.net技术大会特意把lock的几种方法比较了一下

    monitor 和 lock synclock(VB) 是一样的(语法糖)
    效率比较高 但是读写都用一个锁

    mutex 其实速度非常快 但是根据JR的说法 你在系统级别使用互斥 万一有一个服务(金山毒霸)正在扫描这个资源 你可能就死锁了

    readwritelock 表面上很强大 速度慢得一塌糊涂

    所以比较好用的是 3.5 的 readwriteslim

  12. 老赵
    admin
    链接

    老赵 2009-09-10 10:14:00

    @Galactica
    你这说的和我了解下来完全相反,我可以确定Mutex,Semaphore这些类因为要进入内核态,所以性能差。
    因此.NET一直推荐用lock创建critical section,.NET 4.0也引入了SemaphoreSlim这个不进入内核态的类来提高性能。

    至于Event,.NET中各种Event都是要进入内核态的,因此能不能就不用,许多场景可以通过Monitor.Enter/Exit/Pulse/PulseAll来解决。
    在.NET 4.0中也引入了比如LaunchDownEvent,ManualResetEventSlim等不进入内核态的轻量级类来提高性能。

    这些我都是可以确定的,无论是理论还是试验我都做过。你一直在围绕C++来说,是不是对相同名词的定义不同?

  13. 韦恩卑鄙
    *.*.*.*
    链接

    韦恩卑鄙 2009-09-10 10:16:00

    readwritelock 提供了类似事务的多level实现 非常臃肿

    相比之下 越简单的锁速度越快 比如lock 和 readwritelockslim


    mutex 必须快 但是多个不同程序进程之间的mutex 应该是共享的 比较危险

  14. 韦恩卑鄙
    *.*.*.*
    链接

    韦恩卑鄙 2009-09-10 10:22:00

    得更正一下 当时是英语听译 mutex的部分也可能是“在不同进程间最快”的意思

  15. 花间蕊
    *.*.*.*
    链接

    花间蕊 2009-09-10 11:09:00

    @Jeffrey Zhao
    赵老师,我想请问您一下,lock在创建关键区时,是不是因为操作系统没有进入内核态,因此性能才比Multex高呢?
    请您解惑.

  16. 老赵
    admin
    链接

    老赵 2009-09-10 12:28:00

    @韦恩卑鄙
    Mutex,XxxEvent,Semaphore因为其实是封装了内核对象,因此是可以跨进程的。
    但是很多时候其实不需要这样,所以还是Slim的好。

  17. 老赵
    admin
    链接

    老赵 2009-09-10 12:29:00

    @花间蕊
    是的。

  18. Franz
    *.*.*.*
    链接

    Franz 2009-09-10 12:44:00

    public sealed class LockHolder<T> : IDisposable
        where T : class
    {
        private T handle;
        private bool holdsLock;
    
        public LockHolder(T handle, int milliSecondTimeout)
        {
            this.handle = handle;
            holdsLock = System.Threading.Monitor.TryEnter(
                handle, milliSecondTimeout);
        }
    
        public bool LockSuccessful
        {
            get { return holdsLock; }
        }
    
        #region IDisposable Members
        public void Dispose()
        {
            if (holdsLock)
                System.Threading.Monitor.Exit(handle);
            // Don't unlock twice
            holdsLock = false;
        }
        #endregion
    }
    
    
    //You would use this class in the following manner:
    
    object lockHandle = new object();
    
    using (LockHolder<object> lockObj = new LockHolder<object>
        (lockHandle, 1000))
    {
        if (lockObj.LockSuccessful)
        {
            // work elided
        }
    }
    // Dispose called here
    
    

    泛型版的

  19. 老赵
    admin
    链接

    老赵 2009-09-10 12:59:00

    @Franz
    lock的目标就是object,你搞个泛型出来,有任何意义吗?
    LockHolder<T>对外根本没有暴露出和T有关的成员,这个泛型版本实在是不知所云阿……

  20. Franz
    *.*.*.*
    链接

    Franz 2009-09-10 13:59:00

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

    也是为了这个目的!也是类似lock的操作而已,并且可是设置timeout.
    当然我看到你还是建议我们继续使用MethodImplOptions.Synchronized方式。

  21. 老赵
    admin
    链接

    老赵 2009-09-10 14:02:00

    @Franz
    我不是说LockHelper没用,我是说这和泛型有什么关系?我看不出任何意义来,不如你举一个例子?

  22. 花间蕊
    *.*.*.*
    链接

    花间蕊 2009-09-10 14:17:00

    @Jeffrey Zhao
    其实赵老师我读这段范型版的代码,感觉它的意义只有一个就在于where T:class上面.如果我们在LockHolder里面传个int给它作范型,编译时就不行了.
    假如我们放那里一个object类型而不是范型T,在传int这种值类型的时候会引发装箱,这样无法保证对一个对象lock多次的时候,lock的是同一个对象.
    另外,我觉得这个泛型约束其实只是在编程层面的,一个LockHolder<object>的构造器中仍然是可以传一个int进去,这做么只是为了清楚一些吧,并不能保证不出问题.

  23. Franz
    *.*.*.*
    链接

    Franz 2009-09-10 15:00:00

    @Jeffrey Zhao
    正如22楼所说的就是为了用这个约束.就是为了限制他为class.如果是结构类型那就锁定失败了.因为System.Threading.Monitor.Exit(Object )入口是OBJECT的

  24. 老赵
    admin
    链接

    老赵 2009-09-10 15:01:00

    @Franz
    好吧,这算是一个理由……

  25. Steven Chen
    *.*.*.*
    链接

    Steven Chen 2009-09-10 16:28:00

    我是来做个记号的,下班后慢慢看

  26. 精密~顽石
    *.*.*.*
    链接

    精密~顽石 2009-09-10 16:44:00

    &quot; 每天回家路上总有一段比较长的路一片漆黑无法看书。这种时候,如果我有兴致则会用手机上上网,但是大部分情况下还是用来想问题的。

    这句话是否可以理解为:

    一,你上下班,没有开车,要不就没有车,要不就住的地方离上班比较近。

    二,在漆黑的路上偶尔拿手机上网,有以下几种可能,手机比较高档,怕被抢。

    三,在拥挤的城市,你不用挤公车,还可以享受宁静的漆黑,多么幸福呀!!!

    means: 我要挤公车,我每天生活在嘈杂之中,我没有一部好手机!

    我猜对您的情况没?呵呵。。。

  27. 菜花[未注册用户]
    *.*.*.*
    链接

    菜花[未注册用户] 2009-09-10 16:58:00

    ThreadPool.QueueUserWorkItem(_ =>
    这个下划线是虾米意思,lanmda表达式里的???
    很菜,谢谢。。。

  28. 老赵
    admin
    链接

    老赵 2009-09-10 17:00:00

    @菜花
    下划线和a、b、c一样,就是一个普通的变量名。

  29. 老赵
    admin
    链接

    老赵 2009-09-10 17:01:00

    @精密~顽石
    现在可以上网的手机遍地都是,哪里需要高档手机。

  30. 菜花[未注册用户]
    *.*.*.*
    链接

    菜花[未注册用户] 2009-09-10 17:09:00

    Jeffrey Zhao:
    @菜花
    下划线和a、b、c一样,就是一个普通的变量名。


    感谢,晕菜好多年。。。

发表回复

登录 / 登录并记住我 ,登陆后便可删除或修改已发表的评论 (请注意保留评论内容)

昵称:(必填)

邮箱:(必填,仅用于Gavatar

主页:(可选)

评论内容(大于5个字符):

  1. Your Name yyyy-MM-dd HH:mm:ss

使用Live Messenger联系我