线程间共享数据的问题
多线程之间共享数据,最大的问题便是数据竞争导致的异常问题。多个线程操作同一块资源,如果不做任何限制,那么一定会发生错误。例如:
1 int g_nResource = 0; 2 void thread_entry() 3 { 4 for (int i = 0; i < 10000000; ++i) 5 g_nResource++; 6 } 7 8 int main() 9 {10 thread th1(thread_entry);11 thread th2(thread_entry);12 th1.join();13 th2.join();14 cout << g_nResource << endl;15 return 0;16 }
输出:
10161838
显然,上面的输出结果存在问题。出现错误的原因可能是:
某一时刻,th1线程获得CPU时间片,将g_nResource从100增加至200后时间片结束,保存上下文并切换至th2线程。th2将g_nResource增加至300,结束时间片,保存上下文并切换回th1线程。此时,还原上下文,g_nResource会还原成之前保存的200的值。
在并发编程中,操作由两个或多个线程负责,它们争先恐后执行各自的操作,而结果取决于它们执行的相对次序,每一种次序都是条件竞争。很多时候,这是良性行为,因为全部可能的结果都可以接受,即便线程变换了相对次序。例如,往容器中添加数据项,不管怎么添加,只要容器的容量够,总能将所有数据项填入,我们只关心是否能全部放入,对于元素的次序并不care。
真正让人烦恼的,是恶性条件竞争。要完成一项操作,需要对共享资源进行修改,当其中一个线程还未完成数据写入时,另一个线程不期而访。恶性条件竞争会产生未定义的行为,并且每次产生的结果都不相同,无形中增加故障排除的难度。
归根结底,多线程共享数据的问题大多数都由线程对数据的修改引发的。如果所有共享数据都是只读数据,就不会有问题。因为,若数据被某个线程读取,无论是否存在其他线程也在读取,该数据都不会受到影响。然而,如果多个线程共享数据,只要一个线程开始改动数据,就会带来很多隐患,产生麻烦。解决办法就是使用互斥对数据进行保护。
1 int g_nResource = 0;2 std::mutex _mutex; //使用互斥3 void thread_entry()4 {5 _mutex.lock(); //加锁6 for (int i = 0; i < 10000000; ++i)7 g_nResource++;8 _mutex.unlock(); //解锁9 }
输出:
20000000
用互斥保护共享数据
为了达到我们想要效果,C++11引入了互斥(mutual exclusion)。互斥是一把对资源的锁,线程访问资源时,先锁住与该资源相关的互斥,若其他线程试图再给它加锁,则须等待,直至最初成功加锁的线程把该互斥解锁。这确保了全部线程所见到的共享数据是自洽的(self-consistent),不变量没有被破坏。
在C++中使用互斥std::mutex
std::mutex是c++中最基本的互斥量。该类定义在头文件中。
构造函数
1 mutex();2 3 //不支持拷贝构造,也不支持移动构造(有定义拷贝,则无移动)4 mutex(const mutex&) = delete;5 mutex& operator=(const mutex&) = delete;
刚初始化的互斥处于unlocked状态。
lock()函数
1 void lock();
用于锁住该互斥量,有如下3中情况:
- 当前没有被锁,则当前线程锁住互斥量,在未调用unlock()函数前,线程拥有该锁。
- 被其他线程锁住,则当前线程被阻塞,一直等待其他线程释放锁。
- 被当前线程锁住,再次加锁会产生异常。
unlock()函数
1 void unlock();
解锁,当前线程释放对互斥量的所有权。在无锁情况下调用unlock()函数,将导致异常。
try_lock()函数
bool try_lock();
尝试锁住互斥量,如果互斥量被其他线程占用,该函数会返回false,并不会阻塞线程。有如下3中情况:
- 当前没有被锁,则当前线程锁住互斥量,并返回true,在未调用unlock函数前,该线程拥有该锁。
- 被其他线程锁住,该函数返回false,线程并不会被阻塞。
- 被当前线程锁住,再次尝试获取锁,返回false。
案例
1 int g_nResource = 0; 2 std::mutex _mutex; 3 void thread_entry() 4 { 5 while (1) 6 { 7 if (_mutex.try_lock()) 8 { 9 cout << this_thread::get_id() << " get lock\n";10 for (int i = 0; i < 10000000; ++i)11 g_nResource++;12 _mutex.unlock();13 return;14 }15 else16 {17 cout << this_thread::get_id() << " no get lock\n";18 this_thread::sleep_for(std::chrono::milliseconds(500));19 }20 }21 }22 23 int main()24 {25 thread th1(thread_entry);26 thread th2(thread_entry);27 th1.join();28 th2.join();29 cout << "Result = " << g_nResource << endl;30 }
输出:
131988 get lock136260 no get lock136260 get lockResult = 20000000
上面代码有一个缺点,就是需要我们手动调用unlock函数释放锁,这是一个安全隐患,并且,在某些情况下(异常),我们根本没有机会自己手动调用unlock函数。针对上面这种情况,c++引入了lock_guard类。
std::lock_guard
std::lock_guard使用RAII手法,在对象创建时,自动调用lock函数,在对象销毁时,自动调用unlock()函数,从而保证互斥总能被正确解锁。该类的实现很简单,直接贴源码:
1 template <class _Mutex> 2 class _NODISCARD lock_guard { // class with destructor that unlocks a mutex 3 public: 4 using mutex_type = _Mutex; 5 6 explicit lock_guard(_Mutex& _Mtx) : _MyMutex(_Mtx) { // construct and lock 7 _MyMutex.lock(); 8 } 9 10 lock_guard(_Mutex& _Mtx, adopt_lock_t) : _MyMutex(_Mtx) {} // construct but don't lock11 12 ~lock_guard() noexcept {13 _MyMutex.unlock();14 }15 16 lock_guard(const lock_guard&) = delete;17 lock_guard& operator=(const lock_guard&) = delete;18 19 private:20 _Mutex& _MyMutex;21 };
std::lock_guard仅提供了构造函数和析构函数,并未提供其他成员函数。所以,我们只能用该函数来获取锁、释放锁。
案例:
1 int g_nResource = 0;2 std::mutex _mutex;3 void thread_entry()4 {5 lock_guard lock(_mutex);6 for (int i = 0; i < 10000000; ++i)7 g_nResource++;8 }
锁的策略标签
std::lock_guard在构造时,可以传入一个策略标签,用于标识当前锁的状态,目前,有如下几个标签,含义如下:
- std::defer_lock:表示不获取互斥的所有权
- std::try_to_lock:尝试获得互斥的所有权而不阻塞
- std::adopt_lock:假设调用方线程已拥有互斥的所有权
这几个标签可以为std::lock_guard 、 std::unique_lock 和 std::shared_lock 指定锁定策略。
用法如下:
1 std::lock(lhs._mutex, rhs._mutex); //对lhs、rhs上锁2 std::lock_guard lock_a(lhs._mutex, std::adopt_lock); //不在上锁3 std::lock_guard lock_b(rhs._mutex, std::adopt_lock); //不在上锁
组织和编排代码以保护共享数据
使用互斥并不是万能的,一些情况还是可能会使得共享数据遭受破坏。例如:向调用者返回指针或引用,指向受保护的共享数据,就会危及共享数据安全。或者,在类内部调用其他外部接口,而该接口需要传递受保护对象的引用或者指针。例如:
1 class SomeData 2 { 3 public: 4 void DoSomething() { cout << "do something\n"; } 5 }; 6 7 class Operator 8 { 9 public:10 void process(std::function<void(SomeData&)> func)11 {12 std::lock_guard lock(_mutex);13 func(data); //数据外溢14 }15 16 private:17 SomeData data;18 mutex _mutex;19 };20 21 void GetDataPtr(SomeData** pPtr, SomeData& data)22 {23 *pPtr = &data;24 }25 26 int main()27 {28 Operator opt;29 SomeData* pUnprotected = nullptr;30 auto abk = [pUnprotected](SomeData& data) mutable31 {32 pUnprotected = &data;33 };34 opt.process(abk);35 pUnprotected->DoSomething(); //以无锁形式访问本应该受到保护的数据36 }
c++并未提供任何方法解决上面问题,归根结底这是我们代码设计的问题,需要牢记:不得向锁所在的作用域之外传递指针和引用,指向受保护的共享数据,无论是通过函数返回值将它们保存到对外可见的内存,还是将它们作为参数传递给使用者提供的函数。
发现接口固有的条件竞争
1 void func() 2 { 3 stack<int> s; 4 if (!s.empty()) 5 { 6 int nValue = s.top(); 7 s.pop(); 8 do_something(nValue); 9 }10 }
在空栈上调用top()会导致未定义行为,上面的代码已做好数据防备。对单线程而言,它既安全,又符合预期。可是,只要涉及共享,这一连串调用便不再安全。因为,在empty()和top()之间,可能有另一个线程调用pop(),弹出栈顶元素。毫无疑问,这正是典型的条件竞争。它的根本原因在于函数接口,即使在内部使用互斥保护栈容器中的元素,也无法防范。
消除返回值导致的条件竞争的方法方法一:传入引用接收数据
templateclass myStack{public: myStack(); ~myStack(); void pop(T& data); //传入引用接收数据};int main(){ myStack s; DataRes result; s.pop(result);}
这在许多情况下行之有效,但还是有明显短处。如果代码要调用pop(),则须先依据栈容器中的元素类型构造一个实例,将其充当接收目标传入函数内。对于某些类型,构建实例的时间代价高昂或耗费资源过多,所以不太实用。并且,该类型必须支持拷贝赋值运算符。
方法二:提供不抛出异常的拷贝构造函数,或不抛出异常的移动构造函数
假设某个接口是按值返回,若它抛出异常,则牵涉异常安全的问题只会在这里出现。那么,只要确保构造函数不会出现异常,该问题就可以解决。解决办法是:让该接口只允许哪些安全的类型返回。
方法三:返回指针,指向待返回元素
返回指针,指向弹出的元素,而不是返回它的值,其优点是指针可以自由地复制,不会抛出异常。可以采用std::shared_ptr托管内存资源。
方法四:结合方法一和方法二,或结合方法一和方法三
将上面几种方法结合起来一起使用。
死锁问题
线程在互斥上争抢锁,有两个线程,都需要同时锁住两个互斥,可它们偏偏都只锁住了一个,都在等待另一把锁,上述情况被称为死锁。
防范死锁的建议是:始终按相同顺序对互斥加锁。
1 class A 2 { 3 public: 4 A(int nValue) : m_nValue(nValue) {} 5 friend void Swap(A& lhs, A& rhs) 6 { 7 if (&lhs == &rhs) return; 8 lock_guard lock_a(lhs._mutex); 9 lock_guard lock_b(rhs._mutex);10 std::swap(lhs.m_nValue, rhs.m_nValue);11 }12 private:13 int m_nValue;14 mutex _mutex;15 };16 17 void func(A& lhs, A& rhs)18 {19 Swap(lhs, rhs);20 }21 22 int main()23 {24 A a1(10);25 A a2(20);26 thread th1(func, std::ref(a1), std::ref(a2)); //传入参数顺序不同27 thread th2(func, std::ref(a2), std::ref(a1)); //传入参数顺序不同28 th1.join();29 th2.join();30 }
上述代码存在死锁发生的可能。原因是在调用Swap时,加锁顺序不一致,并且,上述例子出错更加的隐蔽,故障排除更困难。为此,c++提供了std::lock()函数。
std::lock()函数
该函数可以一次锁住两个或者两个以上的互斥量。由于内部算法的特性,它能避免因为多个线程加锁顺序不同导致死锁的问题。用法如下:
1 class A 2 { 3 public: 4 A(int nValue) : m_nValue(nValue) {} 5 6 friend void Swap(A& lhs, A& rhs) 7 { 8 if (&lhs == &rhs) return; 9 std::lock(lhs._mutex, rhs._mutex);10 std::lock_guard lock_a(lhs._mutex, std::adopt_lock); //已经上锁,不再加锁11 std::lock_guard lock_b(rhs._mutex, std::adopt_lock); //已经上锁,不再加锁12 std::swap(lhs.m_nValue, rhs.m_nValue);13 }14 15 private:16 int m_nValue;17 mutex _mutex;18 };
std::scoped_lock类
c++17提供了scoped_lock类,该类的用法和std::lock_guard类相似,也是用于托管互斥量。二者区别在于scoped_lock类可以同时托管多个互斥。例如:
1 scoped_lock lock(lhs._mutex, rhs._mutex);
由于c++17自带类模板参数推导,因此,上面代码可以改写为:
1 scoped_lock lock(lhs._mutex, rhs._mutex);
防范死锁的补充准则
虽然死锁最常见的诱因之一是互斥操作,但即使没有牵涉互斥,也会发生死锁现象。例如:有两个线程,各自关联了std::thread实例,若它们同时在对方的std::thread实例上调用join(),就能制造出死锁现象却不涉及锁操作。如果线程甲正等待线程乙完成某一动作,同时线程乙却在等待线程甲完成某一动作,便会构成简单的循环等待。防范死锁的准则最终可归纳成一个思想:只要另一线程有可能正在等待当前线程,那么当前线程千万不能反过来等待它。
准则1:避免嵌套锁
假如已经持有锁,就不要试图获取第二个锁,若每个线程最多只持有唯一一个锁,那么对锁的操作不会导致死锁。万一确有需要获取多个锁,我们应采用std::lock()函数,借单独的调用动作一次获取全部锁来避免死锁。
准则2:一旦持锁,就须避免调用由用户提供的程序接口
若程序接口由用户自行实现,则我们无从得知它到底会做什么,它可能会随意操作,包括试图获取锁。一旦我们已经持锁,若再调用由用户提供的程序接口,而它恰好也要获取锁,此时就会导致死锁。
准则3:依次从固定顺序获取锁
如果多个锁是绝对必要的,却无法通过std::lock()在一步操作中获取全部的锁,我们只能退而求其次,在每个线程内部都依照固定顺序获取这些锁,并确保所有线程都遵从。
准则4:按层级加锁
依照固定次序加锁可能在实际中并不好执行,那么,我们可以自己构建一个层级锁,根据锁的层级结构来进行加锁。但线程已经获取一个较低层的互斥锁,那么,所有高于该层的互斥锁全部不允许加锁。
运用std::unique_lock类灵活加锁
std::unique_lock类同样可以用来托管互斥量,但它比std::lock_guard类更加灵活,不一定始终占有与之关联的互斥。
构造函数
unique_lock();unique_lock(_Mutex&); //构造并调用lock上锁~unique_lock(); //析构并调用unlock解锁//构造,_Mtx已经被锁,构造函数不在调用lockunique_lock(_Mutex&, adopt_lock_t); //构造,但不对_Mtx上锁,需后续手动调用unique_lock(_Mutex&, defer_lock_t)//构造,尝试获取锁,不会造成阻塞unique_lock(_Mutex&, try_to_lock_t)//构造 + try_lock_shared_forunique_lock(_Mutex&, const chrono::duration&);//构造 + try_lock_shared_untilunique_lock(_Mutex&, const chrono::time_point&);unique_lock(unique_lock&& _Other); //移动构造//若占有则解锁互斥,并取得另一者的所有权unique_lock& operator=(unique_lock&& _Other);//无拷贝构造unique_lock(const unique_lock&) = delete;unique_lock& operator=(const unique_lock&) = delete;
构造函数提供了灵活的加锁策略。
成员函数
//锁定关联互斥void lock();//解锁关联互斥void unlock();//尝试锁定关联互斥,若互斥不可用则返回bool try_lock();//试图锁定关联的可定时锁定 (TimedLockable) 互斥,若互斥在给定时长中不可用则返回bool try_lock_for(const chrono::duration&);//尝试锁定关联可定时锁定 (TimedLockable) 互斥,若抵达指定时间点互斥仍不可用则返回bool try_lock_until(const chrono::time_point&);//与另一 std::unique_lock 交换状态void swap(unique_lock& _Other);//将关联互斥解关联而不解锁它 _Mutex* release();//测试是否占有其关联互斥bool owns_lock();//同owns_lockoperator bool();//返回指向关联互斥的指针_Mutex* mutex();
提供了lock()、unlock()等接口,可以随时解锁或者上锁。
在不同的作用域之间转移互斥归属权
因为std::unique_lock实例不占有与之关联的互斥,所以随着其实例的转移,互斥的归属权可以在多个std::unique_lock实例之间转移。通过移动语义完成,注意区分左值和右值。
转移有一种用途:准许函数锁定互斥,然后把互斥的归属权转移给函数调用者,好让他在同一个锁的保护下执行其他操作。代码如下:
1 std::mutex _Mtx; 2 3 void PrepareData() {} 4 5 void DoSomething() {} 6 7 std::unique_lock get_lock() 8 { 9 std::unique_lock lock(_Mtx);10 PrepareData();11 return lock;12 }13 14 void ProcessData()15 {16 std::unique_lock lock(get_lock());17 DoSomething();18 }
按适合的粒度加锁
“锁粒度”该术语描述一个锁所保护的数据量。粒度精细的锁保护少量数据,而粒度粗大的锁保护大量数据。锁操作有两个要点:一是选择足够粗大的锁粒度,确保目标数据都受到保护;二是限制范围,务求只在必要的操作过程中持锁。只要条件允许,我们仅仅在访问共享数据期间才锁住互斥,让数据处理尽可能不用锁保护。持锁期间应避免任何耗时的操作,如读写文件。这种情况可用std::unique_lock处理:假如代码不再需要访问共享数据,那我们就调用unlock()解锁;若以后需重新访问,则调用lock()加锁。
1 std::mutex _Mtx; 2 bool GetAndProcessData() 3 { 4 std::unique_lock lock(_Mtx); 5 DataResource data = GetData(); 6 lock.unlock(); 7 bool bResult = WirteToFile(data); //非常耗时 8 lock.lock(); 9 SaveResult(bResult);10 return bResult;11 }
一般地,若要执行某项操作,那我们应该只在所需的最短时间内持锁。换言之,除非绝对必要,否则不得在持锁期间进行耗时的操作,如等待I/O完成或获取另一个锁(即便我们知道不会死锁)。例如,在比较运算的过程中,每次只锁住一个互斥:
1 class Y 2 { 3 private: 4 int some_detail; 5 mutable std::mutex m; 6 int get_detail() const 7 { 8 std::lock_guard lock_a(m); 9 return some_detail;10 }11 public:12 Y(int sd):some_detail(sd){}13 friend bool operator==(Y const& lhs, Y const& rhs)14 {15 if(&lhs==&rhs)16 return true;17 int const lhs_value=lhs.get_detail(); 18 int const rhs_value=rhs.get_detail(); 19 return lhs_value==rhs_value; ⇽--- ④20 }21 };
为了缩短持锁定的时间,我们一次只持有一个锁。
保护共享数据的其他工具
互斥是保护共享数据的最普遍的方式之一,但它并非唯一方式。
在初始化过程中保护共享数据
假设我们需要某个共享数据,而它创建起来开销不菲。因为创建它可能需要建立数据库连接或分配大量内存,所以等到必要时才真正着手创建。这种方式称为延迟初始化(lazy initialization)。最常见的就是实现懒汉式单例模式,现在,时代变了,实现线程安全的单例模式,不需要使用双重锁了!
std::call_once()函数与std::once_flag
std::call_once()函数可以确保可调用对象仅执行一次,即使是在并发访问下。该函数定义如下:
1 template <class _Fn, class... _Args>2 void(call_once)(once_flag& _Once, _Fn&& _Fx, _Args&&... _Ax);
- _Once:std::once_flag对象,它确保仅有一个线程能执行函数。
- _Fx:待调用的可调用对象。
- _Ax:传递给可调用对象的参数包。
用std::call_once()函数实现单例:
1 class Singleton 2 { 3 public: 4 static Singleton* Ins() 5 { 6 std::call_once(_flag, []() { 7 _ins = new Singleton; 8 }); 9 return _ins;10 }11 12 Singleton(const Singleton&) = delete;13 Singleton& operator=(const Singleton&) = delete;14 15 protected:16 Singleton() { std::cout << "constructor" << std::endl; }17 ~Singleton() { std::cout << "destructor" << std::endl; } //必须声明为私有,否则返回指针将可析构18 19 private:20 struct Deleter21 {22 ~Deleter() {23 delete _ins;24 _ins = nullptr;25 }26 };27 static Deleter _deleter;28 static Singleton* _ins;29 static std::once_flag _flag;30 };31 32 Singleton::Deleter Singleton::_deleter;33 Singleton* Singleton::_ins = nullptr;34 std::once_flag Singleton::_flag;
Deleter确保Singleton对象销毁时,能够释放_ins对象。
Magic Static特性
C++11标准中定义了一个Magic Static特性:如果变量当前处于初始化状态,当发生并发访问时,并发线程将会阻塞,等待初始化结束。
用Magic Static特性实现单例:
1 class Singleton 2 { 3 public: 4 static Singleton& Ins() 5 { 6 static Singleton _ins; 7 return _ins; 8 } 9 10 Singleton(const Singleton&) = delete;11 Singleton& operator=(const Singleton&) = delete;12 13 protected:14 Singleton() { std::cout << "constructor" << std::endl; }15 ~Singleton() { std::cout << "destructor" << std::endl; }16 };
保护甚少更新的数据结构
考虑一个存储着DNS条目的缓存表,它将域名解释成对应的IP地址。给定的DNS条目通常在很长时间内都不会变化——在许多情况下,DNS条目保持多年不变。尽管,随着用户访问不同网站,缓存表会不时加入新条目,但在很大程度上,数据在整个生命期内将保持不变。为了判断数据是否有效,必须定期查验缓存表;只要细节有所改动,就需要进行更新。
更新虽然鲜有,但它们还是会发生。另外,如果缓存表被多线程访问,更新过程就需得到妥善保护,以确保各个线程在读取缓存表时,全都见不到失效数据。
如果使用传统的互斥,效率可能不高:当更新缓存表时,阻止其他线程访问数据是理所应到。但很多时候,数据未发生改变,但每个线程读取数据都会导致上锁,即读多写少,std::mutex效率就比较低了。
C++17标准库提供了两种新的互斥:std::shared_mutex和std::shared_timed_mutex。
std::shared_mutex
- 平台:c++17
- 头文件:
std::shared_mutex类可用于保护共享数据不被多个线程同时访问。与独占式互斥不同,该类拥有两种访问级别:
- 共享 – 多个线程能共享同一互斥的所有权。
- 独占性 – 仅一个线程能占有互斥。
std::shared_mutex有如下特点:
- 若一个线程已获得独占锁(通过lock、try_lock)则无其他线程能获取该锁(包括共享的)。
- 仅当任何线程均未获取独占性锁时,共享锁才能被多个线程获取(通过lock_shared 、try_lock_shared)。
- 在一个线程内,同一时刻只能获取一个锁(共享或独占性)。
构造函数
shared_mutex(); //构造互斥~shared_mutex(); //析构互斥//无拷贝shared_mutex(const shared_mutex&) = delete;shared_mutex& operator=(const shared_mutex&) = delete;
独占锁
void lock(); //锁定互斥,若互斥不可用则阻塞void unlock(); //解锁互斥void try_lock(); //尝试锁定互斥,若互斥不可用则返回
共享锁
void lock_shared(); //为共享所有权锁定互斥,若互斥不可用则阻塞bool try_lock_shared(); //尝试为共享所有权锁定互斥,若互斥不可用则返回void unlock_shared(); //解锁共享所有权互斥
案例
1 std::shared_mutex _Mtx; 2 void func() 3 { 4 _Mtx.lock_shared(); 5 cout << " thread Id = " << this_thread::get_id() << " do something!\n"; 6 _Mtx.unlock_shared(); 7 } 8 9 int main()10 {11 _Mtx.lock_shared(); //使用共享锁锁住12 thread th1(func);13 thread th2(func);14 th1.join();15 th2.join();16 _Mtx.unlock_shared();17 }
main函数中使用共享锁锁住,实际并不影响其他线程获取共享锁,如果将main函数中的共享锁换成独占锁,程序将发生死锁。同理,如果将func函数中的共享锁换成独占锁,同样会造成死锁,获取独占锁时,如果当前有其他线程正持有共享锁,那么该线程将阻塞,直到其他线程释放共享锁。
std::shared_timed_mutex
- 平台:c++14
- 头文件:
与std::shared_mutex类相似,只是提供了额外的成员函数。
构造函数
shared_timed_mutex();~shared_timed_mutex();shared_timed_mutex(const shared_timed_mutex&) = delete;shared_timed_mutex& operator=(const shared_timed_mutex&) = delete;
独占锁
void lock(); //锁定互斥,若互斥不可用则阻塞void unlock(); //解锁互斥bool try_lock(); //尝试锁定互斥,若互斥不可用则返回//尝试锁定互斥,若互斥在指定的时限时期中不可用则返回bool try_lock_for(const chrono::duration&);//尝试锁定互斥,若直至抵达指定时间点互斥不可用则返回bool try_lock_until(const chrono::time_point&)
共享锁
void lock_shared(); //为共享所有权锁定互斥,若互斥不可用则阻塞bool try_lock_shared(); //尝试为共享所有权锁定互斥,若互斥不可用则返回void unlock_shared(); //解锁互斥(共享所有权)//尝试为共享所有权锁定互斥,若互斥在指定的时限时期中不可用则返回bool try_lock_shared_for(const chrono::duration&);//尝试为共享所有权锁定互斥,若直至抵达指定时间点互斥不可用则返回bool try_lock_shared_until(const chrono::time_point&);
std::shared_lock
std::shared_lock和std::unique_lock类相似,unique_lock用于操作独占锁,其构造函数将调用lock()函数,析构函数将调用unlock()函数。shared_lock用于操作共享锁,其构造函数将调用lock_shared()函数,析构函数将调用unlock_shared()函数。
构造函数
shared_lock();shared_lock(mutex_type&); //构造并调用lock_shared上锁~shared_lock(); //析构并调用unlock_shared解锁//构造,但不对_Mtx上锁,需后续手动调用shared_lock(mutex_type&, defer_lock_t)//构造,尝试获取锁,不会造成阻塞shared_lock(mutex_type&, try_to_lock_t)//构造,_Mtx已经被锁,构造函数不在调用lockshared_lock(mutex_type&, adopt_lock_t)//构造 + try_lock_shared_forshared_lock(mutex_type&, const chrono::duration&)//构造 + try_lock_shared_untilshared_lock(mutex_type&, const chrono::time_point&)shared_lock(shared_lock&&); //移动构造shared_lock& operator=(shared_lock&&); //移动赋值,会先解锁
成员函数
//锁定关联的互斥void lock();//尝试锁定关联的互斥bool try_lock();//解锁关联的互斥void unlock();//尝试锁定关联的互斥,以指定时长try_lock_for(const chrono::duration&);//尝试锁定关联的互斥,直至指定的时间点bool try_lock_until(const chrono::time_point&);//解除关联 mutex 而不解锁mutex_type* release();//测试锁是否占有其关联的互斥bool owns_lock();//同owns_lockoperator bool();//返回指向关联的互斥的指针mutex_type* mutex();//与另一 shared_lock 交换数据成员void swap(shared_lock& _Right)
案例
1 class A 2 { 3 public: 4 A& operator=(const A& other) 5 { 6 //上独占锁(写操作) 7 unique_lock lhs(_Mtx, defer_lock); 8 9 //上共享锁(读操作)10 shared_lock rhs(other._Mtx, defer_lock); 11 12 //上锁13 lock(lhs, rhs); 14 15 to_do_assignment(); //赋值操作16 return *this;17 }18 private:19 mutable std::shared_mutex _Mtx;20 };
递归加锁
假如线程已经持有某个std::mutex实例,试图再次对其重新加锁就会出错,将导致未定义行为。但在某些场景中,确有需要让线程在同一互斥上多次重复加锁,而无须解锁。C++标准库为此提供了std::recursive_mutex,其工作方式与std::mutex相似,不同之处是,其允许同一线程对某互斥的同一实例多次加锁。我们必须先释放全部的锁,才可以让另一个线程锁住该互斥。例如,若我们对它调用了3次lock(),就必须调用3次unlock()。只要正确地使用std::lock_guard和std::unique_lock,它们便会处理好递归锁的余下细节。
工作中尽量避免使用递归锁,这可能是一种拙劣的设计,换一种方式,可能用普通锁就解决问题了。比如,提取一个新的函数,在外部先加锁,然后递归调用该函数。
Copyright
本文参考至《c++并发编程实战》 第二版,作者:安东尼·威廉姆斯。本人阅读后添加了自己的理解并整理,方便后续查找,可能存在错误,欢迎大家指正,感谢!