在什么条件下线程可以同时进入锁(监控)区域多次?

(问题修改):到目前为止,答案都包括一个线程重新进入锁定区域的线程,通过递归之类的东西,您可以在其中跟踪单个线程进入锁定两次的步骤。 但是有可能以某种方式,对于单个线程(可能来自ThreadPool,可能是由于定时器事件或异步事件或线程进入hibernate状态并在其他一些代码块中被唤醒/重用)以某种方式产生两个不同的地方彼此独立,因此,当开发人员通过简单地阅读他们自己的代码而没有想到它时,会遇到锁重入问题?

在ThreadPool类备注( 单击此处 )中,备注似乎表明睡眠线程应在不使用时重复使用,否则会因睡眠而浪费。

但是在Monitor.Enter参考页面上( 点击这里 ),他们会说“同一个线程在没有阻止的情况下不止一次调用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(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."); } } } 

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

 public static Queue q = whatever; 

假设Queue有一个Dequeue方法返回一个bool,指示队列是否可以成功出列。

假设你有一个循环:

 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)当你处理消息时, 禁用处理程序直到处理消息。

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

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

虽然这是一个非常简单的示例,但在许多情况下,您可能会出现相互依赖或递归的行为。

例如:

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

需要在同一个锁上重新输入相同的线程,以确保您不会因自己的代码而出现死锁。

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

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

当然,这也是一个无限循环; 你可能有一个条件,你想要递归,你不会有无限循环。

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

恕我直言,重新进入锁定不是你需要注意避免的事情(鉴于许多人的锁定心理模型,这充其量是危险的,请参阅下面的编辑 )。 文档的要点是解释线程无法使用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的评论以获取更多详细信息,但摘要是当您看到lock您对它的解释应该是“此代码块的所有激活都与单个线程相关联”,而不是 ,因为它通常被解释, “此代码块的所有激活都作为单个primefaces单元执行”。

例如:

 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不会停止。 正如埃里克在他的回答中指出的那样,直到有一天有人同时排队太多行动之前,你可能都没有意识到这个问题。

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

让我们考虑除递归之外的其他事情。
在某些业务逻辑中,他们希望控制同步行为。 其中一种模式,它们在某处调用Monitor.Enter ,并希望稍后在其他地方调用Monitor.Exit 。 以下是获取相关想法的代码:

 public partial class Infinity: IEnumerable { IEnumerator IEnumerable.GetEnumerator() { return this.GetEnumerator(); } public IEnumerator 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; 

玩得开心。