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

时间:2012-12-21 03:05:25

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

(问题修改):到目前为止,答案都包括一个线程重新进入锁定区域的线程,通过递归之类的东西,您可以在其中跟踪单个线程进入锁定两次的步骤。但是有可能以某种方式,对于单个线程(可能来自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.");
        }
    }
}

6 个答案:

答案 0 :(得分:12)

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

public static Queue<Action> q = whatever;

假设Queue<T>有一个方法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)当你处理消息时,禁用处理程序直到处理消息。< / p>

答案 1 :(得分:5)

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

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”对象以验证该项目。

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

答案 2 :(得分:4)

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

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

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

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

答案 3 :(得分:4)

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

重新入学的一个简单例子:

private object locker = new object();

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

线程已经两次锁定同一个对象,因此必须释放两次。通常它不是那么明显,并且有各种方法相互调用同步对象。关键是你不必担心线程会阻塞自己。

那说你通常应该尽量减少锁定所需的时间。获取锁定在计算上并不昂贵,与您可能听到的相反(它大约为几纳秒)。锁争用是昂贵的。

修改

请阅读下面的Eric的评论以获取更多详细信息,但摘要是当您看到lock时,您的解释应该是&#34;此代码块的所有激活都与单个线程&#34;和,因为它通常被解释,&#34;此代码块的所有激活都作为单个原子单元执行&#34;。

例如:

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

答案 4 :(得分:1)

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

答案 5 :(得分:0)

让我们考虑除递归之外的其他事情。
在某些业务逻辑中,他们希望控制同步行为。 其中一种模式,他们在某处调用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;

玩得开心。