《C++并发实战》精读总结

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

本章主要内容

本章以 C++11 为起点,依次讲解并发与多线程概念、使用并发的理由、C++ 并发发展历史,最后给出最简 C++ 多线程示例;点明 C++11 首次在标准层面提供跨平台多线程库,适配多核硬件发展趋势,同时指出:多线程语法简单,难点是保证并发下程序行为正确。

一、何谓并发和多线程

并发是两个或多个独立活动同时发生。计算机里分为两种实现形态:

  1. 单核任务切换(假象并发):单核 CPU 快速在任务间轮换执行,依靠上下文切换模拟同时运行,切换存在保存恢复现场、缓存失效带来的开销;假象并发和硬件并发环境下,程序行为会存在细微差异。
  2. 硬件并发:多核 / 多处理器真正并行执行任务,硬件线程数量决定硬件最大并行上限;即便多核机器,任务总数通常多于硬件线程,依旧会进行任务调度切换。

并发有两条实现途径:

  1. 多进程并发:进程相互隔离,依靠管道、socket、信号等 IPC 通信。优点是隔离性强、并发代码更安全,还可以跨机器分布式部署;缺点是进程启动、通信开销大,通信机制复杂,C++ 标准无原生 IPC 支持。
  2. 多线程并发(本书研究对象) :同一进程内的多个线程,属于轻量级执行流,共享进程地址空间,线程间通信开销远低于进程;代价是共享内存没有自动保护机制,程序员需要自行保证共享数据一致性。本书后续提到的 "并发" 默认指代多线程并发。

二、应用程序为什么要使用并发和多线程

使用并发只有两类合理动机,同时要判断何时不该使用:

  1. 关注点分离:把不同职责拆分到独立线程,解耦代码逻辑、提升交互响应性。典型场景:UI 线程处理交互,其他线程执行后台耗时任务。这种划分是软件逻辑层面,线程数量不和 CPU 核心绑定。
  2. 提升性能 :CPU 不再依靠提高单核主频提速,需要并发挖掘多核算力。分为任务并行 (一个大任务分不同阶段交给多个线程)和数据并行(多线程对多份数据执行相同运算,易并行算法扩展性好);既可以缩短单个任务耗时,也可以提升整体吞吐量。
  3. 不适合使用并发的原则:当收益无法覆盖成本时不使用。成本包含线程启动开销、系统资源消耗、频繁上下文切换损耗,以及代码复杂度提升、更容易引入 bug;线程资源有上限,线程过多反而降低性能,高并发场景可借助线程池管控线程数量。只有性能收益显著,或是关注点分离收益明确时,才适合引入并发。

三、C++ 的并发史

  1. C++98 标准没有线程概念,也没有正式内存模型,原生不支持多线程;早期多线程开发只能依赖 POSIX、Windows 平台 API,或是 Boost、ACE、MFC 这类第三方库封装接口,代码不具备跨平台一致性,缺少统一内存模型,跨平台容易出问题。
  2. C++11 是里程碑:新增线程感知内存模型,标准库提供线程管理、共享数据保护、同步机制、原子操作整套组件,大量设计参考 Boost.Thread,实现可移植的 C++ 多线程编程。
  3. 库设计目标:追求低抽象惩罚 ,标准库封装性能接近底层原生 API;同时提供底层原子操作,替代平台汇编;预留native_handle()接口,可以拿到底层平台句柄调用平台专属能力,但代码会丧失可移植性。

四、一个简单的 C++ 多线程程序

示例用std::thread创建子线程执行任务,#include <thread>是线程管理头文件;每个线程都要有入口函数,主线程入口是main,子线程入口在构造std::thread对象时传入;join()作用是让主线程阻塞等待子线程执行完毕,防止主线程提前退出导致子线程直接终止。 这个示例仅用于语法演示,本身没有工程价值,印证了并发不能随意滥用。

本章核心一句话小结

随着多核普及,C++11 标准化多线程,让跨平台多线程编程成为可能;并发分多进程、多线程两种路线,合理用途是关注点分离与性能优化,并发语法简单,难点在于处理共享数据、保证并发正确性。

第二章 线程管理

本章主要内容

本章围绕std::thread讲解线程基础管理:启动新线程、向线程传递参数、等待线程(join)与分离线程(detach)、线程所有权转移、运行时确定线程数量、线程唯一标识符。核心规则:std::thread 对象销毁前,必须 join 或者 detach,否则析构会调用 std::terminate 终止程序。

一、启动新线程

线程通过构造std::thread对象启动,构造时传入可调用对象(普通函数、函数对象、lambda)。

  1. 注意最令人头痛的语法解析:直接传临时函数对象容易被编译器识别为函数声明;可以多加括号、列表初始化、lambda 来规避。
  2. 可调用对象会被拷贝到线程内部存储空间;如果拷贝对象里面包含局部变量的引用 / 指针,极易产生悬空引用、未定义行为。
  3. 主线程是main入口,子线程执行完入口函数就退出。

二、向线程函数传递参数

std::thread构造函数会拷贝传入的参数到线程内部,不是直接转发:

  1. 传字符串字面量、局部数组指针有风险:隐式类型转换在线程内部才执行,原变量提前销毁就会悬空;建议提前显式转换。
  2. 如果想要传递引用,不能直接传,需要std::ref()包装。
  3. 支持传入成员函数指针,第一个参数是对象地址,后续为成员函数实参。
  4. 支持移动语义:std::move转移所有权,适合std::unique_ptr这类只允许移动、不可拷贝的对象。

三、等待线程与分离线程

  1. join() :阻塞当前线程,等待目标子线程执行完毕;join 之后joinable()变为 false,一个线程只能 join 一次。可以用 RAII(thread_guard /scoped_thread)保证无论是否异常抛出,线程都会被 join,规避资源风险。scoped_thread 在线程构造时就校验是否合法,析构自动 join。
  2. detach() :分离线程,切断std::thread对象和底层线程的关联,线程变成后台守护线程,"发后即忘",运行结束由运行时库回收资源;分离后无法再 join。适合长时间后台任务、多独立文档窗口这类场景。
  3. 两者前提:对象必须joinable()==true。

四、转移线程所有权

std::thread可移动、不可拷贝 ,线程所有权可以通过std::move在多个std::thread对象之间转移:

  1. 临时 thread 对象赋值会隐式移动,命名对象必须显式std::move。
  2. 如果一个 std::thread 对象已经绑定线程,再向它 move 赋值,会直接 terminate。
  3. 移动特性支持:函数返回 thread、thread 存入 vector 批量管理、批量创建一组线程然后循环 join。

五、运行时决定线程数量

std::thread::hardware_concurrency()返回硬件可并行线程数(一般是 CPU 核心数,获取失败返回 0),仅作参考。 并行累加示例思路:根据任务总量、最小任务分片大小,取硬件并发数和最大任务分片数的较小值作为线程数,避免超额订阅(线程过多,频繁上下文切换,性能下降);主线程也参与计算,减少线程创建数量。

六、线程唯一标识符

类型:std::thread::id,用来识别线程:

  1. 获取方式:thread对象.get_id();当前线程std::this_thread::get_id()。
  2. 支持比较、排序、存入容器作为 key、打印输出;id 相等代表是同一个线程或者都不绑定线程。
  3. 用途:区分主线程 / 工作线程、做权限判断、保存每个线程对应的信息。

本章一句话小结

std::thread 负责管理线程生命周期,创建即启动;通过 join 等待、detach 后台运行,借助 move 转移所有权;利用hardware_concurrency合理规划线程数量,std::thread::id用来识别区分线程,全程重点警惕局部变量引用 / 指针带来的悬空风险和异常安全问题。

第三章 线程间共享数据

本章主要内容

共享数据带来的竞争问题、std::mutex 互斥量保护共享数据、死锁成因与规避、std::lock_guard / std::unique_lock、锁粒度、共享数据保护的替代方案(std::call_once、读写锁、递归互斥量)。 核心:只读共享数据天然安全;修改共享数据最容易触发恶性条件竞争、破坏不变量;互斥锁保证修改的中间状态对其他线程不可见,但锁本身会引入死锁、接口竞争、性能问题。

一、共享数据带来的问题

  1. 不变量 (invariant):数据结构必须满足的约束(双向链表前后指针配对),修改过程中不变量会短暂被破坏;如果别的线程此时读到中间状态,就出 BUG。
  2. 条件竞争 (race condition) :程序行为依赖线程调度的相对执行顺序。
    • 良性竞争:顺序变化结果依然正确;
    • 恶性条件竞争 :不变量被破坏;其中数据竞争 (data race) 是特殊恶性竞争,并发读写同一个无同步的独立对象,直接是 C++ 标准里的未定义行为。
    • 痛点:很难复现、负载高才容易出现、加调试器后现象消失。
  3. 三种解决恶性竞争思路:
    • 互斥锁(本章重点);
    • 无锁编程(第 7 章,设计数据结构让每一步修改都保持不变量);
    • 软件事务内存 STM(C++ 标准无原生支持)。

二、使用互斥量 std::mutex

std::mutex:同一时刻只允许一个线程持有锁,保护临界区。

  1. std::lock_guard:RAII 锁,构造上锁、析构自动解锁,简单轻量,不能手动解锁、不能转移所有权,适合范围固定、一次性上锁的场景。

坑点:不要把受保护数据的指针 / 引用泄露到锁范围之外(返回引用、传给外部回调函数保存引用,都会绕过锁保护)。

  1. 接口固有的条件竞争:经典例子std::stack的 empty()+top()+pop()。 empty 返回 false 到 top 读取之间,别的线程 pop,栈变空;top 读到值之后 pop 之前,两个线程拿到同一个元素,重复处理。 解决思路:重新设计接口,把检查 + 读取 + 弹出放到同一个临界区内 ,书中线程安全栈方案:
    • pop 重载版本 1:传引用接收弹出元素;
    • pop 重载版本 2:返回std::shared_ptr<T>,避免拷贝抛异常丢失数据;
    • 栈为空直接抛异常,去掉外部 empty 判断。

三、死锁

死锁:多个线程互相持有对方需要的锁,互相永久等待。

  1. 经典场景:两个线程,线程 A 拿 m1 等 m2,线程 B 拿 m2 等 m1。
  2. 解决方案:std::lock(m1,m2,...),一次性同时锁多个互斥量,内部算法保证不会死锁,要么全部上锁,要么全部不上;配合std::adopt_lock给 lock_guard 使用。
  3. 通用避死锁准则:
    • 避免嵌套锁;
    • 持有锁时,尽量不要调用用户传入的未知代码(用户代码内部可能再拿锁);
    • 固定顺序获取锁;
    • 锁层次结构(hierarchical_mutex,自己实现,用 thread_local 记录当前层级,低层级锁不能去拿高层级锁,运行时校验);
    • 死锁不只来自互斥锁,join()互相等待也会死锁。

四、std::unique_lock 灵活锁

相比lock_guard更灵活,代价是更大内存开销、轻微性能损耗:

  • 支持std::defer_lock:构造时不上锁,后面再 lock;适合配合std::lock批量锁多个 mutex;
  • 支持手动unlock()、lock():可以提前释放锁、后续重新加锁,用来缩小持锁时间;
  • 支持移动语义:锁所有权可以转移,能作为函数返回值传出;
  • owns_lock() 查询当前实例是否持有锁。

五、锁粒度

  • 粗粒度:一把锁保护大量数据,实现简单、竞争激烈、并发差;
  • 细粒度:多把锁分开保护不同数据,并发高,但更容易触发死锁;
  • 原则:临界区尽量短,只在访问共享数据时持有锁;耗时 IO、计算等操作放到锁外。

陷阱:粒度拆分后,原本需要原子完成的操作被拆开,依然存在条件竞争(两个锁分开读取两个值,读到的不是同一快照)。

六、替代互斥量的同步设施

  1. std::once_flag + std::call_once :专门用来做延迟初始化(懒加载)。
    • 保证可调用对象全局只执行一次;初始化完成之后几乎无开销;
    • 替代危险的双重检查锁 DCL(DCL 存在数据竞争,是未定义行为);
    • 补充:C++11 起,函数内 static 局部变量初始化天然线程安全,也是单例懒加载方案。
  2. 读写锁(shared_mutex,书中是 boost 版本) :适合读多写少场景(DNS 缓存)
    • 读:shared_lock,多个读者可以并行;
    • 写:独占锁,写的时候阻塞所有读写;
  3. std::recursive_mutex 递归互斥量 :同一个线程可以多次 lock 同一个 mutex,需要对应次数 unlock。
    • 不推荐优先使用,通常代表设计缺陷;常见场景:类多个成员函数都加锁,成员函数互相调用;更好方案:抽一个无锁的私有内部函数。

本章小结一句话

互斥锁通过临界区保证修改共享数据时不变量不对外暴露,但要小心锁泄露、接口拆分带来的条件竞争、死锁;根据场景选用 lock_guard/unique_lock;读多写少用读写锁,一次性初始化优先 call_once;锁的粒度和持锁时长是并发性能的关键。

第四章 同步并发操作

本章主要内容

讲解线程间同步操作:等待事件、条件变量、线程安全队列、期望(future)、std::async、std::packaged_task、std::promise、限定时间等待、时钟与时长/时间点、超时函数,以及用同步操作简化代码的函数化编程和消息传递模式。

核心要点

4.1 等待事件或其他条件

  • 线程等待另一线程完成任务时,不应持续检查共享标志,也不应依赖不确定的sleep_for()周期。

  • 优先使用C++标准库工具等待事件:条件变量 和期望。

  • 条件变量两套实现:std::condition_variable仅与std::mutex工作;std::condition_variable_any可与任何满足最低标准的互斥量工作,但可能有体积、性能、资源开销;一般首选std::condition_variable。

  • wait(lock, predicate):

    • 条件满足返回;不满足则解锁互斥量并阻塞等待。

    • 被通知后重新获取互斥锁并再次检查条件。

    • 必须使用std::unique_lock,因为等待期间需解锁/再次上锁;std::lock_guard不够灵活。

    • 条件变量可能伪唤醒,不建议使用有副作用的函数做条件检查。

  • notify_one()唤醒一个等待线程;notify_all()唤醒所有等待线程,适用于多线程等待同一事件或共享数据初始化/更新。

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

  • 队列接口需合并front()和pop(),避免接口固有条件竞争。

  • 提供try_pop()和wait_and_pop():

    • try_pop()尝试弹出,总会直接返回。

    • wait_and_pop()等待有值可检索才返回。

  • 完整线程安全队列包含:push()、wait_and_pop()两个重载、try_pop()两个重载、empty();互斥量需mutable以便const成员函数和拷贝构造函数上锁。

  • push()中加锁压入数据后notify_one();wait_and_pop()中用unique_lock和条件变量等待队列非空。

4.2 使用期望等待一次性事件

  • 期望 用于一次性事件;两种:std::future<>(唯一期望)和std::shared_future<>(共享期望),仿照unique_ptr/shared_ptr。

  • std::future实例只能与一个指定事件关联;std::shared_future可关联多个事件,所有实例同时变就绪,可访问事件相关数据。

  • 期望对象本身不提供同步访问;多个线程访问独立期望对象需互斥量保护;但多个线程可各自持有std::shared_future副本安全访问同一异步结果。

4.2.1 带返回值的后台任务

  • std::async启动异步任务并返回std::future对象,持有最终结果;调用get()时若未就绪则阻塞,就绪后返回结果。

  • std::async可传可调用对象和参数;支持成员函数指针、函数对象、std::ref、只移动类型等。

  • 启动策略:

    • std::launch::async:必须在新线程执行。

    • std::launch::deferred:延迟到wait()或get()时执行。

    • std::launch::deferred | std::launch::async:实现选择;默认策略。

4.2.2 任务与期望

  • std::packaged_task<>将函数或可调用对象绑定一个期望;调用时执行相关函数,期望状态置为就绪,返回值存储为相关数据。

  • 模板参数是函数签名;指定返回类型决定get_future()返回的std::future<>类型,参数列表决定函数调用操作符。

  • 可用于线程池、任务调度器、GUI线程更新等:将任务包装为packaged_task,放入队列,由特定线程取出执行,调用者通过future等待结果。

4.2.3 使用std::promise

  • std::promise<T>提供设定值方式,与std::future<T>配对;通过get_future()获取相关future。

  • 使用set_value()设置值后,对应future状态变就绪;设置前销毁promise会存储异常。

  • 适用于单线程处理多连接等场景:用promise/future对表示成功/失败标识或数据负载。

4.2.4 为期望存储异常

  • std::async中函数抛异常,异常存储到期望结果中,调用get()时重新抛出。

  • std::packaged_task被调用时抛异常,同样存储到期望结果中,get()时再抛出。

  • std::promise可调用set_exception()存入异常,常配合std::current_exception()在catch块中使用;已知异常类型可用std::copy_exception()直接存储新异常。

  • 未调用设置函数前销毁promise或packaged_task,会存储std::future_errc::broken_promise相关的std::future_error异常。

4.2.5 多个线程的等待

  • std::future只能由一个线程get()一次,多线程并发访问独立future对象会有数据竞争和未定义行为。

  • std::shared_future可拷贝,多个对象可引用同一关联期望结果;每个线程持有自己的副本,通过各自副本获取结果最安全。

  • std::shared_future可由std::future移动构造,也可用share()成员函数直接转移所有权;容器可自动推断类型。

4.3 限定等待时间

  • 两种超时方式:

    • 时延超时:指定一段时间,后缀_for。

    • 绝对超时:指定时间点,后缀_until。

  • 时钟 :std::chrono::system_clock代表系统时钟"实际时间",不稳定;std::chrono::steady_clock为稳定时钟,适合超时计算;std::chrono::high_resolution_clock可能具有最小节拍周期/最高精度。

  • 时延 :std::chrono::duration<>,第一模板参数为类型表示,第二为每个单元秒数;预定义类型有nanoseconds、microseconds、milliseconds、seconds、minutes、hours等;支持隐式转换和duration_cast<>显式截断转换;支持加减、乘除常数、count()获取单位时间数量。

  • 基于时延等待示例:future.wait_for(milliseconds(35))返回future_status::timeout、ready或deferred;基于时延的等待使用内部稳定时钟计时。

  • 时间点 :std::chrono::time_point<>,第一参数指定时钟,第二参数为时间计量单位;可加减时延得到新时间点,可相减得时间差;time_since_epoch()获取到时钟纪元的时延。

  • _until等待函数使用时间点;若使用系统时钟,时钟调整会影响等待总长度;等待条件变量有超时时推荐用循环+wait_until处理伪唤醒。

  • 具有超时功能的函数:

    • std::this_thread::sleep_for(duration)、sleep_until(time_point)。

    • 条件变量wait_for/wait_until,可带谓词。

    • std::timed_mutex、std::recursive_timed_mutex支持try_lock_for/try_lock_until。

    • std::unique_lock支持带时延/时间点构造和try_lock_for/try_lock_until。

    • std::future/std::shared_future支持wait_for/wait_until,返回future_status。

4.4 使用同步操作简化代码

  • 同步工具作为构建块,应关注需要同步的操作,而非具体机制;函数化编程风格中每个任务拥有自己的数据,结果通过期望广播给其他线程。

  • 函数化编程 :函数结果只依赖传入参数,不依赖外部状态;不修改外部状态,避免共享可变数据,从而减少条件竞争和互斥量需求。C++11的lambda、std::bind、auto和future使FP风格并发化成为可能。

  • 快速排序FP并行版 :用std::async对小于中间值的部分在另一线程排序,大于部分递归排序;new_lower为std::future<std::list<T>>,拼接前调用get()获取结果。std::async可能启动新线程或同步切换任务,依赖实现文档。

  • 也可用spawn_task()包装std::packaged_task和std::thread,为线程池实现铺路。

  • 消息传递同步操作(CSP):线程理论上完全独立、无共享数据,通过消息队列通信;C++线程共享地址空间,需约定确保不共享数据,消息队列细节可封装在库中。

  • ATM状态机示例:ATM逻辑分为物理机械线程、逻辑线程、银行通讯线程,通过消息纯通信;逻辑线程为状态机,每个状态等待可接受消息,处理后可转移到新状态;同步完全包含在消息传递库中。该模式称为参与者模式(Actor model):独立参与者运行在独立线程上,互相发消息,不共享状态。

一句话小结

第4章系统介绍了条件变量、future/promise/packaged_task、超时等待等同步工具;条件变量适合等待条件达成,future适合一次性事件结果,shared_future支持多线程等待同一结果;函数化编程和消息传递模式可通过避免共享可变数据简化并发同步。

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

本章主要内容

讲解C++11内存模型基础(对象、内存位置、修改顺序)、标准库原子类型与原子操作、各种内存顺序选项(序列一致、自由序列、获取-释放、consume)、释放序列与栅栏,以及原子操作如何对非原子操作进行排序。核心:内存模型是互斥量、条件变量、future等高层同步工具正常工作的底层基础;原子操作不仅避免数据竞争导致的未定义行为,还允许在不同线程上的操作之间强制排序。

核心要点

5.1 内存模型基础

  • 对象与内存位置:

    • 每个变量都是对象(包括成员变量对象);每个对象至少占有一个内存位置。

    • 基本类型都有确定的内存位置(即使相邻或属于数组)。

    • 相邻位域属于同一内存位置。

    • C++标准定义对象为"存储区域",对象可拥有类型和生命周期,并可包含子对象。

  • 对象、内存位置与并发:

    • 两个线程访问不同内存位置:无问题。

    • 两个线程访问同一内存位置:只读安全;有修改则可能产生条件竞争。

    • 避免竞争需规定执行顺序:互斥量确定访问顺序,或原子操作同步机制。

    • 未规定同一内存地址访问顺序且存在多线程写入时,产生数据竞争和未定义行为。

    • 原子操作不能消除竞争本身,但可将程序拉回定义行为区域。

  • 修改顺序:

    • 每个对象都有由程序中所有线程确定的修改顺序,在初始化阶段确定。

    • 非原子类型需足够同步操作确保各线程遵守修改顺序;原子类型由编译器负责必要同步。

    • 所有线程需遵守每个独立对象的修改顺序,但不必遵守独立对象之间的相对操作顺序。

    • 不允许投机执行:后续读必须返回较新值,后续写必须发生在修改顺序之后。

5.2 原子操作和原子类型

  • 原子操作定义:不可分割的操作,任意线程不能看到其执行一半的状态;状态要么完成,要么未完成。

  • 非原子操作可能被其他线程看到"一半"的状态,构成数据竞争和未定义行为。

  • 标准原子类型 (<atomic>头文件):

    • 所有操作都是原子的;大多数提供is_lock_free()判断是否直接使用原子指令。

    • std::atomic_flag是唯一不提供is_lock_free()且保证无锁的原子类型,只能clear()和test_and_set(),需用ATOMIC_FLAG_INIT初始化。

    • 其他原子类型通过特化std::atomic<>模板访问,可能非无锁。

    • 备选类型名如atomic_bool、atomic_int等对应std::atomic<>特化;同一程序混用备选名与特化名会降低可移植性。

    • 标准原子类型不可拷贝构造和拷贝赋值;支持隐式转换为对应内置类型,支持赋值、load()、store()、exchange()、compare_exchange_weak()、compare_exchange_strong()及复合赋值(整型和指针特化还支持++/--)。

    • 赋值操作返回非原子值而非引用,避免额外加载和条件竞争。

  • 内存顺序三类操作:

    • Store:memory_order_relaxed、memory_order_release、memory_order_seq_cst。

    • Load:memory_order_relaxed、memory_order_consume、memory_order_acquire、memory_order_seq_cst。

    • Read-modify-write:memory_order_relaxed、memory_order_consume、memory_order_acquire、memory_order_release、memory_order_acq_rel、memory_order_seq_cst。

    • 所有操作默认顺序为memory_order_seq_cst。

  • std::atomic_flag:

    • 最简单布尔标志,只能销毁、清除、设置(查询旧值);clear()为存储操作,test_and_set()为读-改-写操作。

    • 可用于实现自旋互斥锁:lock()循环test_and_set(memory_order_acquire),unlock()调用clear(memory_order_release)。

  • std::atomic<bool>:

    • 可用非原子bool构造和赋值;支持load()、store()、exchange()、compare_exchange_weak()、compare_exchange_strong()。

    • "比较/交换"比较当前值与预期值:相等则存储新值,不等则将预期值更新为当前值;返回bool。

    • compare_exchange_weak()可能伪失败(值相等但存储不成功),通常需循环;compare_exchange_strong()实际值不等时保证返回false,可消除循环。

    • 可分别指定成功和失败内存顺序;失败顺序不能为release或acq_rel;未指定失败顺序时按成功顺序推导(release变relaxed,acq_rel变acquire)。

  • std::atomic<T*>:

    • 接口类似std::atomic<bool>,但操作对象为T*;支持fetch_add()、fetch_sub()及+=、-=、++、--。

    • fetch_add()/fetch_sub()返回旧指针值;复合赋值返回新值;均为读-改-写操作,可指定任意内存顺序标签。

  • 标准原子整型:

    • 支持fetch_add()、fetch_sub()、fetch_and()、fetch_or()、fetch_xor()及复合赋值、++/--。

    • 缺少除法、乘法和移位操作,必要时可用compare_exchange_weak()循环实现。

  • std::atomic<>主模板:

    • 允许用户自定义类型(UDT)创建原子变量,但UDT必须:有拷贝赋值运算符、无虚函数或虚基类、使用编译器创建的拷贝赋值、所有基类和非静态成员支持拷贝赋值、满足"位可比"(可用memcmp()比较)。

    • 限制目的:让编译器可将UDT视为原始字节组,避免在锁内调用用户代码,防止死锁,并允许使用原子指令。

    • 对UDT的std::atomic<>通常使用内部锁;接口仅load()、store()、exchange()、compare_exchange_weak()、compare_exchange_strong()、赋值和向T转换。

    • 浮点类型的compare_exchange_strong可能因表达式不同而"相等却失败",不是真正的原子浮点计算。

  • 原子操作释放函数:

    • 非成员函数以atomic_为前缀,如std::atomic_load()、std::atomic_store();带_explicit后缀的可指定内存顺序。

    • 设计为与C语言兼容,第一个参数为指向原子对象的指针。

    • std::atomic_flag对应std::atomic_flag_test_and_set()、std::atomic_flag_clear()及_explicit版本。

    • 标准库还为std::shared_ptr<>提供原子操作释放函数(load、store、exchange、compare/exchange),虽shared_ptr本身不是原子类型。

5.3 同步操作和强制排序

  • 示例背景:一个线程填充数据并设置原子标志,另一线程等待标志后读取数据;通过"先行发生"和"同步发生"确定必要顺序。

  • 同步发生:

    • 只能在原子类型之间进行的操作关系。

    • 变量x上适当标记的原子写操作W,同步于对x适当标记的原子读操作(读取W写入的内容),或W之后同一线程上的原子写操作,或任意线程对x的一系列原子读-改-写操作。

    • 默认所有原子操作都是"适当标记"的。

  • 先行发生:

    • 程序中基本构建块的操作顺序,指定某操作影响另一操作。

    • 单线程:源码中A在B之前,则A先行于B。

    • 操作间无序执行时通常无先行关系;函数参数求值顺序未指定。

    • 线程间先行:A线程间先行于B,且B线程间先行于C,则A线程间先行于C(可传递)。

  • 内存顺序选项:

    • 序列一致(memory_order_seq_cst):

      • 最简单、直观,所有操作有单一全序;需要全局同步,最昂贵。

      • 示例中assert(z.load()!=0)永不触发,因为存储x或存储y至少一个发生,且全序保证读到较新值。

    • 自由序列(memory_order_relaxed):

      • 无同步关系;同一线程对同一变量仍服从先行关系;不同线程几乎不需要相对顺序。

      • 唯一要求:同一线程中单个原子变量访问不能重排序;已看到特定值后,后续读不会检索更早的值。

      • 不同变量可自由重排序,只要服从任意先行关系;示例中assert可能触发。

    • 获取-释放(memory_order_acquire/memory_order_release):

      • 释放操作与读取该释放操作写入值的获取操作同步。

      • 同一线程中,释放前的操作先行于获取后的操作。

      • 不同线程分别写x和y时,获取-释放不能保证跨变量顺序;同一线程先写x再释放写y,另一线程获取读y后再读x,则可保证读到x的新值。

      • 获取-释放序列可跨多个线程传递同步:thread_1释放写sync1,thread_2获取读sync1后释放写sync2,thread_3获取读sync2,则thread_1对data的写入对thread_3可见。

      • 读-改-写操作使用memory_order_acq_rel可同时具有获取和释放语义;仅acquire的fetch_sub不与存储同步,仅release的fetch_or不与读取同步。

      • 获取-释放与序列一致混合时:序列一致加载如获取,序列一致存储如释放,序列一致读-改-写如acq_rel;自由操作仍自由,但会与额外同步绑定。

      • 互斥量lock是获取操作,unlock是释放操作;锁性质保证结果与序列一致无区别。

    • memory_order_consume与数据依赖:

      • 完全依赖数据,引入"前序依赖"和"携带依赖"关系。

      • 若A操作结果被B使用,则A携带依赖B;携带依赖可传递;A前序依赖B则A线程间前序依赖B。

      • 典型用法:原子加载指针时用consume,之前存储用release,保证指针指向的值已同步,无需对其他非依赖数据施加同步。

      • std::kill_dependency()可显式打破依赖链,用于优化,需谨慎并有性能数据支持。

  • 释放序列与同步:

    • 存储标记release/acq_rel/seq_cst,加载标记consume/acquire/seq_cst,且链上每个加载读取前一操作写入的值,则构成释放序列,初始存储同步于最终加载。

    • 链上任何原子读-改-写操作可拥有任意内存顺序(甚至relaxed)。

    • 示例:生产者用count.store(number_of_items, memory_order_release)发布数据,消费者count.fetch_sub(1, memory_order_acquire)获取数据;fetch_sub的acquire语义保证读取到release存储之前的数据。

  • 栅栏(fence):

    • std::atomic_thread_fence(memory_order_release)/memory_order_acquire。

    • 释放栅栏与获取栅栏同步,条件:加载操作读取释放栅栏后存储的值。

    • 两个栅栏都必要:一个线程释放,另一线程获取,才能构建同步关系。

    • 栅栏同步点是栅栏本身;栅栏后的写入不被栅栏排序,只有栅栏位于两次存储之间才硬性保证顺序。

  • 原子操作对非原子操作排序:

    • 非原子操作"前序"于原子操作,且该原子操作线程间先行于另一线程操作,则该非原子操作也先行于另一线程操作。

    • 栅栏可对非原子操作排序:非原子写x在释放栅栏前,原子写y在栅栏后;另一线程原子读y后经获取栅栏再读x,则x无数据竞争且顺序有保证。

    • memory_order_release/memory_order_consume对也可排序非原子访问(如动态分配对象)。

    • 这是互斥量、条件变量等高层同步工具工作的基础:自旋锁中lock的test_and_set(acquire)与unlock的clear(release)构成同步,保护数据的修改先行于unlock,进而先行于后续lock及受保护数据访问。

一句话小结

C++11内存模型以对象、内存位置和修改顺序为基础;原子类型和原子操作不仅避免数据竞争导致的未定义行为,还通过序列一致、自由序列、获取-释放、consume及栅栏等机制在不同线程操作间强制排序;获取-释放序列可跨线程传递同步,栅栏和原子操作还能对非原子操作排序,这正是互斥量、条件变量、future等高层同步工具正确工作的底层基础。