【TRT】C++多线程

1. thread

1.1 启动线程

#include 
#include 
#include 

using namespace std;

void worker(int a, std::string& output) {
    printf("hello thread!\n");
    this_thread::sleep_for(chrono::milliseconds(1000));
    output = "work output";
    printf("worker done.\n");
}

int main() {

    std::string output;
    thread t(worker, 567, std::ref(output));

    if (t.joinable()) {
        t.join();
    }
    printf("output: %s\n", output);
    printf("main done.\n");
    return 0;
}

注意点:

  • 如果主线程不join.等待子线程。在析构的时候会报错。
  • 如果join一个没有启动的线程变量,也会报错
  • 在join时判断线程变量是否可以join

1.2 detach

  • 分离线程,取消管理权,使得线程成为野线程。一般不建议使用,主要用在,不需要知道线程何时结束的场景,比如多线程拷贝文件操作时。
  • detach 过后,不需要join。

1.3 参数传递

注意传引用的时候

thread t(worker, 567, std::ref(output));

1.4 类成员函数作为线程函数

class Infer {
public:
    Infer() {
        worker_thread_ = thread(&Infer::infer_worker, this);
    }
    ~Infer() {
        if(worker_thread_.joinable()) {
            worker_thread_.join();
        }
    }
private:
    void infer_worker() {
        for (size_t i = 0; i < 100; i++)
        {
            printf("hello thread!\n");
            this_thread::sleep_for(chrono::milliseconds(2000));
        }
    }

private:
    thread worker_thread_;
};

将类成员函数作为线程函数:

worker_thread_ = thread(&Infer::infer_worker, this);

2. 生产者消费者

2.1 最简单实现

#include 
#include 
#include 
#include 
#include 
#include 

using namespace std;
queue<string> qjobs_;

void video_capture() {
    int pic_id = 0;
    while(true) {
        char name[100];
        sprintf(name, "PIC_%d", pic_id++);
        printf("生产了一个新图片: %s\n", name);
        qjobs_.push(name);
        this_thread::sleep_for(chrono::microseconds(1000));
    }
}

void infer_worker() {
    while (true)
    {
        if(!qjobs_.empty()) {
            auto pic = qjobs_.front();
            qjobs_.pop();
            printf("消费掉一个图片: %s\n", pic);
            this_thread::sleep_for(chrono::microseconds(1000));
        }
        // 强制当前线程交出时间片,防止一直占用cpu资源
        this_thread::yield();
    }
    
}

int main() {
    thread t1(video_capture);
    thread t2(infer_worker);
    if (t1.joinable()) {
        t1.join();
    }
    if (t2.joinable()) {
        t2.join();
    }
    printf("Done!");
    return 0;
}

因为queue 不是线程安全的,所以需要对共享资源加锁.
这里使用加锁的逻辑

#include 
mutex lock_;
{
	lock_guard l(lock_);
	/*code*/
}
#include 
#include 
#include 
#include 
#include 
#include 
#include 

using namespace std;
mutex lock_;
queue<string> qjobs_;

void video_capture() {
    int pic_id = 0;
    while(true) {
        {
            lock_guard lock(lock_);
            char name[100];
            sprintf(name, "PIC_%d", pic_id++);
            printf("生产了一个新图片: %s\n", name);
            qjobs_.push(name);
        }
        this_thread::sleep_for(chrono::microseconds(1000));
    }
}

void infer_worker() {
    while (true)
    {
        if(!qjobs_.empty()) {
            {
                lock_guard lock(lock_);
                auto pic = qjobs_.front();
                qjobs_.pop();
                printf("消费掉一个图片: %s\n", pic);
            }
            this_thread::sleep_for(chrono::microseconds(1000));
        }
        // 强制当前线程交出时间片,防止一直占用cpu资源
        this_thread::yield();
    }
    
}

int main() {
    thread t1(video_capture);
    thread t2(infer_worker);
    if (t1.joinable()) {
        t1.join();
    }
    if (t2.joinable()) {
        t2.join();
    }
    printf("Done!");
    return 0;
}

2.2. 队列溢出问题

当生产太快,消费太慢,如何实现溢出限制。当队列满的时候,不生产,等待队列有空间再生产。需求描述:

  • 当队列满的时候,停止生产,生产线程阻塞,并且释放对队列的锁。
  • 消费者消费后,通知生产线程,生产线程重新获得锁,继续生产。
    为了满足上述的需求,这里可以使用c++ 的 condition_variable来实现
// 生产者线程
{
   unique_lock lock(lock_);
     // cv_.wait() 
     // 当条件满足时, 继续执行, 获得锁的占有权
     // 当条件不满足时,线程阻塞等待,释放锁
     cv_.wait(lock, [&](){
         return qjobs_.size() < limit_;
     });
     char name[100];
     sprintf(name, "PIC_%d", pic_id++);
     printf("生产了一个新图片: %s qjob.size(): %d\n", name, qjobs_.size());
     qjobs_.push(name);
 }
// 消费者线程
{
   unique_lock lock(lock_);
    auto pic = qjobs_.front();
    qjobs_.pop();
    printf("消费掉一个图片: %s\n", pic);
    // 消费一个后通知 cv_ 并释放锁
    cv_.notify_one();
}
  • condition_variable 必须配合unique_lock 使用
  • cv_.wait() 当条件满足时 获得锁的占有权,继续执行
  • 当条件不满足时,释放锁,线程阻塞等待
  • cv_.notify_one() 消费完后通知生产者线程继续生产,并释放锁。

2.3. 跨线程结果传输

为了在消费者线程外拿到推理结果,需要借助一些机制来实现跨线程结果传输。
这里利用c++ 标准线程的 futurepromise 实现
构建 Job 结构体

struct Job {
	shared_ptr<promise<string>> pro;
	string input;
}

qjobs_

struct Job {
    shared_ptr<promise<string>> pro;
    string input;
};

生产者线程

void video_capture() {
    int pic_id = 0;
    while(true) {
        Job job;
        {
            unique_lock lock(lock_);
            // cv_.wait() 当条件满足时 获得锁的占有权,继续执行
            // 当条件不满足时,释放锁,线程阻塞等待
            cv_.wait(lock, [&](){
                return qjobs_.size() < limit_;
            });
            char name[100];
            sprintf(name, "PIC_%d", pic_id++);
            printf("生产了一个新图片: %s qjob.size(): %d\n", name, qjobs_.size());
            job.pro.reset(new promise<string>());
            job.input = name;
            qjobs_.push(job);
        }
        // 等待结果
        auto result = job.pro->get_future().get();
        printf("JOB %s -> %s\n", job.input.c_str(), result);
        this_thread::sleep_for(chrono::milliseconds(2000));
    }
}

消费者

void infer_worker() {
    while (true)
    {
        if(!qjobs_.empty()) {
            {
                unique_lock lock(lock_);
                auto pjob = qjobs_.front();
                qjobs_.pop();
                printf("消费掉一个图片: %s\n", pjob.input);
                // 消费一个后通知 cv_ 并释放锁
                auto result = pjob.input + "--infer";
                // 存放值
                pjob.pro->set_value(result);
                cv_.notify_one();
            }
            this_thread::sleep_for(chrono::milliseconds(4000));
        }
        // 强制当前线程交出时间片,防止一直占用cpu资源
        this_thread::yield();
    }
    
}

3. 封装多线程Infer类

3.1 Infer类实现

#include 
using namespace std;

class Infer {
public:
    bool load_model(const string& file) {
        context_ = file;
        return true;
    }

    void forward() {
        if(context_.empty()) {
            printf("模型没有加载.\n");
            return;
        }
        /*forward logic*/
    }

	bool destory() {
		context_.clear();
}

private:
    string context_;
};

Infer infer;
infer.forward();

常见的Infer类将模型状态管理和模型推理放在一个Infer 类里面,这样会导致一些问题:

  • Model 没有正常加载时,推理异常判断。
  • Model 被释放了,会导致推理异常。
  • 重复load逻辑
  • 需要手动释放模型。

Note: Infer中必须考虑模型状态相关的异常情况。

  • 正常代码中存在大量异常情况的处理
  • 异常逻辑如果没有写好,后者没有考虑到,将会导致程序崩溃。

3.2 使用RAII实现Infer对象的创建

shared_ptr<Infer> create_infer(const string& file) {
    shared_ptr<Infer> instance(new Infer());
    if(!instance->load_model(file)) {
        instance.reset();
    }
    return instance;
}
  • 获取Infer 实例,立即加载模型
  • 加载模型失败,表示资源获取失败
  • 加载模型成功,则资源获取成功

调用代码

auto infer = create_infer("a");
if (infer == nullptr) {
	printf("failed.\n");
	return -1;
}
infer->forward();
  • 避免了外部执行load_model. 保证只调用一次,避免了重复调用的逻辑
  • 获取模型一定初始化成功,因此forward函数,不必判断模型是否加载成功

3.3 接口模式

解决问题:

  • 解决load_model 还能被外部看到的问题,拒绝外面调用load_model
  • 解决成员变量对外可见的问题。对于成员函数是特殊类型,比如说cudaStream_t, 那么使用者必定包含cuda_runtime.h,否则语法解析失败,造成头文件污染。

头文件接口

#ifndef __INFER_H__
#define __INFER_H__
#include 
#include 

// 接口类,纯虚类
// 原则是: 只暴露调用者需要的函数,其他一概不暴露
// 比如load_model, 通过RAII 做定义,因此load_model不需要
// 内部如果有启动线程等,start, stop 也不需要暴露,而是初始化的时候就自动启动,都是RAII的定义
class Interface {
public:
    virtual void forward() = 0;
};

std::shared_ptr<Interface> create_infer(const std::string& file);

#endif // __INFER_H__
  • 头文件只暴露最简单的功能,避免造成头文件污染。有可能用户找不到依赖库的头文件。
  • 隐藏了内部实现。方便算法库的封装
  • 接口模式对编译友好,防止修改头文件,导致引入头文件的文件重新编译。

实现类

#include "infer.h"
using namespace std;

class InferImpl: public Interface{
public:
    bool load_model(const string& file) {
        context_ = file;
        return true;
    }

    virtual void forward() override{
        /*forward logic*/
    }

private:
    string context_;
};

shared_ptr<Interface> create_infer(const string& file) {
    shared_ptr<InferImpl> instance(new InferImpl());
    if(!instance->load_model(file)) {
        instance.reset();
    }
    return instance;
}

使用算法接口

#include "infer.h"

auto infer = create_infer("a");
if (infer == nullptr) {
	printf("Create infer failed!\n";
	return -1;
}
auto result = infer->forward("xxx");
return 0;

4. 多batch生产者消费者类的封装

4.1 封装

class InferImpl: public Interface{
public:
    bool load_model(const string& file) {
        // 尽量保证资源哪里分配哪里释放,哪里使用。这样使得程序足够简单
        context_ = file;
        worker_thread_ = thread(&InferImpl::worker, this);
        return true;
    }

    virtual void forward() override{
        /*forward logic*/
    }
private:
    void worker() {

    }
private:
    thread worker_thread_;
    string context_;
};
  bool load_model(const string& file) {
       // 尽量保证资源哪里分配哪里释放,哪里使用。这样使得程序足够简单
       context_ = file;
       worker_thread_ = thread(&InferImpl::worker, this);
       return true;
   }

在这个代码里,context 在load_model中分配,但是在worker线程中使用。这样不够好,应该保证资源在同一个地方分配,统一个地方释放,同一个地方使用。这样做得目的是保证程序足够简单,并且防止资源泄露。

4.2 修改模型加载

修改:

class InferImpl: public Interface{
public:
    bool load_model(const string& file) {
        worker_thread_ = thread(&InferImpl::worker, this, file);
        return true;
    }

    virtual void forward() override{
        /*forward logic*/
    }
private:
    void worker(string f) {
        context_ = f;
    }
private:
    thread worker_thread_;
    string context_;
};

这样将模型的context_ 加载放在了worker线程里面,解决了模型和推理模块不在同一个线程的问题。但是这个代码也有如下问题:

  • 不知道最后模型加载的状态
    这里使用 futurepromise 拉解决获取模型加载状态获取的问题。
class InferImpl: public Interface{
public:
    bool load_model(const string& file) {
        // 尽量保证资源哪里分配哪里释放,哪里使用。这样使得程序足够简单
        promise<bool> pro;
        worker_thread_ = thread(&InferImpl::worker, this, file, std::ref(pro));
        return pro.get_future().get();
    }

    virtual void forward() override{
        /*forward logic*/
    }
private:
    void worker(string f, promise<bool>& pro) {
        string context = f;
        pro.set_value(context_.empty());
        while(true) {
			/*customer*/
		}
    }
private:
    thread worker_thread_;
    //string context_;
};

由于contex_只在worker内部使用,所以也就不需要context_的成员变量了,context_的生命周期只在 worker() 线程内。

4.3 任务提交与结果返回

4.3.1 直接返回结果

 virtual string forward(string& input) override{
     /*forward logic
         往队列丢任务
     */
     Job job;
     job.pro.reset(new promise<string>());
     job.input = input;
     qjobs_.push(job);

     /*如何返回结果?*/
     return job.pro->get_future().get();
 }

外部调用代码:

string result1 = infer->forward(input1);
string result2 = infer->forward(input2);
string result3 = infer->forward(input3);

4.3.2 使用shared_future 返回结果

上述方式可以解决结果获取的问题,但是每次提交任务后,必须等待任务处理过后才能提价下一个任务,这本质上还是串行的所以可以使用 shared_future 直接返回 一个 future 让调用方决定什么时候去等待结果。代码如下:

 virtual shared_future<string> forward(string& input) override{
    Job job;
	job.pro.reset(new std::promise<std::string>);
	job.input = input;
	//std::this_thread::sleep_for(std::chrono::milliseconds(100));

	std::shared_future<std::string> fut = job.pro->get_future();
	{
		std::lock_guard<std::mutex> l(lock_);
		qjobs_.emplace(job);
	}
	// 被动通知,有任务发送给worker
	cv_.notify_one();
	return fut;
 }

调用代码:

/*提交任务*/
auto rst_future1 = infer->forward(input1);
auto rst_future2 = infer->forward(input2);
auto rst_future3 = infer->forward(input3);

/*在需要的时候获取结果*/
rst_future1.get();
rst_future2.get();
rst_future3.get()

4.4 多batch 推理

void worker(std::string file, std::promise<bool>& pro) {

		//std::string file = "aaa";
		// worker 内实现,模型的加载、使用、释放
		std::string context_ = file;
		if (context_.empty()) {
			pro.set_value(false);
			return;
		}
		else {
			is_running_ = true;
			pro.set_value(true);
		}
		int max_batch_size = 5;
		std::vector<Job> jobs;
		int batch_ids = 0;
		while (true) {
			//  在队列取任务并执行的过程
			{
				std::unique_lock<std::mutex> l(lock_);
				cv_.wait(l, [&]() {
					return !qjobs_.empty();
				});
				while (jobs.size() < max_batch_size && !qjobs_.empty()) {
					jobs.emplace_back(std::move(qjobs_.front()));
					qjobs_.pop();
				}

				// batch process
				for (auto& job : jobs) {
					char buff[100];
					sprintf_s(buff, "%s ---processed[%d]", job.input.c_str(), batch_ids);
					job.pro->set_value(buff);
				}
				std::this_thread::sleep_for(std::chrono::milliseconds(1500));
				batch_ids++;
				jobs.clear();

			} // end unique_lock
		}
		printf("[%s] Infer worker done. \n", file.c_str());
	}

一次从队列中取出多个任务,组成一个batch进行推理。

4.5 程序推出机制

在上面的程序中,并没有程序推出的机制即:
当进程终止时,worker()线程仍然在运行,这会导致程序推出而子线程没有推出,导致报错。这里设置一个标示线程是否退出的变量 ** atomic is_running_**。

std::atomic<bool>			is_running_{false};
void stop() {
	if (is_running_) {
		is_running_ = false;
		// 退出worker线程的等待
		cv_.notify_one();
	}

	//  保证推理线程结束,防止成为孤儿线程
	if (this->worker_thread_.joinable()) {
		worker_thread_.join();
	}
}

析构时或者在需要停止推理时调用

virtual ~InferImpl() {
	stop();
}

消费者逻辑修改

void worker(std::string file, std::promise& pro) {

	//std::string file = "aaa";
	// worker 内实现,模型的加载、使用、释放
	std::string context_ = file;
	if (context_.empty()) {
		pro.set_value(false);
		return;
	}
	else {
		is_running_ = true;
		pro.set_value(true);
	}
	int max_batch_size = 5;
	std::vector jobs;
	int batch_ids = 0;
	while (is_running_) {
		//  在队列取任务并执行的过程
		{
			std::unique_lock l(lock_);
			cv_.wait(l, [&]() {
				return !is_running_ || !qjobs_.empty();
			});

			if (!is_running_) break;
			while (jobs.size() < max_batch_size && !qjobs_.empty()) {
				jobs.emplace_back(std::move(qjobs_.front()));
				qjobs_.pop();
			}

			// batch process
			for (auto& job : jobs) {
				char buff[100];
				sprintf_s(buff, "%s ---processed[%d]", job.input.c_str(), batch_ids);
				job.pro->set_value(buff);
			}
			std::this_thread::sleep_for(std::chrono::milliseconds(1500));
			batch_ids++;
			jobs.clear();

		} // end unique_lock

		
	}
	printf("[%s] Infer worker done. \n", file.c_str());

}
  • while(true) 变成 while(is_running_)
  • 信号量等待退出条件变成如下,即当不再运行时,结束等待,并且退出推理线程。
cv_.wait(l, [&]() {
	return !is_running_ || !qjobs_.empty();
});

if (!is_running_) break;

5. 生产者上限控制

问题提出:生产频率太高,commit 频率太高,而消费频率太低。导致内存占用太大,程序无法长时间运行。缺少队列上限限制的机制。使用独占分配器解决:

  • tensor 复用

  • 队列上限限制

  • 向tensor_alloctor_ 申请一个 tensor

  • 预先分配固定数量的tensor, 比如10个

  • 如果申请的时候,有空闲的tensor没有被分配出去,则把这个空闲给申请者

  • 如果申请的时候,没有空闲的tensor,此时,让申请者等待。

  • 如果使用者使用完毕了,通知tensor_allocator_, 告诉他这个tensor不用了,可以分配给别人了。

  • 这样实现了tensor复用,并且控制了队列的上限。

内存复用类,申请固定数量的内存池,完成内存复用。当内存池中的内存块消耗完后,则不能申请新的内存,需要等待,若等待超时,则返回空。

#ifndef __MONOPOLY_ALLOCATOR_H__
#define __MONOPOLY_ALLOCATOR_H__

#include 
#include 
#include 
#include 

template<class _ItemType>
class MonopolyAllocator {
public:
    class MonopolyData {
    public:
        std::shared_ptr<_ItemType>& data() { return data_; }
        void release() { manager_->release_one(this); }

    private:
        MonopolyData(MonopolyAllocator* pmanager) { manager_ = pmanager; }

    private:
        friend class MonopolyAllocator;
        MonopolyAllocator* manager_ = nullptr;
        std::shared_ptr<_ItemType> data_;
        bool available_ = true;
    };
    typedef std::shared_ptr<MonopolyData> MonopolyDataPointer;

    MonopolyAllocator(int size) {
        capacity_ = size;
        num_available_ = size;
        datas_.resize(size);

        for (int i = 0; i < size; ++i)
            datas_[i] = std::shared_ptr<MonopolyData>(new MonopolyData(this));
    }

    virtual ~MonopolyAllocator() {
        run_ = false;
        cv_.notify_all();

        std::unique_lock<std::mutex> l(lock_);
        cv_exit_.wait(l, [&]() {
            return num_wait_thread_ == 0;
            });
    }

    MonopolyDataPointer query(int timeout = 10000) {

        std::unique_lock<std::mutex> l(lock_);
        if (!run_) return nullptr;

        if (num_available_ == 0) {
            num_wait_thread_++;

            auto state = cv_.wait_for(l, std::chrono::milliseconds(timeout), [&]() {
                return num_available_ > 0 || !run_;
                });

            num_wait_thread_--;
            cv_exit_.notify_one();

            // timeout, no available, exit program
            if (!state || num_available_ == 0 || !run_)
                return nullptr;
        }

        auto item = std::find_if(datas_.begin(), datas_.end(), [](MonopolyDataPointer& item) {return item->available_; });
        if (item == datas_.end())
            return nullptr;

        (*item)->available_ = false;
        num_available_--;
        return *item;
    }

    int num_available() {
        return num_available_;
    }

    int capacity() {
        return capacity_;
    }

private:
    void release_one(MonopolyData* prq) {
        std::unique_lock<std::mutex> l(lock_);
        if (!prq->available_) {
            prq->available_ = true;
            num_available_++;
            cv_.notify_one();
        }
    }

private:
    std::mutex lock_;
    std::condition_variable cv_;
    std::condition_variable cv_exit_;
    std::vector<MonopolyDataPointer> datas_;
    int capacity_ = 0;
    volatile int num_available_ = 0;
    volatile int num_wait_thread_ = 0;
    volatile bool run_ = true;
};

#endif // __MONOPOLY_ALLOCATOR_H__

完整代码参考

https://github.com/JilinLi4/trt_infer/tree/master/test/ThreadTest

你可能感兴趣的:(trt_infer,c++,开发语言)