C++并发编程(中文版)(C++ Concurrency In Action)
本文概括了《C++并发编程(中文版)》的内容,不涉及操作系统实现并发的理论知识
建议学习 C++ 并发编程前,学习计算机操作系统,对互斥量、锁、进程、线程、异步、同步、生产者-消费者问题、读者-写者问题、哲学家进餐问题等有所了解
C++ 对线程的管理围绕着标准库线程类 std::thread 展开(头文件 thread)
启动线程,就是构造 std::thread 对象
管理线程,就是管理 std::thread 对象
使用可调用对象(函数、类、lambda)构造一个线程对象
void do_some_work();
std::thread my_thread(do_some_work);
class background_task
{
public:
void operator()() const
{
do_something();
do_something_else();
}
};
background_task f;
std::thread my_thread(f);
注意:如果可调用类型是临时变量,而不是一个命名的变量,C++编译器会将其解析为函数声明
std::thread my_thread(background_task());
这里相当与声明了一个名为 my_thread 的函数,这个函数带有一个参数(函数指针指向没有参数并返回 background_task 对象的函数),返回一个 std::thread 对象的函数,而非启动了一个线程
使用多组括号,或使用新统一的初始化语法,可以避免这个问题,如下所示:
std::thread my_thread((background_task())); // 1
std::thread my_thread{background_task()}; // 2
std::thread my_thread([]{
do_something();
do_something_else();
});
启动了线程,你需要明确是要等待线程结束(加入式),还是让其自主运行(分离式)
加入式和分离式分别需要使用 std::thread 的成员函数 join() 和 detach()
对象销毁之后再去访问,会产生未定义行为,如下所示:
struct func {
int* i;
func(int* i_) : i(i_) {}
void operator() ()
{
for (unsigned j = 0; j < 1000000; ++j)
{
do_something(*i);
}
}
};
void oops_join()
{
int* some_local_state = new int(0);
func my_func(some_local_state);
std::thread my_thread(my_func);
delete some_local_state;
my_thread.join();
// 新线程试图访问已销毁的对象(some_local_state)
}
这种情况还可能发生在线程还没结束,函数已经退出的时候,这时线程函数还持有函数局部变量的指针或引用,下面展示了这样的一种情况:
void oops_detach()
{
int some_local_state=0;
func my_func(some_local_state);
std::thread my_thread(my_func);
my_thread.detach();
// 不等待线程结束,新线程可能还在运行
}
如果需要等待线程完成,需要使用 std::thread 的成员函数 join()
加入式可以确保局部变量在线程完成后,才被销毁
只能对一个线程使用一次 join(),一旦已经使用过 join(),std::thread 对象就不能再次加入了,当对其使用 joinable() 时,将返回 false
void f() {
auto my_func = []() {
// do_something();
};
std::thread t(my_func);
try
{
// do_something_in_current_thread();
}
catch (...)
{
t.join();
throw;
}
t.join();
}
class thread_guard {
std::thread& t;
public:
explicit thread_guard(std::thread& t_) : t(t_) {}
~thread_guard() {
if (t.joinable()) // 判断线程是否已加入
t.join(); // 如果没有,调用 join() 进行加入
}
// 直接对一个 std::thread 对象进行拷贝或赋值是危险的
thread_guard(thread_guard const&) = delete;
thread_guard& operator=(thread_guard const&) = delete;
};
void f() {
auto my_func = []() {
// do_something();
};
std::thread t(my_func);
thread_guard g(t);
// do_something_in_current_thread();
}
当线程执行完 do_something_in_current_thread() 后,局部对象就要被逆序销毁了
因此,thread_guard 对象 g 是第一个被销毁的,这时线程在析构函数中被加入到原始线程中
如果需要后台运行线程,需要使用 std::thread 的成员函数 detach()
主线程不能与后台运行线程产生直接交互
注意:不能对没有/执行线程/的 std::thread 对象使用 detach()
当 std::thread 对象 t 使用 t.joinable() 返回的是 true,才可以使用 t.detach()
向 std::thread 构造函数中的可调用对象传递参数很简单:
void f(int i, string const& s);
std::thread t(f, 3, "hello");
当指向动态变量的指针作为参数传递给线程,很有可能导致一些未定义的行为,如下所示:
void f(int i, string const& s);
char buffer[1024];
std::thread t(f, 3, buffer);
解决方案就是在传递到 std::thread 构造函数之前就将字面值转化为 string 对象:
void f(int i, string const& s);
char buffer[1024];
std::thread t(f, 3, string(buffer));
直接传递一个引用给线程,构造函数可能无视函数期待的参数类型,盲目拷贝已提供的变量,并将内部拷贝的引用传递给函数:
void reverse_str(string& str) {
reverse(str.begin(), str.end());
}
int main() {
string str = "Hello,World!";
std::thread my_thread(reverse_str, str);
my_thread.join();
}
直接传递引用会引发一系列的问题,可以使用 std::ref 包装引用,从而可将线程的调用改为以下形式:
std::thread my_thread(reverse_str, std::ref(str));
在这之后,reverse_str 就会接收到一个 str 变量的引用,而非拷贝的引用
注意:以下传递成员函数指针的做法是错误的
class my_class {
public:
void print() {
cout << "Hello,World!" << endl;
}
};
int main() {
my_class x;
std::thread my_thread(x.print);
my_thread.join();
}
要将成员函数作为线程函数,就应该提供相应的函数指针,如下所示:
int main() {
my_class x;
std::thread my_thread(&my_class::print, &x);
my_thread.join();
}
std::thread 构造函数的第一个参数传递一个成员函数指针,第二个参数提供一个合适的对象指针
向成员函数传递参数很简单:
class my_class {
public:
void reverse_str(string& str) {
reverse(str.begin(), str.end());
}
};
int main() {
string str = "Hello,World!";
my_class x;
std::thread my_thread(&my_class::reverse_str, &x, std::ref(str));
my_thread.join();
}
有时我们希望提供给线程的资源被线程独占,这时候就需要使用 std::move
void reverse_print(string str) {
reverse(str.begin(), str.end());
cout << str << endl;
}
int main() {
string str = "Hello,World!";
std::thread my_thread(reverse_print, std::move(str));
if (str.empty())
cout << "Empty." << endl;
else cout << str << endl;
my_thread.join();
}
运行结果:
Empty.
!dlroW,olleH
注意:一般的函数调用,参数是值传递的(引用除外),而 std::thread 的构造函数中,提供给线程函数的参数是移动传递的(引用包装器除外)
struct para_class {
para_class() = default;
para_class(para_class&&)noexcept {
cout << "move-constructor" << endl;
}
para_class(para_class const&) {
cout << "copy-constructor" << endl;
}
};
void tester1(para_class x) {
// do_something();
}
int main() {
para_class x;
std::thread my_thread(tester1, x); // 1
my_thread.join();
}
运行结果:
copy-constructor
move-constructor
上述结果表面,参数值传递给 std::thread 的构造函数,移动传递给线程函数
将 1 处代码改写成以下形式:
std::thread my_thread(tester1, std::move(x));
将有以下运行结果:
move-constructor
move-constructor
通过参数的两次移动,就实现了线程对资源的独占
void do_something() {
// insert code
}
int main() {
std::thread t1(do_something);
std::thread t2 = std::move(t1); // 正确,t1 线程的所有权转移给了 t2
std::thread t3 = t2; // 错误,std::thread 对象不可复制
}
std::thread t1(do_something);
std::thread t2;
t2 = std::move(t1); // 正确,t2 没有关联线程
std::thread t3(do_something);
t3 = std::move(t2); // 错误,t3 已有关联线程
void print() {
cout << "Hello,World!" << endl;
}
void exe_thread(std::thread t) {
cout << "thread begin." << endl;
t.join();
cout << "thread end." << endl;
}
int main() {
std::thread t(print);
exe_thread(std::move(t));
}
并行运算前决定线程数量可以避免产生太多的线程,实际启动线程数需要综合考虑并行运算效率和硬件线程数
标准库函数 std::thread::hardware_concurrency() 返回能同时并发在一个程序中的线程数量
例:多核系统中,返回值可以是 CPU 核芯的数量
注意:返回值仅仅是一个提示,当系统信息无法获取时,函数也会返回 0
hardware_concurrency 函数对启动线程数量有很大帮助,如下所示:
// 并行版的 std::accumulate(不考虑异常)
int concurrent_accumulate(int* _First, int* _Last, int _Val) {
// 决定线程数量
const size_t length = _Last - _First; // 元素数量
const size_t min_per_thread = 25; // 每个线程最小计算量
const size_t max_threads = (length + min_per_thread - 1) / min_per_thread; // 最大线程数
const size_t hardware_threads = std::thread::hardware_concurrency(); // 硬件线程数
const size_t num_threads = min(max(hardware_threads, 1U), max_threads); // 实际线程数
// 量产线程
const size_t block_size = length / num_threads; // 每个线程平均计算量
auto accumulate = [&](int* _Beg, int* _End) {
_Val += std::accumulate(_Beg, _End, 0); }; // 线程函数
vector<std::thread> threads(num_threads);
for (size_t i = 0; i < num_threads - 1; i++)
threads[i] = std::thread(accumulate, _First + i * block_size, _First + (i + 1) * block_size);
threads.back() = std::thread(accumulate, _First + (num_threads - 1) * block_size, _Last);
// 并行运算
for (auto& t : threads)
t.join();
return _Val;
}
线程标识类型是 std::thread::id,可以通过两种方式进行检索
- 通过调用 std::thread 对象的成员函数 get_id() 直接获取
(如果 std::thread 对象没有与任何执行线程相关联,get_id() 将返回默认构造值 0)- 当前线程中调用 std::this_thread::get_id() 标准库为 std::thread::id 定义了比较操作,允许将其当作容器的键值,做排序
当用线程来分割一项工作,主线程可能要做一些与其他线程不同的工作
即,主线程的权限高于其他线程,如下所示:
std::thread::id master_thread;
void some_core_part_of_algorithm()
{
if(std::this_thread::get_id()==master_thread)
{
do_master_thread_work();
}
else do_common_work();
}
另外,将线程 ID 分级存储到一个数据结构中,就可以实现线程函数运行权限的多级划分
invariants - 不变式
不变式是程序执行过程中必须遵守的一些抽象的规则
特别是复杂的数据结构,不变式的稳定尤其重要,如下列举了一些常见的不变式:
双链表任意两个相邻的节点,前驱节点的 next 指针必须指向后继结点,后继节点的 prev 指针必须指向前驱节点
容器的 size 方法返回值必须与实际元素数量一致
单线程程序很难破坏不变式(除非你故意这么做 ),但多线程共享数据就可能破坏不变式,使程序崩溃
例 3.1 线程间共享数据破坏不变式
三个线程共享一个双链表 L,线程 t1 遍历双链表,线程 t2 删除节点,线程 t3 插入节点
t2 线程删除节点的步骤如下:
1. 找到要删除的节点 N
2. 更新前一个节点指向 N 的指针,让这个指针指向 N 的下一个节点
3. 更新后一个节点指向 N 的指针,让这个指针指向 N 的前一个节点
4. 删除节点 N
在步骤 3 完成之前,显然 L 不遵守双链表的不变式,这时 t1 的遍历和 t3 的插入操作就有潜在风险
破坏不变式可能导致并行代码的常见错误:条件竞争
条件竞争是指一个系统的运行结果依赖于不受控制的事件的先后顺序
假设你去电影院买票,有很多收银台,很多人在同一时间买,那么你的座位选择范围取决于在之前已预定的座位
在这个案例中,同时买票的人相当于并发进程,买票相当于并发进程竞争共享资源
大多数情况下,即使改变并发进程执行顺序,也是良性竞争,其结果可以接受(你先买和他先买,都不会破坏电影院的秩序)
当不变式遭到破坏时,才会产生条件竞争(你和某人买到了同一时间、同一位置的电影票)
这里提供一些方法来解决恶性条件竞争:
(1) 对数据结构采用某种保护机制,确保只有进行修改的线程才能看到不变式被破坏时的中间状态,从其他访问线程的角度来看,修改不是已经完成了,就是还没开始
(2) 对数据结构和不变式的设计进行修改,修改完的结构必须能完成一系列不可分割的变化(原子操作)
保护共享数据结构的最基本的方式,是使用 C++ 标准库提供的互斥量
即,当程序中有共享数据,为了不让其陷入条件竞争,将所有访问共享数据结构的代码都标记为互斥
标准库头文件 mutex
C++ 中通过实例化 std::mutex 创建互斥量,通过调用成员函数 lock() 进行上锁,unlock() 进行解锁
注意:在每个函数出口都要去调用 unlock(),也包括异常的情况
list<int> shared_list;
std::mutex list_mutex;
void add_to_list(int new_val) {
list_mutex.lock();
try {
shared_list.push_back(new_val);
}
catch (...) {
list_mutex.unlock();
throw;
}
list_mutex.unlock();
}
bool list_contains(int value_to_find) {
bool f;
list_mutex.lock();
try {
f = find(shared_list.cbegin(), shared_list.cend(), value_to_find) != shared_list.cend();
}
catch (...) {
list_mutex.unlock();
throw;
}
list_mutex.unlock();
return f;
}
C++ 标准库为互斥量提供了一个 RAII 语法的模板类 std::lock_guard,其会在构造的时候提供已锁的互斥量,并在析构的时候进行解锁,从而保证了一个已锁的互斥量总是会被正确的解锁
使用 std::mutex 构造的 std::lock_guard 实例,如下所示:
list<int> shared_list;
std::mutex list_mutex;
void add_to_list(int new_val) {
std::lock_guard<std::mutex> guard(list_mutex);
shared_list.push_back(new_val);
}
bool list_contains(int value_to_find) {
std::lock_guard<std::mutex> guard(list_mutex);
return find(shared_list.cbegin(), shared_list.cend(), value_to_find) != shared_list.cend();
}
注意:默认传递给 std::lock_guard 的互斥量是没有上锁的,传递已锁互斥量时要使用 std::adopt_lock 显式声明,如下所示:
std::mutex m;
m.lock();
std::lock_guard(m, std::adopt_lock);
即使将共享数据和线程函数封装在类中,std::lock_guard 也并不总能保护共享数据,如下所示:
class some_data
{
int a;
std::string b;
public:
void do_something();
};
class data_wrapper
{
private:
some_data data;
std::mutex m;
public:
template<typename Function>
void process_data(Function func)
{
std::lock_guard<std::mutex> l(m);
func(data); // 1 传递保护数据给用户函数
}
};
some_data* unprotected;
void malicious_function(some_data& protected_data)
{
unprotected=&protected_data;
}
data_wrapper x;
void foo()
{
x.process_data(malicious_function); // 2 传递一个恶意函数
unprotected->do_something(); // 3 在无保护的情况下访问保护数据
}
例子中传递一个恶意函数绕开 private 的保护取得共享数据
注意:切勿将受保护数据的指针或引用传递到互斥锁作用域之外
数据结构在无锁实现的接口中,会产生条件竞争,以堆栈为例:
stack<int> s;
if (!s.empty()){
auto value = s.top();
s.pop();
do_something(value);
}
以上的单线程安全代码,对于共享的栈对象,这样的调用顺序就不再安全了:
因为在调用 empty() 和调用 top() 之间,可能有来自另一个线程的 pop() 调用并删除了最后一个元素,对一个空栈使用 top() 是未定义行为
当栈被一个内部互斥量所保护时,只有一个线程可以调用成员函数,所以调用可以很好地交错,但依旧不能阻止条件竞争的发生
下表展示了一种可能的执行顺序:
Thread A | Thread B |
---|---|
if (!s.empty); | |
if(!s.empty); | |
int const value = s.top(); | |
int const value = s.top(); | |
s.pop(); | |
do_something(value); | s.pop(); |
do_something(value); |
当线程运行时,调用两次 top(),栈没被修改,所以每个线程能得到同样的值
这种条件竞争类似于两个人在电影院买到了两张完全相同的电影票,比未定义的 empty()/top() 竞争更加严重,因为这个bug很难定位
接口内在的条件竞争,需要在接口设计上有较大的改动
stack<int> s;
if (! s.empty()){
auto value = s.pop();
do_something(value);
}
提议二解决了"买到两张相同票"的问题,但依然没有解决"拷贝构造函数在栈中抛出一个异常"的问题:
假设有一个 stack< vector< int > >,vector 是一个动态容器,拷贝一个 vetcor 时,标准库会从堆上分配很多内存来完成这次拷贝
如果内存分配失败,vector 的拷贝构造函数会抛出一个 std::bad_alloc 异常
当拷贝数据的时候,调用函数抛出一个异常将导致数据丢失
auto value = s.pop();
s.pop() 返回了弹出值,但是拷贝失败了!
所以我们需要改进带有返回值的 pop() 函数,下面列举了一些改进选项:
(1) 传入一个引用
std::vector<int> result;
some_stack.pop(result);
缺点:需要构造出一个栈中类型的实例,而对于一些类型,这需要大量资源;对于另一些类型,构造函数需要一些参数,而有的参数依赖于运行,在这个阶段不一定能得到
(2) 无异常抛出的拷贝构造函数或移动构造函数
既然对于有返回值的 pop() 函数来说,只有异常安全方面的担忧,那么构造函数不会抛出异常的类型就很安全
使用 std::is_nothrow_copy_constructible 和 std::is_nothrow_move_constructible 类型特征检查构造函数是否抛出异常:
头文件 type_traits
struct tree {
TreeNode* root;
tree(tree const&) {
// copy constructor
}
tree(tree&&)noexcept {
// move constructor
}
};
int main() {
cout << "int throw exception? " << !std::is_nothrow_copy_constructible<int>::value << endl;
cout << "copy tree throw exception? " << !std::is_nothrow_copy_constructible<tree>::value << endl;
cout << "move tree throw exception? " << !std::is_nothrow_move_constructible<tree>::value << endl;
}
运行结果:
int throw exception? 0
copy tree throw exception? 1
move tree throw exception? 0
缺点:有抛出异常的构造函数的类型,不能被存储在线程安全的栈中
(3) 返回指向弹出值的指针
拷贝指针不会产生异常,但考虑到需要对对象的内存分配进行管理,推荐使用智能指针
例:采用选项(1)和选项(3),定义线程安全的堆栈接口
template <typename _Ty>
class threadsafe_stack {
public:
threadsafe_stack();
threadsafe_stack(threadsafe_stack const&);
threadsafe_stack(threadsafe_stack&&)noexcept;
threadsafe_stack& operator=(threadsafe_stack const&) = delete; // 赋值操作被删除
void push(_Ty new_val);
std::shared_ptr<_Ty> pop();
void pop(_Ty& val);
bool empty()const;
};
我们使用互斥量给共享数据上锁,来避免恶性条件竞争,但不合理的上锁会引发另一个潜在问题:死锁
与条件竞争完全相反,死锁发生时,不同的两个线程互相等待,从而什么都没做
我们用哲学家进餐问题来理解死锁:
有五个哲学家坐在一张圆桌周围的五张椅子上,交替地进行思考和进餐,在圆桌上有五支筷子分别放在相邻哲学家之间
平时哲学家进行思考,饥饿时便试图取其左、右最靠近他的筷子,只有在他拿到两支筷子时才能进餐,该哲学家进餐完毕后,放下左右两只筷子又继续思考
如果筷子已被别人拿走,则必须等别人吃完之后才能拿到筷子,任一哲学家在自己未拿到两只筷子吃完饭前,不会放下手中已经拿到的筷子
在哲学家进餐问题中,如果五个哲学家依次拿起自己右手边的筷子,就会发生死锁:
虽然哲学家不会抢别人的筷子(不会发生恶性条件竞争),但每个人都在等相邻的人让出筷子,导致谁也没法进餐
避免死锁的一般建议,就是让两个互斥量总以相同的顺序上锁:
顺时针给五支筷子编号 0-5,编号小的筷子总是比编号大的筷子先上锁
严格遵守上锁顺序约束时,有四个哲学家总是先拿右手边的筷子,有一个哲学家总是先拿左手边的筷子(0号筷子),就永远不会发生死锁
可惜固定上锁顺序在某些情况下并不能避免死锁:
一个类 my_class 的不同实例之间经常进行数据的交换操作,为了保证数据交换操作的正确性,就要避免数据被并发修改,所以每个实例都需要一个互斥量保护
固定 swap(my_class& A, my_class& B); 的上锁顺序为:先锁 A,再锁 B
在参数交换了之后,两个线程试图在相同的两个实例间进行数据交换时,程序又死锁了!
my_class A, B;
swap(A, B) 和 swap(B, A) 分别作为两个线程的线程函数时,程序可能发生死锁
使用 C++ 标准库 std::lock 避免死锁:
std::lock 可以一次性锁住多个(两个以上)的互斥量,并且没有副作用(死锁风险)
class some_big_object;
void swap(some_big_object& lhs, some_big_object& rhs);
class X {
private:
some_big_object some_detail;
std::mutex m;
public:
friend void swap(X& lhs, X& rhs);
};
void swap(X& lhs, X& rhs) {
if (&lhs == &rhs)
return;
std::lock(lhs.m, rhs.m);
std::lock_guard<std::mutex> lock_a(lhs.m, std::adopt_lock);
std::lock_guard<std::mutex> lock_b(rhs.m, std::adopt_lock);
swap(lhs.some_detail, rhs.some_detail);
}
当使用 std::lock 去锁多个互斥量时,即使抛出异常也不会发生死锁
当使用 std::lock 去锁 lhs.m 或 rhs.m 时,可能会抛出异常;这种情况下,异常会传播到 std::lock 之外,当 std::lock 成功的获取一个互斥量上的锁,并且当其尝试从另一个互斥量上再获取锁时,就会有异常抛出,第一个锁也会随着异常的产生而自动释放,所以 std::lock 要么将两个锁都锁住,要不一个都不锁
一个线程已获得一个锁时,再别去获取第二个
如果一定需要获取多个锁,使用一个 std::lock 来做这件事(对获取锁的操作上锁),避免产生死锁
(虽然 C++ 标准库提供了嵌套锁数据结构,我们会在后面介绍,但使用嵌套锁仍然是不被推荐的)
用户程序可能做任何事情,包括获取锁
在持有锁的情况下,调用用户提供的代码;如果用户代码要获取一个锁,就会违反 (1)
当硬性条件要求你获取两个以上(包括两个)的锁,并且不能使用 std::lock 单独操作来获取它们,那么最好在每个线程上,用固定的顺序获取它们获取它们(锁)
上文提到,一个类的不同实例之间经常进行数据的交换操作,固定顺序上锁反而引发了死锁
产生问题的根本原因在于固定上锁顺序的规则不合理,导致参数交换位置,实例的上锁顺序就发生了改变
class X;
X A, B;
swap(A, B); // 1
swap(B, A); // 2
合理的上锁顺序规则应该保证 1 和 2 作为线程函数传递给线程时,A 和 B 的上锁顺序不会发生改变
class X {
private:
some_big_object some_detail;
std::mutex m;
public:
X(some_big_object const& sd) :some_detail(sd) {}
X(X&& rhs)noexcept :some_detail(std::move(rhs.some_detail)) {}
friend void swapX(X& lhs, X& rhs);
};
void swapX(X& lhs, X& rhs) {
if (&lhs == &rhs)
return;
if (&lhs < &rhs) {
lhs.m.lock();
rhs.m.lock();
}
else {
rhs.m.lock();
lhs.m.lock();
}
try {
swap(lhs.some_detail, rhs.some_detail);
}
catch (...) {
lhs.m.unlock();
rhs.m.unlock();
throw;
}
lhs.m.unlock();
rhs.m.unlock();
}
为了强制使用固定顺序获取锁,我们给每个互斥量定义一个层级值,实现分层互斥
我们规定:当试图对一个互斥量上锁,在该层锁已被低层持有时,上锁是不允许的,即,合法的上锁顺序为:先上高层锁,后上低层锁(先高后低)
假设我们实现了层次锁数据结构 hierarchical_mutex,通过为互斥量赋层级值实现分层互斥,如下所示:
注意:hierarchical_mutex 不属于 C++ 标准库,需要读者自行实现
hierarchical_mutex high_level_mutex(10000); // 1
hierarchical_mutex low_level_mutex(5000); // 2
int do_low_level_stuff();
int low_level_func()
{
std::lock_guard<hierarchical_mutex> lk(low_level_mutex); // 3
return do_low_level_stuff();
}
void high_level_stuff(int some_param);
void high_level_func()
{
std::lock_guard<hierarchical_mutex> lk(high_level_mutex); // 4
high_level_stuff(low_level_func()); // 5
}
void thread_a() // 6
{
high_level_func();
}
hierarchical_mutex other_mutex(100); // 7
void do_other_stuff();
void other_stuff()
{
high_level_func(); // 8
do_other_stuff();
}
void thread_b() // 9
{
std::lock_guard<hierarchical_mutex> lk(other_mutex); // 10
other_stuff();
}
thread_a() 遵守先高后低的层级规则,所以它运行的没问题:
thread_b() 无视规则,因此在运行的时候肯定会失败:
使用层次锁的优点:
在层级互斥量上不可能产生死锁,因为互斥量本身会严格遵循约定顺序进行上锁
当多个互斥量在是在同一级上时,不能同时持有多个锁,所以在线程的调用链上,每个互斥量都比其前一个有更低的层级值
层次锁数据结构的实现:
为了适应 std::lock_guard<> 模板,需要用户定义的互斥量定义 lock(), unlock() 和 try_lock() 方法
try_lock() 方法 - 尝试为互斥量上锁,如果成功上锁,则返回 true,如果锁被一个线程持有,则直接返回 false,不等待持有锁的线程
hierarchical_mutex 数据结构的实现,如下所示:
class hierarchical_mutex
{
std::mutex internal_mutex; // 底层互斥量
unsigned long const hierarchy_value; // 锁的层级值
unsigned long previous_hierarchy_value; // 前一个锁的层级值
static thread_local unsigned long this_thread_hierarchy_value; // 线程周期变量:当前线程锁的层级值
void check_for_hierarchy_violation() { // 检查是否违反层级规则
if (this_thread_hierarchy_value <= hierarchy_value)
throw std::logic_error("mutex hierarchy violated");
}
void update_hierarchy_value() { // 更新当前线程锁的层级值
previous_hierarchy_value = this_thread_hierarchy_value;
this_thread_hierarchy_value = hierarchy_value;
}
public:
explicit hierarchical_mutex(unsigned long value) :
hierarchy_value(value),
previous_hierarchy_value(0)
{}
void lock() {
check_for_hierarchy_violation();
internal_mutex.lock();
update_hierarchy_value();
}
void unlock() {
this_thread_hierarchy_value = previous_hierarchy_value;
internal_mutex.unlock();
}
bool try_lock() {
check_for_hierarchy_violation();
if (!internal_mutex.try_lock())
return false;
update_hierarchy_value();
return true;
}
};
thread_local unsigned long
hierarchical_mutex::this_thread_hierarchy_value = ULONG_MAX;
将 thread_loacl 变量初始化为 ULONG_MAX 是为了保证线程第一次锁住一个 hierarchical_mutex 时,一定不会违反层级规则
死锁不仅仅会发生在锁之间,也会发生在任何同步构造中,可能产生等待循环的构造都是死锁
将互斥量传入 std::lock_guard 实例时,无论传入之前互斥量有没有上锁,传入之后互斥量是一定上锁的
有时我们希望先定义一把锁,并在后续合适的时候上锁,这就需要更灵活的锁 - std::unique_lock
std::unique_lock 的构造函数接受两个参数,第一个参数是传入的互斥量
第二个参数可以是 std::adopt_lock,表明传入的是已锁互斥量,第二个参数也可以是 std::defer_lock,表明互斥量应保持解锁状态
例 交换操作中使用 std::lock() 和 std::unique_lock
class some_big_object;
void swap(some_big_object& lhs, some_big_object& rhs);
class X
{
private:
some_big_object some_detail;
std::mutex m;
public:
X(some_big_object const& sd) :some_detail(sd) {}
friend void swap(X& lhs, X& rhs)
{
if (&lhs == &rhs)
return;
std::unique_lock<std::mutex> lock_a(lhs.m, std::defer_lock);
std::unique_lock<std::mutex> lock_b(rhs.m, std::defer_lock); // std::defer_lock 留下未上锁的互斥量
std::lock(lock_a, lock_b); // 互斥量在这里上锁
swap(lhs.some_detail, rhs.some_detail);
}
};
如果构造 std::unique_lock 时不传入第二个参数,只传入互斥量,则传入的互斥量会被自动上锁,这时 std::unique_lock 的使用和 std::lock_guard 类似
和 std::lock_guard 一样,std::unique_lock 会在析构时自动解锁传入的互斥量(如果解锁是合法的)
std::unique_lock 的灵活性还体现在,std::unique_lock 也拥有成员函数 lock(), unlock() 和 try_lock(),调用它们时会调用传入互斥量的对应成员函数,
std::unique_lock 可以在其实例析构前调用 unlock() 提前放弃锁,减少锁的持有时间,对提升性能有较大帮助(锁的滥用可能导致并行代码退化为串行执行)
std::lock_guard 和 std::adopt_lock 能完成的需求不必使用 std::unique_lock 和 std::defer_lock
(除非你需要更灵活地使用锁,比如跨作用域传递锁的所有权)
由于 std::unique_lock 的灵活性,我们使用 std::unique_lock 跨作用域传递锁和锁底层的互斥量
例 函数 get_lock() 锁住了互斥量,然后准备数据,返回锁的调用函数
std::unique_lock<std::mutex> get_lock()
{
extern std::mutex some_mutex; // 声明获取作用域外的某个互斥量
std::unique_lock<std::mutex> lk(some_mutex); // 获取互斥量并上锁
prepare_data(); // 准备数据
return lk; // 返回锁
}
void process_data()
{
std::unique_lock<std::mutex> lk(get_lock());
do_something();
}
extern 关键字详解(上例为 extern 关键字的第一类使用方法)
std::unique_lock 可移动,但不可复制,和大多数资源持有类型一样,有时为了避免转移所有权过程出错,就必须显式使用 std::move
(上例中函数的返回值是右值,编译器自动匹配移动构造函数)
3.2.6 提到,锁的滥用可能导致并行代码退化为串行执行,如果很多线程正在等待同一个资源,当有线程持有锁的时间过长,这就会增加等待的时间
这提示我们应该尽量减少线程持有锁的时间,当代码不需要再访问共享数据时,应及时解锁,如下所示:
void get_and_process_data()
{
std::unique_lock<std::mutex> my_lock(the_mutex); // 为了读取数据,对互斥量上锁
some_class data_to_process = get_next_data_chunk();
my_lock.unlock(); // 读取数据完成后及时解锁
result_type result = process(data_to_process);
my_lock.lock(); // 为了写入数据,对互斥量再次上锁
write_result(data_to_process, result);
}
从这个例子中我们引出锁的粒度的概念,锁的粒度用来描述通过一个锁保护着的数据量大小
一个细粒度锁能够保护较小的数据量,一个粗粒度锁能够保护较多的数据量
锁应该能锁住合适粒度的数据,粒度过细可能导致恶性条件竞争,粒度过粗可能导致进程持有锁的时间过长
互斥量是最通用的机制,但其并非保护共享数据的唯一方式,在特定情况下,有很多替代方式可以提供更加合适的保护
多线程共享数据的延迟初始化很难用互斥量实现
首先介绍延迟初始化(lazy initialization):
当我们计划初始化某些资源时,初始化操作可能需要消耗大量时间或内存,如果初始化成本过高,我们考虑推迟初始化的过程,当我们即将使用这些资源时,才进行初始化
(和延迟初始化类似的技巧还有延迟删除)
延迟初始化在单线程代码很常见:
例 单线程实现延迟初始化
std::shared_ptr<some_resource> resource_ptr;
void foo()
{
if (!resource_ptr)
resource_ptr.reset(new some_resource);
resource_ptr->do_something();
}
延迟初始化在多线程中的实现有很多陷阱:
std::shared_ptr<some_resource> resource_ptr;
std::mutex resource_mutex;
void foo()
{
std::unique_lock<std::mutex> lk(resource_mutex); // 访问资源指针前上锁
if (!resource_ptr)
resource_ptr.reset(new some_resource); // 只有初始化过程需要保护
lk.unlock(); // 初始化完成后解锁
resource_ptr->do_something();
}
这段代码是最容易想到的解决方案,也是最稳妥的,但 do_something() 是只读的方法,会有严重的性能浪费:
对共享数据的只读操作应该被设计成任意数量并发的 - 因为只读操作显然不会破坏任何不变式
但这段代码在访问资源指针前对资源上锁,这就导致调用 do_something() 方法前有一次线程同步操作
并发线程在访问资源指针前退化成串行,这显然不是我们想要的
很多人能想出"更好的"一些的办法来做这件事,包括声名狼藉的双重检查锁模式(不用查):
void undefined_behaviour_with_double_checked_locking()
{
if (!resource_ptr)
{
std::lock_guard<std::mutex> lk(resource_mutex);
if (!resource_ptr)
resource_ptr.reset(new some_resource);
}
resource_ptr->do_something();
}
双重检查锁模式似乎想实现如下功能:
这个模式为什么声名狼藉呢?因为这段代码不能保证 do_something() 方法被调用之前资源已经完成了初始化,从而可能引发潜在的条件竞争:
双重检查锁模式的第一次读取数据是未被锁保护的,当 resource_ptr 不为空时,只能说明指针被其它进程写入过,并不能说明指针指向的数据已经完成了初始化,如下所示:
void init_vector(int n) {
extern vector<int>* vec_ptr;
vec_ptr = new vector<int>(n); // 1
for (int i = 0; i < n; i++)
(*vec_ptr)[i] = i;
}
当一个线程调用 init_vector() 初始化 vec_ptr 指针时,初始化进行到 1 之后,vec_ptr 就已经不为空了,但显然 init_vector() 结束前指针指向的数据都没有完成初始化
虽然我们可以改进代码使 vec_ptr 被写入时就是完成初始化的状态,但这不总是被允许的
实际上,解决多线程延迟初始化有两个问题:
1. 同步问题:任何线程的初始化操作必须在所有线程的只读操作之前
2. 并发问题:当资源已经完成了初始化,对资源的只读操作应该是并发的
我们提供两个方案解决问题:
C++ 标准库提供了 std::once_flag 和 std::call_once
就和字面意义一样,std::once_flag 和 std::call_once 配合使用,可以保证指定调用只被进行一次,如下所示:
std::shared_ptr<some_resource> resource_ptr;
std::once_flag resource_flag;
void init_resource()
{
resource_ptr.reset(new some_resource);
}
void foo()
{
std::call_once(resource_flag, init_resource); // 可以完整的进行一次初始化
resource_ptr->do_something();
}
std::once_flag 和 std::call_once 还可以用在类成员的延迟初始化(线程安全),如下所示:
class X
{
private:
connection_info connection_details;
connection_handle connection;
std::once_flag connection_init_flag;
void open_connection()
{
connection = connection_manager.open(connection_details);
}
public:
X(connection_info const& connection_details_) :
connection_details(connection_details_)
{}
void send_data(data_packet const& data)
{
std::call_once(connection_init_flag, &X::open_connection, this);
connection.send_data(data);
}
data_packet receive_data()
{
std::call_once(connection_init_flag, &X::open_connection, this);
return connection.receive_data();
}
};
注意:std::one_flag 的实例不能拷贝和移动,除非你显式定义这些特殊的成员函数
当一个局部变量被声明为 static 类型,并且在声明后就已经完成初始化时,对于多线程调用的函数,这就意味着这里有条件竞争:抢着去定义这个变量
对于 C++11 前的编译器,这样的条件竞争是确实存在的
在 C++11 标准中,这些问题都被解决了:初始化及定义完全在一个线程中发生,并且没有其他线程可在初始化完成前对其进行处理,条件竞争终止于初始化阶段
在只需要一个全局实例情况下,使用 C++11 标准中的 static 关键字可以作为 std::call_once 的替代方案:
class my_class;
my_class& get_my_class_instance()
{
static my_class instance; // 线程安全的初始化过程
return instance;
}
我们在一个专用数据结构中存放了一些数据,并且需要经常读取数据,偶尔更新数据
这是一个读者写者问题,我们应该实现两种工作模式:多个读进程并发读取数据,或一个写进程更新数据
显然 std::mutex 不能满足我们的需求,因为它只能同时被一个进程持有锁,实际上 C++ 标准库没有提供能实现这种功能的 “读者-写者锁”
解决方案:使用 boost 库中的互斥量 boost::shared_mutex 和锁 boost::shared_lock_guard
boost::shared_mutex 支持 std::lock_guard 和 std::unique_lock 上独占锁,还支持 boost::shared_lock_guard 上共享锁
boost::shared_lock_guard 支持 RAII,在其析构时自动释放持有的共享锁
例 使用 boost::shared_mutex 和 boost::shared_lock_guard 对数据结构进行保护
#include
#include
#include
class data_type;
class read_write_class {
data_type* some_private_data;
mutable boost::shared_mutex m;
public:
void read() {
boost::shared_lock_guard<boost::shared_mutex> lk(m);
// read_data();
}
void write() {
std::lock_guard<boost::shared_mutex> lk(m);
// updata_data();
}
};
大多数情况下,数据结构的每个公共成员函数都会对互斥量上锁,然后完成对应的功能,之后再解锁互斥量
不过,有时成员函数会调用另一个成员函数,这时如果第二个成员函数也会试图锁住互斥量,这就会导致未定义行为的发生
我们提供两个解决方案:
C++ 标准库提供 std::recursive_mutex 类,允许一个线程尝试获取同一个互斥量多次
调用线程持有 std::recursive_mutex 锁的期间,可以对其任意次嵌套上锁,但其他线程试图对 std::recursive_mutex 上锁时将被阻塞
注意:如果不使用 RAII 方式获取 std::recursive_mutex 的锁,当你调用 lock() 三次时,你也必须调用 unlock() 三次
成员函数的嵌套调用源自于功能的重合,细化功能模块可以消除嵌套调用
提取出重合的功能函数作为类的私有成员,并且让其它成员函数都对其进行调用,这个私有成员函数不会对互斥量进行上锁(在调用前必须获得锁)
多线程并发中有时不得不考虑线程同步,以生产者-消费者问题为例:
有多个生产者线程和消费者线程并发执行
生产者线程将生产的数据写入有限的缓冲区中
消费者线程从缓冲区中取出数据进行加工
生产者-消费者问题中存在的同步:
当缓冲区满时,生产者不能向缓冲区写数据,等待消费者取数据
当缓冲区空时,消费者不能从缓冲区取数据,等待生产者写数据
综上,需要同步操作的线程往往都等待某种条件达成才能继续执行
那么问题来了:当条件没有达成时,线程应该如何等待?
使用 while 循环检查等待的条件显得不够明智,因为这是一种忙等待:占有处理机资源却只是检查一些标识,而不做实际的运算
一个简单的优化是使用 std::this_thread::sleep_for() 进行周期性的间歇检查
bool flag;
std::mutex m;
void wait_for_flag()
{
std::unique_lock<std::mutex> lk(m);
while (!flag)
{
lk.unlock(); // 解锁互斥量
std::this_thread::sleep_for(std::chrono::milliseconds(100)); // 休眠100ms
lk.lock(); // 再锁互斥量
}
}
注意:std::sleep_for 会让线程阻塞,而不是忙等待
虽然 std::sleep_for 间歇性的阻塞了线程,使得其它线程有机会持有互斥量的锁,但实际使用中很难确定合适的休眠时间:
鉴于 std::sleep_for 的缺陷,我们寻求其它解决方案
当线程等待一个事件时,将线程阻塞,等待的事件完成时,再通过另一线程唤醒,这种机制就称为"条件变量"
C++ 标准库提供 std::condition_variable 和 std::condition_variable_any 实现条件变量机制
(包含在头文件 condition_variable 中)
条件变量需要与一个互斥量一起工作才能实现同步
std::condition_variable 仅限于与 std::mutex 一起工作,而 std::condition_variable_any 可以和任何满足最低标准的互斥量一起工作,从而加上了 _any 的后缀
例 使用 std::condition_variable 实现生产者-消费者模型
#define MAX_BUFFER 64 // 有限缓冲区大小
std::mutex m; // 同步互斥量
queue<data_type> q; // 数据队列
std::condition_variable p_cond, c_cond; // 条件变量
bool has_data_to_prepare(); // 标识符 - 是否还需要生产数据
bool has_data_to_process(); // 标识符 - 是否还需要加工数据
data_type prepare_data(); // 生产数据(无锁)
void process(data_type); // 加工数据(无锁)
// 生产者线程
void producer_thread() {
while (has_data_to_prepare()) {
std::unique_lock<std::mutex> lk(m);
p_cond.wait(lk, [] {return q.size() < MAX_BUFFER; }); // 检查数据队列是否为满,若为满,则阻塞
auto data = prepare_data();
q.push(data);
lk.unlock();
c_cond.notify_one(); // 唤醒等待队列中的一个线程
}
}
// 消费者线程
void consumer_thread() {
while (has_data_to_process()) {
std::unique_lock<std::mutex> lk(m);
c_cond.wait(lk, [] {return!q.empty(); }); // 检查数据队列是否为空,若为空,则阻塞
auto data = q.front();
q.pop();
lk.unlock();
p_cond.notify_one(); // 唤醒等待队列中的一个线程
process(data);
}
}
从上例可以看出,std::condition_variable 对象通过成员函数 wait() 实现线程等待
wait() 接受两个参数,第一个参数是一个锁,这里使用 std::unique_lock 而不是 std::lock_guard 与 wait() 的实现有关,稍后会解释
第二个参数是一个谓词,谓词是一个可调用的表达式,其返回结果是一个能用作条件的值
调用 wait() 时:
向 std::condition_variable 传入 std::unique_lock 而不是 std::lock_guard 的原因:
wait() 执行周期中,会多次上锁和解锁,灵活的锁 std::unique_lock 才能满足需求
std::condition_variable 对象通过成员函数 notify_one() 和 notify_all() 唤醒线程
notify_one() 会从等待队列中唤醒一个线程,notify_all() 会唤醒等待队列中的所有线程
当线程等待某种消耗型资源时,应该使用 notify_one() 唤醒
当线程等待某个标志性事件时,应该使用 notify_all() 唤醒
上例使用 lambda 作为谓词,实际上函数、可调用类都可以作为谓词
注意:谓词不应该有任何副作用(类似于 const 成员函数)
wait() 执行周期中,可能多次检查谓词条件,如果谓词有副作用,则副作用发生的次数是不确定的
3.2.3 中展示了不合理的接口设计引发的条件竞争,为了实现线程安全队列,设计如下接口:
template<typename _Ty>
class threadsafe_queue {
public:
threadsafe_queue();
explicit threadsafe_queue(threadsafe_queue const& other); // 使用 explicit 关键字屏蔽赋值构造
void push(_Ty new_value)&; // 使用引用限定符 & 屏蔽右值队列的 push 方法
bool try_pop(_Ty& value);
std::shared_ptr<_Ty> try_pop();
void wait_and_pop(_Ty& value);
std::shared_ptr<_Ty> wait_and_pop();
bool empty()const;
};
线程安全队列使用 wait_and_pop() 和 try_pop() 方法取代了单线程的 pop() 方法
wait_and_pop() 方法等待队列不为空时弹出队头元素
try_pop() 方法尝试弹出队头元素,若队列为空则直接返回(不等待)
template<typename _Ty>
class threadsafe_queue {
public:
threadsafe_queue() = default;
explicit threadsafe_queue(threadsafe_queue const& other) {
std::lock_guard<std::mutex> lk(other.m);
q = other.q;
}
void push(_Ty new_value)& {
std::lock_guard<std::mutex> lk(m);
q.push(new_value);
cond.notify_one();
}
bool try_pop(_Ty& value) {
std::lock_guard<std::mutex> lk(m);
if (q.empty())
return false;
value = q.front();
q.pop();
return true;
}
std::shared_ptr<_Ty> try_pop() {
std::lock_guard<std::mutex> lk(m);
if (q.empty())
return std::shared_ptr<_Ty>();
auto p = make_shared<_Ty>(q.front());
q.pop();
return p;
}
void wait_and_pop(_Ty& value) {
std::unique_lock<std::mutex> lk(m);
cond.wait(lk, [&] {return !q.empty(); });
value = q.front();
q.pop();
}
std::shared_ptr<_Ty> wait_and_pop() {
std::unique_lock<std::mutex> lk(m);
cond.wait(lk, [&] {return !q.empty(); });
auto p = make_shared<_Ty>(q.front());
q.pop();
return p;
}
bool empty()const {
std::lock_guard<std::mutex> lk(m);
return q.empty();
}
private:
mutable std::mutex m; // 必须使用 mutable 关键字修饰互斥量
std::queue<_Ty> q;
std::condition_variable cond;
};
注意:线程安全的数据结构中,应该使用 mutable 关键字修饰互斥量,以突破 const 关键字的限制
在线程安全队列中,mutable 的互斥量 m,突破了 empty()const 方法和复制构造函数 const& 的限制
启动多个线程,调用线程安全队列的不同接口
int main() {
threadsafe_queue<int> q;
std::mutex cout_mut;
auto threadsafe_cout = [&](string const& ostr) {
std::lock_guard<std::mutex> lk(cout_mut);
cout << ostr << endl;
};
auto copy_thread = [&] {
threadsafe_queue<int> copy_q(q);
threadsafe_cout("[copy_thread] copy accomplishment");
};
auto push_thread = [&](int val) {
q.push(val);
threadsafe_cout("[push_thread] push " + to_string(val));
};
auto pop_thread_1 = [&] {
int val;
q.wait_and_pop(val);
threadsafe_cout("[pop_thread_1] wait_and_pop " + to_string(val));
};
auto pop_thread_2 = [&] {
auto p = q.wait_and_pop();
threadsafe_cout("pop_thread_2] wait_and_pop " + to_string(*p));
};
auto try_pop_thread_1 = [&] {
int val;
if (q.try_pop(val))
threadsafe_cout("[try_pop_thread_1] try_pop " + to_string(val));
else threadsafe_cout("[try_pop_thraed_1] try_pop failed");
};
auto try_pop_thread_2 = [&] {
auto p = q.try_pop();
if (p)
threadsafe_cout("[try_pop_thread_2] try_pop " + to_string(*p));
else threadsafe_cout("[try_pop_thraed_2] try_pop failed");
};
vector<std::thread> threads;
for (int i = 0; i < 10; i++)
threads.emplace_back(push_thread, i);
threads.emplace_back(copy_thread);
threads.emplace_back(pop_thread_1);
threads.emplace_back(pop_thread_2);
for (int i = 0; i < 5; i++)
threads.emplace_back(try_pop_thread_1);
for (int i = 0; i < 5; i++)
threads.emplace_back(try_pop_thread_2);
for (auto& t : threads)
t.join();
cout << "succeed" << endl;
return 0;
}
可能的运行结果:
C++ 标准库模型将一次性事件称为期望(future)
当一个线程需要等待一个特定的一次性事件时,这个线程会较短周期性的等待或检查,事件是否触发,直到对应的事件触发,期望的状态会变为就绪(ready)
C++ 标准库实现了两种期望,声明在头文件 future 中:
std::future 的实例只能与一个指定事件相关联,而 std::shared_future 的实例就能关联多个事件
注意:期望对象本身不提供同步访问操作,当多个线程需要访问一个独立期望对象时,他们必须使用互斥量或类似同步机制对访问进行保护
如果有一个需要长时间的运算才能得到的值,但是我们并不迫切需要这个值,我们可以启动一个新线程来执行这个计算
但是 std::thread 启动的线程没有直接接收返回值的机制,这里就需要 std::async 函数(声明在头文件 future 中)
std::async 会启动一个异步任务,并返回一个 std::future 对象
std::future 对象持有最终计算结果,当你需要这个值时,通过调用成员函数 get() 获取
get() 的调用会阻塞线程直到期望状态为就绪为止,之后返回计算结果
例 使用 std::future 从异步任务中获取返回值
int bk_calculate();
void do_other_stuff();
int main() {
std::future<int> bk_answer = std::async(bk_calculate);
do_other_stuff();
cout << "The answer is " << bk_answer.get() << endl;
}
特别的,当 std::async 启动的异步任务没有返回值时,std::async 返回一个 std::future< void > 对象,此时成员函数 get() 是没有返回值的
类比 std::thread,std::async 允许添加额外的调用参数,这里不再赘述
默认情况下,期望是否进行等待取决于 std::async 是否启动过一个线程,但是你也可以向 std::async 传递一个额外参数改变启动策略,这个额外参数的类型是 std::launch
std::launch 参数:
int main() {
auto say_hello = [] {cout << "Hello World!" << endl; };
auto say_goodby = [] {cout << "Goodby World!" << endl; };
auto f1 = std::async(std::launch::async, say_hello); // 在新线程上执行 say_hello
auto f2 = std::async(std::launch::deferred, say_goodby); // 在 get() 调用时执行 say_goodby
f2.get(); // 执行 say_goodby
}
std::packaged_task 包装一个可调用的对象,并将返回值移交给一个 std::future
std::packaged_task 的模板参数是一个函数签名,比如 void() 就是一个没有参数也没有返回值的函数,包装后的类型为 std::packaged_task
std::packaged_task 对象本身也是一个可调用对象,调用参数和被包装对象相同,返回值类型为 void,也就是不能通过调用 std::packaged_task 对象直接获取返回值
std::packaged_task 使用成员函数 get_future 获取返回值,返回值是对应的 std::future<> 类型,这使得监测可调用对象的任务执行状态成为可能
使用 std::packaged_task 包装可调用对象,可以分离调用和获取返回值操作,如下所示:
int main() {
// 待包装的可调用对象
auto say_hello = [](string name) {
cout << "Hello " << name << '!' << endl;
return 0;
};
// 使用 std::packaged_task 包装可调用对象
std::packaged_task<int(string)> pf(say_hello);
// 执行可调用对象
pf("Bob");
// 使用 std::future 获取返回值
auto f = pf.get_future();
cout << "result is " << f.get();
}
std::packaged_task 的模板参数类型可以不完全匹配,只要可以隐式转换
也就是说,你可以用一个 int(int) 的函数,来构建 std::packaged_task
// 底层可调用对象 int(int)
auto square = [](int x) {
return x * x; };
// 使用 std::packaged_task 包装
std::packaged_task<double(double)> pf(square);
pf(1.5); // 调用者期待的返回值是 2.25
auto f = pf.get_future();
cout << f.get() << endl; // 实际的返回值是 1,因为参数和返回值被隐式转换了
注意:如果使用 std::packaged_task
由于 std::packaged_task 对象是一个可调用对象,所以其可以作为 std::thread 对象的线程函数
当需要异步任务的返回值时,可以将 std::packaged_task 传入 std::thread 或其它支持异步启动的数据结构中,通过 get_future 获取的 std::future 对象监测运行状态和获取返回值
很多图形架构需要特定的线程去更新界面,所以当一个线程需要界面的更新时,它需要发出一条信息给正确的线程,让特定的线程来做界面更新
例 使用 std::packaged_task 执行一个图形界面线程
// 更新界面的任务队列和保护队列的互斥量
queue<std::packaged_task<void()>> tasks;
std::mutex m;
// 返回用户是否关闭了界面
bool gui_shutdown_message_received();
// 获取并处理界面更新消息,加入 tasks 队列中
void get_and_process_gui_message();
// 图形界面线程
void gui_thread() {
while (!gui_shutdown_message_received()) {
get_and_process_gui_message();
std::packaged_task<void()> task;
{
std::lock_guard<std::mutex> lk(m);
if (tasks.empty())
continue;
task = std::move(tasks.front());
tasks.pop();
}
task();
}
}
// 使用 gui_thread 初始化 std::thread 对象
std::thread gui_bg_thread(gui_thread);
// 发送界面更新消息的模板
template<typename Func>
std::future<void> post_task_for_gui_thread(Func f) {
std::packaged_task<void()> task(f);
auto res = task.get_future(); // res 是 std::future 类型
std::lock_guard<std::mutex> lk(m);
tasks.push_back(std::move(task));
return res;
}
图形界面线程循环更新界面,直到收到关闭图形界面的信息后关闭
循环时,图形界面线程调用 get_and_process_gui_message() 获取界面更新消息(例如用户点击),并将消息推入任务队列
如果队列中没有任务,线程将再次循环
如果线程在队列中提取出一个任务,线程将释放队列上的锁,并且执行任务
用户线程调用 post_task_for_gui_thread() 发送消息给图形界面线程后,将保留一个 std::future 对象,来等待期望就绪
这个例子使用的 post_task_for_gui_thread() 函数是一个模板,向图形界面线程发送一系列不同的函数签名的函数
而 std::packaged_task
总的来说,std::packaged_task 使得启动异步任务,并获取任务执行状态变得简单
如果线程等待的一次性事件来自另一个线程,可以使用 std::promise 在线程间实现数据通信
std::promise<_Ty> 对象与一个 std::future<_Ty> 绑定,可以通过成员函数 get_future() 获取绑定的 std::future 对象
注意:std::promise 对象只能调用一次 get_future,否则将会抛出一个 std::future_error 异常
初始化时,std::promise<_Ty> 绑定的 std::future 是非就绪的,你需要使用 std::promise 的成员函数 set_value() 为 std::future 设定一个值,std::future 才会就绪
std::promise 的一个典型应用是在一个线程中启动一项任务,并在另一个线程中获取运行结果
例 使用 std::promise 实现线程同步
using _Ty = int;
// 高消耗运算
_Ty some_costly_calculate() {
std::this_thread::sleep_for(std::chrono::microseconds(1000));
return 0;
}
// 启动任务的线程 - 持有 std::promise
void post_thread(std::promise<_Ty>& p) {
auto val = some_costly_calculate();
p.set_value(val); // 将运行结果传递给与 std::promise 绑定的 std::future 对象
}
void some_other_stuff(_Ty&);
// 等待运行结果的线程 - 持有 std::future
void receive_thread(std::future<_Ty>& f) {
auto val = f.get(); // 等待某个运行结果,这个运行结果可能来自另一个线程
some_other_stuff(val);
}
int main() {
// 定义 std::promise 并获取绑定的 std::future 对象
std::promise<_Ty> p;
auto f = p.get_future();
std::thread t1(post_thread, std::ref(p));
std::thread t2(receive_thread, std::ref(f));
t1.detach();
t2.detach();
system("pause");
}
在这个例子中,receive_thread 等待 post_thread 的运行结果,这是一种同步操作
receive_thread 持有与 std::promise 绑定的 std::future,以此来获取运行结果
post_thread 持有 std::promise,以此来设置运行结果
std::promise 充当了线程同步的桥梁
注意:即使 std::promise 失效,与其绑定的 std::future 也不一定失效
int main() {
// std::promise 和与其绑定的 std::future
auto p_ptr = new std::promise<int>;
auto f = p_ptr->get_future();
// 设置 std::future 的值并使 std::promise 失效
p_ptr->set_value(0);
delete p_ptr;
// std::future 仍然有效
cout << f.get();
}
std::promise< void > 与 std::future< void > 绑定,显然不能用于线程间的数据通信
std::promise<void> p;
auto f = p.get_future(); // f 是 std::future 类型,不能接收返回值
auto val = f.get(); // 错误,std::future::get() 是 void 类型
虽然 std::promise< void > 不能实现线程间数据通信,但仍然可以用于线程同步
// 必须优先执行的工作
void prev_func() {
// do_some_prev_stuff();
}
// 后续工作
void post_func() {
// do_some_post_stuff();
}
// 执行优先工作的线程
void thread1(std::promise<void>& p) {
prev_func();
p.set_value(); // 执行完成后发出通知
}
void thread2(std::future<void>& f) {
f.get(); // 等待优先工作的执行
post_func();
}
int main() {
std::promise<void> p;
auto f = p.get_future();
std::thread t1(thread1, std::ref(p));
std::thread t2(thread2, std::ref(f));
t1.detach();
t2.detach();
system("pause");
}
同步调用的代码中,如果抛出了异常,这个异常将会被调用者看到:
double square_root(double x) {
if (x < 0)
throw std::out_of_range("x < 0");
return sqrt(x);
}
int main() {
square_root(-1); // std::out_of_range
}
如果调用改为异步调用:
int main() {
auto f = std::async(std::launch::async, square_root, -1);
auto val = f.get();
}
异步任务将发生异常,但我们不希望异常被立刻抛出(这不利于调试),我们希望异常延迟到调用 f.get() 才被抛出
也就是说,我们希望 std::future 不仅能存储异步任务的返回值,也能存储异常
实际上,std::future 确实支持存储异常:
当与 std::future 绑定的调用发生异常时,异常将被存储,在调用 get() 时抛出
int main() {
auto f = std::async(std::launch::async, square_root, -1);
try {
auto val = f.get();
}
catch (std::out_of_range& err) {
cout << "std::out_of_range: " << err.what() << endl;
}
catch (...) {
cout << "unknwon exception." << endl;
throw;
}
}
除了 std::future,与 std::future 相关的数据结构也支持存储异常
std::promise 通过 set_value() 设置返回值,如果有异常抛出,则不能正常返回,需要调用 set_exception() 告诉绑定的 std::future,存储异常而非返回值
extern std::promise<double> some_promise;
try {
some_promise.set_value(calculate_value());
}
catch (...) {
some_promise.set_exception(std::current_exception());
}
这里使用了 std::current_exception() 来检索抛出的异常,在异常处理期间调用 std::current_exception() 会捕获当前异常对象
多个线程试图访问同一个 std::future 时会有条件竞争,当多个线程需要等待相同的事件的结果,需要使用 std::shared_future 来替代 std::future
std::future 是可移动不可拷贝的,只能有一个线程引用期望
std::shared_future 是可拷贝的,允许多个线程引用期望
如果必须和其他对象共享 std::future 的所有权,可以通过 std::move 将 std::future 的所有权转移给 std::shared_future
std::promise<int> p;
std::future<int> f(p.get_future());
assert(f.valid()); // 期望 f 是合法的
std::shared_future<int> sf(std::move(f));
assert(!f.valid()); // 期望 f 现在是不合法的
assert(sf.valid()); // sf 现在是合法的
std::future 和 std::shared_future 的成员函数 valid 返回当前期望对象是否合法
assert 是断言函数,如果断言失败,则终止程序,在这个例子中,我们的断言都是成功的
我们还可以隐式转移 std::future 的所有权:
std::promise<std::string> p;
std::shared_future<std::string> sf(p.get_future()); // 隐式转移所有权
还可以使用 std::future 的成员函数 share() 创建新的 std::shared_future,这样就能直接转移期望的所有权
std::promise<_Ty> p;
auto f = p.get_future();
auto sf = f.share();
assert(!f.valid());
assert(sf.valid());
当 _Ty 是复杂类型时,更能体现使用 share() 转移 std::future 的所有权的优势
4.1 和 4.2 介绍的阻塞调用,阻塞时间是不确定的,有时我们需要一种机制限定等待时间,如果等待超时就终止等待
限定等待时间的方式有两种:时延超时和绝对超时
标准库中,处理时延的变量以 _for 作为后缀,处理绝对时间点的变量以 _until 作为后缀
例如,std::condition_variable 除了提供 wait() 外还提供两个成员函数 wait_for() 和 wait_until()
理解超时相关的函数之前,先要理解 C++ 指定时间的方式
C++ 通过 std::chrono 获取时间信息,std::chrono 定义在头文件 chrono 中
std::chrono 是一个命名空间,里面存放了 C++ 时钟类
std::chrono 通常会提供三种时钟,以满足用户的不同需求(短跑运动员训练需要高精度秒表,而日常生活只需要常规手表)
时钟类通过静态成员函数 now() 获取当前时间,返回类型是对应的 time_point 类型,例如:
std::chrono::system_clock::now();
返回系统时钟的当前时间,返回类型是 std::chrono::system_clock::time_point
系统时钟通常是用户可调的,所以调用 std::system_clock::now() 返回的时间可能比上一次调用返回的时间还早,这个特性使得 system_clock 不适合进行时间段计时
如果希望进行时间段计时,应该使用 steady_clock,因为稳定时钟的时间只会单调增长
high_resolution_clock 被翻译为高分辨率时钟,通常标准库不会实现它,一般 high_resolution_clock 只是 system_clock 或 steady_clock 的别名
注意:尽量不要使用 high_resolution_clock,不同标准库对它的实现不同,可移植性差
期待在软件层面实现高分辨率时钟是不明智的,时钟的精度取决于硬件电路时钟信号的精度
时钟周期是硬件的概念,其反映了电路中时钟信号的基本频率高低,不同的硬件时钟周期不同
如果一秒稳定有25个时钟周期,则一个周期为 std::ratio<1, 25>,这种时钟周期均匀分布的时钟,称为稳定时钟
当时钟类静态数据成员 is_steady 为 true,表明这个时钟是稳定的
std::chrono::system_clock 通常是不稳定的,即,std::chrono::system_clock::is_steady 通常为 false
用户对系统时钟的调节,可能导致调用 now() 返回的时间要早于上一次调用 now() 返回的时间,这显然违反了时钟周期的均匀分布
steady_clock 是名副其实的稳定时钟,时间单调均匀地增长
(我们不讨论 high_resolution_clock 的稳定性,它的稳定性本身就是不稳定的 )
C++ 使用 std::chrono::duration<> 处理时延,duration 是一个时间单位
std::chrono::duration<> 有两个参数,第一个参数是一个类型(int, short, double),第二个参数是用 std::ratio 表示的时间单位
例如 std::duration
注意:毫秒级计时要存在 double 中,如 std::duration
std::chrono::duration<> 的第二个参数可以省略,默认时间单位为秒
标准库在 std::chrono 命名空间内,为 std::chrono::duration 提供一系列预定义类型:
nanoseconds - 纳秒,std::chrono::duration
的别名
microseconds - 微秒,std::chrono::duration的别名
milliseconds - 毫秒,std::chrono::duration的别名
seconds - 秒,std::chrono::duration< long long > 的别名
minutes - 分钟,std::chrono::duration> 的别名
hours - 小时,std::chrono::duration> 的别名
std::chrno::duration_cast<> 可以换算以 std::chrono::duration 为单位的时间
例 将小时换算成秒
std::chrono::hours h(1);
auto s = std::chrono::duration_cast<std::chrono::seconds>(h);
cout << s.count() << endl;
使用 std::chrono::duration 的成员函数 count(),可以获得时间单位的数量,返回值的类型是 duration 模板的第一个参数
一小时可以精确换算为3600秒,但反过来不行:
std::chrono::seconds s(3601);
std::chrono::hours h;
h = std::chrono::duration_cast<decltype(h)>(s);
cout << h.count() << endl;
3601秒无法精确换算为小时,所以结果会被截断为一小时
get() 可以等待 std::future 就绪,并取得期望的结果
std::future 的另一个成员函数 wait() 则等待 std::future 就绪,但不取得期望的结果
如果期望类型是 std::future< void >,那么 get() 和 wait() 没有区别
使用 wait() 等待期望,等待可能永远持续下去,如果要限定等待时间,就需要使用 wait_for() 和 wait_until()
wait_for() 接受一个 std::chrono::duration 参数,表明等待时间
// 模拟需要耗时 2 秒才能完成的异步任务
auto some_task = [&] {
std::this_thread::sleep_for(std::chrono::seconds(2));
return 0;
};
auto f = std::async(std::launch::async, some_task);
// 限定等待时间为 1 秒
auto wait_time = std::chrono::seconds(1);
if (f.wait_for(wait_time) == std::future_status::ready)
cout << "ready" << endl;
else cout << "time out" << endl;
wait_for 会返回一个 std::future_status 类型的状态值:
时延使用的时钟是稳定时钟,即使在等待期间即使用户调整了系统时钟,等待时间也不会改变
C++ 使用 std::chrono::time_point<> 处理时间点
time_point 有两个模板参数,第一个参数是要使用的时钟(system_clock 和 steady_clock),第二个参数是时间单位(std::chrono::duration<>),如:
std::chrono::time_point
前面提到,时钟的 now() 函数可以返回现在的时间点:
auto t_now = std::chrono::system_clock::now();
还可以通过时间点加/减时延,来获得一个新的时间点:
auto t_now = std::chrono::system_clock::now();
auto t_fut = t_now + std::chrono::seconds(120);
t_fut 将得到 120 秒后的时间点
这里的时间戳是指某个时间纪元的起点,如 2022年7月23日 20:17 是以公元元年为起点的
科普:unix 时间戳 是 1970年1月1日 00:00,即计算机启动应用程序时
时间戳也是时钟的基本属性,虽然无法直接获取,但可以调用特定 time_point 实例的 time_since_epoch() 来间接获取
time_since_epoch() 返回一个时延值,表示从时钟的时间戳到指定时间的用时
auto t_now = std::chrono::system_clock::now();
auto delta = t_now.time_since_epoch();
auto delta_h = std::chrono::duration_cast<std::chrono::hours>(delta);
两个时间点相减会得到时间差(二者需要使用同一个的时钟),时间差的类型是对应时钟的 duration 类型
auto t_now = std::chrono::system_clock::now();
auto t_fut = t_now + std::chrono::minutes(60);
auto delta = t_fut - t_now;
cout << delta.count() << endl;
delta 是 std::chrono::system_clock::duration 类型
例 程序计时
void do_something() {
// 模拟花费 10 秒的运算
std::this_thread::sleep_for(std::chrono::seconds(10));
}
int main() {
auto t_beg = std::chrono::steady_clock::now();
do_something();
auto t_end = std::chrono::steady_clock::now();
auto t_cost = std::chrono::duration_cast<std::chrono::seconds>(t_end - t_beg);
cout << "do_something cost " << t_cost.count() << " seconds." << endl;
}
std::future 的 wait_until() 在规定时间点之前等待期望就绪,不取得期望的结果
与 wait_for() 类似,wait_until() 也返回一个 std::future_status
例 在 deadline 之前等待
// 模拟耗时的运算
auto some_costly_stuff = []() {
std::this_thread::sleep_for(std::chrono::seconds(10));
return 0;
};
auto f = std::async(std::launch::async, some_costly_stuff);
// 指定超时的时间点
auto ddl = std::chrono::steady_clock::now() + std::chrono::milliseconds(500);
if (f.wait_until(ddl) == std::future_status::ready)
do_something_with(f.get());
和 std::future 类似,std::condition_variable 也支持 wait_for() 和 wait_until(),他们返回一个 std::cv_status 类型的状态
与 std::future_status 不同,std::cv_status 只有 std::cv_status::timeout 和 std::cv_status::no_timeout 两种状态
例 等待一个条件变量 - 有超时功能
std::condition_variable cond;
bool done;
std::mutex m;
bool wait_loop() {
auto const ddl = std::chrono::steady_clock::now() + std::chrono::milliseconds(500);
std::unique_lock<std::mutex> lk(m);
while (!done) {
if (cond.wait_until(lk, ddl) == std::cv_status::timeout)
break;
}
return done;
}
这里使用循环是为了防止假唤醒,一种更为简洁的写法是向 wait_until() 传递谓词
std::condition_variable cond;
bool done();
std::mutex m;
bool wait_loop() {
auto const ddl = std::chrono::steady_clock::now() + std::chrono::milliseconds(500);
std::unique_lock<std::mutex> lk(m);
cond.wait_until(lk, ddl, done);
return done();
}
C++ 提供的各种 wait() 函数一般都有 wait_for() 和 wait_until() 版本,用来实现超时功能
有时你可能需要让线程休眠,一定时间后唤醒,此时使用 std::this_thread::sleep_for() 和 std::this_thread::sleep_until() 更合适
sleep_ 就像一个闹钟,阻塞当前线程,经过指定时延或到达指定时间点后唤醒
实际上,C++ 提供的含有等待功能的函数大多都有 _for() 和 _until() 版本,比如 std::mutex 的 try_lock()
注意:std::mutex 和 std::recursive_mutex 本身并不支持超时锁
对应的,C++ 提供了 std::timed_mutex 和 std::recursive_timed_mutex,以支持 try_lock_for() 和 try_lock_until()
函数化编程(FP,functional programming)是一种简单高效的编程模式,FP模式的单线程代码能轻松转换为多线程代码
下面的代码展示了使用FP模式的快速排序:
// 快速排序模板
template<typename _Ty>
void quick_sort(vector<_Ty>& input) {
#define ITERATOR typename vector<_Ty>::iterator
#define CUT_OFF 25
// 插入排序函数 - 当待排序元素数量少于阈值 CUT_OFF 时,放弃快速排序,改用插入排序
auto insert_sort = [](ITERATOR beg, ITERATOR end) {
auto n = end - beg;
for (decltype(n)i = 1; i < n; i++)
for (auto j = i; j && beg[j] < beg[j - 1]; j--)
swap(beg[j], beg[j - 1]);
};
// 选主元函数 - 选择待排序元素中一个元素作为分割点(主元)
auto get_pivot = [](ITERATOR beg, ITERATOR end) {
ITERATOR candidates[] = {
beg,beg + ((end - beg) >> 1),end - 1 };
if (*candidates[0] > *candidates[1])
swap(candidates[0], candidates[1]);
if (*candidates[0] > *candidates[2])
swap(candidates[0], candidates[2]);
if (*candidates[1] > *candidates[2])
swap(candidates[1], candidates[2]);
return candidates[1];
};
// 分割函数 - 根据选择的主元,将待排序元素分割为小于主元和大于等于主元的两个子集
auto devide = [](ITERATOR beg, ITERATOR end, ITERATOR& pivot) {
swap(*beg, *pivot);
pivot = beg;
for (auto iter = beg + 1; iter != end; iter++)
if (*iter < *pivot) {
swap(*iter, pivot[1]);
swap(*pivot, pivot[1]);
pivot++;
}
};
// 快速排序函数 - 递归地重复选主元、分割、排序子集的操作
std::function<void(ITERATOR, ITERATOR)> q_sort;
q_sort = [&](ITERATOR beg, ITERATOR end) {
if (end - beg <= CUT_OFF) {
insert_sort(beg, end);
return;
}
auto pivot = get_pivot(beg, end);
devide(beg, end, pivot);
q_sort(beg, pivot);
q_sort(pivot + 1, end);
};
q_sort(input.begin(), input.end());
#undef CUT_OFF
#undef ITERATOR
}
由于主元将待排序元素分割为小于主元和大于等于主元的两个子集,使得排序子集可以并发地进行
只需要略微改动 q_sort,就可以实现快速排序的并发,如下所示:
std::function<void(ITERATOR, ITERATOR)> q_sort;
q_sort = [&](ITERATOR beg, ITERATOR end) {
if (end - beg <= CUT_OFF) {
insert_sort(beg, end);
return;
}
auto pivot = get_pivot(beg, end);
devide(beg, end, pivot);
// 以下是改动部分
auto lhs = std::async(q_sort, beg, pivot);
auto rhs = std::async(q_sort, pivot + 1, end);
lhs.wait();
rhs.wait();
};
从这个例子中可以看出,FP模式能有效减轻并发程序设计的工作量
当线程间需要通讯时,使用共享数据不总是明智的选择:
对共享数据的管理(比如设置共享数据的访问权限),会增加线程之间的耦合度,同时提高错误率
为了解决线程间的通讯问题,我们引入通讯顺序进程(CSP,Communicating Sequential Processer)的概念
CSP的概念十分简单:
当没有共享数据时,每个线程都是独立的,每个线程的行为仅取决于接受到的信息(不关心信息发送者)
每个线程都有一个有限状态机:当线程收到一条信息,它将会以某种方式更新其状态,并且可能向其他线程发出一条或多条信息
CSP的所有消息都应该通过消息队列传递,除了消息队列,线程不存在共享数据
使用CSP的代码逻辑更清晰,线程更独立
例 ATM机的状态机模型
试想,有一天你要为实现ATM(自动取款机)写一段代码,ATM的状态机模型如下:
图4.3 一台ATM机的状态机模型(简化)
(转载自原书4.4节)
一种处理所有事情的方法是将所有事情分配到三个独立线程上去:一个线程去处理物理机械,一个去处理ATM机的逻辑,还有一个用来与银行通讯
// 记录了插入银行卡信息的结构体
struct card_inserted
{
std::string account;
};
// atm机的有限状态机模型
class atm {
messaging::receiver incoming; // 底层物理机械
messaging::sender bank; // 银行管理层
messaging::sender interface_hardware; // 表层硬件
void (atm::*state)(); // 记录状态机"状态"的变量
std::string account; // 账户
std::string pin; // 密码
// 状态:等待插入银行卡
void waiting_for_card() {
interface_hardware.send(display_enter_card()); // 向表层硬件发送信息(显示:请插入银行卡)
incoming.wait(). // 底层物理机械等待插入银行卡
// 当插入银行卡后
handle<card_inserted>(
[&](card_inserted const& msg) {
account = msg.account; // 将 account 设置成当前账户
pin = ""; // 在用户输入密码前清空 pin
interface_hardware.send(display_enter_pin()); // 向表层硬件发送信息(显示:请输入密码)
state = &atm::getting_pin; // 更改状态机状态为:等待输入密码
});
}
// 状态:等待输入密码
void getting_pin() {
incoming.wait() // 底层物理机械等待输入密码
.handle<digit_pressed>( // 如果按下数字键
[&](digit_pressed const& msg) {
unsigned const pin_length = 4; // 密码长度
pin += msg.digit; // 接收输入的密码信息,并放入 pin 中存储
if (pin.length() == pin_length) {
bank.send(verify_pin(account, pin, incoming)); // 向银行管理层发送验证信息
state = &atm::verifying_pin; // 根据验证结果更改状态机状态
}
})
.handle<clear_last_pressed>( // 如果按下退格键
[&](clear_last_pressed const& msg) {
if (!pin.empty())
pin.resize(pin.length() - 1); // 删除最后输入的数字
})
.handle<cancel_pressed>( // 如果按下取消键
[&](cancel_pressed const& msg)
{
state = &atm::done_processing; // 更改状态机状态为:终止交易
});
}
public:
void run() {
state = &atm::waiting_for_card; // 初始化状态为:等待插入银行卡
try {
for (;;)
{
(this->*state)(); // 循环运行状态函数
}
}
catch (messaging::close_queue const&)
{
// 此处插入异常处理代码
}
}
};
如你所见,在一个并发系统中CSP编程方式可以极大的简化任务的设计,因为每一个线程都完全被独立对待
在使用多线程去分离关注点时,线程的任务分配是重中之重
C++ 程序中所有数据都由对象构成,这里的"对象"不是继承和派生中的对象,而是对 C++ 数据构建块的一个声明
像 int 或 float 这样的对象是简单基本类型,没有子对象;而类对象拥有子对象
C++ 通过位域控制类对象数据成员的内存位置
图5.1 分解一个 struct,展示不同对象的内存位置
(转载自原书 5.1节)
宽度为0的未命名位域会强制下一位域对齐到下一type边界(放弃当前内存单元剩余部分)
而宽度为0的命名位域会强制当前位域对齐到下一type边界
两个线程试图访问相同内存位置的对象,有可能产生条件竞争
如果两个线程都是写线程,可能构成的是一种严重的条件竞争 - 数据竞争
为了避免数据竞争,就要避免两个线程同时执行访问,即两个线程需要一定的执行顺序,可行的方式有两种:
第一种方式,使用互斥量保护共享数据(第3章)
第二种方式,使用原子操作同步机制
原子操作是指不会被线程调度打断的操作,原子操作不可分割,一旦开始,就一直运行到结束
原子类型是指加载操作和所有修改操作都是原子操作的类型
由于原子操作的不可分割性,系统的任何线程都不可能观察到原子操作完成了一半这种情况
它要么就是做了,要么就是没做,只有这两种可能
相应的,非原子操作可能会被另一个线程观察到只完成一半
如果这个操作是一个存储操作,那么其他线程看到的值,可能既不是存储前的值,也不是存储的值,而是别的不合法值
C++ 标准原子类型定义在头文件 atomic 中
标准原子类型大多是简单基本类型的原子类型版本,如 int 的原子类型版本为 atomic_int,更多的标准原子类型会在后文表中给出
标准原子类型上的所有操作都是原子操作,实现原子操作有两种方式:
标准原子类型几乎都有一个 is_lock_free() 成员函数,供用户查询实现原子操作的方式
只有 std::atomic_flag 类型不提供 is_lock_free() 成员函数,这个类型是一个简单的布尔标志,并且在这种类型上的操作都需要是无锁的
当你有一个简单无锁的布尔标志时,你可以使用其实现一个简单的锁,并且实现其他基础的原子类型
除了标准原子类型,还可以使用 std::atomic<> 获得用户定义的原子类型
std::atomic<> 将一个类型包装为对应的原子类型,如整型原子类型 std::atomic< int >
虽然 std::atomic< int > 是合法的,但不推荐使用,因为 std::atomic< int > 有相关的标准原子类型
标准原子类型 | 相关特化类 |
---|---|
atomic_bool | std::atomic< bool > |
atomic_char | std::atomic< char > |
atomic_schar | std::atomic< signed char > |
atomic_uchar | std::atomic< unsigned char > |
atomic_int | std::atomic< int > |
atomic_uint | std::atomic< unsigned > |
atomic_short | std::atomic< short > |
atomic_ushort | std::atomic< unsigned short > |
atomic_long | std::atomic< long > |
atomic_ulong | std::atomic< unsigned long > |
atomic_llong | std::atomic< long long > |
atomic_ullong | std::atomic< unsigned long long > |
atomic_char16_t | std::atomic< char16_t > |
atomic_char32_t | std::atomic< char32_t > |
atomic_wchar_t | std::atomic< wchar_t > |
由于历史原因,混用标准原子类型名与相关的 std::atomic<> 特化类名,会降低代码可移植性
除了标准原子类型,C++ 还提供了标准原子类型别名(typedef)
如标准库中的 size_t,对应的原子类型别名为 atomic_size_t
原子类型定义 | 标准库中相关类型定义 |
---|---|
atomic_int_least8_t | int_least8_t |
atomic_uint_least8_t | uint_least8_t |
atomic_int_least16_t | int_least16_t |
atomic_uint_least16_t | uint_least16_t |
atomic_int_least32_t | int_least32_t |
atomic_uint_least32_t | uint_least32_t |
atomic_int_least64_t | int_least64_t |
atomic_uint_least64_t | uint_least64_t |
atomic_int_fast8_t | int_fast8_t |
atomic_uint_fast8_t | uint_fast8_t |
atomic_int_fast16_t | int_fast16_t |
atomic_uint_fast16_t | uint_fast16_t |
atomic_int_fast32_t | int_fast32_t |
atomic_uint_fast32_t | uint_fast32_t |
atomic_int_fast64_t | int_fast64_t |
atomic_uint_fast64_t | uint_fast64_t |
atomic_intptr_t | intptr_t |
atomic_uintptr_t | uintptr_t |
atomic_size_t | size_t |
atomic_ptrdiff_t | ptrdiff_t |
atomic_intmax_t | intmax_t |
atomic_uintmax_t | uintmax_t |
看起来很多!但有一个相当简单的模式:
对于标准类型 T,相关的原子类型就在原来的类型名前加上 atomic_ 的前缀:atomic_T
(除了个别缩写)
std::atomic 不仅是原子类型模板,还提供了一系列原子操作,我们先从最简单的 std::atomic_flag 开始介绍
std::atomic_flag 是最简单的标准原子类型,表示一个布尔标志
std::atomic_flag 对象可以在两个状态间切换:设置(true)和清除(false)
std::atomic_flag 类型的对象必须被 ATOMIC_FLAG_INIT 初始化,初始化标志位是"清除"状态(C++20 前)
std::atomic_flag f = ATOMIC_FLAG_INIT;
C++20 后,ATOMIC_FLAG_INIT 宏被弃用,std::atomic_flag 默认初始状态为清除
当标志对象完成初始化,那么你只能做三件事情:销毁,清除或设置
成员函数 clear() 清除标志位
extern std::atomic_flag f;
f.clear(); // f 的状态为 false
成员函数 test_and_set() 设置标志位,并取得其先前值
extern std::atomic_flag f;
bool his = f.test_and_set();
注意:不能拷贝构造另一个 std::atomic_flag 对象,也不能为 std::atomic_flag 对象赋值
标准库删除了 std::atomic_flag 的拷贝和赋值操作,因为拷贝和赋值会破坏操作的原子性:
原子类型的所有操作都是原子的,拷贝和赋值涉及两个对象,必然会读取一个对象的值,然后写入另一个对象
读取和写入操作都是原子的,但合成的操作必定不是原子的,因此操作不被允许
实际上,所有原子类型都不允许拷贝和赋值
std::atomic_flag 的原子性和无锁性,使得其非常适合实现自旋互斥锁
自旋互斥锁:一种低级的互斥锁,自旋锁加锁失败后,线程不会挂起,而是忙等待,直到它拿到锁
例 使用 std::atomic_flag 实现自旋互斥锁
// C++20
class spinlock_mutex {
std::atomic_flag f;
public:
void lock() {
while (f.test_and_set());
}
void unlock() {
f.clear();
}
};
为了保证 std::atomic_flag 的原子性,其没有非修改的查询操作,否则可能出现以下错误代码:
extern std::atomic_flag f;
// 假设有成员函数 test() 负责查询 std::atomic_flag 的状态
if (!f.test()) {
f.set();
do_some_stuff();
f.clear();
}
分离 test() 和 set() 操作显然存在潜在的条件竞争
std::atomic_flag 的这一局限性,导致其不能像普通的布尔标志那样使用,接下来我们介绍 std::atomic_flag 的替代品 std::atomic_bool
(有的版本中 std::atomic_bool 是 std::atomic< bool > 的别名)