第一章 你好,C++的并发世界
本章主要内容
本章以 C++11 为起点,依次讲解并发与多线程概念、使用并发的理由、C++ 并发发展历史,最后给出最简 C++ 多线程示例;点明 C++11 首次在标准层面提供跨平台多线程库,适配多核硬件发展趋势,同时指出:多线程语法简单,难点是保证并发下程序行为正确。
一、何谓并发和多线程
并发是两个或多个独立活动同时发生。计算机里分为两种实现形态:
- 单核任务切换(假象并发):单核 CPU 快速在任务间轮换执行,依靠上下文切换模拟同时运行,切换存在保存恢复现场、缓存失效带来的开销;假象并发和硬件并发环境下,程序行为会存在细微差异。
- 硬件并发:多核 / 多处理器真正并行执行任务,硬件线程数量决定硬件最大并行上限;即便多核机器,任务总数通常多于硬件线程,依旧会进行任务调度切换。
并发有两条实现途径:
- 多进程并发:进程相互隔离,依靠管道、socket、信号等 IPC 通信。优点是隔离性强、并发代码更安全,还可以跨机器分布式部署;缺点是进程启动、通信开销大,通信机制复杂,C++ 标准无原生 IPC 支持。
- 多线程并发(本书研究对象) :同一进程内的多个线程,属于轻量级执行流,共享进程地址空间,线程间通信开销远低于进程;代价是共享内存没有自动保护机制,程序员需要自行保证共享数据一致性。本书后续提到的 "并发" 默认指代多线程并发。
二、应用程序为什么要使用并发和多线程
使用并发只有两类合理动机,同时要判断何时不该使用:
- 关注点分离:把不同职责拆分到独立线程,解耦代码逻辑、提升交互响应性。典型场景:UI 线程处理交互,其他线程执行后台耗时任务。这种划分是软件逻辑层面,线程数量不和 CPU 核心绑定。
- 提升性能 :CPU 不再依靠提高单核主频提速,需要并发挖掘多核算力。分为任务并行 (一个大任务分不同阶段交给多个线程)和数据并行(多线程对多份数据执行相同运算,易并行算法扩展性好);既可以缩短单个任务耗时,也可以提升整体吞吐量。
- 不适合使用并发的原则:当收益无法覆盖成本时不使用。成本包含线程启动开销、系统资源消耗、频繁上下文切换损耗,以及代码复杂度提升、更容易引入 bug;线程资源有上限,线程过多反而降低性能,高并发场景可借助线程池管控线程数量。只有性能收益显著,或是关注点分离收益明确时,才适合引入并发。
三、C++ 的并发史
- C++98 标准没有线程概念,也没有正式内存模型,原生不支持多线程;早期多线程开发只能依赖 POSIX、Windows 平台 API,或是 Boost、ACE、MFC 这类第三方库封装接口,代码不具备跨平台一致性,缺少统一内存模型,跨平台容易出问题。
- C++11 是里程碑:新增线程感知内存模型,标准库提供线程管理、共享数据保护、同步机制、原子操作整套组件,大量设计参考 Boost.Thread,实现可移植的 C++ 多线程编程。
- 库设计目标:追求低抽象惩罚 ,标准库封装性能接近底层原生 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)。
- 注意最令人头痛的语法解析:直接传临时函数对象容易被编译器识别为函数声明;可以多加括号、列表初始化、lambda 来规避。
- 可调用对象会被拷贝到线程内部存储空间;如果拷贝对象里面包含局部变量的引用 / 指针,极易产生悬空引用、未定义行为。
- 主线程是
main入口,子线程执行完入口函数就退出。
二、向线程函数传递参数
std::thread构造函数会拷贝传入的参数到线程内部,不是直接转发:
- 传字符串字面量、局部数组指针有风险:隐式类型转换在线程内部才执行,原变量提前销毁就会悬空;建议提前显式转换。
- 如果想要传递引用,不能直接传,需要
std::ref()包装。 - 支持传入成员函数指针,第一个参数是对象地址,后续为成员函数实参。
- 支持移动语义:
std::move转移所有权,适合std::unique_ptr这类只允许移动、不可拷贝的对象。
三、等待线程与分离线程
- join() :阻塞当前线程,等待目标子线程执行完毕;join 之后
joinable()变为 false,一个线程只能 join 一次。可以用 RAII(thread_guard /scoped_thread)保证无论是否异常抛出,线程都会被 join,规避资源风险。scoped_thread 在线程构造时就校验是否合法,析构自动 join。 - detach() :分离线程,切断
std::thread对象和底层线程的关联,线程变成后台守护线程,"发后即忘",运行结束由运行时库回收资源;分离后无法再 join。适合长时间后台任务、多独立文档窗口这类场景。 - 两者前提:对象必须
joinable()==true。
四、转移线程所有权
std::thread可移动、不可拷贝 ,线程所有权可以通过std::move在多个std::thread对象之间转移:
- 临时 thread 对象赋值会隐式移动,命名对象必须显式
std::move。 - 如果一个 std::thread 对象已经绑定线程,再向它 move 赋值,会直接 terminate。
- 移动特性支持:函数返回 thread、thread 存入 vector 批量管理、批量创建一组线程然后循环 join。
五、运行时决定线程数量
std::thread::hardware_concurrency()返回硬件可并行线程数(一般是 CPU 核心数,获取失败返回 0),仅作参考。 并行累加示例思路:根据任务总量、最小任务分片大小,取硬件并发数和最大任务分片数的较小值作为线程数,避免超额订阅(线程过多,频繁上下文切换,性能下降);主线程也参与计算,减少线程创建数量。
六、线程唯一标识符
类型:std::thread::id,用来识别线程:
- 获取方式:
thread对象.get_id();当前线程std::this_thread::get_id()。 - 支持比较、排序、存入容器作为 key、打印输出;id 相等代表是同一个线程或者都不绑定线程。
- 用途:区分主线程 / 工作线程、做权限判断、保存每个线程对应的信息。
本章一句话小结
std::thread 负责管理线程生命周期,创建即启动;通过 join 等待、detach 后台运行,借助 move 转移所有权;利用hardware_concurrency合理规划线程数量,std::thread::id用来识别区分线程,全程重点警惕局部变量引用 / 指针带来的悬空风险和异常安全问题。
第三章 线程间共享数据
本章主要内容
共享数据带来的竞争问题、std::mutex 互斥量保护共享数据、死锁成因与规避、std::lock_guard / std::unique_lock、锁粒度、共享数据保护的替代方案(std::call_once、读写锁、递归互斥量)。 核心:只读共享数据天然安全;修改共享数据最容易触发恶性条件竞争、破坏不变量;互斥锁保证修改的中间状态对其他线程不可见,但锁本身会引入死锁、接口竞争、性能问题。
一、共享数据带来的问题
- 不变量 (invariant):数据结构必须满足的约束(双向链表前后指针配对),修改过程中不变量会短暂被破坏;如果别的线程此时读到中间状态,就出 BUG。
- 条件竞争 (race condition) :程序行为依赖线程调度的相对执行顺序。
- 良性竞争:顺序变化结果依然正确;
- 恶性条件竞争 :不变量被破坏;其中数据竞争 (data race) 是特殊恶性竞争,并发读写同一个无同步的独立对象,直接是 C++ 标准里的未定义行为。
- 痛点:很难复现、负载高才容易出现、加调试器后现象消失。
- 三种解决恶性竞争思路:
- 互斥锁(本章重点);
- 无锁编程(第 7 章,设计数据结构让每一步修改都保持不变量);
- 软件事务内存 STM(C++ 标准无原生支持)。
二、使用互斥量 std::mutex
std::mutex:同一时刻只允许一个线程持有锁,保护临界区。
std::lock_guard:RAII 锁,构造上锁、析构自动解锁,简单轻量,不能手动解锁、不能转移所有权,适合范围固定、一次性上锁的场景。
坑点:不要把受保护数据的指针 / 引用泄露到锁范围之外(返回引用、传给外部回调函数保存引用,都会绕过锁保护)。
- 接口固有的条件竞争:经典例子
std::stack的empty()+top()+pop()。 empty 返回 false 到 top 读取之间,别的线程 pop,栈变空;top 读到值之后 pop 之前,两个线程拿到同一个元素,重复处理。 解决思路:重新设计接口,把检查 + 读取 + 弹出放到同一个临界区内 ,书中线程安全栈方案:- pop 重载版本 1:传引用接收弹出元素;
- pop 重载版本 2:返回
std::shared_ptr<T>,避免拷贝抛异常丢失数据; - 栈为空直接抛异常,去掉外部 empty 判断。
三、死锁
死锁:多个线程互相持有对方需要的锁,互相永久等待。
- 经典场景:两个线程,线程 A 拿 m1 等 m2,线程 B 拿 m2 等 m1。
- 解决方案:
std::lock(m1,m2,...),一次性同时锁多个互斥量,内部算法保证不会死锁,要么全部上锁,要么全部不上;配合std::adopt_lock给 lock_guard 使用。 - 通用避死锁准则:
- 避免嵌套锁;
- 持有锁时,尽量不要调用用户传入的未知代码(用户代码内部可能再拿锁);
- 固定顺序获取锁;
- 锁层次结构(hierarchical_mutex,自己实现,用 thread_local 记录当前层级,低层级锁不能去拿高层级锁,运行时校验);
- 死锁不只来自互斥锁,
join()互相等待也会死锁。
四、std::unique_lock 灵活锁
相比lock_guard更灵活,代价是更大内存开销、轻微性能损耗:
- 支持
std::defer_lock:构造时不上锁,后面再 lock;适合配合std::lock批量锁多个 mutex; - 支持手动
unlock()、lock():可以提前释放锁、后续重新加锁,用来缩小持锁时间; - 支持移动语义:锁所有权可以转移,能作为函数返回值传出;
owns_lock()查询当前实例是否持有锁。
五、锁粒度
- 粗粒度:一把锁保护大量数据,实现简单、竞争激烈、并发差;
- 细粒度:多把锁分开保护不同数据,并发高,但更容易触发死锁;
- 原则:临界区尽量短,只在访问共享数据时持有锁;耗时 IO、计算等操作放到锁外。
陷阱:粒度拆分后,原本需要原子完成的操作被拆开,依然存在条件竞争(两个锁分开读取两个值,读到的不是同一快照)。
六、替代互斥量的同步设施
- std::once_flag + std::call_once :专门用来做延迟初始化(懒加载)。
- 保证可调用对象全局只执行一次;初始化完成之后几乎无开销;
- 替代危险的双重检查锁 DCL(DCL 存在数据竞争,是未定义行为);
- 补充:C++11 起,函数内 static 局部变量初始化天然线程安全,也是单例懒加载方案。
- 读写锁(shared_mutex,书中是 boost 版本) :适合读多写少场景(DNS 缓存)
- 读:
shared_lock,多个读者可以并行; - 写:独占锁,写的时候阻塞所有读写;
- 读:
- 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等高层同步工具正确工作的底层基础。