Ответ
При работе с асинхронным кодом обычный lock не подходит, так как он блокирует поток. Для безопасной синхронизации в async/await контексте используются специальные примитивы.
Основные способы:
-
SemaphoreSlim— наиболее распространённый и гибкий вариант для ограничения доступа к ресурсу или секции кода. Поддерживает асинхронное ожидание.private readonly SemaphoreSlim _semaphore = new SemaphoreSlim(1, 1); // Одновременно один поток public async Task AccessResourceAsync() { await _semaphore.WaitAsync(); try { // Критическая секция, работа с общим ресурсом await SomeAsyncOperation(); } finally { _semaphore.Release(); } } -
AsyncLock(из библиотекиNito.AsyncEx) — предоставляет более идиоматичный для C# синтаксис, похожий наlock, но для асинхронных методов.private readonly AsyncLock _mutex = new AsyncLock(); public async Task AccessResourceAsync() { using (await _mutex.LockAsync()) { // Критическая секция await SomeAsyncOperation(); } } -
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)) { // Обработка сообщения. Конкуренции нет, так как читает один потребитель. } } -
Иммутабельные структуры данных и
Interlocked— для простых операций (инкремент, сравнение с обменом) используйте атомарные операции классаInterlocked. Лучший "лок" — это его отсутствие: проектируйте систему так, чтобы общее состояние было неизменяемым или использовалось без блокировок.
Ключевой принцип: Избегайте блокировки потоков в асинхронном коде. Всегда предпочитайте асинхронные примитивы (WaitAsync) синхронным (Wait).
Ответ 18+ 🔞
Слушай, асинхронщина — это, конечно, мощь, но с ней и головняк свой. Представь, что обычный lock — это как здоровенный мужик Герасим из той истории, который стоит в дверях и никого не пускает, пока сам не выйдет. Только вот в асинхронном мире все эти потоки — они как те самые немые, но хитрые: они могут вроде как отойти, пока Герасим в туалете, и сделать своё дело. А lock их нихуя не останавливает, он поток-то держит, а задача уже улетела гулять. Короче, полный пиздец для синхронизации.
Так что, если ты не хочешь получить race condition, от которого потом волосы дыбом встанут, есть проверенные штуки.
Чем работать-то?
-
SemaphoreSlim— это наш главный по тарелочкам. Универсальный такой солдатик. Особенно его методWaitAsync()— это святое. Он не блокирует поток, а вежливо говорит задаче: «Постой, дружок, пока предыдущий не закончит».private readonly SemaphoreSlim _шлагбаум = new SemaphoreSlim(1, 1); // Пропускает только одного за раз public async Task ПолезтьВОбщийРесурсAsync() { await _шлагбаум.WaitAsync(); // Стоим в очереди асинхронно, не блокируя весь мир try { // А вот тут мы уже одни, как Герасим в своей каморке. Делаем что надо. await ПоработатьСБазойAsync(); } finally { _шлагбаум.Release(); // Всё, кричим следующему: «Заходи, свободно!» } }Главное —
try/finally. ЗабылRelease— и всё, вечная мерзлота, deadlock, все задачи застыли в очереди на хуй знает сколько. -
AsyncLock(изNito.AsyncEx) — это для эстетов, которымSemaphoreSlimкажется слишком уж голым. Выглядит почти как роднойlock, только асинхронный. Красиво, удобно.private readonly AsyncLock _мутекс = new AsyncLock(); public async Task ПолезтьВОбщийРесурсAsync() { using (await _мутекс.LockAsync()) // Взял ключ — и в царские покои { // Сидишь тут один, правишь бал. await ПоработатьСБазойAsync(); } // using сам всё закроет и ключ вернёт. Умно, блядь. }Но это сторонняя библиотека, зато мозг меньше выносит на ровном месте.
-
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)) { // Обрабатываешь сообщения по одному, конкурентов нет. Идиллия, ёпта. } }Подход топовый для многих сценариев. Меньше синхронизации — меньше боли.
-
Иммутабельность и
Interlocked— а это высший пилотаж. Лучший лок — это когда его нет вообще. Сделал данные неизменяемыми — и ходишь, такой весь из себя безопасный. А для простых операций вроде «добавить единичку» естьInterlocked. Атомарно, быстро, без всяких семафоров.private int _счётчик = 0; public void УвеличитьБезДраки() { Interlocked.Increment(ref _счётчик); // Всё, блядь. Ни один другой поток не влезет в эту операцию. }
Главный принцип, который надо выжечь в мозгу: В асинхронном коде ты должен ждать асинхронно. Никаких .Wait(), .Result или lock() прямо в асинхронном методе — это верный способ придушить приложение и получить deadlock, после которого останется только перезапускать. Используй WaitAsync(), LockAsync() и прочие асинхронные штуки. И думай, а нельзя ли вообще без блокировок, через очереди или неизменяемые данные. Работать будет быстрее, а голова болеть — меньше.