Шпаргалка по многопоточности в .NET (System.Threading)
Parallel vs. Concurrent
Parallelism (параллелизм) — более узкий термин, подчёркивающий наличие нескольких вычислительных устройств (машин, процессоров, ядер).
Concurrency (одновременность) может быть реализована через разбиение на части и быстрое переключение между ними, что создаёт иллюзию параллелизма.
Но это занудство. Термины используют как синонимы даже в специализированных источниках.
Preemptive vs. Cooperative
Preemptive (вытесняющее) — способ переключения контекста на уровне операционной системы, когда процессы и потоки хотели бы занимать всё процессорное время, но планировщик принудительно их приостанавливает, чтобы дать поработать другим.
Cooperative (кооперативное) — способ переключения на уровне runtime или пользовательского кода, когда предусмотрены явные и/или неявные сигналы о готовности добровольно встать на паузу. Неявные сигналы — это например, остановка в ожидании I/O (сеть, диск) или другого системного вызова. Явные сигналы даются командой
yield
(уступить). Модули, взаимодействующие таким образом, называются сопрограммами (coroutine).
Кооперативное переключение менее затратно, так как не требует обращений к ОС. Поэтому употребляют термины “зелёный поток”, “лёгкий поток”, “волокно” (thread — толстая нить, fiber — более тонкое волокно).
See also: Is there a difference between fibers, coroutines and green threads?
Sync vs. Async
Sync (синхронно) подразумевает последовательное упорядоченное блокирующее выполнение по шагам. Перед переходом к следующему шагу, предыдущий шаг должен быть завершён, и его результат известен.
Async (асинхронно) означает, что новые операции могут быть начаты, пока другие не завершены. Допускается одновременное выполнение и перемешивание. Целостность логики достигается через уведомления, подписки на события и т.п.
Async не обязательно подразумевает многопоточность или concurrency. Единственный поток может взять из очереди следующую задачу, пока текущая ожидает ввода/вывода. Так уменьшается время простоя процессора.
Thread
В .NET потоки называют управляемыми (managed threads). В теории они являются виртуальными абстракциями, которые CLR может реализовывать по-разному. На практике — транслируются в настоящие потоки операционной системы:
ThreadPool
Пул (коллекция) заранее запущенных потоков, в которые можно отправлять относительно короткие делегаты (work items) для выполнения в фоне.
Количество потоков динамически оптимизируется алгоритмом Hill Climbing. Если все потоки заняты, формируется очередь ожидания с балансировкой work stealing.
На ThreadPool
опираются базовые реализации более высокоуровневых абстракций — TaskScheduler
и SynchronizationContext
.
Когда не надо использовать:
- В современном .NET практически никогда. Следует предпочесть
Task
иasync/await
. - Для долго работающих потоков.
- Для потоков, которые должны быть запущены моментально (без ожидания в очереди).
- Если требуются настройки, например выставить
Priority
. - Если уверены, что сможете лучше.
Task
Асинхронная операция, планируемая единица работы, результат которой может быть известен не сразу.
Синонимы из других платформ: Deferred
, Future
, Promise
.
Ключевые свойства:
Status
(в процессе, завершена, ошибка и др.)Result
(только уTask<T>
)Exception
До появления Task
писать асинхронный код на .NET было сложно — это были неудобные методы типа BeginInvoke
/ EndInvoke
(модель APM).
Исторически, тип Task
был частью Parallel Extensions. Позже его приспособили и для синтаксиса async/await
, потому что “было заманчиво”:
“The
async
team at Microsoft did consider writing their own ’Future’ type that would represent an asynchronous operation, but theTask
type was too tempting” — A Bit of Task History
И правда, в исходниках .NET класс Task<T>
объявлен в файле Future.cs
С одной стороны, получили унификацию. С другой — путаницу, потому что Task
взяли вместе со всеми флагами, свойствами, конструкторами, многие из которых не имеют смысла в контексте async/await
:
“Developers who have not used
Task
in the past face a bewildering selection ofTask
members, almost all of which should not be used in theasync
world” — A Bit of Task History
“…caused a lot of developers to incorrectly attempt to use the
AttachedToParent
flag withasync
tasks” — StackOverflow
Все Task
можно разделить на две категории:
Те, что являются результатом выполнения делегата:
new Task(...)
Task.Run(...)
Task.Factory.StartNew(...)
Те, что получают состояние и результат извне:
Task.CompletedTask
,Task.FromXXX
— рождённые готовыми. Эти объекты частично закэшированы на уровне runtime.TaskCompletionSource
— переход вTask
из других форм асинхронности (событий и др.)Комбинирование через
Task.WhenAll
,Task.WhenAny
иasync/await
.
Task
реализует IDisposable
, но официально разрешается не диспозить.
ContinueWith
vs. GetAwaiter
Асинхронный код структурируется при помощи callback-функций (функций обратного вызова). Callback-функции, выполняемые после завершения чего-либо, называются продолжениями (continuations).
Есть два способа прикрепить продолжения к Task
:
ContinueWith(...)
— более старый. В изобилии сигнатур этого метода легко запутаться.GetAwaiter()
— вместо кучи аргументов дополнительный объект, awaiter.
Оба метода можно звать несколько раз, чтобы добавить несколько продолжений. Причём Task
может находиться в любом состоянии. Например, если операция уже завершена, то continuation callback будет отправлен на выполнение сразу же.
При прямом использовании, оба подхода приводят к callback hell, или как говорят в интеллектуальных кругах, continuation-passing style (CPS).
Но именно GetAwaiter()
применяется компилятором при “обессахаривании” синтаксиса async/await
.
Важное отличие:
ContinueWith
отправляет свой callback вTaskScheduler
.GetAwaiter()
иasync/await
— вSynchronizationContext
, но на это можно влиять.
TaskScheduler
Task
, созданные из делегата, отправляются в очередь планировщика TaskScheduler
и выполняются там.
Реализация по умолчанию ThreadPoolTaskScheduler
делает вот что:
- Если выставлен флаг
LongRunning
, запускает новыйThread
. - В остальных случаях перенаправляет в
ThreadPool
.
Другие реализации (FromCurrentSynchronizationContext
, ConcurrentExclusiveSchedulerPair
) были актуальны до внедрения async/await
.
SynchronizationContext
Без понимания SynchronizationContext
не получится хорошо разобраться в тонкостях async/await
.
SynchronizationContext
похож на ThreadPool
тем, что принимает делегат с обещанием его выполнить. Базовый SynchronizationContext
делает именно это, являясь прокси для ThreadPool.QueueUserWorkItem
.
Но бывают и другие реализации. Зачем они нужны?
В GUI / frontend приложениях обычно есть очередь, в которую нужно вставать для взаимодействия с элементами пользовательского интерфейса. В desktop-приложениях эта очередь ассоциирована с главным потоком (UI thread), в ASP.NET — с текущим HttpContext
, в Blazor Server — со штуковиной под названием “circuit”.
В других платформах: Android main looper, Swift main dispatch queue, JavaScript event loop, Windows message loop.
SynchronizationContext
задуман как абстракция для обобщения всех таких конструкций.
Вот примеры разных SynchronizationContext
в разных типах .NET приложений:
Workload | Реализация | Примечания |
---|---|---|
WinForms | WindowsFormsSynchronizationContext |
Control.BeginInvoke |
WPF | DispatcherSynchronizationContext |
Dispatcher |
ASP.NET | AspNetSynchronizationContext |
HttpContext |
ASP.NET Core | null |
Считается backend-технологией |
Blazor Server | RendererSynchronizationContext |
Circuit |
Blazor WASM | null |
Нет смысла, т.к. HTML однопоточный |
Console | null |
Но есть и другие сценарии использования. Например, AsyncTestSyncContext
и MaxConcurrencySyncContext
в Xunit
.
В самом общем смысле, SynchronizationContext
— это планировщик операций, работающий по правилам текущего окружения. Правила могут быть самыми разными.
async/await
Компилятор трансформирует функции с модификатором async
в сопрограммы (coroutines) с кооперативным переключением в точках await
.
Оригинальный код режется на шаги (await
— линии разреза), переписывается в подобие конструкции switch
по этим шагам и выносится в скрытый объект IAsyncStateMachine
(стейт-машина, машина состояний, конечный автомат).
На месте кода оригинальной функции создаётся инфраструктурный хелпер из семейства AsyncXXXMethodBuilder
. Его назначение — сформировать результирующий объект Task
, следуя по шагам стейт-машины.
Делается это примерно так:
Выполняется код шага (
IAsyncStateMachine.MoveNext
). Первый шаг всегда синхронный, последующие — могут быть асинхронными.Пример. Если функция начинается сразу с
await
, то первый шаг — это выражение справа отawait
, то есть тот код, который инициирует первую асинхронную операцию. Второй шаг — код, использующий результат первогоawait
.Если очередной шаг завершился синхронно, то сразу переходим к следующему. Но начиная с .NET 8 можно форсировать асинхронность через
ForceYielding
.Если асинхронно, то async method builder через
GetAwaiter()
подписывает себя в качестве продолжения (continuation), запоминает номер следующего шага и делаетreturn
. Это — момент кооперативного переключения.Continuation = переход к шагу, номер которого запомнили перед выходом.
На последнем шаге записывается
Task.Result
, а самаTask
переводится в состояниеRanToCompletion
.Если в процессе случилось исключение, оно ловится, сохраняется в
Task.Exception
и будет выкинуто черезExceptionDispatchInfo
, когда выше по стеку вызывающий код попробует обратиться к результату.
sharplab.io позволяет увидеть преобразованный код во всей красе — как локальные переменные становятся полями, а циклы превращаются в if
/goto
.
По конвенции, имена асинхронных функций должны заканчиваться постфиксом Async
— ToListAsync()
, GetValueAsync()
и т.д.
async/await
“заразен” — если функция стала асинхронной, то все, кто её вызывают слёзы проливают тоже становятся асинхронными.
В старых версиях .NET Framework, async/await
замусоривал память. В современном .NET сделали множество оптимизаций.
Мельчайшие подробности можно почерпнуть из бесконечно-длинной статьи How Async/Await Really Works in C#.
А для самых любопытных — можно сравнить с TypeScript:
- Converting TypeScript
async/await
generates a lot of JavaScript code - Typescript Async/Await Internals
async/await
, SynchronizationContext
и ConfigureAwait(false)
Стандартные awaiter-объекты, создаваемые во время выполнения async
-функций, отправляют асинхронные продолжения в текущий SynchronizationContext
:
IAsyncStateMachine.MoveNext();
. . .
TaskAwaiter awaiter = (...).GetAwaiter();
AsyncTaskMethodBuilder.AwaitUnsafeOnCompleted(awaiter, ...);
. . .
TaskAwaiter.UnsafeOnCompletedInternal(..., continueOnCapturedContext: true);
. . .
Task.UnsafeSetContinuationForAwait(..., continueOnCapturedContext: true);
. . .
var current = SynchronizationContext.Current;
new SynchronizationContextAwaitTaskContinuation(current, ...);
Такое поведение мотивируется удобством для разработчиков приложений. Например, в WinForms и WPF после await
не нужно явно возвращаться в главный поток для обновления интерфейса.
“The
await
keyword tries to bring you back to where you were” — Stephen Toub
Обратная сторона этого удобства — потенциальное ограничение concurrency, так как SynchronizationContext
выстраивает делегаты в единую очередь к главному потоку.
Можно даже получить взаимную блокировку (deadlock), если смешивать await
с блокирующими вызовами типа Task.Wait()
. Но так лучше не делать, в любом случае, независимо от наличия SynchronizationContext
.
Если вы уверены, что после строки с await
нет кода, которому необходим SynchronizationContext
, то добавляйте:
ConfigureAwait(continueOnCapturedContext: false)
Например:
var data = await HttpClient.GetStringAsync(url).ConfigureAwait(false);
File.WriteAllText(...);
File.WriteAllText
будет отправлен не в SynchronizationContext
, а в TaskScheduler
, который, в свою очередь, перенаправит в ThreadPool
.
Распространён совет дописывать ConfigureAwait(false)
в коде “общего назначения” и не дописывать в коде приложений. Но это не правило и не панацея. Подробнее — в ConfigureAwait
FAQ.
Более явно выразить намерение отвязаться от SynchronizationContext
можно с помощью метода Task.Run
, который всегда отправляет делегат в ThreadPool
:
await Task.Run(async delegate {
// Здесь уже SynchronizationContext.Current == null
await ...;
});
Опасность async void
Сигнатуру async void
приходится использовать для обработчиков событий, потому что в .NET принята конвенция void EventHandler(sender, e)
.
В случае ошибки, Exception
оказывается некуда пристроить, поскольку нет возвращаемого объекта Task
. Поэтому AsyncVoidMethodBuilder
в методе SetException
отправляет ошибку в Task.ThrowAsync
.
Далее, при наличии SynchronizationContext
ошибка обрабатывается там, и тогда всё в порядке. Но если его нет (как например, в ASP.NET Core), то исключение направляется в ThreadPool
и заваливает процесс.
Awaitable и Task
-like
Слово await
можно писать перед любым объектом, у которого есть метод GetAwaiter()
подходящего вида. Такой объект будет называться awaitable.
Например, WinRT API возвращают не Task
, а IAsyncOperation<T>
. Но await
работает и с этим типом, потому что компилятор видит GetAwaiter
из WindowsRuntimeSystemExtensions
.
Если добавить атрибут [AsyncMethodBuilder]
, то это уже будет Task
-like type или generalized async return type, который можно использовать в качестве возвращаемого значения.
Пример такого типа —
ValueTask
Используется для оптимизации внутри самого .NET в тех местах, где нужно беречь память.
struct ValueTask<T>
содержит одно из двух:
- Готовое значение
T
Task<T>
либоIValueTaskSource<T>
Типовой сценарий — кэширующий асинхронный метод, который реально асинхронен только первый раз или после таймаута. Например, первый вызов ходит в сеть, а последующие возвращают запомненный результат.
Другой пример — поток с внутренним буфером — асинхронно считывает большой кусок, а затем синхронно отдаёт небольшие порции.
Если такой метод вызывается очень часто, то жалко каждый раз выделять память в куче под вырожденный объект Task
. Поэтому придумали промежуточную структуру-обёртку.
Но в супер-нагруженных сценариях жалко создавать Task
даже изредка, и хитрые люди придумали IValueTaskSource
— многоразовый объект, по функционалу похожий на TaskCompletionSource
. Он может переключаться из состояния “готов” обратно в “не готов”, и из-за этого на использование ValueTask
налагаются ограничения (CA2012
):
- Сразу делать
await
(не сохранять в переменную, не отдавать в другую функцию). - Не делать
await
более одного раза. - Не трогать
GetAwaiter()
.
Короче, обычному смертному ValueTask
неудобен. Но если стороннее API вернуло такой результат, надо иметь в виду ограничения или сразу преобразовать в обычную Task
через AsTask()
.
Видео: Understanding how to use Task
and ValueTask
.
Отмена и CancellationToken
Чтобы вызвать асинхронную функцию с возможностью прерывания / отмены, создаём CancellationTokenSource
. У этого объекта есть метод Cancel
, играющий роль красной кнопки “Стоп”. Либо задаётся таймаут.
CancellationTokenSource.Token
— это readonly-обёртка, играющая роль сигнала (пейджера-бибикалки), который передаётся каскадно по всей цепочке асинхронных вызовов. Каждый хороший асинхронный метод, должен регулярно проверять этот token на предмет IsCancellationRequested
, а также отдавать его во все дочерние асинхронные вызовы.
Если запросили отмену, нужно кинуть исключение OperationCanceledException
, которое на уровне Task
обрабатывается особым образом — переводит её в состояние Canceled
вместо Faulted
.
See also: How do I cancel non-cancelable async operations?
Критические секции, семафоры и др.
В concurrent коде приходится защищать данные от одновременной модификации из разных потоков (race condition). Такие блоки называют критическими секциями. Их защищают взаимоисключающими блокировками.
В русскоязычных терминах можно запутаться. Взаимоисключающая блокировка — хорошо (mutually exclusive), взаимная блокировка — плохо (deadlock).
При написании современного кода с использованием async/await
пригодным к применению оказывается, по-видимому, только SemaphoreSlim
. Он не привязан к конкретному потоку (no thread-affinity) и поддерживает асинхронное ожидание.
С помощью SemaphoreSlim
можно и защищать критические секции, и делать другие интересные вещи, например асинхронные очереди (как здесь).
Если приходится работать на более низком уровне напрямую с потоками, то к вашим услугам целый невесёлый зоопарк способов синхронизации.
Наиболее популярна (и по совместительству эффективна), конечно же, конструкция lock(obj)
, основанная на классе Monitor
. Но есть и ReaderWriterLock
, и штуки работающие между процессами (Mutex
, Semaphore
который не slim), и всякие WaitHandle
и ResetEvent
…
Атомарность, Interlocked
, volatile
Атомарными в .NET являются чтение и запись значений, которые умещаются в 4 байта — ссылки, bool
, int
, float
. Если несколько потоков состязаются за доступ к такой переменной или такому полю, то значение будет прочитано или записано полностью.
Утилита Interlocked
умеет делать атомарное чтение для long
и double
, а также некоторые другие атомарные операции — сравнение с обменом (CAS), обновление с инкрементом и др.
Модификатор volatile
добавляют к полям “атомарных” типов, чтобы при чтении из разных потоков возвращалось самое последнее записанное значение. Детали реализации: отключение кэшей и других оптимизаций, memory barriers.
Interlocked
и volatile
позволяют писать многопоточный lock-free код, но это инструменты для профессионалов:
“I discourage you from ever making a
volatile
field. Volatile fields are a sign that you are doing something downright crazy" — Eric Lippert
ExecutionContext
и AsyncLocal
Аналог переменных окружения на уровне асинхронных вызовов. Английский термин — ambient values.
Технически, это thread-locals, которые переносятся из текущего потока в тот, где будет выполнен предстоящий потенциально-асинхронный вызов.
С каждым таким переносом возникает новая область видимости — последующие изменения значений будут изолированы от внешних (родительских) контекстов, но продолжат распространяться ниже. Поэтому говорят “flow of the execution context” — течёт вниз, как вода под действием гравитации.
Примеры точек, где это происходит:
Для доступа к значениям используют класс AsyncLocal<T>
.
В AsyncLocal
можно записывать параметры, которые лень каждый раз повторять в аргументах функций — CancellationToken
(обсуждение) или correlation ID для логов. Главное не увлечься и не переизобрести Dependency Injection.
Сам .NET хранит таким образом некоторые глобальные настройки:
В .NET Framework ExecutionContext
достаточно тяжёлый и является одной из причин большого числа аллокаций при выполнении async/await
.
В современном .NET:
Используются оптимизированные immutable структуры и copy-on-write при модификации.
Убрали legacy, связанное с Remoting, Code Access Security и др.
SynchronizationContext
больше не частьExecutionContext
, см.ExecutionContext
vsSynchronizationContext
("This is, I personally believe, a mistake in API design”) и DoesSynchronizationContext
no longer flow withExecutionContext
.
Разное
ApartmentState
— имеет смысл только в Windows при использовании COM Interop.Async constructors — таких не бывает. Всё
await
должны быть снаружи, передnew
. Альтернативы: async static factory method,AsyncLazy
.Data parallelism vs. Task parallelism —
Parallel.ForEach
vs.Task.WhenAll
. SIMD-инструкции — пример параллелизма на уровне данных.IAsyncEnumerable
— async streams или async generators. Позволяют комбинироватьawait
иyield return
, то есть асинхронно генерировать серию значений. Компилируются в ещё более навороченную стейт-машину. Используются вместе с операторомawait foreach
.Parallel Extensions, Parallel Framework (PFX) — общее название для:
- Task Parallel Library (TPL) —
Parallel.For
,Parallel.ForEach
, Dataflow, - PLINQ — ускорение LINQ через partitioning и выполнение в разных потоках.
- Task Parallel Library (TPL) —
SpinLock
/SpinWait
— блокировка/ожидание без обращений к планировщику ОС. Цикл сThread.Yield
внутри. Подходит для коротких остановок, потому что занимает процессор.Sync over async — использование блокирующих вызовов (
Task.Result
,Task.Wait
) на результатахasync
-функций. Убивает асинхронность, расходуетThreadPool
, может привести к deadlock при наличииSynchronizationContext
.System.Collections.Concurrent
— потокобезопасные коллекции для сценариев producer/consumer:ConcurrentStack
иConcurrentQueue
— полностью lock-free, основаны наInterlocked
,volatile
иSpinWait
.ConcurrentDictionary
— гранулярные блокировки на уровне bucket. МетодыGetOrAdd
,AddOrUpdate
не атомарны.ConcurrentBag
— использует thread-locals, допускает дубли, не гарантирует порядок.BlockingCollection
— обёртка надConcurrentQueue
или любой другой реализациейIProducerConsumerCollection
. ОперацияTake
блокирует поток до появления элементов.
System.Threading.Channels
— оптимизированный асинхронный аналогBlockingCollection
. ОперацияReadAsync
ждёт, пока появятся элементы. Видео: Working with Channels in .NET.System.Threading.Overlapped
— что-то Windows-специфичное, связанное с асинхронным I/O.TaskCompletionSource
— всегда создавать сRunContinuationsAsynchronously
(объяснение).[ThreadStatic]
vs.ThreadLocal<T>
— оба реализуют thread-local storage.ThreadStatic
— более старый и неудобный, т.к. инициализируется только на первом потоке.ThreadLocal
— надстройка надThreadStatic
.