image


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


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


В этой статье я расскажу, зачем нужен ConfigureAwait(false) и как обойтись без него.


async/await: continuation


Перед тем, как перейти к ConfigureAwait напомню, что такое асинхронный код, где у таска continuation, и что такое SynchronizationContext.


  • Асинхронный код с использованием async/await нарезается на отдельные блоки синхронного кода (разделение происходит по await). Каждый из этих блоков назовём continuation.
  • Переходы между блоками происходят либо синхронно, либо путём подписки на завершение асинхронного действия. Если асинхронное действие завершилось до await, то выполнение продолжится в том же потоке.

Как именно компилятор трансформирует код можно посмотреть, например, на sharplab.io


async void Btn_OnClick(object? sender, RoutedEventArgs e)
{
  Task<string> task = GetTextAsync();
  var text = await task;

  // continuation
  Text.Text = text;
}

Выше приведён код обработчика события нажатия на кнопку. Где будет выполняться continuation этого обработчика событий после завершения асинхронного Task? Нет, не в Thread Pool. Все действия с UI должны производиться в одном потоке, на котором крутится event loop. Этот код будет работать только в том случае, если continuation, обновляющий содержимое TextBox Text вернётся на UI-поток, в котором началась обработка события.


Для этого UI-фреймворки устанавливают SynchronizationContext, который возвращает continuation в очередь основного потока.


Без SynchronziationContext пришлось бы явно перекладывать UI-код на UI-поток:


async void Btn_OnClick(object? sender, RoutedEventArgs e)
{
  var text = await GetTextAsync().ConfigureAwait(false); // теряем SynchronizationContext и переходим в Thread Pool
  await Dispatcher.UIThread.InvokeAsync(() => Text.Text = text); // переданный делегат выполняется в контексте UIThread
}

SynchronizationContext встречается не только в UI-коде. Например, xUnit переопределяет его, для отслеживания async void методов и обработки исключений в них. В старом ASP.NET тоже был задан SynchronizationContext для доступа к HttpContext. К счастью, в ASP.NET Core его нет.


Кроме SynchronizationContext также может быть переопределён TaskScheduler, примерно с теми же последствиями.


И где здесь проблема?


void Btn_OnClick(object? sender, RoutedEventArgs e)
{
  var text = GetTextAsync().GetAwaiter().GetResult(); // синхронное ожидание
  Text.Text = text;
}

async Task<string> GetTextAsync()
{
  var request  = CreateRequest();
  var response = await client.SendAsync(request);

  // GetTextAsync continuation
  var text = Deserialize(response);
  return text;
}

Блокировка UI-потока


Разработчик UI может ожидать выполнения метода GetTextAsync синхронно (или в коде используется библиотека с плохим, синхронным ожиданием внутри).


В этом случае:


  • UI-поток заблокируется до завершения этого метода
  • В соответствии с SynchronizationContext, внутренний continuation метода GetTextAsync (в котором вызывается Deserialize) должен выполняться на UI-потоке
  • Но UI-поток заблокирован и не может выполнить этот continuation
  • Результат: deadlock, хотя поток при этом всего один

В некоторых случаях deadlock может не произойти: если GetTextAsync выполнится синхронно, либо если в нём произойдёт переход в другой контекст, например на Thread Pool.


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


Излишняя нагрузка на UI-поток


Если GetTextAsync ожидается асинхронно (с использованием await), то возникнет другая проблема. Контекст синхронизации попадает в метод GetTextAsync и его continuation с методом Deserialize тоже выполнится на UI-потоке. Блокировки не будет, но UI-поток во время выполнения этого метода не сможет выполнять более полезную нагрузку. Если на UI-поток попадает много лишнего кода, который мог бы выполняться в фоне — приложение станет менее отзывчивым.


ConfigureAwait(false) как решение


Из-за этих проблем и сложности их отлова, в .NET сложилась практика писать код, который потенциально может быть вызван внутри SynchronizationContext (т.е. в коде библиотек) так, чтобы эти проблемы не возникли, каким бы этот контекст не был.


А средство для этого — .ConfigureAwait(false), обеспечивающий перекладывание continuation на Thread Pool.


async void Btn_OnClick(object? sender, RoutedEventArgs e)
{
  var text = await GetTextAsync();

  // Btn_OnClick continuation
  Text.Text = text;
}

async Task<string> GetTextAsync()
{
  var request  = CreateRequest(authToken);
  var response = await client.SendAsync(request).ConfigureAwait(false);

  // GetTextAsync continuation
  // в случае, если `SendAsync` выполнился асинхронно
  // SynchronizationContext.Current теперь null
  var text = Deserialize(response);
  return text;
}

В этом случае, continuation метода GetTextAsync будет выполняться на потоке из Thread Pool, а возврат в исходный контекст синхронизации произойдёт лишь при выходе из GetTextAsync — в результате, Btn_OnClick continuation выполнится на UI потоке, как и ожидалось изначально.


Если await выполнится синхронно — переход в Thread Pool не произойдёт. Отсюда берётся рекомендация использовать .ConfigureAwait(false) вместе с каждым await.


Также, .ConfigureAwait(false) лишает вызывающий код возможности управлять тем, где будет выполняться асинхронный код переопределением SynchronizationContext и TaskScheduler. Какая-то часть кода "сбежит" в стандартный Thread Pool из-за повсеместного использования .ConfigureAwait(false).


.ConfigureAwait(true) задаёт поведение по-умолчанию и не несёт в себе никакого смысла.


Не только Task


Кроме Task/Task<T> .ConfigureAwait(false) актуален для ValueTask/ValueTask<T>, IAsyncEnumerable<T> и IAsyncDisposable (и некоторых других типов).


Особую боль представляет собой IAsyncDisposable. Обёртка ConfiguredAsyncDisposable — не generic, и не даёт возможности получить доступ к оригинальному объекту, в результате требуется разделять создание объекта и его использование в конструкции using. Область видимости переменной при этом выходит за границы блока using, что создаёт риск ошибки в коде.


Как обойтись без .ConfigureAwait(false)


1. Решить проблему на стороне вызывающего кода


Наивно считать, что во всём используемом коде расставлены .ConfigureAwait(false). Можно изначально писать код, выполняющийся в контексте синхронизации так, чтобы ему было безразлично, как написаны await в вызываемом коде.


Этого можно достичь, если запускать весь код, которому не нужен контекст синхронизации на Thread Pool, например с помощью Task.Run. Делегат, переданный в Task.Run будет выполнен без контекста синхронизации — на стандартном Thread Pool. В отсутствии контекста синхронизации ConfugureAwait не несёт смысла.


Task, возвращённый методом Task.Run ожидается уже в контексте синхронизации, поэтому continuation Btn_OnClick будет выполнен на UI-потоке и значение в Text успешно изменится.


async void Btn_OnClick(object? sender, RoutedEventArgs e)
{
  var text = await Task.Run(() => GetTextAsync()); // внутри этой лямбды SynchronizationContext.Current == null

  // Btn_OnClick continuation
  Text.Text = text;
}

async Task<string> GetTextAsync()
{
  var request  = CreateRequest(authToken);
  var response = await client.SendAsync(request);

  // GetTextAsync continuation
  var text = Deserialize(response);
  return text;
}

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


  • будет работать, независимо от наличия .ConfigureAwait(false) в вызываемом коде
  • позволяет вынести больше работы в фон — теперь в Thread Pool выполняется код не только после первого сработавшего .ConfigureAwait(false), но и весь код до, в нашем примере — не только Deserialize, но и CreateRequest.

Также, в Task.Run можно обернуть не только асинхронный метод, но и синхронный, например на случай, если внутри есть блокировка, приводящая к deadlock, или для выноса тяжелых вычислений в фон.


2. Использовать правильное синхронное ожидание


Предыдущий способ будет также работать и при синхронном ожидании. Можно сделать синхронную обёртку над асинхронным методом, которая в отличие от простого .Wait()/.Result/.GetAwaiter().GetResult() будет устойчива к описанному deadlock.


В идеальном мире хочется, чтобы синхронная версия метода была реализована отдельно и не задействовала Thread Pool. Часто это нереалистично из-за необходимости поддерживать две реализации сразу. Этот способ как раз для таких случаев.


public void Do()
{
  if (SynchronizationContext.Current == null && TaskScheduler.Current == TaskScheduler.Default)
    DoAsync().GetAwaiter().GetResult();
  else
    Task.Run(() => DoAsync()).GetAwaiter().GetResult();
}

3. Однократный переход в Thread Pool


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


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


При таком способе переход на Thread Pool произойдёт сразу, до первого асинхронного await. Это может снизить производительность, если обычно все await выполняются синхронно, без смены потока, или наоборот, повысить — если до первого асинхронного await выполняется вычислительный код, зря занимающий UI-поток. В реальных условиях разницу вряд ли получится заметить вовсе.


async Task<string> GetTextAsync()
{
  await TaskEx.EscapeContext(); // await TaskScheduler.Default;

  var request  = CreateRequest(authToken);
  var response = await client.SendAsync(request); // .ConfigureAwait больше не нужен

  // GetTextAsync continuation
  var text = Deserialize(response);
  return text;
}

Способ подсмотрен в dotnet/runtime. Также есть issue о добавлении публичного API и готовая реализация в Microsoft.VisualStudio.Threading.


Ниже приведена реализация, переходящая в Thread Pool только если задан контекст синхронизации или TaskScheduler:


readonly struct EscapeAwaiter : ICriticalNotifyCompletion
{
  public bool IsCompleted
    => SynchronizationContext.Current == null &&
       TaskScheduler.Current == TaskScheduler.Default;

  public void GetResult() { }

  public void OnCompleted(Action continuation)
    => Task.Run(continuation);

  public void UnsafeOnCompleted(Action continuation)
    => ThreadPool.QueueUserWorkItem(state => ((Action)state!)(), continuation);
}

readonly struct EscapeAwaitable
{
  public EscapeAwaiter GetAwaiter() => new EscapeAwaiter();
}

static class TaskEx
{
  public static EscapeAwaitable EscapeContext() => new EscapeAwaitable();
}

Однако, есть случай, когда такой подход не сработает — при реализации IAsyncEnumerable<T>. В этом случае, если вызывающий метод имеет SynchronizationContext, то итератор будет получать контекст при каждом вызове .MoveNextAsync(). В итоге, уход с контекста потребуется делать заново после каждого yield return.


IAsyncEnumerable
async IAsyncEnumerable<int> Process()
{
  for (int i = 0; i < 3; ++i)
  {
    await Task.Delay(1000).ConfigureAwait(false);
    // перешли на Thread Pool
    yield return i;
    // получили контекст обратно, т.к. это уже новый вызов метода `.MoveNextAsync()`
  }
}

4. Кодогенерация


Чтобы не писать .ConfigureAwait(false) вручную — их можно сгенерировать сразу по всей сборке или для отдельных классов и методов. Например, с помощью ConfigureAwait.Fody.


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


Выводы


Проблемы, возникающие при работе асинхронного кода во внешнем контексте синхронизации могут быть решены и без повсеместного иcпользования .ConfigureAwait(false), причём как со стороны вызывающего, так и со стороны вызываемого кода.


Сейчас уже сложно сказать, почему работа с контекстом синхронизации в C# была спроектирована именно таким образом. Да и это лишено смысла — изменить это уже невозможно, т.к. это огромный breaking change.


В .NET сложилась практика использования .ConfigureAwait(false) в коде библиотек, однако это не является обязательным:


  • перейти на Thread Pool можно и другими способами, например с помощью своего Awaiter
  • клиентский код всегда может сам вызывать код библиотеки в правильном контексте
  • в библиотеках созданных для использования, например из ASP.NET Core кода .ConfigureAwait(false) не нужны, т.к. их нет в самом ASP.NET Core

Так что на вопрос "нужен ли ConfigureAwait?" можно ответить: если вы его не используете и никто не жалуется — не нужен. А если уже используете, то всё зависит от кода, который ваш код использует.


Ссылки


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


  1. epeshk Автор
    00.00.0000 00:00

    Корень проблемы .ConfigureAwait(...) на мой взгляд в том, что она сталкивает разработчиков библиотек и UI-кода:

    • разработчики библиотек не хотят засорять код .ConfigureAwait(false)

    • разработчики UI — .ConfigureAwait(true), await Task.Run(() => ...)

    Наиболее автоматизированное решение сейчас — генерировать .ConfigureAwait(false) через Fody. Но Fody — сторонняя приблуда, ещё и усложняющая сборку проекта. Конечно хочется нативного решения, на уровне dotnet sdk или рантайма, и идей, как это сделать описано уже много.

    Атрибут для сборки/параметр *.csproj
    https://github.com/dotnet/csharplang/issues/2542

    Переопредение оператора await на уровне проекта
    https://github.com/dotnet/csharplang/issues/2649

    Короткий синтаксис для `.ConfigureAwait(false)
    https://github.com/dotnet/csharplang/discussions/645

    Новый вид Task/Task<T>, свободный от контекста (даже есть реализация, но кажется, что overkill)
    https://github.com/ufcpp/ContextFreeTask

    Если добавить в язык условный await(false) или await!! грязи меньше не станет, поэтому остановимся на первом proposal. Его можно реализовать, например, в виде configureawait context, по аналогии с nullable context

    С ним в *.csproj можно было бы писать:

    <ConfigureAwait>false</ConfigureAwait>
    

    А в файле с кодом *.cs:

    #configureawait true
    public static async Task ContextDependentMethod() { ... }
    #configureawait restore
    

    Только увы, proposal висит с 2015 года, и никакого результата. Cейчас .ConfigureAwait(false) реализован в виде структуры-обёртки над Task, т.е. на уровне стандартной библиотеки, реализация же proposal потребует знания о ConfigureAwait на стороне компилятора (Roslyn) или рантайма. Возможно, сложность заключается в этом. Или просто всем без разницы и время на это не выделили.


  1. ARad
    00.00.0000 00:00
    +1

    Есть вопросы, возможно глупые...

    1. Решить проблему на стороне вызывающего кода.

    Чем Task.Run лучше ConfigureAwait(false)? Можно ли var text = await Task.Run(() => GetTextAsync()); // внутри этой лямбды SynchronizationContext.Current == null заменить на var text = await GetTextAsync().ConfigureAwait(false);. Что правильнее, быстрее, лучше и почему? В доках вроде как написано использовать второе.

    1. Использовать правильное синхронное ожидание

    Тот же вопрос, почему используется Task.Run, а не ConfigureAwait(false)? Чем это лучше и правильнее?

    1. Однократный переход в Thread Pool

    Предложенный код не выглядит оптимальным...


    1. epeshk Автор
      00.00.0000 00:00

      Можно ли var text = await Task.Run(() => GetTextAsync()); заменить на var text = await GetTextAsync().ConfigureAwait(false);
      Тот же вопрос, почему используется Task.Run, а не ConfigureAwait(false)

      .ConfigureAwait(false) имеет смысл только в методах, которым не нужно возвращаться на UI поток. Если использовать его внутри метода, которому важен контекст, то произойдёт исключение при попытке обновить UI (Text.Text = text;), если выполнение перейдёт на Thread Pool.

      Метод Task.Run в этих примерах разделяет код, которому контекст важен, и код, который может работать на Thread Pool без возврата в исходный контекст. Весь код, запущенный через Task.Run ничего не узнает про контекст синхронизации, в то же время Task, возвращённый Task.Run может ожидаться в нужном контексте — хоть синхронно, хоть асинхронно.


      1. ARad
        00.00.0000 00:00

        Спасибо за ответ, я понял в чем разница.

        По итогу выходит лучшее использовать первый способ если у вас асинхронный процесс редко отдает управление (может надолго заморозить UI поток). Например можно добавить Thread.Delay(1000) в метод GetTextAsync() для моделирования такого случая.

        Второй совет нужно использовать обязательно для синхронного кода который вызывает асинхронный...

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

        Кстати для таких сложных случаев можно ли использовать https://github.com/microsoft/vs-threading которую вы привели как пример или эта библиотека потянет много ненужных зависимостей? По названию она не выглядит как универсальная...


        1. qw1
          00.00.0000 00:00
          +1

          Остальные советы выглядят для меня больше как костыли
          Ну почему костыли.

          Если вы уже согласились на Task.Run, пункт 2) позволит не делать лишнее переключение потока и обратно, если мы уже на ThreadPool, вызывая Task.Run только в нужных случаях.

          Пункт 3) приятный синтаксический сахар.
          Единожды написать в начале метода await TaskEx.EscapeContext(); и дальше можно писать await-ы подряд, не задумываясь о ConfigureAwait на каждом await
          var result1 = await RunQuery1(param);
          var result2 = await RunQuery2(result1);
          var result3 = await RunQuery3(result2);


  1. epeshk Автор
    00.00.0000 00:00

    Корень проблемы .ConfigureAwait(...) на мой взгляд в том, что она сталкивает разработчиков библиотек и UI-кода:

    • разработчики библиотек не хотят засорять код .ConfigureAwait(false)

    • разработчики UI — .ConfigureAwait(true), await Task.Run(() => ...)

    Наиболее автоматизированное решение сейчас — генерировать .ConfigureAwait(false) через Fody. Но Fody — сторонняя приблуда, ещё и усложняющая сборку проекта. Конечно хочется нативного решения, на уровне dotnet sdk или рантайма, и идей, как это сделать описано уже много.

    Атрибут для сборки/параметр *.csproj
    https://github.com/dotnet/csharplang/issues/2542

    Переопредение оператора await на уровне проекта
    https://github.com/dotnet/csharplang/issues/2649

    Короткий синтаксис для `.ConfigureAwait(false)
    https://github.com/dotnet/csharplang/discussions/645

    Новый вид Task/Task<T>, свободный от контекста (даже есть реализация, но кажется, что overkill)
    https://github.com/ufcpp/ContextFreeTask

    Если добавить в язык условный await(false) или await!! грязи меньше не станет, поэтому остановимся на первом proposal. Его можно реализовать, например, в виде configureawait context, по аналогии с nullable context

    С ним в *.csproj можно было бы писать:

    <ConfigureAwait>false</ConfigureAwait>
    

    А в файле с кодом *.cs:

    #configureawait true
    public static async Task ContextDependentMethod() { ... }
    #configureawait restore
    

    Только увы, proposal висит с 2015 года, и никакого результата. Cейчас .ConfigureAwait(false) реализован в виде структуры-обёртки над Task, т.е. на уровне стандартной библиотеки, реализация же proposal потребует знания о ConfigureAwait на стороне компилятора (Roslyn) или рантайма. Возможно, сложность заключается в этом. Или просто всем без разницы и время на это не выделили.


    1. mvv-rus
      00.00.0000 00:00
      +1

      <тут было было про ненужноепро ConfigureAwait(true);? Удалено>

      А по поводу, что с этим делать, я сторонник того, чтобы указывать компилятору, как именно надо разворачивать await в async-методе: либо подразумевать, что await оставляет код в том же контекте синхронизации (для разработчиков приложений), как это сделано сейчас, либо запускает продолжение выполнения метода в ThreadPool (это для разрабочиков библиотек и всяческих вспомогательных методов, которым котнтекст синхронизации фреймворка для работы не нужен).
      Лучший IMHO способ укзать это - использовать атрибут, т.к. его можно указывать и для метода, и для класса, и для всей сборки.


  1. mvv-rus
    00.00.0000 00:00

    Поаккуратнее с примерами. Вот вы приводите пример:

    async Task<string> GetTextAsync()
    {
    await TaskEx.EscapeContext(); // await TaskScheduler.Default;
    var request = CreateRequest(authToken);
    var response = await client.SendAsync(request);
    // .ConfigureAwait больше не нужен //
    GetTextAsync continuation var text = Deserialize(response);
    return text;
    }

    Способ подсмотрен в dotnet/runtime. Также есть issue о добавлении публичного API и готовая реализация в Microsoft.VisualStudio.Threading.

    А что такое TaskEx.EscapeContext()? В стандартной библиотеке времени выполнения такого класса и метода нет, в классе Task такого метода нет тоже. И по ссылкам в GitHub в текущей версии его тоже нет.
    Конечно, если прочитать issue по вашей сcылке да порыться в сети, то можно выяснить, откуда у этого кода ноги растут - но это явно не для того уровня читателей, на который (полагаю) рассчитана эта статья.

    PS А вообще-то, на Хабре некогда уже был опубликован перевод статьи Тэлбота из блога разработчиков MS, в которой хорошо, без лишних сложностей - и, главное, из первых рук - объяснено, что это за зверь такой - ConfigureAwait(false). Что IMHO несколько снижает ценность этой статьи

    PPS Отдельный упрек редактору в новой версии Хабра, который не позволяет нормально процитировать блок кода.


    1. Mingun
      00.00.0000 00:00

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


      1. mvv-rus
        00.00.0000 00:00

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


  1. dimainnature
    00.00.0000 00:00

    Полезная статья, спасибо.


  1. equeim
    00.00.0000 00:00
    +1

    А в чем проблема делать это на стороне библиотечного кода? Библиотека на то и библиотека что у нее нет информации о контексте и о том как ее используют. Например в котлине считается что библиотечную корутину должно быть можно запускать на любом треде/диспетчере, а она сама уже разберётся что ей нужно:

    suspend fun loadAndParseJson(): Json {
      val bytes = withContext(Dispatchers.IO) {
        // IO is for blocking I/O
        // Reading from file
      }
      val json = withContext(Dispatchers.Default) {
        // Default is for computations
        // Parsing data
      }
      return json
    }
    


    1. qw1
      00.00.0000 00:00

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


  1. iamkisly
    00.00.0000 00:00

    Make code great again!!111100


  1. phoenixbk
    00.00.0000 00:00

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

    Task<string> GetTextAsync()
    {
        return Task.Run(async () =>
        {
            var request  = CreateRequest(authToken);
            var response = await client.SendAsync(request);
    
            var text = Deserialize(response);
            return text;
        });
    }


    1. epeshk Автор
      00.00.0000 00:00
      +2

      Такой способ будет работать, но дополнительный Task будет создаваться даже если в вызывающем коде нет контекста.
      Можно доработать и перекладывать в Thread Pool только если контекст есть, получится эквивалент способа 3 (с await EscapeContext())


  1. andrucci
    00.00.0000 00:00

    Для I/O-bound задач Task.Run плох тем, что блокирует поток из ThreadPool'а до момента завершения задачи. А одно из достоинств модели async/await именно в масштабируемости, т.е. в экономии на потоках.


    1. epeshk Автор
      00.00.0000 00:00

      Блокировка UI-потока гораздо заметнее блокировки фонового потока, поэтому Task.Run(() => ...) может быть меньшим злом.

      Если запускаемая функция блокирует поток Thread Pool и это ухудшает производительность — её можно запустить не на Thread Pool, а в отдельном потоке, создав его вручную, или следующим образом (в нынешней реализации .NET этот способ тоже создаёт новый поток):

      Task.Factory.StartNew(
        () => { ... },
        TaskCreationOptions.LongRunning|TaskCreationOptions.HideScheduler);
      

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

      Однако постоянное создание новых потоков может ещё больше навредить производительности, а при выносе вычислений в отдельные потоки пропускная способность Thread Pool также может снизиться, но уже из-за загруженности самих процессорных ядер. Поэтому не всегда целесообразно использование новых потоков или флага LongRunning.


      1. andrucci
        00.00.0000 00:00
        +1

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


      1. qw1
        00.00.0000 00:00

        Если уж заморачиваться такими вещами, то правильно не использовать блокирующие файловые операции, например
        File.ReadAllBytes();
        заменить на
        await File.ReadAllBytesAsync();