线程间共享数据的问题

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

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

c++并发编程实战-第3章 在线程间共享数据的更多相关文章

  1. JAVA并发编程实战---第三章:对象的共享(2)

    线程封闭 如果仅仅在单线程内访问数据,就不需要同步,这种技术被称为线程封闭,它是实现线程安全性的最简单的方式之一.当某个对象封闭在一个线程中时,这种方法将自动实现线程安全性,即使被封闭的对象本生不是线 ...

  2. 【java并发编程实战】第二章:对象的共享

    1.重要的属性 可见性,不变性,原子性 1.1可见性 当一个线程修改某个对象状态的时候,我们希望其他线程也能看到发生后的变化. 在没有同步的情况下,编译器和处理器会对代码的执行顺序进行重排.以提高效率 ...

  3. JAVA并发编程实战---第三章:对象的共享

    在没有同步的情况下,编译器.处理器以及运行时等都可能对操作的执行顺序进行一些意想不到的调整.在缺乏足够同步的多线程程序中,要对内存操作的执行顺序进行判断几乎无法得到正确的结果. 非原子的64位操作 当 ...

  4. Java并发编程实战---第六章:任务执行

    废话开篇 今天开始学习Java并发编程实战,很多大牛都推荐,所以为了能在并发编程的道路上留下点书本上的知识,所以也就有了这篇博文.今天主要学习的是任务执行章节,主要讲了任务执行定义.Executor. ...

  5. 那些年读过的书《Java并发编程实战》一、构建线程安全类和并发应用程序的基础

    1.线程安全的本质和线程安全的定义 (1)线程安全的本质 并发环境中,当多个线程同时操作对象状态时,如果没有统一的状态访问同步或者协同机制,不同的线程调度方式和不同的线程执行次序就会产生不同的不正确的 ...

  6. Java并发工具类(四):线程间交换数据的Exchanger

    简介 Exchanger(交换者)是一个用于线程间协作的工具类.Exchanger用于进行线程间的数据交换.它提供一个同步点,在这个同步点两个线程可以交换彼此的数据.这两个线程通过exchange方法 ...

  7. 【java并发编程实战】第一章笔记

    1.线程安全的定义 当多个线程访问某个类时,不管允许环境采用何种调度方式或者这些线程如何交替执行,这个类都能表现出正确的行为 如果一个类既不包含任何域,也不包含任何对其他类中域的引用.则它一定是无状态 ...

  8. Java并发编程实战 第16章 Java内存模型

    什么是内存模型 JMM(Java内存模型)规定了JVM必须遵循一组最小保证,这组保证规定了对变量的写入操作在何时将对其他线程可见. JMM为程序中所有的操作定义了一个偏序关系,称为Happens-Be ...

  9. Java并发编程实战 第8章 线程池的使用

    合理的控制线程池的大小: 下面内容来自网络.不过跟作者说的一致.不想自己敲了.留个记录. 要想合理的配置线程池的大小,首先得分析任务的特性,可以从以下几个角度分析: 任务的性质:CPU密集型任务.IO ...

  10. java并发编程实战:第二章----线程安全性

    一个对象是否需要是线程安全的取决于它是否被多个线程访问. 当多个线程访问同一个可变状态量时如果没有使用正确的同步规则,就有可能出错.解决办法: 不在线程之间共享该变量 将状态变量修改为不可变的 在访问 ...

随机推荐

  1. 反范式设计,冗余用户姓名,修改用户姓名后,业务表同步更新 -- MySQL 存储过程

    反范式设计,冗余用户姓名,通过存储过程进行业务表的同步更新. 所有的表,在创建的时候,都加了创建人.修改人的字段..用户姓名发生变化时,要将所有的表都更新一遍. 创建存储过程 MySQL CREATE ...

  2. APEX实战第1篇:本地部署拥有AI能力的APEX

    学会部署APEX是为了更好构建企业级AI应用打基础,比如企业级的知识平台.智能报表等. 先前在<手把手教你喂养 DeepSeek 本地模型>,使用AnythingLLM方式,虽然操作上已经 ...

  3. 幻兽帕鲁/Palworld/支持网络联机 v0.1.5.1

    游戏介绍 在广阔的世界中收集神奇的生物"帕鲁",派他们进行战斗.建造.做农活,工业生产等,这是一款支持多人游戏模式的全新开放世界生存制作游戏. 注意事项 先启动STEAM客户端,在 ...

  4. hbase - [03] 客户端常用命令(hbase shell)

    1.列出所有namespace list_namespace 2.创建namespace create_namespace 'ns_name' 3.修改namespace属性 alter_namesp ...

  5. Ansible - [07] 定义变量的几种方式

    题记部分 Ansible 支持十几种定义变量的方式 Inventory 变量 Host Facts 变量 Register 变量 Playbook 变量 Playbook 提示变量 变量文件 命令行变 ...

  6. Vuex:让状态管理不再头疼的“管家”

    如果你正在开发一个 Vue.js 应用程序,但发现自己被各种组件之间的状态共享问题搞得焦头烂额,那么 Vuex 就是你需要的"超级管家".Vuex 是专门为 Vue.js 设计的状 ...

  7. vue练习用免费开源api大全

    1. 网易云api 网易云api是网上一位大神工具网易云获取的,数据都是真实的网易云数据 2. api大全 这是csdn一个兄弟收集的,种类挺多,就是有一些需要money,不过大部分还是免费的 3.  ...

  8. 线上测试木舟物联网平台之如何通过HTTP网络组件接入设备

    一.概述 木舟 (Kayak) 是什么? 木舟(Kayak)是基于.NET6.0软件环境下的surging微服务引擎进行开发的, 平台包含了微服务和物联网平台.支持异步和响应式编程开发,功能包含了物模 ...

  9. QSound、QSoundEffect播放WAV音频

    QSound.QSoundEffect播放WAV音频 本文旨在介绍QSound.QSoundEffect的简单播放音频的方法以及对这两个类的一些基本介绍 文章目录 QSound.QSoundEffec ...

  10. [tldr] 使用ip.sb检查自己所在局域网的公网IP

    使用ip a等一些命令行工具可以帮助我们检查自己的内网IP,但是,如何获取自己的在公网下的IP(即当前所在的局域网被分配的公网IP) 如果使用爬虫,这个IP也是很重要的.BAN IP就是这个IP ht ...