Вступление
Добрый вечер хабровчане. В данной статье хочу описать проблемы работы в многопоточной среде, с которыми я встретился и пути их решения. Более пяти лет я занимаюсь разработкой игровых проектов на С++ / 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. В ожидании адекватной критики…