Контекст выполнения и контекст синхронизации

При работе с async и await, типы контекста ExecutionContext и SynchronizationContext играют важные, но очень разные роли. Вы узнаете, что делает каждый из них, как каждый из них взаимодействует async/await, и почему SynchronizationContext.Current не проходит через точки ожидания.

Что такое ExecutionContext?

ExecutionContext — это контейнер для сопутствующего состояния, который распространяется с логическим потоком управления вашей программы. В синхронном мире внешние данные находятся в локальном хранилище потоков (TLS), а весь код, выполняемый в данном потоке, видит эти данные. В асинхронном мире логическая операция может начаться в одном потоке, затем приостановиться и возобновиться в другом потоке. Данные потока не следуют автоматически — ExecutionContext заставляет их следовать.

Как выполняется передачa ExecutionContext

Захват ExecutionContext с помощью ExecutionContext.Capture(). Восстановите его во время выполнения делегата с помощью ExecutionContext.Run:

static void ExecutionContextCaptureDemo()
{
    // Capture the current ExecutionContext
    ExecutionContext? ec = ExecutionContext.Capture();

    // Later, run a delegate within that captured context
    if (ec is not null)
    {
        ExecutionContext.Run(ec, _ =>
        {
            // Code here sees the ambient state from the point of capture
            Console.WriteLine("Running inside captured ExecutionContext.");
        }, null);
    }
}
Sub ExecutionContextCaptureExample()
    ' Capture the current ExecutionContext
    Dim ec As ExecutionContext = ExecutionContext.Capture()

    ' Later, run a delegate within that captured context
    If ec IsNot Nothing Then
        ExecutionContext.Run(ec,
            Sub(state)
                ' Code here sees the ambient state from the point of capture
                Console.WriteLine("Running inside captured ExecutionContext.")
            End Sub, Nothing)
    End If
End Sub

Все асинхронные API в .NET, которые разделяют работу — Run, QueueUserWorkItem, BeginRead и другие — захватывают ExecutionContext и используют хранимый контекст для обратного вызова. Этот процесс записи состояния в одном потоке и его восстановление на другом — это то, что означает "поток ExecutionContext".

Что такое SynchronizationContext?

SynchronizationContext — это абстракция, представляющая целевую среду, в которой требуется выполнить работу. Различные платформы пользовательского интерфейса предоставляют собственные реализации:

  • Windows Forms предоставляет WindowsFormsSynchronizationContext, который переопределяет Post, чтобы вызвать Control.BeginInvoke.
  • WPF предоставляет DispatcherSynchronizationContext, который переопределяет Post для вызова Dispatcher.BeginInvoke.
  • ASP.NET (в .NET Framework) предоставил собственный контекст, обеспечивающий доступность HttpContext.Current.

Используя SynchronizationContext вместо маршалинга API, специфичных для фреймворков, можно создавать компоненты, работающие в различных фреймворках пользовательского интерфейса.

static class SyncContextExample
{
    public static void DoWork()
    {
        // Capture the current SynchronizationContext
        SynchronizationContext? sc = SynchronizationContext.Current;

        ThreadPool.QueueUserWorkItem(_ =>
        {
            // ... do work on the ThreadPool ...

            if (sc is not null)
            {
                sc.Post(_ =>
                {
                    // This runs on the original context (e.g. UI thread)
                    Console.WriteLine("Back on the original context.");
                }, null);
            }
        });
    }
}
Class SyncContextExample
    Public Shared Sub DoWork()
        ' Install a custom SynchronizationContext for demonstration
        Dim customContext As New SimpleSynchronizationContext()
        SynchronizationContext.SetSynchronizationContext(customContext)

        ' Capture the current SynchronizationContext
        Dim sc As SynchronizationContext = SynchronizationContext.Current

        ThreadPool.QueueUserWorkItem(
            Sub(state)
                ' ... do work on the ThreadPool ...

                If sc IsNot Nothing Then
                    sc.Post(
                        Sub(s)
                            ' This runs on the original context (e.g. UI thread)
                            Console.WriteLine("Back on the original context.")
                        End Sub, Nothing)
                Else
                    Console.WriteLine("No SynchronizationContext was captured.")
                End If
            End Sub)
    End Sub
End Class

' A minimal SynchronizationContext for demonstration purposes
Class SimpleSynchronizationContext
    Inherits SynchronizationContext

    Public Overrides Sub Post(d As SendOrPostCallback, state As Object)
        ' Queue the callback to run on a thread pool thread
        ThreadPool.QueueUserWorkItem(
            Sub(s)
                d(state)
            End Sub)
    End Sub
End Class

Захват синхронизационного контекста

Когда вы захватываете SynchronizationContext, вы читаете ссылку из SynchronizationContext.Current и сохраняете ее для последующего использования. Затем вы вызовете Post по захваченной ссылке, чтобы запланировать работу обратно в эту среду.

Передача ExecutionContext в сравнении с использованием SynchronizationContext

Хотя оба механизма включают фиксацию состояния из потока, они предназначены для разных целей.

  • Поток ExecutionContext означает запись внешнего состояния и создание того же состояния во время выполнения делегата. Делегат запускается в том месте, где он окажется, — состояние следует за ним.
  • Использование SynchronizationContext означает захват цели планирования и его использование для определения места выполнения делегата. Захваченный контекст определяет, где выполняется делегат.

Короче говоря: ExecutionContext отвечает на вопрос "какая среда должна быть видна?", а SynchronizationContext отвечает на вопрос "где должен выполняться код?"

Взаимодействие конструкций async и await с обоими контекстами

Инфраструктура async/await взаимодействует с обоими контекстами автоматически, но по-разному.

ExecutionContext всегда распространяется

При приостановке метода await (так как средство ожидания IsCompleted возвращает false), инфраструктура захватывает состояние ExecutionContext. При возобновлении метода продолжение выполняется в захваченном контексте. Это поведение встроено в построители асинхронных методов (например, AsyncTaskMethodBuilder) и применяется независимо от того, какой тип ожидаемого элемента вы используете.

SuppressFlow() существует, но это не специфичный для await переключатель, как ConfigureAwait(false). Он подавляет захват данных для работы, которую вы ставите в очередь, пока подавление активно. Он не предоставляетawait параметр модели программирования, который сообщает асинхронным конструкторам методов пропустить восстановление захваченного ExecutionContext для продолжения. Этот дизайн намеренно выбран, потому что ExecutionContext - это поддержка инфраструктурного уровня, которая симулирует семантику локальности потока в асинхронном мире, и большинству разработчиков никогда не нужно об этом задумываться.

Функция ожидания задач захватывает СинхронизациюContext

Ожидатели для Task и Task<TResult> включают поддержку SynchronizationContext. Построители асинхронных методов не включают эту поддержку.

await При выполнении задачи:

  1. Проверка ожидателя SynchronizationContext.Current.
  2. Если контекст существует, ожидающий улавливает его.
  3. По завершении задачи продолжение будет размещено обратно в этот захваченный контекст, а не на завершенном потоке или пуле потоков.

Это поведение, как await "возвращает вас обратно к тому, где вы были". Например, возобновление работы на потоке пользовательского интерфейса в настольном приложении.

Конструкция ConfigureAwait управляет захватом SynchronizationContext

Если вы не хотите поведение маршалинга, вызовите ConfigureAwait с использованием false.

await task.ConfigureAwait(false);

Если задано значение continueOnCapturedContext, false, средство ожидания не проверяет наличие SynchronizationContext, и продолжение выполняется там, где бы ни выполнялась задача (обычно в потоке пула потоков). Авторы библиотек должны использовать ConfigureAwait(false) при каждом использовании await, если только код не должен возобновиться в захваченном контексте.

SynchronizationContext.Current не переносится через await-выражения

Эта точка является наиболее важной: SynchronizationContext.Currentне проходит между точками ожидания. Построители асинхронных методов в среде выполнения используют внутренние перегрузки, которые явно подавляют распространение SynchronizationContext как часть ExecutionContext.

Почему это важно

Технически, SynchronizationContext — один из вложенных контекстов, которые ExecutionContext может содержать. Если контекст перетекал как часть ExecutionContext, код, выполняемый в потоке пула потоков, может воспринимать пользовательский интерфейс SynchronizationContext как Current, не потому что этот поток является потоком пользовательского интерфейса, а потому что контекст "утёк" через поток. Это изменение изменит значение SynchronizationContext.Current из "среды, в которой я в настоящее время находится" на "окружающую среду, которая исторически существовала где-то в цепочке вызовов".

Пример Task.Run

Рассмотрим код, который передает работу в пул потоков. Поведение потока пользовательского интерфейса, описанное здесь, применяется только, если SynchronizationContext.Current имеет ненулевое значение, например, в приложении пользовательского интерфейса.

static class TaskRunExample
{
    public static async Task ProcessOnUIThread()
    {
        // This method is called from a thread with a SynchronizationContext.
        // Task.Run offloads work to the thread pool.
        string result = await Task.Run(async () =>
        {
            string data = await DownloadAsync();
            // Compute runs on the thread pool, not the original context,
            // because SynchronizationContext doesn't flow into Task.Run.
            return Compute(data);
        });

        // Back on the original context (the continuation is posted back).
        Console.WriteLine(result);
    }

    private static async Task<string> DownloadAsync()
    {
        await Task.Delay(100);
        return "downloaded data";
    }

    private static string Compute(string data) =>
        $"Computed: {data.Length} chars";
}
Class TaskRunExampleClass
    Public Shared Async Function ProcessOnUIThread() As Task
        ' If a SynchronizationContext is present when this method starts,
        ' the outer await captures it. Task.Run still offloads work to the thread pool.
        Dim result As String = Await Task.Run(
            Async Function()
                Dim data As String = Await DownloadAsync()
                ' Compute runs on the thread pool, not the caller's context,
                ' because SynchronizationContext doesn't flow into Task.Run.
                Return Compute(data)
            End Function)

        ' Resume on the captured context, if one was available.
        Console.WriteLine(result)
    End Function

    Private Shared Async Function DownloadAsync() As Task(Of String)
        Await Task.Delay(100)
        Return "downloaded data"
    End Function

    Private Shared Function Compute(data As String) As String
        Return $"Computed: {data.Length} chars"
    End Function
End Class

Как правило, SynchronizationContext.Current в консольном приложении - это null, поэтому фрагмент кода не возобновляется в настоящем потоке пользовательского интерфейса. Вместо этого фрагмент кода концептуально иллюстрирует правило: если пользовательский интерфейс SynchronizationContext передается через await точки, то await внутри передаваемого делегата будет воспринимать этот контекст пользовательского интерфейса как Current. Продолжение после await DownloadAsync() затем будет возвращаться в поток пользовательского интерфейса, чтобы Compute(data) выполнялся в потоке пользовательского интерфейса, а не в пуле потоков. Это поведение сводит на нет цель Task.Run вызова.

Поскольку среда выполнения подавляет SynchronizationContext поток в ExecutionContext, await внутри Task.Run не наследует внешний контекст пользовательского интерфейса, и выполнение продолжения продолжается в пуле потоков, как и должно.

Сводка

Аспект Контекст выполнения SynchronizationContext
Purpose Переносит контекстное состояние через асинхронные границы Представляет целевой планировщик (где должен выполняться код)
Захвачено Конструкторы асинхронных методов (инфраструктура) Ожидатели задач (await task)
Потоки в ожидании? Да, всегда Нет—записано и размещено, а не передано потоком
API супрессии ExecutionContext.SuppressFlow (расширенный; редко требуется) ConfigureAwait(false)
Объем Все объекты, которые можно ожидать Task и Task<TResult> (пользовательские ожидатели могут добавлять аналогичную логику)

См. также