前言:
本文主要参考书籍c++并发编程实战,属于自己的学习笔记,加入了一些自己的理解,希望可以帮助其他人学习和理解c++并发编程实战这本书。
书中提到了c++11以上的c++14和c++17,所以如果想要编程实战,尽量选择支持对应版本的编译器。
c++11中新增了线程库,c++标准中有了自己支持多线程开发的库,后续的对于线程的使用,都是基于c++11的 thread 库。
一.线程管理基础
首先对于线程的管理,主要内容包括:
·启动新线程
·等待线程与分离线程
·线程唯一标识符
(一).线程管理
对于一个线程的使用最基础的就是创建一个线程,以往对于一个线程的使用,一般是调用平台相关的函数,但是这对于代码的可移植性造成了一些问题。而使用c++的线程库,通过语言本身的线程库,可以做到线程开发平台无关性。
在c++11中,由于线程库的引入,线程的创建变得更加简单。在c++中,新线程的启动被简化为创建一个是 std::thread对象。
1.无参函数初始化线程
线程对象的初始化时,传入的对象可以分为以下几种:
(1).通过普通全局函数创建线程
void do_some_work();//一个没有返回值的普通函数
std::thread mythread(do_some_work);//创建thread对象,有参构造,参数为函数
可以通过传入一个没有参数函数指针,去初始化线程对象,线程对象创建完成后即开始,将传入的函数作为线程函数开始运行。
(2).通过仿函数创建线程
class my_funclass
{
public:
void operator() ()const
{
do_something();
}
};
my_funclass f;
std::thread mythread(f);
将一个重载过()的类创建的实例作为std::thread对象构造参数,也可以实现初始化,这里使用的时无参数的传递,有参函数放在了后面,统一说明。
注意:如果你传入的是一个临时变量,而不是一个命名的变量,c++编译器会把其解析为函数声明,而不是类型对象的定义。
std::thread mythread(my_funclass());
这相当于我们声明了一个名为mythread函数,并没有正确初始化。
为了解决这个问题,可以采用以下两种方式:使用多组括号,或者使用统一的初始化语法(大括号代替括号。
std::thread mythread((my_funclass())); //使用多组括号
std::thread mythread{my_funclass()}; // 使用新统一的初始化语法
(3).通过lambda表达式创建线程
std::thread mythread([]() {
do_something();
});
c++11中引入了一个新的功能,lambda表达式,它的功能是允许你编写一个局部函数,并可能捕捉一些局部变量,同时也可以满足避免额外的参数传递的需求。
2.等待线程完成
在线程成功运行成功后,可以通过std::join让线程以加入式运行,还是使用std::detach去分离出去让其自主运行。
2.1 std::m_thread.join
std::m_thread.join调用的过程中,调用他的线程会一直等到对应线程运行结a
需要注意的是一旦用过join(),std::thread的对象就不能再次join加入了,对其使用joinable()时,将返回false。
2.2 std::m_thread.detach
std::m_thread.detach调用时,调用函数时不阻塞,会让线程在后台运行,不会去等待线程结束。
若要使用detach时,必须是可加入的线程,所以在使用detach时也需要对其使用joinable检查。
2.3 特殊情况下的等待
如前所述,需要对一个还未销毁的std::thread对象使用join和detach。
而在使用join的过程中需要精心选取调用join的位置,若是在join调用前,发生了异常,这次join调用将被跳过。
所以当倾向于在没有异常发生的情况下使用join时,需要在异常处理过程中调用join,从而避免生命周期的问题。即只要有退出函数的地方就加入join。
还有另一种解决方式,使用”资源申请即初始化方式“,提供一个类,在类中的析构函数中使用join(),如同下面给出的代码。
class thread_guard
{
std::thread& t;
public:
explicit thread_guard(std::thread &t_):t(t_)
{}
~thread_guard()
{
if (t.joinable())
{
t.join();
}
}
thread_guard(thread_guard const&) = delete;
thread_guard& operator = (thread_guard const&) = delete;
};
这里显示告诉编译器不要生成默认的拷贝构造和拷贝赋值构造,这两个拷贝操作时危险的,可能会弄丢已经加入的线程。
(二).传递参数给线程函数
1.传递参数时需要注意的问题
如果我们在一个可调用对象或函数中使用传递进来的参数时,这个过程基本上就是简单地将额外的参数传递给std::thread构造函数。这个参数会默认的以复制的方式传到线程函数的内部存储空间,即便内部参数需要的是一个引用。
换句话说,在一个函数中创建线程,即使这个函数的参数是非常量引用,但是到了函数再次向thread初始化函数中传递参数的时候,会变成普通参数传递,还是会传一份复制的。
(1).传参时的隐式转换问题
这其中有一种情况为,当需要传递一个string类型的参数时,传入了char型数组,或者字符字面量,他会隐式转化成string对象,但是这个过程就可能出现问题,初始化线程的主线程若是在初始化结束前结束,就会造成错误。
对此的解决方案为,在传参之前显示的先创建string对象,再去传递参数。
void f(int n, string const& str);
void oops(int suma)
{
char buffer[1024];
sprintf(buffer, "%i", suma);
std::thread t(f, 3, std::string(buffer));
}
另一种情况是传参过程中,std::thread的构造函数会无视你提供的的函数期待的参数类型,直接会对参数拷贝。不过在这个拷贝中会将参数作为右值的形式拷贝,这是为了照顾那些只能移动的类型。
(三).线程转让所有权
std::thread对象是可以移动的,但是不可拷贝,可以通过move语义将一个线程的所有权从一个实例转移到另一个实例,但是为一个已经关联了实例的线程对象通过另一个实例赋值就会报错。
可以通过raii思想,创建一个score_guard,当作用域退出时,可以在析构函数中检查并释放对应线程。
对于一些敏感的容器,如vector,在讲vector作为返回值时,会默认调用移动函数,所以可以在线程内部创建线程实例,而后通过vector返回。
(四).运行时决定线程数量
可以通过std::thread::hardware_concurrency()函数来获得能并发在一个程序里的最大线程数量。
(五).标识线程
线程的标识类型为std::thread::id,并可以通过两种方式来检索:
第一种,可以通过调用std::thread对象的成员函数get_id() 来直接获取,如果std::thread对象没有和任何线程实例相关联,这个函数会返回一个默认构造值 std::thread::type,这个值表示"无线程"。
第二种,可以在当前线程中调用std::this_thread::get_id() 也可以获得线程标识。
线程id支持大小比较和排序,也可以作为容器的键值。
(六).总结
本章讨论了c++标准库的基本线程管理方式,包括线程的创建方式,等待结束和不等待结束。并了解了如何在线程启动前,像线程函数中传递参数,如何转移线程的使用权,如何使用线程组来分割任务。最后讨论了线程标识。
二.线程间共享数据
本章主要内容有以下三点:
共享数据带来的问题
使用互斥量保护数据
数据保护的替代方案
(一).共享数据带来的问题
涉及到共享数据的时候,问题就可能是因为共享数据修改所导致的。如果数据是可读的,那么操作不会影响到数据,更不会涉及到数据的更改,所以所有的数据都会获得同样的数据。但是当一个或多个线程要修改共享数据时,就会产生很多麻烦。
(1).条件竞争
并发中竞争条件的形成,取决于一个以上线程的相对执行顺序,每个线程抢着完成自己的任务。大多数情况下为线程执行顺序对结果影响不大的良性竞争,其结果可以接受。但是还有一种特殊的条件竞争:并发的去修改一个独立对象,数据竞争是未定义行为的起因。
(2).避免恶性条件竞争
最简单的办法就是对数据结构采用某种保护机制,确保只有进行修改的线程才能看到不变量被破坏时的中间状态。 从其他访问线程的角度来看,修改不是已经完成了,就是还没开始。
另一个选择是对数据结构和不变量的设计进行修改,修改完的数据必须能完成一系列不可分割的变化,也就是保证每个不变量保持稳定的状态,这就是所谓的无锁编程。不过这种方式很难得到正确得结果。
还有一种就是用事务得方式去处理数据结构得更新,但是c++没对软件事务内存(STM)支持。
保护共享数据结构的最基本方式,是使用c++标准库提供的互斥量。
(二).使用互斥量保护共享数据
1.c++中使用互斥量
c++中通过实例化std::mutex创建互斥量实例,通过成员函数lock()对互斥量上锁,unlock()进行解锁。不过在实际使用过程中,不推荐直接使用成员函数上锁,那意味着退出函数的出口都要调用unlock()。 c++标准库为互斥量提供了一个RAII语法的模板类str::lock_guard ,在构造的时候提供已锁的互斥量,并在析构的时候解锁,从而保证了一个已锁互斥量能够正确解锁。
std::mutex和str::lock_guard都在<mutex>头文件当中。
#include<functional>
#include<iostream>
#include<thread>
#include<mutex>
#include<list>
using namespace std;
std::list<int> some_list;
std::mutex some_mutex;
void add_to_list(int new_value)
{
std::lock_guard<std::mutex> guard(some_mutex);
some_list.push_back(new_value);
}
bool list_contains(int value_to_find)
{
std::lock_guard<mutex> guard(some_mutex);
return std::find(some_list.begin(), some_list.end(), value_to_find) != some_list.end();
}
代码中some_list被互斥量mutex保护,保证同一时间只可能有一个函数对其进行操作。
注意:具有访问能力的指针或者引用可以访问(并修改)比保护的数据,而不会被互斥锁限制。
2.用代码保护共享数据
主要需要注意,切勿将受保护的数据的指针或引用传递到互斥锁作用域之外,无论是函数返回值,还是存储在外部可见内存,异或是以参数的形式传递到用户提供的函数当中。
3.定位接口间的条件竞争
如果对于一个非共享的栈,调用empty()后调用top()是安全的。
但是对于一个共享的栈对象,这样的调用顺序就不再安全了,因为在调用empty()和调用top()之间,可能有来自另一个线程的pop()调用并删除了最后一个元素。这就是一个经典的接口间的条件竞争,即使使用互斥量对栈内部数据进行保护,依旧不能阻止条件竞争的发生,这就是接口固有的问题。
而对于栈的操作,如果将pop和top合成为一个操作,则会发生,若需要拿出的元素太大,拷贝需要的空间很大,就会导致在拷贝时的失败,而后栈删除了这个元素,现在的情况是,我的东西因为拷贝失败没有拿到,栈内的那一份也被删除了。为了避免这个问题,将top和pop分成了两部分,但是这导致了新的条件竞争。
[1].对于接口条件竞争有以下四种解决办法
(1).传入一个引用
第一个办法是将引用作为pop函数的参数,传入pop()函数中获取想要的"弹出值”。
这种方法的缺点是,需要构建出一个栈中类型的实例,用于接收目标值。一方面这可能花费更多资源。另一方面有着其他一些限制,如有的对象的构造函数可能是有参的,这个阶段的代码不一定可用。还有的对象即使支持拷贝构造,或者支持移动构造,有些用户自定义类型可能都不支持赋值操作。
(2).无异常抛出的拷贝构造函数或移动构造函数
虽然安全,但非可靠。尽管能在编译时使用std::is_nothrow_copy_constructible和std::is_nothrow_move_contructible类型特征,让拷贝和移动构造函数不抛出异常,但是这种方式局限性太强。
(3).返回指向弹出值得指针
指针的优势是自由拷贝,并且不会产生异常。
缺点是返回一个指针需要对对象的内存分配进行管理,对于简单数据类型,类型管理的消耗大于直接返回值。
这里推荐使用shared_ptr可以避免内存泄露。
(4)采用选项1 + 2 或者选项1 + 3
为了通用代码的灵活性,可以提供多种选项,然后让用户自己选择哪些是可用的。
[2].线程安全栈的实现
削减接口可以获得最大程度的安全,甚至限制对栈的一些操作,通过限制栈的接口,将栈的接口减少到push(),pop(),empty()。简化接口更有利于数据控制,可以保证互斥量将一个操作完全锁住。
下面代码展示了一个线程安全栈的实现:
#include<stack>
#include<memory>
#include<thread>
#include<mutex>
#include<list>
struct empty_stack : std::exception//线程为空仍出栈时抛出的异常
{
const char* what() const throw() {
return "empty stack!";
}
};
template <class T>
class threadsafe_stack
{
private:
mutable std::mutex m_mutex;//锁住对栈的操作的互斥量,后面在const修饰的empty函数中使用,所以需要声明为mutable
std::stack<T> data;//内部封装的栈
public:
threadsafe_stack() :data(std::stack<T>()){}//无参构造
threadsafe_stack(threadsafe_stack& other)//支持拷贝构造
{
std::lock_guard<std::mutex> lk(m_mutex);
data = other.data;//在锁内部拷贝构造比列表初始化构造更加安全
}
threadsafe_stack& operator =(threadsafe_stack&) = delete;//删除拷贝赋值操作
void push(T new_value)//添加元素
{
std::lock_guard<std::mutex> lk(m_mutex);
data.push(new_value);
}
std::shared_ptr<T> pop()//智能指针版pop
{
std::lock_guard<std::mutex> lk(m_mutex);
if (data.empty()) throw empty_stack();//先检查栈非空再进行操作
std::shared_ptr<T> const res(std::make_shared<T>(data.top()));//先去为栈顶元素申请空间复制
data.pop();//在去弹出栈顶空间
return res;
}
void pop(T& value)//引用版pop
{
std::lock_guard<std::mutex> lk(m_mutex);
if (data.empty()) throw empty_stack();
value = data.top();//先拷贝栈顶实例
data.pop();//再去弹出栈顶实例
}
bool empty() const//查看栈是否为空
{
std::lock_guard<std::mutex> lk(m_mutex);//在const修饰的函数中,使用变量,需要声明为mutable
return data.empty();
}
};
4.死锁
在两个或者两个以上的互斥量锁定一个操作的时候,互斥量的上锁次序不同,就可能导致死锁。
避免死锁的一般建议就是总是以相同的顺序上锁。但是在具体的实现中可能也会遇到各种问题。
但是在c++11中有办法解决这个问题,std::lock 函数可以一次性锁住多个(两个以上)的互斥量,并且没有副作用(死锁风险)。
#include<thread>
#include<mutex>
class big_class;
void swap(int& lh, int& rh);
class big_class
{
std::mutex m;
int a;
public:
friend void swap(big_class &lh,big_class &rh)
{
if (&lh == &rh)
return;
std::lock(lh.m, rh.m);//同时锁住lh和rh内部的互斥量
std::lock_guard<std::mutex> lock_a(lh.m, std::adopt_lock);//将锁的控制权交给lock_guard
std::lock_guard<std::mutex> lock_b(rh.m, std::adopt_lock);//
swap(lh.a, rh.a);
}
};
当lock函数成功获取一个互斥量的锁,并且尝试从另一个互斥量上再获取锁时,就会有异常抛出,第一个锁也会随着异常的产生而自动释放,所以std::lock要么将两个锁都锁住,要不一个都不锁。
c++17中加入了一个std::scoped_lock,与lock_gard功能一样,满足RAII原则,用法同std::lock,同时锁住两个变量,上面的代码可以换成下面的。
void swap(big_class &lh,big_class &rh)
{
if (&lh == &rh)
return;
std::scoped_lock<std::mutex, std::mutex> guard(lh.m, rh.m);
swap(lh.a, rh.a);
}
5.避免死锁的进阶指导
避免嵌套锁
一个线程已获得一个锁时,别再去获取第二个。
避免在持有锁时调用用户提供的代码
因为代码是用户提供的,你没有办法确定用户要做什么
使用固定顺序获取锁
当硬性条件要求你获取两个或两个以上的锁,并且不能使用std::lock单独操作来获取他们,那么最好在每个线程上,使用固定顺序获取他们。
可以采用手递手的方式去获取锁,这种方法也会面对一些问题,当前后两个顺序去获取锁的时候,当手递手到中间的时候就会发生死锁。
这里的解决方法就是定义遍历的顺序,对于一个链表,必须获得上一个节点的锁后才能获取下一个节点的锁。
为了解决这个问题可以使用一种层次锁的数据结构,为每个锁分配一个层级,线程只能获取比当前层次更低的锁,这就可以保证线程始终以一定顺序上锁。
下面是一个简单的层次锁实现:
//有功夫再写
6.std::unique_lock-灵活的锁
std::unique_lock使用更为自由的不变量,这样std::unique_lock实例不会总与互斥量的数据类型相关,使用起来更加灵活。
首先可将std::adopt_lock作为第二个参数传入构造函数,对互斥量进行管理;
也可将std::defer_lock作为第二参数传递进去,表明互斥量应该保持解锁状态。这样就可以被std::unique_lock对象的lock函数获取,或者可以传递std::unique_lock对象到是std::lock()中。
但是在使用std::unique_lock会比std::lock_guard占用更多的空间,并且比std::lock_guard稍慢一些。这就是保证std::unique_lock灵活性的代价。
下面使用std::unique_lock和std::defer_lock代替std::lock_guard和std::adopt_lock实现的使用lock获取两个锁。
#include<thread>
#include<mutex>
class big_class;
void swap(int& lh, int& rh);
class big_class
{
std::mutex m;
int a;
public:
friend void swap(big_class& lh, big_class& rh)
{
if (&lh == &rh)
return;
std::unique_lock<std::mutex> lock_a(lh.m, std::defer_lock);//将锁以解锁状态交给unique_lock
std::unique_lock<std::mutex> lock_b(rh.m, std::defer_lock);//
std::lock(lock_a, lock_b);//同时锁住lh和rh内部的互斥量
swap(lh.a, rh.a);
}
};
7.不同域中的互斥量所有权的传递
std::unique_lock实例没有与自身相关的互斥量,一个互斥量的所有权可以通过移动操作,在不同实例中进行传递。
下面这个例子展示了移动一个互斥量。
#include<thread>
#include<mutex>
std::mutex some_mutex;
std::unique_lock<std::mutex> get_lock()
{
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();
}
代码中使用unique_lock获取了一个互斥量,而后作为返回值通过移动构造传递到process_data函数中。
unique_lock实例在没销毁前也具有释放锁的能力,当锁没有必要持有的时候,可以在特定的代码分支对其进行选择性的释放。这对应用的性能提升很重要,持有锁的时间增加会导致性能下降。
8.锁的粒度
如果很多线程正在等待同一个资源,当有线程持有锁的时间过长,就会增加等待的时间。所以尽量在锁住互斥量的时间只访问共享资源,其余时间释放锁。
对于两个类,假设我们现在要比较他们两个内部元素的大小,就可以有两种方式,一种是同时获得内部的锁,比较完在释放。另一种先锁住一个类,获取他内部的元素,释放锁,再锁住第二个类,获取内部元素,释放锁,而后再比较。
两种方式各有优缺点,第一种虽然效率会降低,但是保证比较的时候没有其他干扰,原因是第二种方法可能在比较之前被修改了,导致结果不正确。
总之,对于不同数据结构,要权衡利弊,合理选择锁的粒度以获得更高性能。
(三).保护共享数据的其他方式
有时我们使用互斥量仅仅是为了保护数据的初始化过程,但是初始化后就没有作用了,反而会对性能有影响。处于这个原因,c++标准提供了一种纯粹保护共享数据初始化过程的机制。
1.保护共享数据的初始化过程
传统方式就是使用互斥量锁住一片区域,包括检查资源是否初始化,初始化资源。
#include<thread>
#include<mutex>
std::shared_ptr<some_resourse> resourse_ptr;
std::mutex resourse_mutex;
void foo()
{
std::unique_lock<std::mutex> lk(resourse_mutex);
if (!resourse_ptr)
{
resourse_ptr.reset(new some_resourse);
}
lk.unlock();
resourse_ptr->do_something();
}
这个实例中,为了保护初始化过程,将检查和初始化过程通过锁住互斥量的方式锁起来了。但是锁住了两行代码,为了获得更好得性能,提出了一种替代方案,“双锁检查模式”:
#include<thread>
#include<mutex>
std::shared_ptr<some_resourse> resourse_ptr;
std::mutex resourse_mutex;
void foo()
{
if (!resourse_ptr)
{
std::unique_lock<std::mutex> lk(resourse_mutex);
if (!resourse_ptr)//2
{
resourse_ptr.reset(new some_resourse);
}
}
resourse_ptr->do_something();
}
这里除了开始检查一次资源以外,在用互斥量中又检查了一次,避免在获取互斥量这段时间有其他线程初始化了资源。但是这也存在一种隐性的条件竞争,第一次的检查没有和2后面的初始化过程同步,即使他知道有一个线程正在进行初始化,但是可能还没有初始化完全,这时他调用do_something就会出现问题。
为了解决上述的问题,c++11提供了std::once_flag 和 std::call_once。
比起锁住互斥量,并显示的检查指针,每个线程只需要使用std::call_once就可以,在std::call_once 的结束时,就能安全的知道指针已经被其他的线程初始化了。
使用std::call_once消耗的资源比互斥量更少,特别是在初始化完成的时候。
#include<thread>
#include<mutex>
std::shared_ptr<some_resourse> resourse_ptr;
std::once_flag resource_flag;//1
void init_resourse()//2
{
resourse_ptr.reset(new some_resourse);
}
void foo()
{
std::call_once(resource_flag, init_resourse);//3
resourse_ptr->do_something();
}
代码中使用3去延迟初始化资源,可以保证只初始化一次资源,并且其他线程看见的时候就是初始化好的资源。
2.保护不常更新的数据结构
对于像dns映射表这种不经常更新但是读取频繁的数据结构,我们可以采用c++标准提供的共享锁。
c++17标准库提供了std::shared_mutex和std::shared_timed_mutex,c14中只提供了后一种,c++11中没有提供。如果是在支持c++14之前标准的编译器,可以使用boost库中实现的互斥量。
std::shared_mutex和std::shared_timed_mutex的区别就是,后者支持更多的操作,但是前者有更高的性能优势。
对于读者,可以使用c++14中的std::shared_lock<std::shared_mutex> 去获取一个共享锁,他的使用方法和std::unique_lock相同。
写者则通过,std::unique_lock<std::shared_mutex> 获得互斥量,保证只有一个线程可以进行操作。
当任意一个线程拥有一个共享锁时,这个线程就会尝试获取一个独占锁,直到其他线程放弃他们的共享锁;同样的,当一个线程获取一个独占锁时,其他线程就无法获得共享锁或独占锁,直道第一个线程放弃其拥有的锁。
#include<string>
#include<mutex>
#include<map>
#include<shared_mutex>
class dns_entry;
class dns_cache
{
std::map<std::string, dns_entry> entries;
mutable std::shared_mutex entry_mutex;
public:
dns_entry find_entry(std::string const &domain) const
{
std::shared_lock<std::shared_mutex> sk(entry_mutex);//1
std::map<std::string, dns_entry>::const_iterator const ite = entries.find(domain);
return ite = entries.end() ? dns_entry() : ite->second;
}
void update_or_add_entry(std::string const&demain,
dns_entry const &dns_datails)
{
std::unique_lock<std::shared_mutex> uk(entry_mutex);//2
entries[demain] = dns_datails;
}
};
代码中find_entry通过使用std::shared_lock<>来保护共享和只读权限(1),这就可以使多线程同时使用find_entry而不会报错。另一方面,update_or_add_entry()使用std::lock_guard<>实例,当表格需要更新时,为其提供访问独占权限,阻止其他线程访问。
3.嵌套锁
在实际使用时,一个线程尝试获取一个互斥量多次,而没有对其进行一次释放是可以的。这是因为c++标准库提供了std::recursive_mutex类。除了可以对单个线程获取多个锁,其余功能与mutex相同。也可以使用std::lock_guard<>和std::unique_lock<>防止锁未释放。
三.同步并发操作
本章主要内容:
等待事件
带有期望值的等待一次性事件
在限定时间内等待
使用同步操作简化代码
(一).等待一个事件或者其他条件
当一个线程需要等待另外一个线程完成任务时,他会有很多选择。
第一种:持续的检查共享数据标志(用于保护工作的互斥量),知道另一个线程完成任务时对这个标志进行重设。两个线程同时对中间的互斥量访问反而会让性能更差。
第二种:使用std::this_thread::sleep_for()进行周期性的间歇,但是这种方式的睡眠时间不好把握,太长太短都不合适。
第三种(优先考虑):使用c++标准库的“条件变量”机制去等待事件发生。
1.等待条件达成
c++标准库对于条件变量有两套实现,std::condition_variable和std::condition_variable_any。
两者的头文件都在<contion_variable>头文件的声明中。他们都需要与一个互斥量一起才能工作(互斥量是为了同步); 前者仅限于与std::mutex一起工作,后者可以和任何满足最低要求的互斥量一起工作,从而加上了_any的后缀。因为后者的灵活性,导致后者有着更大的额外开销,所以一般把std::condition_variable作为首选的类型,对灵活性有很大要求时才会使用后者。