Thread concurrency C++11, свой велосипед технологии (Apple) GCD

в 8:06, , рубрики: c++, GCD, Песочница, Программирование, с++11, метки: , ,
Вступление

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

Автор: codeoneclick

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js