it-swarm.com.ru

Если async-await не создает никаких дополнительных потоков, то как это делает приложения отзывчивыми?

Снова и снова я вижу, что использование async-await не создает никаких дополнительных потоков. Это не имеет смысла, потому что единственный способ, которым компьютер может делать больше, чем 1 вещь одновременно,

  • На самом деле делает более 1 вещь одновременно (выполнение параллельно, используя несколько процессоров)
  • Имитация этого путем планирования задач и переключения между ними (немного A, немного B, немного A и т.д.)

Так что, если async-await не выполняет ни одного из них, то как это может сделать приложение отзывчивым? Если существует только 1 поток, то вызов любого метода означает ожидание завершения метода, прежде чем делать что-либо еще, и методы внутри этого метода должны ждать результата, прежде чем продолжить, и так далее.

198
Ms. Corlib

На самом деле, async/await не так волшебно. Полная тема довольно обширна, но я думаю, что для быстрого, но достаточно полного ответа на ваш вопрос мы справимся.

Давайте рассмотрим простое событие нажатия кнопки в приложении Windows Forms:

public async void button1_Click(object sender, EventArgs e)
{
    Console.WriteLine("before awaiting");
    await GetSomethingAsync();
    Console.WriteLine("after awaiting");
}

Я собираюсь явно не говорить о том, что это такое, GetSomethingAsync пока что возвращается. Скажем так, это то, что завершится, скажем, через 2 секунды.

В традиционном, не асинхронном мире ваш обработчик событий нажатия кнопки будет выглядеть примерно так:

public void button1_Click(object sender, EventArgs e)
{
    Console.WriteLine("before waiting");
    DoSomethingThatTakes2Seconds();
    Console.WriteLine("after waiting");
}

Когда вы нажмете кнопку в форме, приложение будет зависать примерно на 2 секунды, пока мы ждем завершения этого метода. То, что происходит, - то, что "насос сообщений", в основном петля, заблокирован.

Этот цикл непрерывно спрашивает окна: "Кто-нибудь что-то сделал, например, переместил мышь, что-то щелкнул? Нужно ли что-то перекрашивать? Если так, скажите мне!" а затем обрабатывает это "что-то". Этот цикл получил сообщение, что пользователь нажал "button1" (или эквивалентный тип сообщения из Windows), и в итоге вызвал наш метод button1_Click выше. Пока этот метод не вернется, этот цикл застрял в ожидании. Это занимает 2 секунды, и в течение этого сообщения не обрабатываются.

Большинство вещей, которые имеют дело с окнами, выполняется с помощью сообщений, что означает, что если цикл обработки сообщений прекращает прокачивать сообщения, даже на секунду, он быстро заметен пользователем. Например, если вы переместите блокнот или любую другую программу поверх вашей собственной программы, а затем снова уйдете, в вашу программу будет отправлено множество сообщений Paint, указывающих, какая область окна, которая теперь внезапно снова стала видимой. Если цикл обработки сообщений обрабатывает что-то заблокированное, то рисование не выполняется.

Итак, если в первом примере async/await не создает новые темы, как это происходит?

Хорошо, что происходит, что ваш метод разделен на две части. Это один из тех общих вопросов, поэтому я не буду вдаваться в подробности, но достаточно сказать, что метод разбит на две эти вещи:

  1. Весь код, ведущий к await, включая вызов GetSomethingAsync
  2. Весь код, следующий за await

Иллюстрация:

code... code... code... await X(); ... code... code... code...

Переставленные:

code... code... code... var x = X(); await X; code... code... code...
^                                  ^          ^                     ^
+---- portion 1 -------------------+          +---- portion 2 ------+

В основном метод выполняется так:

  1. Он выполняет все до await
  2. Он вызывает метод GetSomethingAsync, который делает свое дело, и возвращает что-то, что завершится через 2 секунды в будущем

    Пока что мы все еще в исходном вызове button1_Click, происходящем в основном потоке, вызванном из цикла сообщений. Если код, ведущий к await, занимает много времени, пользовательский интерфейс будет по-прежнему зависать. В нашем примере не так много

  3. Ключевое слово await вместе с некоторой умной магией компилятора заключается в том, что оно в основном что-то вроде "Хорошо, вы знаете, что я собираюсь просто вернуться из обработчика события нажатия кнопки здесь. Когда вы (как в ждем) дойдем до завершения, дайте мне знать, потому что у меня еще есть код для выполнения ".

    На самом деле он даст --- SynchronizationContext class знать, что это сделано, что, в зависимости от текущего контекста синхронизации, который находится в данный момент в игре, поставит в очередь на выполнение. Класс контекста, используемый в программе Windows Forms, поставит его в очередь, используя очередь, которую качает цикл сообщений.

  4. Таким образом, он возвращается обратно к циклу сообщений, который теперь может продолжать качать сообщения, например, перемещать окно, изменять его размер или нажимать другие кнопки.

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

  5. Через 2 секунды мы ожидаем завершения, и теперь происходит то, что он (ну, контекст синхронизации) помещает сообщение в очередь, на которую смотрит цикл обработки сообщений, говоря: "Эй, я получил еще немного кода для вам выполнить ", и этот код является весь код после ожидания.
  6. Когда цикл обработки сообщений достигает этого сообщения, он в основном "повторно вводит" тот метод, в котором он остановился, сразу после await и продолжает выполнение остальной части метода. Обратите внимание, что этот код снова вызывается из цикла сообщений, поэтому, если этот код делает что-то длинное без правильного использования async/await, он снова заблокирует цикл сообщений

Здесь есть много движущихся частей, поэтому здесь есть несколько ссылок на дополнительную информацию, я собирался сказать "если вам это нужно", но эта тема довольно обширна, и она довольно важно знать некоторые из этих движущихся частей . Вы обязательно поймете, что async/await все еще является утечкой. Некоторые из базовых ограничений и проблем все еще проникают в окружающий код, и если они этого не делают, вам обычно приходится отлаживать приложение, которое ломается случайным образом, по-видимому, без веской причины.


Хорошо, а что если GetSomethingAsync раскрутит поток, который завершится через 2 секунды? Да, тогда очевидно, что есть новая тема в игре. Этот поток, однако, не является , потому что асинхронности этого метода, потому что программист этого метода выбрал поток для реализации асинхронного кода. Почти все асинхронные операции ввода/вывода не используют поток, они используют разные вещи. Сами по себе async/await не запускают новые потоки, но, очевидно, "то, чего мы ждем", может быть реализовано с использованием потоков.

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

  • Веб-запросы (и многие другие связанные с сетью вещи, которые требуют времени)
  • Асинхронное чтение и запись файлов
  • и многое другое, хороший знак - если у рассматриваемого класса/интерфейса есть методы с именами SomethingSomethingAsync или BeginSomething и EndSomething и в этом участвует IAsyncResult.

Обычно эти вещи не используют нить под капотом.


Итак, вы хотите что-то из этого "широкого материала темы"?

Что ж, давайте спросим попробуйте Roslyn о нашем нажатии кнопки:

Попробуйте Рослин

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

245
Lasse Vågsæther Karlsen

единственный способ, которым компьютер может выполнять более 1 действия одновременно, - это (1) на самом деле выполнение более 1 действия одновременно, (2) моделирование его путем планирования задач и переключения между ними. Так что, если асинхронное ожидание не делает ни один из этих

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

Кроме того, я думаю, что вам не хватает третьего варианта. Мы, старики, сегодня дети с их рэп-музыкой должны слезть с моего газона и т.д. - помните мир Windows в начале 1990-х годов. Не было многопроцессорных машин и планировщиков потоков. Вы хотели запустить два приложения Windows одновременно, вы должны были вывести . Многозадачность была кооперативной . ОС сообщает процессу, что он запускается, и если он плохо себя ведет, он останавливает обслуживание всех других процессов. Он работает до тех пор, пока не уступит, и каким-то образом он должен знать, как выбрать то место, на котором остановился, в следующий раз, когда операционная система вернет ему управление . Однопоточный асинхронный код во многом похож на этот, с "await" вместо "yield". Ожидание означает: "Я запомню, где я остановился здесь, и позволю кому-то еще какое-то время бежать; перезвоните мне, когда задача, по которой я жду, завершена, и я заберу то, где я остановился". Я думаю, вы можете увидеть, как это делает приложения более отзывчивыми, как это было в Windows 3 дня.

вызов любого метода означает ожидание завершения метода

Есть ключ, который вам не хватает. метод может вернуться до завершения своей работы. В этом суть асинхронности. Метод возвращает, он возвращает задачу, которая означает, что "эта работа в процессе; скажите мне, что делать, когда она будет завершена". Работа метода не выполнена, , даже если он вернулся .

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

80
Eric Lippert

Я объясняю это полностью в своем блоге Нет темы .

Таким образом, современные системы ввода/вывода интенсивно используют DMA (прямой доступ к памяти). На сетевых картах, видеокартах, контроллерах жестких дисков, последовательных/параллельных портах и ​​т.д. Имеются специальные выделенные процессоры. Эти процессоры имеют прямой доступ к шине памяти и выполняют чтение/запись независимо от процессора. ЦП просто нужно уведомить устройство о расположении в памяти, содержащей данные, и затем он может делать свое дело, пока устройство не создаст прерывание, уведомляющее ЦП о завершении чтения/записи.

Когда операция выполняется, процессор не выполняет никаких действий, и, следовательно, нет потока.

77
Stephen Cleary

Я действительно рад, что кто-то задал этот вопрос, потому что я долгое время считал, что потоки необходимы для параллелизма. Когда я впервые увидел циклы событий , я подумал, что это ложь. Я подумал про себя: "Этот код не может быть параллельным, если он выполняется в одном потоке". Имейте в виду, что это после я уже прошел через борьбу за понимание различия между параллелизмом и параллелизмом.

После собственных исследований я наконец нашел недостающую часть: select() . В частности, IO мультиплексирование, реализованное различными ядрами под разными именами: select(), poll(), epoll(), kqueue(). Это системные вызовы , которые, хотя детали реализации различаются, позволяют передавать набор дескрипторов файлов для просмотра. Затем вы можете сделать еще один вызов, который блокирует, пока один из отслеживаемых файловых дескрипторов не изменится.

Таким образом, можно дождаться набора событий IO (основной цикл событий), обработать первое событие, которое завершается, и затем вернуть управление обратно в цикл событий. Промыть и повторить.

Как это работает? Ну, краткий ответ - это магия ядра и аппаратного уровня. Помимо процессора, в компьютере много компонентов, и эти компоненты могут работать параллельно. Ядро может управлять этими устройствами и напрямую связываться с ними для получения определенных сигналов.

Эти системные вызовы мультиплексирования IO являются фундаментальным строительным блоком однопоточных циклов событий, таких как node.js или Tornado. Когда вы await функции, вы наблюдаете за определенным событием (завершение этой функции), а затем возвращаете управление обратно в основной цикл событий. Когда событие, которое вы смотрите, завершается, функция (в конце концов) начинает с того места, где она остановилась. Функции, которые позволяют вам приостанавливать и возобновлять вычисления таким образом, называются сопрограммы .

27
gardenhead

awaitи asyncиспользуют задачи , а не потоки.

Фреймворк имеет пул потоков, готовых выполнить некоторую работу в виде объектов Task ; отправка задачи в пул означает выбор свободной, уже существующей 1, поток для вызова метода действия задачи.
Создание задачи - это создание нового объекта, намного быстрее, чем создание нового потока.

Учитывая Задача можно прикрепить продолжение к нему, это новое Задача объект, который будет выполнен после завершения потока.

Поскольку async/await использует Task , они не создают новый поток.


Хотя техника программирования прерываний широко используется в каждой современной ОС, я не думаю, что она здесь уместна.
Вы можете иметь две связанные с процессором задачи , выполняющиеся параллельно (фактически с чередованием) в одном процессоре, используя aysnc/await.
Это нельзя объяснить просто тем фактом, что ОС поддерживает организацию очереди IORP.


В прошлый раз, когда я проверял, что компилятор преобразовал методы asyncв DFA , работа делится на этапы, каждый из которых заканчивается инструкцией awaitname__.
awaitзапускает свою задачу и присоединяет ее к продолжению для выполнения следующего шага.

В качестве примера концепции приведу пример псевдокода.
Вещи упрощаются ради ясности и потому, что я точно не помню все детали.

method:
   instr1                  
   instr2
   await task1
   instr3
   instr4
   await task2
   instr5
   return value

Это превращается в нечто подобное

int state = 0;

Task NeXTSTEP()
{
  switch (state)
  {
     case 0:
        instr1;
        instr2;
        state = 1;

        task1.addContinuation(NeXTSTEP());
        task1.start();

        return task1;

     case 1:
        instr3;
        instr4;
        state = 2;

        task2.addContinuation(NeXTSTEP());
        task2.start();

        return task2;

     case 2:
        instr5;
        state = 0;

        task3 = new Task();
        task3.setResult(value);
        task3.setCompleted();

        return task3;
   }
}

method:
   NeXTSTEP();

1 На самом деле пул может иметь свою политику создания задач.

22
Margaret Bloom

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

Использование await само по себе не делает ваше приложение волшебно отзывчивым. Если что бы вы ни делали в методе, от которого вы ожидаете, из блоков потока пользовательского интерфейса, он по-прежнему будет блокировать ваш пользовательский интерфейс так же, как это делает не ожидаемая версия.

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

14
Andrew Savinykh

Вот как я все это вижу, это может быть не очень технически точно, но это помогает мне, по крайней мере :).

Есть в основном два типа обработки (вычисления), которые происходят на машине:

  • обработка, которая происходит на процессоре
  • обработка, которая происходит на других процессорах (GPU, сетевая карта и т. д.), назовем их IO.

Таким образом, когда мы пишем кусок исходного кода, после компиляции, в зависимости от объекта, который мы используем (и это очень важно), обработка будет ограничено ЦП или ограничено IO = и на самом деле это может быть связано с комбинацией обоих.

Некоторые примеры:

  • если я использую метод Write объекта FileStream (который является потоком), обработка будет, скажем, 1% привязана к процессору и 99% IO привязана.
  • если я использую метод Write объекта NetworkStream (который является потоком), обработка будет, скажем, 1% привязана к процессору и 99% IO привязана.
  • если я использую метод Write объекта Memorystream (который является потоком), обработка будет на 100% привязана к процессору.

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

Теперь, чтобы оптимизировать вещи, иногда полезно иметь возможность запускать код параллельно (обратите внимание, я не использую Word асинхронно), если это возможно и/или необходимо.

Некоторые примеры:

  • В настольном приложении я хочу напечатать документ, но не хочу его ждать.
  • Мой веб-сервер одновременно обслуживает множество клиентов, каждый из которых получает свои страницы параллельно (не сериализуется).

До async/await у нас было два решения:

  • Нить. Он был относительно прост в использовании с классами Thread и ThreadPool. Потоки связаны только с процессором.
  • "Старая" Begin/End/AsyncCallback модель асинхронного программирования. Это просто модель, она не говорит вам, будете ли вы иметь процессор или IO привязаны. Если вы посмотрите на классы Socket или FileStream, он будет привязан к IO, что здорово, но мы редко используем его.

Async/await - это только общая модель программирования, основанная на концепции Task. Это немного проще в использовании, чем потоки или пулы потоков для задач, связанных с процессором, и намного проще в использовании, чем старая модель Begin/End. Под прикрытием, однако, это "просто" супер сложная полнофункциональная оболочка на обоих.

Итак, реальный выигрыш в основном за счет IO связанных задач, задача, которая не использует ЦП, но async/await - это всего лишь модель программирования, это не помогает Вы должны определить, как/где обработка произойдет в конце.

Это означает, что это не потому, что у класса есть метод "DoSomethingAsync", возвращающий объект Task, который можно предположить, что он будет привязан к процессору (что означает, что он может быть бесполезным , особенно если у него нет параметра токена отмены) или IO Bound (что означает, что это, вероятно, must ) или сочетание того и другого (поскольку модель довольно вирусная, связь и потенциальные выгоды могут, в конце концов, быть супер смешанными и не столь очевидными).

Итак, возвращаясь к моим примерам, выполнение операций записи с использованием async/await в MemoryStream останется привязанным к процессору (я, вероятно, не получу от этого пользы), хотя я, несомненно, выиграю от этого с файлами и сетевыми потоками.

12
Simon Mourier

Обобщая другие ответы:

Async/await в первую очередь создается для задач, связанных с IO, поскольку, используя их, можно избежать блокировки вызывающего потока. Их основное применение - в потоках пользовательского интерфейса, где нежелательно блокировать поток в операции с привязкой IO.

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

2
vaibhav kumar

На самом деле цепочки async await являются конечным автоматом, сгенерированным компилятором CLR.

Однако async await использует потоки, которые TPL использует пул потоков для выполнения задач.

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

Дальнейшее чтение:

Что генерирует async & await?

Async Await и Generated StateMachine

Асинхронный C # и F # (III.): Как это работает? - Томас Петричек

Edit :

Хорошо. Похоже, мои разработки неверны. Однако я должен отметить, что конечные автоматы являются важными активами для async awaits. Даже если вы принимаете асинхронный ввод-вывод, вам все равно нужен помощник, чтобы проверить, завершена ли операция, поэтому нам все еще нужен конечный автомат и определить, какая подпрограмма может выполняться асинхронно вместе.

0
Steve Fan