Многопоточность и синхронизация
Гонки и взаимоблокировки, lock и Monitor, SemaphoreSlim, volatile и Interlocked, голодание пула потоков, Channels и конкурентные коллекции.
18 вопросов
JuniorТеорияОчень частоЧто такое взаимоблокировка, и как два потока с двумя блокировками её создают?
Что такое взаимоблокировка, и как два потока с двумя блокировками её создают?
Взаимоблокировка — это когда потоки навсегда ждут блокировки, уже удерживаемые другими, и ни один не может продолжить. Поток A берёт lock 1, затем хочет lock 2; поток B берёт lock 2, затем хочет lock 1. Каждый держит нужное другому, и оба стоят вечно.
Типичные ошибки
- ✗Путать взаимоблокировку с голоданием, когда поток лишь долго не может выиграть блокировку
- ✗Ждать, что CLR обнаружит цикл и бросит исключение, вместо тихого зависания
- ✗Считать, что реентерабельность
lockспасает, когда блокировки берут в противоположном порядке
Уточняющие вопросы
- →Как убедиться, что зависший процесс именно во взаимоблокировке, а не просто медленный?
- →Почему одна
lock, которую берут все потоки, сама по себе не даёт взаимоблокировки?
JuniorТеорияОчень частоКакой объект передавать в lock, и почему lock(this) и lock("key") — ошибка?
Какой объект передавать в lock, и почему lock(this) и lock("key") — ошибка?
Блокируйтесь на private readonly object, видимом только вашему классу. lock(this) делает блокировку публичной: чужой код с вашим экземпляром тоже может её взять и заблокировать вас. lock("key") хуже: литералы интернируются, и блокировка становится общей на процесс.
Типичные ошибки
- ✗Блокироваться на
thisили на публичном поле, позволяя чужому коду взять тот же монитор - ✗Блокироваться на строковом литерале, который интернируется и потому общий на весь процесс
- ✗Блокироваться на значимом типе или свежем объекте, из-за чего каждый поток берёт свой монитор
Уточняющие вопросы
- →Почему блокировка на значимом типе вообще ничего не защищает?
- →Как тип
System.Threading.Lockиз .NET 9 меняет эту рекомендацию?
JuniorТеорияОчень частоЧто такое состояние гонки, и почему два потока с count++ могут потерять инкремент?
Что такое состояние гонки, и почему два потока с count++ могут потерять инкремент?
Состояние гонки — это когда результат зависит от несинхронизированного чередования потоков. count++ — не одна операция: он читает, прибавляет единицу и записывает обратно. Два потока могут прочитать одно значение, оба прибавить единицу и оба записать — один инкремент теряется.
Типичные ошибки
- ✗Считать
count++одной атомарной инструкцией, а не read-modify-write - ✗Думать, что гонки бывают только на многоядерном железе, а не при вытеснении на одном ядре
- ✗Считать, что пометка поля как
volatileсама по себе делаетcount++безопасным
Уточняющие вопросы
- →Какой примитив делает инкремент целого атомарным без взятия
lock? - →Как добиться стабильного воспроизведения потерянного инкремента в тесте?
JuniorТеорияЧастоЧто такое пул потоков .NET, и почему Task.Run предпочтительнее создания new Thread?
Что такое пул потоков .NET, и почему Task.Run предпочтительнее создания new Thread?
Пул потоков — это управляемый рантаймом набор переиспользуемых рабочих потоков. Task.Run ставит работу в него, поэтому вы не платите за создание потока, а пул ограничивает параллелизм. Каждый new Thread стоит около 1 МБ стека плюс работа ОС.
Типичные ошибки
- ✗Создавать
new Threadна каждый запрос вместо постановки работы в пул - ✗Считать управляемый поток дешёвым — он стоит выделенного стека плюс учёта в ОС
- ✗Думать, что размер пула фиксирован числом ядер и никогда не растёт
Уточняющие вопросы
- →Какую работу нельзя ставить в пул потоков и почему?
- →Как пул решает, что пора добавить ещё один рабочий поток?
MiddleТеорияЧастоКогда ConcurrentDictionary выигрывает у обычного Dictionary под lock?
Когда ConcurrentDictionary выигрывает у обычного Dictionary под lock?
ConcurrentDictionary разносит блокировки по сегментам: писатели в разные ключи почти не конкурируют, а чтения не берут блокировку, а Dictionary под lock сериализует всё. Но фабрика GetOrAdd идёт вне блокировки и может отработать дважды для ключа.
Типичные ошибки
- ✗Считать, что фабрика значений
GetOrAddвызывается ровно один раз на ключ - ✗Составлять два вызова
ConcurrentDictionaryи ждать атомарности этой пары - ✗Думать, что один
lockвокругDictionaryтак же масштабируется под тяжёлым чтением
Уточняющие вопросы
- →Как обёртка значения в
Lazy<T>заставляет дорогую фабрикуGetOrAddотработать лишь однажды? - →Почему свойство
CountуConcurrentDictionaryдороже, чем кажется?
MiddleТеорияЧастоЧем различаются lock, Monitor и Mutex, и когда действительно нужен Mutex?
Чем различаются lock, Monitor и Mutex, и когда действительно нужен Mutex?
lock — сахар над Monitor.Enter/Monitor.Exit в try/finally; оба внутрипроцессные, управляемые и реентерабельные для одного потока. Monitor добавляет TryEnter с таймаутом и Wait/Pulse. Mutex — более медленный объект ядра, именуемый между процессами.
Типичные ошибки
- ✗Брать
Mutexвнутри одного процесса, платя за переход в ядро без всякой причины - ✗Не знать, что
lock— это простоMonitor.Enter/Monitor.Exitвtry/finally - ✗Упускать
Monitor.TryEnterс таймаутом как способ не блокироваться навсегда
Уточняющие вопросы
- →Почему
Mutexобязан освобождать именно тот поток, который его захватил? - →Что освобождает
Monitor.Wait, и почему для его вызова нужно уже держать монитор?
MiddleТеорияЧастоКогда Parallel.ForEach лучше асинхронных задач, и когда это неверный инструмент?
Когда Parallel.ForEach лучше асинхронных задач, и когда это неверный инструмент?
Parallel.ForEach разбивает CPU-bound работу по потокам пула и блокирует вызывающий поток — верно для расчётов, неверно для I/O, где он занимает поток пула на операцию, а await не держит ни одного. Тело цикла обязано быть потокобезопасным.
Типичные ошибки
- ✗Использовать
Parallel.ForEachдля I/O-вызовов, занимая по потоку пула на операцию - ✗Считать
Parallel.ForEachасинхронным — он блокирует вызывающий поток до конца цикла - ✗Менять общую коллекцию или счётчик из тела цикла без синхронизации
Уточняющие вопросы
- →Что меняет
MaxDegreeOfParallelism, и когда его стоит ограничивать? - →Какой тип заменяет
Parallel.ForEach, когда каждому элементу нужен I/O-вызов черезawait?
MiddleТеорияИногдаКакие четыре условия должны выполниться для взаимоблокировки, и как порядок захвата ломает одно?
Какие четыре условия должны выполниться для взаимоблокировки, и как порядок захвата ломает одно?
Все четыре должны выполняться одновременно: взаимное исключение, удержание с ожиданием, отсутствие вытеснения и круговое ожидание. Нарушьте любое одно — и взаимоблокировка невозможна. Единый порядок захвата убирает круговое ожидание, а Monitor.TryEnter с таймаутом — удержание.
Типичные ошибки
- ✗Назвать четыре условия, но не увидеть, что для профилактики достаточно нарушить одно
- ✗Ждать, что рантайм обнаружит цикл и отберёт блокировку у владельца
- ✗Считать единый порядок захвата косметикой, а не стандартным способом профилактики
Уточняющие вопросы
- →Как задать устойчивый порядок захвата для объектов, у которых нет естественного порядка?
- →Что поток обязан сделать после
Monitor.TryEnter, вернувшегоfalse, чтобы не попасть в livelock?
MiddleТеорияИногдаЧем Interlocked.Increment отличается от инкремента того же поля внутри lock?
Чем Interlocked.Increment отличается от инкремента того же поля внутри lock?
Оба делают инкремент атомарным. Interlocked.Increment — одна lock-free инструкция с полным барьером памяти: без блокировки и переключения контекста, поэтому при конкуренции дешевле. Но она покрывает лишь одну переменную, а lock — несколько полей.
Типичные ошибки
- ✗Думать, что
Interlockedлишь добавляет барьер и всё равно требуетlockдля атомарности - ✗Применять
Interlockedк двум полям по отдельности и ждать атомарности пары - ✗Брать
lockна горячем одиночном счётчике там, где хватило бы interlocked-сложения
Уточняющие вопросы
- →Как построить lock-free обновление вычисляемого значения через
Interlocked.CompareExchange? - →Почему обычное 64-битное чтение не атомарно на 32-битном рантайме и что это чинит?
MiddleТеорияИногдаКогда выбирать SemaphoreSlim вместо lock, и что он умеет из того, что не умеет lock?
Когда выбирать SemaphoreSlim вместо lock, и что он умеет из того, что не умеет lock?
SemaphoreSlim пропускает до N владельцев, ограничивая параллелизм, а не сводя его к одному. Его WaitAsync освобождает поток вместо блокировки, поэтому семафор можно удерживать через await, чего lock не позволяет. Он не реентерабельный.
Типичные ошибки
- ✗Пытаться удержать
lockчерезawait, что компилятор просто запрещает - ✗Считать
SemaphoreSlimреентерабельным и заблокировать поток против самого себя - ✗Звать
Wait()вместоWaitAsync()в асинхронном коде, блокируя поток пула
Уточняющие вопросы
- →Почему освобождение
SemaphoreSlimвсегда должно стоять в блокеfinally? - →Чем
SemaphoreSlimотличается от классаSemaphore, работающего через ядро?
SeniorТеорияИногдаЧем различаются очереди «производитель-потребитель» Channel<T> и BlockingCollection<T>?
Чем различаются очереди «производитель-потребитель» Channel<T> и BlockingCollection<T>?
BlockingCollection<T> оборачивает конкурентную коллекцию и блокирует поток в Take() на пустой очереди или в Add() на полной. Channel<T> — асинхронный аналог: ReadAsync/WriteAsync освобождают поток, а ограниченный канал заставляет писателя ждать.
Типичные ошибки
- ✗Использовать
BlockingCollection<T>из асинхронного кода, блокируя поток пула на каждомTake() - ✗Оставлять канал неограниченным, из-за чего медленный потребитель растит очередь до исчерпания памяти
- ✗Забывать завершить писателя, оставляя потребителей в ожидании канала, который никогда не кончится
Уточняющие вопросы
- →Что делает завершение писателя канала с потребителем, стоящим в
ReadAllAsync? - →Какой
BoundedChannelFullModeвыбрать для телеметрии, которую под нагрузкой допустимо терять?
SeniorТеорияИногдаЗачем библиотечный код вызывает ConfigureAwait(false), и что делает контекст синхронизации SynchronizationContext?
Зачем библиотечный код вызывает ConfigureAwait(false), и что делает контекст синхронизации SynchronizationContext?
SynchronizationContext захватывается на await, и продолжение отправляется обратно в него — в цикл сообщений UI или контекст запроса ASP.NET. ConfigureAwait(false) отменяет захват, и продолжение идёт на любом потоке пула: без перехода контекста и взаимоблокировки.
Типичные ошибки
- ✗Думать, что
ConfigureAwait(false)возвращает продолжение в исходный контекст - ✗Считать, что он меняет поток самой ожидаемой операции, а не её продолжения
- ✗Ставить его в прикладном коде, где захваченный контекст как раз и нужен
Уточняющие вопросы
- →Почему в прикладном коде ASP.NET Core
ConfigureAwait(false)больше не нужен? - →Чем
TaskSchedulerотличается отSynchronizationContextв выборе места выполнения продолжения?
SeniorДебаггингИногдаОбработчик кнопки WPF навсегда зависает на .Result. Найдите и исправьте взаимоблокировку.
Обработчик кнопки WPF навсегда зависает на .Result. Найдите и исправьте взаимоблокировку.
.Result блокирует поток UI, а await в FetchAsync захватил SynchronizationContext диспетчера и отправляет продолжение в этот же заблокированный цикл. Лечится сквозной асинхронностью: ожидайте задачу через await в async-методе.
Типичные ошибки
- ✗Винить пул потоков вместо захваченного
SynchronizationContext, в который отправляется продолжение - ✗Менять
.Resultна.Wait()илиGetAwaiter().GetResult()— все три блокируют тот же поток - ✗Ставить
ConfigureAwait(false)в обработчике, а не наawaitвнутри асинхронного метода
Уточняющие вопросы
- →Почему
ConfigureAwait(false)внутриFetchAsyncтоже расклинил бы это, и почему такое решение всё же слабее? - →Почему тот же код не даёт взаимоблокировки в контроллере ASP.NET Core?
SeniorТеорияИногдаЧто вызывает голодание пула потоков, и почему блокировка внутри async-метода его провоцирует?
Что вызывает голодание пула потоков, и почему блокировка внутри async-метода его провоцирует?
Голодание — работа приходит быстрее, чем у пула есть потоки. Блокирующие вызовы (.Result, SemaphoreSlim.Wait(), синхронный I/O, блокирующее тело Parallel.ForEach) занимают потоки пула. Сверх минимума пул добавляет их медленно, и задержки растут.
Типичные ошибки
- ✗Блокироваться на
.Resultили.Wait()внутри асинхронной цепочки, идущей на потоках пула - ✗Считать, что
async-метод не занимает поток, даже когда его тело блокируется - ✗Ждать мгновенного добавления потоков пулом, а не его медленного темпа подстройки
Уточняющие вопросы
- →Как отличить голодание пула от настоящего упора в процессор по продакшен-дампу?
- →Когда повышение
ThreadPool.SetMinThreads— законное решение, а не подпорка?
MiddleТеорияРедкоЧем [ThreadStatic] отличается от ThreadLocal<T>, и почему оба опасны на потоках пула?
Чем [ThreadStatic] отличается от ThreadLocal<T>, и почему оба опасны на потоках пула?
Оба дают каждому потоку свою копию значения. У [ThreadStatic] нет инициализатора для потока, значение стартует с default; ThreadLocal<T> берёт фабрику и инициализируется лениво. Потоки пула переиспользуются, и старое значение утекает в следующую задачу.
Типичные ошибки
- ✗Ждать, что инициализатор
[ThreadStatic]-поля выполнится на каждом потоке — он сработает лишь на первом - ✗Переносить контекст через
awaitв потоковых данных, хотя продолжение идёт на другом потоке - ✗Забывать, что потоки пула переиспользуются, и оставленное значение утекает в следующую задачу
Уточняющие вопросы
- →Какой тип переносит контекст через
await, когда продолжение выполняется на другом потоке? - →Почему
ThreadLocal<T>с освобождаемыми объектами нужно освобождать, и что течёт, если этого не сделать?
SeniorТеорияРедкоКуда девается необработанное исключение, брошенное в new Thread и внутри Task?
Куда девается необработанное исключение, брошенное в new Thread и внутри Task?
В new Thread или колбэке пула необработанное исключение фатально — CLR обрушивает процесс. Внутри Task оно перехватывается: .Wait()/.Result бросают его в AggregateException, await разворачивает, а ненаблюдённая задача его теряет.
Типичные ошибки
- ✗Оборачивать
Thread.Start()илиTask.Run()вtry/catchи ждать, что он поймает тело - ✗Считать, что необработанное исключение в фоновом потоке убивает только этот поток
- ✗Думать, что
awaitотдаётAggregateExceptionтак же, как.Result
Уточняющие вопросы
- →Что позволяет сделать событие
TaskScheduler.UnobservedTaskExceptionс потерянным исключением? - →Почему исключение из
async void-метода не может быть поймано вызывающим кодом?
SeniorТеорияРедкоЧто гарантирует volatile в C#, и чего он явно не гарантирует?
Что гарантирует volatile в C#, и чего он явно не гарантирует?
volatile даёт упорядочивание, а не атомарность: чтение имеет семантику acquire, запись — release, поэтому обращения не переносятся через них, а записанное значение становится видимым другим потокам. Атомарным count++ он не делает — нужен Interlocked или lock.
Типичные ошибки
- ✗Считать, что
volatileделает атомарнымcount++или любое составное обновление - ✗Использовать
volatileкак дешёвую заменуlockдля инвариантов из нескольких полей - ✗Думать, что это подсказка только компилятору, без влияния на переупорядочивание и видимость на уровне процессора
Уточняющие вопросы
- →Почему volatile-запись, за которой идёт volatile-чтение другого поля, всё ещё допускает переупорядочивание store-load?
- →Когда
Volatile.Read/Volatile.Writeпредпочтительнее пометки поля какvolatile?