Optimising asynchronous operations in .NET

Research article
DOI:
https://doi.org/10.60797/IRJ.2025.157.40
Issue: № 7 (157), 2025
Suggested:
28.04.2025
Accepted:
06.06.2025
Published:
17.07.2025
533
5
XML
PDF

Abstract

The article is devoted to the principles of state management of asynchronous operations on the .NET platform in order to minimise allocations in managed memory. Typical scenarios of using asynchronous programming in client-server applications are analysed. The focus is on the following aspects: synchronous termination of asynchronous functions, Task and Task<T> caching strategies, application of ValueTask and ValueTask<T> types, IValueTaskSource<T> interface implementations. The results of testing different types of asynchronous methods on the example of calculating the Ackerman function are given. The paper is intended for software developers working on creating high-performance and responsive .NET applications where optimisation of resource-intensive asynchronous operations is critical.

1. Введение

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

Асинхронный подход обеспечивает:

- параллельное выполнение операций ввода-вывода без связывания потоков;

- уменьшение количества кода в рабочих потоках обогащенных клиентских приложений (созвучно с понятием «толстый» клиент).

Это приводит к двум сценариям использования асинхронного программирования.

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

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

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

Мелкомодульный параллелизм — последовательность небольших параллельных операций, между которыми выполнение возвращается в главный поток пользовательского интерфейса

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

Целесообразность применения асинхронного программирования несомненна при работе с операциями ввода-вывода и трудоемкими по времени вычислениями

,
. Однако, чрезмерная асинхронность может снизить производительность из-за накладных расходов связанных с выделением объектов-обещаний в управляемой памяти (куче).

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

- синхронное завершение асинхронной функции;

- кеширование Task/Task<T>;

- применение структуры ValueTask/ValueTask<T>;

- реализация IValueTaskSource<T>.

2. Синхронное завершение асинхронной функции

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

csharp
1async void Main()
2{
3  string html = await GetWebPageAsync("https://csharpcooking.github.io");
4  html.Length.Dump("Страница загружена");
5  // Попробуем снова. В этот раз должно быть мгновенно:
6  html = await GetWebPageAsync("https://csharpcooking.github.io");
7  html.Dump("Страница загружена");
8}
9static Dictionary<string, string> _cache = new Dictionary<string, string>();
10async Task<string> GetWebPageAsync(string uri)
11{
12  string html;
13  if (_cache.TryGetValue(uri, out html)) return html;
14  return _cache[uri] = await new WebClient().DownloadStringTaskAsync(uri);
15}

При ожидании задачи компилятор оптимизирует код, проверяя свойство IsCompleted. Если задача уже завершена (например, при наличии данных в кеше), выполнение происходит с возвратом завершённого экземпляра задачи без создания продолжения. Это называется синхронным завершением. В противном случае создается продолжение для асинхронного выполнения. Такой подход позволяет избежать накладных расходов на асинхронность, когда она не требуется, ускоряя выполнение кода, когда данные доступны немедленно.

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

.

Асинхронное программирование в C# предоставляет интересную возможность: создавать асинхронные методы, которые фактически никогда не используют ключевое слово await. Например, можно написать метод вида:

csharp
1async Task<string> Foo() { return "abc"; }

Хотя компилятор и выдаст предупреждение об отсутствии await, такой код вполне допустим и работоспособен. Альтернативный способ достижения того же результата заключается в использовании метода Task.FromResult, который возвращает уже завершенную задачу. В этом случае метод может выглядеть так:

csharp
1Task<string> Foo() { return Task.FromResult("abc"); }

Этот вариант не требует использования ключевого слова async. Оба подхода позволяют сохранить согласованность интерфейса и обеспечивают гибкость при реализации асинхронных интерфейсов. Они особенно полезны, когда необходимо работать с асинхронным кодом в преимущественно синхронных сценариях. Важно отметить, что в обоих случаях возвращается сигнализированная (завершенная) задача. Когда же метод помечен как async и использует await, компилятор автоматически генерирует состояние машины (state machine) для управления асинхронными операциями. Это включает в себя создание нескольких объектов и структур для управления жизненным циклом задачи, что влечет за собой дополнительные накладные расходы.

Если наш метод GetWebPageAsync вызывается из потока пользовательского интерфейса, то он является неявно безопасным к потокам в том смысле, что его можно было бы вызвать несколько раз подряд (инициируя тем самым множество параллельных загрузок) без необходимости в каком-либо блокировании с целью защиты кеша. Однако если бы последовательность обращений относилась к одному и тому же URI, то инициировалось бы множество избыточных загрузок, которые все в конечном итоге обновляли бы одну и ту же запись в кеше. Хоть это и не ошибка, но более эффективно было бы сделать так, чтобы последующие обращения к тому же самому URI взамен (асинхронно) ожидали результата выполняющегося запроса.

Существует простой способ достичь указанной цели, не прибегая к блокировкам или сигнализирующим конструкциям. Вместо кеша строк мы создаем объект-обещания (Task<string>) — кеш «будущего»:

csharp
1static Dictionary<string, Task<string>> _cache = new Dictionary<string, Task<string>>();
2Task<string> GetWebPageAsync(string uri)
3{
4  Task<string> downloadTask;
5  if (_cache.TryGetValue(uri, out downloadTask)) return downloadTask;
6  return _cache[uri] = new WebClient().DownloadStringTaskAsync(uri);
7}

Обратите внимание, что мы не помечаем метод как async, поскольку напрямую возвращаем объект задачи, полученный в результате вызова метода класса WebClient.

Теперь при повторяющихся вызовах метода GetWebPageAsync с тем же самым URI мы гарантируем получение одного и того же объекта Task<string>. Это обеспечивает и дополнительное преимущество минимизации нагрузки на сборщик мусора

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

Чтобы сделать код безопасным к потокам без защиты со стороны контекста синхронизации, необходимо блокировать все тело метода

:

csharp
1lock (_cache)
2{
3  Task<string> downloadTask;
4  if (_cache.TryGetValue(uri, out downloadTask)) return downloadTask;
5  return _cache[uri] = new WebClient().DownloadStringTaskAsync(uri);
6}

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

3. Кеширование Task

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

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

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

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

Для иллюстрации рассмотрим следующий пример:

csharp
1public async Task WriteAsync(byte value)
2{
3  if (_bufferedCount == _buffer.Length)
4  {
5    await FlushAsync();
6  }
7  _buffer[_bufferedCount++] = value;
8}

В данном примере, если метод завершается синхронно, ему не нужно возвращать новый Task, так как возвращаемое значение отсутствует. В таких случаях платформа .NET использует кешированный необобщенный Task, который возвращает пустое значение (эквивалент void в синхронных методах). Этот кешированный синглтон доступен через свойство Task.CompletedTask. (Слово «синглтон» (от англ. «singleton») в программировании обозначает паттерн проектирования, который ограничивает создание экземпляра класса одним объектом.)

Таким образом, если в методе WriteAsync буфер заполнен, вызывается метод FlushAsync, который является асинхронным и возвращает Task. Однако, если в буфере достаточно свободного пространства, операция записи выполняется синхронно, и новый объект Task не создается.

Или, например, представим код, в котором метод NextValueAsync имеет возвращаемый объект типа Task<bool>:

csharp
1static Random r = new Random();
2static int _random;
3public static async Task Main(string[] args)
4{
5  List<Task<bool>> taskList = new List<Task<bool>>();
6  for (int i = 0; i < 1000; i++)
7  {
8    taskList.Add(NextValueAsync());
9  }
10  // Ожидаем завершения всех задач
11  await Task.WhenAll(taskList);
12  // Группируем задачи по результату и проверяем эквивалентность экземпляров
13  var groupedTasks = taskList.GroupBy(task => task.Result);
14  foreach (var group in groupedTasks)
15  {
16    var firstTask = group.First();
17    bool allSame = group.All(task => object.ReferenceEquals(task, firstTask));
18    Console.WriteLine(allSame
19        ? $"Все задачи с результатом {group.Key} являются одним и тем же экземпляром."
20        : $"Не все задачи с результатом {group.Key} являются одним и тем же экземпляром.");
21  }
22}
23static async Task<bool> NextValueAsync()
24{
25  await RandomWithAsyncDelay(false);
26  return _random > 0;
27}
28static async Task RandomWithAsyncDelay(bool setAsync)
29{
30  if (setAsync) await Task.Delay(1000);
31  _random = r.Next(0, 2);
32}
Поскольку есть только два возможных результата типа bool(true и false), то существует только два возможных объекта Task, которые нужны для представления этих результатов. В сценарии синхронного завершения среда .NET обеспечивает кеширование этих объектов, возвращая их с соответствующим значением без выделения памяти. Только в случае асинхронного завершения (достигается вызовом RandomWithAsyncDelay с параметром setAsync равным true) методу понадобится создать новый Task, потому что его нужно будет вернуть до того, как станет известен результат операции. Вывод программы при различных значениях параметра setAsync представлен на рисунках 1 и 2.
Вывод программы при setAsync = false

Рисунок 1 - Вывод программы при setAsync = false

Вывод программы при setAsync = true

Рисунок 2 - Вывод программы при setAsync = true

При использовании Task<int> в качестве типа возвращаемого объекта асинхронного метода наблюдается иная ситуация. Кеширование всех возможных значений Task<int> потребовало бы сотни гигабайт памяти, так как Int32 представляет собой 32-битное целое число со знаком, которое может принимать около 4,3 миллиарда уникальных значений (в диапазоне от -2147483648 до 2147483647). Поэтому среда выполнения предоставляет ограниченный кеш для Task<int>, покрывающий только небольшой набор значений: значения Task<int> кешируются для диапазона значений от -1 до 9 (для подтверждения и получения дополнительной информации можно обратиться к исходному коду в репозитории dotnet/runtime на GitHub, конкретно к файлу «Task.cs»
). Это означает, что если метод возвращает значение в этом диапазоне, то будет использована кешированная задача, а не создана новая.

Множество методов библиотеки стремятся сгладить ситуацию за счет использования собственного кеша. В частности, в .NET Framework 4.5 метод MemoryStream.ReadAsync всегда выполняется синхронно, поскольку он считывает данные из памяти (данный метод можно найти в исходном коде .NET на GitHub

).

4. Применение структуры ValueTask

.NET Core 2.0 ввел новый тип ValueTask<TResult>

, доступный через NuGet пакет System.Threading.Tasks.Extensions, чтобы решить проблему создания ненужных объектов Task<TResult> при синхронных операциях. (NuGet
— это менеджер пакетов для платформы .NET, который позволяет разработчикам добавлять сторонние библиотеки в свои проекты, управлять зависимостями и обновлениями библиотек.) ValueTask<TResult> позволяет оборачивать как TResult, так и Task<TResult>. При синхронном и успешном выполнении асинхронного метода, структура ValueTask<TResult> возвращается без размещения объекта в куче. Только при асинхронном выполнении создается объект Task<TResult>, который затем оборачивается в ValueTask<TResult>. Если асинхронный метод завершается с исключением, для представления этого исключения используется объект Task<TResult>, который затем оборачивается в ValueTask<TResult>. Это позволяет ValueTask<TResult> оставаться компактной структурой, которая не требует отдельного поля для хранения исключения. Пример:

csharp
1public async ValueTask<int> ExampleMethodAsync()
2{
3  try
4  {
5    // Выполнение асинхронной операции
6    int result = await SomeAsyncOperation();
7    return result;
8  }
9  catch (Exception ex)
10  {
11    // Если возникает исключение, оно будет упаковано в Task<int>
12    return new ValueTask<int>(Task.FromException<int>(ex));
13  }
14}

В этом примере, если SomeAsyncOperation завершается синхронно и успешно, ValueTask<int> просто вернет результат. Если возникает исключение, создается Task<int> с этим исключением, и ValueTask<int> оборачивает его, сохраняя компактность структуры. Применение конструкции await Task.FromException<int>(ex) в завершении метода привело бы к созданию дополнительного кода, необходимого для управления продолжением выполнения программы. В отличие от этого, использование конструкции new ValueTask<int>(Task.FromException<int>(ex)) является более производительным решением. Оно позволяет сразу вернуть экземпляр структуры ValueTask<int>, снижая накладные расходы. Такой подход оптимизирует работу программы за счет исключения ненужных операций, связанных с асинхронным управлением.

Однако при разработке высокопроизводительных сервисов по-прежнему важно минимизировать любое выделение памяти, включая размещения в куче объектов, которые связаны с асинхронными операциями. В .NET Core 2.1 тип ValueTask<TResult> был усовершенствован для поддержки пула объектов и их повторного использования. Вместо того чтобы просто оборачивать TResult или Task<TResult>, был введен новый интерфейс IValueTaskSource<TResult>

,
, и ValueTask<TResult> был дополнен возможностью оборачивать его. IValueTaskSource<TResult> обеспечивает основную функциональность, необходимую для представления асинхронной операции в ValueTask<TResult>.

Основные методы IValueTaskSource<TResult>:

GetStatus(short token): этот метод используется для проверки статуса асинхронной операции. Он возвращает значение типа ValueTaskSourceStatus, которое указывает, завершена ли операция, находится ли она в ожидании или завершилась с ошибкой.

OnCompleted(Action<object> continuation, object state, short token, ValueTaskSourceOnCompletedFlags flags): этот метод регистрирует обратный вызов (callback), который будет вызван при завершении асинхронной операции.

GetResult(short token): этот метод используется для получения результата операции или выброса исключения, если операция завершилась с ошибкой.

Интерфейс IValueTaskSource<TResult> позволяет создавать асинхронные операции, которые могут быть связаны с пулом объектов. Это позволяет многократно использовать один и тот же объект для выполнения разных асинхронных операций, избегая избыточных аллокаций памяти и снижая нагрузку на сборщик мусора. В отличие от IValueTaskSource<TResult> тип Task<TResult> разработан так, чтобы быть безопасным и предсказуемым в многопоточных сценариях. Как только задача завершена, её состояние становится неизменным, и она может быть безопасно использована многократно (например, вызов await может быть выполнен несколько раз). Это означает, что после завершения задачи Task<TResult> она больше не может быть изменена или использована для новой операции.

Проблема повторного использования ValueTask<TResult> хорошо прослеживается на примере следующего кода:

csharp
1public class DatabaseReadOperation : IValueTaskSource<string>, IDisposable
2{
3  private static readonly ObjectPool<DatabaseReadOperation> _pool = new DefaultObjectPool<DatabaseReadOperation>(new DefaultPooledObjectPolicy<DatabaseReadOperation>());
4  private ManualResetEventSlim _completion = new ManualResetEventSlim(false);
5  private string _result;
6  private short _token;
7  public DatabaseReadOperation() { }
8  public static DatabaseReadOperation Rent(string result)
9  {
10    var objPool = _pool.Get();
11    objPool._result = result;
12    // objPool._token = (short)Environment.TickCount;
13    objPool._completion.Reset();
14    return objPool;
15  }
16  public ValueTask<string> StartobjPoolAsync()
17  {
18    return new ValueTask<string>(this, _token);
19  }
20  public void Complete()
21  {
22    _completion.Set();
23  }
24  public string GetResult(short token)
25  {
26    if (token != _token)
27    {
28      throw new InvalidOperationException("Invalid token");
29    }
30    return _result;
31  }
32  public ValueTaskSourceStatus GetStatus(short token)
33  {
34    if (token != _token)
35    {
36      throw new InvalidOperationException("Invalid token");
37    }
38    return _completion.IsSet ? ValueTaskSourceStatus.Succeeded : ValueTaskSourceStatus.Pending;
39  }
40  public void OnCompleted(Action<object> continuation, object state, short token, ValueTaskSourceOnCompletedFlags flags)
41  {
42    if (token != _token)
43    {
44      throw new InvalidOperationException("Invalid token");
45    }
46    ThreadPool.QueueUserWorkItem(_ =>
47    {
48      _completion.Wait();
49      continuation(state);
50    });
51  }
52  public void Dispose()
53  {
54    // Возвращаем объект в пул после завершения
55    _pool.Return(this);
56  }
57}
58public class Program
59{
60  public static async void Main(string[] args)
61  {
62    // Первый запрос к базе данных
63    var objPool = DatabaseReadOperation.Rent("Результат #1");
64    ValueTask<string> firstValueTask = objPool.StartobjPoolAsync();
65    // Обработка запроса
66    ThreadPool.QueueUserWorkItem(async _ =>
67    {
68      string result = await firstValueTask;
69      Console.WriteLine($"firstValueTask.Result: {result}");
70    });
71    // Повторная обработка того же ValueTask (проблема)
72    ThreadPool.QueueUserWorkItem(async _ =>
73    {
74      Thread.Sleep(100); // Симуляция задержки
75      string result = await firstValueTask;
76      Console.WriteLine($"firstValueTask.Result: {result}");
77    });
78    Thread.Sleep(10); // Симуляция работы сервера
79    objPool.Complete(); // Завершаем операцию     
80    objPool.Dispose(); // Возвращаем объект в пул
81    Thread.Sleep(10); // Симуляция задержки до следующего запроса
82                      // Второй запрос к базе данных, используя повторно тот же объект из пула
83    objPool = DatabaseReadOperation.Rent("Результат #2");
84    ValueTask<string> secondValueTask = objPool.StartobjPoolAsync();
85    // Обработка второго запроса
86    ThreadPool.QueueUserWorkItem(async _ =>
87    {       Thread.Sleep(100); // Симуляция задержки
88      string result = await secondValueTask;
89      Console.WriteLine($"secondValueTask.Result: {result}");
90    });
91    objPool.Complete(); // Завершаем операцию     
92    objPool.Dispose(); // Возвращаем объект в пул
93  }
94}

Вывод программы:

firstValueTask.Result: Результат #1

firstValueTask.Result: Результат #2

secondValueTask.Result: Результат #2

Структурно представим выполнение вышеуказанного кода:

1. Первый запрос к базе данных:

1.1. Создается объект objPool типа DatabaseReadOperation, который арендуется из пула (Microsoft.Extensions.ObjectPool.ObjectPool<T>) и используется для первой асинхронной операции. Ему присваивается результат "Результат #1".

1.2. Запускаются два потока, которые ожидают завершения этой операции. Первый поток немедленно выполняет await firstValueTask, получает результат и завершает выполнение.

1.3. Второй поток ожидает завершения firstValueTask через 100 миллисекунд. Это ожидание выполняется уже после того, как первый поток завершил выполнение и объект objPool возвращен в пул для повторного использования (см. пункт 2.1 далее).

2. Второй запрос к базе данных:

2.1. Тот же объект objPool повторно арендуется для выполнения второго запроса, и ему присваивается новый результат "Результат #2".

2.2. Запускается еще один поток, который асинхронно ожидает завершения второй операции.

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

В представленном коде закомментированная строка:

objPool.token = (short)Environment.TickCount;

играет важную роль в предотвращении проблемы повторного использования экземпляра ValueTask<TResult>. Эта строка отвечает за обновление токена, который используется для проверки корректности выполнения асинхронной операции. Токен (_token) используется для обеспечения уникальности операции. Каждый раз, когда объект арендуется из пула для новой операции, этот токен должен обновляться. В противном случае, если токен останется прежним, все связанные с этим объектом ValueTask будут указывать на принадлежность текущей операции, даже если объект уже был возвращен в пул и повторно использован для другой операции. Если каждый раз при аренде объекта из пула вы обновляете токен (например, с использованием Environment.TickCount или другого уникального значения), вы гарантируете, что старые ValueTask, связанные с этим объектом, больше не будут действительны. Это предотвращает ситуацию, когда второй поток получает результат, предназначенный для новой операции, что мы видели в предыдущем примере.

Таким образом, возможность повторного использования экземпляра ValueTask<TResult> без негативных последствий определяется правильной реализацией интерфейса IValueTaskSource<TResult>. Важнейшую роль в этом процессе играет корректное управление токенами, которые обеспечивают уникальность и правильную идентификацию каждой асинхронной операции. При правильном использовании IValueTaskSource<TResult> и соответствующей реализации методов интерфейса можно добиться значительного улучшения производительности, минимизируя накладные расходы на память и избегая ошибок, связанных с многократным ожиданием на одном и том же экземпляре ValueTask<TResult>.

Когда ValueTask<TResult> был представлен в .NET Core 2.0, основной акцент был сделан на оптимизации сценариев с синхронным завершением операций, чтобы избежать лишнего выделения памяти под объект Task<TResult>, если результат уже был доступен. Это объясняло отсутствие необходимости в необобщенном классе ValueTask, поскольку для синхронного выполнения можно было просто использовать синглтон Task.CompletedTask, что не требовало создания новых объектов.

Однако с развитием технологий и появлением требования исключить выделение памяти даже при асинхронных завершениях операций, необходимость в необобщенном ValueTask вновь стала актуальной. Так в .NET Core 2.1 был представлен необобщенный ValueTask. Это дало возможность управлять асинхронными операциями с минимальными накладными расходами, аналогично обобщенным версиям, но с пустым возвращаемым значением. Освобождение от выделения в куче при асинхронном завершении с использованием необобщенного ValueTask достигается за счет использования необобщенного интерфейса IValueTaskSource, который позволяет реализовать логику асинхронной операции так, чтобы управлять её завершением без необходимости создания нового объекта Task в куче.

5. Реализация IValueTaskSource c применением ManualResetValueTaskSourceCore

Реализация интерфейса IValueTaskSource<T> может показаться нетривиальной задачей. Ранее при описании проблемы повторного использования экземпляра ValueTask<TResult> был представлен класс DatabaseReadOperation, реализующий интерфейс IValueTaskSource<string>. В данном коде поток, выполняющий await firstValueTask, узнает о необходимости выполнить продолжение благодаря механизму синхронизации с использованием блокирующей конструкции ManualResetEventSlim

, что является минусом.

С введением ManualResetValueTaskSourceCore<TResult> в .NET Core 3.0 ситуация поменялась: данная изменяемая структура предоставляет встроенные механизмы для управления состоянием асинхронной задачи и обработки продолжений, причем обеспечивая данное управление без блокирующих примитивов синхронизации. Экземпляр данной структуры можно использовать в качестве поля на объекте, чтобы помочь ему реализовать интерфейс IValueTaskSource<T>. Представим класс DatabaseReadOperation с применением этой структуры.

csharp
1public class DatabaseReadOperation : IValueTaskSource<string>, IDisposable
2{
3  private static readonly ObjectPool<DatabaseReadOperation> _pool = new DefaultObjectPool<DatabaseReadOperation>(new DefaultPooledObjectPolicy<DatabaseReadOperation>());
4  private ManualResetValueTaskSourceCore<string> _core; // Используем ManualResetValueTaskSourceCore для управления состоянием и результатом
5  private string _result;
6  public DatabaseReadOperation() { }
7  public static DatabaseReadOperation Rent(string result)
8  {
9    var objPool = _pool.Get();
10    objPool._result = result;
11    objPool._core.Reset(); // Сбрасываем состояние перед повторным использованием
12    return objPool;
13  }
14  public ValueTask<string> StartOperationAsync()
15  {
16    return new ValueTask<string>(this, _core.Version);
17  }
18  public void Complete()
19  {
20    _core.SetResult(_result); // Завершаем операцию и устанавливаем результат
21  }
22  public string GetResult(short token)
23  {
24    return _core.GetResult(token); // Получаем результат операции
25  }
26  public ValueTaskSourceStatus GetStatus(short token)
27  {
28    return _core.GetStatus(token); // Получаем статус операции
29  }
30  public void OnCompleted(Action<object> continuation, object state, short token, ValueTaskSourceOnCompletedFlags flags)
31  {
32    _core.OnCompleted(continuation, state, token, flags); // Регистрируем продолжение
33  }
34  public void Dispose()
35  {
36    // Возвращаем объект в пул после завершения
37    _pool.Return(this);
38  }
39}

На рисунке 3 представлен вывод после запуска кода:

csharp
1// Первый запрос к базе данных
2var objPool = DatabaseReadOperation.Rent("Результат #1");
3ValueTask<string> firstValueTask = objPool.StartOperationAsync();
4// Обработка запроса
5ThreadPool.QueueUserWorkItem(async _ =>
6{
7  string result = await firstValueTask;
8  Console.WriteLine($"firstValueTask.Result: {result}");
9});
10// Повторная обработка того же ValueTask (проблема)
11ThreadPool.QueueUserWorkItem(async _ =>
12{
13  Thread.Sleep(100); // Симуляция задержки
14  string result = await firstValueTask;
15  Console.WriteLine($"firstValueTask.Result: {result}");
16});
17Thread.Sleep(10); // Симуляция работы сервера
18objPool.Complete(); // Завершаем операцию     
19objPool.Dispose(); // Возвращаем объект в пул
20Thread.Sleep(10); // Симуляция задержки до следующего запроса
21// Второй запрос к базе данных, используя повторно тот же объект из пула
22objPool = DatabaseReadOperation.Rent("Результат #2");
23ValueTask<string> secondValueTask = objPool.StartOperationAsync();
24// Обработка второго запроса
25ThreadPool.QueueUserWorkItem(async _ =>
26{
27  Thread.Sleep(100); // Симуляция задержки
28  string result = await secondValueTask;
29  Console.WriteLine($"secondValueTask.Result: {result}");
30});
31objPool.Complete(); // Завершаем операцию     
32objPool.Dispose(); // Возвращаем объект в пул
Вывод программы с использованием ManualResetValueTaskSourceCore

Рисунок 3 - Вывод программы с использованием ManualResetValueTaskSourceCore

На рисунке 3 наблюдается ошибка, потому что токен, переданный в метод GetStatus, больше не соответствует внутреннему состоянию объекта после его повторного использования из пула. Это объясняется следующим.

Сброс (Reset) очищает все внутренние состояния объекта, такие как захваченные контексты выполнения, продолжения, исключения и результат предыдущей операции. После вызова _core.Reset поле _version будет инкрементировано и свойство _core.Version вернет значение, которое не будет соответствовать первоначальному. Это важно для предотвращения ошибок при многократном ожидании завершения одной и той же задачи. В приведенном выше коде объект из пула был возвращен и повторно использован слишком рано, в то время, когда один из потоков все еще ожидал первоначальный объект.

6. Ограничения на использование ValueTask

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

Первое – многократное ожидание (await) на одном и том же ValueTask/ValueTask<TResult>: поскольку внутренний объект может быть обработан и использоваться в другой операции, многократное ожидание может привести к тому, что объект уже будет занят другой задачей. Это может привести к некорректной работе программы. В отличие от Task, который всегда остается в завершенном состоянии и поддерживает многократные ожидания, ValueTask может перейти в незавершенное состояние при повторном использовании. В программе выше, где рассматривалось использование ManualResetValueTaskSourceCore<string> при реализации интерфейса IValueTaskSource<string>, представлен частный случай нарушения данного ограничения — параллельное ожидание одного и того же ValueTask<string>. Если один и тот же ValueTask ожидается в нескольких потоках одновременно, это создает условия для возникновения гонки.

Второе — использование метода GetAwaiter().GetResult() до завершения операции: в отличие от Task, который поддерживает блокирующий вызов до завершения задачи, реализация IValueTaskSource или IValueTaskSource<TResult> не обязана поддерживать блокировку до завершения операции. Поэтому вызов метода GetResult может привести к состояниям гонки и непредсказуемому поведению программы.

Указанные ограничения определяют важное правило: с экземплярами ValueTask или ValueTask<TResult> вы должны либо ожидать их напрямую с помощью await (возможно с ConfigureAwait(false), указывающее, что после await выполнение кода не обязано возвращаться в исходный контекст синхронизации), либо сразу преобразовать их в Task с помощью метода AsTask, а затем больше не использовать исходный объект.

Из-за представленных ограничений, требующих соблюдения определенных рекомендаций, программисты реже прибегают к использованию ValueTask<TResult>. Поэтому в этом отношении, если производительность не перевешивает удобство использования, Task<TResult> остаются предпочтительными. Кроме того, есть небольшие затраты, связанные с возвращением ValueTask<TResult> вместо Task<TResult>. Например, по результатам тестирования реализаций функции Аккермана

ожидание Task<TResult> при определенных значениях параметров функции выполняется быстрее, чем ожидание ValueTask<TResult> (см. таблицу, рисунки 4 и 5 далее). Ниже представлен фрагмент кода программы
, с помощью которого было проведено данное тестирование.

csharp
1static void Main()
2{
3  BenchmarkRunner.Run<Ackermann>();
4}
5[IterationCount(100)]
6[MemoryDiagnoser]
7public class Ackermann
8{
9  [Params(1,2,3)]
10  public int m;
11  [Params(1,2,3)]
12  public int n;
13  [Benchmark(Baseline = true)]
14  public int Baseline()
15  {
16    return AckermannFunc(m, n);
17    int AckermannFunc(int m, int n) => (m, n) switch
18    {
19      (0, _) => n + 1,
20      (_, 0) => AckermannFunc(m - 1, 1),
21      _ => AckermannFunc(m - 1, AckermannFunc(m, n - 1)),
22    };
23  }
24  [Benchmark]
25  public ValueTask<int> ValueTask()
26  {
27    return AckermannFunc(m, n);
28    async ValueTask<int> AckermannFunc(int m, int n) => (m, n) switch
29    {
30      (0, _) => n + 1,
31      (_, 0) => await AckermannFunc(m - 1, 1),
32      _ => await AckermannFunc(m - 1, await AckermannFunc(m, n - 1)),
33    };
34  }
35  [Benchmark]
36  public Task<int> Task()
37  {
38    return AckermannFunc(m, n);
39    async Task<int> AckermannFunc(int m, int n) => (m, n) switch
40    {
41      (0, _) => n + 1,
42      (_, 0) => await AckermannFunc(m - 1, 1),
43      _ => await AckermannFunc(m - 1, await AckermannFunc(m, n - 1)),
44    };
45  }
46}

Функция Аккермана обладает высокой степенью рекурсивной вложенности. В представленном коде в конечной точке рекурсии происходит синхронное завершение, в результате весь стек вызовов тоже разворачивается синхронно, и выполнение не уходит в асинхронность. В этом случае ValueTask<int> не создаёт Task<int>, и код остаётся полностью стековым, без выделений памяти в куче.

Результаты тестирования реализаций функции Аккермана с применением .NET SDK 9.0.100 и библиотеки BenchmarkDotNet v0.14.0

представлены в таблице. Для тестирования была использована целевая платформа с характеристиками:

-    процессор Intel Core i5-9300H (8 логических, 4 физических ядра)

-    оперативная память DDR4 16 ГБ,

-    операционная система Windows 11 (10.0.22631.4460),

Runtime=.NET 9.0.3 (9.0.325.11113), X64 RyuJIT AVX2.

Таблица 1 - Результаты тестирования реализаций функции Аккермана

Метод

Число рек.  вызовов

m

n

Средн. арифм., нс.

Станд. откл., нс.

Кол-во сборок мусора в Gen0 на 1000 операций

Кол-во выделяемой памяти в куче за 1 вызов метода, байт

Baseline

4

1

1

3,506

0,0902

-

-

ValueTask

76,036

2,2531

-

-

IValueTaskSource

378,316

11,3795

0,0877

368

Task

50,700

1,0317

-

-

Baseline

6

1

2

7,313

0,0782

-

-

ValueTask

106,601

6,3150

-

-

IValueTaskSource

503,147

28,4584

0,1354

568

Task

65,809

2,1491

-

-

Baseline

8

1

3

8,576

0,2664

-

-

ValueTask

136,545

3,2220

-

-

IValueTaskSource

678,346

19,3167

0,1831

768

Task

89,820

2,4432

-

-

Baseline

14

2

1

18,519

0,6279

-

-

ValueTask

270,178

7,9779

-

-

IValueTaskSource

1221,201

29,1151

0,3242

1360

Task

163,981

5,7236

-

-

Baseline

27

2

2

36,497

0,6551

-

-

ValueTask

482,350

11,3347

-

-

IValueTaskSource

3009,284

60,8933

0,6599

3376

Task

352,306

7,8184

-

-

Baseline

44

2

3

57,001

0,7175

-

-

ValueTask

814,358

28,9962

-

-

IValueTaskSource

4343,942

172,8679

1,5030

6296

Task

569,717

13,4053

0,0515

216

Baseline

106

3

1

135,360

1,1921

-

-

ValueTask

1961,375

62,9385

-

-

IValueTaskSource

10705,974

446,4759

4,0436

16937

Task

1534,464

40,6479

0,3777

1584

Baseline

541

3

2

674,350

5,9826

-

-

ValueTask

9459,547

181,8086

 

 

IValueTaskSource

66247,500

5013,7841

21,8506

91701

Task

9222,128

378,4115

4,7455

19872

Baseline

2432

3

3

3293,043

44,9792

-

-

ValueTask

44116,709

1783,5449

-

-

IValueTaskSource

414282,242

17464,7995

96,6797

416848

Task

46579,047

1897,1332

30,3345

126864

Времена работы методов Baseline, ValueTask, IValueTaskSource, Task

Рисунок 4 - Времена работы методов Baseline, ValueTask, IValueTaskSource, Task

Количество сборок мусора в ходе работы методов Task и IValueTaskSource

Рисунок 5 - Количество сборок мусора в ходе работы методов Task и IValueTaskSource

Тестирование показало, что при использовании ValueTask<int> не происходит выделения памяти в куче, тогда как Task<int> аллоцирует память при каждом await. Однако скорость работы с Task<int> при малых аллокациях выше, чем при использовании ValueTask<int>. Это обусловлено затратами, связанные с использованием ValueTask<TResult>:

- При передаче экземпляра структуры ValueTask<TResult> между методами происходит копирование структуры, что влечет дополнительные накладные расходы. В случае Task<TResult>, передается только указатель на объект в куче.

ValueTask<TResult> может представлять: синхронный результат; ссылку на Task<TResult>; объект, реализующий интерфейс IValueTaskSource<TResult>. Это делает управление состоянием более сложным, поскольку метод, использующий ValueTask<TResult>, должен учитывать все три случая.

Адаптация функции Аккермана к интерфейсу IValueTaskSource<T> не дало выигрыша ни в производительности, ни в использовании управляемой памяти, так как использование глубокой рекурсии приводит к переполнению пула объектов.

7. Заключение

Асинхронное программирование в .NET представляет собой мощный инструмент для повышения производительности и отзывчивости приложений, особенно в сценариях, связанных с операциями ввода-вывода или длительными вычислениями.

По итогам проведенного анализа сформированы принципы оптимизации асинхронных операций:

- Выполнение предварительной проверки завершения асинхронной операции и, если результат уже доступен (например, из кеша), обеспечить синхронное завершение метода, чтобы избежать затрат на создание асинхронной инфраструктуры. Использование кешированных задач, таких как Task.CompletedTask или Task.FromResult, обеспечивает возвращение результата с минимальными накладными расходами.

- Применение методов, возвращающих Task<bool>, Task<int>, позволяет использовать кешированные экземпляры для значений, что исключает необходимость создания новых объектов.

- Использование структуры ValueTask<T> позволяет избежать выделения памяти в куче, оборачивая либо результат, либо объект задачи. Для использования данной структуры требуется соблюдение определенных ограничений, таких как избегание многократного ожидания одного и того же экземпляра.

- Реализация механизма управления асинхронными операциями через интерфейс IValueTaskSource<T> позволяет организовать пул повторно используемых объектов. Такой механизм требует ручного управления состояниями асинхронных операций, чтобы избежать ошибок.

Результаты тестирования в синхронном сценарии на примере функции Аккермана показали, что при малых аллокациях в памяти целесообразно применение Task<T>, так как данный тип не уступает в производительности ValueTask<T>. В асинхронном сценарии с возвращаемой структурой ValueTask<TResult> положительный результат потенциально достижим с применением интерфейса IValueTaskSource<T> и эффективным использованием пула объектов (т.е. не вызывая его переполнения).

Article metrics

Views:533
Downloads:5
Views
Total:
Views:533