c++并发编程实战-第3章 在线程间共享数据

线程间共享数据的问题

多线程之间共享数据,最大的问题便是数据竞争导致的异常问题。多个线程操作同一块资源,如果不做任何限制,那么一定会发生错误。例如:

 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++中最基本的互斥量。该类定义在<mutex>头文件中。

构造函数

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         else
16         {
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 lock
136260 no get lock
136260 get lock
Result = 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 lock
11 
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<mutex> 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<mutex> lock_a(lhs._mutex, std::adopt_lock);  //不再上锁
3 std::lock_guard<mutex> 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<mutex> 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) mutable
31     {
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(),弹出栈顶元素。毫无疑问,这正是典型的条件竞争。它的根本原因在于函数接口,即使在内部使用互斥保护栈容器中的元素,也无法防范。

消除返回值导致的条件竞争的方法

方法一:传入引用接收数据

template<typename T>
class myStack
{
public:
    myStack();
    ~myStack();

    void pop(T& data);        //传入引用接收数据

};

int main()
{
    myStack<DataRes> 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<mutex> lock_a(lhs._mutex);
 9         lock_guard<mutex> 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<mutex> lock_a(lhs._mutex, std::adopt_lock);  //已经上锁,不再加锁
11         std::lock_guard<mutex> 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<mutex, mutex> lock(lhs._mutex, rhs._mutex);

由于c++17自带类模板参数推导,因此,上面代码可以改写为:

1 scoped_lock lock(lhs._mutex, rhs._mutex);

防范死锁的补充准则

虽然死锁最常见的诱因之一是互斥操作,但即使没有牵涉互斥,也会发生死锁现象。例如:有两个线程,各自关联了std::thread实例,若它们同时在对方的std::thread实例上调用join(),就能制造出死锁现象却不涉及锁操作。如果线程甲正等待线程乙完成某一动作,同时线程乙却在等待线程甲完成某一动作,便会构成简单的循环等待。防范死锁的准则最终可归纳成一个思想:只要另一线程有可能正在等待当前线程,那么当前线程千万不能反过来等待它。

死锁条件(个人补充)

我们可以总结出,一般在满足如下4个条件时会发生死锁:

  • 互斥条件:多个线程访问某个资源时是互斥的,如果多个线程需要同时占用资源,必须等待,要等资源被释放后,其他线程才能获取。
  • 保持和请求条件:当一个线程获取资源后,在没有释放该资源之前(占有该资源)又去获取其他资源(请求)。
  • 不可剥夺条件:在一个线程获取资源后,如果该线程不主动释放资源,其他线程无法使其释放资源。
  • 循环等待条件:死锁形成后,必然是相互等待的状态。

准则1:避免嵌套锁

假如已经持有锁,就不要试图获取第二个锁,若每个线程最多只持有唯一一个锁,那么对锁的操作不会导致死锁。万一确有需要获取多个锁,我们应采用std::lock()函数,借单独的调用动作一次获取全部锁来避免死锁。

准则2:一旦持锁,就须避免调用由用户提供的程序接口

若程序接口由用户自行实现,则我们无从得知它到底会做什么,它可能会随意操作,包括试图获取锁。一旦我们已经持锁,若再调用由用户提供的程序接口,而它恰好也要获取锁,此时就会导致死锁。

准则3:依次从固定顺序获取锁

如果多个锁是绝对必要的,却无法通过std::lock()在一步操作中获取全部的锁,我们只能退而求其次,在每个线程内部都依照固定顺序获取这些锁,并确保所有线程都遵从。

准则4:按层级加锁

依照固定次序加锁可能在实际中并不好执行,那么,我们可以自己构建一个层级锁,根据锁的层级结构来进行加锁。但线程已经获取一个较低层的互斥锁,那么,所有高于该层的互斥锁全部不允许加锁。

准则5:控制锁的作用域(个人补充)

在使用锁的时候,尽可能限制其作用域,缩短占用时间。

准则6:使用超时机制(个人补充)

在使用锁时,加上一定的时间限制。如果超时,那么则认为此次操作失败,执行返回操作。

准则7:使用RAII技术(个人补充)

尽量使用RAII技术来使用锁,减少锁的误操作,还可以使代码更加简洁。

运用std::unique_lock类灵活加锁

std::unique_lock类同样可以用来托管互斥量,但它比std::lock_guard类更加灵活,不一定始终占有与之关联的互斥。

构造函数

unique_lock();
unique_lock(_Mutex&);     //构造并调用lock上锁
~unique_lock();                //析构并调用unlock解锁

//构造,_Mtx已经被锁,构造函数不在调用lock
unique_lock(_Mutex&, adopt_lock_t);    

//构造,但不对_Mtx上锁,需后续手动调用
unique_lock(_Mutex&, defer_lock_t)

//构造,尝试获取锁,不会造成阻塞
unique_lock(_Mutex&, try_to_lock_t)

//构造 + try_lock_shared_for
unique_lock(_Mutex&, const chrono::duration<_Rep, _Period>&);

//构造 + try_lock_shared_until
unique_lock(_Mutex&, const chrono::time_point<_Clock, _Duration>&);

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<_Rep, _Period>&);

//尝试锁定关联可定时锁定 (TimedLockable) 互斥,若抵达指定时间点互斥仍不可用则返回
bool try_lock_until(const chrono::time_point<_Clock, _Duration>&);

//与另一 std::unique_lock 交换状态
void swap(unique_lock& _Other);

//将关联互斥解关联而不解锁它
 _Mutex* release();

//测试是否占有其关联互斥
bool owns_lock();

//同owns_lock
operator 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<std::mutex> get_lock()
 8 {
 9     std::unique_lock<std::mutex> lock(_Mtx);
10     PrepareData();
11     return lock;
12 }
13 
14 void ProcessData()
15 {
16     std::unique_lock<std::mutex> lock(get_lock());
17     DoSomething();
18 }

按适合的粒度加锁

“锁粒度”该术语描述一个锁所保护的数据量。粒度精细的锁保护少量数据,而粒度粗大的锁保护大量数据。锁操作有两个要点:一是选择足够粗大的锁粒度,确保目标数据都受到保护;二是限制范围,务求只在必要的操作过程中持锁。只要条件允许,我们仅仅在访问共享数据期间才锁住互斥,让数据处理尽可能不用锁保护。持锁期间应避免任何耗时的操作,如读写文件。这种情况可用std::unique_lock处理:假如代码不再需要访问共享数据,那我们就调用unlock()解锁;若以后需重新访问,则调用lock()加锁。

 1 std::mutex _Mtx;
 2 bool GetAndProcessData()
 3 {
 4     std::unique_lock<std::mutex> 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<std::mutex> 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 Deleter
21     {
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
  • 头文件: <shared_mutex> 

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
  • 头文件: <shared_mutex> 

与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<_Rep, _Period>&);

//尝试锁定互斥,若直至抵达指定时间点互斥不可用则返回
bool try_lock_until(const chrono::time_point<_Clock, _Duration>&)

共享锁

void lock_shared();        //为共享所有权锁定互斥,若互斥不可用则阻塞
bool try_lock_shared();    //尝试为共享所有权锁定互斥,若互斥不可用则返回
void unlock_shared();      //解锁互斥(共享所有权)

//尝试为共享所有权锁定互斥,若互斥在指定的时限时期中不可用则返回
bool try_lock_shared_for(const chrono::duration<_Rep, _Period>&);

//尝试为共享所有权锁定互斥,若直至抵达指定时间点互斥不可用则返回
bool try_lock_shared_until(const chrono::time_point<_Clock, _Duration>&);

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已经被锁,构造函数不在调用lock
shared_lock(mutex_type&, adopt_lock_t)

//构造 + try_lock_shared_for
shared_lock(mutex_type&, const chrono::duration<_Rep, _Period>&)

//构造 + try_lock_shared_until
shared_lock(mutex_type&, const chrono::time_point<_Clock, _Duration>&)

shared_lock(shared_lock&&);      //移动构造
shared_lock& operator=(shared_lock&&);    //移动赋值,会先解锁

成员函数

//锁定关联的互斥
void lock();

//尝试锁定关联的互斥
bool try_lock();

//解锁关联的互斥
void unlock();

//尝试锁定关联的互斥,以指定时长
try_lock_for(const chrono::duration<_Rep, _Period>&);

//尝试锁定关联的互斥,直至指定的时间点
bool try_lock_until(const chrono::time_point<_Clock, _Duration>&);

//解除关联 mutex 而不解锁
mutex_type* release();

//测试锁是否占有其关联的互斥
bool owns_lock();

//同owns_lock
operator 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<shared_mutex> lhs(_Mtx, defer_lock);            
 8 
 9         //上共享锁(读操作)
10         shared_lock<shared_mutex> 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::recursive_mutex>和std::unique_lock<std::recursive_mutex>,它们便会处理好递归锁的余下细节。

工作中尽量避免使用递归锁,这可能是一种拙劣的设计,换一种方式,可能用普通锁就解决问题了。比如,提取一个新的函数,在外部先加锁,然后递归调用该函数。

Copyright

本文参考至《c++并发编程实战》 第二版,作者:安东尼·威廉姆斯。本人阅读后添加了自己的理解并整理,方便后续查找,可能存在错误,欢迎大家指正,感谢!

posted @ 2023-09-14 16:01  西兰花战士  阅读(211)  评论(0编辑  收藏  举报