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

242

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

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

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

Мисс корлиб
источник
17
Задачи ввода-вывода не связаны с процессором и поэтому не требуют потока. Основной смысл асинхронного режима - не блокировать потоки во время задач, связанных с вводом-выводом.
Джухарр
24
@jdweng: Нет, совсем нет. Даже если он создал новые темы , это сильно отличается от создания нового процесса.
Джон Скит
8
Если вы понимаете асинхронное программирование на основе обратных вызовов, то вы понимаете, как await/ asyncработает без создания каких-либо потоков.
user253751
6
Это не делает приложение более отзывчивым, но отговаривает вас блокировать потоки, что является частой причиной не отвечающих приложений.
Оуэн
6
@RubberDuck: Да, он может использовать поток из пула потоков для продолжения. Но он не запускает поток так, как воображает OP, - он не похож на то, что говорит: «Возьми этот обычный метод, теперь запусти его в отдельном потоке - там это асинхронно». Это намного тоньше, чем это.
Джон Скит

Ответы:

299

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

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

Итак, если в первом примере 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 о том, что это сделано, и, в зависимости от текущего контекста синхронизации, который находится в данный момент в игре, будет поставлен в очередь на выполнение. Класс контекста, используемый в программе Windows Forms, поставит его в очередь, используя очередь, которую качает цикл сообщений.

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

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

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

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


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

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

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

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


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

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

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

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

Лассе В. Карлсен
источник
11
Так что это в основном то, что ОП описывает как « Имитация параллельного выполнения с помощью планирования задач и переключения между ними », не так ли?
Берги
4
@ Берги Не совсем. Выполнение действительно параллельное - асинхронная задача ввода-вывода продолжается и не требует потоков для продолжения (это было тем, что использовалось задолго до появления Windows - MS DOS также использовала асинхронный ввод-вывод, хотя и не есть многопоточность!). Конечно, await можно использовать так, как вы это описываете, но в целом это не так. Запланированы только обратные вызовы (в пуле потоков) - между обратным вызовом и запросом поток не требуется.
Луаан
3
Вот почему я хотел явно не говорить слишком много о том, что сделал этот метод, поскольку вопрос был конкретно об асинхронном / ожидающем, который не создает свои собственные потоки. Очевидно, что они могут быть использованы , чтобы ждать для потоков до завершения.
Лассе В. Карлсен
6
@ LasseV.Karlsen - Я принимаю твой отличный ответ, но я все еще одержим одну деталь. Я понимаю, что обработчик событий существует, как на шаге 4, который позволяет насосу сообщений продолжать прокачку, но когда и где «вещь, которая занимает две секунды» продолжает выполняться, если не в отдельном потоке? Если бы он выполнялся в потоке пользовательского интерфейса, он все равно заблокировал бы поток сообщений во время его выполнения, потому что он должен был выполняться некоторое время в том же потоке. [Продолжение] ...
rory.ap
3
Мне нравится ваше объяснение с насосом сообщений. Чем отличается ваше объяснение, когда нет сообщений, как в консольном приложении или веб-сервере? Как достигается повторное внедрение метода?
Пучач
95

Я объясняю это в полной мере в моем блоге Там нет резьбы .

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

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

Стивен Клири
источник
Просто чтобы прояснить ... Я понимаю высокий уровень того, что происходит при использовании async-await. Что касается создания без потоков - нет потоков только в запросах ввода-вывода для устройств, которые, как вы сказали, имеют свои собственные процессоры, которые обрабатывают сам запрос? Можем ли мы предположить, что ВСЕ запросы ввода-вывода обрабатываются на таких независимых процессорах, что означает использование Task.Run ТОЛЬКО для действий, связанных с ЦП?
Йонатан Нир
@YonatanNir: речь идет не только об отдельных процессорах; любой ответ, управляемый событиями, естественно асинхронный. Task.Runнаиболее подходит для действий , связанных с процессором , но также имеет несколько других применений.
Стивен Клири
1
Я закончил читать вашу статью, и все еще есть кое-что базовое, чего я не понимаю, так как я не очень знаком с реализацией ОС нижнего уровня. Я получил то, что вы написали, где вы написали: «Операция записи сейчас« в полете ». Сколько потоков ее обрабатывает? Нет». , Так что, если нет потоков, то как выполняется сама операция, если не в потоке?
Йонатан Нир,
6
Это недостающий кусок в тысячах объяснений !!! На самом деле, кто-то работает в фоновом режиме с операциями ввода-вывода. Это не поток, а другой выделенный аппаратный компонент, выполняющий свою работу!
the_dark_destructor
2
@PrabuWeerasinghe: Компилятор создает структуру, которая содержит переменные состояния и локальные переменные. Если await должен привести к выходу (то есть, вернуться к вызывающей стороне), эта структура упакована и живет в куче.
Стивен Клири
87

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

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

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

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

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

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

Эрик Липперт
источник
Другие современные языки высокого уровня также поддерживают аналогичное явное кооперативное поведение (т. Е. Функция выполняет некоторые действия, дает [возможно, отправку некоторого значения / объекта вызывающей стороне], продолжается там, где она остановилась, когда управление возвращено [возможно, с дополнительным вводом данных] ). Во-первых, в Python достаточно много генераторов.
JAB
2
@JAB: абсолютно. Генераторы называются «блоками итераторов» в C # и используют yieldключевое слово. И asyncметоды, и итераторы в C # являются формой сопрограммы , которая является общим термином для функции, которая знает, как приостановить свою текущую операцию для возобновления позже. У многих языков в эти дни есть сопрограммы или подобные ему потоки управления.
Эрик Липперт
1
Хорошая аналогия с yield - это многозадачность в рамках одного процесса. (и, таким образом, избегая проблем со стабильностью системы при общесистемной совместной многозадачности)
user253751
3
Я думаю, что концепция «процессорных прерываний», используемая для ввода-вывода, не знает многих модемных «программистов», поэтому они думают, что поток должен ждать каждого бита ввода-вывода.
Ян Рингроз
@EricLippert Асинхронный метод WebClient фактически создает дополнительный поток, см. Здесь stackoverflow.com/questions/48366871/…
KevinBui
28

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

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

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

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

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

gardenhead
источник
25

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

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

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

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


Хотя техника программирования прерываний широко используется в каждой современной ОС, я не думаю, что она здесь уместна.
Вы можете иметь две связанные с процессором задачи, выполняемые параллельно (фактически с чередованием) в одном процессоре 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 На самом деле пул может иметь свою политику создания задач.

Маргарет Блум
источник
16

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

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

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

Андрей Савиных
источник
3
Это не соревнование во-первых; это сотрудничество!
Эрик Липперт
16

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Саймон Мурье
источник
1
Это довольно хороший ответ, использующий theadpool для работы с процессором, плохой в том смысле, что потоки TP должны использоваться для разгрузки операций ввода-вывода. Работа с привязкой к ЦП, конечно же, должна блокироваться с оговорками, и ничто не мешает использовать несколько потоков.
Дэвидкарр
3

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

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

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

Вайбхав Кумар
источник
Хорошее резюме, но я думаю, что оно должно ответить еще на 2 вопроса, чтобы дать полную картину: 1. В каком потоке выполняется ожидаемый код? 2. Кто контролирует / настраивает упомянутый пул потоков - разработчик или среда выполнения?
Стойке
1. В этом случае, в основном ожидаемый код - это операция ввода-вывода, которая не использует потоки ЦП. Если желательно использовать await для работы с процессором, можно создать отдельную задачу. 2. Поток в пуле потоков управляется планировщиком задач, который является частью инфраструктуры TPL.
Вайбхав Кумар
2

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

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

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

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

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

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что говорите, скажите компилятору, что «остановитесь здесь, добавьте остальную часть функции как задачу продолжения». Компилятор позаботится обо всем остальном, так что вам не придется.

Calmarius
источник
0

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

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

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

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

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

Async Await и Generated StateMachine

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

Редактировать :

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

Стив Фан
источник
0

Это не прямой ответ на вопрос, но я думаю, что это интересная дополнительная информация:

Async и await не создают новые темы сами по себе. НО, в зависимости от того, где вы используете асинхронное ожидание, синхронная часть ДО того, как ожидание может работать в другом потоке, чем синхронная часть ПОСЛЕ ожидания (например, ядра ASP.NET и ASP.NET ведут себя по-разному).

В приложениях на основе UI-Thread (WinForms, WPF) вы будете находиться в одном потоке до и после. Но когда вы используете асинхронное удаление в потоке пула потоков, поток до и после ожидания может не совпадать.

Отличное видео на эту тему

Blechdose
источник