目前在做推理引擎开发相关的工作,这块内容的话,对工程能力的要求还是比较高的,不再像以前只是写一些Python脚本训训模型就可以了,而且深入了解C++之后,也能感受到Python较C++暴露出的缺点,另一方面,由于模型推理所需的高效性,目前推理引擎的开发基本上都是用C++来实现,而且其中绕不开的一个难点就是多线程。这个系列我打算将我学习C++多线程开发的历程整理成文章,梳理相关知识点并整合到已有的知识体系中。

1. 线程和进程

线程和进程是操作系统的概念,这部分知识应该在学CSAPP的时候能够学习到,从另一个角度来说,假如已经编写了一个main.cpp文件,并且里面定义了main函数,然后通过编译该文件后就可以生成一个可执行文件(程序)main.o,当我们在终端中运行./main.o后,该可执行文件便会被操作系统加载到内存中,保存在一个相对独立的内存区域(虚拟地址空间),该空间的起始地址是0x00, 在32位操作系统上一个进程分配的空间大小最大为4GB,如下图所示,这里面的地址都是相对地址,真正访问的时候是会映射到内存物理地址上去的,这种策略可以有效防止多个进程运行时的地址重叠问题。这个虚拟地址空间中最重要的几个部分:

1、栈区(stack):由编译器自动分配释放 ,存放函数的参数值,局部变量的值等。其操作方式类似于数据结构中的栈。

2、堆区(heap):般由程序员分配释放, 若程序员不释放,程序结束时可能由OS回 收 。注意它与数据结构中的堆是两回事,分配方式倒是类似于链表,呵呵。

3、全局区(静态区)(static):全局变量和静态变量的存储是放在一块的,初始化的全局变量和静态变量在一块区域, 未初始化的全局变量和未初始化的静态变量在相邻的另一块区域。 - 程序结束后由系统释放。

4、文字常量区 :常量字符串就是放在这里的。 程序结束后由系统释放

5、程序代码区 :存放函数体的二进制代码。

进程的虚拟地址空间

当程序加载到内存后,会自动开启一个主线程用来执行main函数中的代码,程序按照代码逻辑依次执行直至退出main函数,这个过程便称之为进程——即进行中的程序。可见程序运行时便会启动一个线程,此外我们也可以直接或间接调用创建线程的系统函数来在进程中继续创建除了主线程之外的其他线程,原理上这些线程可以共享该进程内存空间的所有东西。C++2.0已经提供了pthread库封装了多线程相关的一些函数。

2. C++多线程操作函数

多线程运行的目的是为了提高效率或者使不同模块的任务能够同时运行,一般情况下后台服务的目的都是前者,即提高服务吞吐量,服务部署之后是可以通过动态设置并发线程数来提高处理效率的。C++中常用的多线程相关的库函数有thread、mutex、future、conditional_variable等等。

①mutex: 创建一个互斥锁对象用于锁定临界区,临界区中主要包含了一些如果多个线程同时进入该区域,会导致操作混乱,所以在某一个线程进入临界区时,便给该区域上锁,当其他线程执行到要进入临界区时,发现该区域已经上锁,则阻塞在这个地方。拥有锁的线程直到退出该区域时再解锁,解锁之后,其他线程(包括已阻塞的线程才能继续运行)。原始的加解锁函数时lock()和unlock(), 但如果忘记了unlock则会导致死锁。所以比较常用的方法是创建临时对象 std::lock_guardstd::mutex lk(mutex_obj),这样可以实现在退出作用域时自动解锁。另外std::unique_lock也可以实现自动加解锁,但相比于std::lock_guard更加灵活,可以实现随时主动加解锁,但也会更加消耗系统资源,所以一般情况下lock_guard就够用了。

②thread: 用于创建线程。创建一个thread对象时,需要传入一个可调用对象,如果有传参还可以直接传参进去。

③future: 可以从异步执行的线程中获取结果,类似于一个占位符,当线程执行结束后便可通过.get()方法获取结果,未执行结束则阻塞直到结果返回。

④conditional_variable:当 std::condition_variable对象调用wait 函数后,当前线程会被阻塞,直到另一个线程在相同的 std::condition_variable 对象上调用了 notify 函数来唤醒当前线程。另外还有第二参数限制,只有当第二参数为false时才会执行阻塞,这个第二参数在生产者消费者模式中是任务队列的size,即如果队列为空则消费者停止获取任务。

3. C++线程池

构建线程池的目的是为了在任务很多的情况下,利用多个线程同时执行,以提高处理效率。我们只需要将任务传递给线程池,该任务便可以自动由线程池管理起来,在适当的时候执行。那么总的来说,线程池需要实现的两个模块分别是:①任务队列:用于暂存传递给线程池的任务;②线程队列:构建一定数目的线程用于执行线程队列中的任务。下面的线程池参照Skykey:基于C++11实现线程池 这篇写的,推荐大家去读原作者的这篇文章,写的很详细。

任务队列类:

template <typename T>
class SafeQueue
{
public:
SafeQueue() {}
~SafeQueue() {}
SafeQueue(SafeQueue&& other) {} //获取empty状态
bool is_empty() {
std::lock_guard<std::mutex> m_guard(m);
return taskQ.empty();
} // 入队和出队
int push(T& task) {
std::lock_guard<std::mutex> m_guard(m);
taskQ.push(task);
return 0;
}
bool pop(T& task) {
std::lock_guard<std::mutex> m_guard(m);
if (taskQ.empty()) {
return false;
}
task = taskQ.front();
taskQ.pop();
return true;
} private:
std::queue<T> taskQ;
std::mutex m;
};

任务队列类的实现关键是对每一个接口加锁(线程安全),防止多线程同时访问导致数据混乱,也有可能导致程序崩溃。主要接口有三个:

empty():用于返回当前队列是否为空的状态,该状态会作为后面worker线程阻塞的条件;

push(): 将任务压入任务队列;

pop(): 从任务队列获取一个任务,并返回一个获取成功与否的信号;

地外,该任务队列实现为一个模板类,所以可以传入的任务类型也比较灵活。

线程池类:

#include "taskqueue.h"
class ThreadPool
{
private:
class ThreadWorker
{
private:
int m_id;
ThreadPool* m_pool;
public:
ThreadWorker(ThreadPool* pool, const int id) :
m_id(id), m_pool(pool) {} void operator()() {
std::function<void()> func;
bool dequeued; while (!m_pool->m_shutdown)
{
{
std::unique_lock<std::mutex> lock(m_pool->m_conditional_mutex);
if (m_pool->m_queue.empty())
{
m_pool->m_conditional_lock.wait(lock);
}
dequeued = m_pool->m_queue.pop(func);
}
if (dequeued)
{
func();
}
}
}
}; public:
ThreadPool(const int n_threads = 4) :m_threads(std::vector<std::thread>(n_threads)), m_shutdown(false) {}
ThreadPool(const ThreadPool&) = default;
ThreadPool(ThreadPool&&) = default;
ThreadPool& operator=(const ThreadPool&) = default;
ThreadPool& operator=(ThreadPool&&) = default; void init() {
for (int i = 0; i < m_threads.size(); ++i) {
m_threads.at(i) = std::thread(ThreadWorker(this, i));
}
} void shutdown()
{
m_shutdown = true;
m_conditional_lock.notify_all();
for (int i = 0; i < m_threads.size(); i++)
{
if (m_threads.at(i).joinable()) {
m_threads.at(i).join();
}
}
} template <typename F, typename... Args>
auto submit(F&& f, Args &&...args) -> std::future<decltype(f(args...))>
{
// Create a function with bounded parameter ready to execute
std::function<decltype(f(args...))()> func = std::bind(std::forward<F>(f), std::forward<Args>(args)...);// 连接函数和参数定义,特殊函数类型,避免左右值错误
// Encapsulate it into a shared pointer in order to be able to copy construct
auto task_ptr = std::make_shared<std::packaged_task<decltype(f(args...))()>>(func);
// Warp packaged task into void function
std::function<void()> warpper_func = [task_ptr]()
{
(*task_ptr)();
};
// 队列通用安全封包函数,并压入安全队列
m_queue.enqueue(warpper_func);
// 唤醒一个等待中的线程
m_conditional_lock.notify_one();
// 返回先前注册的任务指针
return task_ptr->get_future();
}
private:
bool m_shutdown;
SafeQueue<std::function<void()>> m_queue;
std::vector<std::thread> m_threads;
std::mutex m_conditional_mutex;
std::condition_variable m_conditional_lock;
};

线程池类的实现相对比较复杂,主要有以下几点:

ThreadWorker:其中的核心是工作者线程启动后自动获取任务队列中的任务并执行的过程,这里面涉及到同步的问题。一般我们都是给thread传入一个函数,让其自动执行完毕并自动销毁线程。这里原理上我们也可以这样做,但是反复创建和销毁线程会带来额外开销。所以线程池里我们是给每个线程传入一个可调用的工作者线程对象,该对象的()重载函数带有while(1)循环,可以反复从任务队列中读取任务并执行。

submit():该函数就有点复杂了(我也很懵),用到了c++高级用法,但核心就是对任意参数类型的任意函数用函数适配器和智能指针封装为一个返回类型固定为std::function<void()>的匿名函数,然后将其push到任务队列中,并随机唤醒一个由m_conditional_lock阻塞的线程继续运行。

init(): 启动每个工作者线程,如果任务队列为空则阻塞,非空则持续获取任务并执行,直到任务队列为空再次阻塞。

shutdown(): 将关闭信号置为true,并唤醒所有线程并紧接着阻塞等待所有工作者线程退出。此时工作者线程执行到while判断处时条件为假->退出循环->退出函数->线程关闭. 等到所有子线程退出后,shutdown函数也就退出了。

测试代码:

#include "threadpool/ThreadPool.h"
#include <random>
#include <iostream>
#include <chrono>
#include <future> std::random_device rd;
std::mt19937 mt(rd());
std::uniform_int_distribution<int> dist(-1000, 1000);
auto rnd = std::bind(dist, mt); void simulate_hard_computation()
{
std::this_thread::sleep_for(std::chrono::milliseconds(2000 + rnd()));
} void multiply(const int a, const int b)
{
simulate_hard_computation();
const int res = a * b;
std::cout << a << "*" << b << "=" << res << std::endl;
} void multiply_output(int& out, const int a, const int b)
{
simulate_hard_computation();
out = a * b;
std::cout << a << "*" << b << "=" << out << std::endl;
} int multiply_return(const int a, const int b)
{
simulate_hard_computation();
const int res = a * b;
std::cout << a << "*" << b << "=" << std::endl;
return res;
} int main()
{
ThreadPool pool(3);
pool.init();
//传递多个任务
for (int i = 1; i <= 3; i++) {
for (int j = 1; j <= 10; j++) {
pool.submit(multiply, i, j);
}
}
//使用ref传递的输出参数提交函数
int output_ref;
auto future1 = pool.submit(multiply_output, std::ref(output_ref), 5, 6);
future1.get();
std::cout << "Last operation result is equals to " << output_ref << std::endl;
// 使用return参数提交函数
auto future2 = pool.submit(multiply_return, 30, 11);
// 等待乘法输出完成
int res = future2.get();
std::cout << "Last operation result is equals to " << res << std::endl; pool.shutdown();
return 0;
}

测试代码中模拟了多种传参的函数类型,均可以通过submit方法传递给线程池执行:

①有传参无返回值void func(a,b):直接调用pool.submit(func,a,b)

②有传参且有返回值int func(a,b):直接调用并用future对象接收返回值,如 auto future_res = pool.submit(func,std::ref(res), a, b) , 然后调用future_res.get()方法阻塞到函数执行完毕并获取返回值。

③有传参且返回值通过参数返回void func(T &res, a,b),同样直接调用执行,但返回值要用std::ref封装一下,如auto future_res = pool.submit(func,std::ref(res), a, b),这里future_res是用来调用get()方法等待函数执行完毕的,等到执行完毕后,res的值便可以正常访问。

基础的线程池功能就是以上这些,此外还可以加入优先级任务队列、工作者线程数按照实时任务数量动态增减等高级功能。此外还可以在任务队列为空或者低于某一阈值时通知其他线程(生产者)提交任务到任务队列,即生产者消费者模式。目前C++相关知识还掌握的很浅,需要继续学习,等有新的知识点了再写,共勉。

参考文献:

  1. https://zhuanlan.zhihu.com/p/367309864

C++学习笔记——多线程(1)的更多相关文章

  1. 0037 Java学习笔记-多线程-同步代码块、同步方法、同步锁

    什么是同步 在上一篇0036 Java学习笔记-多线程-创建线程的三种方式示例代码中,实现Runnable创建多条线程,输出中的结果中会有错误,比如一张票卖了两次,有的票没卖的情况,因为线程对象被多条 ...

  2. Java学习笔记-多线程-创建线程的方式

    创建线程 创建线程的方式: 继承java.lang.Thread 实现java.lang.Runnable接口 所有的线程对象都是Thead及其子类的实例 每个线程完成一定的任务,其实就是一段顺序执行 ...

  3. tensorflow学习笔记——多线程输入数据处理框架

    之前我们学习使用TensorFlow对图像数据进行预处理的方法.虽然使用这些图像数据预处理的方法可以减少无关因素对图像识别模型效果的影响,但这些复杂的预处理过程也会减慢整个训练过程.为了避免图像预处理 ...

  4. ffmpeg学习笔记-多线程音视频解码

    之前的视频解码仍然存在问题,那就是是在主线程中去完成解码的,会造成线程阻塞,这里将其改为多线程解码,使其主线程不被阻塞 前面介绍了音视频的主线程解码,那样会阻塞主线程,在前面学习了多线程以后,就可以对 ...

  5. python学习笔记- 多线程(1)

    学习多线程首先先要理解线程和进程的关系. 进程 计算机的程序是储存在磁盘中的可执行的二进制文件,执行时把这些二进制文件加载到内存中,操作系统调用并交给处理器执行对应操作,进程是程序的一次执行过程,这是 ...

  6. java学习笔记 --- 多线程(多线程的创建方式)

    1.创建多线程方式1——继承Thread类. 步骤:  A:自定义类MyThread继承Thread类.  B:MyThread类里面重写run()? 为什么是run()方法呢? C:创建对象 D:启 ...

  7. 0040 Java学习笔记-多线程-线程run()方法中的异常

    run()与异常 不管是Threade还是Runnable的run()方法都没有定义抛出异常,也就是说一条线程内部发生的checked异常,必须也只能在内部用try-catch处理掉,不能往外抛,因为 ...

  8. 0039 Java学习笔记-多线程-线程控制、线程组

    join线程 假如A线程要B线程去完成一项任务,在B线程完成返回之前,不进行下一步执行,那么就可以调用B线程的join()方法 join()方法的重载: join():等待不限时间 join(long ...

  9. JAVA并发编程学习笔记------多线程调优

    1. 多线程场景下尽量使用并发容器代替同步容器 (如ConcurrentHashMap代替同步且基于散列的Map, 遍历操作为主要操作的情况下用CopyOnWriteArrayList代替同步的Lis ...

随机推荐

  1. yum下载安装git服务

    yum install git 安装成功后,配置 用户 邮箱信息 注: youxiu326 github账号名称 youxiu326@163.com    github账号对应邮箱 git confi ...

  2. 2.安装Spark与Python练习

    一.安装Spark <Spark2.4.0入门:Spark的安装和使用> 博客地址:http://dblab.xmu.edu.cn/blog/1307-2/ 1.1 基础环境 1.1.1 ...

  3. C++面向对象 - 类的前向声明的用法

    C++中的类应当是先定义,然后使用.但在处理相对复杂的问题,比如考虑类的组合时,有可能遇到两个类相互引用的情况,这种情况称为循环依赖. 考虑下面代码: class A { public: void f ...

  4. CSS:两端对齐原理(text-align:justify)

    我是一个小白我是一个小白我是一个小白喷我吧,哈哈 写样式的是时候经常会碰到字体两端对齐的效果,一般就网上找端css样式复制下就结束了,没有考虑过原理是啥贴下代码 <head> <me ...

  5. ES6-11学习笔记--函数的参数

    参数的默认值 与解构赋值结合 length属性 作用域 函数的name属性   ES5设置函数参数默认值: function foo(x, y) { y = y || 'world'; console ...

  6. python-查找鞍点

    [题目描述]对于给定5X5的整数矩阵,设计算法查找出所有的鞍点的信息(包括鞍点的值和行.列坐标,坐标从1开始). 提示:鞍点的特点:列上最小,行上最大.   [练习要求]请给出源代码程序和运行测试结果 ...

  7. c++实现中介者模式--虚拟聊天室

    内容: 在"虚拟聊天室"实例中增加一个新的具体聊天室类和一个新的具体会员类,要求如下: 1. 新的具体聊天室中发送的图片大小不得超过20M. 2. 新的具体聊天室中发送的文字长度不 ...

  8. 【Android开发】控件外边框自定义

    1.在drawable里面新建自定义的资源文件shape <?xml version="1.0" encoding="utf-8"?> <sh ...

  9. MySQL 中的 SQL 语句详解

    @ 目录 总结内容 1. 基本概念 2. SQL列的常用类型 3. DDL简单操作 3.1 数据库操作 3.2 表操作 4. DML操作 4.1 修改操作(UPDATE SET) 4.2 插入操作(I ...

  10. BootstrapBlazor实战 Tree树形控件使用(2)

    继续上篇实战BootstrapBlazor树型控件Tree内容, 本篇主要讲解整合Freesql orm快速制作数据库后台维护页面 demo演示的是Sqlite驱动,FreeSql支持多种数据库,My ...