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

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

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

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

Ответ 1

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

Разрешите простое событие нажатия кнопки в приложении 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 секунды, и в течение этого времени никакие сообщения не обрабатываются.

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

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

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

  • Весь код, предшествующий await, включая вызов GetSomethingAsync
  • Все следующие коды await

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

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

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

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

В основном метод выполняется следующим образом:

  • Выполняет все до await
  • Он вызывает метод GetSomethingAsync, который выполняет свою задачу, и возвращает то, что будет завершено в будущем

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

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

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

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

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

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

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


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

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

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

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


ОК, так что вы хотите получить часть этого "широкого предмета"?

Хорошо, позвольте спросить Попробовать Roslyn о нашей кнопке:

Попробуйте Roslyn

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

Ответ 2

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

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

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

Ответ 3

Единственный способ, которым компьютер может казаться делать больше чем 1 вещь за раз, - это (1) Фактически делать больше чем 1 вещь за раз, (2) имитировать его, планируя задачи и переключаться между ними. Поэтому, если async-await не выполняет ни те, ни другие

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

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

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

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

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

Ответ 4

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

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

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

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

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

Ответ 5

await и async используйте "Задачи без потоков".

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

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

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


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


В прошлый раз, когда я проверил преобразованные методы компилятора async в DFA, работа делится на этапы, каждый из которых заканчивается инструкцией await.
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 Собственно пул может иметь свою политику создания своей задачи.

Ответ 6

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

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

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

Ответ 7

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

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

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

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

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

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

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

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

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

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

Перед асинхронным/ожиданием мы, по сути, имели два решения:

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

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

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

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

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

Ответ 8

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

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

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

Ответ 9

Я пытаюсь объяснить это снизу вверх. Может быть, кто-то найдет это полезным. Я был там, сделал это, заново изобрел это, когда делал простые игры в DOS на Паскале (старые добрые времена...)

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

while (getMessage(out message)) // pseudo-code
{
   dispatchMessage(message); // pseudo-code
}

Фреймворки обычно скрывают эту деталь от вас, но она есть. Функция getMessage считывает следующее событие из очереди событий или ожидает, пока событие не произойдет: перемещение мыши, нажатие клавиши, нажатие клавиши, щелчок и т.д. И затем dispatchMessage отправляет событие соответствующему обработчику события. Затем ожидает следующего события и т.д. До тех пор, пока не произойдет событие quit, которое выйдет из циклов и завершит работу приложения.

Обработчики событий должны работать быстро, чтобы цикл обработки событий мог опрашивать больше событий, а пользовательский интерфейс оставался отзывчивым. Что произойдет, если нажатие кнопки запускает такую дорогостоящую операцию, как эта?

void expensiveOperation()
{
    for (int i = 0; i < 1000; i++)
    {
        Thread.Sleep(10);
    }
}

Хорошо, пользовательский интерфейс перестает отвечать на запросы до тех пор, пока не завершится 10-секундная операция, когда элемент управления остается внутри функции. Чтобы решить эту проблему, вам нужно разбить задачу на небольшие части, которые можно выполнить быстро. Это означает, что вы не можете обработать все это в одном событии. Вы должны выполнить небольшую часть работы, а затем отправить другое событие в очередь событий, чтобы попросить продолжения.

Таким образом, вы изменили бы это на:

void expensiveOperation()
{
    doIteration(0);
}

void doIteration(int i)
{
    if (i >= 1000) return;
    Thread.Sleep(10); // Do a piece of work.
    postFunctionCallMessage(() => {doIteration(i + 1);}); // Pseudo code. 
}

В этом случае запускается только первая итерация, затем она отправляет сообщение в очередь событий для запуска следующей итерации и возвращает. В нашем примере псевдофункция postFunctionCallMessage помещает событие "вызов этой функции" в очередь, поэтому диспетчер событий вызовет его, когда достигнет. Это позволяет обрабатывать все другие события графического интерфейса при непрерывном выполнении фрагментов длительной работы.

Пока эта длительная задача выполняется, ее событие продолжения всегда находится в очереди событий. Таким образом, вы в основном изобрели свой собственный планировщик задач. Где события продолжения в очереди - это "процессы", которые работают. На самом деле это то, что делают операционные системы, за исключением того, что отправка событий продолжения и возврат в цикл планировщика осуществляется через прерывание таймера ЦП, когда ОС зарегистрировала код переключения контекста, поэтому вам не нужно об этом заботиться. Но здесь вы пишете свой собственный планировщик, поэтому вам нужно позаботиться об этом - пока.

Таким образом, мы можем запускать долго выполняющиеся задачи в одном потоке параллельно с графическим интерфейсом, разбивая их на небольшие куски и отправляя события продолжения. Это общая идея класса Task. Он представляет часть работы, и когда вы вызываете .ContinueWith для нее, вы определяете, какую функцию вызывать как следующую часть, когда заканчивается текущая часть (и ее возвращаемое значение передается в продолжение). Класс Task использует пул потоков, где в каждом потоке есть цикл обработки событий, ожидающий выполнения частей, аналогичных тому, который я хотел показать в начале. Таким образом, вы можете запускать миллионы задач параллельно, но только несколько потоков для их выполнения. Но это будет так же хорошо работать с одним потоком - до тех пор, пока ваши задачи правильно разделены на маленькие кусочки, каждый из которых работает в parellel.

Но выполнение всей этой цепочки, разбивающей работу на мелкие части вручную, является трудоемкой работой и полностью портит структуру логики, потому что весь код фоновой задачи в основном представляет собой беспорядок .ContinueWith. Вот где вам поможет компилятор. Это делает всю эту цепочку и продолжение для вас в фоновом режиме. Когда вы говорите await, вы говорите компилятору, что "остановитесь, добавьте остальную часть функции в качестве задачи продолжения". Компилятор позаботится обо всем остальном, так что вам не придется.

Ответ 10

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

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

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

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

Что генерирует async и ждет?

Async Await и Generated StateMachine

Asynchronous С# и F # (III.): Как это работает? - Томас Петричек

Edit

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