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