读书笔记《C++并发编程实战》(3) - 线程间数据共享

竞争条件:
    进程中各个线程可共享数据,既是优点也是缺点,而导致不同线程操作数据出现异常的情况时,竞争条件是其中之一。

避免竞争条件:
    一:采用保护机制,封装正在修改的或访问的数据结构,确保其他线程在共享数据被修改前或修改后才可见可访问。
    二:修改数据结构设计、或者使用不变量,如无锁编程(无锁数据结构等)。
    
互斥体保护:
    互斥体作为同步原语,可确保一个线程获取共享数据时锁定资源,并处理资源后解锁,其他线程才可见可访问该资源。
    不过需要精心处理互斥体,确保不会出现死锁、保护过多、过少的数据。
    std::mutex封装了基本的锁操作,此外使用std::lock_guard类模板实现互斥体的RAII惯用法,以避免显式调用lock/unlock等接口。
    另外互斥体一般与受保护的共享数据放在一起,如同一个类中,且需要精心设计避免受保护的数据外泄(如:抛出地址或引用或者传入
    了其他可访问受保护的共享数据的外部函数等)。线程已持有std::mutex锁时,若该线程再次获取该锁将出现异常,此时应使用
    递归锁std::recursive_mutex。

死锁:
    一般在两个或者多个线程拥有对方的锁资源时却相互等待另一方释放所拥有的锁资源。进而产生死锁。这个情况常出现在需要锁定两个
    或者更多互斥体或其他原语时以执行操作时最常出现的情形。其他的情形也包括等待相互的另一方线程退出或者其他资源或同步需求时。
    
避免死锁的一般做法:
    始终使用相同的顺序锁定两个或多个互斥体或其他原语。但尽管如此,此也可能导致出现死锁(如相同实例对象执行交换操作)。
    此时可借助std::lock模板函数同时锁定两个或者多个锁并结合std::lock_guard类模板来释放已被锁住的锁(借助std::adopt_lock占位标识
    时的构造方式)。std::lock模板函数可实现类似于事务的方式,要么同时获取到锁要么均失败。

避免死锁的其他方法:
    1. 避免锁嵌套;也即是单个线程至多持有一个锁,避免再获取其他锁,此外若需要获取多个锁,则应结合使用std::lock模板函数来获取多个锁。
    2. 避免持有锁时,再调用用户提供的代码:客户提供的代码可能会引入获取锁的操作,进而可能导致又出现获取多个锁时的死锁问题。
    3. 以固定顺序获取锁:对于需要获取多个锁且不使用std::lock模板函数时,则每个线程应以相同的顺序获取这些锁。
    4. 使用锁层次方式:给每个互斥体或其他原语分配层号,并记录每个线程都锁定了哪些互斥体。当代码试图锁定一个互斥体时,若它在较为低层
       已持有锁定,则不允许它获取该互斥体(也即此互斥体的层号比已所锁定的互斥体的层号相等或更高,否则获取更低层号的互斥体则是允许的)。
    5. std::unique_lock模板锁定,该锁定模板相对std::lock_guard更灵活可不需拥有该锁对象且有lock/unlock/trylock等接口实现操作锁;
       但其占用多一点空间和损耗一定的效率,此外该模板锁还支持所有权转移也即std::move语义。
    
锁定的粒度:
    应对需要的共享数据的保护进行加锁,避免粒度过大或者过小的情况。过大时可能导致多线程并发性能。
    此外锁不仅需要关心锁定粒度还有持有锁时的时间,避免长时间持有锁。
    
共享数据的其他保护机制或工具:
    1. 在初始化时保护共享数据:保护第一次初始化。(二次检查锁定存在一定的问题,即数据竞争,不同线程在读取或是写入某个对象或指针时
       并不同步),std::once_flag以及std::call_once可解决此类问题。事实上对于返回局部static的对象在早期C++11之前可能会有问题的会出现竞争,
       但C++11之后返回局部的static对象也是安全的且可以替代std::call_once的。
    2. 保护很少更新的数据结构,例如使用读写锁,即单个独占的写锁,多个共享的读锁。以减少因读操作而被频繁保护数据结构加锁。
    3. 递归锁std::recursive_mutex;可实现多次获取同一个锁,但是获取和释放需成对进行的。可结合std::unique_lock或std::lock_guard来防止获取
       与释放锁不一致的情况。通常递归锁是不推荐的,此时应考虑重新设计实现或者重构部分实现以避免递归锁。

猜你喜欢

转载自www.cnblogs.com/haomiao/p/11647391.html