1. Mutex
1.1 std::mutex
std::mutex 是C++11中最基本的互斥量,std::mutex对象提供了独占所有权的特性——即不支持递归地对 std::mutex 对象上锁,而 std::recursive_lock 则可以递归地对互斥量对象上锁。
成员函数:
构造函数 —— std::mutex不允许拷贝构造,也不允许move拷贝,最初产生的mutex对象是处于unlocked状态的;lock() —— 调用线程将锁住该互斥量。线程调用该函数会发生下面 3 种情况: (1) 如果该互斥量当前没有被锁住,则调用线程将该互斥量锁住,直到调用 unlock之前,该线程一直拥有该锁; (2) 如果当前互斥量被其他线程锁住,则当前的调用线程被阻塞住; (3) 如果当前互斥量被当前调用线程锁住,则会产生死锁(deadlock)。unlock() —— 解锁,释放对互斥量的所有权;try_lock() —— 尝试锁住互斥量,如果互斥量被其他线程占有,则当前线程也不会被阻塞。线程调用该函数也会出现下面 3 种情况: (1) 如果当前互斥量没有被其他线程占有,则该线程锁住互斥量,直到该线程调用 unlock 释放互斥量; (2) 如果当前互斥量被其他线程锁住,则当前调用线程返回 false,而并不会被阻塞掉; (3) 如果当前互斥量被当前调用线程锁住,则会产生死锁(deadlock)。
#include <iostream>
#include <thread>
#include <mutex>
std::mutex mtx;
void print_block(int n, char c)
{
mtx.lock();
for(int i = 0; i < n; ++i)
{
std::cout << c;
}
std::cout << '\n';
mtx.unlock();
}
int main ()
{
std::thread th1(print_block, 50, '*');
std::thread th2(print_block, 50, '$');
th1.join();
th2.join();
return 0;
}
1.2 std::recursive_mutex
其工作方式与std::mutex相似,不同之处是,其允许同一线程对某互斥的同一实例多次加锁。
1.3 std::time_mutex
其工作方式与std::mutex相似,多了两个成员函数,try_lock_for(),try_lock_until()
try_lock_for() —— 函数接受一个时间范围,表示在这一段时间范围之内线程如果没有获得锁则被阻塞住(与 std::mutex 的 try_lock() 不同,try_lock 如果被调用时没有获得锁则直接返回 false),如果在此期间其他线程释放了锁,则该线程可以获得对互斥量的锁,如果超时(即在指定时间内还是没有获得锁),则返回 false;try_lock_until() —— 函数则接受一个时间点作为参数,在指定时间点未到来之前线程如果没有获得锁则被阻塞住,如果在此期间其他线程释放了锁,则该线程可以获得对互斥量的锁,如果超时(即在指定时间内还是没有获得锁),则返回 false。
#include <iostream>
#include <chrono>
#include <thread>
#include <mutex>
std::timed_mutex mtx;
void fireworks()
{
while (!mtx.try_lock_for(std::chrono::milliseconds(200))) {
std::cout << "-";
}
std::this_thread::sleep_for(std::chrono::milliseconds(1000));
std::cout << "*\n";
mtx.unlock();
}
int main ()
{
std::thread threads[10];
for (int i=0; i<10; ++i)
threads[i] = std::thread(fireworks);
for (auto& th : threads) th.join();
return 0;
}
1.4 std::recursive_timed_mutex
其工作方式与time_mutex相似,不同之处是,其允许同一线程对某互斥的同一实例多次加锁。
1.5 std::shared_mutex
std::shared_mutex C++17中引入,用于管理可转移和共享所有权的互斥对象,适用场景比较特殊:一个或多个读线程同时读取共享资源,且只有一个写线程来修改这个资源,这种情况下才能从shared_mutex获取性能优势。
共享锁仅有一个限制,即假设它已被某些线程所持有,若别的线程试图获取排他锁,就会发生阻塞,直到那些线程全都释放该共享锁。反之,如果任一线程持有排他锁,那么其他线程全都无法获取共享锁或排他锁,直到持锁线程将排他锁释放为止。
- 更新操作可用
std::lock_guard<std::shared_mutex> 或std::unique_lock<std::shared_mutex> 锁定; - 对于那些无须更新数据结构的线程,可以另行改用共享锁
std::shared_lock<std::shared_mutex> 实现共享访问
#include <map>
#include <string>
#include <mutex>
#include <shared_mutex>
class dns_entry
{};
class dns_cache
{
std::map<std::string,dns_entry> entries;
std::shared_mutex entry_mutex;
public:
dns_entry find_entry(std::string const& domain)
{
std::shared_lock<std::shared_mutex> lk(entry_mutex);
std::map<std::string,dns_entry>::const_iterator const it=
entries.find(domain);
return (it==entries.end())?dns_entry():it->second;
}
void update_or_add_entry(std::string const& domain,
dns_entry const& dns_details)
{
std::lock_guard<std::shared_mutex> lk(entry_mutex);
entries[domain]=dns_details;
}
};
此例中,find_entry()函数只读取数据,采用std::shared_lock<> 实例保护共享的、只读的访问; update_or_add_entry()函数中,需要修改数据,采用 std::lock_guard<> 实例进行排他访问
2. lock类
2.1 std::lock_guard
针对互斥类融合实现了RAII手法:在构造时给互斥加锁,在析构时解锁,从而保证互斥总被正确解锁。
在C++中,我们通过构造std::mutex的实例来创建互斥,调用成员函数lock()对其加锁,调用unlock()解锁。但我不推荐直接调用成员函数的做法。原因是,若按此处理,那我们就必须记住,在函数以外的每条代码路径上都要调用unlock(),包括由于异常导致退出的路径。取而代之,C++标准库提供了类模板std::lock_guard
#include <iostream>
#include <thread>
#include <mutex>
#include <stdexcept>
std::mutex mtx;
void print_even (int x) {
if (x%2==0) std::cout << x << " is even\n";
else throw (std::logic_error("not even"));
}
void print_thread_id (int id) {
try {
std::lock_guard<std::mutex> lck (mtx);
print_even(id);
}
catch (std::logic_error&) {
std::cout << "[exception caught]\n";
}
}
int main ()
{
std::thread threads[10];
for (int i=0; i<10; ++i)
threads[i] = std::thread(print_thread_id,i+1);
for (auto& th : threads) th.join();
return 0;
}
指针或引用问题:
利用互斥保护共享数据并不太简单,我们不能把std::lock_guard对象化作“铁拳”,对准每个成员函数施予“重击”。一旦出现游离的指针或引用,这种保护就全部形同虚设。不管成员函数通过什么“形式”——无论是返回值,还是输出参数(out parameter)——向调用者返回指针或引用,指向受保护的共享数据,就会危及共享数据安全。
上例中,我们遗漏了foo(),调用unprotected->do_something()的代码未被标记。无奈,C++线程库对这个问题无能为力;只有靠我们自己——程序员——正确地锁定互斥,借此保护共享数据。从乐观的角度看,只要遵循下列指引即可应对上述情形:不得向锁所在的作用域之外传递指针和引用,指向受保护的共享数据,无论是通过函数返回值将它们保存到对外可见的内存,还是将它们作为参数传递给使用者提供的函数。
多个互斥对象问题:
C++标准库提供了std::lock()函数,专门解决这一问题。它可以同时锁住多个互斥,而没有发生死锁的风险。
class some_big_object
{};
void swap(some_big_object& lhs,some_big_object& rhs)
{}
class X
{
private:
some_big_object some_detail;
mutable 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::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_guard实例;std::adopt_lock 指明互斥锁已经被std::lock锁住,lock_guard构造函数中不需要再次加锁。
C++17还进一步提供了新的RAII类模板std::scoped_lock<> 。std:: scoped_lock<> 和std::lock_guard<> 完全等价,只不过前者是可变参数模板(variadic template),接收各种互斥型别作为模板参数列表,还以多个互斥对象作为构造函数的参数列表。
上例中swap()函数中代码可以改为:
friend void swap(X& lhs, X& rhs)
{
if(&lhs==&rhs)
return;
std::scoped_lock guard(lhs.m, rhs.m);
swap(lhs.some_detail,rhs.some_detail);
}
2.2 std::scoped_lock
C++17进一步提供了新的RAII类模板std::scoped_lock<> 。std:: scoped_lock<> 和std::lock_guard<> 完全等价,只不过前者是可变参数模板(variadic template),接收各种互斥型别作为模板参数列表,还以多个互斥对象作为构造函数的参数列表。
2.3 std::unique_lock
- 与lock_guard类似,RAII机制;
- 与lock_guard不同,可以随时加锁解锁;
- std::unique_lock实例不占有与之关联的互斥,所以随着其实例的转移,互斥的归属权可以在多个std::unique_lock实例之间转移。转移会在某些情况下自动发生,譬如从函数返回实例时,但我们须针对别的情形显式调用std::move()。
可以随时加锁解锁的示例:
#include <iostream>
#include <memory>
#include <mutex>
#include <thread>
int v = 1;
void critical_section(int change_v){
static std::mutex mtx;
std::unique_lock<std::mutex> lock(mtx);
v = change_v;
printf("%d\n", v);
lock.unlock();
lock.lock();
v += 1;
printf("%d\n", v);
}
int main() {
std::thread t1(critical_section, 2), t2(critical_section, 3);
t1.join();
t2.join();
}
unique_lock的第二个参数:
std::adopt_lock ,同lock_guard的第二个参数,表示互斥量已经被lock了(程序员必须把互斥量先lock,否则会报异常);std::try_to_lock ,尝试用mutex中的lock()去锁定这个mutex,但如果没有锁定成功,也会立刻返回,并不会阻塞;std::defer_lock ,延后锁,不能先lock(),否则汇报异常
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::lock(lock_a,lock_b);
swap(lhs.some_detail,rhs.some_detail);
}
std::unique_lock 占用更多的空间,也比std::lock_guard 略慢。但std::unique_lock 对象可以不占有关联的互斥,具备这份灵活性需要付出代价:需要存储并且更新互斥信息。 所以,如果std::lock_guard 已经能满足所需,我建议优先采用。话说回来,有一些情形需要更灵活的处理方式,则std::unique_lock类更为合适。延时加锁即属此例。另一种情形是,需要从某作用域转移锁的归属权到其他作用域。
3. std::once_flag类
以线程安全的懒汉式单例模式为例
#include "singleton.h"
Singleton *Singleton::m_pSingleton = nullptr;
mutex Singleton::m_mutex;
Singleton *Singleton::GetInstance()
{
if (m_pSingleton == nullptr) {
std::lock_guard<std::mutex> lock(m_mutex);
if (m_pSingleton == nullptr) {
m_pSingleton = new Singleton();
}
}
return m_pSingleton;
}
加锁影响效率。也有可能诱发恶性条件竞争。 当前线程在锁保护范围外读取指针①,而对方线程却可能先获取锁,顺利进入锁保护范围内执行写操作③,因此读写操作没有同步,产生了条件竞争,既涉及指针本身,还涉及其指向的对象。尽管当前线程能够看见其他线程写入指针,却有可能无视新实例Singleton的创建。
C++标准委员会相当重视以上情况,在C++标准库中提供了std::once_flag 类和std:: call_once() 函数,以专门处理该情况。上述代码先锁住互斥,再显式检查指针,导致问题出现。我们对症下药,令所有线程共同调用std::call_once()函数,从而确保在该调用返回时, 指针初始化由其中某线程安全且唯一地完成(通过适合的同步机制)。必要的同步数据则由std::once_flag实例存储,每个std::once_flag实例对应一次不同的初始化。相比显式使用互斥,std::call_once()函数的额外开销往往更低,特别是在初始化已经完成的情况下,所以如果功能符合需求就应优先使用。
#include "singleton.h"
Singleton *Singleton::m_pSingleton = nullptr;
std::once_flag flag;
Singleton *Singleton::GetInstance()
{
std::call_once(flag, []() {
m_pSingleton = new Singleton();
});
return m_pSingleton;
}
如果把局部变量声明成静态数据,那样便有可能让初始化过程出现条件竞争。根据C++标准规定,只要控制流程第一次遇到静态数据的声明语句,变量即进行初始化。若多个线程同时调用同一函数,而它含有静态数据,则任意线程均可能首先到达其声明处,这就形成了条件竞争的隐患。C++11标准发布之前,许多编译器都未能在实践中正确处理该条件竞争。其原因有可能是众多线程均认定自己是第一个,都试图初始化变量;也有可能是某线程上正在进行变量的初始化,但尚未完成,而别的线程却试图使用它。 C++11解决了这个问题,规定初始化只会在某一线程上单独发生,在初始化完成之前,其他线程不会越过静态数据的声明而继续运行。 于是,这使得条件竞争原来导致的问题变为,初始化应当由哪个线程具体执行。
class Singleton
{
protected:
Singleton(){};
public:
static Singleton& getInstance() {
static Singleton instance;
return instance;
}
};
4. 预防死锁
防范死锁的准则最终可归纳成一个思想:只要另一线程有可能正在等待当前线程,那么当前线程千万不能反过来等待它。
具体内容参见《C++并发编程实战(第2版)》3.2.5小节。
4.1 避免嵌套锁
第一条准则最简单:假如已经持有锁,就不要试图获取第二个锁。万一确有需要获取多个锁,我们应采用std::lock()函数,借单独的调用动作一次获取全部锁来避免死锁。
4.2 一旦持锁,就须避免调用由用户提供的程序接口
这是上一条准则的延伸。若程序接口由用户自行实现,则我们无从得知它到底会做什么,它可能会随意操作,包括试图获取锁。一旦我们已经持锁,若再调用由用户提供的程序接口,而它恰好也要获取锁,那便违反了避免嵌套锁的准则,可能发生死锁。
4.3 依从固定顺序获取锁
如果多个锁是绝对必要的,却无法通过std::lock()在一步操作中全部获取,我们只能退而求其次,在每个线程内部都依从固定顺序获取这些锁。
4.4 按层级加锁
我们把应用程序分层,并且明确每个互斥位于哪个层级。若某线程已对低层级互斥加锁,则不准它再对高层级互斥加锁。具体做法是将层级的编号赋予对应层级应用程序上的互斥,并记录各线程分别锁定了哪些互斥。
4.5 将准则推广到锁操作以外
死锁现象并不单单因加锁操作而发生,任何同步机制导致的循环等待都会导致死锁出现。因此也值得为那些情况推广上述准则。譬如,我们应尽可能避免获取嵌套锁;若当前线程持有某个锁,却又同时等待别的线程,这便是坏的情况,因为万一后者恰好也需获取锁,反而只能等该锁被释放才能继续运行。类似地,如果要等待线程,那就值得针对线程规定层级,使得每个线程仅等待层级更低的线程。有一种简单方法可实现这种机制:让同一个函数启动全部线程,且汇合工作也由之负责。
5. 参考资料
《C++并发编程实战(第2版)》
|