闭关之 C++ 并发编程笔记(二):同步、内存模型和原子操作

目录

第4章 并发操作的同步

4.1 等待事件或等待其他条件

  • 线程甲需要等待线程乙完成任务,可以采取几种不同方式。
    • 在共享数据内部维护一标志
      • 线程甲须不断查验标志,浪费原本有用的处理时间
      • 一旦互斥被锁住,则其他任何线程无法再加锁
    • 让线程甲调用 std::this_thread::sleep_for() 函数,在各次查验之间短期休眠。
    • 使用 C++ 标准库的工具等待事件发生
      • 条件变量
      • 优先采用这种方式。

4.1.1 凭借条件变量等待条件成立

  • 条件变量的两种实现:头文件 <condition_variable>
    • std::condition_variable
      • std::condition_variable 仅限于与 std::mutex 一起使用
    • std::condition_variable_any
      • 更加通用
      • 可能产生额外开销
    • std::condition_variable 应予优先采用,除非有必要令程序更灵活
  • 生产者、消费者模式
    • Code_4_1_1
  • 在线程间传递数据的常见方法是运用队列

4.1.2 利用条件变量构建线程安全的队列

  • 线程安全的队列代码
    • Code_4_1_2
  • 条件变量也适用于多个线程都在等待同一个目标事件的情况

4.2 使用future等待一次性事件发生

  • 若线程需等待某个特定的一次性事件发生,则会以恰当的方式取得一个future,它代表目标事件;
  • C++ 标准程序库有两种 future,分别由两个类模板实现,其声明位于标准库的头文件 <future>
    • 独占 future(unique future,即 std::future<>
    • 共享 future(shared future,即 std::shared_future<>
  • 没有关联数据,我们应使用特化的模板 std::future<void>std::shared_future<void>
  • future 能用于线程间通信
  • 多个线程需访问同一个 future 对象,必须用互斥或其他同步方式进行保护
  • C++ 的并发技术规约在 std::experimental 名字空间中给出了上述类模板的扩展版本:std::experimental::future<>std::experimental::shared_future<>
    • 名字空间 “std::experimental” 的名字(experimental)意在强调其中所含的类和函数尚未被 C++ 标准正式采纳,而非表示代码质量优劣
    • 要使用这些工具,我们必须包含头文件 <experimental/future>

4.2.1 从后台任务返回值

  • 但因为 std::thread 没有提供直接回传结果的方法,所以函数模板 std::async() 应运而生
    • 其声明也位于头文件 <future> 中。
  • 运用 std::future 取得异步任务的函数返回值
    int find_the_answer_to_ltuae();
    void do_other_stuff();
    ....
    std::future<int> the_answer=std::async(find_the_answer_to_ltuae);
    do_other_stuff();
    std::cout<<"The answer is "<<the_answer.get()<<std::endl;
    
  • std::async() 补充一个参数,以指定采用哪种运行方式, 参数的类型是 std::launch
    • std::launch::deferred
      • 指定在当前线程上延后调用任务函数
      • 等到在 future 上调用了 wait()get() ,任务函数才会执行
    • std::launch::async
      • 必须另外开启专属的线程,在其上运行任务函数
    • 该参数的值还可以是 std::launch::deferred | std::launch:: async
      • auto f=std::async(std::launch::deferred,func,std::ref(parame));
  • 凭借std::async(),即能简便地把算法拆分成多个子任务,且可并发运行
    • 运用类模板std::packaged_task<>的实例,也能将任务包装起来
    • 利用std::promise<>类模板编写代码,显式地异步求值
    • 与std::promise相比,std::packaged_task的抽象层级更高,

4.2.2 关联future实例和任务

  • std::packaged_task<> 连结了 future 对象与函数(或可调用对象)
    • std::packaged_task<> 对象在执行任务时,会调用关联的函数(或可调用对象),把返回值保存为 future 的内部数据,并令 future 准备就绪
    • 可作为线程池的构件单元
  • 若一项庞杂的操作能分解为多个子任务,则可把它们分别包装到多个 std::packaged_task<> 实例之中,再传递给任务调度器或线程池
  • 类模板 std::packaged_task<> 具有成员函数 get_future(),它返回 std::future<> 实例
  • std::packaged_task<> 特化示例:
    template<>
    class packaged_task<std::string(std::vector<char>*,int)>
    {
    public:
        template<typename Callable>
        explicit packaged_task(Callable&& f);
        std::future<std::string> get_future();
        void operator()(std::vector<char>*,int);
    };
    
  • std::packaged_task 对象是可调用对象
    • 可以直接调用
    • 还可以将其包装在std::function对象内,当作线程函数传递给 std::thread 对象
    • 也可以传递给需要可调用对象的函数
  • 在线程间传递任务
    • 许多图形用户界面(Graphical User Interface,GUI)框架都设立了专门的线程,作为更新界面的实际执行者
    • 更新界面,就必须向它发送消息,由它执行操作。该模式可以运用 std::packaged_task 实现
    • Code_4_2_2
  • 有些任务无法以简单的函数调用表达出来,还有一些任务的执行结果可能来自多个部分的代码。
    • 借助std::promise显式地异步求值去实现

4.2.3 创建std::promise

借助std::promise显式地异步求值。

  • 若应用要处理大量网络连接,通常交由少量线程负责处理(可能只有一个),每个线程同时处理多个连接。
  • std::promise<T> 给出了一种异步求值的方法(类型为T)
    • 某个 std::future<T> 对象与结果关联,能延后读出需要求取的值
    • 配对的 std::promisestd::future 可实现下面的工作机制
      • 待数据的线程在 future 上阻塞
        • get_future()
      • 而提供数据的线程利用相配的promise设定关联的值,使 future 准备就绪
        • set_value()
  • 利用多个 promise 在单个线程中处理多个连接
    • Code_4_2_3
    • 对于某些特殊场景,可能不适用

4.2.4 将异常保存到future中

  • 若经由 std::async() 调用的函数抛出异常,则会被保存到 future 中

    • 代替本该设定在 future 中的值
    • future 随之进入就绪状态,等到其成员函数 get() 被调用
    • 调用 get() 后存储在内的异常即被重新抛出
  • std::promise 也具有同样的功能它通过成员函数的显式调用实现。

    • 假如我们不想保存值,而想保存异常,就不应调用 set_value()
    • 而应调用成员函数 set_exception()
      extern std::promise<double> some_promise;
      try
      {
          some_promise.set_value(calculate_value());
      }
      catch(...)
      {
          //std::current_exception() 用于捕获抛出的异常
          some_promise.set_exception(std::current_exception());
      }
      
      • std::make_exception_ptr() 直接保存新异常,而不触发抛出行为
        • some_promise.set_exception(std::make_exception_ptr(std::logic_error("foo ")));
  • 另一种方法保存异常到 future 中:

    • 不调用 promise 的两个 set() 成员函数,也不执行包装的任务,而直接销毁与 future 关联的 std::promise 对象或 std::packaged_task 对象
    • 析构函数都会将异常 std::future_error 存储为异步任务的状态数据
    • 值为错误代码 std::future_errc::broken_promise
  • future自身存在限制

    • 它只容许一个线程等待结果。若我们要让多个线程等待同一个目标事件,则需改用 std::shared_future
  • std::future 特性

    • 它模拟了对异步结果的独占行为,get() 仅能被有效调用唯一一次
    • 这个特性令并发访问失去意义,只有一个线程可以获取目标值
    • 原因是第一次调用 get() 会进行移动操作,之后该值不复存在
  • 只要同步操作是一对一地在线程间传递数据,std::future 就都能处理

4.2.5 多个线程一起等待

  • 改用std::shared_future, 若我们从多个线程访问同一个对象,就必须采取锁保护以避免数据竞争
    • 首选方式是,向每个线程传递 std::shared_future 对象的副本,它们为各线程独自所有,并被视作局部变量
    • 这些副本就作为各线程的内部数据,由标准库正确地同步,可以安全地访问
  • future 和 promise 都具备成员函数 valid(),用于判别异步状态是否有效。
  • 应用场景
    • 复杂的电子表格及其类似的应用中,利用 std::shared_future,实现并行处理。每个单元格的终值都唯一确定,并可以用于其他单元格的计算公式
  • 创建 std::shared_future
    std::promise<std::string> p;
    std::shared_future<std::string> sf(p.get_future()); 
    

4.3 限时等待

  • 应用场景
    • 向交互的用户或其他进程告知 “我还在”
    • 或者,如果我们不想再等下去,则单击“取消”键,才可以中止程序
  • 有两种超时(timeout)机制可供选用:
    • 一是迟延超时(duration-based timeout),线程根据指定的时长而继续等待(如30毫秒)
    • 二是绝对超时(absolute timeout),在某特定时间点(time point)来临之前,线程一直等待。
  • 处理迟延超时的函数变体以 “_for” 为后缀,而处理绝对超时的函数变体以 “_until” 为后缀。
    • std::condition_variable 含有成员函数 wait_for()wait_until()
    • 它们各自具备两个重载
      • 一个重载停止等待的条件是收到信号、超时,或发生伪唤醒
      • 向另一个重载函数提供断言,在对应线程被唤醒之时,只有该断言成立(向条件变量发送信号),它才会返回,如果超时,这个重载函数也会返回

4.3.1 时钟类

  • 每种时钟都是一个类,提供4项关键信息
    • 当前时刻
    • 时间值的类型
    • 该时钟的计时单元的长度
    • 计时速率是否恒定,即能否将该时钟视为恒稳时钟 (steady clock)
      • 若时钟的计时速率恒定(无论速率是否与计时单元相符)且无法调整,则称之为恒稳时钟
  • std::chrono::system_clock::now() 可返回系统时钟的当前时刻
  • time_point 成员类型(member type),它是该时钟类自有的时间点类
  • some_clock::now() 的返回值的类型就是 some_clock::time_point
  • 时钟类的计时单元属于名为 period 的成员类型,它表示为秒的分数形式
    • 若时钟每秒计数 25 次,它的计时单元即为 std::ratio<1,25>
    • 若时钟每隔 2.5 秒计数 1 次,则其计时单元为 std::ratio<5,2>
  • 时钟类具有静态数据成员 is_steady
    • 该值在恒稳时钟内为true,否则为false。
    • std::chrono::system_clock 类不是恒稳时钟,因为它可调整。
  • 恒稳时钟对于超时时限的计算至关重要
    • 本地系统时钟的偏差,依然可能导致调用两次 now(),后来返回的时间值甚至早于前一个
  • 恒稳时钟类 std::chrono::steady_clock
  • 系统时钟类 std::chrono::system_clock
    • 表示系统的“真实时间”
    • 它具备成员函数 from_time_t()to_time_t()
    • 将 time_t 类型的值和自身的 time_point 值互相转化
  • 高精度时钟类 std::chrono::high_resolution_clock
  • 上述时钟类都在标准库的头文件中

4.3.2 时长类

  • std::chrono::duration<> 是标准库中最简单的时间部件
    • 它是类模板,具有两个模板参数,
      • 前者指明采用何种类型表示计时单元的数量(如int、long 或 double)
      • 后者是一个分数,设定该时长类的每一个计时单元代表多少秒
    • 采用 short 值计数的分钟时长类是 std::chrono::duration<short,std::ratio<60,1>>
      • 1分钟包含60秒
    • 采用 double 值计数的毫秒时长类是 std::chrono::duration<double,std::ratio<1,1000>>
      • 1毫秒是1/1000秒
  • 为方便起见,C++14 引入了名字空间 std::chrono_literals
    • 其中预定义了一些字面量后缀运算符(literal suffix operator)
      • 能够缩短明文写入代码的时长值
      • 15ns 和 std::chrono::nanoseconds(15) 是两个相等的值
      • 2.5min 为 std::chrono::duration<some-floating-point-type,std::ratio <60,1>>
      using namespace std::chrono_literals;
      auto one_day=24h;
      auto half_an_hour=30min;
      auto max_time_between_messages=30ms;
      
    • 假如不要求截断时长值,它们之间的转化将隐式进行
    • 显式转换通过 std::chrono::duration_cast<> 完成
  • 支持迟延超时的等待需要用到 std::chrono::duration<> 实例
    //等待某个future进入就绪状态,并以35毫秒为限
    std::future<int> f=std::async(some_task);
    if(f.wait_for(std::chrono::milliseconds(35))==std::future_status::ready)
        do_something_with(f.get());
    
    • 一旦超时,函数就返回 std::future_status::timeout
    • 准备就绪,则函数返回 std::future_status::ready
    • 若 future 的相关任务被延后,函数返回 std::future_status::deferred
    • 迟延超时的等待需要一个参照标准,它采用了标准库内部的恒稳时钟

4.3.3 时间点类

  • 时钟类中,时间点由类模板 std::chrono::time_point<> 的实例表示
    • 它的第一个模板参数指明所参考的时钟,
    • 第二个模板参数指明计时单元(std::chrono::duration<>的特化)
  • 时间点是一个时间跨度,始于一个称为时钟纪元的特定时刻,终于该时间点本身
  • time_since_epoch()
    • 返回一个时长对象,表示从时钟纪元到该时间点的时间长度
  • 可将时间点加减时长
    • std::chrono::high_resolution_clock::now() + std::chrono::nanoseconds(500)
      • 给出 500 纳秒以后的未来时刻
  • 两个时间点共享同一个时钟,我们也可以将它们相减,得到的结果是两个时间点间的时长
    auto start=std::chrono::high_resolution_clock::now();
    do_something();
    auto stop=std::chrono::high_resolution_clock::now();
    std::cout<<"do_something() took "
    <<std::chrono::duration<double,std::chrono::seconds>(stop-start).count()
    <<" seconds"<<std::endl;
    
  • 静态函数 std::chrono::system_clock::to_time_point() 转换 time_t 值
    • 求出基于系统时钟的时间点
    • 最优解
      • 在程序代码中的某个固定位置,将 some_clock::now()和前向偏移相加得出时间点
  • 条件变量进行限时等待
    • Code_4_3_3
      std::condition_variable cv;
      bool done;
      std::mutex m;
      bool wait_loop()
      {
          auto const timeout = std::chrono::steady_clock::now() + std::chrono::milliseconds(500);
          std::unique_lock<std::mutex> lk(m);
          while (!done)
          {
              if (cv.wait_until(lk, timeout) == std::cv_status::timeout)
                  break;
          }
          return done;
      }
      

4.3.4 接受超时时限的函数

  • 超时时限的最简单用途是,推迟特定线程的处理过程,若它无所事事,就不会占用其他线程的处理时间

  • std::this_thread::sleep_for()

    • 线程或采用 sleep_for() 按指定的时长休眠
  • std::this_thread::sleep_until()

    • 休眠直到指定时刻为止
  • 普通的 std::mutexstd::recursive_mutex 不能限时加锁

  • std::timed_mutexstd::recursive_timed_mutex 可以限时加锁

    • 这两种锁都含有成员函数 try_lock_for()try_lock_until()
      • 前者尝试在给定的时长内获取锁
      • 后者尝试在给定的时间点之前获取锁
  • C++ 标准库中接受超时时限的函数

    • std::this_thread
      • sleep_for
      • sleep_unitl
      • 无返回值
    • std::condition_variablestd::condition_variable_any
      • wait_for(lock, duration)
      • wait_until(lock, time_point)
      • 被唤醒时断言的返回值–bool
    • std::cv_status::timeoutstd::cv_status::no_timeout
      • wait_for(lock, duration, predicate)
      • wait_until(lock, time_point, predicate)
      • 被唤醒时断言的返回值–bool
  • std::timed_mutex, std::recursive_timed_mutexstd::shared_timed_mutex

    • try_lock_for(duration)
    • try_lock_until(time_point)
    • 若获取了锁,则返回 true,否则返回 false
  • std::shared_timed_mutex

    • try_lock_shared_for(duration)
    • try_lock_shared_until(time_point)
    • 若获取了锁,则返回 true,否则返回 false
  • std::unique_lock<TimedLockable>unique_lock(lockable, duration)unique_lock(lockable, time_point)

    • try_lock_for(duration)
    • try_lock_until(time_point)
    • 如果在新构建的对象上获取了锁,那么 owns_lock() 返回 true,否则返回 false
    • 若获取了锁,则返回 true,否则返回 false
  • std::shared_lock<SharedTimedLockable>, shared_lock(lockable,duration)shared_lock(lockable,time_point)

    • try_lock_for(duration)
    • try_lock_until(time_point)
    • 如果在新构建的对象上获取了锁,那么 owns_lock() 返回 true,否则返回 false
    • 若获取了锁,则返回 true,否则返回 false
  • std::future<ValueType>std::shared_future<ValueType>

    • wait_for(duration)
    • wait_until(time_point)
    • 如果等待超时则返回 std::future_status::timeout
    • 如果 future 已就绪则返回 std::future_status::ready
    • 如果 future 上的函数按推迟方式执行,且尚未开始执行,则返回 std::future_status::deferred

4.4 运用同步操作简化代码

  • 线程间不会直接共享数据,而是由各任务分别预先备妥自己所需的数据,并借助 future 将结果发送到其他有需要的线程

4.4.1 利用 future 进行函数式编程

  • 函数式编程”(functional programming)是指一种编程风格,函数调用的结果完全取决于参数,而不依赖任何外部状态。
  • 函数式语言所含的要素大多数是纯的,真正改动共享状态的是非纯函数
  • 若要以 C++ 实现函数式编程风格的并发编程,future 则是画龙点睛之笔使之真正切实可行
    • future 对象可在线程间传递,所以一个计算任务可以依赖另一个任务的结果,却不必显式地访问共享数据。
  • 函数式编程风格的快速排序
    • Code_4_4_1_1
  • 函数式编程风格的并行快速排序 (std::async())
    • Code_4_4_1_2
    • std::async() 每次都开启新线程,那么只要递归3层,就会有8个线程同时运行
    • 如果递归10层(考虑约1000个元素的情形),将有1024个线程同时运行
    • 一旦线程库断定所产生的任务过多,就有可能转为按同步方式生成新任务
  • 函数式编程风格的并行快速排序 (spawn_task())
    • 使用 packaged_task
    • Code_4_4_1_3
    • std::result_of 被弃用,这个函数需要重写
  • C++17 标准库给出了快速排序的并发重载版本
  • 全面把握线程池的构建细节,并绝对掌握线程池执行任务的方式。
    • 只有这样,才值得让 std::async() 退下“火线”,而优先采用线程池模式
  • 并发编程范式存在多种风格
    • 函数式编程是其中之一
      • 它能够摆脱共享的可变数据
    • 通信式串行进程 (Communicating Sequential Process,CSP)
      • 线程相互完全隔离,没有共享数据,采用通信管道传递消息
      • 编程语言Erlang和MPI[27]编程环境都采用了这种范式

4.4.2 使用消息传递进行同步

  • CSP的理念
    • 假设不存在共享数据,线程只接收消息
    • 那么单纯地依据其反应行为,就能独立地对线程进行完整的逻辑推断。
  • CSP线程实际上都与状态机(state machine)等效
  • 无论我们采用什么方法实现 CSP 线程,只要将它们分割出去,视作独立进程,就能让并发行为摆脱共享数据,从而大有可能消除大部分复杂性,使程序更容易编写,并且降低错误率
  • 真正的 CSP 模型没有共享数据,全部通信都经由消息队列传递
    • 但是 C++ 线程共享地址空间,因而这一规定无法强制实施
    • 我们必须恪守规定,确保剔除线程间的共享数据。
    • 作为线程间通信的唯一途径,消息队列必须共享,但细节要由程序库封装并隐藏
  • 模拟自动柜员机逻辑的一种方式是状态机
    • 等待-处理-转移
    • Code_4_4_2_1
  • 附录 C 将提供完整的代码,简单地实现自动柜员机系统
  • 上例还示范了“分离关注点”的软件设计原则
    • 通过利用多个线程,整体任务按要求被明确划分。
  • 使用消息传递编写程序
    • 无须再忧虑同步和并发的问题
    • 在某个具体的状态下,仅仅专注于所应该收发的消息即可
  • CSP风格的编程可大幅简化并发系统的设计工作,因为我们可以完全独立地处理每个线程

4.4.3 符合并发技术规约的后续风格并发

  • 并发技术规约在名字空间std::experimental内
  • 给出了对应 std::promisestd:: packaged_task 的新版本
    • 它们都返回 std::experimental::future 实例,而非 std::future
  • 一旦结果数据就绪,就接着进行某项处理”,这正是后续的功能。
    • 为 future 添加后续调用的成员函数名为 then()。
std::experimental::future<int> find_the_answer;
auto fut=find_the_answer();
auto fut2=fut.then(find_the_question);
assert(!fut.valid());
assert(fut2.valid());
  • 一旦最开始的 future 准备就绪, 后续函数 find_the_question() 会在“某一线程上”运行
    • 无法确定具体是哪个线程
    • 无法向后续函数传递参数
      • 因为参数已经由程序库预设好,先前准备就绪的 future 会传入后续函数,它所包含的结果会触发后续函数的调用

4.4.4 后续函数的连锁调用

  • 登录模块设计
    • 虽然异步可以防止主线程卡死,但是交由一个后台线程任务也很重。因此,可以使用连锁调用,开启多个线程
      • Code_4_4_4

4.4.5 等待多个future

  • 分发收集模式、类似 map reduce
  • Code_4_4_5
  • when_any() 胜任如下情形
    • 为了充分利用可调配的并发资源,我们生成多个任务同时运行,但只要其中一个完成运行,我们就需马上另外处理该项最先得出的结果

4.4.6 运用std::experimental::when_any()函数等待多个future,直到其中之一准备就绪

  • 我们可以生成多个线程,它们分别查找数据集的子集;若有线程找到了符合条件的值,就设立标志示意其他线程停止查找,并设置最终结果的值
  • Code_4_4_6
  • 其涉及std::experimental::when_any()std::experimental::when_all()的全部使用方式:
    • 两者都通过容器将多个std::experimental::future移动复制到函数中,而且它们都以传值的方式接收参数,所以我们需要显式地向函数内部移动 future,或传递临时变量。

4.4.7 线程闩和线程卡——并发技术规约提出的新特性

  • 线程闩(latch)和线程卡(barrier)的含义
    • 线程闩是一个同步对象,内含计数器,一旦减到0,就会进入就绪状态
      • 同一线程能令线程闩计数器多次减持,而多个线程也可分别令其计数器减持一次,或者两者兼有
    • 线程卡是可重复使用的同步构件,针对一组给定的线程,在它们之间进行同步。
      • 每个同步周期内,只准许每个线程唯一一次运行到其所在之处

4.4.8 基本的线程闩类 std::experimental::latch

  • std::experimental::latch 由头文件 <experimental/latch>
    • 等待目标事件数目
    • 接收唯一一个参数,在构建该类对象时,我们需通过这个参数设定其计数器的初值
    • 每当等待的目标事件发生时,我们就在线程闩对象上调用 count_down(),一旦计数器减到 0,它就进入就绪状态
    • 若要等待线程闩的状态变为就绪,则在其上调用 wait()
    • 若需检查其是否已经就绪,则调用 is_ready()
    • 要使计数器减持,同时要等待它减到0,则应该调用 count_down_and_wait()
    void foo(){
        unsigned const thread_count=...;
        //构建线程闩对象done
        latch done(thread_count);                     
        my_data data[thread_count];
        std::vector<std::future<void> > threads;
        for(unsigned i=0;i<thread_count;++i)
            //用std::async()发起相同数量的线程
            threads.push_back(std::async(std::launch::async,[&,i]{         
                //各线程负责生成相关的数据块
                data[i]=make_data(i);
                //在完成时即令计数器减持
                done.count_down(); 
                //进行下一步处理                    
                do_more_stuff();                       
            }));
        //主线程在处理生成的数据之前,只要在线程闩上等待,就能等到全部数据准备完成
        done.wait();                                   
        process_data(data,thread_count);     
        //std::future 的析构函数会发生自动调用,这保证了前面所启动的线程全都会结束运行          
    }
    

4.4.9 基本的线程卡类 std::experimental::barrier

  • 并发技术规约提出了两种线程卡
    • std::experimental::barrier
    • std::experimental::flex_barrier
    • 头文件 <experimental/barrier> 中定义
    • 前者相对简单,因而额外开销可能较低,而后者更加灵活,但额外开销可能较高
  • std::experimental::barrier 针对的场景
    • 假定有一组线程在协同处理某些数据,各线程相互独立,分别处理数据,因此操作过程不必同步。
    • 但有在全部线程都完成各自的处理后才可以操作下一项数据或开始后续处理
    • 通过在线程卡对象上调用 arrive_and_wait() 等待同步组的其他线程
    • 只要组内最后一个线程也运行至此,所有线程即被释放,线程卡会自我重置
    • 只要在线程卡上调用 arrive_and_drop(),即可令线程显式地脱离其同步组
  • 线程闩的意义在于关闸拦截
    • 一旦它进入了就绪状态,就始终保持不变
  • 而线程卡则不同
    • 线程卡会释放等待的线程并且自我重置,因此它们可重复使用
    • 线程卡只与一组固定的线程同步,若某线程不属于同步组,它就不会被阻拦,亦无须等待相关的线程卡变为就绪
  • 利用线程卡同一步组线程
    • Code_4_4_9

4.4.10 std::experimental::flex_barrier——std::experimental::barrier的灵活版本

  • 设定串行区域
    • 当所有线程运行至该线程卡处时,区域内的代码就会接着运行,直到完成后全部线程才会释放。
  • std::experimental::flex_barrier 类的接口与 std::experimental::barrier 类的不同之处仅仅在于
    • 前者具备另一个构造函数,其参数既接收线程的数目,还接收补全函数(completion function)
  • Code_4_4_10 为 Code_4_4_9 的改版
  • 我们通过补全函数设定串行区域,其功能相当强大,还能改变参与同步的线程数目

总结

关键 API

  • std::this_thread::sleep_for()
  • std::condition_variable 和 std::condition_variable_any
  • std::future<> and std::shared_future<>
  • std::async()
    • wait()
    • get()
  • std::packaged_task<>
    • get_future()
  • std::promise 执行结果可能来自多个部分的代码
    • get_future()
    • set_value()
  • set_exception()
  • std::shared_future
  • wait_for()
  • wait_until()
  • std::chrono::system_clock::now()
  • some_clock::now()
  • std::chrono::steady_clock::now()
  • std::chrono::high_resolution_clock::now()
  • std::chrono::duration<>
  • std::chrono::time_point<>
  • time_since_epoch()
  • std::chrono::system_clock::to_time_point()
  • std::timed_mutex and std::recursive_timed_mutex
    • try_lock_for()
    • try_lock_until()
  • std::experimental::future
  • then()
  • std::experimental::when_any()
  • std::experimental::when_all()
  • std::experimental::latch
    • count_down()
    • is_ready()
    • count_down_and_wait()
  • std::experimental::barrier
    • arrive_and_wait()
    • arrive_and_drop()
  • std::experimental::flex_barrier
  • std::experimental::barrier

设计思想

  • 生产者消费者 Code_4_1_1
  • 线程安全的队列 Code_4_1_2
  • 更新GUI设计的专门线程 Code_4_2_2
    • 一个或多个任务被设置,一个处理任务线程
  • 单个线程中处理多个连接 Code_4_2_3
    • 线程有限,无法同时开启N个线程,需要若干线程,每一个线程处理多个请求时可参考
    • 任务接受,处理,发送
  • 条件变量进行限时等待 Code_4_3_3
  • 函数式编程风格的并行快速排序 Code_4_4_1_3
  • 函数式编程 与 通信式串行进程(CSP)
  • 状态机 & 分离关注点 Code_4_4_2_1
    • 模拟自动柜员机逻辑
    • 等待-处理-转移
    • 思想是设置一个状态函数指针,循环执行该指针指向函数,而每个线程处理完毕后,将下一个步骤的函数指针赋值给循环中的状态函数
  • 后续连锁 登录模块设计 Code_4_4_4
    • 线程执行完成,交给下一个线程完成剩余任务
  • 分发收集模式、类似 map reduce Code_4_4_5
    • std::experimental::when_all
    • 等待所有线程执行完毕,再执行
  • 并行查询 Code_4_4_6
    • std::experimental::when_any()
    • 发起多个线程,有一个线程完成任务即可退出
  • 多线程创建数据(计数) std::experimental::latch
    • 线程闩,完成线程,计数器会减少,直至为0
  • 多线程复制数据 (同步组)Code_4_4_9, Code_4_4_10
    • 线程卡,Vulkan中经常使用

第五章 C++ 内存模型和原子操作

5.1 内存模型基础

  • 内存模型牵涉两个方面:基本结构和并发

5.1.1 对象和内存区域

  • C++ 程序的数据全部都由对象构成
  • C++ 标准只将“对象”定义为“某一存储范围”
  • 不论对象属于什么类型,它都会存储在一个或多个内存区域中
  • 用到了位域,那么请注意,它有一项重要的性质
    • 尽管相邻的位域分属不同对象,但照样算作同一内存区域
  • 4个要点:
    • 每个变量都是对象,对象的数据成员也是对象;
    • 每个对象都占用至少一块内存区域;
    • 若变量属于内建基本类型(如int或char),则不论其大小,都占用一块内存区域(且仅此一块),即便它们的位置相邻或它们是数列中的元素;
    • 相邻的位域属于同一内存区域。

5.1.2 对象、内存区域和并发

  • 重要性质:所有与多线程相关的事项都会牵涉内存区域
    • 如果两个线程各自访问分离的内存区域,则相安无事,一切运作良好;
    • 反之,如果两个线程访问同一内存区域,我们就要警惕了。假使没有线程更新内存区域,则不必在意,只读数据无须保护或同步
  • 重要:未定义行为是 C++ 中一种最棘手的问题。根据 C++ 标准,只要应用程序含有任何未定义行为,情况将难以预料。
    • 实际开发中真的很烦
  • 另一重要之处
    • 凡是涉及数据竞争的内存区域,我们都通过原子操作来访问,即可避免未定义行为。
    • 这种做法不能预防数据竞争本身,因为我们依旧无法指定某一原子操作,令其首先踏足目标内存区域,但此法确实使程序重回正轨,符合已定义行为的规范。

5.1.3 改动序列(modification order)

  • 变量的值会随时间推移形成一个序列。在不同的线程上观察属于同一个变量的序列,如果所见各异,就说明出现了数据竞争和未定义行为
  • 若我们采用了原子操作,那么编译器有责任保证必要的同步操作有效、到位

5.2 C++ 中的原子操作及其类别

  • 原子操作是不可分割的操作(indivisible operation)。在系统的任一线程内,我们都不会观察到这种操作处于半完成状态;
  • 非原子操作(non-atomic operation)在完成到一半的时候,有可能为另一线程所见。
  • 在 C++ 环境中,多数情况下,我们需要通过原子类型实现原子操作。

5.2.1 标准原子类型

  • 标准原子类型的定义位于头文件 <atomic>
  • 我们可以凭借互斥保护,模拟出标准的原子类型:
    • 它们全部(几乎)都具备成员函数 is_lock_free() ,准许使用者判定某一给定类型上的操作是能由原子指令(atomic instruction)直接实现
      • x.is_lock_free() 返回 true
    • 还是要借助编译器和程序库的内部锁来实现
      • x.is_lock_free() 返回 false
  • 原子操作的关键用途是取代需要互斥的同步方式
  • 如原子操作本身也在内部使用了互斥,就很可能无法达到所期望的性能提升
  • 而更好的做法是采用基于互斥的方式,该方式更加直观且不易出错
    • 无锁数据结构正属于这种情况
  • C++ 程序库专门为此提供了一组宏
    • 它们的作用是,针对由不同整数类型特化而成的各种原子类型,在编译期判定其是否属于无锁数据结构
  • 从 C++17 开始,全部原子类型都含有一个静态常量表达式成员变量
    • static constexpr member variable
      • X::is_always_lock_free
    • 功能与上面宏相同
      • 编译生成的一个特定版本的程序,当且仅当在所有支持该程序运行的硬件上,原子类型 X 全都以无锁结构形式实现,该成员变量的值才为 true
    • 示例
      • 假设一个程序含有原子类型 std::atomic<uintmax_t>
      • 而相关的原子操作必须用到某些 CPU 指令
      • 如果多种硬件可以运行该程序,但仅有其中一部分支持这些指令,那么等到运行时才可以确定它是否属于无锁结构
      • 此时,std::atomic<uintmax_t>::is_always_lock_free 的值在编译期即确定为 false
      • 若在任意给定的目标硬件上,std::atomic<int> 都以无锁结构形式实现
      • std::atomic<int>::is_always_lock_free 的值会是 true
      • 略,使用 C++ 17 特性
  • 只有一个原子类型不提供 is_lock_free() 成员函数
    • std::atomic_flag
    • 必须采取无锁操作
      • 类型 std::atomic_flag 的对象在初始化时清零
      • 随后即可通过成员函数 test_and_set() 查值并设置成立
      • 或者由 clear() 清零
  • 其余的原子类型都是通过类模板 std::atomic<> 特化得出的
  • C++标准并没有为普通指针定义位运算(如&=),所以不存在专门为原子化的指针而定义的位运算。
  • 开发是尽量使用 std::atomic<> 特化, 而不用别名
  • 由于不具备拷贝构造函数或拷贝赋制操作符,因此按照传统做法,标准的原子类型对象无法复制,也无法赋值
    • 可以接受内建类型赋值,也支持隐式地转换成内建类型,还可以直接经由成员函数处理
      • load()
      • store()
      • exchange()
      • compare_exchange_weak()
      • compare_exchange_strong()
    • 支持复合赋值操作
      • +=、−=、*=|=
    • 整型和指针的 std::atomic<> 特化都支持 ++−− 运算
      • 这些操作符有对应的具名成员函数,fetch_add()fetch_or()
  • C++ 的赋值操作符通常返回引用,指向接受赋值的对象,但原子类型的设计与此有别,要防止暗藏错误
    • 多线程使用引用需要注意
  • 类模板 std::atomic<> 并不局限于上述特化类型, 它其实具有泛化模板
    • 可依据用户自定义类型创建原子类型的变体
    • 该泛化模板所具备的操作仅限于以下几种
      • load()、store()
        • 接受用户自定义类型的赋值,以及转换为用户自定义类型
      • exchange()、compare_exchange_weak()、compare_exchange_strong()
  • 对于原子类型上的每一种操作,都可以提供额外的参数
    • 从枚举类 std::memory_order 取值
      • 用于设定所需的内存次序语义 (memory-ordering semantics)
    • 枚举类 std::memory_order 具有6个可能的值
      • std::memory_order_relaxed
      • std::memory_order_acquire
      • std::memory_order_consume
      • std::memory_order_acq_rel
      • std::memory_order_release
      • std::memory_order_seq_cst
  • 操作被划分为3类
    • 存储(store)操作,可选用的内存次序有
      • std::memory_order_relaxed
      • std::memory_order_release
      • std::memory_order_seq_cst
    • 载入(load)操作,可选用的内存次序有
      • std::memory_order_relaxed
      • std::memory_order_consume
      • std::memory_order_acquire
      • std::memory_order_seq_cs
    • 读-改-写”(read-modify-write)操作,可选用的内存次序有
      • std::memory_order_relaxed
      • std::memory_order_consume
      • std::memory_order_acquire
      • std::memory_order_release
      • std::memory_order_acq_rel
      • std::memory_order_seq_cst

5.2.2 操作 std::atomic_flag

  • std::atomic_flag 是最简单的标准原子类型,表示一个布尔标志。
    • 该类型的对象只有两种状态:成立或置零
  • 唯一用途是充当构建单元,因此, 我们认为普通开发者一般不会直接使用它
  • std::atomic_flag 类型的对象必须由宏 ATOMIC_FLAG_INIT 初始化
    • 它把标志初始化为置零状态
      • std::atomic_flag f=ATOMIC_FLAG_INIT
  • std::atomic_flag
    • 唯一保证无锁的原子类型
  • 如果 std::atomic_flag 对象具有静态存储期,它就会保证以静态方式初始化,从而避免初始化次序的问题。
    • 对象在完成初始化后才会操作其标志。
  • std::atomic_flag 对象的初始化后,我们只能执行3种操作
    • 销毁:对应析构函数
    • 置零:对应 clear()
    • 读取原有的值并设置标志成立:对应 test_and_set()
    • 可以为 clear()test_and_set() 指定内存次序
    • clear()是存储操作,因此无法采用 std::memory_order_acquirestd::memory_order_acq_rel内存次序
    • test_and_set() 是“读-改-写”操作,因此能采用任何内存次序
      //调用显式地采用释放语义将标志清零
      f.clear(std::memory_order_release); 
      //调用采用默认的内存次序,获取旧值并设置标志成立
      bool x=f.test_and_set(); 
      
  • 无法从 std::atomic_flag 对象拷贝构造出另一个对象
  • std::atomic_flag 功能有限,所以它可以完美扩展成自旋锁互斥(spin-lock mutex)
    class spinlock_mutex
    {
        std::atomic_flag flag;
    public:
        spinlock_mutex():
            flag(ATOMIC_FLAG_INIT)
        {}    
        void lock()
        {
            while(flag.test_and_set(std::memory_order_acquire));
        }
        void unlock()
        {
            flag.clear(std::memory_order_release);
        }
    };
    
    • 上述自旋锁互斥在 lock() 函数内忙等。
      • 若不希望出现任何程度的竞争,那么该互斥远非最佳选择

5.2.3 操作 std::atomic

  • 相比 std::atomic_flag,它是一个功能更齐全的布尔标志
  • 无法拷贝构造或拷贝赋值
  • 它们所支持的赋值操作符不返回引用,而是按值返回
  • store()是存储操作
  • load()是载入操作
  • exchange() 是“读-改-写” 操作
    std::atomic<bool> b;
    bool x=b.load(std::memory_order_acquire);
    b.store(true);
    x=b.exchange(false,std::memory_order_acq_rel);
    
  • 依据原子对象当前的值决定是否保存新值
    • 引入了一种操作
      • 若原子对象当前的值符合预期,就赋予新值
      • 这一新操作被称为“比较-交换”(compare-exchange)
      • compare_exchange_weak()compare_exchange_strong()
      • 比较-交换函数返回布尔类型
        • 如果完成了保存动作(前提是两值相等),则操作成功,函数返回 ture
        • 反之操作失败,函数返回 false
    • 比较-交换操作是原子类型的编程基石
      • 用者给定一个期望值,原子变量将它和自身的值比较,如果相等,就存入另一既定的值
      • 否则,更新期望值所属的变量,向它赋予原子变量的值
  • 佯败(spurious failure)
    • 原子化的比较-交换必须由一条指令单独完成,而某些处理器没有这种指令,无从保证该操作按原子化方式完成。
    • 要实现比较-交换,负责的线程则须改为连续运行一系列指令,但在这些计算机上,只要出现线程数量多于处理器数量的情形,线程就有可能执行到中途因系统调度而切出,导致操作失败。这种计算机最有可能引发上述的保存失败
  • compare_exchange_weak()可能佯败,所以它往往必须配合循环使用
    bool expected=false;
    extern atomic<bool> b; //由其他源文件的代码设定变量的值
    //只要expected变量还是false,
    //就说明compare_exchange_weak()的调用发生佯败,我们就继续循环
    while(!b.compare_exchange_weak(expected,true) && !expected);
    
  • 只有当原子变量的值不符合预期时,compare_exchange_strong() 才返回 false
  • 假如经过简单计算就能得出要保存的值,而在某些硬件平台上,虽然使用compare_exchange_weak() 可能导致佯败,但改用 compare_exchange_strong() 却会形成双重嵌套循环(因 compare_exchange_strong()自身内部含有一个循环),那么采用compare_exchange_weak() 比较有利于性能。
  • 反之,如果存入的值需要耗时的计算,选择 compare_exchange_strong() 则更加合理。因为只要预期值没有变化,就可避免重复计算
  • 比较-交换函数还有一个特殊之处
    • 它们接收两个内存次序参数。这使程序能区分成功和失败两种情况,采用不同的内存次序语义
    • 合适的做法是
      • 若操作成功,就采用 std::memory_order_acq_rel 内存次序
      • 否则改用 std::memory_order_relaxed 内存次序
      • 两种内存次序不准用作失败操作的参数
  • std::atomic<bool>std::atomic_flag 的另一个不同点是
    • 前者有可能不具备无锁结构,它的实现可能需要在内部借用互斥,以保证操作的原子性
    • 可以调用成员函数 is_lock_free(),检查 std::atomic<bool> 是否具备真正的无锁操作

5.2.4 操作 std::atomic<T*>:算术形式的指针运算

  • 它与 std::atomic<bool> 相似,同样不能拷贝复制或拷贝赋值
  • 根据类模板的定义,std::atomic<T*> 也具备成员函数
    • is_lock_free()
    • load()
    • store()
    • exchange()
    • compare_exchange_weak()
    • compare_exchange_strong()
    • 但接收的参数和返回值是 T* 类型
  • std::atomic<T*> 提供的新操作是算术形式的指针运算
    • fetch_add()
      • 它们返回原来的地址
      • “读-改-写”操作
    • fetch_sub()
      • 它们返回原来的地址
      • “读-改-写”操作
    • +=
    • −=
    • ++
    • −−
  • 假设变量x属于类型 std::atomic<Foo*> ,其指向 Foo 对象数组中的第一项,那么操作 x+=3 会使之变为指向数组第四项,并返回 Foo* 型的普通指针
    class Foo{};
    Foo some_array[5];
    std::atomic<Foo*> p(some_array);
    //接收附加的参数
    //p.fetch_add(3,std::memory_order_release);
    Foo* x=p.fetch_add(2); //令p加2,返回旧值
    assert(x==some_array);
    assert(p.load()==&some_array[2]);
    x=(p-=1); // 令p减1,返回新值
    assert(x==&some_array[1]);
    assert(p.load()==&some_array[1]);
    

5.2.5 操作标准整数原子类型

  • std::atomic<int>std::atomic<unsigned long long> 这样的整数原子类型上,可以执行的操作颇为齐全
    • 常用的原子操作
      • load()
      • store()
      • exchange()
      • compare_exchange_weak()
      • compare_exchange_strong()
    • 原子运算
      • fetch_add()
      • fetch_sub()
      • fetch_and()
      • fetch_or()
      • fetch_xor()
    • 复合赋值形式
      • +=−=&=|=^=
    • 前后缀形式的自增和自减
      • ++xx++−−xx−−

5.2.6 泛化的 std::atomic<> 类模板

  • 要满足一定条件才能使用模板 std::atomic<>
    • 必须具备平实拷贝赋值操作符(trivial copy-assignment operator),
    • 它不得含有任何虚函数,也不可以从虚基类派生得出
    • 还必须由编译器代其隐式生成拷贝赋值操作符
    • 若自定义类型具有基类或非静态数据成员,则它们同样必须具备平实拷贝赋值操作符
  • 由于以上限制,赋值操作不涉及任何用户编写的代码,因此编译器可借用 memcpy() 或采取与之等效的行为完成它
  • 编译器往往没能力为 std::atomic<> 生成无锁代码
    • 因此, 它必须在内部运用锁保护所有操作
  • 内建浮点类型确实满足了 memcpy()memcmp() 的适用条件
    • 故类型 std::atomic <float>std::atomic<double> 可以为我们所用
      • 若在这两个原子类型上调用 compare_exchange_strong()函数,其行为可能出人意料
  • 相比 int void* 的体积,只要用户自定义的 UDT 类型的体积不超过它们,那么在大多数常见的硬件平台上,都可以为 std::atomic<UDT> 类型采用原子指令
  • 在某些硬件平台上,就算自定义类型的体积是int或void*的两倍,同样会得到其原子指令的支持
    • 这些硬件平台往往都具有名为“双字比较-交换”的指令
    • Double-Word-Compare-And-Swap,DWCAS
    • 它与compare_exchange_xxx()函数对应
    • 这种硬件支持有助于编写无锁代码
  • 原子对象无法被创建
    • std::atomic<std::vector<int>>
    • 原因是vector含有非平实拷贝构造函数和非平实拷贝赋值操作符
  • 各原子类型上可执行的操作
    • 可查询该节的表

5.2.7 原子操作的非成员函数

  • 非成员函数,与各原子类型上的所有操作逐一等价
  • 大部分非成员函数依据对应的成员函数命名,只不过冠以前缀 “atomic_”
    • std::atomic_load()
    • 带有后缀 “_explicit”,接收更多参数以指定内存次序,
    • 不带后缀不接收内存次序参数
    • 这些非成员函数要兼容C语言,所以它们全都只接收指针,而非引用
  • 操作 std::atomic_flag 的非成员函数是
    • std::atomic_flag_test_and_set()
    • std::atomic_flag_clear()
    • 它们也具有其他变体,以后缀“_explicit”结尾
      • std::atomic_flag_test_and_set_explicit()
      • std::atomic_flag_clear_explicit()
  • C++ 标准委员会认为,额外提供std:shared_ptr<>的原子函数十分重要,所以标准库给出了共享指针的原子操作
    • 载入、存储、交换和比较-交换
      std::shared_ptr<my_data> p;
      void process_global_data()
      {
          std::shared_ptr<my_data> local=std::atomic_load(&p);
          process_data(local);
      }
      void update_global_data()
      {
          std::shared_ptr<my_data> local(new my_data);
          std::atomic_store(&p,local);
      }
      
    • 操作 std::share_ptr 的函数也具有变体
  • 并发技术规约还提供了 std::experimental::atomic_shared_ptr<T>,其也是一种原子类型。我们须包含头文件 <experimental/atomic> 才能使用该类型
    • 与std::atomic上的操作一样, 它具备以下操作
      • 载入、存储、交换和比较-交换
    • atomic_shared_ptr<> 被设计成一个独立类型,
      • 因为按照这种形式,它有机会通过无锁方式实现,而且比起普通的 std::shared_ptr 对象,它没有增加额外开销
      • 但是在目标硬件平台上,我们仍需查验它是否属于无锁实现
        • is_lock_free() 判定
  • 在多线程环境下处理共享指针,我们要避免采用普通的std::share_ptr类型
    • 也不要通过非成员原子函数对其进行操作
  • 类型 std::experimental::atomic_shared_ptr 应予优先采用(就算它不是无锁实现)
    • 可以使代码更加清晰,并确保全部访问都按原子化方式进行
    • 还能预防误用普通函数,最终避免数据竞争。
  • 标准原子类型不仅能避免未定义操作、防范数据竞争,还能让用户强制线程间的操作服从特定次序
    • std::mutexstd::future<>,都以这种强制服从的内存次序为基础
    • 其中奥义
      • 内存模型中涉及的并发细节,以及运用原子操作同步数据和强制施行内存次序

5.3 同步操作和强制次序

  • 假设有两个线程共同操作一个数据结构,其中一个负责增添数据,另一个负责读取数据。为了避免恶性条件竞争,写线程设置一个标志,用以表示数据已经存储妥当,而读线程则一直待命,等到标志成立才着手读取
  • 原子变量 data_ready 的操作提供了所需的强制次序,它属于 std::atomic<bool> 类型
    #include <vector>
    #include <atomic>
    #include <iostream>
    std::vector<int> data;
    std::atomic<bool> data_ready(false);
    void reader_thread()
    {
        while(!data_ready.load())
        {
            std::this_thread::sleep(std::chrono::milliseconds(1));
        }
        std::cout<<"The answer="<<data[0]<<"\n"; 
    }
    void writer_thread()
    {
        data.push_back(42); 
        data_ready=true; 
    }
    

5.3.1 同步关系

  • 同步关系的基本思想是:对变量x执行原子写操作W和原子读操作R,且两者都有适当的标记。只要满足下面其中一点,它们即彼此同步
    • R读取了W直接存入的值。
    • W所属线程随后还执行了另一原子写操作,R读取了后面存入的值
    • 任意线程执行一连串“读-改-写”操作(如fetch_add()或compare_exchange_weak()),而其中第一个操作读取的值由W写出
    • 用白话说就是:无论什么操作,都要操作W写后的数据

5.3.2 先行关系

  • 先行关系和严格先行关系是在程序中确立操作次序的基本要素
  • 它们的用途是清楚界定哪些操作能看见其他哪些操作产生的结果
  • 但如果同一语句内出现多个操作,则它们之间通常不存在先行关系
    • 因为C++标准没有规定执行次序
  • 先行关系和严格先行关系的不同
    • 在线程间先行关系和先行关系中,各种操作都被标记为 memory_order_consume
    • 严格先行关系则无此标记
    • 由于绝大多数代码都不会用 memory_order_consume 标记,因此实际上这一区别对我们影响甚微。
    • 简洁起见,本书后文将一律使用“先行关系”

5.3.3 原子操作的内存次序

  • 原子类型上的操作服从 6 种内存次序:
    • memory_order_relaxed
    • memory_order_consume
    • memory_order_acquire
    • memory_order_release
    • memory_order_acq_rel
    • memory_order_seq_cst。
      • 是可选的最严格的内存次序
  • 虽然内存次序共有6种,但它们只代表3种模式:
    • 先后一致次序
      • memory_order_seq_cst
    • 获取-释放次序
      • memory_order_consume
      • memory_order_acquire
      • memory_order_release
      • memory_order_acq_rel
    • 宽松次序
      • memory_order_relaxed
  • 在不同的CPU架构上,这几种内存模型也许会有不同的运行开销。
  • 采用x86或x86-64架构的CPU(如在台式计算机中常见的Intel和AMD处理器)并不需要任何额外的同步指令,就能确保服从获取-释放次序的操作的原子化,甚至不采取特殊的载入操作就能保障先后一致次序,而且其存储行为的开销仅略微增加
  • C++ 提供了上述各种内存序模型,资深程序员可以自由选用,籍此充分利用更为细分的次序关系,从而提升程序性能
  • 普通开发者则能采取默认方式,按先后一致性次序执行原子操作
    • 比起其他内存序,它分析起来要容易很多
先后一致次序 memory_order_seq_cst
  • 事件视为按先后顺序发生,其操作与这种次序保持一致
  • 尽管这种内存次序易于理解,但代价无可避免
    • 在弱保序的多处理器计算机上,保序操作会导致严重的性能损失
  • 若想使用这种内存次序,而又在意它对性能的影响,则应查阅目标处理器架构的说明文档
  • 先后一致次序示例
    • Code_5_3_3_1
  • 先后一致次序是最直观、最符合直觉的内存次序
    • 但由于它要求在所有线程间进行全局同步,因此也是代价最高的内存次序。
非先后一致次序
  • 线程之间不必就事件发生次序达成一致。
    • 我们不仅须舍弃交替执行完整操作的思维模式
    • 还得修正原来的认知,不再任由编译器或处理器自行重新排列指令
宽松次序
理解宽松次序
  • 强烈建议避免使用宽松原子操作,
获取-释放次序
  • 避免了“绝对先后一致次序”的额外开销
  • 获取-释放次序比宽松次序严格一些
  • 原子化载入即为获取操作(memory_order_acquire)
  • 原子化存储即为释放操作(memory_order_release)
  • 而原子化“读-改-写”操作则为获取或释放操作,或二者皆是(memory_order_acq_rel)
    • fetch_add()exchange()
  • 这种内存次序在成对的读写线程之间起到同步作用
    • 释放与获取操作构成同步关系
  • 获取-释放次序可用于多线程之间的数据同步,
    • 即使“过渡线程”的操作不涉及目标数据,也照样可行
  • Code_5_3_3_2
    • 注意,要理解代码,需要结合书和书中所有相关示例去看
通过获取-释放次序传递同步
  • 如果原子操作对先后一致的要求不是很严格,那么由成对的获取-释放操作实现同步,开销会远低于由保序操作实现的全局一致顺序
  • Code_5_3_3_3
获取-释放次序和 memory_order_consume 次序造成的数据依赖
  • memory_order_consume 次序是获取-释放次序的组成部分
  • 相当特别
    • 它完全针对数据依赖,引入了线程间先行关系中的数据依赖细节
    • C++17 标准明确建议对其不予采用
    • 不应在代码中使用memory_order_consume次序
小节
  • 这节虽然看懂了,但是还是有些迷茫
    • 后期关注 先后一致次序 和 获取-释放次序
      • 前者适合新手,但是开销较大
      • 后者性能会更好,但更烧脑

5.3.4 释放序列和同步关系

  • 构成一个释放序列
    • 如果存储操作的标记是
      • memory_order_release
      • memory_order_acq_rel
      • memory_order_seq_cst
    • 而载入操作则以
      • memory_order_consume
      • memory_order_acquire
      • memory_order_seq_cst
    • 这些操作前后相扣成链,每次载入的值都源自前面的存储操作,那么该操作链由一个释放序列组成
  • 若最后的载入操作服从内存次序为 memory_order_acquire 或 memory_order_seq_cst 则最初的存储操作与它构成同步关系
  • 共享队列容器操作 生产者、消费者
    • Code_5_3_4

5.3.5 栅栏

  • 栅栏也常常被称作“内存卡”或“内存屏障”
    • 它们在代码中划出界线,限定某些操作不得通行
  • 用途是强制施加内存次序,却无须改动任何数据
    • 通常,它们与服从 memory_order_relaxed 次序的原子操作组合使用
  • 栅栏可以令宽松操作服从一定的次序
    • Code_5_3_5
  • 栅栏的整体运作思路是:
    • 若存储操作处于释放栅栏后面,而存储操作的结果为获取操作所见,则该释放栅栏与获取操作同步
    • 若载入操作处于获取栅栏前面,而载入操作见到了释放操作的结果,则该获取栅栏与释放操作同步
  • 栅栏存在与否并不影响已经加诸其上的次序

5.3.6 凭借原子操作令非原子操作服从内存次序

  • Code_5_3_6
    • 与 Code_5_3_5 差距就是,使用普通 bool 类型 而不是原子类型
  • 令非原子操作服从内存次序的不只有栅栏。
    • 凭借分别标记为 memory_order_release 和 memory_order_consume 的原子操作,就能按非原子化的方式访问动态分配的对象

5.3.7 强制非原子操作服从内存次序

  • 先行关系中蕴含着控制流程的先后执行顺序
    • 利用这一重要性质,即可借原子操作强制非原子操作服从内存次序
  • 第2~4章讲解了多种同步机制,根据同步关系,按各种形式为相关内存次序提供了保证
    • std::thread
      • 执行了join调用,而此函数成功返回,则该线程的运行完成与这一返回动作同步
    • std::mutex、std::timed_mutex、std::recursive_mutex和std::recursive_timed_mutex
      • 给定一互斥对象,在其加锁和解锁的操作序列中,每个unlock()调用都与下一个lock()调用同步,
      • 或与下一个try_lock()、try_lock_for()、try_lock_until()的成功调用同步
      • 如果try_lock()、try_lock_for()或try_lock_until()的调用失败,则不构成任何同步关系
    • std::shared_mutex和std::shared_timed_mutex
      • 其加锁和解锁的操作序列中,每个unlock()调用都与下一个 lock()调用同步,或与下一个 try_lock()、try_lock_for()、try_lock_until()、try_lock_shared()、try_lock_shared_for()和try_lock_shared_until()的成功调用同步
    • std::promise、std::future和std::shared_future
      • future对象上调用wait()、get()、wait_for()或wait_until(),成功返回std::future_status::ready,那么这两次调用的成功返回构成同步
      • 我们在关联的std::future对象上调用wait()、get()、wait_for()或wait_until(),成功返回std::future_status:: ready,那么std::promise对象的析构函数与该成功返回构成同步。
    • std::packaged_task、std::future和std::shared_future
      • 给定一std::packaged_task对象,则我们由get_future()得到关联的std::future对象,它们共享异步状态。若包装的任务由std::packaged_task的函数调用操作符运行,我们在关联的std::future对象上调用wait()、get()、wait_for()或wait_until(),成功返回std::future_status::ready,那么任务的运行完结与该成功返回构成同步
    • std::async、std::future和std::shared_futur
      • 我们在该 std::future 对象上调用wait()、get()、wait_for()或wait_until(),成功返回std::future_status:: ready,那么任务线程的完成与该成功返回构成同步。
    • std::experimental::future、std::experimental::shared_future和后续函数
      • 异步共享状态会因目标事件触发而变成就绪,共享状态上所编排的后续函数也随之运行,该事件与后续函数的启动构成同步
    • std::experimental::latch
      • 给定一std::experimental::latch实例,若在其上调用count_down()或count_down_and_wait(),则每次调用的启动都与其自身的完成同步。
    • std::experimental::barrie
      • 给定一std::experimental::barrier实例,若在其上调用arrive_and_wait()或arrive_and_drop(),则每次调用的启动都与下一次arrive_and_wait()的运行完成同步。
    • std::experimental::flex_barrie
      • 给定一std::experimental::flex_barrier实例,若在其上调用arrive_and_wait()或arrive_and_drop(),则每次调用的启动都与下一次arrive_and_wait()的运行完成同步
      • 给定一std::experimental::flex_barrier实例,若在其上调用arrive_and_wait()或arrive_and_drop(),则每次调用的启动都与其补全函数的下一次启动同步
      • 给定一std::experimental::flex_barrier实例,若在其上调用arrive_and_wait()或arrive_and_drop(),则这些调用会因等待补全函数的完成而发生阻塞,而补全函数的返回与这些调用的完成构成同步
    • std::condition_variable和std::condition_variable_any
      • 条件变量并不提供任何同步关系。
      • 它们本质上是忙等循环的优化,其所有同步功能都由关联的互斥提供

总结

关键 API

  • is_lock_free()
  • is_always_lock_free()
  • std::atomic_flag
    • test_and_set()
    • clear()
  • std::atomic<>
    • load()
    • store()
    • exchange()
    • compare_exchange_weak()
    • compare_exchange_strong()
  • std::memory_order
    • std::memory_order_relaxed
    • std::memory_order_acquire
    • std::memory_order_consume
    • std::memory_order_acq_rel
    • std::memory_order_release
    • std::memory_order_seq_cst
  • std::atomic_flag
  • std::atomic
    • store()
    • load()
    • exchange()
  • std::atomic<T*>
    • is_lock_free()
    • load()
    • store()
    • exchange()
    • compare_exchange_weak()
    • compare_exchange_strong()
    • fetch_add()
    • fetch_sub()
  • std::atomic 和 std::atomic
    • load()
    • store()
    • exchange()
    • compare_exchange_weak()
    • compare_exchange_strong()
  • std::atomic<>
  • std::atomic_load()
  • std::atomic_flag_test_and_set()
  • std::atomic_flag_clear()
  • std::atomic_flag_test_and_set_explicit()
  • std::atomic_flag_clear_explicit()
  • std::experimental::atomic_shared_ptr

设计思想

  • 自旋锁互斥 (笔记中)
  • 共享队列容器操作 生产者、消费者 Code_5_3_4
  • 1
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值