Как работают асинхронность и параллелизм на одноядерном процессоре в C#
Что проверяют
Различаете ли вы конкурентность и параллелизм и понимаете ли, что async/await не создаёт потоков. Следом обычно спрашивают, ускорит ли Task.Run вычисления на одном ядре и возможны ли там гонки.
Как отвечать
Разведите три понятия:
- конкурентность: несколько задач находятся в работе одновременно, но выполняются по очереди. На одном ядре ОС переключает потоки по кванту времени, вытесняя их;
- параллелизм: инструкции разных задач исполняются физически одновременно. На одном ядре его нет,
Parallel.Forи PLINQ дадут только накладные расходы на переключение; - асинхронность: поток не ждёт, пока идёт внешняя операция. Пока сетевой запрос или чтение файла обрабатывают драйвер и железо, ни один поток не заблокирован.
Дальше про механику async/await. Метод выполняется синхронно до первого await над незавершённой задачей, затем компилятор сохраняет состояние в конечном автомате и возвращает Task вызывающему. Продолжение запускается, когда операция завершилась: в UI-приложении через SynchronizationContext на UI-потоке, в ASP.NET Core на потоке пула. На одном ядре это выгодно именно для ввода-вывода: один поток обслуживает сотни ожидающих запросов.
Для вычислений всё иначе. Task.Run на одном ядре не ускоряет работу, он лишь уводит её с UI-потока, чтобы окно не зависало. Пул потоков стартует с минимумом рабочих потоков, равным числу ядер, поэтому при блокирующем коде он быстро упирается в голодание и добавляет потоки медленно. Ситуация вполне современная: контейнер с лимитом CPU в одно ядро.
Где спотыкаются
Считают, что на одном ядре гонок не бывает. Вытеснение может случиться посреди count++, поэтому Interlocked, lock и SemaphoreSlim нужны так же, как на многоядерной машине.
Говорят, что await запускает код в фоновом потоке. Он ничего не запускает, а только подписывает продолжение на завершение задачи.
Блокируют асинхронный код через .Result или .Wait(). В UI-приложении с SynchronizationContext это классический дедлок, а в сервисе поток пула простаивает впустую, и на одном ядре это быстро приводит к голоданию пула.