C++ : 线程库
- 一、线程thread
- 1.1 thread类
- 1.1.1 thread对象构造函数
- 1.1.2 thread类的成员函数
- 1.1.3 线程函数的参数问题
- 1.2 this_thread 命名空间域
- 1.2.1 chrono
- 二、mutex互斥量库
- 2.1 mutex的四种类型
- 2.1.1 mutex 互斥锁
- 2.2.2 timed_mutex 时间锁
- 2.2.3 recursive_muetx 递归锁
- 2.2.4 recursive_timed_muetx 时间递归锁
- 2.2 RAII的加锁策略
- 2.2.1 lock_guard 作用域锁
- 2.2.2 unique_lock 独占锁
- 三、condition_variable 条件变量
- 3.1 wait系列函数
- 3.2 notify系列函数
- 3.3 简单示例
- 四、atomic 原子性操作库
- 4.1 atomic使用
- 4.2 CAS
一、线程thread
操作线程需要头文件,头文件包含线程相关操作,内含两个内容:
thread类
:操作线程的基本类this_thread
命名空间域:用于操作当前线程
1.1 thread类
1.1.1 thread对象构造函数
- 调用无参的构造函数
thread提供了无参的构造函数,调用无参的构造函数创建出来的线程对象没有关联任何线程函数,即没有启动任何线程。
由于thread提供了移动赋值函数,因此当后续需要让该线程对象与线程函数关联时,可以以带参的方式创建一个匿名对象,然后调用移动赋值将该匿名对象关联线程的状态转移给该线程对象。
void func(int n)
{for (int i = 0; i <= n; i++){cout << i << endl;}
}
int main()
{thread t1;//...t1 = thread(func, 10);t1.join();return 0;
}
使用场景:
实现线程池的时候就是需要先创建一批线程,但一开始这些线程什么也不做,当有任务到来时再让这些线程来处理这些任务。
- 调用带参的构造函数
template <class Fn, class... Args>
explicit thread (Fn&& fn, Args&&... args);
参数说明:
- fn:可调用对象,比如函数指针、仿函数、lambda表达式、被包装器包装后的可调用对象等。
- args…:调用可调用对象fn时所需要的若干参数。
调用带参的构造函数创建线程对象,能够将线程对象与线程函数fn进行关联。
void func(int n)
{for (int i = 0; i <= n; i++){cout << i << endl;}
}
int main()
{thread t2(func, 10);t2.join();return 0;
}
- 调用移动构造函数
void func(int n)
{for (int i = 0; i <= n; i++){cout << i << endl;}
}
int main()
{thread t3 = thread(func, 10); //右边值对象t3.join();return 0;
}
1.1.2 thread类的成员函数
成员函数 | 功能描述 |
---|---|
join | 对该线程进行等待,在等待的线程返回之前,调用 join 函数的线程将会被阻塞 |
joinable | 判断该线程是否已经执行完毕,如果是则返回 true,否则返回 false |
detach | 将该线程与创建线程进行分离,被分离后的线程不再需要创建线程调用 join 函数等待 |
get_id | 获取该线程的 id |
swap | 将两个线程对象关联线程的状态进行交换 |
joinable函数同样也可以判断线程是否有效,下面情况都是线程无效的情况:
- 采用无参构造函数构造的线程对象。(该线程对象没有关联任何线程)
- 线程对象的状态已经转移给其他线程对象。(已经将线程交给其他线程对象管理)
- 线程已经调用join或detach结束。(线程已经结束)
1.1.3 线程函数的参数问题
线程函数的参数是以值拷贝方式拷贝到线程空间中的
,就算线程函数的参数为引用类型,在线程函数中修改后也不会影响到外部实参,因为其实际引用的是线程栈中的拷贝,而不是外部实参。
#include <thread>
void ThreadFunc1(int& x)
{x += 10;
}int main()
{int a = 10;// 在线程函数中对a修改,不会影响外部实参,因为:线程函数参数虽然是引用方式,但其实际引用的是线程栈中的拷贝thread t1(ThreadFunc1, a);t1.join();cout << a << endl;
}
要通过线程函数的形参改变外部的实参,**下面有三种方式:
1. 借助std::ref函数
线程函数的参数类型为引用类型时,如果要想线程函数形参引用的是外部传入的实参,而不是线程栈空间中的拷贝,那么在传入实参时需要借助ref函数保持对实参的引用。
#include <thread>
void ThreadFunc1(int& x)
{x += 10;
}int main()
{int a = 10;// 如果想要通过形参改变外部实参时,必须借助std::ref()函数thread t2(ThreadFunc1, std::ref(a));t2.join();cout << a << endl;
}
2. 地址的拷贝
将线程函数的参数类型改为指针类型,将实参的地址传入线程函数,此时在线程函数中可以通过修改该地址处的变量,进而影响到外部实参。
#include <thread>
void ThreadFunc2(int* x)
{*x += 10;
}int main()
{int a = 10;// 地址的拷贝thread t3(ThreadFunc2, &a);t3.join();cout << a << endl;return 0;
}
3. 借助lambda表达式
将lambda表达式作为线程函数,利用lambda函数的捕捉列表,以引用的方式对外部实参进行捕捉,此时在lambda表达式中对形参的修改也能影响到外部实参。
#include <thread>int main()
{int a = 10;// 借助lambda表达式thread t3([&a]{a+=10;});t3.join();cout << a << endl;return 0;
}
1.2 this_thread 命名空间域
std::this_thread
是一个命名空间,用于访问当前线程。它提供了一组函数来操作当前线程。
函数名 | 功能描述 |
---|---|
yield | 当前线程"放弃"执行,让操作系统调度另一线程继续执行 |
get_id | 返回当前线程的 ID |
sleep_until | 让当前线程休眠到一个具体时间点 |
sleep_for | 让当前线程休眠一个时间段 |
get_id
和yield
都可以直接执行,不用传入参数。而后两个函数与时间相关,要用到C++封装的时间库chrono
。
1.2.1 chrono
是一个头文件,内包含chrono命名空间域,该域内部封装了各种时间的相关操作。
std::chrono::system_clock
:系统时钟,表示从 Unix 纪元开始的时间(1970 年 1 月 1 日 00:00:00 UTC)。std::chrono::steady_clock
:稳定时钟,表示从程序启动开始的时间。
这两个时钟都有一个now成员函数,返回当前的时间。但是system_clock会受到系统时钟影响,如果用户调整了系统时间,就有可能造成时间错误,而稳定时钟不受系统时钟影响。如下:
auto t1 = std::chrono::system_clock::now();
auto t2 = std::chrono::steady_clock::now();
这两个函数都返回一个time_point类型,表示当前时间点。
duration
用于表示一个时间段,这个类的用法比较复杂,因此C++封装了一些可以直接使用的类:
- std::chrono::nanoseconds (纳秒)
std::chrono::microseconds (微秒)
std::chrono::milliseconds (毫秒)
std::chrono::seconds (秒)
std::chrono::minutes (分钟)
std::chrono::hours (小时)
这些类都是typedef后的duration,如果想要表示一个时间段,直接传数字即可:
auto dur1 = std::chrono::seconds(3); // 3秒
auto dur2 = std::chrono::minutes(5); // 5分钟
sleep_until需要传入一个时间点time_point,比如想要睡眠10秒,就可以用当前时间 + 10秒得到一个时间点,再用sleep_until完成睡眠:
auto t1 = std::chrono::steady_clock::now(); // 获取当前时间
auto dur = std::chrono::seconds(10); // 获取十秒时间段
auto t2 = t1 + dur; // 时间点 + 时间段 = 时间点,十秒后std::this_thread::sleep_until(t2); // 睡眠到 10 秒后
sleep_for需要传入一个时间段duration,同样的睡眠十秒:
auto dur = std::chrono::seconds(10); // 获取十秒时间段
std::this_thread::sleep_for(dur); // 睡眠到 10 秒后
二、mutex互斥量库
mutex的种类:
mutex
:互斥锁recursive_muetx
:递归锁timed_mutex
:时间锁recursive_timed_muetx
:时间递归锁
两种基于RAII的加锁策略:
lock_guard
:作用域锁unique_lock
:独占锁
2.1 mutex的四种类型
2.1.1 mutex 互斥锁
mutex锁是C++11提供的最基本的互斥量,mutex对象之间不能进行拷贝,也不能进行移动。
成员函数如下:
成员函数 | 功能描述 |
---|---|
lock | 对互斥量进行加锁 |
unlock | 对互斥量进行解锁,释放互斥量的所有权 |
try_lock | 对互斥量尝试进行加锁 |
线程函数调用lock
时,可能会发生以下三种情况:
- 如果该互斥量当前没有被其他线程锁住,则调用线程将该互斥量锁住,直到调用unlock之前,该线程一致拥有该锁。
- 如果该互斥量已经被其他线程锁住,则当前的调用线程会被阻塞。
- 如果该互斥量被当前调用线程锁住,则会产生死锁(deadlock)。
线程调用try_lock
时,类似也可能会发生以下三种情况:
- 如果该互斥量当前没有被其他线程锁住,则调用线程将该互斥量锁住,直到调用unlock之前,该线程一致拥有该锁。
- 如果该互斥量已经被其他线程锁住,则try_lock调用返回false,当前的调用线程不会被阻塞。
- 如果该互斥量被当前调用线程锁住,则会产生死锁(deadlock)。
简单实例:
int num = 0;void test(int n, std::mutex& mtx)
{for (int i = 0; i < n; i++){mtx.lock();num++;mtx.unlock();}
}int main()
{std::mutex mtx;std::thread t1(test, 2000, std::ref(mtx));std::thread t2(test, 2000, std::ref(mtx));t1.join();t2.join();std::cout << "num = " << num << std::endl;return 0;
}
2.2.2 timed_mutex 时间锁
时间锁就是限定每次申请锁的时长,如果超过一定时间没有申请到锁,就返回。
成员函数:
成员函数 | 功能描述 |
---|---|
lock | 对互斥量进行加锁 |
unlock | 对互斥量进行解锁,释放互斥量的所有权 |
try_lock | 对互斥量尝试进行加锁 |
try_lock_until | 如果到指定时间还没申请到锁就返回false,申请到锁返回true |
try_lock_for | 如果一段时间内没申请到锁就返回false,申请到锁返回true |
try_lock_for
:接受一个时间范围,表示在这一段时间范围之内线程如果没有获得锁则被阻塞住,如果在此期间其他线程释放了锁,则该线程可以获得对互斥量的锁,如果超时(即在指定时间之内还是没有获得锁),则返回false。try_lock_untill
:接受一个时间点作为参数,在指定时间点未到来之前线程如果没有获得锁则被阻塞住,如果在此期间其他线程释放了锁,则该线程可以获得对互斥量的锁,如果超时(即在指定时间点到来时还是没有获得锁),则返回false。
简单示例:
int num = 0;void test(int n, std::timed_mutex& mtx)
{while (n){bool ret = mtx.try_lock_for(std::chrono::microseconds(1));if (ret){num++;n--;mtx.unlock();}else{std::cout << "加锁超时" << std::endl;}}
}int main()
{std::timed_mutex mtx;std::thread t1(test, 2000, std::ref(mtx));std::thread t2(test, 2000, std::ref(mtx));t1.join();t2.join();std::cout << "num = " << num << std::endl;return 0;
}
2.2.3 recursive_muetx 递归锁
recursive_mutex叫做递归互斥锁,该锁专门用于递归函数中的加锁操作。
- 如果在递归函数中使用mutex互斥锁进行加锁,那么在线程进行递归调用时,可能会重复申请已经申请到但自己还未释放的锁,进而导致死锁问题。
- 而recursive_mutex允许同一个线程对互斥量多次上锁(即递归上锁),来获得互斥量对象的多层所有权,但是释放互斥量时需要调用与该锁层次深度相同次数的unlock。
简单实例:
int num = 0;void test(int n, std::recursive_mutex& mtx)
{if (n <= 0)return;mtx.lock();test(n - 1, mtx);mtx.unlock();
}int main()
{std::recursive_mutexmtx;std::thread t1(test, 2000, std::ref(mtx));t1.join();std::cout << "num = " << num << std::endl;return 0;
}
如果是使用mutex.lock()时,申请不到锁,不论是谁占有这把锁,都会陷入阻塞,直到锁被释放。recursive_mutex则会记录是谁占有这把锁,在recursive_mutex.lock()时,会检查申请锁的线程和占有锁的线程是不是同一个,如果是同一个,则直接申请成功,因此可以避免递归死锁。
2.2.4 recursive_timed_muetx 时间递归锁
与时间和递归锁效果相加。
2.2 RAII的加锁策略
2.2.1 lock_guard 作用域锁
lock_guard
是C++11中的一个模板类,其定义如下:
template <class Mutex>
class lock_guard;
C++是用的是RAII方法进行加锁:
- 在需要加锁的地方,用互斥锁实例化一个lock_guard对象,在lock_guard的构造函数中会调用lock进行加锁。
- 当lock_guard对象出作用域前会调用析构函数,在lock_guard的析构函数中会调用unlock自动解锁
简单示例:
#include<iostream>
#include<thread>
#include<mutex>
using namespace std;mutex mtx;
bool Stop()
{return false;
}void func()
{// 保护一段匿名的代码区间{lock_guard<mutex> lg(mtx); // 调用构造函数构造if (!Stop())return; // 调用析构函数析构}}// 生命周期结束后调用析构函数析构
int main()
{func();return 0;
}
模拟实现lock_guard:
namespace JRH
{template<class Mutex>class lock_guard{public:lock_guard(Mutex& mtx):_mtx(mtx){_mtx.lock(); // 加锁}~lock_guard(){_mtx.unlock();}// 防拷贝lock_guard(const Mutex&) = delete;lock_guard& operator=(const Mutex&) = delete;private:Mutex& _mtx;};
}
1.lock_guard类中包含一个锁成员变量(引用类型),这个锁就是每个lock_guard对象管理的互斥锁。
2.调用lock_guard的构造函数时需要传入一个被管理互斥锁,用该互斥锁来初始化锁成员变量后,调用互斥锁的lock函数进行加锁。
3.lock_guard的析构函数中调用互斥锁的unlock进行解锁。
4.需要删除lock_guard类的拷贝构造和拷贝赋值,因为lock_guard类中的锁成员变量本身也是不支持拷贝的。(防拷贝)
2.2.2 unique_lock 独占锁
lock_guard的可操作性很低,只有构造和析构两个函数,也就是只有自动释放锁的能力。而unique_lock功能更加丰富,而且可以自由操作锁。
unique_lock在构造时,可以传入一把锁,在构造的同时会对该锁进行加锁。在unique_lock析构时,判断当前的锁有没有加锁,如果加锁了就先释放锁,后销毁对象。
而在构造与析构之间,也就是整个unique_lock的生命周期,可以自由的加锁解锁:
lock
:加锁
unlock
:解锁
try_lock
:如果没上锁就加锁,上锁了就返回
try_lock_until
:如果到指定时间还没申请到锁就返回false,申请到锁返回true
try_lock_for
:如果一段时间内没申请到锁就返回false,申请到锁返回true
提供了以上五个接口,也就是说可以作用于前面的任何一款锁。另外的unique_lcok还允许赋值operator=,调用赋值时,如果当前锁没有持有锁,那么直接拷贝。如果当前锁持有锁,那么把锁的所有权转移给新的unique_lcok,自己不再持有锁。
三、condition_variable 条件变量
谈到锁,自然也要谈条件变量,这是线程同步的重要手段,C++将条件变量放在头文件<condition_variable>
中。
条件变量库有很多函数,但总体分为两类函数,分别是wait系列函数和notify系列函数。
3.1 wait系列函数
wait函数提供了两个不同版本的接口:
void wait (unique_lock<mutex>& lck);template <class Predicate>
void wait (unique_lock<mutex>& lck, Predicate pred);
其有两个重载,第一个只有一个参数,也就是我刚刚提到的只要传入一个unique_lock<mutex>
。第二个重载允许传入第二个参数pred,这是一个可调用对象,用于作为条件变量的判断值。
wait的第二个参数要求是一个可调用对象,返回值类型伪bool,作用如下:
- 返回true:表示条件成立,wait直接返回,不进入等待队列
- 返回false:表示条件不成立,wait阻塞,进入等待队列直到被唤醒
为什么调用wait系列函数时需要传入一个互斥锁
- 因为wait系列函数一般是在临界区中调用的,为了让当前线程调用wait阻塞时其他线程能够获取到锁,因此调用wait系列函数时需要传入一个互斥锁,当线程被阻塞时这个互斥锁会被自动解锁,而当这个线程被唤醒时,又会自动获得这个互斥锁。
- 因此wait系列函数实际上有两个功能,一个是让线程在条件不满足时进行阻塞等待,另一个是让线程将对应的互斥锁进行解锁。
wait_for和wait_until函数
- wait_for:进入条件变量的等待队列,一定时间后如果没有被唤醒,则不再等待返回false
- wait_until:进入条件变量的等待队列,到指定时间后如果没有被唤醒,则不再等待返回false
- 这两个与时间相关的等待,分别要传入时间段duration和时间点time_point。至于为什么要传入一把锁,这属于并发编程的知识,就不在博客中讲解了。
3.2 notify系列函数
条件变量下可能会有多个线程在进行阻塞等待,这些线程会被放到一个等待队列中进行排队。
notify系列成员函数的作用就是唤醒等待的线程,包括notify_one和notify_all。
- notify_one:唤醒等待队列中的首个线程,如果等待队列为空则什么也不做。
- notify_all:唤醒等待队列中的所有线程,如果等待队列为空则什么也不做。
3.3 简单示例
下面我们通过 实现两个线程交替打印1-100数 的例子来更好的认识条件变量
int n = 0;
bool flag = true;std::mutex mtx;
std::condition_variable cv; // 条件变量void func(bool run) // run用于标识是否轮到当前线程输出
{while (n < 100){std::unique_lock<std::mutex> lock(mtx);while (flag != run) // 使用while代替if,防止伪唤醒cv.wait(lock); // 没轮到当前线程,进入条件变量等待std::cout << n << std::endl;n++;flag = !flag;cv.notify_one();}
}int main()
{std::thread t1(func, true); // falg == true 输出偶数std::thread t2(func, false); // falg == false 输出奇数t1.join();t2.join();return 0;
}
while循坏防止伪唤醒。
四、atomic 原子性操作库
在多线程情况下要加锁,就是因为很多操作不是原子性的。但是有一些简单的操作,比如num++
,每次都加锁解锁,性能必然会降低。因此C++又提供了原子库,其实现了简单操作的原子化,一些简单的++,–等操作都实现了原子化,可以在不加锁的情况下保证线程安全,需要头文件<atomic>
。
4.1 atomic使用
在C++11中,程序员不需要对原子类型变量进行加锁解锁操作,线程能够对原子类型变量互斥的
访问。更为普遍的,程序员可以使用atomic类模板,定义出需要的任意原子类型。
atomic<类型> 变量名;
简单示例:
std::atomic<int> num = 0;void test(int n)
{for (int i = 0; i < n; i++){num++;}
}int main()
{std::thread t1(test, 2000);std::thread t2(test, 2000);t1.join();t2.join();std::cout << "num = " << num << std::endl;return 0;
}
atomic类成员函数:
operator++
和operator--
,自增自减的操作是原子的。- fetch_* :
- fetch_add:原子性,增加指定的值
- fetch_sub:原子性,减少指定的值
- fetch_and:原子性,与指定值按位与
- fetch_or:原子性,与指定值按位或
- fetch_xor:原子性,与指定值按位异或
std::atomic<int> num = 3;
num.fetch_add(5);
例如fetch_add,用于实现对一个原子类型增加指定值,
以上代码完成了3 + 5的计算,且过程是原子性的
- store :用于设定原子类型为指定值
std::atomic<int> num = 3;
num.store(100);
--num.store(100)相当于num = 100,但是过程是原子性的
-
load用于获取原子类型当前的值,也是原子的。
-
operator T是隐式类型转换,也就是从atomic转化为T类型,此时就可以把原子类型当作一般类型来使用了,不过要注意的是,隐式转换后就是一般类型,不再具有原子性。
4.2 CAS
CAS(Compare and Set)是一种原子操作,用于实现并发编程中的无锁同步。它通过比较内存位置的当前值与预期值,如果相等则更新为新值,从而避免竞态条件。CAS操作广泛应用于多线程环境。
C++之所以可以实现变量的原子操作,是基于CAS的原子操作,这是一个硬件级别的操作,其涉及三个操作数:
- 内存位置
- 预期值
- 更新值
操作流程为:读取内存位置的当前值,判断是否与预期值相等,如果相等,将其变为更新值,如果不相等,返回当前值。
下面是一个CAS的伪代码:
boolean CAS(内存R, 寄存器A, 寄存器B) {if (R == A) { // 比较内存位置R的当前值是否等于寄存器A中的预期值R = B; // 如果相等,则将内存位置R更新为寄存器B中的新值return true; // 返回操作成功}return false; // 否则返回操作失败
}
- 参数说明:
内存R
:共享变量的内存位置。寄存器A
:预期值(expected value)。寄存器B
:新值(new value)。
- 工作流程:
- 首先,读取内存位置RRR的当前值。
- 比较当前值是否等于预期值AAA。
- 如果相等,原子性地将RRR更新为BBB,并返回
true
。 - 如果不相等,说明RRR已被其他线程修改,操作失败,返回
false
。
- 原子性保证:在实际硬件中,CAS操作由一条CPU指令(如x86的
CMPXCHG
)实现,确保整个比较和更新过程不可中断。
CAS操作的优缺点
CAS操作在并发编程中高效,但也存在一些限制:
- 优点:避免线程阻塞,减少上下文切换开销,适用于高并发场景(如计数器或锁实现)。
- 缺点:
- ABA问题:如果RRR的值从AAA变为BBB后又变回AAA,CAS会误以为值未变,可能导致逻辑错误。解决方法包括添加版本号(如Java的
AtomicStampedReference
)。 - 仅支持单个变量的原子操作,对多变量复合操作需额外机制(如循环CAS)。
- 在高竞争环境下,频繁失败会消耗CPU资源。
- ABA问题:如果RRR的值从AAA变为BBB后又变回AAA,CAS会误以为值未变,可能导致逻辑错误。解决方法包括添加版本号(如Java的