C++ 多线程处理(编程)

目标检测与深度学习

共 5871字,需浏览 12分钟

 ·

2021-03-06 18:46


文章导读

本文是一篇工程开发方面的文章,算法的落地还是需要工程代码的依托,比如在激光雷达感知模块中如果既包含了传统点云处理做障碍物检测,又使用深度学习做点云目标识别,往往需要采用多线程并行处理两个分支,再将输出结果融合后送入跟踪模块,这里简单介绍下C++的多线程知识。


目录

  • 多线程原理

  • 多线程同步和互斥

  • 多线程示例代码


多线程原理

线程是进程中的一个实体,是被系统独立分配和调度的基本单位。也就是说线程是CPU可执行调度的最小单位。引入线程之后将进程的两个基本属性分开了,线程作为调度和分配的基本单位,进程作为独立分配资源的单位。


线程基本上不拥有资源,只拥有一点运行中必不可少的资源,在同一个进程中的所有线程都共享地址空间,线程间的大部分数据可以共享并且相比多进程来说,多线程间的通信开销小、启动速度快、占用资源少。


为什么要进行多线程并发呢?

主要是用于任务拆分和提高性能:将程序划分成不同的任务,每个线程执行一个或多个任务,可以将整个程序的逻辑变的更加清晰;任务之间各自并发降低运算时间。


谈到多线程我们经常听到并发、并行的概念,并发与并行有什么区别呢?

串行很容易理解就是说所有任务都按先后顺序执行。而并行和并发的区别在于多个任务是否同时执行。


并发是指在同一时间段内,能够处理多个事件,在并发程序中可以同时拥有两个或者多个线程。这意味着,如果程序在单核处理器上运行,那么这两个线程将交替地换入或者换出内存。这些线程是同时“存在”的——每个线程都处于执行过程中的某个状态。 


并行是指在同一时刻上,能够处理多个事件。如果程序能够并行执行,那么就一定是运行在多核处理器上。此时程序中的每个线程都将分配到一个独立的处理器核上,因此可以同时运行。


多线程同步和互斥

多个线程同时执行任务肯定存在线程间的同步和互斥:

1. 线程同步:指线程之间所具有的一种制约关系,一个线程的执行依赖另外一个线程的消息,当它没有得到另一个线程的消息时应等待,直到消息到达时才被唤醒。


2. 线程互斥:指对于共享的进程系统资源,每个线程访问时的排他性。当有若干个线程都要使用某一个共享资源时,任何时刻最多只允许一个线程去使用,其他线程必须等待,知道占用占用资源者释放该资源。线程互斥可以看成是一种特殊的线程同步。


线程间的同步方法大体可以分为两类:

1. 用户模式(使用时不需要切换内核态,只在用户态完成操作):

  • 临界区:适合一个进程内的多线程访问公共区域或代码段时使用;


2. 内核模式(利用系统内核对象的单一性来进行同步,使用时需要切换内核态与用户态):

  • 事件:通过线程间触发事件实现同步互斥;

  • 互斥量:适合不同进程内多线程访问公共区域或代码段时使用,与临界区相似;

  • 信号量:与临界区和互斥量不同,可以实现多个线程同时访问公共区域数据,原理与操作系统中PV操作类似,先设置一个访问公共区域的线程最大连接数,每有一个线程访问共享区资源数就减一,直到资源数小于等于零;



多线程示例代码

C++11中提供了thread库管理线程、保护共享数据、线程间同步等功能。头文件是#include<thread>。一个进程至少要有一个线程,在C++中可以认为main函数就是我们的主线程。而在创建thread对象的时候,就是在这个线程之外创建了一个独立的子线程。只要创建了这个子线程并且开始运行了,主线程就完全和它没有关系了,不知道CPU会什么时候调度它运行,什么时候结束运行。


如何创建、启动、结束多线程?


C++有三种创建线程的方式:

1. 通过一个初始函数创建线程

#include <iostream>#include <thread>using namespace std;void RecogniseThread(){    std::cout << "DL Recognise point cloud." << std::endl;}int main(){    thread task(RecogniseThread);    task.join();    //task.detach();    std::cout << "执行主线程" << std::endl;        return 0;}
  • 上例构建一个thread对象task,构造的时候传递一个函数名进去,它就是这个线程的入口函数,函数执行完表示该线程也执行结束;

  • 线程创建成功后就立即启动,并没有一个开始的API触发启动线程;

  • 线程运行后需要显示的决定程序是否用join阻塞等待它完成,并回收该线程中使用的资源或者detach分离出去让他自行运作;


2. 通过类对象创建线程

通过类来构造线程其实是仿函数的功能。仿函数是使用类来模拟函数调用行为,只要重载一个类的operator()方法,即可像调用一个函数一样调用类。提供给thread实例的函数对象会复制到新线程的存储空间中,函数对象的执行和调用都在线程的内存空间中执行。

#include <iostream>#include <thread>using namespace std;class Recognise{    void operator()(){        std::this_thread::sleep_for(std::chrono::milliseconds(1000));        std::cout << "子线程延迟1s." << std::endl;    }}
int main(){ thread task(Recognise); task.detach(); std::cout << "主线程结束" << std::endl; return 0;}
  • join用于阻塞线程,所以主线程要等待子线程执行完毕才会继续向下执行;调用join还会清理线程相关的存储部分,这表示join只能调用一次。使用joinable()来判断join()可否调用。

  • detach用于主线程和当前线程分离,主线程可以先执行结束,如果主线程执行完了,子线程会在C++后台运行,一旦使用detach,与这个子线程关联的对象会失去对这个主线程的关联,此时这个子线程会驻留在C++后台运行,当主线程执行完毕结束,子线程会移交给C++运行时库管理,这个运行时库会清理与这个线程相关的资源(守护线程),detach会使子线程失去进程的控制;

  • 开启线程后必须显示的决定主线程是等待还是分离子线程,否则会引起程序崩溃。

  • 上例中让子线程延迟1s目的是让主线程先结束,task对象被销毁,但是由于detach分离出线程,所以子线程仍可以脱离主线程运行打印;


3. 通过lambda表达式创建线程

#include <iostream>#include <thread>using namespace std;int main(){    auto lambda_thread = [] {        std::this_thread::sleep_for(std::chrono::milliseconds(1000));        std::cout << "子线程延迟1s." << std::endl;    };    thread task(lambda_thread);    task.detach();    std::cout << "主线程结束" << std::endl;        return 0;}



线程如何调用类成员函数并传入参数?

#include <iostream>#include <thread>class LidarProcess {public:    void RecogniseThread(const PointCloudPtr &input_cloud_ptr){        std::cout << "Process DL Inference." << std::endl;    }};int main(){    LidarProcess lidar_process;    PointCloudPtr inlier_cloud_ptr(new PointCloud);    std::thread task(std::bind(&LidarProcess::RecogniseThread, this, inlier_cloud_ptr));    task.join();     return 0;}


创建一个LidarProcess类,在主函数中将类LidarProcess中的成员函数绑定到线程对象task上。截取的工程代码,有些结构体定义未放出来,比如PointCloudPtr 是智能指针,所以在这就没有delete了。


多线程如何进行同步?

互斥量是为了解决数据共享过程中可能存在的访问冲突的问题。在C++11中提供4互斥量:

std::mutex;                  //非递归的互斥量std::timed_mutex;            //带超时的非递归互斥量std::recursive_mutex;        //递归互斥量std::recursive_timed_mutex;  //带超时的递归互斥量


其中最常用的是mutex,通过实例化mutex创建互斥量,在进入临界区之前调用成员函数lock进行上锁,退出临界区时对互斥量unlock进行解锁,当一个线程使用特定互斥量锁住共享数据时,其他的线程想要访问锁住的数据,都必须等到之前那个线程对数据进行解锁后,才能进行访问。(这种方式需要在每个函数的出口或者异常都去调用unlock)

mutex mx;            //实例化mutexvector<BBox> bboxes; //用于DL推理后存储Bounding box的容器
vector<BBox> RecogniseObject(const PointCloudPtr input_cloud_ptr){...}
void RecogniseThread(const PointCloudPtr input_cloud_ptr){ mu.lock(); //如果这句话抛出异常,mu永远会被锁住 bboxes = RecogniseObject(input_cloud_ptr); mu.unlock();}


因为通过lock与unclock可以解决线程之间的资源竞争问题,如果在RecogniseObject中执行识别时程序因为某些原因退出了,此时就无法unlock,这样其他线程也就无法获取mutex资源从而造成死锁现象,其实在加锁之前可以通过trylock()尝试一下能不能加锁。但更好的方式是采用lock_guard或者unique_lock来控制std::mutex。所以C++提供了一种RAII(Resource Acquisition Is Initialization 资源获取即初始化)方式的模板类lock_guard,会在构造的时候提供加锁的互斥量,并在析构的时候进行解锁,从而保证了一个已锁的互斥量总是会被正确的解锁。(unique_lock类似)

mutex mx;            //实例化mutexvector<BBox> bboxes; //用于DL推理后存储Bounding box的容器
vector<BBox> RecogniseObject(const PointCloudPtr input_cloud_ptr){...}
void RecogniseThread(const PointCloudPtr input_cloud_ptr){ lock_guard<mutex> guard(mu); //当此句异常,mu对象自动被解锁 bboxes = RecogniseObject(input_cloud_ptr);}


大多数情况下,互斥量会和被保护的数据放在同一个类中,而不是定义成全局变量。二者均定义成private成员。互斥量保护的数据需要对接口的设计相当谨慎,要确保互斥量能锁住任何对保护数据的访问,尤其是需要传递指针和引用时。


如何转移线程的所有权?

thread是可移动的(movable)的,但不可复制(copyable)。移动拷贝或者移动赋值都使得原有对象对所属资源的控制权发生转移,从对象A转移到对象B,对资源的控制只在对象B中保留。在C++ 11中标准库中,提供了std::move函数用于资源移动操作来改变线程的所有权,灵活的决定线程在什么时候join或者detach。

thread task_1(RecogniseThread);thread task_2(move(task_1));


将线程从task_1转移给task_2,这时候task_1就不再拥有线程的所有权,调用task_1.join()或task_1.detach()会出现异常,要使用task2来管理线程,此时task1的ID变为0,joinable变为为false。这也就意味着thread可以作为函数的返回类型,或者作为参数传递给函数,能够更为方便的管理线程。



    ✄------------------------------------------------


    看到这里了,说明您也喜欢这篇文章,您可以点击「分享」与朋友们交流,点击「在看」使我们的新文章及时出现在您的订阅列表中,或顺手「点赞」给我们一个支持,让我们做的更好哦。


    欢迎微信搜索并关注「目标检测与深度学习」,不被垃圾信息干扰,只分享有价值知识!


浏览 128
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报