В чем разница использований асинхронных и неасинхронных лямбда-выражений в Task.Run
Насколько мне известно Task.Run отдает задачу потоку взятому из пула, для выполнения этой самой задачи. Но в чем разница между
Этим
Task.Run(() => SomeAsyncTaskMethod());
и этим
Task.Run(async () => await SomeAsyncTaskMethod());
В первом случае всё понятно, мы просто отдаем другому потоку выполнение таска, а активный поток продолжает идти дальше по коду. Но что происходит во втором случае? Как я понимаю подобные конструкции нужны для более сложных задач, где нужна последовательность выполнения методов, но зачем писать это, если в таком случае можно запускать внутри лямбды еще один таск, ведь без этого - не важно есть await или нет, метод будет выполнен синхронно.
И так же что происходит с потоком? Выделенный поток становится в ожидание и отдает управление другому потоку, или как?
Этот вопрос у меня возник по той простой причине, что если мы напишем в каком-нибудь асинхронном методе следующее:
// Представим, что этот код лежит в каком-нибудь async методе
...
await Task.Run(() => AnotherOneMethod());
...
то активный поток, когда увидит оператор await просто отдаст задачу другому потоку, а сам уйдет обратно в тот код(в моем случае Main), который вызвал сам метод.
И при всём при этом если мы используем await Task.Run(async () => await SomeAsyncTaskMethod()); то он будет выполнятся на одном потоке который был взят и пула. В чем же тогда смысл использовать в этом месте асинхронное лямбда выражение, если всё будет работать ровно так же? Этот вопрос у меня возникает не сколько от непонимания как это работает, сколько от непонимания зачем в огромном количестве примеров использования async\await пишут подобные конструкции.
Я понимаю, что в сфере асинхронного программирования знаний у меня еще недостаточно, я могу ошибаться в терминологии, так что было бы неплохо, если бы мне в случае чего разъяснили в каких моментах я не прав или дали ру-статью о тасках и асинхроне в шарпе в целом
Ответы (1 шт):
Чтобы разобраться, нужно понять, что такое лямбда на логическом уровне.
() => MethodAsync() где метод возращает Task это
Task MethodAsync()
{
return MethodAsync();
}
async () => await MethodAsync() где метод возвращает Task это
async Task LambdaAsync()
{
await MethodAsync();
}
В данном конкретном случае используется Func<Task> или Func<Task<T>>, в зависимости от того, что возвращает сам асинхронный метод метод - Task или Task<T>.
И здесь важный момент - первый вариант ЛУЧШЕ второго по той простой причине что он является его оптимизированной версией, а работают логически они оба совершенно ОДИНАКОВО.
Оптимизация заключается в том, что для async метода компилятор генерирует достаточно сложную обвязку - машину состояний. А зачем нам машина состояний, в которой всего-лишь одно полезное состояние? В этом случае эффективным решением является избавление от async.
Такая оптимизация может применена в любом методе, не только в лямбдах, а условия следующие:
- Внутри
asyncметода всего-лишь одинawait awaitстоит перед хвостовым методом, то есть после метода сawaitничего в коде не написаноawaitне является частью конструкцииtry-catch-finally(напомню, конструкцияusingдляIDisposableпорождаетtry-finally)
Вот и получается, что оба ваши Task.Run - одинаковые логически.
Теперь про потоки. Вы пытаетесь разобраться с системой ожидания (асинхронностью) с помощью рассуждения о потоках (многопоточности). Здесь есть подвох - это разные, никак не связанные между собой механизмы. Многие их путают или смешивают, это грубая ошибка в рассуждениях, которая ведет к очевидно ошибочным выводам.
Чтобы понять как система ожидания async/await взаимодействует с потоками, надо знать внутреннее устройство контекста синхронизации. А точнее именно текущего контекста синхронизации. В UI приложениях типа Winforms или WPF используется однопоточный контекст синхронизации, в консольном приложении он отсутствует, то есть используется контекст синхронизации по умолчанию.
Подробнее можно с этим разобраться взглянув на логику выполнения async метода, на примере вот этого псевдокода:
async
{
// начало
await
// продолжение
await
// продолжение
}
Здесь "начало" всегда выполняется в потоке вызывающего метода, а "продолжение" - в потоке, в который его поместил текущий контекст синхронизации. Если контекста нет, то есть SynchronizationContext.Current возвращает null, тогда "продолжение" выполняется на пуле потоков
public virtual void Post(SendOrPostCallback d, object? state)
=> ThreadPool.QueueUserWorkItem(static s => s.d(s.state), (d, state), preferLocal: false);
В вашем же случае продолжения никакого нет (оно есть, там технический return task; но будем считать что нет), а следовательно контекст синхронизации не играет никакой роли и разницы при выполнении первого и второго вариантов лямбд нет.
По изложенному выше, так как Task.Run предназначен для выполнения его делегата на пуле потоков, то да, независимо от того, синхронная у вас лямбда или асинхронная - она будет выполнена потоком из пула.
Но будьте осторожны с асинхронными лямбдами, никогда не делайте так async => Method(), то есть не используйте асинхронные лябды без await. В большинстве случаев у вас получится async void лямбда и ожидания ее выполнения не произойдет, как и захвата вероятного исключения внутри нее. Например:
// ПЛОХОЙ КОД, НЕ ДЕЛАЙТЕ ТАК
await Task.Run(async () => Task.Delay(1000));
Здесь ожидания в 1 секунду не произойдет, так как лямбда будет типа async Task<Task>. То есть чтобы этот код исправить, в чем особого смысла нет, то надо написать так
var task = await Task.Run(async () => Task.Delay(1000));
await task;
Поэтому будьте внимательны, не используйте async без await.
При этом вот эти 2 варианта отработают корректно:
await Task.Run(() => Task.Delay(1000));
await Task.Run(async () => await Task.Delay(1000));