Замечание из этой же серии: если 80% программного кода поддаются распараллеливанию, а 20% - нет, то получить больше 40% прироста производительности от добавления второго ядра (равно как и более чем пятикратный выигрыш при любом числе процессоров) невозможно. Прибавьте сюда принципиально неразделимые ресурсы - например, оперативную память[Если программу тормозила в первую очередь она и если 90% времени CPU ожидал, пока в кэш не будет залита очередная порция данных, то установка двух процессоров приведет в лучшем случае к тому, что каждый из CPU будет простаивать 95% времени, а выигрыш в быстродействии составит… 5%], - и сразу станет ясно, почему выжать из двухъядерного процессора двукратное превосходство в производительности даже в специализированных программах удается через раз, а в среднем все ограничивается 40-80%. Это не проблема, а скорее, особенность параллельного программирования; тем не менее следует помнить, что параллельность - отнюдь не панацея и что от порядка распределения данных по потокам может зависеть многое.
Грабли вторые - существование разделяемых между потоками данных. Представим простейшую модельную ситуацию, когда танк попадает под обстрел во время ремонта. В текущий тик времени «в танк ударила болванка, вот-вот рванет боекомплект» - с танка снимается 70 единиц «здоровья», гибнет водитель и выходит из строя двигатель. Но в тот же тик механику, вторую минуту заменяющему разбитый трак, удается-таки справиться со своей задачей, поэтому танку добавляется 10 единиц «здоровья» и снимаются все ранее полученные повреждения[Знаю, что звучит дико, но в играх и не такое бывает]. И если все происходит действительно одновременно, то окончательное состояние танка получается недетерминированным - у него с равной вероятностью может и убавиться 70 очков, и прибавиться 10; могут и сохраниться все прежние повреждения, и бесследно сгинуть новые - все зависит только от того, «кто последний» записывал «правильные» по его мнению данные в область памяти, соответствующую танку. Вполне может получиться так, что, к примеру, 70 единиц жизни с танка снимут, а повреждения будут устранены. Или наоборот. И это еще в лучшем случае: а что будет, если в ходе попадания той болванки игра посчитает танк уничтоженным и сотрет его из памяти, а тут откуда ни возьмись прибежит механик и заявит, что несуществующему танку нужно прибавить 10 единиц «здоровья»? Катастрофа и вылет программы!
***
Поэтому для защиты разделяемых между несколькими потоками переменных в параллельных программах вводятся специальные объекты синхронизации, которые позволяют заблокировать изменение того или иного объекта двумя потоками одновременно. Делается это примерно так: объект[Объект синхронизации, но вместе с ним - и весь объект (тот же наш игровой танк, например), который этот объект синхронизации защищает] отдается какому-то одному конкретному потоку, а другие желающие получить объект в пользование ставятся в очередь и ждут, пока нужный объект не освободится. Программисты, забывающие это сделать, как раз и наступают на те самые вторые грабли, обладающие пренеприятным свойством незаметно ломать программу. И ломать так, что она обрушивается не в момент «поломки», а минуты через три, когда «сомнительное место» давным-давно затерялось в пучинах кода, причем происходит это каждый раз в новом месте и в новое время.
Грабли третьи: если недостаточное количество объектов синхронизации - зло, ибо программист рискует заполучить время от времени глючащую программу, то переизбыток этих объектов - жуткие вериги на шее проекта. Пусть, скажем, практически любой из наших объектов может изменять игровую землю и стремится получить ее для себя. Поскольку принадлежать двум объектам одновременно земля не сможет, то находится она в каждый момент времени только у одного объекта. Который и будет обрабатываться, а всем остальным потокам придется терпеливо ожидать своей очереди. Получится вот такая картинка (рис. 3), где параллельными вычислениями и не пахнет. С этим успешно борются, беря блокировку ровно на то время, пока она действительно необходима (прочитать состояние земли, проверить его и записать новое состояние), но тогда возникают новые грабли - дедлоки. Предположим, что мы угодили снарядом в землю совсем рядышком от стоящего на ней танка. Пострадала и земля, и танк. Программа добросовестно определяет, что, где и как требуется изменить (поменять рельеф земли и изменить «жизни» у танка), берет первый объект синхронизации «на землю», тянется ко второму объекту синхронизации «на танк»… и тут же виснет. В чем дело? Оказывается, этот танк ждет, когда освободится земля, чтобы внести в нее свои изменения. И пока он земли не дождется, он не отдаст блокировку на самого себя, которая нужна потоку, который «держит» блокировку на ту самую землю. Считаете, что подобный дедлок - надуманная штука? Значит, вы никогда не занимались параллельным программированием: подобные ситуации здесь возникают если не на каждом шагу, то, по крайней мере, очень часто. Еще одна ситуация того же рода - один из потоков взял блокировку на что-то, но забыл освободить, а сторонний поток некстати решил это что-то проверить. Отсюда вытекает второе золотое правило «параллельного» программиста - никогда не пытаться обладать двумя объектами одновременно и тщательно проверять, что все однажды взятые объекты своевременно освобождаются.
Неплохой джентльменский набор, не правда ли? А сколько занятных вопросов связано с работой в «параллельном» режиме стандартных библиотек! К примеру, функция GetHostByAddr в стандартном программном интерфейсе Microsoft, активно использующаяся сетевыми программами, одно время грешила тем, что при ее повторных вызовах с разными адресами из разных потоков выдавала обоим потокам указатель на одну и ту же структуру данных, хотя запрашивали они совсем разное. И если производитель клятвенно заверяет вас, что его библиотека совсем-совсем, ну честно-честно является thread-safe[Безопасной для использования в параллельном режиме], - вспомните, что даже Microsoft нет-нет да и ошибается, модифицируя продукт с десятилетней историей. А о трудности отыскания подобных глюков красноречиво свидетельствует то, сколько потребовалось времени, чтобы GetHostByAddr выловить[Исправили его в Windows XP SP1. Сколько лет он жил никем не замеченный, одному богу известно].
Интерфейс MPI
Еще один стандарт де-факто в мире параллельных вычислений - пакет MPI (Message Passing Interface), тоже разрабатывавшийся как универсальное средство облегчения жизни разработчику параллельного ПО. Только устроено оно совсем иначе, нежели OpenMP, и ориентировано в основном для других, «более возвышенных» целей.
Идея MPI заключается в следующем. OpenMP (да и многие другие системы для разработки параллельного ПО) ориентируется на так называемые системы с общей памятью, когда на компьютере запущена всего одна программа (точнее, один процесс), но внутри этого процесса «живет» несколько потоков исполнения, каждому из которых доступна вся память процесса, а стало быть, и все его данные. MPI исходит из другой предпосылки: на компьютере запущено много-много программ (процессов), которые друг с другом напрямую общаться не могут и вынуждены устанавливать контакт через специальные окна или даже внешние каналы связи. Называется все это IPC (Inter-Process Communication) и, как вы уже, наверное, догадались, сильно изменяется от компьютера к компьютеру и от операционной системе к операционной системе. А MPI - попытка стандартизировать связь между процессами, предоставив всем желающим удобную модель запуска на нескольких процессорах тех программ, которые будут коллективно обрабатывать данные, и обеспечивая «почтовые пересылки» между этими программами. Вот и весь Message Passing Interface.