C++11系列
文章目录
- C++11系列
- 前言
- [一 、thread线程库](#一 、thread线程库)
-
- [1.1 构造线程对象](#1.1 构造线程对象)
-
- [1.1.1 调用无参的构造函数](#1.1.1 调用无参的构造函数)
- [1.1.2 调用带参的构造函数](#1.1.2 调用带参的构造函数)
- [1.1.3 调用移动构造函数](#1.1.3 调用移动构造函数)
- [1.2 thread提供的常用成员函数](#1.2 thread提供的常用成员函数)
- [1.3 获取线程的id方式](#1.3 获取线程的id方式)
- [1.4 join与detach](#1.4 join与detach)
-
- [1.4.1 join方式](#1.4.1 join方式)
- [1.4.1 detach方式](#1.4.1 detach方式)
- [1.5 线程函数的参数传递](#1.5 线程函数的参数传递)
- 二、mutex互斥量库
-
- [2.1 std::mutex(互斥锁)](#2.1 std::mutex(互斥锁))
- [2.2 std::recursive_mutex(递归互斥锁)](#2.2 std::recursive_mutex(递归互斥锁))
- [2.3 std::timed_mutex(定时互斥锁)](#2.3 std::timed_mutex(定时互斥锁))
- [2.4 std::lock_guard(锁守卫,RAII 机制)](#2.4 std::lock_guard(锁守卫,RAII 机制))
- [2.5 std::unique_lock(灵活的锁守卫)](#2.5 std::unique_lock(灵活的锁守卫))
-
- [2.5.1 手动控制加锁时机](#2.5.1 手动控制加锁时机)
- [2.5.2 尝试加锁或定时加锁结合](#2.5.2 尝试加锁或定时加锁结合)
- [2.5.3 同时锁定多个互斥量(避免死锁)](#2.5.3 同时锁定多个互斥量(避免死锁))
- 三、原子性操作库(atomic)
-
- [3.1 线程安全问题](#3.1 线程安全问题)
- [3.2 原子类](#3.2 原子类)
前言
在Linux线程的学习过程中,我们已对POSIX线程库(pthread) 进行了详尽阐述,对线程的核心概念 以及线程创建的底层实现逻辑 也建立了较为深刻的认知。而本文将要聚焦的C++线程库,其核心价值在于对不同操作系统的线程操作接口进行统一封装 。从根本上解决了跨平台适配问题,极大地增强了代码的可移植性 。需要明确的是,C++11线程库本质上是对原生线程接口的封装,其实现线程的核心原理与POSIX线程等原生线程并无本质区别。
本文将简单介绍线程操作接口的使用,不再探讨底层实现
一 、thread线程库
C++11的关键特性之一,便是正式为标准库引入了线程支持。这一特性使C++在并行编程领域无需再依赖pthread等第三方库。
1.1 构造线程对象
cpp
thread() noexcept;
template <class Fn, class... Args>
explicit thread (Fn&& fn, Args&&... args);
thread (const thread&) = delete;
thread (thread&& x) noexcept;
在thread
库中给我们提供了创建线程对象的方式:
1.1.1 调用无参的构造函数
thread类提供了无参构造函数 ,通过它创建的线程对象不会关联任何线程函数 ,即不会启动实际线程;但由于thread类支持移动赋值操作 ,后续若需让该线程对象与特定线程函数关联,可通过带参方式创建匿名线程对象,再借助移动赋值将匿名对象所关联的线程状态转移给该线程对象。
cpp
#include<thread>
#include<iostream>
using namespace std;
void func(int n){
for(int i=0;i< n;i++){
cout<<i<<endl;
}
}
int main(){
thread _thread;//创建_thread对象
_thread=thread(func,5);
_thread.join();
return 0;
}
**应用场景:**一般在实现线程池的时候需要先创建一批线程,但一开始这些线程并不工作,当有任务到来时再让这些线程来处理这些任务。
在上篇的项目中就使用了这种方法
1.1.2 调用带参的构造函数
cpp
template <class Fn, class... Args>
explicit thread (Fn&& fn, Args&&... args);
- fn: 可调用对象,比如函数指针、仿函数、lambda表达式、被包装器包装后的可调用对象等。
- args...: 调用可调用对象fn时传递的参数。
... Args
的具体使用方法我在C++11部分详细介绍了
cpp
void func(int n){
for (int i = 0; i < n; i++){
cout << i << endl;
}
}
int main()
{
thread _thread(func, 5);
_thread.join();
return 0;
}
1.1.3 调用移动构造函数
cpp
void func(int n){
for (int i = 0; i < n; i++){
cout << i << endl;
}
}
int main(){
thread _thread = thread(func, 5);
_thread.join();
return 0;
}
从实际使用体验来看,相较于Linux平台下POSIX线程库提供的接口,C++11线程库的接口在使用便捷性上有了显著提升。
1.2 thread提供的常用成员函数
join
:对该线程进行等待,在等待的线程返回之前,调用join
函数的线程将会被阻塞。joinable
:判断该线程是否已经执行完毕,如果是则返回true
,否则返回false
(一般结合join
和detach
使用避免无效的调用这两个函数)。detach
:将该线程与创建线程进行分离,被分离后的线程不再需要创建线程调用join
函数对其进行等待。get_id
:获取该线程的id
。swap
:将两个线程对象关联线程的状态进行交换。
1.3 获取线程的id方式
thread类的get_id
成员函数可用于获取线程ID,但该方法必须通过线程对象调用 ;若需在当前线程函数内部(即线程对象所关联的执行函数中)获取自身线程ID,则应使用std::this_thread
命名空间下的get_id
函数。
cpp
void func(int n){
cout<<this_thread::get_id()<<endl;//在线程执行的函数内部获取线程id
for (int i = 0; i <n; i++){
cout << i << endl;
}
sleep(1);
}
int main(){
thread _thread = thread(func, 5);
cout<<_thread.get_id()<<endl;//线程对象获取线程id
if(_thread.joinable()){//判断线程是否还在执行,如果是就等待,否则就返回
_thread.join();
}
return 0;
}
此外std::this_thread
命名空间还提供了三个实用函数:
yield
:当前线程主动"放弃"执行权,促使操作系统调度其他线程继续运行。sleep_until
:使当前线程休眠,直至指定的具体时间点再恢复执行。sleep_for
:让当前线程休眠一段指定的时间长度后,再继续执行后续操作。
这些函数需要结合时间类使用,操作比较简单,可以结合官方文档了解一下
1.4 join与detach
启动线程后,当线程退出时必须对其占用的资源进行回收,否则可能引发内存泄漏等问题。C++11 thread库提供了两种核心的线程资源回收方式:
1.4.1 join方式
逻辑比较简单就不展示代码示例了
通过调用线程对象的join()
成员函数,当前线程会阻塞并等待目标线程执行完毕,随后由系统自动回收目标线程的资源。适用于对目标线程执行结果具有依赖性逻辑的场景。
但是采用join()
方式回收线程资源时,存在潜在风险:若线程对象在调用join()
之前,程序因异常、提前返回等情况终止了后续代码的执行,join()
将无法被调用,导致线程资源无法正常回收。
因此,通常将join()
调用放在资源管理对象的析构函数 中(RAII封装),确保无论程序流程如何跳转,join()
都能被可靠执行。
1.4.1 detach方式
调用线程对象的detach()
成员函数后,目标线程会与创建它的线程"分离",成为"后台线程",其资源会在执行结束后由操作系统自动回收,无需创建线程显式等待。此方式适用于无需关注线程执行结果、希望线程独立运行的场景。
1.5 线程函数的参数传递
线程函数的参数是以值拷贝 的方式传入线程空间的------即便线程函数的参数声明为引用类型,在函数内部对其修改也不会影响外部实参 。这是因为此时函数参数引用所指向的,实际是线程栈中拷贝生成的临时对象,而非原始的外部实参。
有些编译器是无法编译的
cpp
void Func(int& x){
x += 5;
}
int main(){
int a = 5;
// 在线程函数中对a修改,不会影响外部实参
thread _thread(Func, a);
_thread.join();
cout << a << endl;
}
造成这一现象的核心原因在于:当以thread _thread(ThreadFunc1, a);
方式创建线程时,参数实际是先传递给thread
类的构造函数,经其内部封装、解包等一系列处理后,才最终传递给线程函数------这一过程逻辑复杂,这里就不介绍了。
而在实际开发中,多个线程常需共享同一变量或锁资源,此时可采用以下方式解决:
方式一: 借助std::ref函数
若希望线程函数的参数能直接引用外部传入的原始实参,则在创建线程、向thread
构造函数传递实参时,可以借助std::ref()
函数对实参进行包装------通过std::ref()
可显式保持对外部实参的引用关系。
cpp
void Func(int& x){
x += 5;
}
int main(){
int a = 5;
thread _thread1(Func, std::ref(a));//存在线程安全问题
thread _thread2(Func,std::ref(a));//这里仅做演示
_thread1.join();
_thread2.join();
cout << a << endl;
}
方式二: 传地址的形式
另一种解决方案是将线程函数的参数类型改为指针类型,在创建线程时传入实参的地址。此时,线程函数可通过解引用该指针直接操作地址指向的变量,其修改会直接作用于外部原始实参。
cpp
void Func(int* x){
*x += 5;
}
int main(){
int a = 5;
thread _thread1(Func,&a);
thread _thread2(Func,&a);
_thread1.join();
_thread2.join();
cout << a << endl;
}
方式三: 借助lambda表达式
使用lambda表达式作为线程函数,可以通过捕获列表以引用方式获取外部参数。这样在lambda表达式内部对参数的修改会直接作用于外部变量。
cpp
int main(){
int a = 5;
auto Func=[&](){
a+=5;
};
thread _thread1(Func);
thread _thread2(Func);
_thread1.join();
_thread2.join();
cout << a << endl;
}
二、mutex互斥量库
C++11引入了多种功能强大的锁机制
2.1 std::mutex(互斥锁)
cpp
constexpr mutex() noexcept;
mutex (const mutex&) = delete;
std::mutex
是C++11提供的最基础互斥量,其对象不支持拷贝或移动操作。
std::mutex
的核心成员函数及功能如下:
lock()
:对互斥量执行加锁操作try_lock()
:尝试对互斥量加锁(非阻塞)unlock()
:对互斥量执行解锁操作,释放所有权
调用lock()
时可能出现三种情况:
- 若互斥量当前未被任何线程锁定,调用线程会成功获取锁并保持锁定状态,直至调用
unlock()
释放; - 若互斥量已被其他线程锁定,当前调用线程会进入阻塞状态,等待锁释放;
- 若互斥量已被当前调用线程自身锁定,会直接引发死锁(deadlock)。
线程调用try_lock()
时,可能出现以下三种情况:
- 若互斥量当前未被任何线程锁定,调用线程会成功获取锁并保持锁定状态,直至调用
unlock()
释放; - 若互斥量已被其他线程锁定,
try_lock()
会立即返回false
,当前调用线程不会阻塞,可继续执行后续逻辑; - 若互斥量已被当前调用线程自身锁定,会直接引发死锁(deadlock)。
可以执行下方代码感受,try_lock()不会阻塞等待锁资源
cpp
void Func(string &str,mutex&_mutex){
if(_mutex.try_lock()){//如果互斥量没有被锁定
for(int i=0;i<5;i++){
cout<<str<<i<<endl;
sleep(1);
}
}else{
cout<<str<<endl;
}
}
int main(){
string str1="_thread1: ";
string str2="_thread2: ";
mutex _mutex;
thread _thread1(Func,ref(str1),ref(_mutex));
thread _thread2(Func,ref(str2),ref(_mutex));
_thread1.join();
_thread2.join();
}
死锁的四个必要条件:
- 互斥条件
定义 :线程对所分配到的资源(如互斥量、共享内存等)具有排他性使用权,即同一时间内,一个资源只能被一个线程占用,其他线程若需使用该资源,必须等待当前占用线程释放。
2. 请求与保持条件定义 :线程在已持有部分资源的前提下,又主动请求获取其他线程已持有的资源;在获取新资源前,不会释放自己已持有的资源。
3. 不可剥夺条件定义 :线程已获取的资源,在其主动释放前,不能被其他线程强制剥夺,只能由持有资源的线程自行释放。
4. 循环等待条件定义 :多个线程之间形成一种资源请求的循环依赖关系,即线程1等待线程2持有的资源,线程2等待线程3持有的资源,......,线程n等待线程1持有的资源,最终构成一个闭环。
2.2 std::recursive_mutex(递归互斥锁)
std::recursive_mutex
(递归互斥锁)是一种专门针对递归函数场景设计的互斥量。
cpp
int main(){
recursive_mutex _mutex;
while(1){
_mutex.lock();
........
_mutex.unlock();
}
}
若在递归函数中使用普通的std::mutex
,当线程进行递归调用时,会因重复申请已持有的未释放锁而直接引发死锁。而std::recursive_mutex
的核心特性在于允许同一线程对互斥量进行递归加锁 ,从而获得该互斥量的多层所有权;相应地,释放时需调用与加锁次数相等的unlock()
,才能完全释放互斥量。
此外,std::recursive_mutex
同样提供lock()
、try_lock()
和unlock()
成员函数,其基础行为与std::mutex
一致。
2.3 std::timed_mutex(定时互斥锁)
std::timed_mutex
提供了两个带超时机制的加锁函数,专门用于需要限制等待锁时间的场景:
-
try_lock_for(duration)
:接受一个时间间隔参数。若线程未获取到锁,会在指定时间内保持阻塞;若期间其他线程释放锁,当前线程可成功获取锁并返回true
;若超时仍未获取锁,则返回false
。 -
try_lock_until(time_point)
:接受一个具体时间点参数。在该时间点到来前,若线程未获取到锁会保持阻塞;若期间其他线程释放锁,当前线程可成功获取锁并返回true
;若到达指定时间点仍未获取锁,则返回false
。
此外,std::timed_mutex
同样提供 lock()
、try_lock()
和 unlock()
成员函数,其基础特性与 std::mutex
一致------例如 lock()
的阻塞加锁、try_lock()
的非阻塞尝试加锁等。
std::recursive_timed_mutex结合了 std::recursive_mutex 和 std::timed_mutex 的特性:既允许同一线程多次获取锁(递归加锁),又支持带超时的锁获取操作,是一种更灵活的同步原语。
2.4 std::lock_guard(锁守卫,RAII 机制)
使用互斥锁时,若程序执行流因异常、提前返回等情况发生跳跃,可能导致锁资源未被释放,进而使后续申请该互斥锁的线程陷入阻塞,最终引发死锁。这种风险在锁保护的代码块中直接返回的场景中尤为常见。
为解决这一问题,C++11引入了基于RAII(资源获取即初始化) 思想的锁封装机制------std::lock_guard
。它们通过将锁的生命周期与对象生命周期绑定,确保无论程序执行流如何跳转(正常结束、异常退出或提前返回),锁都能在封装对象析构时自动释放。
std::lock_guard
类模板基于RAII机制实现了对互斥锁的自动化管理:
在需要加锁的代码块中,用目标互斥锁实例化lock_guard
对象时,其构造函数会自动调用互斥锁的lock()
方法完成加锁;当lock_guard
对象超出作用域(如代码块执行完毕、异常退出等),其析构函数会自动调用互斥锁的unlock()
方法释放锁。
cpp
void Func(mutex&_mutex){
lock_guard<mutex> lock(_mutex);//这样会对整个线程函数加锁
{
lock_guard<mutex> lock(_mutex);//这样只对for循环加锁
for(int i=0;i<5;i++){
n++;
sleep(1);
}
}
cout<<"这句代码不需要加锁"<<endl;
}
在使用这种锁时可以通过上面的技巧,对特定的代码精准加锁。
2.5 std::unique_lock(灵活的锁守卫)
由于std::lock_guard
功能单一,无法对锁进行灵活控制,C++11进一步提供了std::unique_lock
。
std::unique_lock
与lock_guard
类似,同样基于RAII机制封装互斥锁------创建对象时会在构造函数中调用lock()
加锁,对象销毁时会在析构函数中调用unlock()
解锁,确保锁的自动管理。
但与lock_guard
相比,unique_lock
更为灵活,它提供了更丰富的成员函数。
2.5.1 手动控制加锁时机
手动控制加锁时机当你需要在创建锁对象后,等待某个条件满足再加锁时
cpp
std::mutex mtx;
// 延迟加锁(构造时不获取锁)
std::unique_lock<std::mutex> lock(mtx, std::defer_lock);
// ... 其他操作 ...
lock.lock(); // 手动加锁
2.5.2 尝试加锁或定时加锁结合
try_lock()、try_lock_for() 等方法,实现非阻塞加锁或超时加锁:
cpp
std::timed_mutex tmtx;
std::unique_lock<std::timed_mutex> lock(tmtx, std::defer_lock);
// 尝试在1秒内获取锁
if (lock.try_lock_for(std::chrono::seconds(1))) {
// 成功获取锁
} else {
// 超时处理
}
2.5.3 同时锁定多个互斥量(避免死锁)
配合 std::lock() 函数同时锁定多个锁,确保所有锁以相同顺序获取,避免死锁:
cpp
std::mutex mtx1, mtx2;
// 延迟初始化两个锁(均不加锁)
std::unique_lock<std::mutex> lock1(mtx1, std::defer_lock);
std::unique_lock<std::mutex> lock2(mtx2, std::defer_lock);
// 同时锁定两个锁(内部保证无死锁)
std::lock(lock1, lock2);
// 安全操作共享资源...
std::lock 是标准库提供的一个工具函数,它能够可以同时锁定一个或多个互斥锁。其工作原理如下:当尝试锁定多个锁时,如果 lock1 未被占用,函数会立即将其锁定;若 lock2 已被占用,函数会先释放 lock1 并等待 lock2 可用。一旦 lock2 被释放,函数会重新尝试同时锁定 lock1和 lock2,直到成功锁定所有指定的锁为止。
在有些情况下线程需要同时拥有两把锁才可以继续用执行,如果采用下面的方式,程序就面临着死锁问题,这时我们就可有使用是std::lock()函数来规避这种问题(代码逻辑简单就不分析了)
cpp
mutex _mutex1;
mutex _mutex2;
void Func1(){
_mutex1.lock();
_mutex2.lock();
//.......其他操作
_mutex1.unlock();
_mutex2.unlock();
}
void Func2(){
_mutex2.lock();
_mutex1.lock();
//.......其他操作
_mutex2.unlock();
_mutex1.unlock();
}
int main(){
mutex _mutex;
thread _thread1(Func1);
thread _thread2(Func2);
_thread1.join();
_thread2.join();
}
cpp
void Func1() {
// 同时锁定两个互斥量
std::lock(_mutex1, _mutex2);
// 用adopt_lock接管已锁定的互斥量,确保自动解锁
std::lock_guard<std::mutex> lock1(_mutex1, std::adopt_lock);
std::lock_guard<std::mutex> lock2(_mutex2, std::adopt_lock);
}
void Func2() {
// 同样用std::lock保证加锁顺序一致
std::lock(_mutex1, _mutex2);
// 自动解锁(与Func1的解锁顺序无关,RAII会处理)
std::lock_guard<std::mutex> lock1(_mutex1, std::adopt_lock);
std::lock_guard<std::mutex> lock2(_mutex2, std::adopt_lock);
try_lock是一个函数模板,用于同时对多个锁对象进行尝试锁定。如果成功锁定所有对象,则返回-1;若任一对象锁定失败,则将已锁定的对象全部解锁,并返回首个失败对象的下标(首个参数对象的下标从0开始计算)。
这个可以自己尝试一下。
cpp
template <class Fn, class... Args>
void call_once (once_flag& flag, Fn&& fn, Args&&... args);
//在多线程调用中设置fn只会执行一次,被第一个到来的线程执行
三、原子性操作库(atomic)
3.1 线程安全问题
在多线程编程中,线程安全是最核心的问题。我们通常采用加锁机制来解决这一问题,但锁机制会带来两个明显弊端:一是导致其他线程阻塞,影响程序整体运行效率;二是对于简单操作而言,频繁的加解锁会引起大量线程切换开销,还可能引发死锁问题。
针对这些痛点,C++11引入了原子操作机制。原子操作是指不可被中断的一个或一系列操作,通过提供原子数据类型。
3.2 原子类
原子类通过底层硬件的原子操作(如 CAS 指令等),确保对变量的读写操作不被线程调度打断,从而从根源上避免多线程并发访问时的竞态条件。
以下为 C++ 标准库中提供的原子类型及其对应的内置类型:
原子类型名称 | 对应的内置类型名称 |
---|---|
atomic_bool | bool |
atomic_char | char |
atomic_schar | signed char |
atomic_uchar | unsigned char |
atomic_int | int |
atomic_uint | unsigned int |
atomic_short | short |
atomic_ushort | unsigned short |
atomic_long | long |
atomic_ulong | unsigned long |
atomic_llong | long long |
atomic_ullong | unsigned long long |
atomic_char16_t | char16_t |
atomic_char32_t | char32_t |
atomic_wchar_t | wchar_t |
使用原子类时,无需额外加锁(如 mutex),即可安全地在多线程中对变量进行读写。
此外c++11还允许用户自定义原子类行:
cpp
atmoic<T> t; // 声明一个类型为T的原子类型变量t
需要注意的是,在定义自定义类型的原子对象时,需要使得成员函数为真:
这里只进行简单介绍,如有需要可以结合官方文档深入学习。