Можно ли внутри блока lock в C# выполнять асинхронные операции (await)?

«Можно ли внутри блока lock в C# выполнять асинхронные операции (await)?» — вопрос из категории Многопоточность, который задают на 25% собеседований C# Разработчик. Ниже — развёрнутый ответ с разбором ключевых моментов.

Ответ

Нет, это запрещено и приведет к ошибке компиляции или серьезным runtime-проблемам, таким как взаимные блокировки (deadlock).

Почему нельзя:

  1. Механизм lock в C# — это синтаксический сахар для Monitor.Enter и Monitor.Exit. Эти операции привязаны к текущему потоку выполнения.
  2. Ключевое слово await приостанавливает выполнение текущего метода и возвращает управление потоку. Если это произойдет внутри lock, поток освободится, но монитор объекта останется захваченным этим потоком.
  3. Когда асинхронная операция завершится, возобновление метода (await) может произойти на другом потоке из пула потоков. Этот новый поток попытается вызвать Monitor.Exit для объекта, который он никогда не захватывал, что приведет к SynchronizationLockException.

Пример проблемы:

private readonly object _syncLock = new object();

public async Task IncorrectMethodAsync()
{
    lock (_syncLock) // Захватываем монитор в текущем потоке
    {
        // ОПАСНО! При await поток может быть освобожден.
        await SomeAsyncOperation(); // <- Точка, где всё ломается
        // Возобновление здесь может произойти на ДРУГОМ потоке.
    } // Новый поток попытается освободить чужой lock -> Исключение!
}

Правильные альтернативы для асинхронной синхронизации:

1. Использование SemaphoreSlim с поддержкой асинхронности:

private readonly SemaphoreSlim _asyncLock = new SemaphoreSlim(1, 1);

public async Task CorrectMethodAsync()
{
    await _asyncLock.WaitAsync(); // Асинхронное ожидание
    try
    {
        // Критическая секция, безопасная для асинхронных операций
        await SomeAsyncOperation();
        await AnotherAsyncOperation();
    }
    finally
    {
        _asyncLock.Release(); // Гарантированное освобождение
    }
}

2. Использование AsyncLock из сторонних библиотек (например, Nito.AsyncEx):

private readonly AsyncLock _mutex = new AsyncLock();

public async Task MethodWithAsyncLock()
{
    using (await _mutex.LockAsync()) // Удобная обертка
    {
        await SomeAsyncOperation();
    }
}

3. Перепроектирование кода: Часто необходимость в lock внутри асинхронного метода указывает на проблему с дизайном. Рассмотрите:

  • Очереди (Channel, BufferBlock): для обработки задач одним потребителем.
  • Конкурентные коллекции (ConcurrentDictionary, ConcurrentQueue): если нужно только потокобезопасное хранилище.
  • Неизменяемые (immutable) структуры данных: чтобы полностью избежать состояния, требующего блокировки.