При каких условиях поток может входить в область блокировки (монитора) более одного раза одновременно?

(вопрос пересмотрен): до сих пор все ответы включают один поток, повторно входящий в область блокировки линейно, через такие вещи, как рекурсия, где вы можете отслеживать шаги одного потока, входящего в блокировку дважды. Но возможно ли каким-то образом, для одного потока (возможно, из ThreadPool, возможно, в результате событий таймера или асинхронных событий или потока, идущего спать и пробуждаемого/повторно используемого в каком-то другом куске кода отдельно), каким-то образом быть порожденным в двух разных местах независимо друг от друга, и, следовательно, столкнуться с проблемой повторного входа в замок, когда разработчик не ожидал этого, просто прочитав свой собственный код?

в замечаниях класса ThreadPool (нажмите здесь) замечания, похоже, предполагают, что спящие нити должны быть повторно использованы, когда они не используются, или иначе впустую спать.

но на мониторе.Введите справочную страницу (нажмите здесь) мол " это законно для того же потока, чтобы вызвать Введите несколько раз без блокировки."возможно для одного потока, чтобы войти в ту же область блокировки дважды?

Предположим, у вас есть область блокировки, которая занимает, К сожалению, много времени. Это может быть реалистично, например, если вы получаете доступ к некоторой памяти, которая была выгружена (или что-то еще.) Нить в заблокированной области может пойти спать или что-то. Имеет ли тот же поток право запускать больше кода, который может случайно попасть в ту же область блокировки? Следующее В моем тестировании не приводит к запуску нескольких экземпляров одного и того же потока в одну и ту же область блокировки.

Итак, как возникает проблема? Чего именно нужно избегать?

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 ответов


Предположим, у вас есть очередь, содержащая действия:

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) Когда вы обрабатываете сообщение, отключить обработчик пока сообщение не будет обработано.


повторный вход возможен, если у вас есть такая структура:

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" для проверки элемента.

повторная запись одного и того же потока на одной и той же блокировке необходима, чтобы убедиться, что вы не получаете взаимоблокировки с вашим собственным кодом.


один из более тонких способов рекурсии в блок блокировки находится в GUI-фреймворках. Например, можно асинхронно вызывать код в одном потоке пользовательского интерфейса (класс формы)

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

конечно, это также помещает в бесконечный цикл; у вас, вероятно, будет условие, при котором вы захотите рекурсировать, в какой момент у вас не будет бесконечного цикла.

используя lock не является хорошим способом сна / пробуждения потоков. Я бы просто использовал существующие рамки, такие как Task Параллельная библиотека (TPL) для простого создания абстрактных задач (см. Task) для создания и базовой структуры обрабатывает создание новых потоков и их спящий режим, когда это необходимо.


ИМХО, повторный вход в замок-это не то, чего вам нужно избегать (учитывая ментальную модель блокировки многих людей, это в лучшем случае опасно, см. редактировать ниже). Суть документации заключается в том, чтобы объяснить, что поток не может блокировать себя с помощью Monitor.Enter. Это не всегда относится ко всем механизмам синхронизации, фреймворкам и языкам. Некоторые из них имеют непереходную синхронизацию, и в этом случае вы должны быть осторожны, чтобы поток не блокировал себя. Что? вы должны быть осторожны, всегда звоните Monitor.Exit для каждого Monitor.Enter звонок. The lock ключевое слово делает это для вас автоматически.

тривиальный пример с повторным входом:

private object locker = new object();

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

поток дважды вводил блокировку одного и того же объекта, поэтому он должен быть освобожден дважды. Обычно это не так очевидно и существуют различные методы, вызывающие друг друга, которые синхронизируются на одном объекте. Дело в том, что вам не нужно беспокоиться о блокировке потока себя.

это сказало, что вы должны попытаться свести к минимуму количество времени, необходимое для удержания блокировки. Приобретение блокировки не является вычислительно дорогостоящим, вопреки тому, что вы можете услышать (это порядка нескольких наносекунд). Lock contention-это то, что дорого.

редактировать

пожалуйста, прочитайте комментарии Эрика ниже для получения дополнительной информации, но резюме заключается в том, что когда вы видите 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 не остановит. Как указывает Эрик в своем ответе, вы можете не понять проблему, пока однажды кто-то не выстроит слишком много действий одновременно.


потоки ThreadPool не могут быть повторно использованы в другом месте только потому, что они заснули; они должны закончить, прежде чем они будут повторно использованы. Поток, который занимает много времени в области блокировки, не имеет права запускать больше кода в какой-либо другой независимой точке управления. Единственный способ испытать повторный вход блокировки-это рекурсия или выполнение методов или делегатов внутри блокировки, которые повторно входят в блокировку.


давайте подумаем о чем-то другом, чем рекурсия.
В некоторых бизнес-логики, они хотели бы контролировать поведение синхронизации. Один из этих паттернов, они вызывают 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(), и получите lockCount больше 1, вы вернулись;не обязательно одновременных.
если бы это было не безопасно для реентерабельность, вы застряли в цикле foreach.
В блоке кода where 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;

получать удовольствие.