c++多线程并发学习笔记(1)
共享数据带来的问题:条件竞争
避免恶性条件竞争的方法:
1. 对数据结构采用某种保护机制,确保只有进行修改的线程才能看到修改时的中间状态。从其他访问线程的角度来看,修改不是已经完成了,就是还没开始。
2. 对数据结构的设计进行修改,修改完的结构必须能完成一系列不可分割的变化,也就是保证每个不变量保持稳定的状态,这就是所谓的无锁编程。
3. 使用事务的方式去处理数据结构的更新(这里的"处理"就如同对数据库进行更新一样)。所需的一些数据和读取都存储在事务日志中,然后将之前的操作合为一步,再进行提交。当数据结构被另一个线程修 改后,或处理已经重启的情况下,提交就会无法进行,这称作为“软件事务内存”(software transactional memory (STM))。
使用互斥量来保护共享数据
主要实现方法:当访问共享数据前,将数据锁住,在访问结束后,再将数据解锁。线程库需要保证,当一个线程使用特定互斥量锁住共享数据时,其他的线程想要访问锁住的数据,都必须等到之前那个线程对数据进行解锁后,才能进行访问。这就保证了所有线程都能看到共享数据,并而不破坏不变量。
互斥量不是万能的,在使用时要注意以下问题:
1. 需要编排代码来保护数据的正确性
2. 避免接口间的竞争条件
3. 避免死锁
4. 对数据的保护太多或太少
c++中的互斥量
通过实例化 std::mutex 来创建互斥量实例,需要包含头文件<mutex>
使用方法:通过成员函数lock() 和unlock()来实现上锁和解锁
#include <iostream> #include <thread> #include <mutex> using namespace std; class Test { std::mutex m; public: void add(int& num) { m.lock();//上锁 ++num; cout << num << endl; m.unlock();//解锁 } }; int main() { int num = 1; Test test; thread t1(&Test::add, &test, std::ref(num)); thread t2(&Test::add, &test, std::ref(num)); t1.join(); t2.join(); }
但是,实践中不推荐直接去调用成员函数,调用成员函数就意味着,必须在每个函数出口都要去调用unlock(),也包括异常的情况。C++标准库为互斥量提供了一个RAII语法的模板类std::lock_guard
,在构造时就能提供已锁的互斥量,并在析构的时候进行解锁,从而保证了一个已锁互斥量能被正确解锁。
对于上个例子而言,可以改造为:
#include <iostream> #include <thread> #include <mutex> using namespace std; class Test { std::mutex m; public: void add(int& num) { lock_guard<std::mutex> guard(m); //在构造时lock ++num; cout << num << endl; }//在析构时unlock }; int main() { int num = 1; Test test; thread t1(&Test::add, &test, std::ref(num)); thread t2(&Test::add, &test, std::ref(num)); t1.join(); t2.join(); }
另外要注意:在使用互斥量来保护数据时,要注意检查指针和引用。切勿将受保护数据的指针或引用传递到互斥锁作用域之外,无论是函数返回值,还是存储在外部可见内存,亦或是以参数的形式传递到用户提供的函数中去。只要没有成员函数通过返回值或者输出参数的形式,向其调用者返回指向受保护数据的指针或引用,数据就是安全的。
接口间的竞争
考虑一个std::stack,它有top(), pop(), empty()等方法。即使我们在每个方法调用内部使用互斥量std::mutex 进行保护,由于接口之间的依赖关系,还是会存在竞争。例如:在调用empty()和调用top()之间,可能有来自另一个线程的pop()调用并删除了最后一个元素。这是一个经典的条件竞争,使用互斥量对栈内部数据进行保护,但依旧不能阻止条件竞争的发生,这就是接口固有的问题。
死锁
线程有对锁的竞争:一对线程需要对他们所有的互斥量做一些操作,其中每个线程都有一个互斥量,且等待另一个解锁。这样没有线程能工作,因为他们都在等待对方释放互斥量。这种情况就是死锁,它的最大问题就是由两个或两个以上的互斥量来锁定一个操作。
避免死锁的一般方法:就是让两个互斥量总以相同的顺序上锁:总在互斥量B之前锁住互斥量A,就永远不会死锁。
std::lock
——可以一次性锁住多个(两个以上)的互斥量,并且没有副作用(死锁风险)。因为std::lock
要么将两个锁都锁住,要不一个都不锁。
// 这里的std::lock()需要包含<mutex>头文件 class some_big_object; void swap(some_big_object& lhs,some_big_object& rhs); class X { private: some_big_object some_detail; 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::adopt_lock作用是声明互斥量已在本线程锁定,std::lock_guard只是保证互斥量在作用域结束时被释放 std::lock_guard<std::mutex> lock_b(rhs.m,std::adopt_lock); swap(lhs.some_detail,rhs.some_detail); } };
避免死锁的一些方法:
1. 避免嵌套锁
一个线程已获得一个锁时,再别去获取第二个。因为每个线程只持有一个锁,锁上就不会产生死锁。当你需要获取多个锁,使用一个std::lock
来做这件事(对获取锁的操作上锁),避免产生死锁。
2. 避免在持有锁时调用用户提供的代码
因为代码是用户提供的,你没有办法确定用户要做什么;用户程序可能做任何事情,包括获取锁。
3. 使用固定顺序获取锁
当硬性条件要求你获取两个或两个以上的锁,并且不能使用std::lock
单独操作来获取它们;那么最好在每个线程上,用固定的顺序获取它们(锁)。
4. 使用锁的层次结构
std::unique_lock——灵活的锁
互斥锁保证了线程间的同步,但是却将并行操作变成了串行操作,这对性能有很大的影响,所以我们要尽可能的减小锁定的区域,也就是使用细粒度锁。这一点lock_guard
做的不好,不够灵活,lock_guard
只能保证在析构的时候执行解锁操作,lock_guard
本身并没有提供加锁和解锁的接口,但是有些时候会有这种需求。
class LogFile { std::mutex _mu; ofstream f; public: LogFile() { f.open("log.txt"); } ~LogFile() { f.close(); } void shared_print(string msg, int id) { { std::lock_guard<std::mutex> guard(_mu); //do something 1 } //do something 2 { std::lock_guard<std::mutex> guard(_mu); // do something 3 f << msg << id << endl; cout << msg << id << endl; } } };
上述代码因为有两段代码需要上锁保护,所以使用lock_guard只能用两个局部变量来上锁和解锁,使用一个也可以,但锁的粒度太大,影响效率,这个时候就可以用unique_lock。
unique_lock它提供了lock()
和unlock()
接口,能记录现在处于上锁还是没上锁状态,在析构的时候,会根据当前状态来决定是否要进行解锁(lock_guard
就一定会解锁)。
上面的代码使用unique_lock可以修改为:
class LogFile { std::mutex _mu; ofstream f; public: LogFile() { f.open("log.txt"); } ~LogFile() { f.close(); } void shared_print(string msg, int id) { std::unique_lock<std::mutex> guard(_mu); //do something 1 guard.unlock(); //临时解锁 //do something 2 guard.lock(); //继续上锁 // do something 3 f << msg << id << endl; cout << msg << id << endl; // 结束时析构guard会临时解锁 // 这句话可要可不要,不写,析构的时候也会自动执行 // guard.ulock(); } };
另外,还可以使用std::defer_lock
设置初始化的时候不进行默认的上锁操作:
std::unique_lock<std::mutex> guard(_mu, std::defer_lock);
unique_lock的灵活是有代价的,因为它内部需要维护锁的状态,所以效率要比lock_guard
低一点,在lock_guard
能解决问题的时候,就是用lock_guard
,反之,使用unique_lock
。
另外,unique_lock
和lock_guard
都不能复制,lock_guard
不能移动,但是unique_lock
可以。
保护共享数据的初始化过程
某些场景下,我们需要代码只被执行一次,比如单例类的初始化,考虑到多线程安全,需要进行加锁控制。C++11中提供的call_once可以很好的满足这种需求。
#include<mutex> template <class Fn, class... Args> void call_once (once_flag& flag, Fn&& fn, Args&&...args);
第一个参数是std::once_flag的对象(once_flag是不允许修改的,其拷贝构造函数和operator=函数都声明为delete),第二个参数可调用实体,即要求只执行一次的代码,后面可变参数是其参数列表。
call_once保证函数fn只被执行一次,如果有多个线程同时执行函数fn调用,则只有一个活动线程(active call)会执行函数,其他的线程在这个线程执行返回之前会处于”passive execution”(被动执行状态不会直接返回,直到活动线程对fn调用结束才返回。对于所有调用函数fn的并发线程,数据可见性都是同步的(一致的)。还有一个要注意的地方是 once_flag的生命周期,它必须要比使用它的线程的生命周期要长。所以通常定义成全局变量比较好。
一些其他的互斥锁
嵌套锁:std::recursive_mutex
除了可以对同一线程的单个实例上获取多个锁,其他功能与std::mutex
相同。互斥量锁住其他线程前,必须释放拥有的所有锁,所以当调用lock()三次后,也必须调用unlock()三次。
shared_mutex(c++17)/std::shared_timed_mutex(C++ 14)
shared_mutex的适用场景比较特殊:一个或多个读线程同时读取共享资源,且只有一个写线程来修改这个资源,这种情况下才能从shared_mutex获取性能优势。对于不需要去修改数据结构的线程,
可以使用std::shared_lock<std::shared_mutex>
获取访问权。
参考资料:
https://chenxiaowei.gitbook.io/c-concurrency-in-action-second-edition-2019/3.0-chinese/3.2-chinese
https://www.jianshu.com/p/34d219380d90
https://blog.csdn.net/xijiacun/article/details/71023777