Мне никогда не нравилась многословность кода. Длинные и подробные названия упрощают работу с бизнес-логикой, но технические детали кода хочется держать краткими, чтобы они отвлекали на себя минимум внимания.
Одна из многословных конструкций .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
.
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)
ARad
00.00.0000 00:00+1Есть вопросы, возможно глупые...
Решить проблему на стороне вызывающего кода.
Чем
Task.Run
лучшеConfigureAwait(false)
? Можно лиvar text = await Task.Run(() => GetTextAsync()); // внутри этой лямбды SynchronizationContext.Current == null
заменить наvar text = await GetTextAsync().ConfigureAwait(false);
. Что правильнее, быстрее, лучше и почему? В доках вроде как написано использовать второе.Использовать правильное синхронное ожидание
Тот же вопрос, почему используется
Task.Run
, а неConfigureAwait(false)
? Чем это лучше и правильнее?Однократный переход в Thread Pool
Предложенный код не выглядит оптимальным...
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
может ожидаться в нужном контексте — хоть синхронно, хоть асинхронно.ARad
00.00.0000 00:00Спасибо за ответ, я понял в чем разница.
По итогу выходит лучшее использовать первый способ если у вас асинхронный процесс редко отдает управление (может надолго заморозить UI поток). Например можно добавить
Thread.Delay(1000)
в методGetTextAsync()
для моделирования такого случая.Второй совет нужно использовать обязательно для синхронного кода который вызывает асинхронный...
Остальные советы выглядят для меня больше как костыли... Или нужны в сложных случаях которые мне пока непонятны...
Кстати для таких сложных случаев можно ли использовать https://github.com/microsoft/vs-threading которую вы привели как пример или эта библиотека потянет много ненужных зависимостей? По названию она не выглядит как универсальная...
qw1
00.00.0000 00:00+1Остальные советы выглядят для меня больше как костыли
Ну почему костыли.
Если вы уже согласились на Task.Run, пункт 2) позволит не делать лишнее переключение потока и обратно, если мы уже на ThreadPool, вызывая Task.Run только в нужных случаях.
Пункт 3) приятный синтаксический сахар.
Единожды написать в начале методаawait TaskEx.EscapeContext();
и дальше можно писать await-ы подряд, не задумываясь о ConfigureAwait на каждом awaitvar result1 = await RunQuery1(param); var result2 = await RunQuery2(result1); var result3 = await RunQuery3(result2);
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) или рантайма. Возможно, сложность заключается в этом. Или просто всем без разницы и время на это не выделили.mvv-rus
00.00.0000 00:00+1<тут было было про ненужноепро
ConfigureAwait(true);?
Удалено>А по поводу, что с этим делать, я сторонник того, чтобы указывать компилятору, как именно надо разворачивать await в async-методе: либо подразумевать, что await оставляет код в том же контекте синхронизации (для разработчиков приложений), как это сделано сейчас, либо запускает продолжение выполнения метода в ThreadPool (это для разрабочиков библиотек и всяческих вспомогательных методов, которым котнтекст синхронизации фреймворка для работы не нужен).
Лучший IMHO способ укзать это - использовать атрибут, т.к. его можно указывать и для метода, и для класса, и для всей сборки.
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 Отдельный упрек редактору в новой версии Хабра, который не позволяет нормально процитировать блок кода.
Mingun
00.00.0000 00:00Так двумя абзацами ниже реализация же приведена. Хотя соглашусь, лучше бы комментарием отметить, что «реализацию смотрите ниже» или даже сразу её в тот же блок кода засунуть, а то действительно поначалу сбивает с толку.
mvv-rus
00.00.0000 00:00Ну да, увидел, в конце концов - в конце длинного блока кода, про связь которого с предыдущем блоком в статье явно не упомянуто. А еще лично меня сбило с толку, что когда-то (лет десять назад в какой-то промежуточной версии .NET Framework) этот класс и этот метод таки были (но в окончательную версию не вошли), и следы этого остались и в памяти, и в интернете, во всяких разных примерах.
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 }
qw1
00.00.0000 00:00В данном примере форсируется переключение потоков, а легко можно придумать пример приложения, которому чтение json и его парсинг допустимо делать в одном потоке, без переключения. В общем случае неважно, но для перфекционистов может быть эстетически неприемлемо.
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; }); }
epeshk Автор
00.00.0000 00:00+2Такой способ будет работать, но дополнительный
Task
будет создаваться даже если в вызывающем коде нет контекста.
Можно доработать и перекладывать в Thread Pool только если контекст есть, получится эквивалент способа 3 (сawait EscapeContext()
)
andrucci
00.00.0000 00:00Для I/O-bound задач Task.Run плох тем, что блокирует поток из ThreadPool'а до момента завершения задачи. А одно из достоинств модели async/await именно в масштабируемости, т.е. в экономии на потоках.
epeshk Автор
00.00.0000 00:00Блокировка UI-потока гораздо заметнее блокировки фонового потока, поэтому
Task.Run(() => ...)
может быть меньшим злом.Если запускаемая функция блокирует поток Thread Pool и это ухудшает производительность — её можно запустить не на Thread Pool, а в отдельном потоке, создав его вручную, или следующим образом (в нынешней реализации .NET этот способ тоже создаёт новый поток):
Task.Factory.StartNew( () => { ... }, TaskCreationOptions.LongRunning|TaskCreationOptions.HideScheduler);
В этом случае, блокирующий код внутри метода будет выполняться в отдельном потоке и заблокирует только его.
Однако постоянное создание новых потоков может ещё больше навредить производительности, а при выносе вычислений в отдельные потоки пропускная способность Thread Pool также может снизиться, но уже из-за загруженности самих процессорных ядер. Поэтому не всегда целесообразно использование новых потоков или флага
LongRunning
.andrucci
00.00.0000 00:00+1Согласен, эта проблема актуальна больше для сервера. Но вот тоже интересный подход применительно к UI - прокачивать сообщения в момент простоя.
qw1
00.00.0000 00:00Если уж заморачиваться такими вещами, то правильно не использовать блокирующие файловые операции, например
File.ReadAllBytes();
заменить наawait File.ReadAllBytesAsync();
epeshk Автор
Корень проблемы
.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
можно было бы писать:А в файле с кодом
*.cs
:Только увы, proposal висит с 2015 года, и никакого результата. Cейчас
.ConfigureAwait(false)
реализован в виде структуры-обёртки надTask
, т.е. на уровне стандартной библиотеки, реализация же proposal потребует знания оConfigureAwait
на стороне компилятора (Roslyn) или рантайма. Возможно, сложность заключается в этом. Или просто всем без разницы и время на это не выделили.