c++ 多线程编程std::thread, std::shared_mutex, std::unique_lock

在C++11新标准中,可以简单通过使用thread库,来管理多线程,使用时需要#include <thread>头文件。

简单用例如下:

1 std::thread(Simple_func);
2 std::thread t(Simple_func);
3 t.detach();

第一行是直接启动一个新线程来执行Simple_func函数,而第二行先声明一个线程函数t(返回类型为thread),然后用detach方法等待线程结束。

C++11有两种方式来等待线程结束:

  • detach方式,启动的线程自主在后台运行,当前的代码继续往下执行,不等待新线程结束。前面代码所使用的就是这种方式。
    • 调用detach表示thread对象和其表示的线程完全分离;
    • 分离之后的线程是不在受约束和管制,会单独执行,直到执行完毕释放资源,可以看做是一个daemon线程;
    • 分离之后thread对象不再表示任何线程;
    • 分离之后joinable() == false,即使还在执行;
  • join方式,等待启动的线程完成,才会继续往下执行。假如前面的代码使用这种方式,其输出就会0,1,2,3,因为每次都是前一个线程输出完成了才会进行下一个循环,启动下一个新线程。
    • 只有处于活动状态线程才能调用join,可以通过joinable()函数检查;
    • joinable() == true表示当前线程是活动线程,才可以调用join函数;(非joinable线程直接调用join会触发abort函数中断)
    • 默认构造函数创建的对象是joinable() == false;
    • join只能被调用一次,之后joinable就会变为false,表示线程执行完毕;
    • 调用 ternimate()的线程必须是 joinable() == false;
    • 如果线程不调用join()函数,即使执行完毕也是一个活动线程,即joinable() == true,依然可以调用join()函数;

每创建一个合法的thread对象,变对应一个底层的线程,对一个thread对象来说,其存在两个状态:可结合不可结合

线程的可结合性(joinability)是指线程是否可以被其他线程等待并回收其资源。在具备可结合性的线程中,创建线程的线程可以调用特定的函数(如pthread_join)等待该线程的结束,并在线程终止后回收其资源。如上例:在t被声明后,该线程立即执行,而.join()和.detach()方法是主线程等待线程结束的方法,若不调用这两种方法,则会在主线程退出时抛出异常"abort() has been called",这是因为t是可结合的,在主线程结束调用t的析构函数时会抛出此异常。你可能会问为什么可结合的线程不被声明等待方法就无法析构,此处使用EffectiveModernCpp中的例子解释:

constexpr auto tenMillion = 10000000;           //constexpr见条款15

bool doWork(std::function<bool(int)> filter,    //返回计算是否执行;
            int maxVal = tenMillion)            //std::function见条款2
{
    std::vector<int> goodVals;                  //满足filter的值

    std::thread t([&filter, maxVal, &goodVals]  //填充goodVals
                  {
                      for (auto i = 0; i <= maxVal; ++i)
                          { if (filter(i)) goodVals.push_back(i); }
                  });

    auto nh = t.native_handle();                //使用t的原生句柄//来设置t的优先级

    if (conditionsAreSatisfied()) {
        t.join();                               //等t完成
        performComputation(goodVals);
        return true;                            //执行了计算
    }
    return false;                               //未执行计算
}

可看到上例中给出了一个doWork函数,其目的是使用过滤函数filter得到合法的数据goodVals,并在conditionsAreSatisfied()条件满足下处理数据,为了提高效率使用了多线程来处理得到goodVals。

此处有个问题,即conditionsAreSatisfied()为true时不会产生异常,若conditionsAreSatisfied()为false时则会抛出异常,因为t未被声明为join或detach。假设t可以被隐式声明为join或detach,那么会产生如下两种情况:

  • 隐式join 。这种情况下,std::thread的析构函数将等待其底层的异步执行线程完成。这听起来是合理的,但是可能会导致难以追踪的异常表现。比如,如果conditonAreStatisfied()已经返回了falsedoWork继续等待过滤器应用于所有值就很违反直觉。

  • 隐式detach 。这种情况下,std::thread析构函数会分离std::thread与其底层的线程。底层线程继续运行。听起来比join的方式好,但是可能导致更严重的调试问题。比如,在doWork中,goodVals是通过引用捕获的局部变量。它也被lambda修改(通过调用push_back)。假定,lambda异步执行时,conditionsAreSatisfied()返回false。这时,doWork返回,同时局部变量(包括goodVals)被销毁。栈被弹出,并在doWork的调用点继续执行线程。

所以说对于std::thread对象必须显式声明其等待方法。

相对地,不具备可结合性的线程被称为“分离线程”。分离线程在终止时,会自动释放其资源,无需其他线程进行回收操作。分离线程的创建和终止过程更加轻量级,不需要等待或回收线程的资源,适用于那些不需要线程结果或资源清理的场景。

不可结合状态有以下四种:

  • 默认构造的std::threads。这种std::thread没有函数执行,因此没有对应到底层执行线程上。
  • 已经被移动走的std::thread对象。移动的结果就是一个std::thread原来对应的执行线程现在对应于另一个std::thread
  • 已经被joinstd::thread 。在join之后,std::thread不再对应于已经运行完了的执行线程。
  • 已经被detachstd::thread 。detach断开了std::thread对象与执行线程之间的连接。

 

 

向线程传递参数

向线程调用的函数传递参数也是很简单的,只需要在构造thread的实例时,依次传入即可。例如

int Simple_func(int a, int b);
std::thread t(Simple_func,1,2);

需要注意的是,默认的会将传递的参数以拷贝的方式复制到线程空间,即使参数的类型是引用,如果在线程中使用引用来更新对象时,就需要注意了。默认的是将对象拷贝到线程空间,其引用的是拷贝的线程空间的对象,而不是初始希望改变的对象。例如:

int ChangeNum(int &a);
int num=0;
std::thread t(ChangeNum,num);
t.join();

在线程内,将对象的字段a和b设置为新的值,但是在线程调用结束后,这两个字段的值并不会改变。这样由于引用的实际上是局部变量num的一个拷贝。

若想通过线程改变对象值,需调用std::ref,将num的引用传入线程,如:

std::thread t(ChangeNum,std::ref(num));

thread是可移动的(movable)的,但不可复制(copyable)。可以通过move来改变线程的所有权,灵活的决定线程在什么时候join或者detach。

std::thread也可以去包装一个类,前提是该类对()操作符进行了重载,使其相当于拥有了函数的性质。(此处类似于std::bind的绑定)


 

在多线程编程的时候,资源竞争是很常见的问题,因此需要引入互斥锁。c++11中提供了std::mutex,而在C++17开始,标准库提供了shared_mutex类。

需注意,std::mutex 既不可移动,也不可复制。因而包含他们的类也同时是不可移动和不可复制的。

对于shared_mutex,可以理解为共享锁,允许多个线程同时对同一资源进行操作。而lock_guard、unique_lock可以理解为独占锁,只允许一个线程对资源进行操作。

在一些只读函数中可以用std::shared_mutex,而在写操作函数中需用std::unique_lock。

std::shared_mutex是c++17中引入的,不支持std::mutex,需用std::shared_mutex声明互斥信号量。

  • std::lock_guard (c++11): 单个std::mutex(或std::shared_mutex)
  • std::unique_lock (c++11): 单个std::mutex(或std::shared_mutex), 用法比std::lock_guard更灵活
  • std::shared_lock (c++14): 单个std::shared_mutex
  • std::scoped_lock (c++17): 多个std::mutex(或std::shared_mutex)

以上四种锁均是c++中符合Scoped Locking(将RAII手法应用于locking的并发编程技巧,即在构造时获得锁,在析构时释放锁)。

其中c++17提供了std::scoped_lock可以对多个不同类型的mutex进行Scoped Locking,其预防死锁策略很简单,假设要对n个mutex(mutex1, mutex2, ..., mutexn)上锁,那么每次只尝试对一个mutex上锁,只要上锁失败就立即释放获得的所有锁(方便让其他线程获得锁),然后重新开始上锁,处于一个循环当中,直到对n个mutex都上锁成功。这种策略是基本上是有效的。

参考文章:https://immortalqx.github.io/2021/12/04/cpp-notes-3/

https://zhuanlan.zhihu.com/p/461530638

 

posted @ 2023-03-31 11:21  _Explosion!  阅读(231)  评论(0编辑  收藏  举报