Pull to refresh

Comments 26

Подача интересная, но до сих пор не понимаю - зачем это всё знать и понимать?

По моему достаточно того, что код до реального IO выполняется синхронно (а может оказаться что и вовсе продолжит выполнение синхронно).

Только начал читать и уже вопросы.

1) не поставлена цель - что мы вообще хотим сделать?

2) Очень неудачный самый первый пример. В нем зачем-то асинхронные функции вызываются синхронно. А зачем? Вызывайте синхронно синхронные. В чем смысл-то? Что тут быстрее исполняется? Зачем тут весь этот кипиш с async/await? Не с той стороны заходите, ох не с той )

  1. Цель - "понять, как работает async/await" (это написано в заголовке статьи).

  2. Вы пишете про самый первый пример: "зачем-то асинхронные функции вызываются синхронно". Но ведь это не так. В нём асинхронно вызываются асинхронные функции - 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-стека. В контексте статьи спорить не о чем - здесь код лишь иллюстрирует преобразование компилятора.

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

Он придуман для софтверной симуляции передачи управления как в нитях.

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.DefaultThreadPoolTaskSchedulerThreadPool. И тем самым мы сразу и надёжно отвязываемся от 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.

Sign up to leave a comment.

Articles