【Example】C++ 标准库 std::thread 与 std::mutex

简介: 与 Unix 下的 thread 不同的是,C++ 标准库当中的 std::thread 功能更加简单,可以支持跨平台特性。因此在应用需要跨平台的情况下,应优先考虑使用 std::thread。同时为了使多线程操作更加安全,std::thread 经常与标准库互斥量 std::mutex 相配合使用。

std::thread

std::thread 对象是 C++ 标准库当中最基本的多线程实现方式。

可以使用 thread 对象查看和管理应用程序中的执行线程。 使用 thread 默认构造函数创建的 对象不与任何执行线程相关联。 使用 thread 可调用对象构造的 对象将创建一个新的执行线程,并调用该 中的可调用对象 thread。 Thread 对象可以移动,但不能复制。 这就是执行线程只能与一个对象关联 thread 的原因。

每个执行线程都具有 thread::id 类型的唯一标识符。 函数 this_thread::get_id 返回调用线程的标识符。 成员函数 thread::get_id 返回由 对象管理的线程的标识符 thread 。 thread::this_thread::get_id、thread::thread::get_id对于默认构造的对象,该方法返回一个对象,该对象的值对于所有默认构造的对象都相同,并且不同于在调用时可以联接的任何执行线程返回的值。

-- Microsoft Docs

 

std::thread 的头文件是:

#include <thread>

它的语法是:

【伪代码】std::threadt(FuncPtr, args1, ...);
【常规情况】std::threadt1(SortVectorMutex, std::ref(m), std::ref(vec1));

可以看到 std::thread 第一个参数为一个函数指针,后面则是该函数的参数。

当 std::thread 对象被初始化后,线程便立即开始执行。请注意是线程对象被初始化后,当使用默认空构造函数创建对象后,线程并没有被初始化,因此不会开始新的线程。

std::thread 的构造函数:

构造函数 操作 是否初始化
thread() noexcept; 默认空构造函数
template <class Fn, class... Args> explicit thread(Fn&& fn, Args&&... args); 初始化构造函数
thread(thread&& x) noexcept; 移动构造函数 视情况而定

 

额外备注:

1,std::thread 禁用了拷贝构造函数(thread(const thread&) = delete),无法被拷贝构造。

2,std::thread 禁用了拷贝赋值重载(thread& operator=(const thread&) = delete),无法被拷贝赋值。

3,std::thread 可以被移动赋值:

thread&operator=(thread&&rhs) noexcept;
std::threadt3(PrintID);
std::threadt4=std::move(t3);

4,std::thread 的移动构造本身不会对其进行初始化,如果被移动的对象本身是已初始化过的,那么它也是初始化的,反之亦然。

 

std::thread 的成员函数:

成员函数名 作用
join 阻塞等待到该线程结束。
detach 将线程从父进程分离,无法再通过 thread 对象对其进行操作,生命周期也脱离父进程,最终由操作系统进行资源回收。
joinable 检查线程是否可被阻塞等待。
get_id 获取该线程的唯一标识符。
swap 与指定 thread 对象进行互换操作。
native_handle 获取该线程的句柄。
hardware_concurrency 返回逻辑处理器数量。

 

以下展示了一个 std::thread 的最简单应用:

voidPrintID() {
cout<<"Thread ID: "<<std::this_thread::get_id() <<endl;    
return;
}
voidSortVector(vector<int>&vec) {
std::sort(vec.begin(), vec.end());
return;
}
intmain()
{
cout<<"Concurrency: "<<std::thread::hardware_concurrency() <<endl;
std::threadt1(PrintID);
std::threadt2(PrintID);
std::threadt3(PrintID);
vector<int>vec1{2, 1, 4, 8, 7, 5, 9, 3};
std::threadt4(SortVector, ref(vec1));
t4.join();
for (auto&i : vec1)
    {
cout<<i<<endl;
    }
t1.join();
t2.join();
t3.join();
returnEXIT_SUCCESS;
}

 

 

互斥量

在多线程操作当中,必然会出现对资源的并发访问,如果资源本身会因为多个线程同时操作而导致损坏不可用,这时就需要用到互斥量进行保护,也就是俗称的“锁”。

C++ 标准库当中提供了互斥量 mutex 系列,然而在实际开发当中更多是与 std::lock_guard 、std::unique_lock 配合使用。

但是,要想学会使用 std::lock_guard 和 std::unique_lock ,必须先了解基本的 std::mutex。

 

头文件:

#include <mutex>

在头文件当中提供了四种互斥量:

名称 作用
std::mutex 基本互斥量
std::timed_mutex 定时互斥量
std::recursive_mutex 递归互斥量
std::recursive_timed_mutex 定时递归互斥量

 

std::mutex 与 std::timed_mutex

先从最基本的 std::mutex 入手,其余互斥量皆是其变种。需要了解4个公共方法:

名称 作用
lock 阻止其他线程。如果已被其他线程阻止,则等待到被解除,再获取所有权并阻止。
unlock 立即解除阻止。
try_lock 尝试获取所有权,如果没有被其他线程阻止,则获取所有权并阻止。如果已被其他线程阻止,则返回false。
native_handle 返回 mutex 的句柄。

 

以下演示了一个对 std::mutex 最简单的使用:

voidSortVectorMutex(std::mutex&m, vector<int>&vec) {
m.lock();
std::sort(vec.begin(), vec.end());
m.unlock();
return;
}
voidPushVectorGuard(std::mutex&m, vector<int>&vec) {
m.lock();
vec.push_back(15);
vec.push_back(12);
vec.push_back(10);
m.unlock();
return;
}
intmain()
{
std::mutexm;
vector<int>vec1{2, 1, 4, 8, 7, 5, 9, 3};
std::threadt1(SortVectorMutex, ref(m), ref(vec1));
std::threadt2(PushVectorGuard, ref(m), ref(vec1));
t1.join();
t2.join();
for (auto&i : vec1)
    {
cout<<i<<endl;
    }
returnEXIT_SUCCESS;
}

 

可以看到,std::mutex 的操作仅仅对作用域的手动上锁与解锁。因此,也要牢记,lock 与 unlock 应成对使用避免造成死锁!

那么 std::timed_mutex 呢?无非就是在 mutex 的基础上增加了时间限制功能而已:

名称 作用
try_lock_for 等待到时间间隔
try_lock_until 等待到指定时间

演示代码:

voidSortVectorTimeMutex(std::timed_mutex&m, vector<int>&vec) {
std::chrono::millisecondstimes=std::chrono::milliseconds(100); // 100毫秒if (m.try_lock_for(times)){
std::sort(vec.begin(), vec.end());
m.unlock();
    }
return;
}

 

std::recursive_mutex 与 std::recursive_timed_mutex

std::mutex 及其变种不允许同一个线程对互斥量多次上锁,而 std::recursive_mutex 则允许。相应的 lock 次数也必须和 unlock 次数相等,否则仍然死锁。

例子:

classBrainBox{
public:
std::recursive_mutexrec_mutex;
public:
voidPrintHelloByte() {
this->rec_mutex.lock();
cout<<"Hello Byte"<<endl;
this->rec_mutex.unlock();
return;
    }
voidPrintHelloBlu() {
this->rec_mutex.lock();
this->PrintHelloByte(); // 对互斥量重复上锁cout<<"Hello Blu"<<endl;
this->rec_mutex.unlock();
return;
    }
};
intmain()
{
BrainBoxbox;
std::threadt1(&BrainBox::PrintHelloByte, &box);
std::threadt2(&BrainBox::PrintHelloBlu, &box);
t1.join();
t2.join();
returnEXIT_SUCCESS;
}

 

 

std::lock_guard 与 std::unique_lock

上面演示了C++标准库4种互斥量的原始用法。在实际开发当中,互斥量更多的是与 std::lock_guard 、std::unique_lock 相配合使用。

是一种更为智能、安全、现代的用法。std::lock_guard 和 std::unique_lock 设计上并存,并非功能上的替代关系。

 

std::lock_guard

首先,它是一个模板类,它的语法是:

std::lock_guard<std::mutex>locker(Mutex);

 

它需要一个互斥量对其进行初始化操作,它的特点是:根据 RAII 原则,在构造函数中上锁(创建即上锁),在析构函数中解锁(销毁即解锁)

voidPushVectorGuard(std::mutex&m, vector<int>&vec) {
try    {
std::lock_guard<std::mutex>locker(m);
vec.push_back(15);
vec.push_back(12);
vec.push_back(10);
    }
catch (conststd::exception&e)
    {
cout<<e.what() <<endl;
    }
return;
}

于是,可以总结出它的特点:

1,简单易用。

2,锁定范围是它初始化位置向后的作用域。

3,无法手动上锁、解锁。

4,不能被复制。

5,异常安全,防止线程意外结束导致死锁。

6,不会对 std::mutex 本身进行托管,初始化时请确保 std::mutex 也已经正确初始化。

因此,在需要对资源进行保护的小范围作用域内,应首先考虑使用std::lock_guard。

 

std::unique_lock

std::lock_guard 在简单且小范围的作用域内,它无疑是高效的。

有一个显著的问题,std::lock_guard 机制是初始化即上锁,涵盖它初始化位置向后的所有作用域。也就意味着它并不灵活。无法手动管理锁定及解锁时机。

所以这时候就需要 std::unique_lock 登场了,它同样是一个模板类,拥有和 std::lock_guard 一样的异常安全优点。

 

首先需要注意的是 std::unique_lock 会获得 mutex 对象的所有权。

一个已经托管给 std::unique_lock 的 mutex 对象就不要再去手动调用方法、给 guard 使用、托管给其他 unique。

语法:

【伪代码】std::unique_lock<MutexType>name(MutexObj, args);
【一般情况】std::mutexmutex;
std::unique_lock<std::mutex>unique_m(mutex);
orstd::unique_lock<std::mutex>unique_m(mutex, std::defer_lock);
orstd::unique_lock<std::mutex>unique_m(mutex, std::adopt_lock);

 

是的,它有第二个参数,第二个参数是固定的几个值,分别代表:

名称 作用
std::defer_lock 默认不锁定互斥量。(不获得所有权)
std::adopt_lock 告诉正在初始化的 unique_lock 互斥量已锁定。
std::try_to_lock 默认尝试锁定,如果失败则不阻止当前线程。

 

如果第二个参数为空,那么 std::unique_lock 会默认对托管的互斥量进行 lock 操作,如果互斥量已经 lock,它会等待互斥量被 unlock 后再进行托管并上锁。

std::unique_lock 作为互斥量的强大补充,它拥有以下方法:

名称 作用
lock 阻止其他线程。如果已被其他线程阻止,则等待到被解除,再获取所有权并阻止。
unlock 立即解除阻止。
mutex 返回当前托管的互斥量指针。
owns_lock 检查当前 unique_lock 是否与拥有关联互斥量的所有权。
release 解除与互斥量对象的关联。(但不解锁互斥量)
swap 与另一个 unique_lock 交换 mutex 所有权。
try_lock 尝试获取所有权,如果没有被其他线程阻止,则获取所有权并阻止。如果已被其他线程阻止,则返回false。
try_lock_for 等待到时间间隔。
try_lock_until 等待到指定时间。

 

代码例子:(参考了 CPP Reference 当中例子)

classBrainBox{
public:
std::mutexc_mutex;
intvalue=0;
};
voidChangeValue(BrainBox&skylake, BrainBox&coffeelake) {
std::unique_lock<std::mutex>locker1(skylake.c_mutex, std::defer_lock);
std::unique_lock<std::mutex>locker2(coffeelake.c_mutex, std::defer_lock);
std::lock(locker1, locker2);
skylake.value+=1;
coffeelake.value+=2;
return;
};
intmain()
{
BrainBoxboxA;
BrainBoxboxB;
std::threadt1(ChangeValue, std::ref(boxA), std::ref(boxB));
std::threadt2(ChangeValue, std::ref(boxB), std::ref(boxA));
t1.join();
t2.join();
cout<<"BrainBox A : "<<boxA.value<<endl; // out 3cout<<"BrainBox B : "<<boxB.value<<endl; // out 3returnEXIT_SUCCESS;
}

于是,可以总结出它的特点:

1,std::unique_lock 是通用互斥包装器,允许延迟锁定、锁定的有时限尝试、递归锁定、所有权转移和与条件变量一同使用。

2,std::unique_lock 可移动,但不可复制。

3,std::unique_lock 会获得互斥量的所有权以完全托管 mutex。

4,std::unique_lock 的资源开销大于 std::lock_guard。

5,默认情况下,std::unique_lock 和 std::lock_guard 一样同样锁定它初始化位置向后的作用域。

 

 

std::condition_variable

std::condition_variable 和 std::condition_variable_any 是标准库线程同步以条件变量方式的实现。

它的作用是根据设定的条件同步一个或多个线程。

头文件:

#include <condition_variable>

 

其中 std::condition_variable 仅支持 std::unique_lock<std::mutex> 类型作为互斥量。

std::condition_variable_any 可以支持任意基本可锁定(BasicLockable)类型作为互斥量,例如 [C++14]std::shared_lock,但也就意味着它的效率低于 std::condition_variable。

它们两个的公共函数接口也基本一致:

名称 作用 支持
wait 阻止。 CV and Any
wait_for 阻止到时间间隔。 CV and Any
wait_until 阻止到指定时间。 CV and Any
notify_one 放行一个线程,如果此时托管了多个线程,则随机抽取。 CV and Any
notify_all 放行所有线程。 CV and Any
native_handle 返回原生句柄。 CV

 

它的语法是:

【伪代码】// 负责同步阻塞的互斥量std::mutexcv_mutex;
// 声明std::condition_variablecv;
// 等待 收到通知放行cv.wait(cv_mutex);
// 条件等待 收到通知且满足条件情况下放行cv.wait(uni_m, [=] {
return!flag;
});
// 通知一个线程cv.notify_one();
// 通知所有线程cv.cv.notify_all();

 

例子演示了 std::condition_variable 的使用:

#include <iostream>usingstd::cout;
usingstd::endl;
#include <thread>#include <mutex>#include <condition_variable>voidPrintID_CV(std::mutex&mu, std::condition_variable&cv, constint&flag) {
std::unique_lock<std::mutex>uni_m(mu);
cv.wait(uni_m, [=] {
returnflag!=1;
        });
cout<<"Thread ID: "<<std::this_thread::get_id() <<endl;
return;
}
intmain()
{
std::mutexcv_mutex;
std::condition_variablecv;
intflag=0;
std::threadt1(&PrintID_CV, ref(cv_mutex), ref(cv), flag);
while (flag!=1)
    {
std::unique_lock<std::mutex>uni_m(cv_mutex);
std::cout<<"Input 1 is print thread id."<<endl;
std::cin>>flag;
    }
cout<<"Msg Thread..."<<endl;
cv.notify_one();
t1.join();
returnEXIT_SUCCESS;
}

以上例子运行后需要手动在键盘上输入 1 才会打印线程ID。

 

注意事项:

1,std::condition_variable 和 std::condition_variable_any 对象本身均不可拷贝和赋值。

2,根据代码演示,使用 std::std::condition_variable 与 std::condition_variable_any 都仅仅是负责条件变量,而加锁、解锁操作都始终需要有一个互斥量交由其托管。其中 std::condition_variable 仅支持 std::unique_lock<std::mutex>。

3,调用 wait()、wait_for()、wait_until() 函数后,内部会阻止当前线程运行,并 unlock 互斥量

4,wait()、wait_for()、wait_until() 函数的第二个可选参数为返回 true 或 false 的任何表达式(lambda、Callback),为阻塞条件,当收到解锁信号且阻塞条件不满足(即表达式返回值为False)的情况下才会放行。

5,condition_variable.h 提供了额外的辅助函数 std::notify_all_at_thread_exit,语法为:

void std::notify_all_at_thread_exit (condition_variable& cv, unique_lock<mutex> mutex);

当调用该函数的线程退出后,会通知其他受该 std::condition_variable 托管的线程放行。为了避免误操作,请尽量避免使用该函数或在 wait 函数当中增加第二参数作为条件

 

 

额外补充

std::call_once

使用例子另见:【Example】C++ 单例模式 演示代码 (被动模式、兼容VS2022编译)

 

std::lock 与 std::try_lock

std::lock

锁定给定的可锁定 (Lockable) 对象 lock1 、 lock2 、 ... 、 lockn ,用免死锁算法避免死锁。

以对 lock 、 try_lock 和 unlock 的未指定系列调用锁定对象。若调用 lock 或 unlock 导致异常,则在重抛前对任何已锁的对象调用 unlock 。

 

std::try_lock

尝试锁定每个给定的可锁定 (Lockable) 对象 lock1 、 lock2 、 ... 、 lockn ,通过以从头开始的顺序调用 try_lock 。

若调用 try_lock 失败,则不再进一步调用 try_lock ,并对任何已锁对象调用 unlock ,返回锁定失败对象的 0 底下标。

若调用 try_lock 抛出异常,则在重抛前对任何已锁对象调用 unlock 。

 

--- CPP Reference

voidChangeValueAdopt(BrainBox&skylake, BrainBox&coffeelake) {
std::lock(skylake.c_mutex, coffeelake.c_mutex);
std::unique_lock<std::mutex>locker1(skylake.c_mutex, std::adopt_lock);
std::unique_lock<std::mutex>locker2(coffeelake.c_mutex, std::adopt_lock);
skylake.value+=1;
coffeelake.value+=2;
return;
};

 

死锁的四个必要条件:

必要条件

如果在一个系统中以下四个条件同时成立,那么就能引起死锁:

  1. 互斥:至少有一个资源必须处于非共享模式,即一次只有一个进程可使用。如果另一进程申请该资源,那么申请进程应等到该资源释放为止。
  2. 占有并等待:—个进程应占有至少一个资源,并等待另一个资源,而该资源为其他进程所占有。
  3. 非抢占:资源不能被抢占,即资源只能被进程在完成任务后自愿释放。
  4. 循环等待:有一组等待进程 {P0,P1,…,Pn},P0 等待的资源为 P1 占有,P1 等待的资源为 P2 占有,……,Pn-1 等待的资源为 Pn 占有,Pn 等待的资源为 P0 占有。

-- C语言中文网

 

结束

2022-03-14 凌晨 3:40



====================================

芯片烤电池 C++ Example 2022-Spring Season Pass :

【Example】C++ 标准库常用容器全面概述

【Example】C++ 回调函数及 std::function 与 std::bind

【Example】C++ 运算符重载

【Example】C++ 标准库智能指针 unique_ptr 与 shared_ptr

【Example】C++ 接口(抽象类)概念讲解及例子演示

【Example】C++ 虚基类与虚继承 (菱形继承问题)

【Example】C++ Template (模板)概念讲解及编译避坑

【Example】C++ 标准库 std::thread 与 std::mutex

【Example】C++ 标准库多线程同步及数据共享 (std::future 与 std::promise)

【Example】C++ 标准库 std::condition_variable

【Example】C++ 用于编译时封装的 Pimpl 演示 (编译防火墙 Private-IMPL)

【Example】C++ 单例模式 演示代码 (被动模式、兼容VS2022编译)

====================================

相关文章
|
6天前
|
存储 C++ 容器
C++一分钟之-正则表达式库(regex)
【7月更文挑战第7天】C++从C++11开始支持正则表达式,通过`&lt;regex&gt;`库提供功能。本文涵盖基本概念如`std::regex`、`std::smatch`,以及`regex_search`和`regex_match`的使用。常见问题包括大小写敏感性、特殊字符转义、贪婪与非贪婪匹配和捕获组。提供的代码示例展示了如何进行匹配、不区分大小写的匹配、特殊字符匹配、贪婪与非贪婪匹配和捕获组的使用。理解并练习正则表达式能提升文本处理效率。
12 0
|
11天前
|
存储 算法 程序员
C++基础知识(八:STL标准库(Vectors和list))
C++ STL (Standard Template Library标准模板库) 是通用类模板和算法的集合,它提供给程序员一些标准的数据结构的实现如 queues(队列), lists(链表), 和 stacks(栈)等. STL容器的提供是为了让开发者可以更高效率的去开发,同时我们应该也需要知道他们的底层实现,这样在出现错误的时候我们才知道一些原因,才可以更好的去解决问题。
|
11天前
|
算法 前端开发 C++
C++基础知识(八:STL标准库 deque )
deque在C++的STL(Standard Template Library)中是一个非常强大的容器,它的全称是“Double-Ended Queue”,即双端队列。deque结合了数组和链表的优点,提供了在两端进行高效插入和删除操作的能力,同时保持了随机访问的特性。
|
11天前
|
存储 C++ 索引
C++基础知识(八:STL标准库 Map和multimap )
C++ 标准模板库(STL)中的 map 容器是一种非常有用的关联容器,用于存储键值对(key-value pairs)。在 map 中,每个元素都由一个键和一个值组成,其中键是唯一的,而值则可以重复。
|
2天前
|
设计模式 安全 编译器
【C++11】特殊类设计
【C++11】特殊类设计
22 10
|
7天前
|
C++
C++友元函数和友元类的使用
C++中的友元(friend)是一种机制,允许类或函数访问其他类的私有成员,以实现数据共享或特殊功能。友元分为两类:类友元和函数友元。类友元允许一个类访问另一个类的私有数据,而函数友元是非成员函数,可以直接访问类的私有成员。虽然提供了便利,但友元破坏了封装性,应谨慎使用。
39 9
|
2天前
|
存储 编译器 C语言
【C++基础 】类和对象(上)
【C++基础 】类和对象(上)
|
11天前
|
编译器 C++
【C++】string类的使用④(字符串操作String operations )
这篇博客探讨了C++ STL中`std::string`的几个关键操作,如`c_str()`和`data()`,它们分别返回指向字符串的const char*指针,前者保证以&#39;\0&#39;结尾,后者不保证。`get_allocator()`返回内存分配器,通常不直接使用。`copy()`函数用于将字符串部分复制到字符数组,不添加&#39;\0&#39;。`find()`和`rfind()`用于向前和向后搜索子串或字符。`npos`是string类中的一个常量,表示找不到匹配项时的返回值。博客通过实例展示了这些函数的用法。
|
11天前
|
存储 C++
【C++】string类的使用③(非成员函数重载Non-member function overloads)
这篇文章探讨了C++中`std::string`的`replace`和`swap`函数以及非成员函数重载。`replace`提供了多种方式替换字符串中的部分内容,包括使用字符串、子串、字符、字符数组和填充字符。`swap`函数用于交换两个`string`对象的内容,成员函数版本效率更高。非成员函数重载包括`operator+`实现字符串连接,关系运算符(如`==`, `&lt;`等)用于比较字符串,以及`swap`非成员函数。此外,还介绍了`getline`函数,用于按指定分隔符从输入流中读取字符串。文章强调了非成员函数在特定情况下的作用,并给出了多个示例代码。
|
11天前
|
C++
【C++】string类的使用④(常量成员Member constants)
C++ `std::string` 的 `find_first_of`, `find_last_of`, `find_first_not_of`, `find_last_not_of` 函数分别用于从不同方向查找目标字符或子串。它们都返回匹配位置,未找到则返回 `npos`。`substr` 用于提取子字符串,`compare` 则提供更灵活的字符串比较。`npos` 是一个表示最大值的常量,用于标记未找到匹配的情况。示例代码展示了这些函数的实际应用,如替换元音、分割路径、查找非字母字符等。