Но вот лязганье траков по брусчатке внезапно поутихло, а затем и вовсе смолкло, - на очередном перекрестке бронеколонну встретил окоп и наспех выстроенная баррикада. Головной танк, прицеливаясь, повернул башню вправо и слегка опустил ствол. Мгновение безмолвия - и ночь пронзает громкий звук…
…традиционной ошибки приложения Windows, - моя программа, имитирующая сцену сражения, вылетела с очередным малоинформативным сообщением о несвоевременном обращении в оперативную память по адресу 0x85e54f29. «Когда же это, черт возьми, кончится?» - подумал я, со вздохом запуская отладчик…
Введение
Как вы уже догадались, эта статья - отнюдь не про войну. Просто мне не хотелось начинать описание технологий параллельного программирования со скучных векторов и систем массового обслуживания, встречающихся в наиболее распространенных параллельных приложениях - всяческих числодробилках типа графических пакетов или кодеров-декодеров и серверах, и я решил остановиться на другом «двигателе прогресса» - на компьютерных играх. С их помощью я попробую объяснить, что такое параллельное программирование, почему оно считается столь трудоемким, почему требует от программиста высокой квалификации и какие инструменты могут облегчить жизнь начинающего (и не только) «параллельного» разработчика. А модельной задачей нам послужит то самое сражение в безымянном городе, с которого начался наш рассказ.
Задача
Итак, допустим, что мы делаем стратегическую игрушку по мотивам Великой Отечественной войны. У нас есть игровое поле с разбросанными по нему неподвижными объектами, есть какое-то количество движущихся, умирающих и отстреливающихся объектов (юнитов), есть некий модуль искусственного интеллекта, заставляющий юниты охотиться друг на друга и не ломиться сквозь стену, выполняя приказ игрока, а аккуратно объезжать препятствия. Также у нас есть некий программный код - физический движок, обеспечивающий не только красивый и реалистичный разлет обломков юнитов после прямого попадания, но и более приземленные задачи вроде покачивания танка на ухабах, плавный разгон и торможение, модель оружия и повреждений и прочие «детали», придающие сцене естественность. И, конечно же, у нас есть графический движок, который отвечает за отображение творящегося на экране безобразия. Словом, есть все необходимое, и нам остается только собрать все это в единую программу.
Для начала рассмотрим «традиционный» подход к программированию, который в нашем модельном примере выглядит следующим образом. Пишется некоторый кусочек кода (назовем его GameTick), который последовательно перебирает все имеющиеся в игре объекты, «вычисляя» события, происходящие с ними в данный момент времени (Tick, тик). Скажем, один объект - солдат в окопе - «поразмыслил» своим модулем AI, принял решение бросить гранату и бросил ее, сгенерировав новый игровой объект - летящую гранату. Другой объект - брошенный другим солдатом секунду назад «коктейль Молотова» - в результате вычислений физического движка изменил свое положение в пространстве, прошел проверку на соприкосновение с броней танка и прекратил существование. Танк, угодивший под бутылку с зажигательной смесью, перешел из состояния «танк обыкновенный» в состояние «танк горящий». Другой танк повернул башню еще на пять градусов влево. На этом игровые объекты, требующие вычислений, закончились, и сцена с игровыми объектами ушла на обработку к графическому движку, который конвертировал абстрактных солдат, танков и игровое поле во вполне осязаемые полигоны и текстуры, понятные видеокарте. При этом на экране появилась описанная выше картинка, сменившая предшествовавший кадр. А меж тем наша программа опросила клавиатуру и мышь (не решил ли пользователь как-то повлиять на ситуацию?) и перешла на уже известный нам участок кода делать очередной GameTick. Добавим сюда музыкальное сопровождение по вкусу - и игра «заработала»[Разумеется, все не так просто, но в первом приближении можно довольствоваться и этой моделью]. Танки ездят, снаряды летают, геймер отчаянно дает бойцам указания мышкой, в колонках «бумкает» все, что положено… остается лишь записать свежесотворенный шедевр на DVD и топать к издателю.
А теперь представим, что все то же самое мы хотим сделать «параллельно».
«Детские трудности» параллельного программирования
Зачем? Ну хотя бы затем, что сегодня это модно. И без технологии HyperThreading и оптимизаций под нее нам уже года три как не жить. На самом деле, конечно, причина грядущего перехода к параллельному программированию в том, что крупнейший производитель процессоров для ПК - корпорация Intel - обещает, что к концу следующего года более 70% продаваемых ею процессоров[А стало быть, как минимум половина всех продаваемых x86-совместимых процессоров] будут двухъядерными, - с чуть меньшей производительностью в пересчете на один-единственный поток исполнения, но зато выполняющие два (или даже четыре) потока одновременно[Подробнее о двухъядерных процессорах см. на offline.computerra.ru/2005/594/39218]. Поэтому если программист сумеет хорошо «раскидать» программу на два параллельных потока (сам процессор делать этого не умеет), то он получит на двухъядерном чипе гораздо большую производительность, чем на аналогичном по стоимости одноядерном. А если не сумеет - то получится как в хорошо знакомом всем россиянам изречении о том, что «хотели как лучше…». Таким образом, налицо и кнут (грозящее снижение производительности, если оставить все «как есть»), и пряник (потенциальный прорыв в скорости) - более чем убедительные аргументы за то, чтобы не отставать от технического прогресса.
***
Но как это вообще делается? В классическом варианте - полностью вручную. Главный поток программы (который создала при запуске приложения операционная система) формирует (посредством специальных системных вызовов) несколько новых потоков[В случае Unix-систем при этом происходит весьма нетривиальная вещь: при создании первого потока «главный» поток как бы «замораживается» операционной системой, а в операционной системе возникают еще две сущности - новый поток, запущенный по просьбе «главного», и «наследующий» поток, который продолжает исполнение «главного» кода, но не является собственно процессом], приступающих к выполнению программы с того места, которое указывается в числе параметров вызова. Детали реализации в разных ОС отличаются[Существует два основных стандарта: используемый в мире Open Source стандартный интерфейс pthreads (POSIX Threads) и детище Microsoft - так называемая Win32 Threading model], однако принцип совершенно одинаков: одна программа, одни и те же данные, несколько точек исполнения, одновременно перемещающихся по программе. Таким образом, вместо кода типа
Выполнить Действие1( )
Выполнить Действие2( )
мы записываем что-то вроде
ЗапуститьПоток(Действие1)
ЗапуститьПоток(Действие2)
и при этом Действие1 и Действие2 выполняются параллельно и независимо друг от друга. То есть в отличие от «классики», где программа сперва проверяет, попал ли в танк снаряд, а уж затем решает, что этому танку делать дальше, здесь обсчет поведения объектов происходит одновременно. Правда, поскольку действие, как правило, выполняется одно, но над разными данными (скажем, для десятка танков вызывается один и тот же программный код, рассчитывающий физику и новые координаты танка), то гораздо чаще возникает код
ЗапуститьПоток(Действие, для Объекта1)
ЗапуститьПоток(Действие, для Объекта2)
где в самом действии образуется конструкция вида
Понять, для каких данных нужно выполнять действие
Выполнить действие для этих данных
С практической точки зрения это означает, что теперь не только снаряды летают одновременно с перемещением танков, но и танки ездят не «по очереди», а все сразу. И поскольку танков и снарядов у нас довольно много, то, казалось бы, игра не просто параллелится, - она разбивается на сотни потоков и, стало быть, сможет получить дополнительные преимущества даже на будущих двухпроцессорных 32-ядерных системах с поддержкой четырехпоточного HyperThreading. Однако этого не происходит, и вот почему: запуск потока - весьма и весьма дорогая по меркам процессора процедура, которая требует немалого времени, грозящего свести на нет все преимущества параллельной обработки. Переключение между потоками - тоже процесс небыстрый, и если мы разбили исполнение программы на 32 потока, а процессор умеет исполнять только два потока одновременно, то постоянные переключения между шестнадцатью потоками на каждое виртуальное ядро очень сильно «просадят» производительность. А потому программисты зачастую отказываются от «простого» решения и прибегают к более сложной конструкции, когда все необходимые рабочие потоки (причем их число тщательно выбирается, чтобы исключить лишние переключения) запускаются заблаговременно, а в нужных местах «главный» поток «раздает» им текущие задания. Что-то вроде