Применение многопоточности в играх. (2 стр)
Автор: Алексей Строкин
Решение проблемы с командами и их параметрами
Очевидно, что так просто подобную проблему с командами и их параметрами не решить, но стоит вспомнить, что язык С++ предоставляет массу средств для сокрытия и обеспечения надёжности кода связанного с преобразованиями типов. Я имею в виду использование template.
Схема процесса пересылки команды между секциями.
При отсылке команды происходит следующее:
1. Секция источник вызывает темплейтную функцию SendCommand() и передаёт ей в качестве параметров команду и адрес секции приёмника.
Например так:
SendCommand( VFS::SET_BUFFER(DataPos,DataPos+size),to);Где SendCommand() это:
template<class COMMAND_NAME> void SendCommand(const COMMAND_NAME& command, const THREAD_GUID& to);
2. Команда копируется в буфер команд потока получателя, с указанием кому она предназначена, её размера (эта информация нужна буферу для освобождения блока памяти занятого командой, так как размер команды не фиксирован и зависит от количества и типов её параметров) и уникального ID команды который берется у генератора ID (ниже будет описан отдельно) .
Пример функция класса буфера команд:
template<class T> void AddCommand(const T& command, const THREAD_GUID& to) { //Проверка на наследование класса T от THREAD_COMMAND //т.к. в последствии придётся удалять T из буфера и //будет нужен виртуальный деструктор static_cast<const THREAD_COMMAND*>( &command); //Выделение места в буфере под команду и др. данные COMMAND* cm=( COMMAND*)Insert( sizeof( T) + COMMAND_SIZE); //Инициализация cm->to=to; cm->size=sizeof( T); cm->ID=::GetID( ( T*)NULL); //Копирование команды в буфер new ( ( THREAD_COMMAND*)( ( BYTE*)cm + COMMAND_SIZE)) T( command); } struct COMMAND { THREAD_GUID to; DWORD size; DWORD ID; }; enum { COMMAND_SIZE = sizeof( COMMAND) }; //базовый класс команды class THREAD_COMMAND { public: virtual ~THREAD_COMMAND( ) {} };
3. Принимающий поток обращается в буфер команд, с помощью поля адресата находит у себя в массиве секцию получатель.
4. Из секции извлекается таблица зарегистрированных ей ID команд и адресов функций для преобразования THREAD_COMMAND в требуемый тип.
5. В таблице быстрым поиском находится ID команды равный ID в буфере. Под быстрым поиском я подразумеваю, что используется некий алгоритм для ускорения поиска. Например, хеш-функция, сортировка по ID, организация таблицы в виде сбалансированного дерева и т.п.
6. После чего по найденному адресу функции преобразования делается её вызов с передачей ей в качестве параметра указателя на THREAD_COMMAND.
7. Функция делает преобразование THREAD_COMMAND в необходимый тип и вызывает нужную функцию Reaction секции.
Пример реализации такой функции:
template <typename SECTION_NAME, typename COMMAND_NAME> class T_RECEIVE_S : public CONFORM_S { public: static void TUntypedCommandS(SECTION* section, THREAD_COMMAND* command) { ( ( SECTION_NAME*)section)->Reaction( *( ( COMMAND_NAME*)command)); } };
Адрес именно этой функции хранится в таблице секции.
Естественно возникает вопрос, а как создаётся эта таблица функций? Для её создания используется механизм регистрации реакций секции. У меня в движке используется несколько вариантов такой регистрации, всех их я описывать не буду, поэтому для наглядности объясню только один наиболее простой.
Схема регистрации реакций секции.
Порядок регистрации:
1. В cpp файле после определения класса секции, объявляется (с помощью define) статическая переменная темплейтного класса от секции S1 и команды COMMAND_A.
2. В своём конструкторе этот класс получает у секции (с типом S1) её таблицу команд и добавляет в неё запись. Запись содержит ID команды полученной от генератора и адрес статической функции для преобразования (см. код класса T_RECEIVE_S).
Таблица ID команд реакций может храниться следующим образом.
class SECTION { public: virtual TABLE_COMMANDS& GetTableCommands() = 0; }; template <class SECTION_NAME> class T_SECTION : public SECTION { public: virtual TABLE_COMMANDS& GetTableCommands( ) { static T_TABLE_COMMANDS<SECTION_NAME> TableCommands; return TableCommands; } };
Вот так выглядит регистрация реакций секции TEST в программе.
class COMMAND_A: public THREAD_COMMAND {}; class COMMAND_B: public THREAD_COMMAND {}; class TEST : public T_SECTION<TEST> { public: void Reaction(const COMMAND_A& command) { } void Reaction( const COMMAND_B& command) { } }; SECTION_REACTION( TEST, COMMAND_A) SECTION_REACTION( TEST, COMMAND_B)
Наконец осталось объяснить внутреннее устройство генератора ID и тему команд и их параметров можно закрывать.
class BASE_IDGEN { protected: static int nextid; }; template<typename T> class IDGEN : public BASE_IDGEN { int id; public: IDGEN() { //Проверка что если тип с таким именем typeid(T).name() уже был зарегистрирован, //то присвоить ранее назначенный ему ID переменной id, иначе { id=nextid; ++nextid; } } operator int( ) const { return id; } }; template <typename T> struct ID_CLASS { static IDGEN<T> Idgen; }; template <typename T> IDGEN<T> ID_CLASS<T>::Idgen; //Функция возвращающая идентификатор типа T template <typename T> DWORD GetID( const T*) { static ID_CLASS<T> id; return ( int)id.Idgen; }
Таким образом, чтобы получить ID например для типа COMMAND_A, достаточно вызвать функцию GetID((COMMAND_A *)NULL).
Простейшая МП программа на описанном движке.
Файл TestOne.cpp:
#include "Main.h" #include "TestTwo.hpp" int main(const int argc, const char* argv[] ) { printf( "Begin MT Core\n"); //Запуск движка с двумя потоками в пуле MT_CORE::Start( 2); printf( "End MT Core\n"); return 0; } //Описание секции TEST_ONE class TEST_ONE : public MT_CORE::T_SECTION<TEST_ONE> { public: //Вызывается при старте секции virtual void Start( ) { printf( "Start section one and send PRINT_WORLD\n"); //Отослать команду PRINT_WORLD секции TEST_TWO // thisGUID() – адрес текущей секции SendCommand( PRINT_WORLD( thisGUID( )),GUID_TEST_TWO( )); printf( "HELO\n"); } //Обработчик команды PRINT_OK void Reaction( const PRINT_OK& command) { printf( "Receive PRINT_OK\n"); //Требование завершить текущую секцию. thisExit( ); } //Вызывается при завершении секции virtual void Close( ) { printf( "Close section one\n"); } }; //зарегистрировать реакцию на команду PRINT_OK в секции TEST_ONE SECTION_REACTION( TEST_ONE,PRINT_OK) //Запустить секцию TEST_ONE при старте движка на потоке 0 MT_CORE::T_AUTO_SECTION<TEST_ONE,0> test_one;
Файл TestTwo.hpp:
#ifndef TEST_TWO__HPP #define TEST_TWO__HPP //Команда для распечатки слова WORLD для секции TEST_TWO struct PRINT_WORLD: public MT_CORE::THREAD_COMMAND { const MT_CORE::THREAD_GUID from; PRINT_WORLD(const MT_CORE::THREAD_GUID& from):from( from) {} }; //Подтверждение распечатки для секции TEST_ONE struct PRINT_OK: public MT_CORE::THREAD_COMMAND { }; //Функция получения адреса секции TEST_TWO const MT_CORE::THREAD_GUID& GUID_TEST_TWO( ); #endif
Файл TestTwo.cpp:
#include "Main.h" #include "TestTwo.hpp" //Описание секции TEST_TWO class TEST_TWO : public MT_CORE::T_SECTION<TEST_TWO>{ public: //Вызывается при старте секции virtual void Start() { printf( "Start section two\n"); } //Обработчик команды PRINT_WORLD void Reaction( const PRINT_WORLD& command) { //Требование завершить текущую секцию. thisExit( ); printf( "Receive PRINT_WORLD and send PRINT_OK\n"); printf( "WORLD\n"); //Отослать подтверждение секции TEST_ONE SendCommand( PRINT_OK( ),command.from); } //Вызывается при завершении секции virtual void Close( ) { printf( "Close section two\n"); } }; //зарегистрировать реакцию на команду PRINT_WORLD в секции TEST_TWO SECTION_REACTION( TEST_TWO,PRINT_WORLD) //Запустить секцию TEST_TWO при старте движка на потоке 1 MT_CORE::T_AUTO_SECTION<TEST_TWO,1> test_two; //Функция получения адреса секции TEST_TWO const MT_CORE::THREAD_GUID& GUID_TEST_TWO( ) { return test_two.thisID( ); }
Результат работы программы:
Begin MT Core
Start section one and send PRINT_WORLD
HELO
Start section two
Receive PRINT_WORLD and send PRINT_OK
WORLD
Receive PRINT_OK
Close section one
Close section two
End MT Core
Press any key to continue
Выводы
1. Код программы не загрязнён различными вызовами синхро-объектов, что упрощает его понимание.
2. Так как каждая отдельно взятая секция работает на одном потоке, то внутри неё программист может спокойно использовать привычные методы работы с переменными и функциями, так как невозможен одновременный вызов из разных потоков функций секции. Это также значительно упрощает отладку такого кода.
3. Сокрытие кода (секция TEST_ONE ничего не знает о внутреннем устройстве секции TEST_TWO) позволяет не боятся необоснованного использования её внутренних функций и переменных из другой секции (в другом потоке).
4. Возможность использования различных встроенных в движок средств мониторинга и автоматических проверок снижают вероятность непреднамеренного возникновения dead lock или live lock в программе, облегчают их обнаружение и отладку.
5. Возможность быстрого переноса секций с одного потока на другой. Простота изменения количества потоков в пуле, без значительного переписывания кода программы. Позволяют влиять на степень предсказуемости поведения программы и облегчают замер разницы быстродействия между однопоточной (пул из одного потока) и её многопоточной версией.
6. Возможность отправлять секциям команды, позволяет передавать любое количество параметров любого типа. Программист сам может решить, какие данные передавать непосредственно в команде (передача с копированием) а какие через указатель в команде без копирования (разделяемые данные).
7. Помещение механизмов синхронизации и пересылки данных внутрь движка, без возможности прямого доступа к ним игрового программиста, создаёт удобства для проведения оптимизации работы этих механизмов без изменения всего кода программы.
Всё это хоть и не решает всех проблем использования МП в игре но, по крайней мере, позволяет успешно с ними бороться.
Пример увеличения быстродействия игр.
Пример увеличения быстродействия игр в однопроцессорных системах за счёт распараллеливания работы CPU и GPU.
Для наглядности я приведу упрощённую схему обмена командами между секцией рендера в одном потоке и секцией игры в другом.
Схема обмена командами между рендером и игрой.
Описание работы рендера и игры.
1. Рендер посылает игре команду о начале очередного игрового цикла и вызывает функцию прорисовки кадра GPU. Например, в случае DirectX это будет pD3DDevice->Present(), после чего поток рендера блокируется системой до окончания прорисовки кадра GPU, и процессорное время отдаётся другим потокам.
2. Игра, получив уведомление о начале прорисовки, отдаёт остаток своего времени рендеру Sleep(0). Это делается для того, чтобы рендер успел начать прорисовку кадра и затем, при блокировании потока рендера, процессорное время снова было отдано игре. Игра производит обсчёт следующего кадра, пересчитывая физику положение объектов и т.п., с отсылкой команд рендеру о произошедших изменениях. Здесь надо особо отметить, что игра напрямую не работает с внутренними переменными рендера. Игре доступны только копии переменных и блоки данных не участвующие непосредственно в обработке кадра GPU.
3. Закончив расчет кадра, игра уведомляет командой рендер о завершении своей работы и ожидает разрешения от рендера начать расчёт следующего кадра.
4. После выполнения pD3DDevice->Present() система возвращает управление потоку, и рендер начинает получать и обрабатывать команды игры связанные с текущим кадром. Завершив эту работу и получив от игры уведомление о завершении кадра, рендер посылает игре команду о начале следующего игрового цикла и снова вызывает функцию pD3DDevice->Present().
Таким образом, в работе программы возникает момент, когда CPU и GPU работают параллельно, т.е. CPU не ждет завершения выполнения операции на GPU. Величина этого времени сильно зависит от сложности сцены, текущего видеорежима и fps-а. В принципе в полноэкранном режиме при fps 60 кадров в секунду и средней по сложности для GPU сцене, такой метод распараллеливания способен дать игре примерно 10 миллисекунд дополнительного времени на кадр.
Заключение.
В заключении хотелось бы отметить, что приёмы, описанные в этой статье, далеки от совершенства и нуждаются в улучшении, чем я периодически и занимаюсь. Множество различных методов, связанных с оптимизацией кода и внутренней организации МП движка, не вошли в данную статью, но в зависимости от интереса общественности к данной теме, я постараюсь в силу своих возможностей написать об этом в следующих статьях. Будущее игр, как мне кажется, за многопоточными программами и разработкой приёмов для написания таких программ стоит заняться уже сейчас.
Если кого-то затронутая мной тема заинтересовала, то я всегда рад пообщаться с коллегами. Моя домашняя страничка.
Хочу поблагодарить за идейную и моральную поддержку при написании МП движка Руслана Абдикеева, а также за помощь в составлении статьи Козлова Ивана и Николая Лисуна.
9 ноября 2003 (Обновление: 17 сен 2009)