文章目录
-
- 线程库(thread)
-
- [一、 线程对象的构造方式](#一、 线程对象的构造方式)
-
- [1. 无参构造](#1. 无参构造)
- [2. 带参构造](#2. 带参构造)
- [3. 移动构造](#3. 移动构造)
- 二、thread提供的成员函数
-
- [1. joinable的其他功能](#1. joinable的其他功能)
- [2. 获取线程的id的方式](#2. 获取线程的id的方式)
- [3. join和detach](#3. join和detach)
- [4. 线程函数参数(易错点)](#4. 线程函数参数(易错点))
- 互斥锁(mutex)
-
- 一、mutex的种类
-
- [1. std::mutex](#1. std::mutex)
- [2. std::recursive_mutex](#2. std::recursive_mutex)
- [3. std::timed_mutex](#3. std::timed_mutex)
- [4. std::recursive_timed_mutex](#4. std::recursive_timed_mutex)
- 二、lock_guard和unique_lock
- 条件变量(condition_variable)
- 原子性操作库(atomic)
线程库(thread)

在C++11之前,涉及到的多线程问题通常都是和平台相关的,比如windows和Linux下各有自己的接口,这使得代码的可移植性比较差。C++11中最重要的特性就是对线程进行了支持,使得C++并行编程时不需要依赖第三方库,从而解决了这个问题,而且在原子操作中还引入了原子类的概念。
要使用标准库中的线程,必须包含
<thread>头文件。
注意:
- 线程是操作系统中点的一个概念,线程对象可以关联一个线程,用来控制线程以及获取线程的状态。
- 当创建一个线程对象后,没有提供线程函数,该对象实际没有对应任何线程。
一、 线程对象的构造方式

1. 无参构造
thread提供了无参的构造函数,调用无参的构造函数创建出来的线程对象没有关联任何线程入口函数,也就是没有启动任何线程。
cpp
thread t1;
那后续如何启动线程呢?
由于thread提供了移动赋值函数,因此当后续需要让该线程对象与线程函数关联时,可以以带参的方式创建一个匿名对象,然后调用移动赋值将该匿名对象关联线程的状态转移给该线程对象。
如下:
cpp
// 线程入口函数
void handler(int n)
{
for (int i = 0; i <= n; i++)
{
cout << i << endl;
}
}
int main()
{
thread t1;
// 带参创建匿名对象移动赋值给t1
t1 = thread(handler, 10);
t1.join(); // 等待线程
return 0;
}
应用场景:实现线程池的时候就是需要先创建一批线程,但一开始这些线程什么也不做,当有任务到来时再让这些线程来处理这些任务。类似于懒汉模式的思想,可以提高程序效率。
2. 带参构造
cpp
template <class Fn, class... Args>
explicit thread (Fn&& fn, Args&&... args);
参数说明:
fn:可调用对象。比如函数指针、仿函数、lambda表达式、被包装器包装后的可调用对象等。args...:调用可调用对象fn时所需要的若干参数。
调用带参的构造函数创建线程对象,能够直接将线程对象与线程函数fn进行关联。
示例如下:
cpp
// 线程入口函数
void handler(int n)
{
for (int i = 0; i <= n; i++)
{
cout << i << endl;
}
}
int main()
{
thread t2(handler, 10);
t2.join();
return 0;
}
3. 移动构造
thread提供了移动构造函数,能够用一个右值线程对象来构造一个线程对象。
cpp
// 线程入口函数
void handler(int n)
{
for (int i = 0; i <= n; i++)
{
cout << i << endl;
}
}
int main()
{
thread t3 = thread(handler, 10);
t3.join();
return 0;
}
注意:
- 如果创建线程对象时提供了线程入口函数,就会启动一个线程来执行这个线程入口函数,该线程会与主线程并发执行。
线程入口函数一般情况下可按照以下三种方式提供:- 函数指针
- lambda表达式
- 函数对象(包装器和仿函数)
- thread类是防拷贝的,不允许拷贝构造和赋值重载,但支持移动拷贝和移动赋值,可以将一个线程对象关联线程的状态转移给其他线程对象,并且转移期间不影响线程的执行。
二、thread提供的成员函数

常用的成员函数如下:
| 成员函数 | 功能 |
|---|---|
| join | 对该线程进行等待,在等待的线程返回之前,调用join函数的线程将会被阻塞 |
| joinable | 判断该线程是否已经执行完毕,如果是则返回true,否则返回false |
| detach | 将该线程与创建线程进行分离,被分离后的线程不再需要创建线程调用join函数对其进行等待 |
| get_id | 获取该线程的id |
| swap | 将两个线程对象关联线程的状态进行交换 |
1. joinable的其他功能
joinable函数还可以用于判定线程是否是有效的,如果是以下任意情况,则线程无效:
- 采用无参构造函数构造的线程对象。(该线程对象没有关联任何线程)
- 线程对象的状态已经转移给其他线程对象。(已经将线程交给其他线程对象管理)
- 线程已经调用join或detach结束。(线程已经结束)
2. 获取线程的id的方式
获取线程的id的方式有两种,根据适用于不同的使用场景:
-
std::thread::get_id:用于获取特定 std::thread 对象所代表的线程的ID。
使用场景: 当你有一个 std::thread 对象,并且想要知道它代表的线程的ID时,可以使用这个成员函数。示例:
cpp#include <iostream> #include <thread> void foo() { // 一些操作... } int main() { std::thread t(foo); std::cout << "Thread ID: " << t.get_id() << std::endl; t.join(); return 0; } -
std::this_thread::get_id:用于获取当前正在执行的线程的ID。
使用场景 : 当你在某个线程内部,并且想要知道该线程的ID时,可以使用这个函数。示例:
cpp#include <iostream> #include <thread> void foo() { std::cout << "Current Thread ID: " << std::this_thread::get_id() << std::endl; } int main() { std::thread t(foo); t.join(); return 0; }
此外,this_thread命名空间中还提供了以下三个函数:
| 函数名 | 功能 |
|---|---|
| yield | 当前线程"放弃"执行,让操作系统调度另一线程继续执行 |
| sleep_until | 让当前线程休眠到一个具体时间点 |
| sleep_for | 让当前线程休眠一个时间段 |
3. join和detach
启动一个线程后,当这个线程退出时,需要对该线程所使用的资源进行回收,否则可能会导致内存泄露等问题(类比与进程等待来理解)。
thread库给我们提供了如下两种回收线程资源的方式:
join方式
主线程创建新线程后,可以调用join函数等待新线程终止,当新线程终止时join函数就会自动清理线程相关的资源。
join函数清理线程的相关资源后,thread对象与已销毁的线程就没有关系了,因此一个线程对象一般只会使用一次join,否则程序会崩溃。
cpp
void foo(int n)
{
for (int i = 0; i <= n; i++)
{
cout << i << endl;
}
}
int main()
{
thread t(foo, 20);
t.join();
t.join(); //程序崩溃
return 0;
}
detach方式
主线程创建新线程后,也可以调用detach函数将新线程与主线程进行分离,分离后新线程会在后台运行,其所有权和控制权将会交给C++运行库,此时C++运行库会保证当线程退出时,其相关资源能够被正确回收。
使用示例:
cpp
void foo(int n)
{
detach(); // 当前线程与主线程分离
for (int i = 0; i <= n; i++)
{
cout << i << endl;
}
}
int main()
{
thread t(foo, 20);
// 无需join等待
return 0;
}
注意:
- 使用detach的方式回收线程的资源,一般在线程对象创建好之后就立即调用detach函数。
- 否则线程对象可能会因为某些原因,在后续调用detach函数分离线程之前被销毁掉,这时就会导致程序崩溃。
- 因为当线程对象被销毁时会调用thread的析构函数,而在thread的析构函数中会通过
joinable判断这个线程是否需要被join,如果需要那么就会调用terminate终止当前程序(程序崩溃)。
4. 线程函数参数(易错点)
线程函数的参数是以值拷贝的方式拷贝到线程栈空间中的 ,就算线程函数的参数为引用类型,在线程函数中修改后也不会影响到外部实参,因为其实际引用的是线程栈中的拷贝,而不是外部实参。
比如:
cpp
void add(int& num)
{
num++;
}
int main()
{
int num = 0;
thread t(add, num);
t.join();
cout << num << endl; // 0
return 0;
}
尽管add函数的参数类型是引用,但num最终还是0。
像这种要通过线程函数的形参改变外部的实参,有以下三种方式:
方式一:借助std::ref函数
在传入实参时需要借助ref函数可以保持对实参的引用。
示例:
cpp
void add(int& num)
{
num++;
}
int main()
{
int num = 0;
thread t(add, ref(num));
t.join();
cout << num << endl; // 1
return 0;
}
方式二:地址的拷贝(指针)
将线程函数的参数类型改为指针类型,将实参的地址传入线程函数,此时在线程函数中可以通过修改该地址处的变量,进而影响到外部实参。
示例:
cpp
void add(int* num)
{
(*num)++;
}
int main()
{
int num = 0;
thread t(add, &num);
t.join();
cout << num << endl; //1
return 0;
}
方式三:借助lambda表达式
将lambda表达式作为线程函数,利用lambda函数的捕捉列表,以引用的方式对外部实参进行捕捉,此时在lambda表达式中对形参的修改也能影响到外部实参。
示例:
cpp
int main()
{
int num = 0;
thread t([&num]{num++; });
t.join();
cout << num << endl; //1
return 0;
}
互斥锁(mutex)

一、mutex的种类
在C++11中,mutex总共包含了四个互斥量的种类:
1. std::mutex
C++11提供的最基本的互斥量,该类的对象之间不能拷贝,也不能移动拷贝。
mutex中最常用的成员函数如下:
| 成员函数 | 功能 |
|---|---|
| lock | 对互斥量进行加锁 |
| try_lock | 尝试对互斥量进行加锁 |
| unlock | 对互斥量进行解锁,释放互斥量的所有权 |
注意,线程函数调用lock时,可能会发生以下三种情况:
- 如果该互斥量当前没有被锁住,则调用线程将该互斥量锁住,直到调用
unlock之前,该线程一直拥有该锁。 - 如果当前互斥量被其他线程锁住,则当前的调用线程被阻塞。
- 如果当前互斥量被当前调用线程锁住,则会产生死锁。
线程函数调用try_lock时,可能会发生以下三种情况:
- 如果当前互斥量没有被其他线程占有,则该线程锁住互斥量,直到该线程调用unlock释放互斥量。
- 如果当前互斥量被其他线程锁住,则try_lock返回false,而不会被阻塞掉。
- 如果当前互斥量被当前调用线程锁住,则会产生死锁。
2. std::recursive_mutex
recursive_mutex叫做递归互斥锁,该锁专门用于递归函数中的加锁操作。
- 如果在递归函数中使用mutex互斥进行加锁,那么在线程进行递归调用时,可能会重复申请已经申请到但自己还未释放的锁,进而导致死锁问题。
- 而recursive_mutex允许同一个线程对互斥量多次上锁(即递归上锁),来获得互斥量对象的多层所有权,但是释放互斥量时需要调用与该锁层次深度相同次数的
unlock。
除此之外,recursive_mutex也提供了lock、try_lock和unlock成员函数,其的特性与mutex大致相同。
3. std::timed_mutex
timed_mutex中额外提供了以下两个成员函数:
try_lock_for:接受一个时间范围,表示在这一段时间范围之内,调用线程如果没有获得锁则被阻塞住,如果在此期间有其他线程释放了锁,则该线程获得这个锁,如果超时,则放回false。try_lock_untill:接受一个时间点作为参数,在指定时间点未到来之前线程如果没有获得锁则被阻塞住,如果在此期间有其他线程释放了锁,则该线程获得这个锁,如果超时,则返回false。
除此之外,timed_mutex也提供了lock、try_lock和unlock成员函数,其的特性与mutex相同。
4. std::recursive_timed_mutex
顾名思义,recursive_timed_mutex就是recursive_mutex和timed_mutex的结合,recursive_timed_mutex既支持在递归函数中进行加锁操作,也支持定时尝试申请锁。
二、lock_guard和unique_lock
对于我们来说,使用互斥锁最担忧的事情就是忘记释放锁,特别是在加锁的范围太大的时候。此后申请这个互斥锁的线程就会被阻塞住,也就是造成了死锁问题。
最常见的就是此处在锁中间代码函数返回,此外还有一个比较常见的情况就是在锁的范围内抛异常,也很容易导致死锁问题。
因此C++11采用RAII的方式对锁进行了封装,于是就出现了lock_guard和unique_lock。
lock_guard
lock_guard是C++11中的一个模板类,其定义如下:
cpp
template <class Mutex>
class lock_guard;
lock_guard类模板主要是通过RAII的方式,对其管理的互斥锁进行了封装。
使用方法:
- 在需要加锁的地方,用互斥锁实例化一个lock_guard对象,在lock_guard的构造函数中会调用lock进行加锁。
- 当lock_guard对象出作用域前会调用析构函数,在lock_guard的析构函数中会调用unlock自动解锁。
通过这种构造对象时加锁,析构对象时自动解锁的方式就有效的避免了死锁问题。
示例:
cpp
void foo(mutex &mtx)
{
lock_guard<mutex> lg(mtx); //调用构造函数加锁
//...
FILE* fout = fopen("data.txt", "r");
if (fout == nullptr)
{
//...
return; //调用析构函数解锁
}
//...
} //调用析构函数解锁
int main()
{
mutex mtx;
thread t(foo, std::ref(mtx));
return 0;
}
从lock_guard对象定义到该对象析构,这段区域的代码都属于互斥锁的保护范围。
如果只想用lock_guard保护某一段代码,可以通过定义匿名的局部域来控制lock_guard对象的生命周期。比如:
cpp
void foo(mutex &mtx)
{
//...
//匿名局部域
{
lock_guard<mutex> lg(mtx); //调用构造函数加锁
FILE* fout = fopen("data.txt", "r");
if (fout == nullptr)
{
//...
return; //调用析构函数解锁
}
} //调用析构函数解锁
//...
} //调用析构函数解锁
int main()
{
mutex mtx;
thread t(foo, std::ref(mtx));
return 0;
}
unique_lock
由于lock_guard太单一,用户没有办法对锁进行控制,因此C++11又提供了unique_lock。
unique_lock与lock_guard类似,unique_lock类模板也是采用RAII的方式对锁进行了封装。在创建unique_lock对象调用构造函数时也会调用lock进行加锁,在unique_lock对象销毁调用析构函数时也会调用unlock进行解锁。
不同的是,unique_lock更加的灵活,提供了更多的成员函数:
- 加锁/解锁操作:lock、try_lock、try_lock_for、try_lock_until和unlock。
- 修改操作:移动赋值、swap、release(返回它所管理的互斥量对象的指针,并释放所有权)。
- 获取属性:owns_lock(返回当前对象是否上了锁)、operator bool(与owns_lock的功能相同)、mutex(返回当前unique_lock所管理的互斥量的指针)。
条件变量(condition_variable)

condition_variable中提供的成员函数,可分为wait系列和notify系列两类。
一、wait系列成员函数
wait系列成员函数的作用就是让调用线程进行阻塞等待,包括wait、wait_for和wait_until。
先来看看wait,在C++11中它有两个版本:
cpp
//版本一
void wait(unique_lock<mutex>& lck);
//版本二
template<class Predicate>
void wait(unique_lock<mutex>& lck, Predicate pred);
函数说明:
- 调用版本一的wait函数时只需要传入一个互斥锁,线程调用wait后会立即被阻塞,直到被唤醒为止。
- 调用版本二的wait函数时除了需要传入一个互斥锁,还需要传入一个返回值类型为bool的可调用对象,与版本一不同的是,当线程被唤醒后还需要调用那个传入的可调用对象,如果返回值为false,那么这个线程还需要继续被阻塞。
为什么调用wait系列函数时需要传入一个互斥锁?
- 因为wait系列函数一般是在临界区中调用的,为了让当前线程调用wait阻塞时其他线程能够获取到锁,因此调用wait系列函数时需要传入一个互斥锁,当线程被阻塞时这个互斥锁会被自动解锁 ,而当这个线程被唤醒时,又会自动获得这个互斥锁。
- 因此wait系列函数实际上有两个功能,一个是让线程在条件不满足时进行阻塞等待,另一个是让线程将对应的互斥锁进行解锁。
wait_for和wait_until函数的使用方式与wait函数类似:
- wait_for函数也提供了两个版本的接口,只不过这两个版本的接口都比wait函数对应的接口多了一个参数,这个参数是一个时间段,表示让线程在该时间段内进行阻塞等待,如果超过这个时间段则线程被自动唤醒。
- wait_until函数也提供了两个版本的接口,只不过这两个版本的接口都比wait函数对应的接口多了一个参数,这个参数是一个具体的时间点,表示让线程在该时间点之前进行阻塞等待,如果超过这个时间点则线程被自动唤醒。
- 线程调用wait_for或wait_until函数在阻塞等待期间,其他线程调用notify系列函数也可以将其唤醒。此外,如果调用的是wait_for或wait_until函数的第二个版本的接口,那么当线程被唤醒后还需要调用传入的可调用对象,如果可调用对象的返回值为false,那么当前线程还需要继续被阻塞。
注意:调用wait系列函数时,传入互斥锁的类型必须是unique_lock。
二、notify系列成员函数
notify系列成员函数的作用就是唤醒等待的线程。
包括:
notify_one:唤醒等待队列中的首个线程,如果等待队列为空则什么也不做。notify_all:唤醒等待队列中的所有线程,如果等待队列为空则什么也不做。
注意: 条件变量下可能会有多个线程在进行阻塞等待,这些线程会被放到一个等待队列中进行排队。
原子性操作库(atomic)
多线程最主要的问题是共享数据带来的数据二义性问题(即线程安全)。
如果共享数据都是只读的,就没啥问题,因为只读操作不会影响到数据,更不会涉及对数据的修改,所以所有线程都会获得同样的数据。
但是,当一个或多个线程要修改共享数据时,如果修改数据的操作是原子性的,就没问题,否则就会产生很多潜在的麻烦。
原子性操作(感性的定义):这个操作要么没做,要么就做完了。
原子性操作(计算机中的定义):一个操作只由一条汇编语句完成且该汇编语句属于简单汇编指令。
就比如++操作就不是一个原子操作,该操作分为三条汇编语句:
load:将共享变量n从内存加载到寄存器中。update:更新寄存器里面的值,执行+1操作。store:将新值从寄存器写回共享变量n的内存地址

为什么不是原子操作就不安全了呢?
因为CPU调度线程是给予有限的时间片的,在时间片耗尽前不能保证非原子操作一定完成,很可能会卡在操作中间,就比如会在++操作的store就被切走了,此时调度其他线程,可能完整地执行了一次++操作,当这个线程再重新被调度时,加载线程上下文继续执行store,而这个变量却已经被其他线程++过了,最终就会导致两个线程分别对共享变量n进行了一次++操作,但最终n的值却只被++了一次。
加锁解决线程安全问题
C++98中对于这里出现的线程安全的问题,我们只需要选择性对修改共享数据的操作进行加锁保护即可。
原子类解决线程安全问题
C++11中引入了原子操作类型,它可以使得对该类型的变量的操作转化为原子操作,使得线程间数据的同步变得非常高效。
如下:
| 原子类型名称 | 对应的内置类型名称 |
|---|---|
| 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 |
注意:需要使用以上原子操作变量时,必须添加头文件
在C++11中,程序员不需要对原子类型变量进行加锁解锁操作,线程能够对原子类型变量互斥的访问。
更为普遍的,程序员可以使用atomic类模板,定义出需要的任意原子类型。
cpp
atmoic<T> t; // 声明一个类型为T的原子类型变量t
注意:
- 原子类型通常属于"资源型"数据,多个线程只能访问单个原子类型的拷贝,因此在C++11中,原子类型只能从其模板参数中进行构造,不允许原子类型进行拷贝构造、移动构造以及operator=等。
- 为了防止意外,标准库已经将atmoic模板类中的拷贝构造、移动构造、赋值运算符重载默认删除掉了。
cpp
#include <atomic>
int main()
{
atomic<int> a1(0);
//atomic<int> a2(a1); // 编译失败
atomic<int> a2(0);
//a2 = a1; // 编译失败
return 0;
}
原子类型不仅仅支持原子的++操作,还支持原子的--、加一个值、减一个值、与、或、异或操作。
拓展学习:CAS无锁编程