在C++11之前,涉及到多线程问题,都是和平台相关的,比如windows和linux下各有自己的接口,这使得代码的可移植性比较差。C++11中最重要的特性就是对线程进行支持了,使得C++在并行编程时不需要依赖第三方库,而且在原子操作中还引入了原子类的概念。
1.调用无参的构造函数
thread提供了无参的构造函数,调用无参的构造函数创建出来的线程对象没有关联任何线程函数,即没有启动任何线程。比如:
thread t1;
thread提供了移动赋值函数,因此当后续需要让该线程对象与线程函数关联时,可以以带参的方式创建一个匿名对象,然后调用移动赋值将该匿名对象关联线程的状态转移给该线程对象。
void Func(int n)
{
for (int i = 0; i < n; i++)
{
std::cout << i << std::endl;
}
}
int main()
{
std::thread t1;
t1 = std::thread(Func, 2);
t1.join();
return 0;
}
场景: 实现线程池的时候就是需要先创建一批线程,但一开始这些线程什么也不做,当有任务到来时再让这些线程来处理这些任务。
2.调用带参的构造函数
thread的带参的构造函数的定义如下:
template <class Fn, class... Args>
explicit thread (Fn&& fn, Args&&... args);
fn:可调用对象,比如函数指针、仿函数、lambda表达式、被包装器包装后的可调用对象等。args...:调用可调用对象fn时所需要的若干参数。void Func(int n)
{
for (int i = 0; i < n; i++)
{
std::cout << i << std::endl;
}
}
int main()
{
std::thread t1(Func, 2);
t1.join();
return 0;
}
3.调用移动构造函数
thread提供了移动构造函数,能够用一个右值线程对象来构造一个线程对象。
void Func(int n)
{
for (int i = 0; i < n; i++)
{
std::cout << i << std::endl;
}
}
int main()
{
std::thread t1 = std::thread(Func, 2);
t1.join();
return 0;
}
注意:thread类是防拷贝的,不允许拷贝构造和拷贝赋值,但是可以移动构造和移动赋值,可以将一个线程对象关联线程的状态转移给其他线程对象,并且转移期间不影响线程的执行。
thread中常用的成员函数如下:
| 成员函数 | 功能 |
|---|---|
| join | 对该线程进行等待,在等待的线程返回之前,调用join函数的线程将会被阻塞 |
| joinable | 判断该线程是否已经执行完毕,如果是则返回true,否则返回false |
| detach | 将该线程与创建线程进行分离,被分离后的线程不再需要创建线程调用join函数对其进行等待 |
| get_id | 获取该线程的id |
| swap | 将两个线程对象关联线程的状态进行交换 |
此外,joinable函数还可以用于判定线程是否是有效的,如果是以下任意情况,则线程无效:
调用thread的成员函数get_id可以获取线程的id,但该方法必须通过线程对象来调用get_id函数,如果要在线程对象关联的线程函数中获取线程id,可以调用this_thread命名空间下的get_id函数。比如:
void Func(int n)
{
std::cout << std::this_thread::get_id() << std::endl;
}
int main()
{
std::thread t1(Func, 1);
t1.join();
return 0;
}
this_thread命名空间中还提供了以下三个函数:
| 函数名 | 功能 |
|---|---|
| yield | 当前线程“放弃”执行,让操作系统调度另一线程继续执行 |
| sleep_until | 让当前线程休眠到一个具体时间点 |
| sleep_for | 让当前线程休眠一个时间段 |
对于下面这段程序,看似没什么问题,但是当我们打印出来就会发现,每一次x的值都是不一样的,因为产生了线程安全的问题,那么如何解决这个问题呢?就需要用到我们的mutex了。
int x = 0;
void Func(int n)
{
for (int i = 0; i < n; i++)
{
x++;
}
}
int main()
{
int n = 10000;
std::thread t1(Func, n);
std::thread t2(Func, n);
t1.join();
t2.join();
std::cout << x << std::endl;
return 0;
}
在C++11中,mutex总共包了四个互斥量的种类:
1.std::mutex
mutex锁是C++11提供的最基本的互斥量,mutex对象之间不能进行拷贝,也不能进行移动。mutex中常用的成员函数如下:
| 成员函数 | 功能 |
|---|---|
| lock | 对互斥量进行加锁 |
| try_lock | 尝试对互斥量进行加锁 |
| unlock | 对互斥量进行解锁,释放互斥量的所有权 |
注意,线程函数调用lock()时,可能会发生以下三种情况:
线程函数调用try_lock()时,可能会发生以下三种情况:
我们此时对上面的代码进程修改:
int x = 0;
std::mutex mtx;
void Func(int n)
{
for (int i = 0; i < n; i++)
{
mtx.lock();
x++;
mtx.unlock();
}
}
int main()
{
int n = 10000;
std::thread t1(Func, n);
std::thread t2(Func, n);
t1.join();
t2.join();
std::cout << x << std::endl;
return 0;
}
此时我们就会发现,打印出来的x的值一直都是20000,并不会发生任何改变。
2. std::recursive_mutex
其允许同一个线程对互斥量多次上锁(即递归上锁),来获得对互斥量对象的多层所有权,释放互斥量时需要调用与该锁层次深度相同次数的 unlock(),除此之外,std::recursive_mutex 的特性和 std::mutex 大致相同。
我们来看下面这段代码,我们发现运行他以后程序就崩溃了,那是因为如果在递归函数中使用mutex互斥锁进行加锁,那么在线程进行递归调用时,可能会重复申请已经申请到但自己还未释放的锁,进而导致死锁问题。
int x = 0;
std::mutex mtx;
void Func(int n)
{
if (n == 0)
return;
mtx.lock();
x++;
Func(n - 1);
mtx.unlock();
}
int main()
{
int n = 10000;
std::thread t1(Func, n);
std::thread t2(Func, n);
t1.join();
t2.join();
std::cout << x << std::endl;
return 0;
}
此时就可以使用我们的std::recursive_mutex了。
int x = 0;
//std::mutex mtx;
std::recursive_timed_mutex mtx;
void Func(int n)
{
if (n == 0)
return;
mtx.lock();
x++;
Func(n - 1);
mtx.unlock();
}
int main()
{
int n = 10000;
std::thread t1(Func, n);
std::thread t2(Func, n);
t1.join();
t2.join();
std::cout << x << std::endl;
return 0;
}
我们为你会发现,上面的代码在Debug版本下就崩了,但在Realse版本下就可以正常运行,那是因为每个线程都拥有自己的独立栈空间,但是又不是很大,递归的次数太多就会导致溢出,程序就崩了。
3.std::timed_mutex
timed_mutex中提供了以下两个成员函数:
try_lock_for:接受一个时间范围,表示在这一段时间范围之内线程如果没有获得锁则被阻塞住,如果在此期间其他线程释放了锁,则该线程可以获得对互斥量的锁,如果超时(即在指定时间之内还是没有获得锁),则返回false。try_lock_until:接受一个时间点作为参数,在指定时间点未到来之前线程如果没有获得锁则被阻塞住,如果在此期间其他线程释放了锁,则该线程可以获得对互斥量的锁,如果超时(即在指定时间点到来时还是没有获得锁),则返回false。除此之外,timed_mutex也提供了lock、try_lock和unlock成员函数,其的特性与mutex相同。
4.std::recursive_timed_mutex
recursive_timed_mutex就是recursive_mutex和timed_mutex的结合,recursive_timed_mutex既支持在递归函数中进行加锁操作,也支持定时尝试申请锁。
其实加锁的方式有很多种,可以在for循环内加锁,也可以在for循环外加锁,接下来我们来看一看在for循环外进行加锁。
int x = 0;
std::mutex mtx;
void Func(int n)
{
mtx.lock();
for (int i = 0; i < n; i++)
{
x++;
}
mtx.unlock();
}
int main()
{
int n = 10000;
std::thread t1(Func, n);
std::thread t2(Func, n);
t1.join();
t2.join();
std::cout << x << std::endl;
return 0;
}
那么他们有什么区别呢?
int main()
{
int n = 10000;
int x = 0;
std::mutex mtx;
std::thread t1([&, n]()
{
mtx.lock();
for (int i = 0; i < n; i++)
{
x++;
}
mtx.unlock();
});
std::thread t2([&, n]()
{
mtx.lock();
for (int i = 0; i < n; i++)
{
x++;
}
mtx.unlock();
});
t1.join();
t2.join();
std::cout << x << std::endl;
return 0;
}
使用互斥锁时,如果加锁的范围太大,那么极有可能在中途返回时忘记了解锁,此后申请这个互斥锁的线程就会被阻塞住,也就是造成了死锁问题。比如出现抛异常的时候:
int x = 0;
std::mutex mtx;
void Func(int n)
{
try
{
mtx.lock();
for (int i = 0; i < n; i++)
{
x++;
if (i % 3 == 0)
{
throw std::exception("抛异常");
}
}
mtx.unlock();
}
catch (const std::exception& e)
{
std::cout << e.what() << std::endl;
}
}
int main()
{
int n = 10000;
std::thread t1(Func, n);
std::thread t2(Func, n);
t1.join();
t2.join();
std::cout << x << std::endl;
return 0;
}
我们会发现,此时加锁以后就没有解锁,抛异常后直接跳跃到catch位置,没有解锁,也就造成是死锁问题。
因此C++11采用RAII的方式对锁进行了封装,于是就出现了lock_guard和unique_lock。
lock_guard
template <class Mutex>
class lock_guard;
lock_guard类模板主要是通过RAII的方式,对其管理的互斥锁进行了封装。
通过这种构造对象时加锁,析构对象时自动解锁的方式就有效的避免了死锁问题。
模拟实现lock_guard:
template<class lock>
class lockGuard
{
public:
lockGuard(lock& lk)
:_lk(lk)
{
_lk.lock();
}
~lockGuard()
{
_lk.unlock();
}
lockGuard(const lockGuard&) = delete;
lockGuard& operator=(const lockGuard&) = delete;
private:
lock& _lk;
};
int x = 0;
std::mutex mtx;
void Func(int n)
{
try
{
lockGuard<std::mutex> lock(mtx);
for (int i = 0; i < n; i++)
{
x++;
if (i % 3 == 0)
{
throw std::exception("抛异常");
}
}
}
catch (const std::exception& e)
{
std::cout << e.what() << std::endl;
}
}
int main()
{
int n = 10000;
std::thread t1(Func, n);
std::thread t2(Func, n);
t1.join();
t2.join();
std::cout << x << std::endl;
return 0;
}
unique_lock
但由于lock_guard太单一,用户没有办法对锁进行控制,因此C++11又提供了unique_lock。
unique_lock与lock_guard类似,unique_lock类模板也是采用RAII的方式对锁进行了封装。在创建unique_lock对象调用构造函数时也会调用lock进行加锁,在unique_lock对象销毁调用析构函数时也会调用unlock进行解锁。
但lock_guard不同的是,unique_lock更加的灵活,提供了更多的成员函数:
比如如下场景就适合使用unique_lock:

线程安全问题
多线程最主要的问题是共享数据带来的问题(即线程安全)。如果共享数据都是只读的,那么没问题,因为只读操作不会影响到数据,更不会涉及对数据的修改,所以所有线程都会获得同样的数据。但是,当一个或多个线程要修改共享数据时,就会产生很多潜在的麻烦。比如:
int x = 0;
void Func(int n)
{
for (int i = 0; i < n; i++)
{
x++;
}
}
int main()
{
int n = 10000;
std::thread t1(Func, n);
std::thread t2(Func, n);
t1.join();
t2.join();
std::cout << x << std::endl;
return 0;
}
我们会发现,上面程序每次打印出来的数值都不一样,其根本原因就是下x++的操作并不是原子的,他的步骤分为三步:
load:将共享变量n从内存加载到寄存器中。update:更新寄存器里面的值,执行+1操作。store:将新值从寄存器写回共享变量n的内存地址。对应汇编代码如下:

有可能线程1在执行刚进入函数就被切走了,并没有执行++操作,此时就切换到线程2,线程可能执行的时间长一点,执行完所有操作才切换为线程1,此时线程1会执行刚才未完成的操作,最终就会导致两个线程分别对共享变量n进行了一次++操作,但最终n的值却只被++了一次。
我们在上面已经谈到可以用加锁解决线程安全问题,但是无论是for循环里面还是外面加锁,都多多少少会产生一些问题,for循环里面加锁就会频繁的进行加锁和解锁操作,for循环外面加锁会使两个线程变成串行操作,操作不当就会造成死锁问题。
原子类解决线程安全问题
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 |
注意: 需要用大括号对原子类型的变量进行初始化。
std::atomic_int x = { 0 };
void Func(int n)
{
for (int i = 0; i < n; i++)
{
x++;
}
}
int main()
{
int n = 10000;
std::thread t1(Func, n);
std::thread t2(Func, n);
t1.join();
t2.join();
std::cout << x << std::endl;
return 0;
}
除此之外,也可以使用atomic类模板定义出任意原子类型:
std::atomic<int> x = 0;
void Func(int n)
{
for (int i = 0; i < n; i++)
{
x++;
}
}
int main()
{
int n = 10000;
std::thread t1(Func, n);
std::thread t2(Func, n);
t1.join();
t2.join();
std::cout << x << std::endl;
return 0;
}
condition_variable中提供的成员函数,可分为wait系列和notify系列两类。
wait系列成员函数
wait系列成员函数的作用就是让调用线程进行阻塞等待,包括wait、wait_for和wait_until。
下面先以wait为例进行介绍,wait函数提供了两个不同版本的接口:
//版本一
void wait(unique_lock<mutex>& lck);
//版本二
template<class Predicate>
void wait(unique_lock<mutex>& lck, Predicate pred);
函数说明:
为什么调用wait系列函数时需要传入一个互斥锁?
wait_for和wait_until函数的使用方式与wait函数类似:
注意: 调用wait系列函数时,传入互斥锁的类型必须是unique_lock。
notify系列成员函数
notify系列成员函数的作用就是唤醒等待的线程,包括notify_one和notify_all。
notify_one:唤醒等待队列中的首个线程,如果等待队列为空则什么也不做。notify_all:唤醒等待队列中的所有线程,如果等待队列为空则什么也不做。注意: 条件变量下可能会有多个线程在进行阻塞等待,这些线程会被放到一个等待队列中进行排队。
尝试用两个线程交替打印1-100的数字,要求一个线程打印奇数,另一个线程打印偶数,并且打印数字从小到大依次递增。
该题目主要考察的就是线程的同步和互斥。
我们还需要考虑下面这两个问题:
所以我们在这儿就需要控制住这两个条件:
我们假设有两个线程t1,t2,线程t1打印奇数,线程t2打印偶数,当程序第一次运行时,我们就必须保证线程t1是先运行的,会出现两种情况:

我们可以利用线程t1打印奇数,t2打印偶数的条件对等待条件进行限制,如果x为奇数,就调用t1进行打印,阻塞t2,完成操作后唤醒t2,如果x为偶数,就调用t2进行打印,阻塞t1,完成操作后唤醒t1,由此就完成了两个线程是交替进行打印,并且不会出现某个线程连续多次打印的情况。

整体代码如下:
#include
#include
#include
#include
int main()
{
int x = 1;
int n = 100;
std::mutex mtx;
std::condition_variable cv;
std::thread t1([&, n]() {
while (1)
{
if (x > 100)
break;
std::unique_lock<std::mutex> lock(mtx); //调用构造函数加锁
if (x % 2 == 0)
{
cv.wait(lock);//如果为偶数就阻塞
}
std::cout << std::this_thread::get_id() << ":" << x << std::endl;
x++;
cv.notify_one();
}
});
std::thread t2([&, n]() {
while (1)
{
if (x > 100)
break;
std::unique_lock<std::mutex> lock(mtx); //调用构造函数加锁
if (x % 2 != 0)
{
cv.wait(lock);//如果为奇数就阻塞
}
std::cout << std::this_thread::get_id() << ":" << x << std::endl;
x++;
cv.notify_one();
}
});
t1.join();
t2.join();
return 0;
}