C++11 多线程与并发编程
C++11系列
文章目录
- C++11系列
- 前言
- 一 、thread线程库
- 1.1 构造线程对象
- 1.1.1 调用无参的构造函数
- 1.1.2 调用带参的构造函数
- 1.1.3 调用移动构造函数
- 1.2 thread提供的常用成员函数
- 1.3 获取线程的id方式
- 1.4 join与detach
- 1.4.1 join方式
- 1.4.1 detach方式
- 1.5 线程函数的参数传递
- 二、mutex互斥量库
- 2.1 std::mutex(互斥锁)
- 2.2 std::recursive_mutex(递归互斥锁)
- 2.3 std::timed_mutex(定时互斥锁)
- 2.4 std::lock_guard(锁守卫,RAII 机制)
- 2.5 std::unique_lock(灵活的锁守卫)
- 2.5.1 手动控制加锁时机
- 2.5.2 尝试加锁或定时加锁结合
- 2.5.3 同时锁定多个互斥量(避免死锁)
- 三、原子性操作库(atomic)
- 3.1 线程安全问题
- 3.2 原子类
前言
在Linux线程的学习过程中,我们已对POSIX线程库(pthread) 进行了详尽阐述,对线程的核心概念以及线程创建的底层实现逻辑也建立了较为深刻的认知。而本文将要聚焦的C++线程库,其核心价值在于对不同操作系统的线程操作接口进行统一封装。从根本上解决了跨平台适配问题,极大地增强了代码的可移植性。需要明确的是,C++11线程库本质上是对原生线程接口的封装,其实现线程的核心原理与POSIX线程等原生线程并无本质区别。
本文将简单介绍线程操作接口的使用,不再探讨底层实现
一 、thread线程库
C++11的关键特性之一,便是正式为标准库引入了线程支持。这一特性使C++在并行编程领域无需再依赖pthread等第三方库。
1.1 构造线程对象
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类支持移动赋值操作,后续若需让该线程对象与特定线程函数关联,可通过带参方式创建匿名线程对象,再借助移动赋值将匿名对象所关联的线程状态转移给该线程对象。
#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 调用带参的构造函数
template <class Fn, class... Args>
explicit thread (Fn&& fn, Args&&... args);
- fn: 可调用对象,比如函数指针、仿函数、lambda表达式、被包装器包装后的可调用对象等。
- args…: 调用可调用对象fn时传递的参数。
... Args
的具体使用方法我在C++11部分详细介绍了
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 调用移动构造函数
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
函数。
void func(int n){cout<<this_thread::get_id()<<endl;//在线程执行的函数内部获取线程idfor (int i = 0; i <n; i++){cout << i << endl;}sleep(1);
}
int main(){thread _thread = thread(func, 5);cout<<_thread.get_id()<<endl;//线程对象获取线程idif(_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 线程函数的参数传递
线程函数的参数是以值拷贝的方式传入线程空间的——即便线程函数的参数声明为引用类型,在函数内部对其修改也不会影响外部实参。这是因为此时函数参数引用所指向的,实际是线程栈中拷贝生成的临时对象,而非原始的外部实参。
有些编译器是无法编译的
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()
可显式保持对外部实参的引用关系。
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;
}
方式二: 传地址的形式
另一种解决方案是将线程函数的参数类型改为指针类型,在创建线程时传入实参的地址。此时,线程函数可通过解引用该指针直接操作地址指向的变量,其修改会直接作用于外部原始实参。
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表达式内部对参数的修改会直接作用于外部变量。
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(互斥锁)
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()不会阻塞等待锁资源
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
(递归互斥锁)是一种专门针对递归函数场景设计的互斥量。
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()
方法释放锁。
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 手动控制加锁时机
手动控制加锁时机当你需要在创建锁对象后,等待某个条件满足再加锁时
std::mutex mtx;
// 延迟加锁(构造时不获取锁)
std::unique_lock<std::mutex> lock(mtx, std::defer_lock);
// ... 其他操作 ...
lock.lock(); // 手动加锁
2.5.2 尝试加锁或定时加锁结合
try_lock()、try_lock_for() 等方法,实现非阻塞加锁或超时加锁:
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() 函数同时锁定多个锁,确保所有锁以相同顺序获取,避免死锁:
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()函数来规避这种问题(代码逻辑简单就不分析了)
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();
}
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开始计算)。
这个可以自己尝试一下。
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还允许用户自定义原子类行:
atmoic<T> t; // 声明一个类型为T的原子类型变量t
需要注意的是,在定义自定义类型的原子对象时,需要使得成员函数为真:
这里只进行简单介绍,如有需要可以结合官方文档深入学习。