Что значит «одновременно»
В прошлом уроке «Молния» проезжала все круги, затем начинала «Комета», а «Вихрь» ждал их обоих. Организовать такой заезд удалось одним знакомым циклом:
foreach (RaceCar car in cars)
{
car.Drive(3);
}
Это фрагмент Main из нашего проекта. Каждый следующий вызов Drive начинался после завершения предыдущего, поэтому порядок событий совпадал с порядком автомобилей в массиве. Такое выполнение называют последовательным.
Теперь хочется выпустить машины вместе. Но что именно должно измениться в программе? Нам нужно, чтобы работа над одним автомобилем могла продвигаться до того, как завершится работа над другим. «Комета» не должна ждать финиша «Молнии», чтобы получить свой первый круг.
Посмотрим, как это могло бы происходить, если бы каждому автомобилю мы поручили отдельный поток, а для выполнения был доступен только один логический процессор. Планировщик мог бы дать время потоку «Молнии», затем переключиться на «Комету», потом на «Вихрь» и снова вернуться к первой машине. Все три работы ещё не закончены, но каждая получает возможность продолжиться.
В журнале это могло бы выглядеть так: «Молния» сообщает о первом круге, затем появляются сообщения «Кометы» и «Вихря», а потом «Молния» сообщает о втором. Это пример возможного порядка, а не расписание, которому обязана следовать система. Планировщик ничего не знает о наших кругах: он может переключить поток посреди работы или дать ему выполнить несколько кругов подряд.
В этом случае потоки чередуются. Они продвигаются в течение одного периода времени, хотя единственный доступный логический процессор в каждый момент выполняет команды одного из них. Именно поэтому несколько работающих программ могут выглядеть одновременными даже тогда, когда им приходится делить вычислительное время.
Слово «конкурентное» здесь не означает соперничество машин за первое место. Речь о том, что программа ведёт несколько незавершённых работ в один период времени. Например, пока обработка событий «Молнии» ещё продолжается, обработка событий «Кометы» уже началась.
Если доступных логических процессоров несколько, два потока могут выполнять команды в один и тот же момент. Один обрабатывает «Молнию», другой — «Комету». Это уже параллельное выполнение: работа действительно происходит одновременно на нескольких вычислительных ресурсах.
Конкурентность и параллельность связаны, но не совпадают. В первом случае мы говорим о продвижении нескольких работ в общий период, а во втором уточняем, что какие-то из них выполняются физически одновременно. По одним перемешанным строкам в консоли отличить эти случаи не получится: похожий журнал можно получить и при чередовании потоков.
А где в этой картине многопоточность? Она описывает устройство программы: в одном процессе есть несколько потоков выполнения. Мы можем поручить им разные автомобили, после чего планировщик будет распределять между ними процессорное время. Получится ли параллельная работа, зависит уже и от доступных ресурсов, и от того, какие потоки готовы выполняться.
При этом даже для нашей гонки отдельный поток на каждую машину не обязателен. Один поток мог бы сначала немного продвинуть «Молнию», затем «Комету» и «Вихрь», после чего повторить такой шаг. Это основа игрового цикла: за один проход обновляется состояние нескольких объектов, а не вся дистанция одного автомобиля. Внешне они тоже двигались бы вместе.
Мы будем использовать отдельные потоки как учебную модель. На ней удобно наблюдать независимый запуск, ожидание финиша и доступ к общим данным. Это осознанный выбор для курса, а не требование к устройству любой гоночной игры. Само добавление потоков также ничего не говорит об ускорении: их работу нужно организовать, а результат зависит от задачи.
Когда работа ждёт
До сих пор мы обсуждали выполнение команд. Но диспетчеру гонки может понадобиться то, что наша программа не вычисляет сама: например, погода от сетевой службы. Запрос отправлен, а ответ ещё не пришёл. Сколько бы дополнительных ядер мы ни дали симулятору, удалённый сервер от этого не ответит быстрее.
Если выполнить запрос с блокирующим ожиданием, вызвавший его поток не сможет перейти к другой работе до получения результата. Для небольшой программы, которой больше нечем заниматься, такой вариант может быть вполне достаточным. Но нашему диспетчеру во время ожидания могут приходить новые события заезда, и останавливать их обработку из-за погоды неудобно.
Здесь понадобится асинхронность. Она позволяет начать операцию и организовать получение её результата позже, не заставляя вызывающий код блокироваться до завершения.
В случае асинхронного сетевого запроса ожидать будет операция, а поток не обязан оставаться заблокированным на всё это время. Он может выполнять другую доступную работу. Когда ответ придёт, программа продолжит его обработку — не обязательно в том же потоке, который начинал запрос.
Для такого ожидания не требуется создавать отдельный поток под каждый запрос. Но и сам ответ быстрее не приходит: меняется то, чем программа может заниматься до его получения. В одной программе поэтому могут сочетаться несколько потоков и асинхронные операции. Эти понятия не обозначают взаимоисключающие режимы.
Разница становится яснее, если сравнить две задачи симулятора. Первая — рассчитать траектории множества автомобилей. Здесь результат требует вычислений, и основная нагрузка приходится на процессор. Такие задачи называют CPU-bound — ограниченными вычислительной работой. Если независимые части расчёта достаточно велики, их распределение между несколькими вычислительными ресурсами может помочь. Для короткой операции расходы на такое распределение могут оказаться больше выигрыша.
Вторая задача — получить погоду по сети. Здесь существенная часть времени уходит на ожидание ввода-вывода. Такие задачи называют I/O-bound. Асинхронное ожидание позволяет не занимать поток на всё время, пока данные идут к нам. Вычислить траекторию и дождаться ответа — разная работа, поэтому и подходящие способы её организации различаются.
Наш Drive пока только выводит сообщения. Он не рассчитывает физику автомобиля и не запрашивает данные по сети, поэтому измерять на нём пользу параллельных вычислений или асинхронности рано. Сейчас он помогает увидеть порядок вызовов. В следующем уроке создадим первый дополнительный поток и поручим ему уже знакомый метод. После запуска посмотрим, как изменился журнал заезда.
Мы начали с вопроса, как позволить автомобилям продвигаться до финиша друг друга. Их обработка может чередоваться на одном логическом процессоре или выполняться параллельно на нескольких. Оба случая позволяют вести несколько работ в общий период времени, но перемешанные сообщения ещё не доказывают физическую параллельность.
Для учебного заезда мы выбрали многопоточность: поручим работу с автомобилями отдельным потокам одного процесса. Это позволит разбирать их взаимодействие на знакомом примере. При этом один поток тоже способен обновлять несколько машин по очереди в игровом цикле — отдельный поток на каждый объект не является обязательным правилом.
Ожидание погоды показало другую задачу: программе нужен внешний результат, а не дополнительные вычисления. Асинхронный запрос позволит не блокировать поток на время этого ожидания. К нему мы придём позже, а сначала запустим первый дополнительный поток и продолжим нашу гонку.