Какие способы безопасной блокировки (lock) при асинхронных операциях в C# ты знаешь?

Ответ

При работе с асинхронным кодом обычный lock не подходит, так как он блокирует поток. Для безопасной синхронизации в async/await контексте используются специальные примитивы.

Основные способы:

  1. SemaphoreSlim — наиболее распространённый и гибкий вариант для ограничения доступа к ресурсу или секции кода. Поддерживает асинхронное ожидание.

    private readonly SemaphoreSlim _semaphore = new SemaphoreSlim(1, 1); // Одновременно один поток
    
    public async Task AccessResourceAsync()
    {
        await _semaphore.WaitAsync();
        try
        {
            // Критическая секция, работа с общим ресурсом
            await SomeAsyncOperation();
        }
        finally
        {
            _semaphore.Release();
        }
    }
  2. AsyncLock (из библиотеки Nito.AsyncEx) — предоставляет более идиоматичный для C# синтаксис, похожий на lock, но для асинхронных методов.

    private readonly AsyncLock _mutex = new AsyncLock();
    
    public async Task AccessResourceAsync()
    {
        using (await _mutex.LockAsync())
        {
            // Критическая секция
            await SomeAsyncOperation();
        }
    }
  3. Channel или BufferBlock<T> (из TPL Dataflow) — для сценариев, где синхронизацию можно заменить очередью сообщений (producer/consumer). Это часто более масштабируемый и чистый подход.

    private readonly Channel<MyMessage> _channel = Channel.CreateUnbounded<MyMessage>();
    
    // Producer
    public async Task ProduceAsync(MyMessage msg) => await _channel.Writer.WriteAsync(msg);
    
    // Consumer (запускается один раз)
    public async Task ConsumeAsync(CancellationToken ct)
    {
        await foreach (var msg in _channel.Reader.ReadAllAsync(ct))
        {
            // Обработка сообщения. Конкуренции нет, так как читает один потребитель.
        }
    }
  4. Иммутабельные структуры данных и Interlocked — для простых операций (инкремент, сравнение с обменом) используйте атомарные операции класса Interlocked. Лучший "лок" — это его отсутствие: проектируйте систему так, чтобы общее состояние было неизменяемым или использовалось без блокировок.

Ключевой принцип: Избегайте блокировки потоков в асинхронном коде. Всегда предпочитайте асинхронные примитивы (WaitAsync) синхронным (Wait).

Ответ 18+ 🔞

Слушай, асинхронщина — это, конечно, мощь, но с ней и головняк свой. Представь, что обычный lock — это как здоровенный мужик Герасим из той истории, который стоит в дверях и никого не пускает, пока сам не выйдет. Только вот в асинхронном мире все эти потоки — они как те самые немые, но хитрые: они могут вроде как отойти, пока Герасим в туалете, и сделать своё дело. А lock их нихуя не останавливает, он поток-то держит, а задача уже улетела гулять. Короче, полный пиздец для синхронизации.

Так что, если ты не хочешь получить race condition, от которого потом волосы дыбом встанут, есть проверенные штуки.

Чем работать-то?

  1. SemaphoreSlim — это наш главный по тарелочкам. Универсальный такой солдатик. Особенно его метод WaitAsync() — это святое. Он не блокирует поток, а вежливо говорит задаче: «Постой, дружок, пока предыдущий не закончит».

    private readonly SemaphoreSlim _шлагбаум = new SemaphoreSlim(1, 1); // Пропускает только одного за раз
    
    public async Task ПолезтьВОбщийРесурсAsync()
    {
        await _шлагбаум.WaitAsync(); // Стоим в очереди асинхронно, не блокируя весь мир
        try
        {
            // А вот тут мы уже одни, как Герасим в своей каморке. Делаем что надо.
            await ПоработатьСБазойAsync();
        }
        finally
        {
            _шлагбаум.Release(); // Всё, кричим следующему: «Заходи, свободно!»
        }
    }

    Главное — try/finally. Забыл Release — и всё, вечная мерзлота, deadlock, все задачи застыли в очереди на хуй знает сколько.

  2. AsyncLock (из Nito.AsyncEx) — это для эстетов, которым SemaphoreSlim кажется слишком уж голым. Выглядит почти как родной lock, только асинхронный. Красиво, удобно.

    private readonly AsyncLock _мутекс = new AsyncLock();
    
    public async Task ПолезтьВОбщийРесурсAsync()
    {
        using (await _мутекс.LockAsync()) // Взял ключ — и в царские покои
        {
            // Сидишь тут один, правишь бал.
            await ПоработатьСБазойAsync();
        } // using сам всё закроет и ключ вернёт. Умно, блядь.
    }

    Но это сторонняя библиотека, зато мозг меньше выносит на ровном месте.

  3. Channel или BufferBlock<T> — а это уже философия другая. Зачем драться за ресурс, как собаки, если можно сделать очередь? Один пишет, другой читает — и никаких драк. Масштабируется овердохуища.

    private readonly Channel<Сообщение> _труба = Channel.CreateUnbounded<Сообщение>();
    
    // Писатель
    public async Task ОтправитьСообщениеAsync(Сообщение msg) => await _труба.Writer.WriteAsync(msg);
    
    // Читатель (запускается, например, один раз при старте)
    public async Task ЧитатьВсюЖизньAsync(CancellationToken ct)
    {
        await foreach (var msg in _труба.Reader.ReadAllAsync(ct))
        {
            // Обрабатываешь сообщения по одному, конкурентов нет. Идиллия, ёпта.
        }
    }

    Подход топовый для многих сценариев. Меньше синхронизации — меньше боли.

  4. Иммутабельность и Interlocked — а это высший пилотаж. Лучший лок — это когда его нет вообще. Сделал данные неизменяемыми — и ходишь, такой весь из себя безопасный. А для простых операций вроде «добавить единичку» есть Interlocked. Атомарно, быстро, без всяких семафоров.

    private int _счётчик = 0;
    public void УвеличитьБезДраки()
    {
        Interlocked.Increment(ref _счётчик); // Всё, блядь. Ни один другой поток не влезет в эту операцию.
    }

Главный принцип, который надо выжечь в мозгу: В асинхронном коде ты должен ждать асинхронно. Никаких .Wait(), .Result или lock() прямо в асинхронном методе — это верный способ придушить приложение и получить deadlock, после которого останется только перезапускать. Используй WaitAsync(), LockAsync() и прочие асинхронные штуки. И думай, а нельзя ли вообще без блокировок, через очереди или неизменяемые данные. Работать будет быстрее, а голова болеть — меньше.