Шпаргалка по многопоточности в .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
asyncteam at Microsoft did consider writing their own ’Future’ type that would represent an asynchronous operation, but theTasktype was too tempting” — A Bit of Task History
И правда, в исходниках .NET класс Task<T> объявлен в файле Future.cs
С одной стороны, получили унификацию. С другой — путаницу, потому что Task взяли вместе со всеми флагами, свойствами, конструкторами, многие из которых не имеют смысла в контексте async/await:
“Developers who have not used
Taskin the past face a bewildering selection ofTaskmembers, almost all of which should not be used in theasyncworld” — A Bit of Task History
“…caused a lot of developers to incorrectly attempt to use the
AttachedToParentflag withasynctasks” — 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/awaitgenerates 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
awaitkeyword 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
volatilefield. 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, см.ExecutionContextvsSynchronizationContext("This is, I personally believe, a mistake in API design”) и DoesSynchronizationContextno longer flow withExecutionContext.
Разное
ApartmentState— имеет смысл только в Windows при использовании COM Interop.Async constructors — таких не бывает. Всё
awaitдолжны быть снаружи, передnew. Альтернативы: async static factory method,AsyncLazy.Data parallelism vs. Task parallelism —
Parallel.ForEachvs.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.