【问题标题】:Under what conditions can a thread enter a lock (Monitor) region more than once concurrently?什么情况下一个线程可以同时多次进入一个锁(Monitor)区域?
【发布时间】:2013-01-21 03:34:23
【问题描述】:

(问题已修改):到目前为止,答案都包括单个线程通过递归等方式线性重新进入锁定区域,您可以在其中跟踪单个线程两次进入锁定的步骤。但是是否有可能以某种方式生成单个线程(可能来自 ThreadPool,可能是由于计时器事件或异步事件或线程进入睡眠状态并分别在其他代码块中被唤醒/重用)以某种方式产生两个彼此独立的不同地方,因此,当开发人员通过阅读自己的代码没有预料到时,就会遇到锁重入问题?

在 ThreadPool 类备注 (click here) 中,备注似乎建议在休眠线程不使用时应重用它们,否则会因休眠而浪费。

但在 Monitor.Enter 参考页面 (click here) 上,他们说 “同一线程在不阻塞的情况下多次调用 Enter 是合法的。” 所以我认为必须有我应该小心避免的事情。它是什么?一个线程怎么可能两次进入同一个锁区?

假设您有一些锁定区域需要很长时间。这可能是现实的,例如,如果您访问一些已被分页(或其他)的内存。锁定区域中的线程可能会进入睡眠状态或其他什么。同一个线程是否有资格运行更多代码,而这些代码可能会意外进入同一个锁定区域?在我的测试中,以下内容不会让同一线程的多个实例运行到同一锁定区域。

那么问题是如何产生的呢?您究竟需要注意避免什么?

class myClass
{
    private object myLockObject;
    public myClass()
    {
        this.myLockObject = new object();
        int[] myIntArray = new int[100];               // Just create a bunch of things so I may easily launch a bunch of Parallel things
        Array.Clear(myIntArray, 0, myIntArray.Length); // Just create a bunch of things so I may easily launch a bunch of Parallel things
        Parallel.ForEach<int>(myIntArray, i => MyParallelMethod());
    }
    private void MyParallelMethod()
    {
        lock (this.myLockObject)
        {
            Console.Error.WriteLine("ThreadId " + Thread.CurrentThread.ManagedThreadId.ToString() + " starting...");
            Thread.Sleep(100);
            Console.Error.WriteLine("ThreadId " + Thread.CurrentThread.ManagedThreadId.ToString() + " finished.");
        }
    }
}

【问题讨论】:

  • 第一:问一个新问题而不是修改一个旧问题会更好。其次,“是否有可能以某种方式在两个不同的地方独立地生成一个线程?”不;线程的定义是它是单点控制。如果您有两个独立的控制点,那么您就有两个线程。 (这忽略了一些涉及“轻量级线程”的微妙之处,也就是“纤维”;这是一个相当高级的话题,你现在不需要关心。)
  • @EricLippert 你的评论在我看来是最好的“答案”。 ThreadPool 线程不能仅仅因为它们进入睡眠状态而在其他地方重用;他们需要在重新使用之前完成。在锁定区域中花费很长时间的线程没有资格在其他一些独立的控制点运行更多代码。体验锁重入的唯一方法是通过递归或执行锁内的方法或委托来重入锁。如果您想发布类似的内容作为答案,我会将其标记为已接受的答案。
  • 好吧,总是有 APC。请参阅 KERNEL32 的 SleepEx 函数的 MSDN 文档。 AFAIK Thread.Sleep 将线程置于警报状态,就像任何 IO 函数一样,因此睡眠或等待线程可能会执行 APC。然而,APC 很少用作通用回调,所以我想说这导致“你的”代码执行的可能性非常小。

标签: c# .net concurrency thread-safety locking


【解决方案1】:

假设您有一个包含操作的队列:

public static Queue<Action> q = whatever;

假设Queue&lt;T&gt; 有一个方法Dequeue 返回一个布尔值,指示队列是否可以成功出队。

假设你有一个循环:

static void Main()
{
    q.Add(M);
    q.Add(M);
    Action action;
    while(q.Dequeue(out action)) 
      action();
}
static object lockObject = new object();
static void M()
{
    Action action;
    lock(lockObject) 
    { 
        if (q.Dequeue(out action))
            action();
    }
}

显然主线程两次进入M中的锁;此代码是可重入。也就是说,它通过间接递归进入自身

您觉得这段代码不可信吗?它不应该。 这就是 Windows 的工作原理。每个窗口都有一个消息队列,当一个消息队列被“抽取”时,对应于这些消息的方法被调用。当您单击一个按钮时,一条消息进入消息队列;当队列被抽出时,对应于该消息的点击处理程序被调用。

因此,编写 Windows 程序中的锁包含对泵送消息循环的方法的调用是极其常见且极其危险的。如果您首先因为处理消息而进入了那个锁,并且如果消息在队列中两次,那么代码将间接进入自身,这可能会导致各种疯狂。

消除这种情况的方法是 (1) 永远不要在锁内做任何稍微复杂的事情,以及 (2) 在处理消息时,禁用处理程序,直到处理完消息。

【讨论】:

    【解决方案2】:

    如果你有这样的结构,重新进入是可能的:

    Object lockObject = new Object(); 
    
    void Foo(bool recurse) 
    {
      lock(lockObject)
       { 
           Console.WriteLine("In Lock"); 
           if (recurse)  { foo(false); }
       }
    }
    

    虽然这是一个非常简单的示例,但在许多具有相互依赖或递归行为的场景中都是可能的。

    例如:

    • ComponentA.Add():锁定一个通用的“ComponentA”对象,将新项目添加到 ComponentB。
    • ComponentB.OnNewItem():新项目触发列表中每个项目的数据验证。
    • ComponentA.ValidateItem():锁定一个通用的“ComponentA”对象来验证项目。

    需要在同一个锁上重新进入同一个线程,以确保您自己的代码不会发生死锁。

    【讨论】:

      【解决方案3】:

      在 GUI 框架中可以递归到锁定块中的一种更微妙的方法。例如,您可以在单个 UI 线程(Form 类)上异步调用代码

      private object locker = new Object();
      public void Method(int a)
      {
          lock (locker)
          {
              this.BeginInvoke((MethodInvoker) (() => Method(a)));
          }
      }
      

      当然,这也会陷入无限循环;您可能有一个条件,您希望通过该条件进行递归,此时您不会有无限循环。

      使用lock 不是睡眠/唤醒线程的好方法。我会简单地使用现有的框架,如任务并行库 (TPL) 来简单地创建抽象任务(参见 Task)来创建,并且底层框架处理创建新线程并在需要时将它们休眠。

      【讨论】:

      • 我对这个答案很感兴趣,因为它正在接近您可能不希望重新进入锁定区域的情况。当你 BeginInvoke 时,你是在别人的代码上运行你自己的线程,对吧?所以也许一个更清晰的例子是 Bob 编写的 someClass,获得锁并调用另一个 Sally 编写的 Class 的方法,后者也获得了锁。那可能吗?还是有道理的?
      • @EdwardNedHarvey 好吧,“这取决于”。 BeginInvoke 确保始终在 UI 线程上调用委托。如果此方法 (Method) 由于 UI 操作(如按下按钮)而被调用,它将在 UI 线程上调用。因此,BeginInvoke 在同一个线程上异步调用Method——这提供了重新进入锁块的可能性。您可以使用Invoke 来保证重新进入锁定块...
      • 如果 Method 没有在 UI 线程上运行,则不会重新进入锁定块。在使用Invoke 而不是BeginInvoke 的情况下,您会遇到死锁。
      • 但是,您通常不会从非 UI 类中调用 BeginInvoke/Invoke——因此这种情况不太可能发生在多个类中。但是,当然,这并不意味着它不能。任何人都可以随意使用 UI 类和方法,无论它多么愚蠢。
      【解决方案4】:

      恕我直言,您无需小心避免重新进入锁(鉴于许多人的锁定心理模型,这充其量是危险的,请参阅下面的编辑)。文档的重点是解释线程不能使用Monitor.Enter 阻塞自己。对于所有同步机制、框架和语言,情况并非总是如此。有些具有不可重入同步,在这种情况下,您必须小心线程不会阻塞自己。您需要注意的是每次调用Monitor.Enter 时始终调用Monitor.Exitlock 关键字会自动为您执行此操作。

      重入的一个小例子:

      private object locker = new object();
      
      public void Method()
      {
        lock(locker)
        {
          lock(locker) { Console.WriteLine("Re-entered the lock."); }
        }
      }
      

      线程已经两次进入同一个对象的锁,所以它必须被释放两次。通常它不是那么明显,并且有各种方法相互调用,在同一个对象上同步。关键是您不必担心线程本身会阻塞。

      也就是说,您通常应该尽量减少您需要持有锁的时间。获取锁的计算成本并不高,与您可能听到的相反(大约为几纳秒)。锁竞争是昂贵的。

      编辑

      请阅读下面 Eric 的 cmets 以获得更多详细信息,但总结是,当您看到 lock 时,您对它的解释应该是“此代码块的所有激活都与单个线程相关联”,并且 不是,正如通常解释的那样,“此代码块的所有激活都作为单个原子单元执行”。

      例如:

      public static void Main()
      {
        Method();
      }
      
      private static int i = 0;
      private static object locker = new object();
      public static void Method()
      {
        lock(locker)
        {
          int j = ++i;
      
          if (i < 2)
          {
            Method();
          }
      
          if (i != j)
          {
            throw new Exception("Boom!");
          }
        }
      }
      

      显然,这个程序失败了。没有lock,结果是一样的。危险在于lock 会让你产生一种错误的安全感,即在初始化j 和评估if 之间没有任何东西可以修改你的状态。问题是您(可能是无意的)有Method 递归到自身,而lock 不会阻止它。正如 Eric 在他的回答中指出的那样,您可能直到有一天有人同时排队太多操作时才会意识到这个问题。

      【讨论】:

      • 我正在为此评论 +1,因为有人在没有任何解释的情况下对其进行了 -1。这条评论没有错,当然不应该得到-1分
      • @EdwardNedHarvey:这个答案当然有问题。第一句话是terrible的忠告。重入非常危险和令人困惑,因为大多数程序都没有正确处理它,它可能导致最糟糕的难以重现、难以调试的错误。相比之下,最后一段是纯金的真棒。
      • @EricLippert 问题是当人们看到一个锁时,通常认为该块中的代码是“原子地”执行的吗?然后重新进入无意中发生(通常通过一些间接层,正如您在答案中指出的那样)并且在初始调用完成之前再次执行一些语句。
      • @mikez:正确。现在,值得指出的是,即使没有锁,Windows 中的可重入代码也可能发生。锁特别讨厌,因为许多人错误地认为“在锁中”意味着“在任何时候最多有一次激活此代码”,而实际上它的意思是“此代码的每次激活都与用相同的线程”。
      • @EricLippert 谢谢!我已根据您的 cmets 更新了我的答案。
      【解决方案5】:

      ThreadPool 线程不能仅仅因为它们进入睡眠状态而在其他地方重用;他们需要在重新使用之前完成。在锁定区域中花费很长时间的线程没有资格在其他一些独立的控制点运行更多代码。体验锁重入的唯一方法是通过递归或执行锁内的方法或委托来重入锁。

      【讨论】:

        【解决方案6】:

        让我们想想递归以外的东西。
        在一些业务逻辑中,他们希望控制同步的行为。 其中一种模式,他们在某处调用Monitor.Enter,并希望稍后在其他地方调用Monitor.Exit。以下是了解这一点的代码:

        public partial class Infinity: IEnumerable<int> {
            IEnumerator IEnumerable.GetEnumerator() {
                return this.GetEnumerator();
            }
        
            public IEnumerator<int> GetEnumerator() {
                for(; ; )
                    yield return ~0;
            }
        
            public static readonly Infinity Enumerable=new Infinity();
        }
        
        public partial class YourClass {
            void ReleaseLock() {
                for(; lockCount-->0; Monitor.Exit(yourLockObject))
                    ;
            }
        
            void GetLocked() {
                Monitor.Enter(yourLockObject);
                ++lockCount;
            }
        
            void YourParallelMethod(int x) {
                GetLocked();
                Debug.Print("lockCount={0}", lockCount);
            }
        
            public static void PeformTest() {
                new Thread(
                    () => {
                        var threadCurrent=Thread.CurrentThread;
                        Debug.Print("ThreadId {0} starting...", threadCurrent.ManagedThreadId);
        
                        var intanceOfYourClass=new YourClass();
        
                        // Parallel.ForEach(Infinity.Enumerable, intanceOfYourClass.YourParallelMethod);
                        foreach(var i in Enumerable.Range(0, 123))
                            intanceOfYourClass.YourParallelMethod(i);
        
                        intanceOfYourClass.ReleaseLock();
        
                        Monitor.Exit(intanceOfYourClass.yourLockObject); // here SynchronizationLockException thrown
                        Debug.Print("ThreadId {0} finished. ", threadCurrent.ManagedThreadId);
                    }
                    ).Start();
            }
        
            object yourLockObject=new object();
            int lockCount;
        }
        

        如果您调用YourClass.PeformTest() 并获得大于1 的lockCount,则您已重新进入; 不一定是并发的
        如果重入不安全,您将陷入 foreach 循环。
        Monitor.Exit(intanceOfYourClass.yourLockObject) 将抛出SynchronizationLockException 的代码块中,这是因为我们尝试调用Exit 的次数超过了它输入的次数。如果您即将使用lock 关键字,您可能不会遇到这种情况,除非直接或间接地递归调用。我想这就是提供lock 关键字的原因:它可以防止Monitor.Exit 以粗心的方式被省略。
        我注意到了Parallel.ForEach的调用,如果你有兴趣可以测试一下。

        要测试代码,.Net Framework 4.0 是最低要求,还需要以下额外的命名空间:

        using System.Threading.Tasks;
        using System.Diagnostics;
        using System.Threading;
        using System.Collections;
        

        玩得开心。

        【讨论】:

          猜你喜欢
          • 1970-01-01
          • 1970-01-01
          • 1970-01-01
          • 2023-03-19
          • 2010-10-15
          • 1970-01-01
          • 1970-01-01
          • 1970-01-01
          • 2015-02-02
          相关资源
          最近更新 更多