C++ 多线程编程

为啥会突然冒出这么一篇和C++编程相关的文章,因为今天看了下之前的工程代码把传统的激光雷达点云处理和深度学习的点云识别流程串行化了,传统的点云处理(用于障碍物检测)和深度学习的目标检测(用于点云识别)输出都是中心点,长宽高,旋转角等属性,正确流程是将两个分支的输出结果融合后送入跟踪模块,所以创建了一个RecogniseThread并行处理传统点云算法和Deep Learning识别算法。这里就简单介绍下C++的多线程(之前写过一篇Python多进程的文章作为姊妹篇也一并放出来)。

多线程原理

线程是进程中的一个实体,是被系统独立分配和调度的基本单位。也就是说线程是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;            //实例化mutex
vector<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;            //实例化mutex
vector<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可以作为函数的返回类型,或者作为参数传递给函数,能够更为方便的管理线程。

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

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值