Многопоточность в .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?