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_ptr
,std::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);
}
}