Блокировка ресурсов между итерациями основного потока (Async/Await)

Допустим, у меня есть форма с двумя кнопками (button1 и button2) и ресурсным объектом (r). Ресурс имеет собственный код блокировки и разблокировки для обработки параллелизма. Ресурс может быть изменен любым потоком.

При нажатии на button1 его обработчик изменяет сам r, а затем асинхронно вызывает _IndependentResourceModifierAsync(), который изменяет r в порожденной задаче. _IndependentResourceModifierAsync() перед этим получает блокировку r. Кроме того, поскольку обработчик возится с самим r, он также получает блокировку r.

Когда нажимается button2, он просто вызывает _IndependentResourceModifierAsync() напрямую. Сам он не блокируется.

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

Есть две вещи, которые я хочу гарантировать:

  1. Если щелкнуть button1 или button2, когда ресурс заблокирован основным потоком, будет выдано исключение. (Нельзя использовать Monitor или Mutex, потому что они управляются потоком)
  2. Вложенность блокировок с button1_Click() по _IndependentResourceModiferAsync() не должна вызывать взаимоблокировку. (Не могу использовать Semaphore).

По сути, я думаю, что я ищу «блокировку на основе стека», если такая вещь существует или даже возможна. Потому что, когда асинхронный метод продолжает работу после ожидания, он восстанавливает состояние стека. Я много искал кого-то еще, у кого была эта проблема, но ничего не вышло. Это, вероятно, означает, что я слишком усложняю вещи, но мне любопытно, что люди говорят об этом. Может быть что-то действительно очевидное, что я упускаю. Большое спасибо.

public class Resource
{
    public bool TryLock();
    public void Lock();
    public void Unlock();
    ...
}

public class MainForm : Form
{
    private Resource r;
    private async void button1_Click(object sender, EventArgs e)
    {
        if (!r.TryLock())
            throw InvalidOperationException("Resource already acquired");
        try
        {
            //Mess with r here... then call another procedure that messes with r independently.
            await _IndependentResourceModiferAsync();
        }
        finally
        {
            r.Unlock();
        }
    }

    private async void button2_Click(object sender, EventArgs e)
    {
        await _IndependentResourceModifierAsync();
    }

    private async void _IndependentResourceModiferAsync()
    {
        //This procedure needs to check the lock too because he can be called independently
        if (!r.TryLock())
            throw InvalidOperationException("Resource already acquired");
            try
            {
                await Task.Factory.StartNew(new Action(() => {
                    // Mess around with R for a long time.
                }));
            }
            finally
            {
                r.Unlock();
            }
    }
}

person Frank Weindel    schedule 24.04.2013    source источник
comment
Вещь 1 никогда не произойдет.   -  person Henk Holterman    schedule 24.04.2013
comment
@HenkHolterman: Почему бы и нет? Ресурсом можно владеть, не блокируя поток пользовательского интерфейса, благодаря использованию асинхронности.   -  person Jon Skeet    schedule 24.04.2013
comment
Да, я пропустил ожидание. Но кажется странным использовать это внутри замка.   -  person Henk Holterman    schedule 24.04.2013
comment
Причина, по которой я использую await Task в блокировке, заключается в том, что мне нужно, чтобы графический интерфейс сразу знал, может ли он выполнить операцию или нет. Если это невозможно, я могу отобразить сообщение об ошибке и попросить их попробовать, когда последняя операция будет завершена.   -  person Frank Weindel    schedule 24.04.2013
comment
Вам действительно нужна вложенная блокировка? Я считаю, что это обычно считается плохой практикой. Почему бы вам не удалить блокировку _IndependentResourceModiferAsync() и не позволить вызываемому обработать это?   -  person svick    schedule 24.04.2013
comment
Мне нравится, когда это возможно, чтобы все было самодостаточным. Это, безусловно, решило бы #2 и позволило бы мне использовать Semaphore, но ради этого поста скажем, что это нежелательно.   -  person Frank Weindel    schedule 24.04.2013


Ответы (3)


Ресурс имеет собственный код блокировки и разблокировки для обработки параллелизма. Ресурс может быть изменен любым потоком.

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

Когда кнопка button1 нажата, ее обработчик выполняет некоторое изменение самого r, а затем асинхронно вызывает _IndependentResourceModifierAsync(), который выполняет некоторое изменение r в порожденной задаче. _IndependentResourceModifierAsync() получает блокировку r перед тем, как сделать это. Кроме того, поскольку обработчик возится с самим r, он также получает блокировку r.

И есть красный флаг. Рекурсивные блокировки почти всегда плохая идея. Я объясняю свои рассуждения в своем блоге.

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

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

Мне это не кажется правильным. Есть ли другой способ сделать это? Отключение кнопок при изменении состояния кажется гораздо более приятным подходом.


Я настоятельно рекомендую провести рефакторинг, чтобы удалить требование рекурсии блокировки. Затем вы можете использовать SemaphoreSlim с WaitAsync для асинхронного получения блокировки и Wait(0) для «попытки блокировки».

Таким образом, ваш код будет выглядеть примерно так:

class Resource
{
  private readonly SemaphoreSlim mutex = new SemaphoreSlim(1);

  // Take the lock immediately, throwing an exception if it isn't available.
  public IDisposable ImmediateLock()
  {
    if (!mutex.Wait(0))
      throw new InvalidOperationException("Cannot acquire resource");
    return new AnonymousDisposable(() => mutex.Release());
  }

  // Take the lock asynchronously.
  public async Task<IDisposable> LockAsync()
  {
    await mutex.WaitAsync();
    return new AnonymousDisposable(() => mutex.Release());
  }
}

async void button1Click(..)
{
  using (r.ImmediateLock())
  {
    ... // mess with r
    await _IndependentResourceModiferUnsafeAsync();
  }
}

async void button2Click(..)
{
  using (r.ImmediateLock())
  {
    await _IndependentResourceModiferUnsafeAsync();
  }
}

async Task _IndependentResourceModiferAsync()
{
  using (await r.LockAsync())
  {
    await _IndependentResourceModiferUnsafeAsync();
  }
}

async Task _IndependentResourceModiferUnsafeAsync()
{
  ... // code here assumes it owns the resource lock
}

Я много искал кого-то еще, у кого была эта проблема, но ничего не вышло. Это, вероятно, означает, что я слишком усложняю вещи, но мне любопытно, что люди говорят об этом.

Долгое время это было невозможно (вообще, точка, точка). С .NET 4.5 это возможно, но некрасиво. Это очень сложно. Я не знаю, чтобы кто-нибудь действительно делал это в производственной среде, и я определенно не рекомендую это делать.

Тем не менее, я играл с асинхронными рекурсивными блокировками как пример в моей библиотеке AsyncEx (он никогда не будет частью общедоступного API). Вы можете использовать его следующим образом (следуя соглашению AsyncEx об уже отмененных токены, действующие синхронно):

class Resource
{
  private readonly RecursiveAsyncLock mutex = new RecursiveAsyncLock();
  public RecursiveLockAsync.RecursiveLockAwaitable LockAsync(bool immediate = false)
  {
    if (immediate)
      return mutex.LockAsync(new CancellationToken(true));
    return mutex.LockAsync();
  }
}

async void button1Click(..)
{
  using (r.LockAsync(true))
  {
    ... // mess with r
    await _IndependentResourceModiferAsync();
  }
}

async void button2Click(..)
{
  using (r.LockAsync(true))
  {
    await _IndependentResourceModiferAsync();
  }
}

async Task _IndependentResourceModiferAsync()
{
  using (await r.LockAsync())
  {
    ...
  }
}

Код для RecursiveAsyncLock не очень длинный, но думать о нем ужасно сложно. Он начинается с неявного асинхронного контекста, который я описываю в подробно в моем блоге (что трудно понять само по себе), а затем использует пользовательские ожидания для «внедрения» кода в нужное время в методы конечного пользователя async.

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

Действуй осторожно, исследователь. Вот драконы.

person Stephen Cleary    schedule 25.04.2013
comment
Большое спасибо за подробный ответ о том, почему я не должен этого делать, что я должен делать вместо этого и как я могу это сделать в любом случае (особенно это). Я собираюсь пойти с чем-то, что вы предложили. Мне очень нравится то, что ты делаешь с IDisposable замком и using им. Я впервые вижу этот узор. - person Frank Weindel; 25.04.2013
comment
Быстрый вопрос о AnonymousDisposable. Я понимаю, как это работает и как я могу легко это написать, но я предполагаю, что это не часть .NET framework? Единственная документация, которую я вижу через Google, - это ответы StackOverflow. Ваше здоровье. - person Frank Weindel; 25.04.2013
comment
Верно, AnonymousDisposable не является предоставленным типом. Однако это действительно легко написать, и я назвал это AnonymousDisposable в своем ответе, потому что это обычное имя для него. Если вам нужна наилучшая производительность, у вас должен быть настоящий одноразовый тип. - person Stephen Cleary; 26.04.2013

Я считаю, что асинхронная блокировка с повторным входом, которая ведет себя достаточно хорошо, невозможна. Это связано с тем, что когда вы запускаете асинхронную операцию, вам не требуется немедленно ее await.

Например, представьте, что вы изменили свой обработчик событий на что-то вроде этого:

private async void button1_Click(object sender, EventArgs e)
{
    if (!r.TryLock())
        throw InvalidOperationException("Resource already acquired");
    try
    {
        var task = _IndependentResourceModiferAsync();
        // Mess with r here
        await task;
    }
    finally
    {
        r.Unlock();
    }
}

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

person svick    schedule 24.04.2013
comment
+1 за некоторые хорошие моменты, но то же самое действительно можно сказать о любой асинхронно-совместимой блокировке. Вам также не нужно await возвращать Task SemaphoreSlim.WaitAsync. - person Stephen Cleary; 25.04.2013
comment
@StephenCleary Я имел в виду, что если вы берете блокировку без повторного входа, а затем выполняете «небезопасный» (без какой-либо блокировки) метод параллельно с каким-либо другим кодом, это ваша вина, что код теперь содержит ошибки. Но если вы возьмете блокировку с повторным входом, а затем выполните «безопасный» (со своей собственной блокировкой) метод параллельно с чем-то еще, можно ожидать, что это сработает, но на самом деле это так же ошибочно. - person svick; 25.04.2013

Я думаю, вам следует взглянуть на SemaphoreSlim (со счетчиком из 1):

  • Он не реентерабельный (не принадлежит потоку)
  • Он поддерживает асинхронное ожидание (WaitAsync)

У меня сейчас нет времени проверять ваш сценарий, но я думаю, что он подойдет.

РЕДАКТИРОВАТЬ: я только что заметил этот вопрос:

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

Нет, это абсолютно не так. Это легко показать — добавьте асинхронный метод, который реагирует на нажатие кнопки, например:

public void HandleClick(object sender, EventArgs e)
{
    Console.WriteLine("Before");
    await Task.Delay(1000);
    Console.WriteLine("After");
}

Установите точку останова для обоих ваших вызовов Console.WriteLine — вы заметите, что до await у вас есть трассировка стека, включая код «обработки кнопок» в WinForms; после этого стек будет выглядеть совсем иначе.

person Jon Skeet    schedule 24.04.2013
comment
Спасибо, Джон. Я думал о семафоре, но он не работает, когда есть вложенные блокировки. - person Frank Weindel; 24.04.2013
comment
Что вы имеете ввиду под не работает? Похоже, в основном вам следует избегать вложенных блокировок, чтобы начать с... - person Jon Skeet; 24.04.2013
comment
Избегание вложенных блокировок позволило бы мне использовать семафор. Тем не менее, я предпочитаю по возможности сохранять блокировки автономными, чтобы вызывающей стороне не приходилось об этом беспокоиться. Я думаю, это разумно. Я согласен с ответом, что это невозможно. - person Frank Weindel; 24.04.2013
comment
@FrankWeindel: Но, судя по всему, замок теперь не автономен. Я бы попытался избежать вложенной блокировки для простого здравого смысла... и вложенная блокировка, и асинхронность достаточно сложны сами по себе, не говоря уже о сочетании. Но я только что заметил одну путаницу, которая может немного объяснить ситуацию - редактирую свой ответ сейчас. - person Jon Skeet; 24.04.2013
comment
Он автономен внутри _IndependentResourceModiferAsync(). Так что любой, кто звонит по нему, не должен будет беспокоиться о каких-либо замках. Причина, по которой button1_Click() также блокирует его, заключается в том, что он что-то делает с ресурсом до/после вызова _IndependentResourceModiferAsync(). И он хочет сохранить атомарность. Но я согласен, эти две концепции вместе действительно чрезмерно усложняют ситуацию, и мне лучше просто заставить конечных вызывающих абонентов захватить замок. - person Frank Weindel; 24.04.2013
comment
Также, когда я говорю о состоянии стека, я имею в виду состояние, которое следует за первоначально названным асинхронным методом. Вы согласитесь, если у вас есть куча асинхронных методов, которые вызывают друг друга с ожиданием, что они в конечном итоге возвращаются обратно к корневому асинхронному методу. - person Frank Weindel; 24.04.2013
comment
@FrankWeindel: Это зависит от того, что именно вы подразумеваете под возвратом и что вы подразумеваете под состоянием - вам нужно привести конкретный пример, чтобы я мог сказать да или нет, на самом деле. - person Jon Skeet; 24.04.2013