Comments 26
Подача интересная, но до сих пор не понимаю - зачем это всё знать и понимать?
По моему достаточно того, что код до реального IO выполняется синхронно (а может оказаться что и вовсе продолжит выполнение синхронно).
Только начал читать и уже вопросы.
1) не поставлена цель - что мы вообще хотим сделать?
2) Очень неудачный самый первый пример. В нем зачем-то асинхронные функции вызываются синхронно. А зачем? Вызывайте синхронно синхронные. В чем смысл-то? Что тут быстрее исполняется? Зачем тут весь этот кипиш с async/await? Не с той стороны заходите, ох не с той )
Цель - "понять, как работает async/await" (это написано в заголовке статьи).
Вы пишете про самый первый пример: "зачем-то асинхронные функции вызываются синхронно". Но ведь это не так. В нём асинхронно вызываются асинхронные функции -
await File.XxxAllTextAsync
.
1) Я бы начал с начала - а зачем вообще придумали async/await?
2) да нет, вызов асинхронной функции с немедленным await - это синхронный вызов. В этом случае, гораздо эффективнее и проще вызвать аналогичную синхронную функцию (например File.ReadAllText), ведь ваш код немедленно ждёт исполнения в другом потоке File.ReadAllTextAsync, что является синхронным выполнением по сути. Так на фига козе баян??
У вас во втором пункте явное незнание, как работает async-await. Синхронности там нет и не должно быть. Иначе действительно, зачем придумали async-await?
Могу даже по-русски:
"Оператор await приостанавливает вычисление включающего асинхронного метода до завершения асинхронной операции, представленной его операндом"
Слова "приостанавливает... до завершения" вам понятны? Это прямое определение синхронного вызова, если сама операция и была операндом - что в примере как раз и написано.
Этот код всё ещё отпускает поток, в отличие от синхронного. В многопоточном приложении это важно.
Приостанавливает (suspend) означает, что метод (сопрограмма) подписывает себя на асинхронное продолжение (continuation) и выходит (делает return), освобождая поток.
Синхронный код ждёт, занимая/блокируя поток через busy wait или примитивы синхронизации ОС.
Да, согласен. Но обычно это имеет значение для очень большого количества одновременных задач/потоков. С точки же зрения программиста, вызов:
Read(); Write();
И вызов:
await ReadAsync(); await WriteAsync() ;
-- исполняются плюс/минус одно и тоже время. И весь смысл асинхронности теряется. Вот если бы был некий цикл типа:
var tRead = File.ReadBlockAsync(...);
bool eof = ...;
var tWrite = Tasks.Completed;
while (!eof) {
await tWrite;
await tRead;
tWrite = File.WriteBlockAsync(...);
tRead = File.ReadBlockAsync(...);
eof = ...;
}
Тогда был бы прямой смысл использовать await - мы параллельно (в идеале) пишем и читаем, что может почти удвоить скорость.
Ещё раз - поток отпускается и может быть переиспользован, что важно в многопоточных приложениях.
В системе после старта работает несколько тысяч потоков. В стандартном приложении средней сложности работает 8+ потоков. Нигде здесь не важно/не нужно отдавать поток. Но если это сервис на сотни RPS и у него самого получаются тысячи потоков из-за медленной обработки каждого реквеста, то да. В примере статьи это совершенно неважно.
Скорее всего, вы имеете в виду отсутствие параллелизма в пределах метода (типа WhenAll
или ForAsync
). От этого ни сам метод, ни дочерние вызовы не перестают быть асинхронными. Хронологически, между Read
и Write
может успеть выполниться часть другого асинхронного метода. Другими словами, может случиться кооперативная многозадачность на уровне приложения (как раз то, зачем придумали async/await
).
гораздо эффективнее и проще вызвать аналогичную синхронную функцию
Я бы не рискнул давать такой совет хотя бы потому, что мы не знаем конкретного call-стека. В контексте статьи спорить не о чем - здесь код лишь иллюстрирует преобразование компилятора.
async
-методы не запускают потоков.
вы забыли добавить: "если эти потоки не нужны", но если без потока не обойтись никто не запрещает создать его в своем async
-методе и передать результат из этого потока по его завершению, через полученный при вызове этого метода, запустившего поток, Task
. Более того async
-метод может завершиться синхронно, если он не получил контекста для создания асинхронной операции. Как раз в том что поддерживается любая модель ассинхронности (из 3-х: с потоком, без потока, синхронно, как минимум) и проявляется универсальность подхода с async/await
.
К сожалению универсальность часто вступает в противоречие с оптимальностью, это тоже надо учитывать на практике.
Может ли магия async/await сама создавать потоки или это все делается явно тем, кто асинхронные функции пишет?
Вот это классный вопрос! Вы коротко сформулировали то, в чем надо убедиться или опровергнуть на практике. То что действительно имеет практическое значение.
Предварительный ответ у меня: async-функция может не явно использовать поток Тред-пула, например, который предоставлен текущим контекстом синхронизации, на сколько я помню/понял то, что написано у Тоуба в статье, и насколько я понимаю можно написать свой класс контекста синхронизации, который будет создавать потоки для async-операций-функций как бы не явно (но писать все таки что-то надо, все равно - то есть в этом смысле это конечно не магия, это глубокое понимание инструментария, как известно чудес не бывает). Это не окончательный ответ, скорее это направление в котором надо инвестегировать.
Это надо бы проверить, это тянет на хорошую статью... посадят меня за то что секреты раскрываю :).
Магия async/await
(то есть преобразование кода, проводимое компилятором) никогда не создаёт потоки сама. Она только комбинирует результаты других вызовов через GetAwaiter
и AsyncXXXMethodBuilder
. При этом даже тип Task
не обязателен - см. Generalized async return types and ValueTask.
Есть хорошая статья, говорящая об асихнронности/синхронном параллелизме.
Если вкратце: "хороший" async код практически никогда не запускает никаких потоков. Асинхронность идет до уровня, например, I/O, где она тоже имеется, единственные потоки, которые могут быть задействованы, это использующиеся для уведомления что I/O операция завершилась, но они полностью подкапотные.
Но тут еще важно понимать SynchronizationContext, в консольных приложениях продолжение, идущее за await, запросто может выдернуть поток из пула потоков, вернее это сделает планировщик задач, который без контекста синхронизации и использует пул потоков.
Если правильно, то тем кто выдаёт функцию. Некоторые операции можно выполнить лишь в специфичном контексте\потоке\устройстве. Например, операцию с гуи можно выполнить лишь в потоке гуи. Операцию проброса на диск на низовом уровне отдельный микропроцессор делает.
Мс как всегда накосячили. Абстракция дырявой получилась.
И правда, моя формулировка получилась слишком общей. Добавил уточнение:
"само по себе преобразование async-метода в стейт-машину не приводит к запуску потока"
Как я понял, одними из способов выполнить задачу в другом потоке через ThreadPool это запустить метод через Task.Run(Do) или await DoAsync().ConfigureAwait(false). Первое изменит контекст выполнения для всего метода Do, второе - для continuation метода DoAsync(). Правильно ли это?
Да, но есть одна мудрость: если нужно сделать Task.Run(), значит код не требует async/await. Т.е. если у нас операция CPU-bound и нам нужно распараллелить работу - async/await здесь не потребуется.
ConfigureAwait обычно используется в рамках оптимизации, когда мы точно уверены, что продолжение async не потребует, например, получать доступ к UI потоку для UI приложений соответственно, т.е. когда захват контекста и размещение в нем континьюации, идущей после await - бессмысленная операция. В таком случае не захватывать контекст выйдет дешевле. Ну и да, континьюацию подхватит поток из пула потоков при false.
Правильно ли это?
Есть неточности. Вот как правильно:
Task.Run(...)
действительно всегда отправляет делегат по маршруту TaskScheduler.Default
→ ThreadPoolTaskScheduler
→ ThreadPool
. И тем самым мы сразу и надёжно отвязываемся от SynchronizationContext.Current
.
await DoAsync().ConfigureAwait(false)
будет иметь эффект только в случае фактической асинхронности. Например:
await (Task.CompletedTask).ConfigureAwait(false);
Debug.Assert(SynchronizationContext.Current == null); // FAIL
Поэтому ConfigureAwait
приходится дописывать каждый раз, к каждому await
.
И с терминологией важно не путаться:
SynchronizationContext
- контекст синхронизации - планировщик, в который отправляются await continuations.ExecutionContext
- контекст выполнения - место хранения async locals.
Другой способ понять, как работает async/await в C#