8

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

在 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<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.");
        }
    }
}
4

6 回答 6

12

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

public static Queue<Action> q = whatever;

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

于 2012-12-21T06:45:28.343 回答
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”对象来验证项目。

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

于 2012-12-21T03:12:24.747 回答
4

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

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

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

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

于 2012-12-21T03:24:22.330 回答
4

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

重入的一个简单例子:

private object locker = new object();

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

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

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

编辑

请阅读下面 Eric 的评论以获取更多详细信息,但总结是,当您看到它时,您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 在他的回答中指出的那样,您可能直到有一天有人同时排队太多操作时才会意识到这个问题。

于 2012-12-21T05:53:33.430 回答
1

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

于 2013-01-01T13:09:31.617 回答
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 循环。
在 where Monitor.Exit(intanceOfYourClass.yourLockObject)will throw you a的代码块中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;

玩得开心。

于 2012-12-31T18:59:24.093 回答