闭关之 C++ 并发编程笔记(一):线程与锁

前言

  Vulkan 算是入门了,但是如果想要更深入的理解其 Command 的机制,还是有必要巩固一下并发编程的基础。所以就买了一本《C++ 并发编程实战 (第二版)》。看了三分之一了,但是觉得光看是没有意义的,重写示例算是必修课!所以边做笔记边实践吧 #109

  • 参考书籍:《C++ 并发编程实战 (第二版)》
  • 参考代码笔记: https://gitee.com/thebigapple/Study_CPlusPlus_20_For_CG.git

第一章 你好,C++并发世界

  • 并发的方式
    • 多进程并发
    • 多线程并发
  • C++ 本身尚不直接支持进程间通信,所以采用多进程的应用软件将不得不依赖于平台专属的应用程序接口(Application Program Interface,API)。
  • 增强性能的并发方
    • 采用这种方式处理单一数据所需的时间依旧不变,而同等时间内能处理的数据相对更多。
  • 避免并发
    • 收益不及代价
    • 性能增幅可能不如预期。线程的启动存在固有开销,
    • 线程是一种有限的资源。若一次运行太多线程,便会消耗操作系统资源,可能令系统整体变慢。
    • 在采用扁平模式内存架构的32位进程中,可用的地址空间是4GB
      • 这很成问题:
        • 假定每个线程栈的大小都是1MB(这个大小常见于许多系统),那么4096个线程即会把全部地址空间耗尽,使得代码、静态数据和堆数据无地立足。
    • 运行的线程越多,操作系统所做的上下文切换就越频繁,每一次切换都会减少本该用于实质工作的时间。
  • 抽象损失
  • 太多线程争抢同一个互斥对象,就会严重影响性能。
  • C++线程库的某些型别有可能提供成员函数native_handle(),允许它的底层直接运用平台专属的API。
  • 管控线程的函数和类在中声明,
  • 每个线程都需要一个起始函数(initial function)、新线程从这个函数开始执行
  • join()会令主线程等待子线程

第二章 线程管控

2.1 线程的基本管控

  • 每个 C++ 程序都含有至少一个线程,即运行main()的线程

2.1.1 发起线程

  • void do_some_work();
  • std::thread my_thread(do_some_work);
  • 抽象
    class background_task
    {
    public:
        void operator()() const
        {
            do_something();
            do_something_else();
        }
    };
    background_task f;
    std::thread my_thread(f);
    
  • 调用detach(),明确设定不等待
    • 由于主函数退出、指针和引用无效导致子线程错误
      • 外理方法
        • 令线程函全自含(self-contained),将数据复制到新线程内部,而不是共享数据。
        • 以下做法极不可取:
          • 意图在函数中创建线程,并让线程访问函数的局部变量。除非线程肯定会在该函数退出前结束,否则切勿这么做。
        • 处理上述情形的另一种方法是汇合新线程,此举可以确保在主线程的函数退出前,新线程执行完毕。

2.1.2 等待线程完成

  • 调用成员函数join()待线程完成
  • 如需选取更精细的粒度控制线程等待, 如:
    • 查验线程结束与否
    • 限定只等待一段时间
  • 需改用其他方式,如:条件变量和future。

2.1.3 在出现异常的情况下等待

  • 如果线程启动以后有异常抛出,而join()尚未执行,则该join()调用会被略过
  • 打算在没发生异常的情况下调用join(),那么就算出现了异常,同样需要调用join(),以避免意外的生存期问题
    • 更优解
      • 可以设计一个类,运用标准的RAII手法,在其析构函数中调用join()
  • RAII (Resource Acquisition Is Initialization),
    • 称为“资源获取就是初始化”,是C++语言的一种管理资源、避免泄漏的惯用法
    • Code_2_1_3
    • 在任何执行线程(thread of execution)上,join()只能被调用一次。假如线程已经汇合过,那么再次调用join()则是错误行为。
    • 复制构造函数和复制赋值操作符都以“=delete”标记,限令编译器不得自动生成相关代码

2.1.4 在后台运行线程

  • UNIX操作系统中,有些进程叫作守护进程(daemon process),它们在后台运行且没有对外的用户界面;沿袭这一概念,分离出去的线程常常被称为守护线程(daemon thread)
  • 若要分离线程,则需在std::thread对象上调用其成员函数detach()。
  • 只有当t.joinable()返回true时,我们才能调用t.detach()
  • 代码清单 2.4 (Code_2_1_4) 线程方式可以用于编辑器

2.2 向线程函数传递参数

  • 线程具有内部存储空间,参数会按照默认方式先复制到该处,新创建的执行线程才能直接访问它们
  • 若需按引用方式传递参数,只要用std::ref()函数加以包装即可
  • 若要将某个类的成员函数设定为线程函数,我们则应传入一个函数指针,指向该成员函数。此外,我们还要给出合适的对象指针,作为该函数的第一个参数
    class X
    {
    public:
    	void do_lengthy_work();
    };
    X my_x;
    std::thread t(&X::do_lengthy_work,&my_x);
    
  • 使用 std::unique_ptr

2.3 移交线程归属权

  • 两种操作需要转移线程的归属权
    • 编写函数,功能是创建线程,并置于后台运行,但该函数本身不等待线程完结,而是将其归属权向上移交给函数的调用者
    • 创建线程,遂将其归属权传入某个函数,由它负责等待该线程结束
  • std::thread支持移动操作的意义是,函数可以便捷地向外部转移线程的归属权
  • 在离开其对象所在的作用域前,确保线程已经完成
    • 20之前新建scoped_thread
    • 20之后使用std::jthread
      • 只要其执行析构函数,线程即能自动汇合
      • Code_2_1_5
        • 重点就是先 join 再 move

2.4 在运行时选择线程数量

  • std::thread::hardware_concurrency()函数,它的返回值是一个指标,表示程序在各次运行中可真正并发的线程数量
  • std::accumulate 累加求和并行算法并行算法
    • 这类算法自己实现过,拿这个做标杆也不错
    • 代码清单 2.8 (Code_2_1_6)
    • std::mem_fn 生成指向成员的指针的包装对象
      Foo f;
      
      auto hello = std::mem_fn(&Foo::say_hello);
      hello(f);
      
      auto count_num = std::mem_fn(&Foo::count_num);
      count_num(f, 2);
      

2.5 识别线程

  • 线程ID所属型别是std::thread::id,它有两种获取方法。
    • 与线程关联的std::thread对象上调用成员函数get_id()
    • 当前线程的ID可以通过调用std::this_thread::get_id()获得

关键 API

  • join()
  • detach()
  • joinable()
  • std::ref()
  • std::thread::hardware_concurrency()
  • get_id()
  • std::this_thread::get_id()
  • std::distance()
  • std::advance()
  • std::for_each()
  • std::mem_fn()
  • std::accumulate()

第三章 在线程间共享数据

3.1 线程间共享数据的问题

3.1.1 条件竞争

  • 数据竞争 (data race)
    • 并发改动单个对象而形成的特定的条件竞争
    • 数据竞争会导致未定义行为

3.1.2 防止恶性条件竞争

  • 有几种方法防止恶性条件竞争
    • 采取保护措施包装数据结构,确保不变量被破坏时,中间状态只对执行改动的线程可见。
    • 修改数据结构的设计及其不变量,由一连串不可拆分的改动完成数据变更,每个改动都维持不变量不被破坏。即,无锁编程
    • 修改数据结构当作事务(transaction)来处理、软件事务内存 (Software Transactional Memory,STM)
      • C++ 没有直接支持 STM
  • 在 C++ 标准中,保护共享数据的最基本方式就是互斥

3.2 用互斥保护共享数据

  • 互斥(mutual exclusion,略作mutex)
  • 同步原语(synchronization primitive)

3.2.1 在 C++ 中使用互斥

  • 在 C++ 中,我们通过构造 std::mutex 的实例来创建互斥,调用成员函数 lock() 对其加锁,调用 unlock() 解锁。
    • 不建议使用
  • C++ 标准库提供了类模板 std::lock_guard<>,针对互斥类融合实现了 RAII 手法:在构造时给互斥加锁,在析构时解锁,从而保证互斥总被正确解锁。
    • 建议使用
  • 头文件 <mutex>
  • 如果编译器支持 C++17 标准, std::lock_guard<> 模板参数列表可以忽略
  • C++17 还引入了 std::scoped_lock,它是增强版的 lock_guard
    • std::scoped_lock guard(some_mutex);
    • Code_3_2_1
      std::mutex some_mutex;        
      ... 
      std::scoped_lock guard(some_mutex);
      
  • 若利用互斥保护共享数据,则需谨慎设计程序接口,从而确保互斥已先行锁定,再对受保护的共享数据进行访问,并保证不留后门
    • 如果成员函数返回指针或引用,指向受保护的共享数据,锁会失效。

3.2.2 组织和编排代码以保护共享数据

  • 一旦出现游离的指针或引用,这种加锁保护就全部形同虚设
  • 只要遵循下列指引即可应对上述情形:
    • 不得向锁所在的作用域之外传递指针和引用,指向受保护的共享数据,无论是通过函数返回值将它们保存到对外可见的内存,还是将它们作为参数传递给使用者提供的函数。

3.2.3 发现接口固有的条件竞争

  • 消除条件竞争方法
    • 传入引用
      • 缺点是需要构造新对象用于接收存在条件竞争的数据
    • 提供不抛出异常的拷贝构造函数,或不抛出异常的移动构造函数
    • 返回指针,指向弹出的元素
      • 指针型别std::shared_ptr是不错的选择
    • 结合方法1和方法2,或结合方法1和方法3
  • 类定义示例:线程安全的栈容器类
    • Code_3_2_3

3.2.4 死锁:问题和解决方法

  • 防范死锁的建议通常是,始终按相同顺序对两个互斥加锁。
  • C++ 标准库提供了 std::lock() 函数,可以同时锁住多个互斥
    • std::lock(lhs.m, rhs.m);
  • std::adopt_lock对象,指明互斥已被锁住,即互斥上有锁存在
    • std::lock_guard 实例应当据此接收锁的归属权,不得在构造函数内试图另行加锁
  • 使用 std::scoped_lock 后,就不需要 std::lock 和 std::adopt_lock 实现互斥
  • 进行内部数据的互换操作
    void swap(X& lhs, X& rhs)
    {
    	if(&lhs==&rhs)
    		return;
    	std::scoped_lock guard(lhs.m, rhs.m);   
    	swap(lhs.some_detail,rhs.some_detail);
    }
    

3.2.5 防范死锁的补充准则

  • 避免嵌套锁

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

  • 依从固定顺序获取锁

  • 按层级加锁

    • 按特定方式规定加锁次序,在运行期据此查验加锁操作是否遵从预设规则
    • 使用层级锁防范死锁
      • Code_3_2_5_1
    • 层级互斥
      • Code_3_2_5_2
  • try_lock(),但它相当简单:

    • 若另一线程已在目标互斥上持有锁,则函数立即返回false,完全不等待
  • 将准则推广到锁操作以外

    • 我们应尽可能避免获取嵌套锁

3.2.6 std::unique_lock<>灵活加锁

  • 标准库针对一些情况提供了 std::unique_lock<> 模板。它与 std::lock_guard<> 一样,也是一个依据互斥作为参数的类模板,并且以 RAII 手法管理锁,不过它更灵活一些
  • 类模板 std::unique_lock<> 放宽了不变量的成立条件,因此它相较 std::lock_guard<> 更灵活一些
  • std::unique_lock 对象不一定始终占有与之关联的互斥
    • 可以传入 std::adopt_lock 实例,借此指明 std::unique_lock 对象管理互斥上的锁;
    • 可以传入 std::defer_lock 实例,从而使互斥在完成构造时处于无锁状态,等以后有需要时才在 std::unique_lock 对象(不是互斥对象)上调用 lock() 而获取锁,或把std::unique_lock 对象交给 std::lock() 函数加锁
  • std::unique_lock 占用更多的空间,也比 std::lock_guard 略慢
    • std::unique_lock 对象可以不占有关联的互斥,具备这份灵活性需要付出代价:需要存储并且更新互斥信息。
  • 采用 std:: unique_lock 替换 std::lock_guard ,便会导致轻微的性能损失
    • 如果 std::lock_guard 已经能满足所需,我建议优先采用
    • std::unique_lock 可用场景
      • 延时加锁
      • 从某作用域转移锁的归属权到其他作用域
  • std::scoped_lock 可代替 std::lock_guard 但不能取代 std::unique_lock ,他们的作用不同

3.2.7 在不同作用域之间转移互斥归属权

  • 因为 std::unique_lock 实例不占有与之关联的互斥,所以随着其实例的转移,互斥的归属权可以在多个 std::unique_lock 实例之间转移。
    • std::unique_lock 属于可移动却不可复制的型别
  • 用途:准许函数锁定互斥,然后把互斥的归属权转移给函数调用者,好让他在同一个锁的保护下执行其他操作
    std::unique_lock<std::mutex> get_lock()
    {
    	extern std::mutex some_mutex;
    	std::unique_lock<std::mutex> lk(some_mutex);
    	prepare_data();
    	return lk;
    }
    void process_data()
    {
    	std::unique_lock<std::mutex> lk(get_lock());
    	do_something();
    }
    

3.2.8 按适合的粒度加锁

  • 粒度为保护数据的量
  • 操作有两个要点:
    • 一是选择足够粗大的锁粒度,确保目标数据受到保护;
    • 二是限制范围,务求只在必要的操作过程中持锁。
  • 持锁期间应避免任何耗时的操作,如读写文件。除非锁的本意正是保护文件访问
  • 否则,为I/O操作加锁将毫无必要地阻塞其他线程(它们因等待获取锁而被阻塞),即使运用了多线程也无法提升性能。

3.3 保护共享数据的其他工具

  • 互斥是保护共享数据的最普遍的方式之一

3.3.1 在初始化过程中保护共享数据

  • 延迟初始化(lazy initialization)
    • 等到必要时才真正着手创建
    • 单线程
    • 初始化后的共享数据本身就能并发访问,但是延迟初始化时不是线程安全的
  • 用互斥实现线程安全的延迟初始化
    • 在C++标准库中提供了std::once_flag类和std:: call_once()函数
      • 所有线程共同调用std::call_once()函数,从而确保在该调用返回时, 指针初始化由其中某线程安全且唯一地完成
      • 必要的同步数据则由std::once_flag实例存储,每个std::once_flag实例对应一次不同的初始化
      • std::once_flag的实例既不可复制也不可移动
        std::shared_ptr<some_resource> resource_ptr;
        std::once_flag resource_flag; 
        void init_resource()
        {
        	resource_ptr.reset(new some_resource);
        }
        void foo()
        {
        	std::call_once(resource_flag, init_resource); 
        	resource_ptr->do_something();
        }
        
  • 适用于多线程使用单例
  • 线程安全的单例初始化
    class my_class;
    my_class& get_my_class_instance()    
    {
    	//利用局部静态变量,实现线程安全
    	static my_class instance;  
    	return instance;
    }
    

3.3.2 保护甚少更新的数据结构

  • 我们想要一种数据结构,若线程对其进行更新操作,则并发访问从开始到结束完全排他,及至更新完成,数据结构方可重新被多线程并发访问。
  • 读写互斥
    • 允许单独一个 “写线程” 进行完全排他的访问,也允许多个 “读线程” 共享数据或并发访问。
  • C++17 标准库提供了两种新的互斥:std::shared_mutexstd::shared_timed_mutex
  • C++14 标准库只有 std::shared_timed_mutex ,而 C++11 标准库都没有。
  • std::shared_mutexstd::shared_timed_mutex 的区别在于,后者支持更多操作。
    • 所以,若无须进行额外操作,则应选用 std::shared_mutex,其在某些平台上可能会带来性能增益。
  • C++14 引入了共享锁的类模板、其工作原理是RAII过程
  • 读写互斥代码
    #include <map>
    #include <string>
    #include <mutex>
    #include <shared_mutex>
    
    class dns_entry
    {};
    
    class dns_cache
    {
    	std::map<std::string,dns_entry> entries;
    	std::shared_mutex entry_mutex;
    public:
    	dns_entry find_entry(std::string const& domain)
    	{
    		std::shared_lock<std::shared_mutex> lk(entry_mutex);
    		std::map<std::string,dns_entry>::const_iterator const it = entries.find(domain);
    		return (it==entries.end())?dns_entry():it->second;
    	}
    	void update_or_add_entry(std::string const& domain,  dns_entry const& dns_details)
    	{
    		std::lock_guard<std::shared_mutex> lk(entry_mutex);
    		entries[domain]=dns_details;
    	}
    };
    
    int main()
    {}
    
    

3.3.3 递归加锁

  • C++ 标准库为此提供了 std::recursive_mutex ,其工作方式与 std::mutex 相似,不同之处是,其允许同一线程对某互斥的同一实例多次加锁。
  • std::recursive_mutex
    • 可以重复加锁
    • 加锁几次,就必须解锁几次
  • 每个公有函数都需先锁住互斥,然后才进行操作,最后解锁互斥。但有时在某些操作过程中,公有函数需要调用另一公有函数。在这种情况下,后者将同样试图锁住互斥,如果采用 std::mutex 便会导致未定义行为。
  • 不推荐使用递归锁
    • 因为递归问题可以转化,更有利于锁的管理

总结

关键 API

  • std::mutex
  • std::scoped_lock()
  • std::lock()
  • try_lock()
  • std::adopt_lock : 指明互斥已被锁住
  • std::defer_lock : 互斥在完成构造时处于无锁状态
  • std::unique_lock<>
  • std::once_flag 和 std::call_once() : 延迟初始化
  • std::shared_mutex 和 std::shared_lock<> : 读写互斥
  • std::recursive_mutex : 递归锁,不建议使用

设计思想

  • 线程安全的栈容器类 Code_3_2_3
  • 层级锁防范死锁 Code_3_2_5_1
  • 层级互斥 Code_3_2_5_2
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值