Перед тем как перейти к рассмотрению предложенных изменений в работе асинхронности в C#, давайте разберемся - зачем все это нужно, как устроено сейчас и какие имеются проблемы, раз разработчики начали искать способы улучшения существующей логики.

Для начала давайте поймем, а зачем вообще нам нужна асинхронность и какие проблемы она решает?

Представим какой-то абстрактный web-api, который ходит за данными в БД. При однопоточном синхронном выполнении следующий запрос может быть обработан только тогда, когда полностью был выполнен предыдущий, что неэффективно, т.к. вся нагрузка ложится только на 1 ядро процессора, а остальные простаивают.

Однопоточное исполнение
Однопоточное исполнение

Современные процессоры обладают множеством как физических ядер, так и логических (HyperThreading/SMT). Чтобы эффективнее использовать процессор и сделать наше приложение многопоточным давайте на каждый запрос запускать новый поток операционной системы, что позволит использовать несколько ядер процессора и не ждать предыдущего запроса для обработки нового.

Многопоточное исполнение
Многопоточное исполнение

Но тут мы сталкиваемся с другой проблемой: создание нового потока является достаточно дорогой операцией. При создании нового потока (для x86_64/linux):

  • делается syscall к ядру ОС, следовательно, происходит переключение в kernel mode

  • Аллоцируется необходимая память:

    • Структуры ядра для управления потоком (task_struct и связанные данные).

    • TLS (Thread Local Storage) - для хранения локальных переменных потока

    • TCB (Thread Control Block) - пространство для хранения служебной информации о потоке (Thread ID, scheduling, signal mask)

    • kernel stack (16 Kb, 2 страницы, которые чаще всего 8 Kb)

    • стек потока (8 Mb, по умолчанию, настраивается через pthread_attr_setstacksize() или ulimit -s)

Последовательность вызовов при создании потока
Последовательность вызовов при создании потока

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

Чтобы не мучать планировщик кучей потоков, давайте создавать фиксированные набор потоков изначально и просто выдавать им задачи для выполнения.

Пул потоков
Пул потоков

Это можно проиллюстрировать следующим кодом:

while (true) 
{
	var task = tasksQueue.GetBlocking();
	
	task.Execute();
}

В целом это неплохой вариант, но тут мы подходим к вопросу того, что большинство наших приложений занимаются не числодробилкой (CPU-bound задачами), а IO-bound - взаимодействие по сети (походы в БД, HTTP/gRPC запросы, Kafka/RabbitMQ и так далее), работой с диском (чтение/запись на диск). Например следующий код для получения пользователя по Id из БД будет выглядеть следующим образом:

public void SomeMethod(Guid userGuid)
{
	// do something

	var user = usersRepository.GetUser(userGuid);

	usersService.DoSomethingWithUser(user);
}
Приостановка потока
Приостановка потока

При вызове блокирующей операции (обращение к диску или сети), под капотом происходит syscall, который прерывает выполнение потока. Ядро при этом помечает поток как невозможный для выполнения, откладывает его и переключает на выполнение другой поток. Затем, когда ядро при помощи прерывания получает информацию (например ответ на сетевой запрос), которая позволяет продолжить выполнение потока, он помещается в очередь для выполнения, и спустя некоторое время поток продолжает свое выполнение.

В итоге мы получаем, что часть времени наш поток просто ожидает какой-то информации и простаивает, также блокировки потоков и прерывания для его возобновления нагружают планировщик (interrupt storms), приводят к частым переключениями контекста (как между потоками, так и context switches) и потере кешей процессора.

Ситуация сейчас

Так мы, собственно, и подошли к асинхронности, при которой задачи для выполнения отделенны от потока. Один поток может переключаться между разными задачами, если задача не может быть сейчас продолжена. Далее я лишь краткой рассмотрю текущую реалиазцию, т.к. есть куда более подробные и качественные статьи, рассказывающие об устройстве асинхронности в текущей ее реализации.

Если сделать предыдущий пример асинхронным, то это будет выглядеть так:

public async Task HelloWorldAsync()
{
    Console.Write("Text: ");

    await _helloWorldService.HelloAsync();
    
    Console.Write(", ");
    
    await _helloWorldService.WorldAsync();
    
    Console.WriteLine("!");
}

Мы просто добавили модификатор async в сигнатуру и await перед вызовом метода, для получения пользователя, но что за этим красивым интерфейсом спрятано?

Сгенерированный код
[NullableContext(1)]
[Nullable(0)]
public class AsyncAwaitDemo
{
  private readonly HelloWorldService _helloWorldService;

  [AsyncStateMachine(typeof (AsyncAwaitDemo.<HelloWorldAsync>d__1))]
  [DebuggerStepThrough]
  public Task HelloWorldAsync()
  {
    AsyncAwaitDemo.<HelloWorldAsync>d__1 stateMachine = new AsyncAwaitDemo.<HelloWorldAsync>d__1();
    stateMachine.<>t__builder = AsyncTaskMethodBuilder.Create();
    stateMachine.<>4__this = this;
    stateMachine.<>1__state = -1;
    stateMachine.<>t__builder.Start<AsyncAwaitDemo.<HelloWorldAsync>d__1>(ref stateMachine);
    return stateMachine.<>t__builder.Task;
  }

  public AsyncAwaitDemo()
  {
    this._helloWorldService = new HelloWorldService();
    base..ctor();
  }

  [CompilerGenerated]
  private sealed class <HelloWorldAsync>d__1 : 
  /*[Nullable(0)]*/
  IAsyncStateMachine
  {
    public int <>1__state;
    public AsyncTaskMethodBuilder <>t__builder;
    [Nullable(0)]
    public AsyncAwaitDemo <>4__this;
    private TaskAwaiter <>u__1;

    public <HelloWorldAsync>d__1()
    {
      base..ctor();
    }

    void IAsyncStateMachine.MoveNext()
    {
      int num1 = this.<>1__state;
      try
      {
        TaskAwaiter awaiter1;
        int num2;
        TaskAwaiter awaiter2;
        if (num1 != 0)
        {
          if (num1 != 1)
          {
            Console.Write("Text: ");
            awaiter1 = this.<>4__this._helloWorldService.HelloAsync().GetAwaiter();
            if (!awaiter1.IsCompleted)
            {
              this.<>1__state = num2 = 0;
              this.<>u__1 = awaiter1;
              AsyncAwaitDemo.<HelloWorldAsync>d__1 stateMachine = this;
              this.<>t__builder.AwaitUnsafeOnCompleted<TaskAwaiter, AsyncAwaitDemo.<HelloWorldAsync>d__1>(ref awaiter1, ref stateMachine);
              return;
            }
          }
          else
          {
            awaiter2 = this.<>u__1;
            this.<>u__1 = new TaskAwaiter();
            this.<>1__state = num2 = -1;
            goto label_9;
          }
        }
        else
        {
          awaiter1 = this.<>u__1;
          this.<>u__1 = new TaskAwaiter();
          this.<>1__state = num2 = -1;
        }
        awaiter1.GetResult();
        Console.Write(", ");
        awaiter2 = this.<>4__this._helloWorldService.WorldAsync().GetAwaiter();
        if (!awaiter2.IsCompleted)
        {
          this.<>1__state = num2 = 1;
          this.<>u__1 = awaiter2;
          AsyncAwaitDemo.<HelloWorldAsync>d__1 stateMachine = this;
          this.<>t__builder.AwaitUnsafeOnCompleted<TaskAwaiter, AsyncAwaitDemo.<HelloWorldAsync>d__1>(ref awaiter2, ref stateMachine);
          return;
        }
label_9:
        awaiter2.GetResult();
      }
      catch (Exception ex)
      {
        this.<>1__state = -2;
        this.<>t__builder.SetException(ex);
        return;
      }
      this.<>1__state = -2;
      this.<>t__builder.SetResult();
    }

    [DebuggerHidden]
    void IAsyncStateMachine.SetStateMachine(IAsyncStateMachine stateMachine)
    {
    }
  }
}

Начнем с того, что весь асинхронный метод при компиляции разбивается на части:

public async Task HelloWorldAsync()
{
	// state -1
    Console.Write("Text: ");

    await _helloWorldService.HelloAsync();
    
	// state 0
    Console.Write(", ");
    
    await _helloWorldService.WorldAsync();
    
	// state 1
    Console.WriteLine("!");
}

Сам метод при компиляции заменяется на нечто, совершенное непохожее на его изначальный вид:

[AsyncStateMachine(typeof (AsyncAwaitDemo.<HelloWorldAsync>d__1))]
[DebuggerStepThrough]
public Task HelloWorldAsync()
{
  AsyncAwaitDemo.<HelloWorldAsync>d__1 stateMachine = new AsyncAwaitDemo.<HelloWorldAsync>d__1();
  stateMachine.<>t__builder = AsyncTaskMethodBuilder.Create();
  stateMachine.<>4__this = this;
  stateMachine.<>1__state = -1;
  stateMachine.<>t__builder.Start<AsyncAwaitDemo.<HelloWorldAsync>d__1>(ref stateMachine);
  return stateMachine.<>t__builder.Task;
}

Здесь:

  • Создается стейт-машина

  • Создается и устанавливается билдер

  • Устанавливается контекст this

  • Запускается стейт-машина и возвращается таска от нее

В конце возвращается таска от AsyncTaskMethodBuilder , значение результата для таски устанавливается в конце выполнения стейт-машины.

Итак, для проворачивания стейт-машины используется метод IAsyncStateMachine.MoveNext и 1-й раз это происходит при вызове AsyncTaskMethodBuilder.Start . Соответсвенно, в нашем примере, в консоль пишется сообщение "Text: ", после этого вызывается метод HelloAsync() , получается его awaiter и проверяется не завершился ли он (например в случае Task.CompletedTask ). Если же таска еще не завершилась, то обновляется стейт (устанавливается 0) и у билдера вызывается метод AwaitUnsafeOnCompleted куда передается awaiter и стейт-машина. Если говорить очень грубо, то под капотом этого метода для awaiter устанавливается continuation (продолжение), в котором вызывается MoveNext стейт-машины.

Разобрав 1 стейт становится понятно, что с остальными стейтами алгоритм тот же. При этом в конце выполенения стейт-машины ставится стейт = -2. Если было перехвачено исключение, то оно устанавливается в Task, если же все завершилось штатно, то устанавливается результат через SetResult().

async2

Предыстория async2. Green threads.

Первоначально, в 2023 году, был проведен эксперимент по поддержке зеленых потоков (green threads). Инициирован он был всвязи с релизом Project Loom в JVM, реализующим эти самые зеленые потоки. Основной задачей задачей эксперимента было понять, наскольк сложно добавить green thread'ы в dotnet и с какими трудностями придется столкнуться.

Green threads (зеленые потоки) - реализация потоков в user-space, управление их выполнением и приостановкой происходит не планировщиком ядра ОС, а рантаймом.

Текущая реализация асинхронности в C# представляет собой корутины, т.е. функции, которые работают кооперативно и могут уступать поток другим корутинам для исполнения. Разница в уровнях абстракции:

  • Корутины оперируют функциями, которые переключаются на нативном потоке ОС

  • Зеленые потоки реализуют собственный механизм многопоточности, управляемый рантаймом. Для функций это выглядит прозрачно, будто бы они исполняются на обычном потоке

Т.к. для функций зеленые потоки выглядят обычным потоками, то сами фукнции могут писаться как самые обыкновенные синхронные функции без дополнительных модификаторов (async) и точек остановки (await). Это позволяет избавиться от проблемы цветных функций, но с начала поговорим, а в чем, сообственно, проблема? Рассмотрим следующий пример кода:

// blue
public static int GetNumber()
{
	return 1;
}

// red
public static async Task<int> GetNumberAsync()
{
	var number = GetNumber();
	var inc = await GetIncrease();
	
	return numb + inc;
}

Здесь у нас есть 2 метода - синхронный (синий) и асинхронный (красный). Мы вызываем синхронный метод из асинхронного, но наоборот мы уже не можем сделать (ну вообще то можем, но тогда заблокируем поток и нужно быть крайне осторожными с обработкой ошибок). Получается следующая матрица:

Caller\Callee

sync

async

sync

да

нет

async

да

да

Также для вызова асинхронных функций еще и используется отдельная семантика, мы должны знать, что результат мы получим когда-нибудь в будущем и чтобы "подождать" его делаем "await".

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

Сравнение производительности для простого ASP.NET Core приложения показало незначительную просадку производительности (надо учитывать, что это был лишь небольшой эксперимент, где не проводилось значительной оптимизации):

ASP.NET Plaintext

Async

Green threads

Requests per second

178,620

162,019

При этом эксперимент подсветил несколько проблем:

  • Сложность с вызовом асинхронных методов (TAP), которая приводит к использованию sync-over-async паттерна

  • Снижение производительности при интеропе (P/Invoke). Время вызова 100КК P/Invoke операций увеличилось с 300ms до 1800ms.

  • Сложность взаимодействия с различными технологиями для защиты кода, например shadow stack

Итогом эксперимента стал следующий вывод: сделать green-thread равными, или даже быстрее, текущего async можно. Но это не поможет избавиться от проблем с производительностью при определенных сценариях. Также добавление еще одной парадигмы негативно скажется на языке и потребует механизмов для обеспечения совместимости между ними. В конечном итоге было принято решение направить усилия на поддержку async на уровне runtime, чем тратить значительные усилия на добавелние очередной, абсолютно новой парадигмы в язык.

Runtime handled asyncs

Итак, как мы увидели ранее в текущей реализации async/await, сам рантайм ничего о них не знает, для него существуют только коллбеки, которые добавляются в очередь на выполнение при установке результата асинхронной операции.

Рассмотрим теперь непосредственно реализацию поддержки асинхронности на уровне рантайма. Далее, для простоты, это будет обозначаться просто как async2.

async2 - эксперимент, в рамках которого исследовалась возможность улучшения асинхронной модели программирования в .net путем добавления механизмов для их обработки непосредственно в runtime. Описанные цели звучат следующим образом:

  • Выяснить, являются ли асинхронные ĸонечные автоматы, генерируемые средой выполнения, существенно более производительными, чем существующая модель асинхронных ĸонечных автоматов.

  • Выяснить, можно ли реализовать вариант асинхронности с неĸоторыми изменениями в семантиĸе, ĸоторый останется совместимым с существующим ĸодом, написанным в оригинальном стиле.

В рамках эксперимента был введен новый модификатор метода - async2 , но это всего лишь временное решение для целей эксперимента. В дальнейшем, в случае реализации, будет по-прежнему использоваться async . Также была сохранена необходимость возврата оберточного типа Task/ValueTask как результата асинхронной операции.

Совместимость с async1

Важной частью эксперимента было обеспечение совместимости с текущей реализацией async/await, которая сейчас, в основном, представлена TAP (Task-based Asynchronous Pattern). В случае полноценной реализации async2 в будущих версиях dotnet, необходима возможность бесшовного вызова async2 → async1 и наоборот async1 → async2. Фрагментация кода на еще одну категорию async1/async2 внесет неразбериху и отсрочит принятие новой концепции разработчиками.

По сути, async1 и async2 можно представить как 2 мира, между которыми необходимо навести мосты, соединив их. Сообственно, этими мостами являются thunk-фукнции, которая обеспечивают связывание разных подходов между собой, обеспечивая их совместную работу.

Давайте начнем с вызова async1 → async2 (где async1-функция вызывает async2-функцию):

Как только рантайм видит такой вызов, то он на лету генерирует "функцию-прокладку" (thunk), которая:

  • Сохраняет текущий ExecutionContext и SynchronizationContext

  • Инициирует вызов целевой async2-функции

  • Конвертирует её нативный результат в объект Task/ ValueTask

// Псевдокод thunk для Task<int> -> async2 int
Task<int> ThunkAsync(int param)
{
    var state = new RuntimeTaskState<int>();
    try 
    {
        int result = TargetAsync2Method(param);
        return state.FromResult(result);
    }
    catch (Exception ex)
    {
        return state.FromException(ex);
    }
}

В случае приостановки целевой async2 функции, прокладка упаковывает ее в Task, который возвращается выше и ожидается классической стейт-машиной.

Вызов async2 → async1 (async2-функция вызывается async1-фукнцию):

В данной случае также используется прокладка, для обеспечения совместимости вызовов, которая:

  • Адаптирует Task.GetAwaiter() к runtime-примитивам

  • Использует низкоуровневые методы AwaitAwaiterFromRuntimeAsync

Это можно представить в виде следующего кода:

public async2 int GetDataAsync()
{
    var awaiter = _httpClient.GetAsync(...).GetAwaiter();
    if (!awaiter.IsCompleted)
    {
        RuntimeHelpers.AwaitAwaiterFromRuntimeAsync(awaiter);
    }
    return awaiter.GetResult();
}

При этом все такие некоторые изменения были допущены, которые могут повлиять на поведение:

  • ExecutionContext В отличие от async1, где он автоматически сохраняется при входе в async метод, в async2 контекст распространяется вверх по стеку. Это означает:

    • Изменения AsyncLocal<T> внутри async2 видны вызывающему коду

    async Task DoOuterAsync() 
    {
        AsyncLocal<string> local = new AsyncLocal<string> { Value = "Outer" };
        await DoInnerAsync();
        Console.WriteLine($"DoOuter: {local.Value}"); // async1: "Outer", async2: "Inner"
    }
    
    async Task DoInnerAsync() 
    {
        AsyncLocal<string>.Value = "Inner";
        await Task.Yield();
    }
    • Для эмуляции поведения async1 добавлен режим CaptureContext, но он снижает производительность на 20-30%

  • SynchronizationContext Управление через атрибут [ConfigureAwait]:

    [assembly: ConfigureAwait(false)]

При этом в async2 используется контекст на момент возврата управления, а не на момент вызова метода, как в async1. В целом, как считают разработчики, это не должно быть критично.

При этом не все возможности async2 в равной степени хорошо удается переложить на async1 для поддержания совместимости. Например, в подходе с разверткой стека есть возможность для передачи в асинхронные функции byref-like параметров. Но при попытке пересечения границы async1 ⟷ async2 такими параметрами thunk-функция будет выкидывать исключение InvalidProgramException

Также, пока, по-умолчанию, async2-методы скрыты от рефлексии Type.GetMethods() и найти их можно только добавив флаг BindingFlags.Async2Visible.

Stack unwind (tasklets)

В данном подходе используется тот факт, что функция сама по себе уже является конечным автоматом, где индексом возобновления - регистр IP (Instruction Pointer), а текущее состоянием - стековый кадр и сохраненные регистры. Для реализации данного прототипа было внесено несколько изменений в JIT:

  1. Запрет связывания InlinedCallFrames с цепочĸой стэкфреймов между приостановками (across suspends) (или отĸлючить инлайнинг p/invoke в этих методах)

  2. Передача указателей на кадры как ByRef

  3. Сообщать все указатели на локальные переменные как ByRef

  4. Принудительно считать AwaitAwaiterFromRuntimeAsync методом async2 , даже если он не помечен ĸаĸ таĸовой (из-за ограничений ĸомпилятора).

Соответсвенно получается, что для приостановки/возобновления необходим стек приостановленной функции. Для этого его необходимо размотать и сохранить (вместе с локальными переменными на стеке и регистрами).

Этим занимается метод AwaitAwaiterFromRuntimeAsync , который захватывает каждый stack frame от себо до функции ThunkAsync или ResumptionFunc и упаковывает их в Tasklet, совокупность которых представляет собой связанный список. Для иллюстрации можно представить Tasklet следующим образом.

struct Tasklet
{
    public IntPtr StackPointer; // Указатель на стек (SP)
    public IntPtr InstructionPointer; // Указатель на инструкцию (IP)
    public object[] LocalVariables; // Локальные переменные
    public object[] GCReferences; // Ссылки на объекты в куче
}

Tasklet'ы региструются в GC, но обрабатываются особым образом для учета ByRef указателей. После размотки стека Tasklet'ы и awaiter сохраняются в Thread Local переменную для последующего использования функциями ThunkAsync/ResumptionFunc .

Что же такое ThunkAsync/ResumptionFunc? Как уже говорилось выше ThunkAsync это функция, которая является прокладкой и обеспечивает совместимость с async1. В частности, данная функция в подходе с размоткой стека обеспечивает возврат Task/ValueTask , что необходимо только для обеспечения совместимости, т.к. сам подход позволяет избавиться от этих типов-оберток. Это позволило бы избавиться от красных/синих функций, но как уже говорилось про green thread, такие изменения в модели слишком болезнены для их введения. Сама функция может быть описана примерно так (это не значит, что она так реализована, лишь приблизительное описание):

Task<ReturnType> ThunkAsync(ParameterType param1, ParameterType2 param2, ...)
{
    // Вариант этого вспомогательного типа будет определен для каждого типа `Task`/`ValueTask`/`Task<TResult>`/`ValueTask<TResult>`
    System.Runtime.CompilerServices.RuntimeTaskState<ReturnType> runtimeTaskState = new;

    // Если Task параметризован, будет тип возвращаемого значения для работы.
    ReturnType result;

    runtimeTaskState.Push();
    try
    {
        try
        {
            // ПРИМЕЧАНИЕ: есть особый случай для методов экземпляров типов значений. Для них
            // переходник создаст упакованный экземпляр значения `this`, а затем вызов метода
            // на этом экземпляре
            result = TargetMethod(param1, param2, ...);
        }
        catch (Exception ex)
        {
            return runtimeTaskState.FromException(ex);
        }
        return runtimeTaskState.FromResult(result);
    }
    finally
    {
        runtimeTaskState.Pop();
    }
}

ResumptionFunc - функция для возобновления приостановленных операций. Диспетчер берет ĸоллеĸцию Tasklet и возобновляет тот, ĸоторый находится на вершине стеĸа, и если он возвращается, то снимает его со стеĸа и выполняет следующий. Также структуры поддерживаются в том виде, чтобы обходчик стека EH (Exception Handler) мог найти список стековых кадров, которые все еще удерживаются Tasklet'ом.

static void ResumptionFunc(Stack<Tasklet> tasklets)
{
	while ((var tasklet = tasklets.Pop()) != null)
	{
		// Восстановление stack frame'а
		RestoreStackPointer(tasklet.StackPointer);
		RestoreInstructionPointer(tasklet.InstructionPointer);
		RestoreLocalVariables(tasklet.LocalVariables);
		RestoreGCReferences(tasklet.GCReferences);
		
		// Передача управления
		ContinueExecution();
	}
}

JIT-compiled state machine (Continuations)

В данном прототипе генерация стейт-машины перекладывается с компилятора на рантайм. Когда функция async2 ожидает другую функцию async2 , JIT генерирует код приостановки для захвата текущего состояния в продолжении. Кроме того, JIT генерирует код для каждой из этих точек приостановки, чтобы иметь возможность возобновить выполнение из созданного продолжения.

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

internal sealed unsafe class Continuation
{
	public Continuation? Next;
	public delegate*<Continuation, Continuation?> Resume;
	public uint State;
	public CorInfoContinuationFlags Flags;
	public byte[]? Data;
	public object[]? GCData;
}

В рантайме изначальный код модифицируется, чтобы принимать дополнительный параметр Continuation , который представляет собой текущее состояние функции при возобновлении. Если функция была вызвана 1-й раз, то continuation отсутствует. Также рантаймом генерируется вспомогательный метод, который используется для возобновления.

Для реализации данного подхода рантаймом предоставляются 2 следующих метода, использование которых мы увидим далее:

// Получить Continuation, созданный вызываемым методом
[Intrinsic]
internal static Continuation? Async2CallContinuation() => null;

// Приостановить функцию с установкой переданного Continuation
[Intrinsic]
private static void SuspendAsync2(Continuation continuation) => throw new UnreachableException();

Рассмотрим на следующем примере:

static async2 Task<int> Foo(int a, int b)
{
	if (a == null || b == null)
	{
		return 0;
	}

	await Task.Yield();

	int result = a + b;

	await Task.Yield();

	return result;
}

JIT при компиляции данного кода трансформирует его. Условно представить скомпилированный вид можно в следующем виде, естественно сам JIT оперирует не C#, а уже напрямую машинными инструкциями, но для удобства восприятия, представим так:

static int Foo(Continuation? continuation, int a, int b)
{
    if (continuation == null)
    {
        // Первый вызов метода
        if (a == 0 || b == 0)
        {
            return 0;
        }

        var awaiter1 = Task.Yield().GetAwaiter();
        if (!awaiter1.IsCompleted)
        {
            // Приостановка
            var continuation = new Continuation
            {
                State = 1,
                Data = SaveStackAsBytes(), // Сохранение стека
                Resume = &IL_STUB_AsyncResume_Foo // Указатель на метод возобновления
            };
           	
			// обрабатывает continuation и устанавливает ссылку
			// на continuation в регистр RCX (для amd64)
			StubHelpers.SuspendAsync2(continuation);

			return default;
        }

        // синхронное продолжение
        awaiter1.GetResult();
        int result = a + b;

        var awaiter2 = Task.Yield().GetAwaiter();
        if (!awaiter2.IsCompleted)
        {
            // Приостановка
            var continuation = new Continuation
            {
                State = 2,
                Data = SaveStackAsBytes(),
                Resume = &IL_STUB_AsyncResume_Foo
            };
            
			StubHelpers.SuspendAsync2(continuation);
			
			return default;
        }

        // синхронное продолжение
        awaiter2.GetResult();
		
        return result;
    }
    else
    {
        // Возобновление выполнения
        switch (continuation.State)
        {
			// Возобновление после первого await
            case 1:
                // восстанавливаем значения на стеке и ссылки на кучу
                RestoreStack(continuation);

                int result = a + b;

                // Второй await
                var awaiter2 = Task.Yield().GetAwaiter();
                if (!awaiter2.IsCompleted)
                {

            		// Приостановка
            		var continuation = new Continuation
            		{
                		State = 2,
                		Data = SaveStackAsBytes(),
                		Resume = &IL_STUB_AsyncResume_Foo
            		};
                    
					StubHelpers.SuspendAsync2(continuation);
					
					return default;
                }

                // синхронное продолжение
                awaiter2.GetResult();

                return result;
			// Возобновление после второго await
            case 2:
                
                RestoreStack(continuation);

                return result;
        }
    }
}

В коде можно заметить, что в поле Resume у Continuation передается указатель на IL_STUB_AsyncResume_Foo. Это функция-прокладка, которая также генерируется рантаймом. Ее можно представить следующим образом:

static Continuation? IL_STUB_AsyncResume_Foo(Continuation continuation)
{
	delegate*<Continuation, int, int, int> foo = &Foo;
	int result = foo(continuation, 0, 0);

	// Получить продолжение, возвращенное предыдущим вызовом функции async2
	Continuation? newContinuation = StubHelpers.Async2CallContinuation();

	if (newContinuation == null) // значит метод завершился
	{
		// Запись значения для выполнения следующего continuation
		Unsafe.Write(ref continuation.Next.Data[index], result);
	}
	
	return newContinuation
}

Этот код кастит кастит указатель на функцию в делегат, который принимает 1-м параметром Continuation, т.к. прослойка вызывается только при возобновлении функции, то здесь он всегда не null.

Далее делается вызов функции, в ходе которого выполняется синхронная часть кода. При этом аргументами функции передаются значения по-умолчанию, т.к. актуальные данные будут восстановлены на стек из Continuation. При этом передавать значения все равно необходимо для того, чтобы stack frame был настроен должным образом и позволял корректно восстановить значения на него.

Если Foo необходимо опять приостановиться, то ссылка на Continuation устанавливается в регистр RCX (для amd64). Stub получает продолжение через метод, предоставляемый рантаймом - Async2CallContinuation . Она возвращает нам Continuation который был создан (или не создан) вызываемой функцией. Если newContinuation == null , значит функция завершилась и ее не надо приостанавливать.

Здесь есть 2 варианта:

  • newContinuation == null : функция завершилась и ее не надо приостанавливать. В таком случае функция вернула актуальное значение, которое записывается в следующий continuation

  • newContinuation != null : функции необходимо опять приостановиться. Тогда Stub'ом возвращается новое продолжение, которое будет передано аргументом в Stub при следующем возобновлении.

Поскольку Continuation является нормальным параметром, для передачи продолжения в целевую функцию не требуется отдельная встроенная функция. Вместо этого заглушка возобновления вызывает цель через calli с сигнатурой, которая включает продолжение, так же как работают заглушки создания экземпляров IL.

Поддержка span/byref

Текущая ситуация заключается в том, что C# не позволяет использовать byref и byref-like типы в асинхронных функциях в любой форме - будь то параметры, локальные переменные или span . Причина этого ограничения - невозможность захвата byref как display-типов. Для последовательности все использование запрещено, даже если оно не требует захвата.

Есть несколько случаев, когда C# поддерживает временные byref путем разложения выражения, производящего byref , на составные части и захвата частей и повторного воспроизведения в местах использования.

staticArray[i].Field += await Somehting(); 

Например это позволяет обработать часть случаев, но кодогенерация далека от идеальной.

В async2 поддержка span и byref не является строгим "нет", но с оговорками. Есть примерно два вида byref , с которыми асинхронная реализация должна будет иметь дело.

  1. byref , ссылающиеся на кучу, сложны, потому что отчетность GC о byref поддерживается только на стеке. Любое альтернативное хранилище должно будет реализовать схему для отчетности сохраненных переменных для целей маркировки/обновления. Основная проблема здесь - производительность. Ожидается, что количество задач может легко достигать тысяч одновременно, алгоритмы O(n) неизбежно приводят к паузам GC. Есть способы смягчить это, некоторые из которых были исследованы и измерены в рамках этого эксперимента.

  2. byref , ссылающимся на стек не нужно сообщать в GC, но они должны быть скорректированы, когда содержащий фрейм реактивируется, так как объект, на который они ссылаются, либо в текущем фрейме и теперь находится в другом месте стека, либо в одном из все еще приостановленных вызывающих фреймов и, следовательно, не на стеке. В присутствии "ref Span param" и подобных, обновление при приостановке также необходимо для поддержания цепочек byref безопасно обходимыми.

Было бы неудобной моделью программирования с точки зрения пользователя, если бы был разрешен только один вид byref, а не другой, так как различие может быть известно только во время выполнения.

В текущем эксперименте было рассмотрено 2 стратегии реализации:

  • Размотка стека с 1:1 захватом стека для приостановленных кадров. В этой модели byref /span , указывающие на кучу или других асинхронных вызывающих, могут поддерживаться, так как есть механизм для отчетности byref в GC. Есть некоторые проблемы с производительностью, но не без решений;

  • Конечный автомат с управляемым хранилищем для захваченных переменных. Текущая реализация не поддерживает захват byref и byref-like. Но она может использовать обычные механизмы отчетности GC, что предлагает множество преимуществ, например отсутствие O(n) алгоритмов, которые должны выполняться во время GC-пауз. У разработчиков есть мысли о том, что byref могут быть захвачены и преобразованы как {object, offset} при следующей сборке мусора, чтобы не нести постоянных O(n) затрат.

Производительность

Чтобы взвесить все "за" и "против", а также понять узкие места реализованных подходов командой было проведено несколько бенчмарков.

Стоимость вызовов между async1 и async2

Т.к. изначально было принято решение о поддержке обоих вариантов асинхронности и возможности их взаимодействия друг с другом, важным аспектом стала непосредственна производительность этого взаимодействия.

Этот тест работает путем вызова асинхронным методом другого почти пустого асинхронного метода в цикле. Тест работает в течение фиксированного периода времени, измеряя количество итераций. Вызывающий метод всегда возвращает Task , а почти пустой метод читает глобальную переменную, а затем ожидает результат вызова Task.Yield() , если эта глобальная переменная равна 0 . (Переменная никогда не имеет значения '0'.)

Стоимость вызова асинхронных методов
Стоимость вызова асинхронных методов

Из этих тестов были сделаны следующие выводы:

  • производительность взаимодействия между моделями async1 и async2 не является проблематично медленной

  • производительность кода async2 , вызывающего функции async1 , фактически выше, чем производительность async1 , вызывающего async1

  • стоимость вызова функции async2 из другой функции async2 существенно меньше

  • производительность вызовов из async1 в async2 для модели с размоткой была значительно медленнее (не показана на графиках)

Обработка исключений на различной глубине

Одной из основных целей эксперимента было как раз ускорение обработки исключений в асинхронных функциях, с чем сейчас имеются проблемы. Для проверки эффективности решения этой задачи разработчиками также были проведены сравнения.

Тест работает путем циклического выполнения до тех пор, пока не пройдет 250 мс, и измерения количества итераций за это время. Каждая итерация работает путем рекурсивного вызова функции до достижения указанной глубины стека (опционально вызывая через блок try/finally ), а затем приостанавливает асинхронный метод, ожидая Task.Yield() или нет, и, наконец, завершает каждую итерацию либо возвратом, либо выбрасыванием вновь созданного исключения.

Данные тесты были проведены только для JIT реализации стейт машин, т.к. реализация на основе размотки стека ощутимо медленнее и не поддерживает обработку исключений.

Сравнение производительности обработки исключений с "чистым" возвратом
Сравнение производительности обработки исключений с "чистым" возвратом

В полученных данных были отмечены следующие моменты:

  • Стоимость выбрасывания исключения чрезвычайно высока, если приложение могло бы завершиться синхронно;

  • Накладные расходы на приостановку асинхронной функции и переход в пул потоков довольно высоки;

  • Производительность обработки исключений в асинхронных функциях, генерируемых средой выполнения, намного быстрее, чем обычная обработка исключений. Фактически, по мере увеличения глубины стека, модель обработки исключений, представленная асинхронными функциями, генерируемыми средой выполнения, не страдает от такого большого штрафа за обход стека, как наш обычный обходчик стека обработки исключений.

При глубине стека в 64 фрейма:

  • В синхронном режиме (NoSuspend) async2 обрабатывает исключения в 8 раз быстрее (3 936 ops/s против 484 у async1).

  • В приостановленном состоянии (Suspend) разрыв достигает 38x (17,565 ops/s против 458).

Производительность обычного асинхронного кода async Task против асинхронных функций, генерируемых средой выполнения

На графике ниже показана сырая производительность выбрасывания исключения при различной глубине стека.

Сравнение обработки исключений при различной глубине стека
Сравнение обработки исключений при различной глубине стека

На этом графике заметно, что особой разницы между ValueTask и Task нет. Также видно, что async2 заметно быстрее, т.к. дизайн async2 позволяет избежать дорогостоящей операции обхода стека, а кривая графика определяется стоимостью приостановки выполнения для обработки исключения.

На следующем графике показана производительность runtime async по сравнению с классическим async для конкретного кода.

Сравнение производительности async1 с async2
Сравнение производительности async1 с async2

Видно, что производительность нового async-кода, сгенерированного во время выполнения, всегда выше для этого кода, чем при традиционной генерации кода. Числа выше 100% означают увеличение скорости.На этом графике представлена производительность асинхронного кода, генерируемого средой выполнения, по сравнению с классическим асинхронным кодом для конкретного используемого кода.

Влияние на производительность захвата/восстановления ExecutionContext и SynchronizationContext

Вариант тестирования Async2Capture предназначен для демонстрации влияния, которое захват/восстановление ExecutionContext и SynchronizationContext оказывает на производительность асинхронной логики, генерируемой средой выполнения. На этом графике более высокие столбцы лучше, и если бы захват не имел стоимости, все они ожидались бы на уровне 100%.

Влияние захвата контекста с async2
Влияние захвата контекста с async2

Сравнение производительности текущей логики async с runtime async с захватом контекста:

async1 vs async2 с захватом контекста
async1 vs async2 с захватом контекста

Влияние блоков try/finally на производительность выбрасывания исключения

Влияние try/finally
Влияние try/finally

Большая часть выигрыша в производительности обработки исключений у прототипа async2, зависит от быстрого обхода стека, который происходит при диспетчеризации исключений. В частности, в модели, генерируемой средой выполнения, есть возможность пропускать фреймы, которые не имеют обработчиков, с помощью простой проверки флага. Это противоречит асинхронному коду, сгенерированному компилятором IL, где реализация генерирует catch , даже если в кадре нет блока try , написанного на C#.

Влияние на GC (размер кучи, паузы)

Был рассмотрен простой тест, который обеспечивает 10000 приостановленных тасок в какой-то момент, каждая из которых содержит 100 стековых фреймов с как минимум 1 объектом и 1 целым числом, живыми через await и, следовательно, требующими захвата. В момент приостановки задач тест форсирует несколько Gen0 GC и измеряет среднее время, затраченное на GC, а также размер управляемой кучи. Чтобы еще больше уменьшить количество вовлеченных переменных, тест является однопоточным и использовался Workstation GC.

Heap size

Peak working set

GC pause (Gen0)

async1

130 Mb

148 Mb

120 us

async2 (Stack unwind)

28 Mb

499 Mb

480 us

async2 (JIT state-machine)

157 Mb

328 Mb

100 us

Здесь нужно сделать несколько замечаний:

  1. Изначально паузы сборки для async2 с размоткой стека (stack unwind) составляли около 48690 us, что было просто недопустимо для сборки мусора в Gen0. После реализации механизма отчетности перед GC для Tasklet время сборки удалось уменьшить.

  2. Можно заметить, что вариант с размоткой стека имеет в разы меньше использование памяти в куче. Это обусловлено использованием нативного malloc (неуправляемой памяти), но при этом рабочий набор больше других вариантов, т.к. приходится хранить весь стек и регистры.

  3. Размер кучи варианта с JIT стейт-машиной зависит от уровня оптимизации JIT. При выключенной TieredCompilation - 157 Mb, при включении в первых итерациях достигает 300 Mb, но позже снижается до 100 Mb.

Альтернативные варианты

Golang

Одним из краеугольных камней Go являются его горутины. По сути, они представляют из себя stackful-корутины с динамически изменяемым размером стека (изначально горутина создается с минимальным размером стека, которые в случае необходимости увеличивается). При это в Go нет операторов, эквивалентных async/await. Все исполняется синхронно, ожидание происходит на каналах (chan) или специальных операциях, которые перехватываются рантаймом. Когда вызывается блокирующая операция (например чтение с диска), происходит неблокирующий вызов, например через epoll/kqueue или syscall в отдельном потоке, а сама горутина приостанавливается и возобновляется когда будет доступен результат.

Таким образом, в Go получилось избавиться от разноцветных функций и при этом реализовать современный и эффективный механизм асинхронности.

Java

Вместе с Java 21 в релиз вышел и Project Loom, который представляет собой реализацию виртуальных потоков в Java. Виртуальные потоки также могут приостаналивать и возобновлять свое исполнение и менять нативный поток исполнения между приостановками. Но при этом разработчиком они могут восприниматься, как просто потоки. Нет необходимости в использовании каких либо дополнительных модификаторов, «асинхронных» функций или помечать точки ожидания. Для приостановки функции рантайм перехватывает блокирующие операции перенося их исполнение в отдельный поток, а вызывающий виртуальный поток «паркуя» (приостанавливая).

Можно заметить общий механизм между Golang и Project Loom в JVM, который заключается в перехвате рантаймом блокирующих операций и выполнение их в неблокирующей манере. Если в Go это было реализовано изначально, то в JVM подобные масштабные изменения заняли 5 лет:

  • Проект был начат в 2017 году

  • Превью стал доступен в 2021

  • В 2022 году стало доступно как превью фича в JDK 19 (STS)

  • Полноценно стал доступен в JDK 21 (LTS), но это еще не означает повсеместного использования. Обеспечение совместимости экосистемы и многих библиотек может занять еще несколько лет.

Итог

Эксперимент с async2 в .NET представляет собой значительный шаг в развитии асинхронного программирования, направленный на повышение производительности и упрощение разработки по сравнению с текущей моделью async/await (async1). Исследования показали, что Async2 обеспечивает впечатляющие улучшения, такие как ускорение обработки исключений до 38 раз при глубоких стеках вызовов и снижение накладных расходов при вызовах между асинхронными методами. Однако эксперимент с зелёными потоками (green threads), вдохновлённый Project Loom в Java, был отклонён из-за проблем с производительностью и совместимостью, что подтолкнуло команду .NET к оптимизации существующей модели асинхронности.

Для реализации Async2 были рассмотрены два подхода: размотка стека (tasklets, stack unwindind) и JIT-сгенерированные машины состояний (continuations, JIT State Machine). Подход на основе JIT оказался предпочтительным благодаря лучшей совместимости с текущей инфраструктурой .NET, меньшим накладным расходам и более высокой производительности в большинстве сценариев. Размотка стека, хотя и поддерживает сложные конструкции, такие как byref и span, оказалась менее практичной из-за увеличения времени пауз сборки мусора и сложности реализации.

На текущий момент async2 остаётся экспериментом, доступным в runtimelab в ветке feature/async2-experiment , которая при этом продолжает поддерживаться, в отличие от feature/green-threads . Она использует временный модификатор async2 и обеспечивает совместимость с async1 через функции-прокладки (thunks), но ограничения, такие как частичная поддержка byref, span и других конструкций, всё ещё требуют доработки. Команда .NET сделала выбор в пользу постепенного улучшения существующей модели асинхронности, чтобы минимизировать риски для разработчиков и сохранить совместимость с существующим кодом.

В перспективе результаты async2 могут быть интегрированы в будущие версии .NET поэтапно, что позволит разработчикам создавать более производительный и интуитивный асинхронный код. Долгосрочная цель - устранение необходимости явного разделения на синхронные и асинхронные функции, что упростит разработку и сделает её более естественной.

Если желаете погрузиться глубже, то ниже приведены основные использованные источники:

  1. Runtime Handled Tasks Experiment

  2. Green Threads Technical Report

Это моя первая статья на Хабре и если вы заметили ошибки, неточности или места, где объяснения выглядят «коряво» — пожалуйста, напишите об этом в комментариях! Критика и предложения по улучшению помогут мне сделать следующие материалы понятнее и полезнее.

Спасибо, что дочитали до конца, и надеюсь, статья была интересной!

Комментарии (6)


  1. iboltaev
    16.07.2025 14:55

    Удивительно, что в 2025м до сих пор где-то проблемы с асинхронностью. В той же Scala еще в 2015м нормальный, но не асинхронный фреймворк еще надо было поискать. И примерно тогда же появились чисто асинхронные коннекторы к Postgres-MySQL. Плюс есть async/await макросы (библиотека). В C++ уже много лет как корутины, и так же асинхронные коннекторы к Postgres-MySQL появились в userver.

    С другой стороны, большинство веб-приложений ходят в базу. А большинство баз (RDBMS) обрабатывают соединения синхронно, поток на соединение или процесс на соединение. Асинхронные только всякий NoSQL, типа HBase/Mongo/Cassandra, и облачные базы типа DynamoDB. Так что если вы в асинхронном приложении обращаетесь к синхронной базе, выигрыш у вас будет символический, т.к. затык будет на стороне базы.


    1. navferty
      16.07.2025 14:55

      Всё-таки в продакшне чаще для СУБД используется отдельный сервер, так что выигрыш для самого приложения, которое отделено от сервера БД, вполне возможен.


    1. MonkAlex
      16.07.2025 14:55

      Нет никаких явных проблем. Просто есть мнение, что можно сделать лучше чем сейчас. Учитывая что асинхронность в C# довольно давно, за эти годы появились идеи.


    1. Lewigh
      16.07.2025 14:55

      Удивительно, что в 2025м до сих пор где-то проблемы с асинхронностью. В той же Scala еще в 2015м нормальный, но не асинхронный фреймворк еще надо было поискать. И примерно тогда же появились чисто асинхронные коннекторы к Postgres-MySQL. Плюс есть async/await макросы (библиотека).

      Не очень понятно про что Вы. Если про C# то это некорректно. Высокоуровневая работа с асинхронностью пришла в C# в 2010 году с TPL а уже в 2012 в нем придумали новую концепцию async/await которая повлияла на большинство мейнстримных языков. Так что C# в этом плане пионер.

      А вот что удивительно действительно, это то что такой популярный язык как Java затянул до 2023 года просто игнорируя эту проблему.


  1. Lewigh
    16.07.2025 14:55

    Спасибо за отличную статью.

    Таким образом, в Go получилось избавиться от разноцветных функций и при этом реализовать современный и эффективный механизм асинхронности.

    Я бы заметил что при всех плюсах данного подхода - вышеупомянутого избавления от цветных функций, мой опыт показывает что обратная сторона - это неудобство работы с параллельными запросами и там где нужно задавать последовательности. Если в C# достаточно просто сделать то в Go это превращается в городьбу из каналов WaitGroups и так далее, раз за разом. Плюс, я до конца не уверен, хорошо это или плохо что я не знаю блокирующий вызов функции или нет. К примеру я вызываю функцию у которой под капотом системный вызов не сетевой природы и это стоит мне нового потока, а я бы хотел знать что функция будет создавать отдельный поток и грузить приложения.

    Можно заметить общий механизм между Golang и Project Loom в JVM, который заключается в перехвате рантаймом блокирующих операций и выполнение их в неблокирующей манере. Если в Go это было реализовано изначально, то в JVM подобные масштабные изменения заняли 5 лет:

    При всем тот что в Java худо бедно выпустили работу с асинхронностью аж в 2023 году по старой доброй традиции сделали это слегка неуклюже на мой взгляд. В том же Go достаточно ключевого слова go в Java это возня с разными видами потоков.


  1. nihil-pro
    16.07.2025 14:55

    Я фронтендер, мне было интересно и познавательно. Спасибо.

    Пы сы: дотнет в глаза никогда не видел))