QGraphicsView优化杂谈(其五、多线程绘制)

在绘制事件中,如果绘制的内容分为多个层次,并且它们的数据也是相对独立的保存的,这种情况就非常适合使用并行化的绘制方式。这期的内容比较歪,重点没在绘制上,反倒是这里使用的一个C++线程池模板挺有说法,这里借机会分析一下它的内容和使用方式。

STL风格的线程池

充分利用现代C++提供的功能,我们能够设计出一个简洁的,可以执行任意函数的线程池类:

#include <vector>
#include <queue>
#include <memory>
#include <thread>
#include <mutex>
#include <condition_variable>
#include <future>
#include <functional>
#include <stdexcept>

class ThreadPool {
public:
    ThreadPool(size_t);
    template<class F, class... Args>
    decltype(auto) enqueue(F&& f, Args&&... args);
    ~ThreadPool();
private:
    // need to keep track of threads so we can join them
    std::vector< std::thread > workers;
    // the task queue
    std::queue< std::function<void()> > tasks;
    
    // synchronization
    std::mutex queue_mutex;
    std::condition_variable condition;
    std::atomic<bool> stop;
};
 
// the constructor just launches some amount of workers
inline ThreadPool::ThreadPool(size_t threads)
    :   stop(false)
{
    for(size_t i = 0; i<threads; ++i)
        workers.emplace_back(
            [this]
            {
                for(;;)
                {
                    std::function<void()> task;
                    {
                        std::unique_lock<std::mutex> lock(this->queue_mutex);
                        this->condition.wait(lock,
                            [this]{ return this->stop || !this->tasks.empty(); });
                        if(this->stop && this->tasks.empty())
                            return;
                        task = std::move(this->tasks.front());
                        this->tasks.pop();
                    }

                    task();
                }
            }
        );
}

// add new work item to the pool
template<class F, class... Args>
decltype(auto) ThreadPool::enqueue(F&& f, Args&&... args)
{
    using return_type = typename std::invoke_result<F, Args...>::type;

    auto task = std::make_shared< std::packaged_task<return_type()> >(
            std::bind(std::forward<F>(f), std::forward<Args>(args)...)
        );

    auto res = task->get_future();
    {
        std::unique_lock<std::mutex> lock(queue_mutex);

        if(stop)
            throw std::runtime_error("enqueue on stopped ThreadPool");

        tasks.emplace([task = std::move(task)]() { (*task)(); });
    }
    condition.notify_one();
    return res;
}

// the destructor joins all threads
inline ThreadPool::~ThreadPool()
{
    {
        std::unique_lock<std::mutex> lock(queue_mutex);
        stop = true;
    }
    condition.notify_all();
    for(std::thread &worker: workers)
        worker.join();
}

这段代码看起来颇为炫技,使用了很多现代C++的特性,我们来分析一下:

  • 首先是对ThreadPool类的定义,这个类只定义了三种方法:ThreadPool的构造、析构函数,以及一个将待执行的函数加入线程池的函数。接下来是ThreadPool类的私有变量:很显然,我们至少需要维护一组线程workers以及一个任务队列tasks,剩下的三个值用于线程同步。

  • 然后是ThreadPool的构造函数,它传入了一个参数,用于确定线程池中的线程数。这里的函数在实现时添加了inline,需要注意的是在新的C++标准中,inline已经不是内联的语义,而是表明这个声明可以被重复定义——在头文件中的类成员函数是默认inline的,所以我们在声明类成员函数的时候通常不需要自己添加inline。而后,这个构造函数中会依次添加n个线程到workers中,而每个工作线程的内容由一个lambda函数定义——在每个循环中使用this->condition.wait()等待条件(this->stop为真或人物队列不为空),当条件满足时则执行后面的操作:

    • if(this->stop && this->tasks.empty()) return; 如果线程池被停止且任务队列为空,则线程结束。
    • task = std::move(this->tasks.front()); 将任务队列的第一个任务移动到 task 变量中,然后,执行这个任务
  • enqueue向线程池中加入需要执行的任务

    template<class F, class... Args>
    decltype(auto) ThreadPool::enqueue(F&& f, Args&&... args)
    {
        using return_type = typename std::result_of<F(Args...)>::type;
    
        auto task = std::make_shared< std::packaged_task<return_type()> >(
                std::bind(std::forward<F>(f), std::forward<Args>(args)...)
            );
    
        // auto --> std::future<typename std::result_of<F(Args...)>::type>
        auto res = task->get_future();
        {
            std::unique_lock<std::mutex> lock(queue_mutex);
    
            // don't allow enqueueing after stopping the pool
            if(stop)
                throw std::runtime_error("enqueue on stopped ThreadPool");
    
            tasks.emplace([task](){ (*task)(); });
        }
        condition.notify_one();
        return res;
    }
    

    函数声明了两个模板变量template<class F, class... Args>,第一个是需要执行的函数类型,第二个是函数的参数,这里是一个可变参模板,并且使用了万能引用,它可以接受任意数量的参数,并且不限制参数类型。(C++11特性)

    第二行的decltype(auto)auto表示自动对函数返回的型别进行推导,decltype(auto)则是C++14的用法,这两个关键词配合可以自动推导函数的返回值类型,如果使用C++11的标准,则需要这样写:

    auto ThreadPool::enqueue(F&& f, Args&&... args) -> std::future<typename std::result_of<F(Args...)>::type>

    这种写法一般只用于返回值类型根模板参数类型有关的情况,因为它也必须编程者自己写出函数的返回类型,并不能起到简化编程效率的效果,而decltype(auto)的适用范围就要广了很多。

    第四行通过std::invoke_result推导函数的返回值类型,并将它用return_type表示,在C++11中,类似的用法是std::result_of<F(Args...)>::type,这个用法在C++17中被弃用,并使用invoke_result作为替代。

    std::make_shared可以返回一个指定类型的 std::shared_ptrstd::packaged_task是C++11引入的标准库类,用于封装可调用对象,如函数等,并将封装对象作为异步任务进行管理,通过与std::future结合使用,完成异步任务结果的获取。在std::make_shared中,使用std::bind将传入的函数与参数进行结构化绑定,并生成一个std::function。因为我们传入的模板参数是万能引用,使用std::forward可以进行完美转发,这样可以避免额外的构造开销(C++11特性)

    此处还有一种更加modern C++的写法,在C++17中,可以将执行的内容封装在一个lambda函数中,通过std::apply传入一个函数和一个元组类型的参数列表,在执行时调用std::apply来调用任务函数:

        auto task = std::make_shared<std::packaged_task<return_type()>>(
            [f = std::forward<F>(f), args = std::make_tuple(std::forward<Args>(args)...)]() mutable {
                return std::apply(std::move(f), std::move(args));
            }
        );
    

    当然,这种写法有点强行了,还多套了一层lambda函数,在平时使用std::bind生成可执行对象即可。

    在12行std::unique_lock<std::mutex> lock(queue_mutex);,通过一个互斥锁来保护任务队列的访问,如果线程池状态正常,则会将任务添加到任务队列中,这里使用了移动语义来转移task对象的所有权。将任务放入任务队列后,使用condition.notify_one();通知一个等待中的线程,有新的任务可用,函数的末尾返回任务的future对象,这样调用者可以获取任务的返回值

将绘制逻辑包装成lambda表达式

调用线程池的emplace_back方法时,可以使用lambda函数将原本的代码逻辑封装起来,这样可以很容易的将原本的执行逻辑变更为多线程的版本:

void paint(...)
{
    // 原本的逻辑,获取每一层的状态,然后依次绘制
    graphicStateManager* stateManager = graphicsGlobal.getCurrentStateManager();
    QList<graphicsStateItem*> layerOptions = stateManager->getStateItem("layer")->childs;
    
    for(auto layerOption : layerOptions) {
        if(!layerOption->isVisible()) continue;
        // ... 开始进行这一层的绘制
    }
    
    /* ------------------------------------------------------------------------- */
    // 新的版本,将绘制任务交给其它线程处理
    graphicStateManager* stateManager = graphicsGlobal.getCurrentStateManager();
    QList<graphicsStateItem*> layerOptions = stateManager->getStateItem("layer")->childs;
    // 获取线程池,使用std::future获取异步数据的返回
    ThreadPool* threadPool = graphicsGlobal.getGraphicsPaintingThreadPool();
    std::vector<std::future<hmGraphicsGlobal::paintingThreadResult>> futures;
    // 将每一层的绘制任务分别放入线程池
    for(auto layerOption : layerOptions) {
        if(!layerOption->isVisible()) continue;
        
        futures.emplace_back(threadPool->enqueue(
        	[args...] // 参数列表
        	-> GraphicsGlobal::paintingThreadResult {
                // 使用一个新的绘制设备对内容进行绘制,然后将结果返回
            }
        ));
    }
    
    ... // 处理其它耗时逻辑
        
    for(auto & future : futures) { 
        // 等待结果的写入,并将它绘制到视图上
        hmGraphicsGlobal::paintingThreadResult res = future.get();
        painter->drawPixmap(0, 0, res.pixmap);
    }
}