【C++相关】一个从提高QPS引发的问题

一个从提高QPS引发的问题
业务背景:

人脸识别中有一个对计算量要求较高的操作,人脸比对操作;正常情况下人脸特征也是一组float类型特征值。常规进行比对当然是进行遍历,for循环走一遍;但是这个操作,在库比较小的时候,表现还行;当人脸库的量级上升到万级或是之上,那么单纯的for循环就无法满足了;这里能做的优化点是,将特征人脸库视作一个大的矩阵,待比对的人脸特征直接进行矩阵运算即可快速获得结果;通常cpu加速可用openblas,GPU加速可使用cublas;同样是矩阵运算,eg:sgemv(矩阵X向量)和sgemm(矩阵X矩阵)速度有比较明显的差异(这里矩阵认为是多维向量);这就引发一个需求:将待比对的特征向量拼成一个大矩阵进行比对;以上视为背景;

从需求引发的思考

比较明确的是,当并发量比较小的时候,因为硬件的比较强悍,实际上并不需要拼成大矩阵的;只有当并发量很高的时候,那就需要对比对模块核心算法进行重新设计了;然后一个概念突然飘到我脑后。。。。。

(用线程池啊。。。。)

线程池初探
  • 既然要使用线程池,总是需要知道什么是线程池,不看不知道,仔细看了下,这个简直是为了解决当前任务而生解决方法;一个完整的线程池包括三个部分:消费层,排队层,生产层;这个属于生产消费者模型;生产层负责向排队序列中添加数据,消费层负责处理排列序列中的数据;

1573288631(1).png

从上图可以看出:

  • 1:一般正常的线程池在初始化时会先启动一定的线程数,这样能保证线程数不会无限制增加;
  • 2:细心的朋友可能看到,其中消费者层,是否终止是缺少一个箭头的,一般建议是析构时进行线程终止;当然也可以提供一个原子锁用于锁用于控制线程的停止;
  • 3:上面有一个比较关键的结构,同步队列,负责两方的通信和数据同步;

既然到了这一步,那么一个简易的线程池已经能够实现了:

code 来源,侵删:https://github.com/progschj/ThreadPool

#ifndef THREAD_POOL_H
#define THREAD_POOL_H
#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);     //initialization thread num
    template<class F, class... Args>
    auto enqueue(F&& f, Args&&... args) 
        -> std::future<typename std::result_of<F(Args...)>::type>;
    ~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;
    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>
auto ThreadPool::enqueue(F&& f, Args&&... args) 
    -> std::future<typename std::result_of<F(Args...)>::type>
{
    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)...)
        );
        
    std::future<return_type> 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;
}

// 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();
}
#endif

至此,线程池相关的基本介绍完了,完结撒花。。。


线程池再探
  • 当然写到这里了都,怎么可能就结束了,那我和其他人有什么区别;认真看了上面的代码,一种头大之情,油然而生,道理我都懂,我还是觉得有点复杂;针对这样的疑问,我想说,请继续看下去。在最早的一幅图中指出,其中比较关键的是同步队列,队列的处理还需要加锁,对于加锁这种东西我总是很慌,有没有线程安全的队列,这样我就只需要考虑加任务和处理任务就行了。当然,这是有的,下面就是轮子:

concurrentqueue:一个支持多生产者,多消费者的无锁队列;

  • 这个无锁队列能够保证存取数据是线程安全的,它主要有如下优点:

    • 啥都不需要,就需要引入一个头文件即可;
    • 数据类型和数量无限制;
    • 异常安全,优秀的内存管理
    • 支持超快的堆操作—>这不正是我需要的么
  • 对最开始的需求而言,有个比较重要的点是,我需要处理队列的数据,并不是一个一个任务的读取,我需要的是超快的堆操作,如果只是一个任务一个任务的读写操作,其中锁状态切换可能最终也会成为瓶颈;
    下面的代码重点说明的就是:try_dequeue_bulk,一次从队列中拿出一定数量的任务

ConcurrentQueue<int> q;
int dequeued[100] = { 0 };
std::thread threads[20];

// Producers
for (int i = 0; i != 10; ++i) {
	threads[i] = std::thread([&](int i) {
		int items[10];
		for (int j = 0; j != 10; ++j) {
			items[j] = i * 10 + j;
		}
		q.enqueue_bulk(items, 10);
	}, i);
}

// Consumers
for (int i = 10; i != 20; ++i) {
	threads[i] = std::thread([&]() {
		int items[20];
		for (std::size_t count = q.try_dequeue_bulk(items, 20); count != 0; --count) {
			++dequeued[items[count - 1]];
		}
	});
}

// Wait for all threads
for (int i = 0; i != 20; ++i) {
	threads[i].join();
}

// Collect any leftovers (could be some if e.g. consumers finish before producers)
int items[10];
std::size_t count;
while ((count = q.try_dequeue_bulk(items, 10)) != 0) {
	for (std::size_t i = 0; i != count; ++i) {
		++dequeued[items[i]];
	}
}

// Make sure everything went in and came back out!
for (int i = 0; i != 100; ++i) {
	assert(dequeued[i] == 1);
}
  • 通过上面的例程是能够将上一小节的线程池类接口给简易优化的,因为队列的线程安全无需考虑的话,只需要处理具体的业务需求即可;至此,基本完结;

总结:

  • 使用C++线程池,一定程度上使编写并发程序变得简单,可以使用简单的互斥锁和条件变量实现一个简易的线程池,从而避免频繁的创建线程;使用线程池,需要设置合理的线程数和队列大小,个人在使用的时候,当队列数设置过小,出现过死锁,这种死锁问题的原因有时候并不太好排查,当加大队列后死锁的情况没了;这是一个队列大小和QPS问题的权衡;

参考文献和链接:

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值