目录
前言
用互斥保护共享数据
接口设计
死锁
防范死锁的准则
灵活加锁
互斥归属权的转移
合适的加锁粒度
共享数据初始化的保护
保护较少更新的数据
递归加锁
总结
在线程之间共享数据,我们需要关注:具体哪个线程用什么方式访问了什么数据;数据改动后,如果牵涉到其它线程,它们要在何时以何种方式获得通知。同一个进程内的多个不同的线程不正确地使用共享数据,容易产生错误。当然只读数据不会造成这些影响。
不变量是针对某特定数据的断言,该断言总是成立,如:“这个变量的值就是链表元素的数目。”数据更新往往会破坏这些不变量。改动线程数据,最简单的问题是破坏不变量,假如某线程正在读取双向链表,另一线程同时在删除节点,在缺乏安全措施的情况下,执行读取操作的线程可能遇见没完全删除的节点(只改变了前面的正向指针,后面的逆向指针还没改),不变量遂被破坏。若此时另一线程同样需要修改链表,容易造成链表损坏。
并发编程中,操作由两个或多个线程负责,它们争先让线程执行各自的操作,而结果取决于它们执行的相对次序,所有这种情况都是条件竞争(race condition)。
防止恶性条件竞争的方法:
1.不变量被破坏时,中间状态只对执行改动的线程可见。
2.无锁编程,修改数据结构的设计及其不变量,由一连串不可拆分的改动完成数据变更,每个改动都维持变量不被破坏。
3.修改数据结构当作事务(transaction)来处理,把需要执行的数据读写操作视为一个完整的序列,先日志存储记录,再把序列当成单一步骤提交运行。若别的线程改变了数据令提交无法完整执行,则事务重新开始。称为软件事务内存(Software Transactional Memory, STM)。
运用名为互斥(mutual exclusion,略作mutex)的同步原语(symchronization primitive)来实现数据互斥:某线程访问一个数据结构前,先锁住与数据相关的互斥;访问结束后,再解锁。其它线程必须等待该线程解锁,才能给它加锁。这样确保了除了正在改动数据的线程,其它线程无法看见不变量被破坏,形成自洽(self-consistent)的共享数据。
在
#include
#include
#include
std::vector v;
std::mutex m;
void add(int val)
{
std::lock_guard guard(m);
v.push_back(val);
}
bool contains(int val)
{
std::lock_guard guard(m);
return std::find(v.begin(), v.end(), val) != v.end();
}
以上例子在add()和contain()两个函数中创建了std::lock_guard对象,使得两个函数对链表的访问互斥。C++17引入了类模版参数推导(class template argument deduction),可以省略std::lock_guard的模版参数:“std::lock_guard guard(m)”。
一般会创建一个类,将受保护的数据结构作为其数据成员,并在成员函数中使用访问互斥。
注意事项:但是如果成员函数返回指针或引用,指向受保护的共享数据,那么只要存在访问该指针或引用的代码,就能访问或修改受保护的数据,无须锁定互斥;如果成员函数内部调用了其它不受掌控的函数,并传入了共享数据的指针或引用,同样危险。所以设计接口需要注意是否存在此类漏洞,我们应该遵循:不得向锁所在作用域之外传递指向受保护数据的指针和引用。
让线程在不同数据上运行相同代码是常用的提升性能的方法,共享的栈容器是理想的工具。
stack s;
if (!s.empty())
{
const int value = s.top();
s.pop();
do_something();
}
在栈结构中,如果使用多线程处理栈数据,并涉及数据共享,这一连串调用并不安全:在empty()和top()之间,可能有另一个线程调用了pop(),在空栈上调用pop()导致未定义行为。
有一种方法是在if语句中捕捉异常,一旦空栈调用top()就抛出异常,这样做即使if语句满足条件,仍然可能抛出异常,if语句就成了优化手段。
假设栈内部有互斥保护,任何时刻只准许单一线程运行其成员函数,函数调用交错有秩,但是top()和pop()之间会存在潜在的条件竞争:
线程A | 线程B |
if(!s.empty()) | |
if(!s.empty()) | |
const int val = s.top(); | |
const int val = s.top(); | |
s.pop() | |
do_something(val) | s.pop() |
do_something(val) |
我们会发现,pop调用了两次,线程B调用pop()弹出的元素未被读取。此时我们会想,如果把top()和pop()合二为一,组合成一个函数,功能是从栈上移除栈顶元素,并返回栈顶元素的值,来解决这一问题。std::stack的设计者考虑到,如果只有栈被改动后,弹出的元素才返回给调用者,在向调用者复制数据的过程中,可能抛出异常导致复制不成功(弹出的元素已从栈上移除),数据会丢失,故把操作分开成两个函数,即便我们无法安全地复制数据,数据还是会留在栈上。
此时我们有几种不完美的方法消除top()和pop()的条件竞争(在函数内部进行数据复制):
1.传入引用。借用一个外部变量接收弹出的元素,以引用pop(int &)的方式传入参数。此方法需要事先构建一个栈元素型别的实例,且要求该型别可赋值(assignable)。
2.元素提供不抛出异常的拷贝/移动构造函数。使用std::is_northrow_copy_constructible和std::is_northrow_move_constructible这两个型别特征(type trait)可以在编译期对某个型别作出判断,但有些类别无法用栈存储。
3.返回指针,指向弹出元素。优点是可以自由复制,且不抛出异常。std::shared_ptr会是不错的选择,系统自动管理内存分配,省去new和delete操作。
我们可以结合方法1和2或1和3。下面的例子分别实现了1和3:
#include
#include
#include
#include
struct empty_stack : std::exception
{
const char* what() const throw();
};
template
class threadsafe_stack
{
private:
std::stack data;
mutable std::mutex m;
public:
threadsafe_stack(){}
threadsafe_stack(const threadsafe_stack& other)
{
std::lock_guard lock(other.m);
data = other.data;
}
threadsafe_stack& operator=(const threadsafe_stack&) = delete;//删除赋值运算操作
void push(T new_val)
{
std::lock_guard lock(m);
data.push(std::move(new_val));
}
//方法一
void pop(T& val)
{
std::lock_guard lock(m);
if (data.empty()) throw empty_stack();
val = data.top();
data.pop();
}
//方法三
std::shared_ptr pop()
{
std::lock_guard lock(m);
if (data.empty()) throw empty_stack();
const std::shared_ptr res(std::make_shared(data.top()));
data.pop();
return res
}
bool empty()const
{
std::lock_guard lock(m);
return data.empty();
}
};
上面的安全栈类可以复制,先在源对象上锁住互斥,再复制内部的std::stack,注意此处不使用初始化列表,保证互斥的锁定会横跨整个复制的过程。
假设有两个线程,都需要同时锁住几个互斥才能进行某项操作,但他们都分别只锁住了一部分,另一部分被对放锁住,于是双方都在等待对方解锁,此时就会引发死锁(deadlock)。
通常防范的建议是,始终按照一定的顺序对互斥加锁。若某函数的参数是同一个类的两各不同实例,我们始终按函数的参数顺序进行加锁(如先给第一个参数的实例上锁,再给第二个),由于封装特性,用户并不了解内部实现,用户同时调用两个该函数,它们接收参数对应的实例顺序相反,仍会引发死锁。
C++标准库提供了std::lock(),可以同时锁住多个互斥,解决这一问题:
class A;
void swap(A& la, A& ra);
class X
{
public:
X(const A& a):m_a(a){}
friend void swap(X& lx, X& rx)
{
if (&lx == &rx) return;
std::lock(lx.m, rx.m);
std::lock_guard lock_l(lx.m, std::adopt_lock);
std::lock_guard lock_r(rx.m, std::adopt_lock);
swap(lx.m_a, rx.m_a);
}
private:
A m_a;
std::mutex m;
};
上述友元函数swap内先比较了两个输入参数的实例,确保它们指向不同的实例,避免std::lock重复上锁导致未定义行为。然后调用std::lock锁住两个互斥,并分别构造 std::lock_guard实例,构造函数中额外提供std::adopt_lock对象,指名互斥已被锁住,构造函数内不得另行加锁。
假如std::lock已成功上锁其中一个互斥,但在另一个互斥上锁时报错,则第一个锁也会被释放,多个互斥的语义是”全员共同成败“(all-or-nothing或全部锁定,或没有锁定抛出异常)。
C++17提供了RAII类模板std::scoped_lock<>,可以接收各种互斥型别作为模板参数列表,还能以多个互斥对象作为构造函数的参数列表。上述友元函数可以改写:
friend void swap(X& lx, X& rx)
{
if (&lx == &rx) return;
std::scoped_lock guard(lx.m, rx.m);
swap(lx.m_a, rx.m_a);
}
C++17的隐式类模板参数推导机制,根据传入的参数匹配正确的型别。
有时候即便没有上锁,也会发生死锁现象,比如两个线程分别关联了两个std::thread实例,。我们同时获取多个锁可以防范死锁,但若代码分别获取,就需要注意一些规则。
1. 避免嵌套锁。若已经持有锁,就尽量不要试图获取第二个锁,万一需要多个锁,则采用std::lock或std::scoped_lock<>同时对多个互斥上锁。
2.持有锁就必须避免调用用户提供的程序接口。用户提供的接口可能试图获取锁,导致嵌套锁,可能发生死锁。
3.固定顺序获取锁。前文已经提到这种直观的方法,若需要加多个锁,但无法通过std::lock一步获取全部锁,则需要按固定顺序。比如:在链表中,给每个节点配备互斥,线程访问链表就要获取相关节点的互斥锁。删除节点时,必须获取当前节点及相邻两个节点的锁,确保它们不被其它线程改变;遍历链表时,线程必须获取当前节点的锁,同时在后续节点上获取锁,确保前向指针不被改动,一旦获取后续节点的锁,当前节点的锁遂可释放。但是,若两个线程分别在相反方向上遍历链表,在交汇时会发生死锁。此时需要规定遍历方向进行避免。
4.按层级加锁。按特定次序规定加锁顺序。把应用程序分层,明确每个互斥位于哪个层级,若某线程已对低级互斥加锁,则不允许再对高级互斥加锁,具体是将层级编号赋予对应层级应用上的互斥。可惜C++库尚未直接支持这种方式,需要自行编写互斥型别hierarchical_mutex。
class hierarchical_mutex
{
std::mutex internal_mutex;
unsigned int long const h_val;//需要上锁的互斥层级(hierarchical value)
unsigned int long previous_h_val;//记录上一次的层级
static thread_local unsigned long this_thread_h_val;//当前层级,静态保持在不同函数域中不变
void check_h_violation()//检查当前层级是否大于要加锁的层级,否 则无法上锁
{
if (this_thread_h_val <= h_val)
{
throw std::logic_error("Mutex hierarchy violated!");
}
}
void update_h_val()
{
previous_h_val = this_thread_h_val;
this_thread_h_val = h_val;
}
public:
explicit hierarchical_mutex(unsigned long val):h_val(val),previous_h_val(0){}
void lock()
{
check_h_violation();
internal_mutex.lock();//检查完成后加锁
update_h_val();
}
void unlock()
{
if (this_thread_h_val != h_val)//避免层级混乱,确保解锁的互斥时最后一个上锁的互斥
throw std::logic_error("Unlock mutex hierarchy error!");
this_thread_h_val = previous_h_val;//解锁时层级复原
internal_mutex.unlock();
}
bool try_lock()//与lock相同原理,会返回是否成功
{
check_h_violation();
if (!internal_mutex.try_lock()) return false;
update_h_val();
return true;
}
};
thread_local unsigned long hierarchical_mutex::this_thread_h_val(ULONG_MAX);//初始对象设置层级最高,可对任意互斥加锁
上述例子中,在内部互斥加锁完成后更新层级,在解锁完成前更新层级,内部互斥保护到位。
以下时简单的应用例子:
hierarchical_mutex high_level_mutex(10000);
hierarchical_mutex mid_level_mutex(5000);
hierarchical_mutex low_level_mutex(1000);
void low_func()
{
std::lock_guard lg(low_level_mutex);
...
}
void thread_a()//先对高层级互斥high_level_mutex加锁,再调用low_func对低层加锁,可以正常运行
{
std::lock_guard lg(high_level_mutex);
low_func();
}
void thread_b()//先中层级加锁,再调用线程a,a中需要对高层加锁,运行出错
{
std::lock_guard lg(mid_level_mutex);
thread_a();
}
若将此方法应用于单向链表的遍历,则前驱节点的层级必须高于当前节点层级。
std::unique_lock<>对象与std::lock_guard<>一样,依据互斥作为参数,但其对象不一定始终占据与之关联的互斥,在构造其对象时,第二各参数可以传入std::adopt_lock(管理互斥上的锁)或std::defer_lock实例(在构造时互斥保持无锁状态),我们可以改写之前的代码:
friend void swap(X& lx, X& rx)
{
if (&lx == &rx) return;
std::unique_lock lock_l(lx.m, std::defer_lock);
std::unique_lock lock_r(rx.m, std::defer_lock);
std::lock(lock_l, lock_r);
swap(lx.m_a, rx.m_a);
}
std::unique_lock实例底层与互斥关联,与互斥同样具有lock、unlock和try_lock成员函数,可以传给std::lock()函数,其实例还有一个内部标志,表明关联的互斥是否被该实例锁占据,保证只有占用互斥时才能进行unlock(),可以通过成员函数owns_lock()查询。因此std::unique_lock相比std::lock_guard<>需要占据更多的存储空间,因此若无特殊需求,优先采用std::lock_guard<>。std::unique_lock拥有类似互斥的成员函数用于加锁和解锁,故可以在对象销毁前进行解锁,更加灵活。
std::unique_lock可以不占有与之关联的互斥,所以互斥归属权可以在多个std::unique_lock实例之间进行转移。std::unique_lock可转移不可复制,注意转移时左值(实在的变量或指向真实变量的引用)需要显式调用std::move()。
std::unique_lock get_lock()
{
std::mutex m;
std::unique_lock ul1(m);
prepare_data();
return ul1;
}
void thread_a()
{
std::unique_lock ul2(get_lock());//局部变量无需std::move()
...
}
上述get_lock函负责准备前期工作 ,并把互斥归属权由ul1转移给thread_a的ul2,线程后续进行相关工作,别的线程无法改动互斥保护的数据。
锁粒度表述一个锁保护的数据量,粒度精细的锁保护少量数据,粒度粗大的锁保护大量数据。锁粒度够大能保证数据得到保护,锁粒度够细则各线程等待时间会减少,因此我们应该权衡安全性与性能,仅仅在必要时才锁住互斥,让数据尽可能不用锁保护。我们可以使用std::unique_lock管理互斥,在需要时lock,不需要互斥加锁时unlock。
下面的例子在比较运算的过程中,每次只对一个对象的数据上锁:
class Y
{
int val;
mutable std::mutex m;
int get_val()const
{
std::lock_guard lg(m);
return val;
}
public:
Y(int value):val(value){}
friend bool operator==(const Y& ly, const Y& ry)
{
if (&ly == &ry) return true;
const int val_l = ly.get_val();
const int val_r = ry.get_val();
return val_l == val_r;
}
};
get_val()函数先锁住一个目标对象,返回其值后,lg销毁解锁,再用get_val()复制另一个对象的值。但我们要注意在单独复制一个值期间,另一个值是否会被其它线程改变引发错误。
共享数据的创建需要较多的开销,因为创建它可能需要建立建立数据库连接或分配大量内存,只有必要时才真正创建,这种方式成为延迟初始化(lazy initialization)。
std::shared_ptr resource_ptr;
std::mutex m;
void foo()
{
std::unique_lock ul(m);
if (!resource_ptr)
{
resource_ptr.reset(new some_resource);//reset指针转shared_ptr
}
ul.unlock();
resource_ptr->do_something();
}
上述每个线程都必须在互斥上轮候,等待查验数据是否已经完成初始化。 且在foo()函数刚进入时,若另一线程已上锁,此时该函数阻塞,等到另一线程完成共享指针初始化,解开锁,并调用do_something()操作改动实例,此时foo()才开始执行,由于指针非空,直接调用do_something(),但函数对实例初始值已被改动不知情,会引发错误。
C++标准库提供了std::once_flag类和std::call_once()函数,std::call_once()接收断言(predicate,又称为“谓词”,在C++语境下,它是函数或可调用对象)作为参数,可以令所有线程共同调用std::call_once()函数,从而确保在该调用返回时,指针初始化由其中某线程安全且唯一地完成(同步机制)。必要的同步数据会由std::once_flag实例存储,相比显式互斥,开销更低。(std::once_flag与std::mutex相似,既不可复制也不可移动)上述函数可以改写为:
std::shared_ptr 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();
}
对于某个类的数据成员,我们也可以用相似的方式安全地进行延时初始化:
class A
{
private:
B b;
C c;//假设C类中有send和receive成员函数
std::once_flag c_init_flag;
void init_c()
{
this->c = open(this->b);
}
public:
A(const B& b_):b(b_){}
void send(const Data& data)
{
std::call_once(c_init_flag, init_c, this);
c.send(data);
}
Data receive()
{
std::call_once(c_init_flag, init_c, this);
return c.receive();
}
};
上述例子中,数据成员c需要特殊的方式init_c()进行初始化,它会在第一次调用send()或receive()中进行初始化,因为在初始化函数中需要用到this指针,故向std::call_once传递this指针作为附加参数。
对于静态数据成员或静态实例,C++11标准了保证线程安全的初始化,可用一个函数代替std::call_once():
class A;
A& get_instance()
{
static A instance;
return instance;
}
除了在初始化过程中保护数据,保护很少更新的数据更为普遍,它们大多时候处于只读状态,因此可以被多个线程并发访问。我们需要一种新的互斥,允许单个线程“写线程”排他地访问,也允许多个“读线程”并发访问。
C++17中引入了std::shared_mutex和std::shared_timed_mutex,后者支持更多操作(后续章节介绍)。与std::muetx类似,可用std::guard_lock
若别的线程试图获取排他锁,其它共享锁和排他锁的线程都会阻塞。
我们知道,如果某个线程已经持有std::muetx实例,试图对其进行重复加锁会引发未定义错误,但在某些场景中,我们需要让线程在同一互斥上多次重复加锁,而无须解锁。当然,在另一线程访问数据前,加上的锁必须全部完成解锁。假设我们要设计一个支持多线程并发访问的类,它就需要包含互斥来保护数据成员,假设每个公有函数都需要锁住互斥,在一些公有函数内部调用了别的公有函数,别的公有函数同样试图锁住互斥,此时便需要重复锁住互斥。
C++标准库提供了std::recursive_mutex,与上述的std::shared_mutex、std::mutex类似,可以使用std::guard_lock
这种递归加锁的函数设计并不推荐,实际上在嵌套的函数调用中,往往可以提取它们的公共部分作为一个新的私有函数,新函数由这两个公有函数调用,且它假定互斥已被锁住。
std::mutex m 声明互斥对象。
std::lock_guard<> 依据互斥作为参数的类模板,在构造和销毁时,分别执行互斥的加锁与解锁。std::adopt_lock参数可以指名互斥对象已上锁。
std::scoped_lock<> RAII手法管理的类模板,用于同时对多个互斥上锁。
避免死锁方法:1.避免嵌套锁(需要则同时上锁)。2.上锁后避免调用用户的接口。3.按顺序加锁与解锁(人工方法、使用层级)。
std::unique_lock可以进行互斥归属权的转移,拥有lock、unlock和try_lock成员函数,可进行灵活解锁,构造时可以传入std::adopt_lock,或std::defer_lock保持无锁。
std::call_once(std::once_flag, ...)方法用于数据初始化时,仅由一个线程完成。
共享互斥:std::shared_mutex。共享锁实例:std::shared_lock
递归锁互斥:std::recursive_mutex。