在C++11之前,涉及到多线程问题,都是和平台相关的,比如windows
和linux
下各有自己的接口,这使得代码的可移植性比较差,如果想要多平台能够同时运行就要使用条件编译写两份代码。
C++11中最重要的特性就是对线程进行支持了,使得C++在并行编程时不需要依赖第三方库,而且在原子操作中还引入了原子类的概念。要使用标准库中的线程,必须包含头文件。
使用线程库,必须包含 < thread > 头文件。
在了解线程库之前我们先讲一个前置知识,我们知道每个线程都要有自己的线程id
,在线程库类中有一个内嵌类型id
类,此类是用数字表示的是线程id
,并且此id类支持比较和流插入运算符
线程对象的构造函数如下:
thread
提供了无参的构造函数,调用无参的构造函数创建出来的线程对象没有关联任何线程函数,内部只有线程对象的相关属性但是没有启动任何线程, 此时线程的id
为0
。
thread
的带参的构造函数,是一个可变参数模板函数,参数说明如下:
fn
:可调用对象,比如函数指针、仿函数、lambda表达式、被包装器包装后的可调用对象等。args...
: 调用可调用对象fn时所需要的若干参数。线程是不允许拷贝的,所以其拷贝函数是delete
的。
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;
}
thread
中常用的成员函数如下:
成员函数 | 功能 |
---|---|
join | 对该线程进行等待,在等待的线程返回之前,调用join函数的线程将会被阻塞 |
joinable | 判断该线程是否已经执行完毕,如果是则返回true,否则返回false |
detach | 将该线程与创建线程进行分离,被分离后的线程不再需要创建线程调用join函数对其进行等待 |
get_id | 获取该线程的id |
swap | 将两个线程对象关联线程的状态进行交换 |
说明:
thread
类是防拷贝的,不允许拷贝构造和拷贝赋值,但是可以移动构造和移动赋值,可以将一个线程对象关联线程的状态转移给其他线程对象,并且转移期间不影响线程的执行。id
会变为0
join
两次会导致程序崩溃this_thread
是std
命名空间下的一个子命名空间,此命名空间提供了访问当前线程的一组函数。
函数 | 功能 |
---|---|
get_id | 获得当前线程的id |
yield | 当前线程“放弃”执行,让出时间片,让操作系统调度另一线程继续执行 |
sleep_until | 让当前线程休眠到一个具体时间点 |
sleep_for | 让当前线程休眠一个时间段 |
我们调用thread的成员函数get_id
可以获取线程的id
,但该方法必须通过线程对象来调用get_id
函数,如果要在线程对象关联的线程函数中获取线程id
,可以调用this_thread
命名空间下的get_id
函数。
void func()
{
//获取线程id
cout << this_thread::get_id() << endl;
}
int main()
{
thread t(func);
t.join();
return 0;
}
线程函数的参数是以值拷贝的方式拷贝到线程栈空间中的,就算线程函数的参数为引用类型,在线程函数中修改后也不会影响到外部实参,因为其实际引用的是线程栈中的拷贝,而不是外部实参。比如:
void add(int& num)
{
num = 10;
}
int main()
{
int num = 0;
thread t(add, num);
t.join();
cout << num << endl; //0
return 0;
}
ps :这段代码可能在vs等编译器下报错,不过不用在意,这种写法的使用也不正确
如果我们想要通过形参改变外部实参时,必须采用以下3种方式:
std::ref()
函数。#include
void ThreadFunc1(int& x)
{
x += 10;
}
void ThreadFunc2(int* x)
{
*x += 10;
}
int main()
{
int a = 10;
// 如果想要通过形参改变外部实参时,必须借助std::ref()函数
thread t1(ThreadFunc1, std::ref(a));
t1.join();
cout << a << endl;
// 利用地址的拷贝,也能改变外部实参
thread t2(ThreadFunc2, &a);
t2.join();
cout << a << endl;
// 利用lambda表达式的捕捉列表
thread t3([&] {a += 10; });
t3.join();
cout << a << endl;
return 0;
}
使用互斥量库,必须包含 < mutex > 头文件。
mutex锁是C++11提供的最基本的互斥量,mutex对象之间不能进行拷贝。
mutex中常用的成员函数如下:
成员函数 | 功能 |
---|---|
lock | 对互斥量进行加锁 |
try_lock | 尝试对互斥量进行加锁,如果加锁失败就立即返回,不会阻塞在锁上 |
unlock | 对互斥量进行解锁,释放互斥量的所有权 |
线程函数调用lock时,可能会发生以下三种情况:
线程调用try_lock时,类似也可能会发生以下三种情况:
recursive_mutex
叫做递归互斥锁,该锁专门用于递归函数中的加锁操作。
如果在递归函数中使用mutex
互斥锁进行加锁,那么在线程进行递归调用时,可能会重复申请已经申请到但自己还未释放的锁,进而导致死锁问题。
而recursive_mutex
允许同一个线程对互斥量多次上锁(即递归上锁),来获得互斥量对象的多层所有权,但是释放互斥量时需要调用与该锁层次深度相同次数的unlock
。
除此之外,recursive_mutex
也提供了lock、try_lock
和unlock
成员函数,其的特性与mutex
大致相同。
我们看到下面的代码能够保证线程在递归时也能一直占有锁,只有当递归完成才会释放锁,两个线程对全局变量进行递归++操作。
#include
#include
#include
std::recursive_mutex mtx;
int x = 0;
void recursiveFunction(int count)
{
mtx.lock();
std::cout << "Thread " << std::this_thread::get_id() << ": Lock acquired, count = "
<< count << std::endl;
if (count > 0)
{
x++;
recursiveFunction(count - 1);
}
std::cout << "Thread " << std::this_thread::get_id() << ": Lock released, count = "
<< count << std::endl;
mtx.unlock();
}
int main()
{
std::thread t1(recursiveFunction, 3);
std::thread t2(recursiveFunction, 2);
t1.join();
t2.join();
cout << "------------------------------------------" << endl;
cout << "final result: " << x << endl;
return 0;
}
timed_mutex中提供了以下两个成员函数:
try_lock_for
:接受一个时间范围,表示在这一段时间范围之内线程如果没有获得锁则被阻塞住,如果在此期间其他线程释放了锁,则该线程可以获得对互斥量的锁,如果超时(即在指定时间之内还是没有获得锁),则返回false。
try_lock_untill
:接受一个时间点作为参数,在指定时间点未到来之前线程如果没有获得锁则被阻塞住,如果在此期间其他线程释放了锁,则该线程可以获得对互斥量的锁,如果超时(即在指定时间点到来时还是没有获得锁),则返回false。
除此之外,timed_mutex也提供了lock、try_lock
和unlock
成员函数,其的特性与mutex相同。
利用时间锁可以写一些有趣的程序,下面的运行结果是不能确定的
#include // std::cout
#include // std::chrono::milliseconds
#include // std::thread
#include // std::timed_mutex
std::timed_mutex mtx;
void fireworks()
{
// 等待获得锁 : 每个线程每200ms打印"-"
while (!mtx.try_lock_for(std::chrono::milliseconds(200)))
{
std::cout << "-";
}
// 得到一个锁! 等待1秒,然后这个线程打印"*"
std::this_thread::sleep_for(std::chrono::milliseconds(1000));
std::cout << "*\n";
mtx.unlock();
}
int main()
{
std::thread threads[10];
// 启动 10 个线程:
for (int i = 0; i < 10; ++i)
threads[i] = std::thread(fireworks);
// join 10个线程
for (auto& th : threads) th.join();
return 0;
}
recursive_timed_mutex就是recursive_mutex和timed_mutex的结合,recursive_timed_mutex既支持在递归函数中进行加锁操作,也支持定时锁。
互斥锁的使用很简单,但是有些情况下互斥锁的使用可能会变得很棘手,例如:
如果加锁的范围太大,那么极有可能在中途返回时忘记了解锁,那么以后其他线程申请这个互斥锁的就会被阻塞住,也就是造成了死锁问题。
又或者是如果线程在锁的范围内抛异常,导致没有解锁,也很容易导致死锁问题。
#include
#include
#include
int x = 0;
mutex mtx;
void Func(int n)
{
for (size_t i = 0; i < n; i++)
{
try
{
mtx.lock();
++x;
cout << x << endl;
if (rand() % 3 == 0)
{
throw exception("抛异常");
}
mtx.unlock();
}
catch (const std::exception& e)
{
cout << e.what() << endl;
}
}
}
int main()
{
thread t1(Func, 10);
thread t2(Func, 10);
t1.join();
t2.join();
return 0;
}
为了解决上面的问题,C++11采用RAII的方式对锁进行了封装,于是就出现了lock_guard
和unique_lock
。
lock_guard是C++11中的一个模板类,其定义如下:
lock_guard
类模板主要是通过RAII的方式,对其管理的互斥锁进行了封装。
在需要加锁的地方,用互斥锁实例化一个lock_guard
对象,在lock_guard
的构造函数中会调用lock()
进行加锁。
当lock_guard
对象出作用域前会调用析构函数,在lock_guard
的析构函数中会调用unlock()
自动解锁。
从lock_guard
对象定义到该对象析构,这段区域的代码都属于互斥锁的保护范围。
lock_guard
类对象也是也是不支持拷贝的。
有了这种方法我们就不用害怕出现上面的情况了!
#include
#include
#include
int x = 0;
mutex mtx;
void Func(int n)
{
for (size_t i = 0; i < n; i++)
{
try
{
lock_guard<mutex> lck(mtx);
++x;
cout << x << endl;
if (rand() % 3 == 0)
{
throw exception("抛异常");
}
}
catch (const std::exception& e)
{
cout << e.what() << endl;
}
}
}
int main()
{
thread t1(Func, 10);
thread t2(Func, 10);
t1.join();
t2.join();
return 0;
}
lock_guard的模拟实现
对lock_guard的模拟实现我们只要做到以下几点:
delete
template<class Mutex>
class lock_guard
{
public:
lock_guard(Mutex& mtx)
:_mtx(mtx)
{
mtx.lock(); //加锁
}
~lock_guard()
{
mtx.unlock(); //解锁
}
lock_guard(const lock_guard&) = delete;
lock_guard& operator=(const lock_guard&) = delete;
private:
Mutex& _mtx;
};
但由于lock_guard太单一,用户没有办法对锁进行控制,因此C++11又提供了unique_lock
。
unique_lock
与lock_guard类似,unique_lock类模板也是采用RAII的方式对锁进行了封装。在创建unique_lock对象调用构造函数时也会调用lock进行加锁,在unique_lock
对象销毁调用析构函数时也会调用unlock进行解锁。
但lock_guard不同的是,unique_lock更加的灵活,提供了更多的成员函数:
operator=
、swap、release(返回它所管理的互斥量对象的指针,并释放所有权)。以下场景就适合使用unique_lock:
需要在锁定期间多次解锁和重新锁定:std::unique_lock
允许在锁定期间多次释放和重新获取锁。这对于需要在锁定期间执行复杂的逻辑或条件判断的情况非常有用。
需要延迟锁定:std::unique_lock
允许在构造时不立即锁定互斥量,而是在需要时手动调用 lock 函数进行锁定。这对于需要在一段代码中的某个特定位置才需要锁定的情况非常有用。
使用条件变量库,必须包含 < condition_variable > 头文件。
condition_variable中提供的成员函数,可分为wait
系列和notify
系列两类。
调用第一个版本的wait
函数时只需要传入一个互斥锁,线程调用wait
后会立即被阻塞,直到被唤醒。
调用第二个版本的wait
函数时除了需要传入一个互斥锁,还需要传入一个返回值类型为bool
的可调用对象,与第一个版本的wait
不同的是,线程在进行wait
之前会先判断可调用对象是否为假,如果为假就进行等待,否则就返回。当线程被唤醒后还需要调用传入的可调用对象,如果可调用对象的返回值为false
,那么该线程还需要继续被阻塞。
wait_for和wait_until函数的使用方式与wait函数类似:
wait_for函数也提供了两个版本的接口,只不过这两个版本的接口都比wait
函数对应的接口多了一个参数,这个参数是一个时间段,表示让线程在该时间段内进行阻塞等待,如果超过这个时间段则线程被自动唤醒。
wait_until
函数也提供了两个版本的接口,只不过这两个版本的接口都比wait
函数对应的接口多了一个参数,这个参数是一个具体的时间点,表示让线程在该时间点之前进行阻塞等待,如果超过这个时间点则线程被自动唤醒。
线程调用wait_for
或wait_until
函数在阻塞等待期间,其他线程调用notify
系列函数也可以将其唤醒。此外,如果调用的是wait_for
或wait_until
函数的第二个版本的接口,那么当线程被唤醒后还需要调用传入的可调用对象,如果可调用对象的返回值为false
,那么当前线程还需要继续被阻塞。
notify
系列成员函数的作用就是唤醒等待的线程,包括notify_one
和notify_all
。
notify_one
:唤醒等待队列中的任意一个线程,如果等待队列为空则什么也不做。
notify_all
:唤醒等待队列中的所有线程,如果等待队列为空则什么也不做。
实现两个线程交替打印1-100
实现这个问题的关键是对同步与互斥的把握,
#include
#include
#include
#include
int x = 1;
mutex mtx;
condition_variable cv;
void Func_1()
{
unique_lock<mutex> lck(mtx);
while (x < 100)
{
if (x % 2 == 0) // 偶数阻塞
{
cv.wait(lck);
}
// 或者这样写也行
//cv.wait(lck, []() {return x % 2 != 0; });
cout << this_thread::get_id() << " :" << x++ << endl;
cv.notify_one();
}
}
void Func_2()
{
unique_lock<mutex> lck(mtx);
while (x <= 100)
{
if (x % 2 != 0) // 奇数阻塞
{
cv.wait(lck);
}
// 或者这样写也行
// cv.wait(lck, []() {return x % 2 == 0; });
cout << this_thread::get_id() << " :" << x++ << endl;
cv.notify_one();
}
}
int main()
{
thread t1(Func_1);
thread t2(Func_2);
t1.join();
t2.join();
return 0;
}
使用原子性操作库(atomic),必须包含 < atomic > 头文件。
多线程最主要的问题是共享数据带来的问题(即线程安全)。如果共享数据都是只读的,那么没问题,因为只读操作不会影响到数据,更不会涉及对数据的修改,所以所有线程都会获得同样的数据。但是,当一个或多个线程要修改共享数据时,就会产生很多潜在的麻烦。
例如下面的程序,对一个变量进行累加,如果是单线程计算结果一定没有问题,但是对于多线程计算结果就有问题了。
#include
#include
int g_val_1 = 0;
int g_val_2 = 0;
void multiThread(int num)
{
for (size_t i = 0; i < num; i++)
{
g_val_1++;
}
}
void singleThread(int num)
{
for (size_t i = 0; i < num; i++)
{
g_val_2++;
}
}
int main()
{
thread t1(multiThread, 100000);
thread t2(multiThread, 200000);
singleThread(300000);
t1.join();
t2.join();
cout << "g_val_1 : "<<g_val_1 << endl;
cout << "g_val_2 : "<<g_val_2 << endl;
return 0;
}
当然这里可以通过加锁来进行解决,但是加锁是一件有损于性能的事情。为了解决这样的问题,C++11提供了原子操作类型,对此类型的操作都是原子的,这样我们就不必进行加锁了。
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 |
将上面的代码进行一点点改变:
...
atomic_int g_val_1 = 0;
atomic_int g_val_2 = 0;
...
除此之外,也可以使用atomic类模板定义出任意原子类型,原子类型通常属于"资源型"数据,多个线程只能访问单个原子类型的拷贝。
因此在C++11中,原子类型只能从其模板参数中进行构造,不允许原子类型进行拷贝构造、移动构造以及operator=等,为了防止意外,标准库已经将atmoic模板类中的拷贝构造、移动构造、赋值运算符重载默认删除掉了。
#include
int main()
{
atomic<int> a1(0);
//atomic a2(a1); // 编译失败
atomic<int> a2(0);
//a2 = a1; // 编译失败
return 0;
}
is_lock_free
函数是一个成员函数,is_lock_free()
检测是否该类型内部是通过使用锁模拟的,若返回false
则表示该原子类型是库或是编译器内部使用一个锁实现的,调用此成员函数不会启动任何数据竞争。
#include
#include
#include
struct A { int a[100]; };
struct B { int x, y; };
int main()
{
std::cout << std::boolalpha
<< "atomic is lock free? "
<< std::atomic<A>().is_lock_free() << endl;
cout <<"atomic is lock free? "
<< std::atomic<B>{}.is_lock_free() << endl;
}
用于将给定的值存储到原子对象中。
int main()
{
atomic<int> atInt(0);
int a = 10;
atInt.store(a);
cout << atInt << endl;
return 0;
}
由于运算符重载,我们更愿意使用=来进行赋值。(=不能用于对象拷贝)
int main()
{
atomic<int> atInt(0);
int a = 10;
// 利用了运算符
atInt = a;
cout << atInt << endl;
return 0;
}
load函数用于获取原子变量的当前值,由于下面的函数的存在,我们更愿意隐式使用。
int main()
{
atomic<int> atInt(0);
// 显示使用
cout << atInt.load() << endl;
// 利用了 operator T()
cout << atInt << endl;
return 0;
}
访问和修改包含的值,将包含的值替换并返回它前面的值。
int main()
{
atomic<int> atInt(0);
cout << atInt.exchange(10) << endl;
cout << atInt << endl;
return 0;
}
这个函数的作用是将 atomic
对象的包含值的内容与预期值进行比较:
true
,则用val替换包含的值false
,则用包含的值替换expectedint main()
{
atomic<int> atInt(0);
int a = 1;
// 失败后 a = 0
cout << atInt.compare_exchange_weak(a, 9) << endl;
// 成功!
cout << atInt.compare_exchange_weak(a, 9) << endl;
//cout << atInt.exchange(10) << endl;
cout << atInt << endl;
return 0;
}
注意:
compare_exchange_weak
函数是一个弱化版本的原子操作函数,因为在某些平台上它可能会失败并重试。如果需要保证严格的原子性,则应该使用compare_exchange_strong
函数。
compare_exchange_weak
类似,都是比较一个值和一个期望值是否相等,并且在相等时将该值替换成一个新值。不同的是,compare_exchange_strong
会保证原子性,并且如果比较失败则会返回当前值。函数名 | 功能 |
---|---|
fetch_add | 添加到包含的值并返回它在操作之前具有的值 |
fetch_sub | 从包含的值中减去,并返回它在操作之前的值。 |
fetch_and | 读取包含的值,并将其替换为在读取值和之间执行按位 AND 运算的结果。 |
fetch_or | 读取包含的值,并将其替换为在读取值和 之间执行按位 OR 运算的结果。 |
fetch_xor | 读取包含的值,并将其替换为在读取值和 之间执行按位 XOR 运算的结果。 |
在这里我们先介绍一个专门的atomic
类,atomic_flag
是最简单的标准原子类型,他代表一个布尔标识,没有拷贝构造函数和拷贝赋值运算符(=delete)。
atomic_flag
默认状态不能确定。可以使用 ATOMIC_FLAG_INIT
宏进行初始化,对象使用该宏初始化,那么可以保证该 atomic_flag
对象在创建时处于 clear
状态。atomic_flag flag = ATOMIC_FLAG_INIT;
atomic_flag
提供了两个成员函数 test_and_set()
和 clear()
来测试和设置标志位。
test_and_set()
函数会将标志位置为 true
,并返回之前的值;clear()
函数将标志位置为 false
。atomic_flag
的 test_and_set()
和 clear()
操作是原子的,可以保证在多线程环境下正确执行。atomic_flag
只能表示两种状态,即 true
或 false
,不能做其他比较操作。通常情况下,atomic_flag
被用作简单的互斥锁,而不是用来存储信息。#include
#include
int main()
{
// 进行初始化 false
atomic_flag flag = ATOMIC_FLAG_INIT;
// 返回 0
cout << flag.test_and_set() << endl;
// 返回 1
cout << flag.test_and_set() << endl;
// 没有返回值
flag.clear();
return 0;
}