0,0
рейтинг
25 июня 2013 в 12:06

Разработка → Thread concurrency C++11, свой велосипед технологии (Apple) GCD из песочницы tutorial

Вступление

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

Подробнее
Спецпроект

Самое читаемое Разработка

Комментарии (9)

  • 0
    все блоки, которыми оперирует GCD объявлены как
    void (^dispatch_block_t)(void)
    

    Если вы уже используете лямбды, то зачем усложнять написание и чтение gcdpp_dispatch_async еще и параметрами?

    ИМХО что-то на подобии такого будет на порядки читабельнее:
    gcdpp::gcdpp_dispatch_async(gcdpp::gcdpp_dispatch_get_global_queue(gcdpp::GCDPP_DISPATCH_QUEUE_PRIORITY_HIGH) ,[=]{
       //  а вот здесь уже вызываем любую функцию с любым количеством параметров, например
      function(1, 2.0f, "Hello World");
    } );
    
  • НЛО прилетело и опубликовало эту надпись здесь
    • 0
      Можно и через бинд запоминать, но имхо если уже используется C++11, то лучше использовать лямбды — они как раз для этого и предназначены. Читабельность от этого только выиграет
      • +1
        Создавать фиктивную лямбду, перевызывающую функцию — это в некотором роде оверхед. В любом случае, создание решения, которое будет работать как с лямбдой, так и с обычной функцией — это то, что нужно для удовлетворения любых вкусов и то, чего ждут от библиотеки. :)
    • 0
      Если повнимательней присмотреться, то можно понять, что предложенный вами вариант будет работать без каких либо изменений.

      class Clazz
      {
          
      public:
          
          std::string m_value;
          
          void Foo(int a, float b, const std::string& c)
          {
              std::cout<<a<<b<<c<<std::endl;
          };
      };
      
      typedef std::function<void(void)> task_t;
      
      TEST(gcdppLib, Dispatch_05)
      {
          std::shared_ptr<Clazz> clazz = std::make_shared<Clazz>();
          task_t task = std::bind(&Clazz::Foo, clazz, 1, 2.0f, "Hello World");
          gcdpp::gcdpp_dispatch_async(gcdpp::gcdpp_dispatch_get_global_queue(gcdpp::GCDPP_DISPATCH_QUEUE_PRIORITY_HIGH), task);
      }
      
      

      Но все равно спасибо за замечание, так как без вас вряд бы проверил такой вариант. ;-)
  • +2
    Я предложил бы подумать об использовании std::packaged_task:
    — он стандартный. Голова о том как эмулировать variadic templates болит только у авторов стандартной библиотеки
    — он может хранить результат (включая выброшенное исключение). Есть возможность ожидать результат выполнения — std::future
    • 0
      Довольно интерессное предложение. Но насколько я понял, избежать работы с variadic templates не получится. Возможно я что-то не так понял, но вот по быстрому набросал реализацию:

      std::queue<std::packaged_task<void()>> queue;
      
      template<class FUNCTION, class ...ARGS>
      std::future<typename std::result_of<FUNCTION(ARGS...)>::type>
      dispatch(FUNCTION &&f, ARGS &&...args) {
          std::packaged_task<typename std::result_of<FUNCTION(ARGS...)>::type()> task(std::bind(f,args...));
          auto future = task.get_future();
          queue.push(std::packaged_task<void()>(std::move(task)));
          return future;
      }
      
      std::function<void(int, float, std::string)> function = [](int a, float b, const std::string& c)
      {
            EXPECT_TRUE(a == 1 && b == 2.0f && c == "Hello World");
      };
          
      auto future = dispatch(std::move(function), 1, 2.0f, "Hello World");
      queue.back()();
      


      Поправьте меня, если я не прав :-)
  • +1
    А это создает нам проблему — как же нам хранить задачи в одной очереди, если они разнотипные?
    Немного позанудствую, Ваш класс gcdpp_t_i_task не абстрактный, а ответом на Ваш вопрос является type erasure :-)
  • 0
    А разве F_ARGS и T_ARGS не совпадают, это немного усложняет пример? Зачем их разделять?

Только зарегистрированные пользователи могут оставлять комментарии. Войдите, пожалуйста.