Асинхронность в C# 5. Часть 4: это не магия

Сегодня я хочу поговорить об асинхронности, которая никак не связана с многопоточностью.

Люди продолжают меня спрашивать: «Ну как возможна асинхронность без многопоточности?» Странный вопрос, поскольку вы наверняка и сами знаете на него ответ. Давайте я задам вопрос по-другому: как возможна многозадачность на одном процессоре? Вы ведь не можете выполнять две вещи «одновременно», если задачу может выполнять только один процессор! Но вы ведь уже знаете ответ на этот вопрос: многозадачность на одноядерном процессе просто означает, что операционная система останавливает одну задачу, сохраняет где-то продолжение, переключается на другую задачу, выполняет ее некоторое время, сохраняет ее продолжение, и, в конце концов, переключается обратно на первую задачу. Параллельность является иллюзией на одноядерных системах; поскольку на самом деле ничего не выполняется одновременно. Как один официант может одновременно обслуживать два столика «одновременно»? Он не может: они обслуживаются по очереди. С опытным официантом клиенту кажется, что его желания выполняются немедленно путем распределения задач так, чтобы ни одни клиент не ждал.

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

Помните, что я не так давно немного рассказывал о том, как в ранних версиях Windows были реализованы процессы? В те дни был только один поток управления; каждый процесс выполнялся некоторое время и затем возвращал управление операционной системе. Операционная система перебирала различные процессы, и передавала каждому из них управление. Если один из них решал загрести себе все процессорное время, тогда остальные процессы подвисали. Это было исключительно кооперативное (cooperative) приключение.

Давайте немного поговорим о многопоточности. Помните, что в далеком 2003-м я немного рассказывал об модели изолированных потоков? Идея заключалась в том, что писать потокобезопасный код сложно и дорого; если вы не хотите идти на эти затраты, значит не делайте этого. Если мы можем гарантировать, что только «поток пользовательского интерфейса» будет обращаться к некоторому элементу управления, тогда ему не нужно заботиться о проблемах многопоточности. Большая часть UI-компонентов ведут себя именно так, а это значит, что поток пользовательского интерфейса ведет себя так, как Windows 3: все должны сотрудничать, в противном случае пользовательский интерфейс перестанет обновляться.

Удивительно большое число людей считают, что приложения реагируют на действия пользователя в Windows магическим образом. Я могу вас уверить, что никакой магии в этом нет. Взаимодействие с пользовательским интерфейсом в Windows реализовано очень просто. Когда что-то происходит, например, нажатие кнопки мыши, операционная система это запоминает. В некоторый момент времени, процесс запрашивает операционную систему: «Ничего интересного не происходило за последнее время?», а операционная система отвечает, «Ну почему же, кто-то нажал кнопку». А процесс затем реагирует на это каким-то образом. А что конкретно происходит, решает процесс; он может проигнорировать это событие, обработать его особым образом, или сказать операционной системе «продолжай самостоятельно, и делай то, что ты обычно делаешь с этим событием». И все это выполняется с помощью очень простого кода:

 while(GetMessage(&msg, NULL, 0, 0) > 0) 
{ 
  TranslateMessage(&msg); 
  DispatchMessage(&msg); 
}

Вот и все. Где-то в сердце каждого процесса имеется цикл, безумно похожий на этот. Один вызов – одно сообщение. Это сообщение может быть слишком низкоуровневым для вашего процесса; например, в нем может говориться, что нажата определенная клавиша на клавиатуре. А вы можете преобразовать его во что-то вроде «нажата кнопка numlock». Именно этим занимается метод TranslateMessage. А также там может находиться определенная процедура обработки конкретного сообщения. Метод DispatchMessage передает это сообщение нужной процедуре.

Я хочу подчеркнуть, что здесь нет никакой магии. Просто цикл. Он работает так же, как и любой другой цикл в языке C. В цикле постоянно вызываются три метода, каждый из которых читает или пишет данные в некоторый буфер и выполняет некоторые действия. Если один из этих методов выполняется слишком долго (обычно это DispatchMessage, поскольку именно в нем содержится логика реакции на соответствующее событие), тогда, догадайтесь, что произойдет? Пользовательский интерфейс перестанет получать, преобразовывать и обрабатывать уведомления от операционной системы, пока этот метод не вернет управление. (Или, пока какой-то другой метод не вытолкнет сообщение из очереди, как рассказывает Реймонд в приведенной статье. Мы еще вернемся к этому вопросу.)

Давайте еще упростим наш код получения и сохранения документов:

 void FrobAll()
{
    for(int i = 0; i < 100; ++i)
        Frob(i);
}

Предположим, этот код выполняется при нажатии кнопки мыши, и в момент вызова метода Frob приходит сообщение от операционной системы типа «кто-то пытается изменить размер окна». Что произойдет? Ничего, вот что. Сообщение будет находиться в очереди, пока управление не вернется обратно в цикл обработки оконных сообщений. Цикл обработки сообщений не выполняется; как такое возможно? Это же всего лишь цикл, а поток, выполняющий этот код, занят выполнением метода Frobbing. Размер окна не будет изменен до окончания выполнения 100 функций Frob.

Теперь предположим у нас есть следующий код:

 async void FrobAll()
{
    for(int i = 0; i < 100; ++i)
    {
        await FrobAsync(i); // получаем каким-то образом запущенную задачу, которая выполняет Forb(i) в текущем потоке 
    }
}

А что будет теперь?

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

В методе FrobAll создается новая задача.

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

Метод FrobAll создает awaiter задачи и устанавливает продолжение.

Управление возвращается циклу обработки сообщений. Цикл видит, что есть сообщение, ожидающее обработки: «пожалуйста, вызови меня». Цикл обработки сообщений обрабатывает это сообщение, и задача начинает свое выполнение. Так происходит первый вызов метода Frob.

Теперь, предположим, приходит другое сообщение, скажем событие об изменение размера окна. Что произойдет? Ничего. Цикл обработки сообщений не выполняется. Он занят вызовом метода Frob. Сообщение остается в цикле необработанным.

После завершения метода Frob управление возвращается задаче. Она помечает себя, как завершенную и посылает еще одно сообщение в очередь сообщений: «как будет у тебя время, вызови, пожалуйста, вызови мое продолжение». (*)

Вызов задачи завершен. Управление возвращает в цикл обработки сообщений. Там обнаруживается необработанное сообщение изменения размера окна. После чего оно обрабатывается.

Вы уже видите, как async делает пользовательский интерфейс более отзывчивым даже без дополнительных потоков? Теперь вам приходится ждать завершения только одного метода Frob, а не всех, прежде чем пользовательский интерфейс отреагирует на события.

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

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

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

ОБНОВЛЕНИЕ: Меня многие спрашивали «значит ли это, что Task Asynchrony Pattern работает только с потоками пользовательского интерфейса, которые содержат цикл обработки сообщений? » Нет. TAP разработан специально для решения проблем параллелизма; он всего лишь расширяет эту работу. Существуют механизмы, позволяющие работать в многопоточных окружениях с пользовательским интерфейсом без очереди сообщений, таких как ASP.NET. Цель этой статьи, рассказать о том, как работает асинхронность в потоке пользовательского интерфейса без многопоточности, а не о том, что асинхронность работает только с пользовательским интерфейсом и без многопоточности. Позднее я расскажу о серверных сценариях работы, где другой тип вспомогательного кода определяет, когда и какую задачу выполнять.

Дополнительный бонус: Старые специалисты VB знают, как сделать отзывчивым пользовательский интерфейс с помощью следующего трюка:

 Sub FrobAll()
  For i = 0 to 99
    Call Frob(i)
    DoEvents
  Next
End Sub

Означает ли это, что этот код аналогичен программе на языке C# 5? Неужели VB6 поддерживает стиль передачи продолжений?

Нет. Все намного проще. DoEvents не возвращает управление в исходный цикл обработки сообщений со словами «продолжи выполнение здесь». Он, скорее, запускает второй цикл обработки сообщений (который, является всего лишь простым циклом), обрабатывает текущие сообщения, а затем возвращает управление методу Froball. Понимаете ли вы, почему это потенциально опасно?

А что если мы находимся в теле метода FrobAll после нажатия на кнопку? И что если в процессе выполнения метода Frob пользователь нажмет на эту же кнопку еще раз? Метод DoEvents запустит еще один цикл обработки сообщений, который очистит очередь сообщений и вызовет метод FrobAll из метода Froball. И так может происходить снова и снова, и мы запустим третий экземпляр метода FrobAll…

Конечно же, это справедливо и для асинхронности, основанной на задачах! Если код запускается по нажатию кнопки, и она будет нажата снова, до завершения предыдущей операции, то будет создан второй набор задач. Для предотвращения этого, разумно, чтобы метод FrobAll возвращал Task, и использовался примерно так:

 async void Button_OnClick(whatever)
{
    button.Disable();
    await FrobAll();
    button.Enable();
}

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

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

------------

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

Оригинал статьи