《C++ Concurrency In Action》读书笔记 - 线程间共享数据
多个线程间共享数据会出现什么问题?
如果所有的数据都是只读的,那么多线程之间同时访问是不会有问题的。但是如果有人要读的同时有人要改就会出现问题。因为并非所有操作都能在一步完成,一般的操作会是先去读取数据,然后做一些其他的运算或者判断,然后回写数据。那么可能发生以下一些情况:
- 但是如果多个线程同时做这个操作的话,可能某一个线程回写的时候,当时判断的逻辑已经不成立了。
- 如果两个线程同时尝试去修改同一份数据,就会出问题。
- 其他更复杂的情况
当多个线程共享内存并且去尝试修改内存里的内容的时候,就会出现race condition。这会让代码出现一些无法预料的情况。比如说:
1 | if (x == 5) |
这段代码在单线程下,如果x等于5,有一定等于10。但是如果多于一个线程运行这段代码,则y的值依赖于两个线程之间的执行顺序。如果还有一些线程在修改x的值,情况就更复杂了。
更糟糕的是在C++中,如果并发的修改同一个对象,会出现未定义的行为(undefined behavior)。
解决方案
应对race condition的解决方案有以下几种:
- 通过某种保护机制来确保同一时间只有一个线程能够对某一个数据进行访问。
- 通过lock-free的形式进行编程。主要指的是将对于数据的修改变成一系列不可分割的操作,C++中通常利用atomic来实现。具体会在lock-free一章中进行介绍。
- 通过STM(software transaction memory)。这个方式是将内存的修改处理成跟数据库事务类似的机制,当修改完成commit到内存中,要么成功要么失败,不会存在中间状态。目前C++语言层面没有对这个功能的直接支持。
C++中主要使用#1和#2。#1的话主要是通过mutex为核心来实现。
利用Mutex来保护数据
Mutex是mutual exclusion(互斥)的意思。提供了lock
和unlock
两个方法,std::mutex
同一时间值能有一个线程获得lock,当有一个线程拥有mutex
的时候,其余调用lock
尝试获得锁的线程会处于等待状态。
mutex本质上是一种资源,所以在C++中一般使用RAII来进行封装,避免出现lock
和unlock
调用不配对的情况。
C++11中提供了std::lock_guard<class Mutex>
和std::unique_lock<class Mutex>
封装。这两个类可以接受任何符合BasicLockable的mutex类型。
除了std::mutex
以外,C++17中还提供了以下几种mutex:
std::mutex
(since C++11) 同时只能有一个线程获得锁,并且一个线程之只能lock一次。std::recursive_mutex
(since C++11) 可重入锁,一个线程可以多次去lock同一个recursive_mutex。std::timed_mutex
(since C++11)。在std::mutex
的基础上提供了try_lock_for
和try_lock_until
,允许在超时的情况下返回。std::recursive_timed_mutex
(since C++11)。在std::recursive_mutex
的基础上提供了try_lock_for
和try_lock_until
,允许在超时的情况下返回。std::shared_mutex
(since C++17)。传说中的读写锁。提供lock
和shared_lock
两套方法。lock
即获得排他锁(exclusive lock),shared_lock
即获得共享锁(shared lock)
使用mutex时候需要注意的事情
当我们用mutex将我们需要保护的数据保护起来以后是不是就可以高枕无忧了呢?这只是“噩梦”的开始。。。。
你需要注意一下若干点:
- 不要将你保护的数据当做以引用或指针形式传入“不知名”的外部函数;
- 同样也不要将你要保护的数据作为指针或引用返回函数外部;
- 当你有多个锁要锁的时候,需要注意死锁;
- 如果你提供一个类型需要保证线程安全,确保你在设计api的时候精心考量;
- 控制锁的粒度,避免一些耗时的无必要的操作放在mutex保护范围内;
死锁
当你试图获取一个以上的锁的时候,如果姿势不对就可能产生死锁。解决死锁的通用的方法是,在所有需要同时获得多个锁的地方都按照一样的顺序去获取锁。当你需要同时获取两个锁的时候,std::lock
可以帮你避免死锁的问题。
但是如果你的锁是在不同地方分别获得的,你还是有可能死锁。
关于如何避免死锁,有一些通用的建议:
- 避免在一个锁的内部嵌套另外一个锁;
- 不要在拥有一个锁的时候去调用其他方法,因为你不知道其他方法会做一些什么事情;
- 用固定的顺序去获取锁;
- 利用Lock Hierarchy,这个核心思想是保证固定顺序去获得锁。每一个锁都分配一个层次号,锁的获取顺序只能是从高层到底层。反向则不行。书中给的例子很好,值得好好去理解。
- 死锁并不一定发生在有锁的地方,任何需要等待其他线程的地方都可能发生死锁。
控制锁的粒度
In general, a lock should be held for only the minimun possible time needed to perform the required operations.
不要多、不要少、够用最好。
其他保护共享数据的方法
static
初始化。C++11开始,static
初始化变量保证是线程安全的。对于一些只需要初始化一次的静态变量使用。- 利用
std::recursive
来避免一些可重入的情况,但是大部分情况需要可重入的锁意味着代码设计可能有问题。最好通过调整代码来避免这种情况。 - 利用
std::shared_lock
来提高性能,针对大部分情况都是读,只有少数情况下会去修改的数据。