Урок по треку C# и .NET 7 мин

Потоки и синхронизация

Thread и ThreadPool, lock, Interlocked, SemaphoreSlim, конкурентные коллекции, volatile.

Многопоточность в .NET это потоки ОС, пул потоков и набор примитивов синхронизации. На собесе спрашивают гонки, lock, Interlocked, конкурентные коллекции и голодание пула.

Потоки и пул

new Thread(...) создаёт поток ОС: дорогой ресурс со своим стеком. Почти всегда работают через пул потоков: Task.Run, Parallel, продолжения await, обработка запросов ASP.NET Core. Пул сам регулирует число потоков и использует локальные очереди с кражей работы.

Если потоки пула блокируются (.Result, Thread.Sleep, синхронный I/O), новые потоки добавляются медленно, и сервис начинает «тормозить» при низкой загрузке CPU. Это голодание пула.

Гонка данных

int count = 0;
Parallel.For(0, 1_000_000, _ => count++);   // результат меньше миллиона

count++ это чтение, сложение и запись. Два потока прочитали одно значение, один инкремент потерян.

Interlocked

Атомарные операции без блокировок: Increment, Decrement, Add, Exchange, CompareExchange. Для сложных обновлений цикл CAS:

int seen;
do { seen = _max; } while (value > seen && Interlocked.CompareExchange(ref _max, value, seen) != seen);

lock

private readonly object _sync = new();   // в .NET 9 можно System.Threading.Lock
lock (_sync)
{
    _balance -= amount;
}

Блокируйте приватный объект, не this, не typeof(...) и не строку. Держите блокировку минимально, не вызывайте внутри внешний код. await внутри lock запрещён: продолжение может оказаться на другом потоке, а монитор привязан к потоку.

Асинхронная синхронизация

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

await _gate.WaitAsync(ct);
try { await SaveAsync(ct); }
finally { _gate.Release(); }

SemaphoreSlim(n) заодно ограничивает параллелизм до n.

Дедлок

Два потока захватывают блокировки в разном порядке и ждут друг друга. Лечится единым порядком захвата, Monitor.TryEnter с таймаутом или укрупнением блокировки. Найти помогает дамп процесса (dotnet-dump, команды clrstack, syncblk).

Конкурентные коллекции

  • ConcurrentDictionary: GetOrAdd, AddOrUpdate. Фабрика в GetOrAdd может вызваться несколько раз, для дорогих значений оборачивают в Lazy<T>.
  • ConcurrentQueue, ConcurrentBag, ConcurrentStack.
  • Channel<T>: асинхронный «производитель-потребитель» с обратным давлением.

volatile и модель памяти

Компилятор, JIT и процессор могут переупорядочивать чтения и записи. volatile и Volatile.Read/Write дают семантику acquire/release, это нужно, например, для флага остановки. Атомарности составных операций volatile не даёт.

Контекст в async-коде

[ThreadStatic] привязан к потоку, а после await код может оказаться на другом. Для данных «на логический поток выполнения» (correlation id) используют AsyncLocal<T>, он течёт вместе с ExecutionContext.

Parallel

Parallel.For/ForEach для CPU-работы, Parallel.ForEachAsync (.NET 6) для асинхронной работы с ограничением параллелизма. PLINQ (AsParallel()) для запросов над большими данными в памяти.

Частые ошибки

  • count++ из нескольких потоков.
  • lock(this) и блокировки на строках.
  • Блокирующее ожидание в пуле потоков.
  • Изменение Dictionary из нескольких потоков (может повредить структуру).
  • Захват блокировок в разном порядке.

Что спросят на собесе

  • Что такое гонка данных и как её избежать?
  • Чем lock отличается от Interlocked и SemaphoreSlim?
  • Почему нельзя await внутри lock?
  • Что такое голодание пула потоков и как его диагностировать?
  • Что гарантирует volatile?

Закрепите за 5 минут

После урока в тренажёре 3-5 проверочных вопросов, дальше модуль уходит в повторения.