Вступление
Добрый вечер хабровчане. В данной статье хочу описать проблемы работы в многопоточной среде, с которыми я встретился и пути их решения. Более пяти лет я занимаюсь разработкой игровых проектов на С++ / Objective C++, в оснвоном под платформу iOS. 2 года назад решил попробовать себя в «нативной» разработке используя только Objective-C. Примерно в тоже время меня заинтересовала технология GCD от Apple (как раз после просмотра очередного WWDC). В первую очередь, в этой технологии меня привлекла гибкая возможность делегирования операций между потоками. Довольно распространненой задачей является загрузка каких-либо игровых ресурсов в низкоприоритетном потоке. Но довольно нетривиальной задачей является смена потока по окончанию операции загрузки на главный поток с целью дальнейшей загрузки в VRAM. Конечно можно было закрыть глаза на эту проблему и использовать Shared Context для графического контекста, но ростущий в то время во мне перфикционизм к собственному коду и решениям проектирования графических систем, не позволил поступить так. В общем было принято решение опробовать GCD на «пет» проекте, которым я как раз в то время занимался. И получилось довольно не плохо. Кроме задач решающих загрузку игровых ресурсов я стал использовать GCD там где это было уместно, ну или мне казалось, что это было уместно.
Прошло много времени и вот появились компиляторы полноценно поддерживающие C++11 стандарт. Так как работаю я в текущий момент в компании, занимающейся разработкой компьютерных игр, то особое требование ставится именно к разработке на С++. Большинству сотрудников чужд Objective-C. Да и сам я не питаю особой любви к этому языку (может быть только кроме его обьектной модели построенной по принципам языка Smalltalk).
Почитав спеки по 11 стандарту, проштудировав множество буржуинских блогов я решился написать свой велосипед схожий с Apple CGD. Конечно я не ставлю себе за цель обьять необьятное и ограничился лишь реализацией паттерна «Пул потоков» и возможностью выйти в любой момент из контекста второстепенного потока на контекст главного потока, и наоборот.
Для этого мне понадобились следующие новшевства С++11 — std::function, variadic templates и конечно работы с std::thread. (std::shared_ptr используется лишь для чувства собственного успокоения). Конечно еще одна цель, которую я поставил перед собой — это кроссплатформенность. И очень был разочарован, когда узнал, что компилятор от Microsoft, укомплектованый в VS 2012, не поддерживал variadic templates. Но, поштудировав немного stackoverflow, я увидел, что и эта проблема решается установкой допольнительного пакета «Visual C++ November 2012 CTP».
Реализация
Как я уже упоминал, в основе этой идеи лежит паттерн «Пул потоков». При проектировании было выделено два класса «gcdpp_t_task» агрегирущего в себе собственно исполняемую задачу и gcdpp_t_queue — очередь накапливающую задачи.
template<class FUCTION, class... ARGS> class gcdpp_t_task { protected: FUCTION m_function; std::tuple<ARGS...> m_args; public: gcdpp_t_task(FUCTION _function, ARGS... _args) { m_function = _function; m_args = std::make_tuple(_args...); }; ~gcdpp_t_task(void) { }; void execute(void) { apply(m_function, m_args); }; };
Как мы видим, данный класс является шаблонным. А это создает нам проблему — как же нам хранить задачи в одной очереди, если они разнотипные?
Давным-давно задаюсь вопросом, почему до сих пор в С++ нет полноценной реализации интерфейсов/протоколов. Ведь принцип программирования от абстракции более эффективен, чем от реализации. Ну ничего, можно создать и абстракный класс.
class gcdpp_t_i_task { private: protected: public: gcdpp_t_i_task(void) { }; virtual ~gcdpp_t_i_task(void) { }; virtual void execute(void) { assert(false); }; };
Теперь, наследуя наш класс задачи от абстракции задачи, мы можем легко все помещать в одну очередь.
Давайте немного остановимся и рассмотрим класс gcdpp_t_task. Как я уже упоминал, класс является шаблонным. Принимает он указатель на функцию (в конкретной реализации представленной лямбда выражением) и набор параметров. Реализует лишь один метод execute, в котором функции передаются засторенные параметры. Вот тут как раз и началась головная боль. Как же засторить параметры в таком виде, чтобы можно было в дальнейшем их передать в отложенном вызове. На помощь пришло решение использовать std::tuple.
template<unsigned int NUM> struct apply_ { template<typename... F_ARGS, typename... T_ARGS, typename... ARGS> static void apply(std::function<void(F_ARGS... args)> _function, std::tuple<T_ARGS...> const& _targs, ARGS... args) { apply_<NUM-1>::apply(_function, _targs, std::get<NUM-1>(_targs), args...); } }; template<> struct apply_<0> { template<typename... F_ARGS, typename... T_ARGS, typename... ARGS> static void apply(std::function<void(F_ARGS... args)> _function, std::tuple<T_ARGS...> const&, ARGS... args) { _function(args...); } }; template<typename... F_ARGS, typename... T_ARGS> void apply(std::function<void(F_ARGS... _fargs)> _function, std::tuple<T_ARGS...> const& _targs) { apply_<sizeof...(T_ARGS)>::apply(_function, _targs); }
Ну что же, вроде как все стало прозрачно и ясно. Теперь дело за малым, огранизовать «Пул потоков» с приоритетами.
class gcdpp_t_queue { private: protected: std::mutex m_mutex; std::thread m_thread; bool m_running; void _Thread(void); public: gcdpp_t_queue(const std::string& _guid); ~gcdpp_t_queue(void); void append_task(std::shared_ptr<gcdpp_t_i_task> _task); };
Вот собственно интерфейс, реализующий агрегацию и инкапсуляцию очереди задач. В конструкторе каждый обьект класса gcdpp_t_queue создает собвственный поток, в котором будут исполняться назначенные задачи. Естественно, такие операции как push и pop обернуты в обьект синхогизации mutex, для безопасной работы в многопоточной среде. Также мне понадобился класс, реализующий схожий функционал, но работающий исключительно в главном потоке. gcdpp_t_main_queue — скромнее по наполнению, так как более тривиален.
А теперь самое главное — оформить это все в более менее рабочий вид.
class gcdpp_impl { private: protected: friend void gcdpp_dispatch_init_main_queue(void); friend void gcdpp_dispatch_update_main_queue(void); friend std::shared_ptr<gcdpp_t_queue> gcdpp_dispatch_get_global_queue(gcdpp::GCDPP_DISPATCH_QUEUE_PRIORITY _priority); friend std::shared_ptr<gcdpp_t_main_queue> gcdpp_dispatch_get_main_queue(void); template<class... ARGS> friend void gcdpp_dispatch_async(std::shared_ptr<gcdpp_t_main_queue> _queue, std::function<void(ARGS... args)> _function, ARGS... args); std::shared_ptr<gcdpp_t_main_queue> m_mainQueue; std::shared_ptr<gcdpp_t_queue> m_poolQueue[gcdpp::GCDPP_DISPATCH_QUEUE_PRIORITY_MAX]; static std::shared_ptr<gcdpp_impl> instance(void); std::shared_ptr<gcdpp_t_queue> gcdpp_dispatch_get_global_queue(gcdpp::GCDPP_DISPATCH_QUEUE_PRIORITY _priority); std::shared_ptr<gcdpp_t_main_queue> gcdpp_dispatch_get_main_queue(void); template<class... ARGS> void gcdpp_dispatch_async(std::shared_ptr<gcdpp_t_main_queue> _queue, std::function<void(ARGS... args)> _function, ARGS... args); public: gcdpp_impl(void); ~gcdpp_impl(void); };
Класс gcdpp_impl — является синглтоном и полностью инкапсулирован от внешних воздействий. Содержит в себе массив из 3 пулов задач (с приоритетами, пока приоритеты реализованы заглушками), и пула для исполнения задач на главном потоке. Также класс содержит 5 friend функции. Функции gcdpp_dispatch_init_main_queue и gcdpp_dispatch_update_main_queue — являются паразитами. Как раз сейчас разрабатываю зловещий план по их выпиливанию. gcdpp_dispatch_update_main_queue — функции обработки задач на главном потоке… и очень хочется избавить пользователя от впиливания данной функции в свой Run Loop.
С остальными функциями вроде все прозрачно:
gcdpp_dispatch_get_global_queue — получает очередь по приоритету;
gcdpp_dispatch_get_main_queue — получает очередь на главном потоке;
gcdpp_dispatch_async — ставит операцию очередь для отложенного вызова в конкретном потоке, в конкретной очереди.
Применение
И зачем все это нужно?
Попытаюсь показать профит данной реализации на нескольких тестах:
std::function<void(int, float, std::string)> function = [](int a, float b, const std::string& c) { std::cout<<<<a<<b<<c<<std::endl; }; gcdpp::gcdpp_dispatch_async<int, float, std::string>(gcdpp::gcdpp_dispatch_get_global_queue(gcdpp::GCDPP_DISPATCH_QUEUE_PRIORITY_HIGH), function, 1, 2.0f, "Hello World");
В данном примере функия обьявленная в лямбда выражении вызовется отложенно в потоке с высоким приоритетом.
class Clazz { public: int m_varible; void Function(int _varible) { m_varible = _varible; }; }; std::shared_ptr<Clazz> clazz = std::make_shared<Clazz>(); clazz->m_varible = 101; std::function<void(std::shared_ptr<Clazz> )> function = [](std::shared_ptr<Clazz> clazz) { std::cout<<"call"<<clazz->m_varible<<std::endl; }; gcdpp::gcdpp_dispatch_async<std::shared_ptr<Clazz>>(gcdpp::gcdpp_dispatch_get_global_queue(gcdpp::GCDPP_DISPATCH_QUEUE_PRIORITY_HIGH), function, clazz);
Это пример использования отложенного вызова операции с кастомным классов в качестве параметра.
void CParticleEmitter::_OnTemplateLoaded(std::shared_ptr<ITemplate> _template) { std::function<void(void)> function = [this](void) { std::shared_ptr<CVertexBuffer> vertexBuffer = std::make_shared<CVertexBuffer>(m_settings->m_numParticles * 4, GL_STREAM_DRAW); ... m_isLoaded = true; }; thread_concurrency_dispatch(get_thread_concurrency_main_queue(), function); }
И самый главный тест — вызов операции на главном потоке из второстепенного потока. Функция _OnTemplateLoaded вызывается из бекграуд потока, который занимается парсингом xml файла с настройками. После чего должен быть создан буффер частиц и текструры должны быть отправленны в VRAM. Данная операция требует выполнения исключительно на том потоке, в котором был создан графический контекст.
Заключение
В общем задача решена в пределах поставленных целей. Конечно еще много чего недоработано и не протестировано, но пока искринка во мне горит буду продолжать совершенствовать свою реализацию GCD. На данный проект было потраченно примерно 18 часов работы, в основном в жертвы приносил рабочие перекуры.
Исходные коды можно найти в открытом доступе source code. Под VS 2012 проект пока не пушил, но думаю в скором времени он там появится.
P.S. В ожидании адекватной критики…