Шпаргалка по многопоточности в .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 the Task 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 of Task members, almost all of which should not be used in the async world” — A Bit of Task History


“…caused a lot of developers to incorrectly attempt to use the AttachedToParent flag with async 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.

По конвенции, имена асинхронных функций должны заканчиваться постфиксом AsyncToListAsync(), GetValueAsync() и т.д.

async/await “заразен” — если функция стала асинхронной, то все, кто её вызывают слёзы проливают тоже становятся асинхронными.

В старых версиях .NET Framework, async/await замусоривал память. В современном .NET сделали множество оптимизаций.

Мельчайшие подробности можно почерпнуть из бесконечно-длинной статьи How Async/Await Really Works in C#.

А для самых любопытных — можно сравнить с TypeScript:

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:


Разное

  • ApartmentState — имеет смысл только в Windows при использовании COM Interop.

  • Async constructors — таких не бывает. Всё await должны быть снаружи, перед new. Альтернативы: async static factory method, AsyncLazy.

  • Data parallelism vs. Task parallelism — Parallel.ForEach vs. Task.WhenAll. SIMD-инструкции — пример параллелизма на уровне данных.

  • IAsyncEnumerableasync 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 и выполнение в разных потоках.
  • 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.