Lecture 17: Transactional Memory
Lecture 17: Transactional Memory
1. 章节标题与概述
Lecture 17: Transactional Memory(事务内存:从”锁的两难”到声明式原子块)
本讲核心问题:前面几讲我们一直在”往下走”——用 test-and-set、fetch-and-add、CAS、LL/SC 这些机器级原子操作,在软件里手工搭建锁、屏障、无锁数据结构。结果也很清楚:程序员在”并发度”与”正确性”之间被夹在中间(讲义原话 between a lock and a hard place,slide 4)。粗粒度锁好写但并发度低;细粒度锁并发度高,但手递手(hand-over-hand)锁序稍有不慎就是死锁,而且读-读共享也要付出独占锁的代价。本讲往回”往上走”一层:能不能不写
lock()/unlock(),只声明”这段代码要原子执行”,让系统去决定怎么实现原子性与隔离性?一个 memory transaction(内存事务)到底是什么语义?实现它必须回答哪几个设计问题?软件实现(STM)与硬件实现(HTM)各自的代价与天花板在哪里?- 涉及的主要硬件/软件机制:
- 语义层:memory transaction = 原子且隔离的内存访问序列,来自数据库事务的三个性质——atomicity(原子性,全有或全无)、isolation(隔离性,提交前对外不可见)、serializability(可串行化,看起来按某个串行顺序提交,但顺序本身不保证)(slide 8)。
- 实现层(设计空间的三问):数据版本策略(data versioning policy)——eager versioning(undo-log based,立即改内存、留撤销日志)vs lazy versioning(write-buffer based,先缓冲、提交时冲刷);冲突检测策略(conflict detection policy)——pessimistic(在每次 load/store 时检查)vs optimistic(在 commit 时检查);检测粒度(granularity of detection)——字 / 缓存行 / 对象,粒度直接决定”伪冲突”数量(slide 3、slide 40)。
- 软件侧(STM):全局版本时钟(global version clock)、读集/写集(read set / write set)、版本字里的锁位表达写集所有权、提交时读集验证、争用管理器(contention manager)与串行化回退(保证前进、消除活锁)。
- 硬件侧(HTM):把”一致性协议对单个地址做的事”推广到”一组地址”(slide 9 的洞见)——用私有缓存里的 read-set / write-set 标记 + 一致性协议的冲突检测(他人请求命中我的读写集 → 冲突)来实现隔离,事务提交 = 把写集一次性地对外发布。
在并行计算知识体系中的角色:本讲是”共享内存并行”这条主线上抽象层级提升的最后一级:原子指令 → 锁/屏障 → 无锁数据结构 → 事务。它把前几讲的所有硬件知识(缓存一致性、伪共享、内存序、原子指令的代价)反着用了一遍:事务并不是魔法,它只是把”锁的争用”换成了”元数据的争用”——读集验证要走一致性,提交要一次原子 RMW 抢全局版本号,检测粒度会带来与伪共享同源的伪冲突。因此本讲既是抽象的顶点,也是”抽象有代价”的最好教材。它同时为后续专题(异构并行、虚拟内存)留下一个现实注脚:工业界最著名的 HTM 实现(Intel TSX/RTM)因为正确性与安全原因曾被迫微码关闭,而事务只覆盖内存这一限制(I/O、
malloc、系统调用不可回滚)至今没有被彻底解决。- 配套材料:
- Fall 2026 日程表(https://www.cs.cmu.edu/~418/schedule.html)把 Oct 5 排为第 17 讲 “Transactional Memory”,该行目前没有 slides 链接(对照第 15/16 讲,那两行在 HTML 注释里写着”slides/video from a previous offering; uncomment when posted for Fall 2026”)——也就是说 Fall 2026 的本讲讲义尚未发布,属未公开。
- 本笔记的事实基础是
cs149_supp/transactions.txt:Stanford CS149(Fall 2025)Lecture 17: Transactional Memory 讲义的逐页抽取文本,共 50 页(文件内标记===== [slide i/50] =====)。该讲义在公开网络可直接获取,已公开,本笔记中所有带 slide 编号的引用均出自它。讲义首页写的是 “Stanford CS149, Fall 2025”——讲义沿用历史版本是正常现象,不是错误。 - CMU 历史学期(s23/f22)的本讲讲义在本地只有登录占位页:
past/s23_18_transactional.pdf、past/s23_19_tm.pdf、supp/f22_transactionalmem.pdf均只有 1.7 KB,抽取文本extracted/s23_18_transactional.txt、extracted/s23_19_tm.txt的内容只有一句 “Your Browser does not support javascript”,即需 CMU 登录、未公开。历史讲义原始位置在/afs/cs/academic/class/15418-*/public/之下。 - 讲课录像(Panopto / YouTube):Fall 2026 日程表中被 HTML 注释隐藏,属未发布。Ed 讨论区、Autolab、Canvas:需登录。
- Fall 2026 授课教师为 Brian Railing 与 Dimitrios Skarlatos;课程由 Kayvon Fatahalian 创建。
- 讲义中树结构示例图注明 “Slide credit: Austen McDonald”;讲义在性能对比图中提到的 TCC 是一个硬件实现的 TM 系统(slide 27 原话:”TCC” is a TM system implemented in hardware)。
- 本笔记第 2.6 节的硬件 TM 细节、第 3 节的三段可运行代码与第 4 节的定量模型属于公开知识补充与本文档作者在本机上的实测(不是 Fall 2026 讲义内容),文中均已显式标注。实测环境:2 路 AMD EPYC 7V13(共 128 线程、2 NUMA 节点、16 个 L3 实例、L3 共 512 MiB),g++ 12.2.0,机器为共享节点(实测期间 load average 约 8–12),绝对数值随机器负载波动,请只把它当作数量级与相对趋势的证据。
2. 核心概念与硬件/软件架构图解
2.1 memory transaction 的三条语义:把”单个地址的一致性”推广到”一组地址”
- 定义与目的:一个 memory transaction(内存事务) 是”一段原子且隔离的内存访问序列”(slide 8)。它必须同时提供:
- Atomicity(原子性,全有或全无):提交(commit)时,事务里的所有写一次性生效;中止(abort)时,这些写看起来从未发生过。
- Isolation(隔离性):事务提交之前,任何其他处理器都不能观察到它的写。
- Serializability(可串行化):所有事务看起来按某个串行顺序提交——但具体顺序不被语义保证(这正是”乐观并发”能成立的前提:既然顺序无所谓,系统就可以在无冲突时并行推进)。
直观解释(”它是什么?”):想象银行转账。你要从 A 账户扣 100、给 B 账户加 100。用锁,你得自己规定”A 的锁和 B 的锁谁先谁后”,并且祈祷所有调用者都遵守同一套顺序(讲义 slide 31–32 的
transfer(A,B)与transfer(B,A)就是活生生的死锁例子)。用事务,你只写atomic { withdraw(A); deposit(B); },你说的是”要什么”(这一段必须原子),不是”怎么做”(slide 6–7 的 declarative vs imperative)。类比:锁像”自己动手拧螺丝并负责按正确顺序拧”,事务像”下单——我只要这批零件装成一个整体,工艺由车间决定”。- 和锁的语义差异(这是本讲最容易被误解的一点,slide 35 专门做了一次 self-check):
| 维度 | lock()/unlock() | atomic { } |
|---|---|---|
| 抽象性质 | 命令式(imperative)低层阻塞原语 | 声明式(declarative)高层原子性声明 |
| 自身语义 | 不提供原子性与隔离性,只是互斥 | 系统保证原子性与隔离性 |
| 适用范围 | 互斥只是用途之一:还可以用来等待条件(条件变量、标志位自旋)、限制并发度、实现一次性初始化 | 只声明原子性;不能用它表达”等待另一个线程做事” |
| 组合性 | 组合需要全局策略(锁序),模块化被破坏 | 事务可组合,最外层事务定义原子性边界(slide 33) |
| 失败处理 | 线程失败可能”带着锁死掉”,需要人工 undo 代码 | 事务中止即回滚,不留锁(failure atomicity) |
- ASCII 图解 D1:事务的读写集合(讲义 slide 16–27 的树示例)
场景: 两个线程在二叉树上更新节点 3 与节点 4 (讲义 slide 16, 图 credit: Austen McDonald)
① 手递手锁(hand-over-hand locking): 边走边锁, 独占整条路径
Thread A: LOCK(1) ──► LOCK(2) ──► LOCK(3) [读1,2,3 / 写3]
│ │ │
└─ 1,2 全程被 A 独占 ──────┘
Thread B: 等待 LOCK(2) ... ◄── 即使 B 只碰 4, 也被 1,2 挡住
(讲义 slide 22 原话: locks on node 1 and 2 during update to node 3
could delay update to 4) => 读-读共享被当成冲突
1
/ \
2 ...
/ \
3 4
▲ ▲
A B (A 写 3, B 写 4; A/B 都"读"过 1,2)
② 事务: 各自声明读写集, 由系统判断是否真的冲突
Transaction A: READ {1,2,3} WRITE {3}
Transaction B: READ {1,2,4} WRITE {4}
▲▲
└┴── 交集只有"读-读"(1,2) => slide 25: NO READ-WRITE or
WRITE-WRITE conflicts! => 两者可以并发提交(无冲突)
③ 若两个事务都写节点 3: READ{1,2,3} WRITE{3} ×2
=> slide 26: Conflicts exist: transactions must be serialized (W-W 冲突)
关键操作与性能特征:
* 锁把"路径"变成临界区 => 延迟 = O(树高 h) 次锁操作, 且被共享祖先串行化
* 事务只需要"记录 + 提交时验证" => 读-读重叠免费, 只有 W-W / R-W 才需要串行化
* 但"记录"不是免费的: 每个读/写都要额外指令与元数据(见 2.5、第 4 节)
2.2 为什么需要它(一):锁的两难与”读-读并发”
定义与目的:讲义用一个极简的
deposit说明问题(slide 5–6):read-modify-write必须原子。用锁要写lock(); tmp = get(a); put(a, tmp+amt); unlock();;用事务只写atomic { ... }。后者之所以更好,不是因为”更短”,而是因为它把并发度的决定权从程序员转移给了系统:系统只在真正冲突(R-W 或 W-W)时才串行化(slide 6 原话:Implementation discussed today uses optimistic concurrency: maintain serialization only in situations of true contention)。- 直观解释:把 HashMap 的例子(slide 11–15)想成图书馆的书架:
- 非线程安全版:没有任何同步原语,最快,但两人同时抽同一格会乱(slide 11:Java
HashMap恰好是 “Bad: not thread safe” 且 “Good: no lock overhead when synchronization not needed”)。 - Java 1.4 的
synchronized包装:整个书架一把大锁。绝对安全,但一个人拿书全馆排队(slide 12:poor scalability)。 - 每桶一锁(finer-grained):每人只锁自己那一格。并发度上去了(slide 14 的实测曲线:fine locks 明显优于 coarse locks),但“根本没人争用”时也要付锁开销(slide 13 原话:incurs lock overhead even if synchronization not needed),而且锁的层级关系一不小心就死锁。
- 事务版:
atomic { return m.get(key); }(slide 15)。手写代码和”大锁版”一样简单,而并发度接近”细粒度锁版”——这就是 TM 的核心卖点。
- 非线程安全版:没有任何同步原语,最快,但两人同时抽同一格会乱(slide 11:Java
- 讲义 slide 27 的性能对比(原文配图有三种曲线:coarse locks / fine locks / TCC):在 balanced tree 与 HashMap 两个数据结构上,”TCC”(硬件 TM)的曲线都优于或接近 fine locks,而 coarse locks 随处理器数增加迅速变差。请注意这张图的含义:TM 的卖点不是”永远最快”,而是在几乎不增加编程难度的情况下达到接近专家手写细粒度锁的性能。
2.3 为什么需要它(二):失败原子性、可组合性、性能可移植性
失败原子性(failure atomicity,slide 29–30):手工写
try/catch的 undo 代码会迅速失控——”必须记住要撤销什么、怎么撤销”,而且有些副作用已经对其他线程可见(例如未捕获的异常导致某个锁永远不释放,整个系统死锁)。事务把它变成系统责任:异常 → 中止 → 内存更新全部撤销,并且”失败的线程不持有任何锁”(slide 30 原话:E.g., no locks held by a failing threads…)。可组合性(composability,slide 31–33):
transfer(A,B)里嵌套synchronized(A){ synchronized(B){...} },另一个线程调transfer(B,A)→ 死锁。事务版的atomic { withdraw(A); deposit(B); }则是”可组合的“:transfer里的事务包含(subsume)withdraw/deposit里的事务,最外层定义原子性边界;transfer(A,B,100)与transfer(B,A,200)会被系统串行化,而transfer(A,B,100)与transfer(C,D,200)则自动并发(slide 33 原话:System manages concurrency as well as possible)。性能可移植性(performance portability,slide 34):为 4 核 CPU 精心调好的锁方案,到了 64 核往往不再最优(锁的争用结构变了、NUMA 变了)。事务把这件事交给系统,”一套代码吃到更多核”成为可能。
但”原子块 ≠ 锁”的反例(slide 36–37):把
synchronized直接换成atomic是错误的。例如两个线程用flagA/flagB互相等待:
// Thread 1 // Thread 2
atomic { ...; flagA = true; atomic { ...; flagB = true;
while (flagB == 0); ... } while (flagA == 0); ... }
^^^^^^ 事务里等待另一个事务的写 => 那个写在本事务提交前永不可见
(隔离性!) => 永远等不到 => 活锁/死锁
结论:atomic 消除了大量数据竞争,但消除不了”原子性违规(atomicity violation)”——程序员如果把本该原子的序列错误地切成两个原子块,一样出错(slide 37:atomic { ptr = A; } 与 atomic { B = ptr->field; } 之间,另一个线程可以把 ptr 置为 NULL)。
2.4 实现 TM 的三大设计问题与事务生命周期
- 定义与目的:TM 系统必须提供原子性与隔离性,同时尽可能保留并发(slide 40)。为此有两个必答问题(讲义把它们明确成 “Two key implementation questions”):
- 数据版本策略(data versioning policy):如何处理”未提交的新版本”与”已提交的旧版本”并存?→ eager(undo-log) 与 lazy(write-buffer)(slide 41)。
- 冲突检测策略(conflict detection policy):何时、如何判定两个并发事务冲突?→ pessimistic 与 optimistic(slide 46–49)。 第 3 问在 “What you should know”(slide 3)里被点名:检测粒度(granularity of detection)——按字、按缓存行还是按对象。
- 冲突的定义(slide 45):系统必须维护事务的 read set(读集,事务中读过的地址) 与 write set(写集,事务中写过的地址),并检测:
- Read-write conflict:事务 A 读了地址 X,而 X 被”尚未提交”的事务 B 写过;
- Write-write conflict:A、B 都未提交,且都写 X。
- ASCII 图解 D2:单个事务的生命周期状态机(把 slide 41–49 的所有策略画在同一张图上)
begin (取时钟快照 snapshot)
│
v
┌───────────────┐ read/write 命中"别人正在写"的字
│ ACTIVE │───────────────────────────────┐
│ (执行事务体) │ pessimistic 检测: 每次 │
└───────┬───────┘ load/store 后检查冲突 │
│ v
┌───────────────────────┼──────────────────────────┐ ┌───────────────┐
│ 事务体显式拒绝 │ 事务体正常结束 │ │ CONTENTION │
│ (app_abort, 如"余额不足")│ (fall through 到提交) │ │ MANAGER │
v v │ │ 等(stall)? │
┌───────────┐ ┌───────────────┐ │ │ 退(abort)? │
│ APP-ABORT │ │ VALIDATING │◄─────────────────┘ └───────┬───────┘
│ 干净回滚 │ │ 验证读集版本 │ optimistic 检测: │ abort
└─────┬─────┘ └───┬───────┬───┘ 只在 commit 时检查 │
│ ok│ │冲突 │
│ v └──────────────────────►┌───────────┐│
│ ┌────────────┐ │ ABORTED │◄┘
│ │ 取提交版本 │ │ 撤销/解锁 │
│ │ 发布写集 │ └─────┬────┘
│ └─────┬──────┘ │ 重试(退避)
v v │
┌─────────┐ ┌───────────┐ │
│ 返回失败 │ │ COMMITTED │ ┌──────┴──────┐
└─────────┘ └───────────┘ │ RESTART │
▲ │ (新 snapshot)│
└── 重试超过上限 => 串行化回退(全局串行执行一次) ──────┴─────────────┘
关键操作与性能特征:
* ACTIVE 期间每一次 read/write 都是"仪器化"(instrumented)的内存访问 => 额外指令
* VALIDATING 的代价 ∝ |read set|, 且要走一致性(可能触发 miss)
* 取提交版本 = 一次全局原子 RMW => 全部事务的串行化点(第 4 节会算它的天花板)
* ABORT 的代价: eager 需要按 undo log 恢复旧值, lazy 只需丢缓冲+解锁
- 数据版本策略:eager vs lazy(slide 41–44)
| 维度 | Eager versioning(undo-log based) | Lazy versioning(write-buffer based) |
|---|---|---|
| 写操作做什么 | 直接改内存,把旧值记入 undo log(每次 store 有额外开销) | 写只进写缓冲区,内存保持旧值 |
| commit 做什么 | 几乎不用做——数据已在内存,只需发布/解锁 | 把写缓冲冲刷(flush)到内存 |
| abort 做什么 | 按 undo log 把旧值逐个写回 | 丢掉缓冲区即可(内存从未被改) |
| 优点 | 提交快 | 中止快,且没有”事务中途崩溃留下脏数据”的容错问题 |
| 缺点 | 中止慢,容错性差(想想事务执行到一半崩溃) | 提交慢 |
| 思想 | “先写下去,赌它不会回滚”(讲义原话:write to memory immediately, hoping transaction won’t abort) | “非写不可时才写内存”(only write to memory when you have to) |
| 适合的负载 | 冲突/中止很少(低争用) | 冲突/中止很多(高争用) |
- 冲突检测策略:pessimistic vs optimistic(slide 46–50)
| 维度 | Pessimistic(a.k.a. eager) | Optimistic(a.k.a. lazy / commit-time) |
|---|---|---|
| 何时检查 | 每次 load/store 时立即检查(slide 46) | 只在 commit 时检查(slide 48) |
| 冲突后谁优先 | 由 contention manager(争用管理器) 决定 stall 还是 abort | 优先让”正在提交的事务”成功,其他事务稍后 abort |
| 优点 | 早发现 → 少做无用功;还能把一部分 abort 转成 stall | 有前进保证(forward progress);通信/检测是批量的(一次验证整个读集) |
| 缺点 | 没有前进保证(可能活锁,讲义特意用 “question: how to avoid livelock?” 提醒);细粒度通信(每条 load/store 都可能检查);检测在关键路径上 | 发现得晚(可能白做很多工作);仍可能有不公平(fairness)问题 |
| 典型代价位置 | 读/写路径(延迟敏感) | 提交路径(吞吐敏感) |
- 检测粒度(granularity):这是第 3 个设计维度,讲义的 “What you should know” 明确列出,而它的后果与”伪共享”完全同源:
| 粒度 | 元数据开销 | 伪冲突风险 | 典型实现 |
|---|---|---|---|
| 字(word) | 大:每个字都要版本/所有权信息(本文档的实现是 16 B 元数据 + 数据,即 100% 空间开销) | 低(只有真正同一个字才冲突) | 软件 TM(本文 3.1/3.2 的 stm::Word) |
| 缓存行(cache line) | 小:复用缓存的 tag/状态位,几乎零额外空间 | 高:同一行里两个互不相干的字被当成冲突 | 硬件 TM(Intel TSX/RTM、TCC 类设计) |
| 对象(object) | 中:每个对象一个版本/锁字段 | 中:同一对象的不同字段也被当冲突 | 对象级 STM |
2.5 软件 TM 的执行模型:版本时钟 + 锁位 + 读集验证 + 写缓冲
定义与目的:本笔记第 3.1 节给出一个完整可运行的教学 STM。它的机制选择非常”标准”:lazy versioning(写缓冲,提交时冲刷) + 混合冲突检测(读/写时检查版本字,提交时验证读集)+ 字粒度 + 全局版本时钟分配提交序号。这套组合足以让”所有共享访问都走事务”的程序严格可串行化。
- 直观解释(”它是什么?”):把每个共享字想象成带封条的储物柜:
- 正常时封条上写着一个编号(版本号,偶数)——这个编号表示”柜子里的东西是第几号事务放进去的”;
- 事务要改某个柜子时,先把封条翻到”占用中“(编号变奇数 = 加了锁位),并把自己的新东西先放在自己的包里(写缓冲),不放进柜子;
- 事务要读某个柜子时,先看封条:如果写着”占用中”→ 说明有人正在改,这就是冲突;如果编号比”我进场时的全局编号”还新 → 说明这是我进场之后才提交的结果,读了它就无法解释成串行执行,于是也退;
- 提交:先核对读过的所有柜子的封条没有被换过(读集验证),然后领一个新的全局编号(一次原子取号),把自己的包一次性倒进各个柜子并换上这个新编号。
- 回退:只有一个逃生门——全局串行(把所有并发者挡在外面,独占执行一次),保证任何事务最终都能前进。
- ASCII 图解 D3:软件 TM 的内存布局与两个线程的执行模型
┌────────────────────── 全局元数据 ──────────────────────┐
│ g_clock : 全局版本时钟 0 2 4 6 ... (每次提交 +2) │◄── 所有事务 begin 读它,
│ (单个 cache line: 所有核都要读/写它 => 天生的争用点) │ commit 用 fetch_add 抢号
└────────────────────────────────────────────────────────┘
事务化内存字 (stm::Word, 16 B: 版本字 + 数据) acct[0] acct[1] acct[2]
┌───────────────────────────┐ ┌───────────┬───────────┬──────────┐
│ ver: 偶数=可访问 / 奇数=被锁│ 一个字横跨 16 B │ ver | val │ ver | val │ ver| val │
│ val: 数据本体 │ 4 个字 = 64 B 行 └───────────┴───────────┴──────────┘
└───────────────────────────┘ ▲ 同一行的两个字的元数据共享缓存行 => 元数据伪共享
Thread A (事务中) Thread B (事务中)
┌──────────────────────────┐ ┌──────────────────────────┐
│ Tx: snapshot = 100 │ │ Tx: snapshot = 102 │
│ read_set : [(X,98),(Y,100)]│ │ read_set : [(X,98),(Z,102)]│
│ wbuf : [(X,15)] │ │ wbuf : [(Z,7)] │
│ owned : [X] ← X 的 ver=99(奇数) │ owned : [Z] │
└────────────┬─────────────┘ └────────────┬─────────────┘
│ commit: 验证读集(版本未变?) │ commit
│ c = g_clock.fetch_add(2)+2 = 104 │
│ X.val = 15; X.ver = 104 (release) │
v v
读-读重叠(都读 X) 不冲突; 只有 R-W / W-W 才退(与讲义 slide 25/26 一致)
关键操作与性能特征:
* 每次 read : 1 次 load(版本) + 1 次 load(值) + 1 次 load(版本) + 读集插入
* 每次 write : 版本 load + CAS(抢锁位) + 写缓冲插入
* 每次 commit: |read set| 次版本 load + 1 次全局原子 RMW + |write set| 次 store
* 因此事务的"元数据工作量"∝ 事务访问的字数, 与事务的"业务工作量"无关
=> 短事务(几个字)时, 元数据开销占比极高(第 4 节给出定量算例)
2.6 硬件 TM(公开知识补充):把一致性协议”升级”成冲突检测器
本小节是公开知识补充(Fall 2026 讲义尚未发布;讲义在 slide 9 提出”对一组地址维持单个地址的性质”、在 slide 27 用 TCC 作为硬件 TM 的例子,但未展开硬件实现细节)。这里给出业界与文献中通用的 HTM 图景,用于理解讲义设计空间在硬件上的映射。
定义与目的:硬件 TM(HTM,如 Intel TSX/RTM)让处理器用私有缓存记录 read set / write set,并复用缓存一致性协议做冲突检测:别的核想要我以事务方式读过的行(或想抢我以事务方式写过的行),就是一次冲突。
直观解释:软件 TM 里”每个字带一个版本封条”要花掉 100% 的空间;硬件 TM 换了个思路——缓存里本来就有每一行的状态(M/E/S/I),那就把”事务读过的行”“事务写过的行”直接记在缓存状态上:事务读一行 → 该行以 Shared 状态留在缓存并打上”读集标记”;事务写一行 → 该行以 Modified 状态留在缓存并打上”写集标记”(新值根本不写回内存,也不让别的核看到——隔离性由一致性协议天然保证)。提交时,这些 Modified 行”转正”即可。类比:软件 TM 是给每个抽屉贴封条,HTM 是让图书馆的门禁系统记录你摸过哪些书架——记录是免费的,因为门禁本来就在那儿。
ASCII 图解 D4:基于缓存的 HTM 结构与冲突检测
Core 0 Core 1 Core 2
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ 私有 L1/L2 │ │ 私有 L1/L2 │ │ 私有 L1/L2 │
│ ┌──────────┐ │ │ ┌──────────┐ │ │ ┌──────────┐ │
│ │ 事务里读过│ │ │ │ 事务里写过│ │ │ │ 非事务 │ │
│ │ 的行: S + │ │ │ │ 的行: M + │ │ │ │ 访问 │ │
│ │ 读集标记 │ │ │ │ 写集标记 │ │ │ │ │ │
│ └──────────┘ │ │ └──────────┘ │ │ └──────────┘ │
└──────┬───────┘ └──────┬───────┘ └──────┬───────┘
│ ①请求读/写某行(无效化或取数) │
v v
═══════════════════ 互连 / 目录 (snooping 或 directory) ═══════════════════
│
├─ ②命中 Core 1 的"事务写集" => WRITE-WRITE 冲突
├─ ③命中 Core 1 的"事务读集" => READ-WRITE 冲突
└─ ④都没有命中 => 正常一致性动作, 事务继续
│
v
冲突处理: 请求方被 nack / 被要求重试; 或某一方(常见: 请求方或写方) abort
事务 abort => 丢弃缓存中被标记的行(只有 M 行需要"丢弃", 因为没写回内存)
事务 commit => 让所有被标记的 M 行"转正"(原子地变得可见)
关键操作与性能特征:
* 记录读写集: 几乎零额外空间(复用缓存状态位), 但能力受"缓存容量"限制
* 冲突检测: 复用一致性流量, 但粒度是"缓存行"(=> 同一行不同字的伪冲突, 与伪共享同源)
* 容量溢出(capacity abort): 读写集放不进缓存 => 事务必须 abort 并回退到软件路径
* 提交: 需要把多个 M 行"一次性"对外发布, 硬件实现复杂度高(这也是 HTM 难做的原因)
HTM 的编程模型(Intel TSX/RTM 的具体形式,公开知识):
_xbegin()开始事务,返回_XBEGIN_STARTED表示进入事务;_xend()提交;_xabort(code)显式中止;失败时返回的状态位可区分原因(_XABORT_CONFLICT冲突、_XABORT_CAPACITY容量溢出、_XABORT_EXPLICIT显式中止、_XABORT_RETRY“立刻重试可能成功”),用_XABORT_CODE(st)可取应用自定义的 code。RTM 不提供前进保证:事务可能永远失败(例如另一个核一直在同一行上做非事务写),因此软件必须准备回退路径(通常是一把锁)。另外:在不支持 RTM 的处理器上执行XBEGIN会产生#UD非法指令异常,所以运行前必须用 CPUID 探测(第 3.3 节的实测代码里,这一条被真实触发过,见第 6 节陷阱 8)。STM vs HTM 对比(汇总):
| 维度 | 软件 TM(STM) | 硬件 TM(HTM / RTM) | |—|—|—| | 读写集记录 | 显式元数据(版本字/锁位),空间开销大(本实现 16 B/字) | 复用缓存状态位,空间开销≈0,但受缓存容量限制 | | 检测粒度 | 可做到字/对象级(伪冲突少) | 缓存行级(同源伪冲突) | | 检测延迟 | 走普通内存访问,慢(额外 load/CAS) | 一次一致性请求即可,快 | | 容量上限 | 几乎无上限(受内存限制) | 有(L1/L2 容量 → capacity abort) | | 能否回滚 I/O、系统调用 | 不能(同 HTM) | 不能(事务内禁止系统调用/页错误/中断) | | 可移植性 | 纯软件,任何平台(但要自己写正确,很难) | 依赖具体 ISA,且历史上曾因勘误被微码关闭 | | 前进保证 | 由争用管理器 + 串行化回退提供 | 无(必须软件回退) | | 本文档的示例 | 3.1 / 3.2(stm.hpp) | 3.3(tsx.cpp) | —
3. 代码示例与性能分析
本节的三段代码都是完整可编译可运行的,并且都在本机实测通过(编译命令写在每个文件头部)。它们的目的是把第 2 节的设计空间”落地”:
- 示例 1(
stm.hpp+transfer.cpp):从零实现一个教学用软件事务内存(STM)——全局版本时钟 + 惰性/急切两种数据版本策略(用-DSTM_EAGER=1一键切换)+ 提交时乐观检测 + 争用管理器 + 串行化回退;用它跑银行转账,并用”总额守恒”这一全局不变量来检验可串行化与失败原子性。 - 示例 2(
bst.cpp):讲义 slide 16–27 的树更新实验的完整复现——同一棵二叉搜索树上比较 手递手细粒度锁 / 单把全局锁 / 事务 三种实现,并用中序遍历验证三棵树的正确性。 - 示例 3(
tsx.cpp):硬件事务内存(Intel TSX/RTM)的编程模型——_xbegin/_xend/_xabort、abort 状态位解码、CPUID 探测与锁回退,并用一个”读取者必须同时看到 8 个账户”的不变量来检验隔离性(无撕裂读)。
3.1 示例 1:一个可运行的软件事务内存(STM)与”银行转账”
文件 stm.hpp(教学 STM:约 190 行,含全部机制注释)
// stm.hpp —— 教学用软件事务内存 (Software Transactional Memory, STM)
// 设计要点:
// 1) 全局版本时钟 (global version clock) 给每个已提交事务分配提交序号 => 可串行化
// 2) 细粒度"锁位"表达写集所有权: 版本字最低位 = 1 表示被某事务独占
// 3) 数据版本策略可选: 惰性(写缓冲, LAZY) / 急切(undo log, EAGER)
// 4) 冲突检测: 读时检查版本(悲观/急切的"见锁即退") + 提交时验证读集(乐观)
// 5) 前进保证: 有限次退避重试后进入"排空式全局串行"回退, 消除活锁
// 用法/编译: 本文件是头文件, 与使用方一起编译, 例如
// g++ -O3 -std=c++17 -pthread transfer.cpp -o transfer (惰性版本)
// g++ -O3 -std=c++17 -pthread -DSTM_EAGER=1 transfer.cpp -o transfer_eager
#pragma once
#include <atomic>
#include <cstdint>
#include <vector>
#include <utility>
#include <functional>
#include <mutex>
#include <thread>
#include <chrono>
#ifndef STM_EAGER
#define STM_EAGER 0 // 0 = 惰性版本(写缓冲), 1 = 急切版本(undo log)
#endif
namespace stm {
struct Word { // 一个"事务化内存字"
std::atomic<uint64_t> ver{0}; // 偶数=可访问; 奇数=被某事务独占
long val{0}; // 数据本体
};
static_assert(sizeof(Word) == 16, "Word should be 16B");
inline std::atomic<uint64_t> g_clock{0}; // 全局版本时钟, 每次提交 +2
inline std::atomic<bool> g_serial{false}; // 全局串行模式(回退)标志
inline std::mutex g_serial_lock; // 回退互斥
// 统计(可选)
inline std::atomic<uint64_t> g_n_abort{0}, g_n_fallback{0}, g_n_commit{0};
struct Tx {
uint64_t snapshot = 0;
bool app_abort = false; // 应用层显式回滚
std::vector<std::pair<Word*,uint64_t>> read_set; // (地址, 当时版本)
std::vector<Word*> owned; // 已持有的写集
#if STM_EAGER
std::vector<std::pair<Word*,long>> undo; // 旧值(undo log)
#else
std::vector<std::pair<Word*,long>> wbuf; // 新值(写缓冲)
#endif
void begin() {
app_abort = false;
read_set.clear(); owned.clear();
if (read_set.capacity() == 0) { read_set.reserve(32); owned.reserve(8); } // 避免事务内扩容
#if STM_EAGER
undo.clear(); if (undo.capacity() == 0) undo.reserve(8);
#else
wbuf.clear(); if (wbuf.capacity() == 0) wbuf.reserve(8);
#endif
snapshot = g_clock.load(std::memory_order_acquire);
}
bool read(Word* w, long& out) {
#if STM_EAGER
for (Word* p : owned) if (p == w) { out = w->val; return true; } // 值已在内存
#else
for (auto& e : wbuf) if (e.first == w) { out = e.second; return true; } // 取自写缓冲
#endif
if (read_committed(w, out)) return true;
// 争用管理器(contention manager): 面对"被独占"的字, 决定等还是退
if (!owned.empty()) return false; // 已持写集: 立刻退, 避免环形等待/死锁
if (!wait_unlocked(w)) return false; // 未持写集: 有界等待持有者提交或回滚
return read_committed(w, out); // 重新读一次
}
bool read_committed(Word* w, long& out) { // 读一个未被独占的字
uint64_t v1 = w->ver.load(std::memory_order_acquire);
if (v1 & 1ULL) return false; // 被某事务独占
long v = w->val;
uint64_t v2 = w->ver.load(std::memory_order_acquire);
if (v1 != v2) return false; // 读到撕裂
if (v1 > snapshot) return false; // 提交于本事务开始之后 => 不可串行化
read_set.emplace_back(w, v1);
out = v;
return true;
}
static bool wait_unlocked(Word* w) { // 有界等待: 持有者很快会提交/回滚
for (int r = 0; r < 8192; ++r) {
if (!(w->ver.load(std::memory_order_acquire) & 1ULL)) return true;
if ((r & 255) == 255) std::this_thread::yield();
}
return false;
}
bool write(Word* w, long x) {
for (Word* p : owned) if (p == w) {
#if STM_EAGER
w->val = x;
#else
for (auto& e : wbuf) if (e.first == w) { e.second = x; break; }
#endif
return true;
}
uint64_t v = w->ver.load(std::memory_order_acquire);
if (v & 1ULL) return false;
if (v > snapshot) return false;
uint64_t expect = v;
if (!w->ver.compare_exchange_strong(expect, v + 1, std::memory_order_acq_rel)) return false;
owned.push_back(w); // 抢到所有权
#if STM_EAGER
undo.emplace_back(w, w->val);
w->val = x;
#else
wbuf.emplace_back(w, x);
#endif
return true;
}
bool commit() {
for (auto& r : read_set) { // 1) 验证读集(乐观检测在此发生)
bool mine = false;
for (Word* p : owned) if (p == r.first) { mine = true; break; }
if (!mine && r.first->ver.load(std::memory_order_acquire) != r.second) { abort(); return false; }
}
uint64_t c = g_clock.fetch_add(2, std::memory_order_acq_rel) + 2; // 2) 申请提交版本
#if STM_EAGER
for (Word* w : owned) w->ver.store(c, std::memory_order_release);
#else
for (auto& e : wbuf) { // 3) 冲刷写缓冲: 先写值, 再 release 发布版本
e.first->val = e.second;
e.first->ver.store(c, std::memory_order_release);
}
#endif
g_n_commit.fetch_add(1, std::memory_order_relaxed);
read_set.clear(); owned.clear();
#if STM_EAGER
undo.clear();
#else
wbuf.clear();
#endif
return true;
}
void abort() { // 回滚 + 释放所有锁
#if STM_EAGER
for (auto& e : undo) e.first->val = e.second; // 急切版本: 恢复旧值
#endif
for (Word* w : owned) {
uint64_t cur = w->ver.load(std::memory_order_relaxed);
w->ver.store(cur & ~1ULL, std::memory_order_release);
}
read_set.clear(); owned.clear();
#if STM_EAGER
undo.clear();
#else
wbuf.clear();
#endif
}
};
inline void backoff(int attempt) {
int r = 1 << (attempt < 6 ? attempt : 6);
for (volatile int i = 0; i < r * 64; ++i) { }
if (attempt >= 4) std::this_thread::yield();
}
// 正常路径: 乐观重试; 超过 tries 次则进入排空式串行回退(保证前进)
template <class F>
bool run_tx(F body, uint64_t* n_abort = nullptr, uint64_t* n_serial = nullptr, int tries = 16) {
for (int a = 0; a < tries; ++a) {
if (g_serial.load(std::memory_order_acquire)) { std::this_thread::yield(); backoff(a); continue; }
Tx tx; tx.begin();
bool body_ok = body(tx);
bool done = body_ok && tx.commit();
bool app = tx.app_abort;
if (!done) tx.abort(); // 所有失败路径都必须回滚(释放锁)
if (done) return true;
if (app) return false; // 应用层拒绝(如"余额不足"): 干净回滚, 不重试
g_n_abort.fetch_add(1, std::memory_order_relaxed);
if (n_abort) ++*n_abort;
backoff(a);
}
// 回退: 全局串行执行一次(带时限的停滞重试), 保证前进
std::lock_guard<std::mutex> g(g_serial_lock);
g_serial.store(true, std::memory_order_release);
g_n_fallback.fetch_add(1, std::memory_order_relaxed);
if (n_serial) ++*n_serial;
auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(1000);
for (;;) {
Tx tx; tx.begin();
bool body_ok = body(tx);
bool done = body_ok && tx.commit();
bool app = tx.app_abort;
if (!done) tx.abort();
if (done || app || std::chrono::steady_clock::now() > deadline) {
g_serial.store(false, std::memory_order_release);
return done;
}
std::this_thread::yield();
}
}
} // namespace stm
文件 transfer.cpp(用上面的 STM 做银行转账;检验可串行化、失败原子性与”读己之所写”)
// Bank transfer(银行转账)微基准: 用同一套 STM 跑"组合式"事务, 并检验不变量
// 编译(惰性版本): g++ -O3 -std=c++17 -pthread transfer.cpp -o transfer
// 编译(急切版本): g++ -O3 -std=c++17 -pthread -DSTM_EAGER=1 transfer.cpp -o transfer_eager
#include "stm.hpp"
#include <cstdio>
#include <cstdlib>
#include <thread>
#include <vector>
#include <atomic>
struct Account { stm::Word balance; };
static Account acct[4096];
// ---- 事务化的原子操作(可组合的构建块) ----
static bool deposit(stm::Tx& tx, Account& a, long amt) {
long b; if (!tx.read(&a.balance, b)) return false; // 冲突 => 重试
return tx.write(&a.balance, b + amt);
}
static bool withdraw(stm::Tx& tx, Account& a, long amt, long* ryow_bad) {
long b; if (!tx.read(&a.balance, b)) return false;
if (b < amt) { tx.app_abort = true; return false; } // "余额不足"=应用层拒绝, 干净回滚
if (!tx.write(&a.balance, b - amt)) return false;
long again; // 检验"读己之所写"(写缓冲/内存一致性)
if (!tx.read(&a.balance, again)) return false;
if (again != b - amt && ryow_bad) ++*ryow_bad;
return true;
}
// transfer 组合 withdraw/deposit: 最外层事务定义原子性边界
// fail_deposit=true 模拟"入账阶段抛异常": 已完成的取款必须被整体回滚(failure atomicity)
static bool transfer(stm::Tx& tx, Account& f, Account& t, long amt, bool fail_deposit, long* ryow_bad) {
if (!withdraw(tx, f, amt, ryow_bad)) return false;
if (fail_deposit) { tx.app_abort = true; return false; }
return deposit(tx, t, amt);
}
int main(int argc, char** argv) {
const int T = (argc > 1) ? atoi(argv[1]) : 4;
const int ITERS = (argc > 2) ? atoi(argv[2]) : 200000;
const int N = (argc > 3) ? atoi(argv[3]) : 1024;
const long INIT = 1000000;
for (int i = 0; i < N; ++i) acct[i].balance.val = INIT;
long expect = (long)N * INIT;
std::atomic<uint64_t> aborts{0}, serials{0}, app_fail{0}, lost{0}, ryow_bad{0};
auto work = [&](int tid) {
uint32_t seed = 12345u + tid * 977u;
uint64_t ab = 0, se = 0; long rb = 0;
for (int i = 0; i < ITERS; ++i) {
seed = seed * 1664525u + 1013904223u;
int a = (int)((seed >> 8) % (unsigned)N);
int b = (int)((seed >> 16) % (unsigned)N);
if (a == b) b = (b + 1) % N;
if (seed & 0x8000u) { int t = a; a = b; b = t; } // 双向, 避免单向抽干账户
long amt = 1 + (long)((seed >> 24) % 100);
bool must_fail = ((i & 15) == 15); // 每 16 次一次"入账失败"
bool ok = stm::run_tx([&](stm::Tx& tx) {
return transfer(tx, acct[a], acct[b], amt, must_fail, &rb);
}, &ab, &se);
if (must_fail ? ok : !ok) lost.fetch_add(1, std::memory_order_relaxed);
if (must_fail) app_fail.fetch_add(1, std::memory_order_relaxed);
}
aborts.fetch_add(ab, std::memory_order_relaxed);
serials.fetch_add(se, std::memory_order_relaxed);
ryow_bad.fetch_add((uint64_t)rb, std::memory_order_relaxed);
};
auto t0 = std::chrono::steady_clock::now();
std::vector<std::thread> th;
for (int i = 0; i < T; ++i) th.emplace_back(work, i);
for (auto& t : th) t.join();
double sec = std::chrono::duration<double>(std::chrono::steady_clock::now() - t0).count();
long total = 0; for (int i = 0; i < N; ++i) total += acct[i].balance.val;
printf("%s T=%2d N=%5d : %7.1f ns/committed-tx %6.2f M tx/s abort=%5.1f%% fallback=%llu "
"lost=%llu ryow_bad=%llu sum=%s\n",
#if STM_EAGER
"EAGER",
#else
"LAZY ",
#endif
T, N, sec * 1e9 / ((double)T * ITERS) * T / T * 1.0,
(double)T * ITERS / sec / 1e6,
100.0 * aborts.load() / (aborts.load() + (double)T * ITERS),
(unsigned long long)serials.load(),
(unsigned long long)lost.load(), (unsigned long long)ryow_bad.load(),
total == expect ? "OK" : "*** CORRUPTED ***");
(void)app_fail;
return (total == expect && lost.load() == 0 && ryow_bad.load() == 0) ? 0 : 1;
}
【代码做什么?】(transfer.cpp)
- 建立不变量:N 个账户各 1,000,000 元,全局”总额守恒”是被检验的不变量——任何”扣了款却没入账”或”事务只做了一半”的错误都会立刻破坏总额。
- T 个线程各自做 ITERS 次转账:用线性同余随机数选
from/to两个不同账户、随机金额(1..100),并随机交换方向(避免单向转账把某个账户抽干)。 - 每 16 次故意制造一次失败:
transfer(..., fail_deposit=true)会先成功取款(已写入事务),然后在”入账”阶段显式拒绝(tx.app_abort = true)。这就精确模拟了讲义 slide 29–30 的try/catch场景:已完成的取款必须被整体回滚(failure atomicity)。程序统计”应当失败的次数”与”实际失败次数”,二者必须相等。 withdraw里额外做一次”读己之所写”检查:写入新余额后立刻再读一次,确认读到的是本次事务写入的新值(而不是内存里的旧值),违例计入ryow_bad(必须为 0)。- 主流程:
run_tx最多乐观重试 16 次(默认tries=16)→ 成功则提交;最后打印时间、每次已提交事务的纳秒数、中止率、串行化回退次数、sum=OK/CORRUPTED。
【并行机制与性能解说】
- 线程与工作分配:T 个
std::thread,每个线程独立生成自己的随机序列(seed = 12345 + tid*977),没有中心任务队列——线程之间唯一的交互是它们碰巧选到同一对账户时的真冲突。这正是”事务把同步决策交给系统”的含义:程序员只声明atomic { withdraw; deposit; },没有任何锁序知识。 - 硬件上发生了什么:
read()是”版本 load→值 load→版本 load”的三次访问 + 一次读集插入;write()是”版本 load + CAS 抢锁位 + 写缓冲插入”;commit()是”逐条验证读集 + 一次全局原子fetch_add+ 逐条发布写集”。没有一次操作会阻塞线程——冲突全部转化为”回退 + 退避 + 重试”,这是乐观并发的典型形态(与讲义 slide 48–49 的 optimistic detection 一致)。 - Work / Span / 并行度(本实验的定量骨架):
- Work(总工作量):
W = N_tx · c_tx,其中N_tx = T·ITERS = 4×200,000 = 800,000次已提交事务,c_tx = c_begin + r·c_read + w·c_write + c_commit(本实验 r = 3 次字读、w = 2 次字写)。实测单线程 133.6 ns/事务,即c_tx ≈ 400个 3.0 GHz 周期。 - Span(关键路径):所有事务的提交都必须串行取一次全局版本号(
g_clock.fetch_add),所以Span = N_tx · c_serial(c_serial≈ 150 周期,见 4.1 的分解)。 - 并行度 = Work / Span ≈ 400 / 150 ≈ 2.7:这就是”即使有 128 个核,这个基准也几乎不可能超过 2.7ד的来源。实测最好的一组是 1,336 ns/tx(T=1)→ 76.7 ns/tx(T=4)≈ 1.74×,与模型同量级;差距来自跨 NUMA 节点时时钟行的弹跳和读集验证的缓存未命中。
- 冲突带来的额外工作量:
p概率冲突时,期望工作量是W/(1-p)(重试的活儿白干)。这也解释了为什么 3.2 节的树实验里”事务几乎不 abort”是关键优势。
- Work(总工作量):
- 实测数据(同一进程内的一组对照,T=4,
taskset -c 0-3固定到同一个 CCD):
| 配置(账户数 N) | 惰性版本 LAZY(ns/已提交事务) | 急切版本 EAGER(ns/已提交事务) | LAZY 中止率 | 说明 |
|---|---|---|---|---|
| N = 2(极端争用) | 169.4 | 205.3 | 17.6% | 冲突几乎全部被”停滞等待”吸收,但吞吐被串行化拖死 |
| N = 8 | 176.3 | 201.6 | 38.6% | 冲突最激烈的区间:中止率最高、吞吐最低 |
| N = 64 | 84.9 | 87.9 | 9.1% | 中止率与”冲突概率模型”吻合(见 4.2) |
| N = 1024(几乎无争用) | 76.7 | 73.0 | 0.7% | 纯元数据开销区间:急切版本反而略快(提交更便宜) |
- 所有配置下
sum=OK(总额守恒)、ryow_bad=0(读己之所写正确)、成功/失败事务计数与预期一致 → 可串行化与失败原子性都被真正验证了,而不只是”看起来能跑”。 - 急切 vs 惰性的结论与讲义 slide 44 的表格完全一致:低争用(N=1024,中止率 0.7%)时 eager 略快(提交便宜);高争用(N=2, N=8)时 lazy 明显更快(中止便宜、中止次数还更少)——“eager 赌不中止,lazy 赌会中止”,实测给出了这张赌注的赔率。
- 注意 N=2 的中止率(17.6%)反而低于 N=8(38.6%):这不是矛盾,而是争用管理器起了作用——在只有两个字的极端争用下,
wait_unlocked()把大量”本该 abort”的冲突变成了短暂停滞(对应讲义 slide 47 Case 2 “early detect (and stall)”)。中止率低并不等于性能好:N=2 的每事务耗时(169 ns)是 N=1024(77 ns)的两倍多。 - 瓶颈在哪:不在内存带宽(1024 个账户只有 16 KB,全在缓存里),也不在业务代码,而在 (a) 全局版本时钟这一个缓存行(提交时一次原子 RMW + 开始时一次 load,所有核共享);(b) 每事务的元数据操作数(r+w 次仪器化访问);(c) 冲突重试。第 4 节会把 (a) 算成一个硬天花板。
3.2 示例 2:并发二叉搜索树——手递手锁 vs 全局锁 vs 事务
本示例是讲义 slide 16–27(树更新,图 credit: Austen McDonald)的可运行复现。实验设计要点:
- 键区间互不相交:把键空间切成 T 段,每段由一个线程独占(线程 t 只插入
(t·PER, (t+1)·PER)内的键),并预先单线程插入 T 个”分界键”作为骨架。于是各线程只写自己子树内的子指针,但都要读从根下来的那一串祖先——这正是讲义 slide 23–25 的情形:共享的只有”读”。 - 插入顺序为保证平衡:每段内部按”先插中点、再递归左右”的顺序插入(
fill_bal),否则按从小到大插入会让 BST 退化成链表(本文档的第一版就踩了这个坑:树高 20000,每次插入要走 20000 个节点,三种实现都慢到 60 µs/次)。 - 事务的节点预分配:
malloc/new不可回滚,所以新节点在事务之外分配好,事务体里只做”把指针挂到空位上”这一件事。这是把”副作用”赶出事务的标准做法(另见第 6 节陷阱 7)。 - 手递手锁:从根开始
lock(父)→lock(子)→unlock(父),锁住路径上每个节点;粗粒度锁:一把全局std::mutex包住整次插入。 - 正确性验证:对三棵树分别做中序遍历,要求节点数与”严格递增”同时成立——如果 STM 出现隔离性错误(丢更新、撕裂),遍历结果几乎必然会暴露。
文件 bst.cpp
// 并发二叉搜索树插入: 粗粒度锁 / 手递手细粒度锁 / 事务, 三者对比
// 编译: g++ -O3 -std=c++17 -pthread bst.cpp -o bst
#include "stm.hpp"
#include <cstdio>
#include <cstdlib>
#include <thread>
#include <vector>
#include <mutex>
#include <chrono>
#include <functional>
#include <atomic>
// ---------------- 事务版本: 子指针是事务化内存字 ----------------
struct TNode {
long key; // 发布后不可变: 可非事务化读
stm::Word left, right; // 子指针(以 long 存指针), 0 表示空
};
static stm::Word root_word;
static bool tx_insert(stm::Tx& tx, long k, TNode* nn) {
stm::Word* cur = &root_word; long c;
if (!tx.read(cur, c)) return false;
for (;;) {
if (c == 0) return tx.write(cur, (long)nn); // 挂到空位上
TNode* n = (TNode*)c;
stm::Word* child = (k < n->key) ? &n->left : &n->right;
long cc; if (!tx.read(child, cc)) return false;
if (cc == 0) return tx.write(child, (long)nn);
cur = child; c = cc; // 继续下降
}
}
static void stm_insert(long k, uint64_t* ab, uint64_t* se) {
TNode* nn = new TNode{k, {}, {}}; // malloc 不可回滚 => 放在事务之外
bool ok = stm::run_tx([&](stm::Tx& tx) { return tx_insert(tx, k, nn); }, ab, se);
if (!ok) { fprintf(stderr, "insert %ld failed\n", k); exit(1); }
}
// ---------------- 手递手(hand-over-hand)细粒度锁版本 ----------------
struct LNode {
long key;
LNode *left = nullptr, *right = nullptr;
std::mutex m;
};
static LNode* lock_root = nullptr;
static void fine_insert(long k) {
LNode* cur = lock_root;
cur->m.lock(); // 先锁父, 再锁子, 然后放父
for (;;) {
LNode** slot = (k < cur->key) ? &cur->left : &cur->right;
if (*slot == nullptr) { *slot = new LNode{k}; cur->m.unlock(); return; }
LNode* nxt = *slot;
nxt->m.lock();
cur->m.unlock();
cur = nxt;
}
}
// ---------------- 粗粒度锁版本 ----------------
static std::mutex g_big_lock;
static void coarse_insert(LNode*& root, long k) {
std::lock_guard<std::mutex> g(g_big_lock);
LNode** p = &root;
while (*p) p = (k < (*p)->key) ? &(*p)->left : &(*p)->right;
*p = new LNode{k};
}
// ---------------- 校验: 中序遍历必须严格递增 ----------------
static long verify_tx(TNode* n, long* prev, bool* sorted) {
if (!n) return 0;
long c = verify_tx((TNode*)n->left.val, prev, sorted);
if (n->key <= *prev) *sorted = false;
*prev = n->key;
return c + 1 + verify_tx((TNode*)n->right.val, prev, sorted);
}
static long verify_lock(LNode* n, long* prev, bool* sorted) {
if (!n) return 0;
long c = verify_lock(n->left, prev, sorted);
if (n->key <= *prev) *sorted = false;
*prev = n->key;
return c + 1 + verify_lock(n->right, prev, sorted);
}
// 以"先插区间中点"的递归顺序插入 => 树高 O(log n), 避免退化成链表
static void fill_bal(long lo, long hi, const std::function<void(long)>& ins) {
if (lo > hi) return;
long mid = lo + (hi - lo) / 2;
ins(mid);
fill_bal(lo, mid - 1, ins);
fill_bal(mid + 1, hi, ins);
}
static LNode* g_coarse_root = nullptr;
static std::atomic<uint64_t> g_ab{0}, g_se{0};
static void body_fine(int tid, long per) {
long base = (long)tid * per;
fill_bal(base + 1, base + per - 1, [](long k) { fine_insert(k); });
}
static void body_coarse(int tid, long per) {
long base = (long)tid * per;
fill_bal(base + 1, base + per - 1, [](long k) { coarse_insert(g_coarse_root, k); });
}
static void body_stm(int tid, long per) {
long base = (long)tid * per;
uint64_t ab = 0, se = 0;
fill_bal(base + 1, base + per - 1, [&](long k) { stm_insert(k, &ab, &se); });
g_ab.fetch_add(ab, std::memory_order_relaxed);
g_se.fetch_add(se, std::memory_order_relaxed);
}
static double timed(const char* name, int T, long M, std::function<void(int,long)> body) {
auto t0 = std::chrono::steady_clock::now();
std::vector<std::thread> th;
for (int i = 0; i < T; ++i) th.emplace_back(body, i, M + 1);
for (auto& x : th) x.join();
double sec = std::chrono::duration<double>(std::chrono::steady_clock::now() - t0).count();
printf("%-22s T=%2d : %8.1f ms %7.1f ns/insert %7.2f M insert/s\n",
name, T, sec * 1e3, sec * 1e9 / ((double)T * M), (double)T * M / sec / 1e6);
return sec;
}
int main(int argc, char** argv) {
const int T = (argc > 1) ? atoi(argv[1]) : 4;
const int M = (argc > 2) ? atoi(argv[2]) : 20000; // 每线程插入数
const long PER = M + 1; // 每线程独占的键区间长度
root_word.val = 0;
lock_root = new LNode{0};
g_coarse_root = new LNode{0};
{ TNode* nn0 = new TNode{0, {}, {}}; // 三棵树用同一套键: 0 与 T-1 个分界键
stm::run_tx([&](stm::Tx& tx) { return tx_insert(tx, 0, nn0); }, nullptr, nullptr); }
for (int t = 1; t < T; ++t) { // 预建骨架: T 个互不相交的键区间
long k = (long)t * PER;
LNode** p = &g_coarse_root; while (*p) p = (k < (*p)->key) ? &(*p)->left : &(*p)->right;
*p = new LNode{k};
fine_insert(k);
TNode* nn = new TNode{k, {}, {}};
stm::run_tx([&](stm::Tx& tx) { return tx_insert(tx, k, nn); }, nullptr, nullptr);
}
timed("hand-over-hand locks", T, M, body_fine);
timed("single global lock", T, M, body_coarse);
timed("STM (transactions)", T, M, body_stm);
printf(" STM aborts=%llu fallbacks=%llu\n",
(unsigned long long)g_ab.load(), (unsigned long long)g_se.load());
long expect = (long)T * PER, prev = -1; bool sorted = true;
long n1 = verify_tx((TNode*)root_word.val, &prev, &sorted);
printf(" STM tree : nodes=%ld (expect %ld) sorted=%s\n", n1, expect, sorted ? "YES" : "NO");
bool ok1 = (n1 == expect && sorted);
prev = -1; sorted = true;
long n2 = verify_lock(g_coarse_root, &prev, &sorted);
printf(" lock tree: nodes=%ld (expect %ld) sorted=%s\n", n2, expect, sorted ? "YES" : "NO");
prev = -1; sorted = true;
long n3 = verify_lock(lock_root, &prev, &sorted);
printf(" hoh tree : nodes=%ld (expect %ld) sorted=%s\n", n3, expect, sorted ? "YES" : "NO");
return (ok1 && n2 == expect && n3 == expect) ? 0 : 1;
}
【代码做什么?】(bst.cpp)
- 三种树各有自己的根:事务版(
TNode,子指针是stm::Word)、手递手锁版(LNode,每个节点一个std::mutex)、全局锁版(同样用LNode但只锁一把全局锁)。 - 预建骨架:先单线程插入
0, PER, 2·PER, …这些分界键,使三棵树的拓扑与键集完全一致(也避免了”第一个节点由谁创建”这种一次性争用干扰测量)。 - 并发阶段:三个变体分别计时——
body_fine(手递手锁)、body_coarse(全局锁)、body_stm(事务,run_tx包裹tx_insert)。 - 事务体
tx_insert:从根开始逐级下降,每一步都tx.read当前节点的子指针;若子指针为空,就tx.write把预分配好的新节点挂上去。整条路径的祖先节点都进入读集,只有最后一个子指针进入写集。 - 校验:
verify_tx/verify_lock做中序遍历,检查节点数 = 80004 且键严格递增;stm_insert在事务最终失败时直接exit(1)(不允许静默丢更新)。
【并行机制与性能解说】
- 线程与工作分配:T 个
std::thread,键区间静态划分(线程 t 处理第 t 段),负载完全均衡(每线程 20,000 次插入),因此负载不均不是本实验的变量——唯一的变量是”同步原语如何对待共享的祖先路径”。 - 硬件上三种实现的差别(这是本讲的核心对照):
- 手递手锁:一次插入要执行
2h ≈ 34次 mutex 操作(h ≈ 17 为树高),其中根节点与分界键被所有线程反复独占——这些缓存行在核之间来回弹跳(cache-line ping-pong),且读共享也被当成冲突(讲义 slide 22)。 - 全局锁:一次插入只有 1 次(高度争用的)mutex 操作,下降过程不加锁,因此比手递手锁更便宜,但仍被彻底串行化。
- 事务:一次插入产生
h ≈ 17条读集记录 + 1 条写集记录 + 1 次提交。祖先路径在两个线程的事务里都是”只读”,而”读-读”在 TM 里根本不是冲突(讲义 slide 25 的结论),所以事务可以真正并发;代价是每事务的元数据操作数与一次全局版本号抢号。
- 手递手锁:一次插入要执行
- Work / Span / 并行度:
- 记每次插入的”事务代价”
c_tx = h·c_read + c_write + c_commit,h ≈ 17。以本机实测的单线程 STM 数据反推:c_tx ≈ 230 ns。 - Work:
W = N · c_tx,N = T·M = 4×20,000 = 80,000次插入。 - Span:
- 事务版的关键路径是”提交必须串行取版本号”:
Span ≈ N · c_serial(c_serial ≈ c_tx/7 ≈ 33 ns,取 4.1 节分解的比例)→ 并行度上限 ≈ 7。 - 锁版(两种)的关键路径是”根节点/全局锁的独占持有”:
Span = N · h · c_lock(手递手)或N · c_mutex(全局锁),即整段计算都在关键路径上,并行度 ≈ 1。
- 事务版的关键路径是”提交必须串行取版本号”:
- 这个
Work/Span差(≈7 对 1)就是下面实测结果的来源。
- 记每次插入的”事务代价”
- 实测数据(T = 4,每线程 20,000 次插入;同一进程内顺序跑三个变体,故三者之间的可比性优于跨进程比较):
| 实现(一次代表性运行) | T=1(ns/insert) | T=4(ns/insert) | T=4 吞吐 | 相对加速(1→4 线程) | 结论 |
|---|---|---|---|---|---|
| 手递手细粒度锁 | 232.4 | 2532.7 | 0.39 M insert/s | 0.09×(变慢 11 倍) | 细粒度锁没有换来并发:共享的是整条祖先路径,锁操作数还放大了 17 倍 |
| 单把全局锁 | 160.8 | 1119.6 | 0.89 M insert/s | 0.14× | 串行化无可避免,但每次插入只有一次锁操作 |
| 事务(STM) | 230.3 | 296.0 | 3.38 M insert/s | 0.78×(基本持平) | 单线程就比全局锁慢(元数据税),但几乎不随线程数退化 |
- 该次运行中 T=4 时事务比全局锁快 3.8×、比手递手锁快 8.6×(重复运行受机器负载影响,STM 与全局锁的倍数在约 1.2×–4× 之间波动,但三者的快慢次序稳定不变);并且
STM aborts=0, fallbacks=0——因为写集互不相交、共享的只有读,没有任何 R-W/W-W 冲突(与讲义 slide 25 的”NO READ-WRITE or WRITE-WRITE conflicts!”完全对应)。 - 单线程时事务反而比全局锁慢(230 ns vs 161 ns/insert):这就是 TM 的”入场费”——每次访问都要仪器化(额外 load、读集/写集插入、提交时验证、抢版本号)。TM 的卖点不是单线程性能,而是可扩展性。
- 手递手锁比全局锁更慢是本实验最反直觉、也最贴合讲义的一张牌:细粒度锁把锁的数量从 1 增加到
2h,而它想买的”并发”在这里根本不存在(根节点是必经之路)。如果换成一个所有线程都写同一个头节点的队列(讲义 slide 28 的PushLeft(DQueue*, int):每个PushLeft都要写leftSentinel->right),那么锁与事务都不会变快——因为那是真正的 W-W 冲突,语义上必须串行化。判断”该不该用事务”的正确问法是:读写集的重叠里有多少是”读-读”? - 瓶颈与注意事项:本实验的绝对数值来自一台共享机器(load average 约 8–12),重复运行可观察到 2–4 倍的波动(例如 STM 在 T=4 的另一组重复里测到 1087 ns/insert)。结论性的是相对关系与趋势,而不是具体数字:重复运行时三个变体的绝对耗时可以在 2–4 倍范围内变化(例如另一组重复运行中三者分别是 1073 / 267 / 222 ns/insert),但“手递手锁最慢、事务最快”这两个次序结论每次都成立;而在我们做过的单线程(T=1)测量里,事务始终比全局锁慢(230 ns vs 161 ns/insert,元数据税的代价)。请把上表当作”同一次实验内三种实现的对照”。
3.3 示例 3:硬件事务内存(Intel TSX / RTM)
文件 tsx.cpp(需要 -mrtm;在不支持 RTM 的机器上必须先 CPUID 探测,否则执行 XBEGIN 会触发 #UD 非法指令异常——本文档实测时真的崩过一次,见第 6 节陷阱 8)
// 硬件事务内存 (Intel TSX / RTM) 示例: 事务化转账 + 不变量读取者
// 编译: g++ -O3 -std=c++17 -pthread -mrtm tsx.cpp -o tsx
// (必须加 -mrtm: RTM 内建函数在未开启该特性时无法内联, 会报 always_inline 错误)
// 说明: 需要 CPU 支持 RTM(Intel Haswell/Broadwell 及后续部分型号)。
// 无 RTM 时 _xbegin() 立即返回失败状态, 本程序自动走互斥锁回退路径。
#include <immintrin.h>
#include <cpuid.h>
#include <cstdio>
#include <cstdlib>
#include <thread>
#include <vector>
#include <atomic>
#include <mutex>
#include <chrono>
struct Account { long balance; };
static Account acct[8];
static std::mutex g_fallback_lock; // 回退路径: 普通互斥锁保证前进
static std::atomic<uint64_t> n_htm_commit{0}, n_conflict{0}, n_capacity{0},
n_explicit{0}, n_other{0}, n_mutex{0}, n_tear{0};
static bool g_has_rtm = false; // 必须先探测: 无 RTM 时执行 XBEGIN 会产生 #UD 非法指令异常
static bool cpu_has_rtm() {
unsigned a, b, c, d;
if (__get_cpuid_max(0, nullptr) < 7) return false;
__cpuid_count(7, 0, a, b, c, d);
return (b & (1u << 11)) != 0; // CPUID.07H:EBX[11] = RTM
}
// 用硬件事务完成一次转账; 冲突/容量溢出时解码状态位并退避重试, 最终回退到锁
static void htm_transfer(int from, int to, long amt) {
if (!g_has_rtm) { // 无硬件事务支持: 直接走锁路径
std::lock_guard<std::mutex> g(g_fallback_lock);
long a = acct[from].balance, b = acct[to].balance;
if (a >= amt) { acct[from].balance = a - amt; acct[to].balance = b + amt; }
n_mutex.fetch_add(1, std::memory_order_relaxed);
return;
}
for (int attempt = 0; ; ++attempt) {
unsigned st = _xbegin();
if (st == _XBEGIN_STARTED) {
// ★ 事务体内是"普通"读写: 硬件用缓存记录读集/写集
long a = acct[from].balance;
long b = acct[to].balance;
if (a < amt) { _xabort(1); } // 显式回滚(应用层拒绝)
acct[from].balance = a - amt; // 写集: 只在提交时对其他核可见
acct[to].balance = b + amt;
_xend(); // 提交: 写集原子生效
n_htm_commit.fetch_add(1, std::memory_order_relaxed);
return;
}
if (st & _XABORT_CONFLICT) n_conflict.fetch_add(1, std::memory_order_relaxed);
else if (st & _XABORT_CAPACITY) n_capacity.fetch_add(1, std::memory_order_relaxed);
else if (st & _XABORT_EXPLICIT) n_explicit.fetch_add(1, std::memory_order_relaxed);
else n_other.fetch_add(1, std::memory_order_relaxed);
if (attempt >= 8) { // 回退: 用锁串行完成
std::lock_guard<std::mutex> g(g_fallback_lock);
long a = acct[from].balance, b = acct[to].balance;
if (a >= amt) { acct[from].balance = a - amt; acct[to].balance = b + amt; }
n_mutex.fetch_add(1, std::memory_order_relaxed);
return;
}
for (volatile int i = 0; i < (1 << attempt) * 64; ++i) { } // 指数退避
}
}
// 读取者: 在同一瞬间观察两个账户, 校验总额不变(检验隔离性)
static void reader(int rounds, const long* expected_total, std::atomic<bool>* stop) {
while (!*stop) {
if (!g_has_rtm) {
long s;
{ std::lock_guard<std::mutex> g(g_fallback_lock);
s = 0; for (int i = 0; i < 8; ++i) s += acct[i].balance; }
if (s != *expected_total) n_tear.fetch_add(1, std::memory_order_relaxed);
continue;
}
unsigned st = _xbegin();
if (st == _XBEGIN_STARTED) {
long s = 0; for (int i = 0; i < 8; ++i) s += acct[i].balance; // 8 个字必须"同一瞬间"
_xend();
if (s != *expected_total) n_tear.fetch_add(1, std::memory_order_relaxed);
} else {
long s;
{ std::lock_guard<std::mutex> g(g_fallback_lock);
s = 0; for (int i = 0; i < 8; ++i) s += acct[i].balance; }
if (s != *expected_total) n_tear.fetch_add(1, std::memory_order_relaxed);
}
(void)rounds;
}
}
int main(int argc, char** argv) {
const int T = (argc > 1) ? atoi(argv[1]) : 4;
const int ITERS = (argc > 2) ? atoi(argv[2]) : 200000;
g_has_rtm = cpu_has_rtm();
printf("CPU supports RTM: %s\n", g_has_rtm ? "YES" : "NO (将全部走互斥锁回退路径)");
for (int i = 0; i < 8; ++i) acct[i].balance = 1000000;
const long expected = 8L * 1000000; // 8 个账户总额守恒
std::atomic<bool> stop{false};
auto t0 = std::chrono::steady_clock::now();
std::thread rd(reader, 0, &expected, &stop);
std::vector<std::thread> th;
for (int i = 0; i < T; ++i) th.emplace_back([&, i] {
uint32_t seed = 99u + i * 7919u;
for (int k = 0; k < ITERS; ++k) {
seed = seed * 1664525u + 1013904223u;
int a = (int)((seed >> 8) % 8), b = (int)((seed >> 16) % 8);
if (a == b) b = (b + 1) % 8;
htm_transfer(a, b, 1 + (long)((seed >> 24) % 50));
}
});
for (auto& x : th) x.join();
stop.store(true); rd.join();
double sec = std::chrono::duration<double>(std::chrono::steady_clock::now() - t0).count();
long total = 0; for (int i = 0; i < 8; ++i) total += acct[i].balance;
printf(" time = %.3f s (%.1f ns/transfer)\n", sec, sec * 1e9 / ((double)T * ITERS));
printf(" htm commits = %llu\n", (unsigned long long)n_htm_commit.load());
printf(" aborts: conflict=%llu capacity=%llu explicit=%llu other=%llu\n",
(unsigned long long)n_conflict.load(), (unsigned long long)n_capacity.load(),
(unsigned long long)n_explicit.load(), (unsigned long long)n_other.load());
printf(" mutex fallbacks = %llu\n", (unsigned long long)n_mutex.load());
printf(" torn reads = %llu (必须为 0)\n", (unsigned long long)n_tear.load());
printf(" INVARIANT total = %ld (expected %ld) => %s\n", total, 8000000L,
total == 8000000L ? "OK" : "*** CORRUPTED ***");
return (total == 8000000L && n_tear.load() == 0) ? 0 : 1;
}
【代码做什么?】(tsx.cpp)
- CPUID 探测:读
CPUID.07H:EBX[11](RTM 支持位)。不支持就完全不执行_xbegin(),直接走互斥锁路径。 - 事务化转账
htm_transfer:_xbegin()成功后,事务体内是完全普通的读写(acct[from].balance等),由硬件用缓存记录读集/写集;两笔修改完成后_xend()提交——两个账户的修改一次性对外可见(隔离性由一致性协议保证)。余额不足时用_xabort(1)显式中止。 - 失败路径:解码返回状态位——
_XABORT_CONFLICT(与他人事务的读写集冲突)、_XABORT_CAPACITY(读写集放不进缓存)、_XABORT_EXPLICIT(显式中止)、其他;然后指数退避重试,重试 8 次仍失败则回退到互斥锁完成这次转账(RTM 不提供前进保证,必须有回退路径)。 - 读取者线程:在”一个瞬间”读 8 个账户并求和,与初始总额比较;不一致就记一次撕裂读(torn read)。这是对隔离性的直接检验——没有原子性/隔离性时,”读 8 个字”几乎不可能看到一致的快照。
- 输出:RTM 是否可用、HTM 提交次数、四类 abort 计数、锁回退次数、撕裂读次数(必须为 0)、总额不变量。
【并行机制与性能解说】
- 硬件上发生了什么:进入事务后,被读的行在私有缓存里以
Shared+ “读集标记”保留,被写的行以Modified+ “写集标记”保留(新值停留在本核缓存,不写回内存,也不让其他核取走——这就是隔离性)。其他核的读/写请求命中我的读写集时,一致性协议即报冲突,通常由请求方或写方 abort。提交时让所有被标记的Modified行”转正”,从而一次性对外可见。 - 实测(本机 2 路 AMD EPYC 7V13):
CPU supports RTM: NO——本机没有 RTM,因此所有 200,000 次转账都走了互斥锁回退路径(mutex fallbacks = 200000,htm commits = 0),实测 106.9–348.4 ns/transfer,torn reads = 0,总额不变量成立。这份输出本身就是结论的一部分:它证明了”没有 HTM 时程序必须、也能够在运行时正确地退化为锁实现”——这是所有 HTM 程序的标准形态(Intel 自己的库、GCC 的-mrtm代码都这么做)。 - Work / Span / 并行度:本程序恰好不能用 Work/Span 来说明扩展性,因为在这台机器上它退化成了锁版本——
Span = Θ(N · c_mutex),即整段计算都在关键路径上,并行度 ≈ 1(实测 4 线程 200,000 次转账 0.070 s、8 线程 400,000 次 0.095 s,吞吐几乎没有提升,正是”全局串行”的特征)。在真有 RTM 的机器上,事务路径的Span变成Θ(N · c_commit_backend)(提交时的发布是硬件完成的多行原子操作,没有全局版本时钟),因此并行度可以远高于 1——这正是 HTM 相对 STM 的核心优势(见 2.6 的表)。 - 注意:
_xbegin()/_xend()之间的代码不能包含会”卡住”的操作——系统调用、I/O、页错误、上下文切换都会导致事务中止(甚至直接回退),且中止时代码的副作用无法撤销(例如你已经printf了)。所以 HTM 事务体必须”纯净、短小、可重复执行”。
4. 性能模型与复杂度分析
本节把前两节的机制变成可以计算的量。所有公式都在”事务的全部内存访问都必须通过事务接口“这一前提下成立(这也是本文档 3.1 节 STM 的使用契约)。
4.1 单事务代价分解(用实测值反推各分量)
假设:标称 3.0 GHz(本机最高 3.7 GHz),事务体含 r 次字读与 w 次字写。则
c_tx = c_begin + r · c_read + w · c_write + c_commit
(取时钟快照) (仪器化读) (仪器化写) (验证读集 + 抢版本号 + 发布写集)
```text
用 3.1 节实测的**单线程 133.6 ns/事务(≈400 周期 @3.0 GHz)**、`r = 3`、`w = 2` 反推各分量:
| 阶段 | 具体操作 | 估计周期 | 备注 |
|---|---|---|---|
| `begin` | `g_clock.load(acquire)` | ≈ 40 | 该行被所有核共享、且每次提交都被写坏 → 常常要重新取 |
| 3 × `c_read` | 版本 load → 值 load → 版本 load,再 push 读集 | ≈ 90(30/次) | 两次元数据 load + 一次数据 load,全是 L1 命中才有这个数 |
| 2 × `c_write` | 版本 load + CAS(抢锁位)+ push 写缓冲 | ≈ 120(60/次) | CAS 是 RMW,比普通 store 贵得多 |
| `commit` | 3 次读集验证 + 1 次 `fetch_add` + 2 次发布 | ≈ 150 | **`fetch_add` 是全部事务共享的串行点** |
| **合计** | | **≈ 400** | 与实测 133.6 ns/事务 吻合(这是"反推"而非独立测量,用于说明结构占比) |
**关键观察**:`commit` 占 37.5%,其中绝大部分是那个**全局原子取号**。这与"锁"的代价结构惊人地相似——**TM 并没有消灭同步,它只是把同步从"用户数据结构"搬到了"运行时元数据"里**。
#### 4.2 冲突概率与重试成本(含一个与实测吻合的算例)
设 `P` 个线程各持有一个进行中的事务,每个事务写 `w` 个共享字,共享字总数为 `N`,且事务在时间上完全重叠(最坏情形),则
```text
p_conflict ≈ (P - 1) · w / N (上界:任意另一个事务的写集与我相交)
期望代价 = c_tx / (1 - p) (每次中止都白干一遍活儿,然后重来)
```text
**数值算例(与 3.1 节实测对照,`P = 4`,`w = 2`)**:
| 共享字数 N | 模型 `p ≈ (P-1)·w/N` | 实测中止率 | 说明 |
|---|---|---|---|
| 64 | 3 × 2/64 = **9.4%** | **9.1%** | 吻合(稀疏冲突区间模型很准) |
| 1024 | 3 × 2/1024 = **0.59%** | **0.7%** | 吻合 |
| 8 | 3 × 2/8 = **75%** | 38.6% | 模型是**上界**:极端争用下争用管理器把大量冲突变成"短暂停滞"而非中止 |
| 2 | 3 × 2/2 = 300%(饱和) | 17.6% | 饱和区:几乎全是停滞等待,吞吐被串行化拖死 |
**重试开销的即时换算**:`c_tx = 100 ns` 时,
| 中止率 p | 10% | 30% | 50% | 90% |
|---|---|---|---|---|
| 期望代价 `c_tx/(1-p)` | 111 ns | **143 ns** | 200 ns | 1000 ns |
| 浪费的工作量占比 | 10% | 30% | 50% | 90% |
也就是说,**在中止率 30% 时,80 万次"有用的"事务实际要执行 ≈ 114 万次事务的工作量**——这正是高争用下 TM 吞吐崩塌的算术原因(对应 3.1 节 N=2/N=8 的实测)。
#### 4.3 三种不同的天花板:带宽、延迟、串行化
**(a)带宽天花板(题面要求的基准算例)**:一个 256 MB 的数组,机器可持续带宽 20 GB/s,则
```text
单次遍历的最少时间 = 256 MB / 20 GB/s = 0.256 GB / 20 GB/s = 12.8 ms
(如果每元素还要读+写各一遍, 就是 2×: 25.6 ms)
```text
把它套到 3.2 节的树上:80,004 个节点、每节点 32 B ≈ **2.56 MB**(能放进 L3,但每个节点还带 16 B×2 的元数据),每次插入要触及 `h ≈ 17` 个不同节点:
```text
每次插入至少触及 17 个缓存行 = 17 × 64 B = 1088 B
80,000 次插入 × 1088 B = 87 MB 的缓存行搬运量
若这些行全部从 DRAM 取: 87 MB / 20 GB/s = 4.35 ms
实测 STM 用时 23.7 ms => 带宽只能解释 4.35/23.7 ≈ 18%
```text
**结论:这个负载不是带宽瓶颈**(树的上层长期驻留缓存)。真正花时间的是**元数据操作数、指针追逐的延迟链、以及提交的串行化**。**不要一看到并行程序就把问题归给"内存带宽"**——这是本讲最容易犯的分析错误。
**(b)延迟天花板**:树的插入是**指针追逐(pointer chasing)**:必须读完父节点才知道子节点在哪,`h ≈ 17` 次依赖串行。若每次 L3 命中 40 ns,纯延迟链就是 680 ns;实测 296 ns/insert 说明大部分访问命中 L1/L2(上层共享节点一直在缓存里)。**TM 的元数据访问也在这条依赖链上**(要先读版本才敢读值),所以"每次访问多两次 load"在延迟敏感代码里被放大。
**(c)串行化天花板(Amdahl 形式)**:把每个事务"必须在全局串行段里执行"的比例记为 `f`,则
```text
f = c_commit_serial / c_tx ≈ 150 / 400 = 0.375
最大加速比 = 1 / f = 2.7× (与核数无关!)
```text
实测最好的一组是 `T=1: 133.6 ns/tx → T=4: 76.7 ns/tx ≈ 1.74×`,与 2.7× 的上限同量级(差额来自跨 NUMA 的时钟行弹跳与验证时的缓存未命中)。**这就是软件 TM 的天花板:它由一个共享元数据字(全局版本时钟)决定,而不是由你有多少核决定。** 这也是硬件 TM 在讲义 slide 27 的性能图里能压过 fine locks 的结构性原因:**HTM 没有全局版本时钟**(版本信息寄生在缓存行状态上,冲突检测随一致性流量批量完成)。
#### 4.4 算术强度 / Roofline 视角:TM 是"元数据密集型"负载
对树的插入做算术强度估计(把"字节"当作有用输出):
```text
有用数据: 1 个 long 键 + 1 个指针写 ≈ 16 B
为完成它移动的缓存行(最坏情况全部 miss): 17 × 64 B = 1088 B
算术强度 AI ≈ 16 B / 1088 B ≈ 0.015 B/B (完全没有计算, 纯数据搬移)
```text
在 Roofline 图上,这类负载远远贴在"内存墙"一侧:**提升性能的方向不是提高 FLOPs,而是减少每次事务触及的字节数与串行点**。而 TM 在这个本已很低的强度上又叠加了两层乘数:
1. **元数据字节数**:本实现的 `stm::Word` 是 **16 B**(8 B 版本字 + 8 B 数据),也就是**每 8 B 有用数据配 8 B 元数据(100% 空间开销)**,而且**元数据与数据同行**(4 个字 = 64 B 一行)→ 两个逻辑上无关的字若落在同一行,它们的**版本字会互相失效**,产生与"伪共享"完全同源的额外一致性流量。
2. **元数据串行点**:全局版本时钟(4.1/4.3 的天花板)。
这两层解释了 3.2 节的现象:**STM 单线程比全局锁慢(元数据税),但在 4 线程下反超 3.8×**——因为它把"锁的争用"(随 P 增长)换成了"元数据的争用"(大部分与 P 无关,只有时钟那一行随 P 恶化)。
#### 4.5 小结:三元设计空间如何影响上面每一个公式
| 设计选择 | 影响 `c_read` / `c_write` | 影响 `c_commit` | 影响 `c_abort` | 影响 `p`(伪冲突) |
|---|---|---|---|---|
| **eager(undo log)** | 写更贵(记旧值 + 直接改内存) | **更便宜**(数据已在内存) | **更贵**(逐个恢复旧值) | 无影响 |
| **lazy(写缓冲)** | 写更便宜(只进缓冲) | **更贵**(冲刷写缓冲) | **更便宜**(丢缓冲 + 解锁) | 无影响 |
| **pessimistic 检测** | 读/写路径变长(每条访问都检查) | 变便宜(提交时不必再验证) | 早中止 → 白干的活少 | 可能把 abort 变成 stall |
| **optimistic 检测** | 路径短 | 变贵(要验证整个读集) | 晚中止 → 白干的活多 | 有前进保证,但仍可能不公平 |
| **字粒度** | 元数据访问多 | 验证条目多 | — | **低**(只有真同一个字才冲突) |
| **缓存行粒度(HTM)** | 几乎零额外指令 | 由硬件完成,快 | 容量/冲突 abort | **高**(同行不同字也算冲突) |
---
### 5. 关键要点
1. **`atomic { }` 与 `lock()/unlock()` 不是同一种东西,也不能互相替换。** 前者是**声明**"这段必须原子(且失败时整体回滚)",实现方式由系统决定;后者只是一个**互斥原语**,本身不提供原子性与隔离性,还被大量用于"等待条件""限制并发度"等与原子性无关的用途(讲义 slide 35 的 self-check)。把 `synchronized` 机械替换成 `atomic` 会导致**它永远等不到**(隔离性使另一线程的写不可见)或者**原子性违规**(本该原子的一段被切成两个原子块,slide 36–37)。
2. **TM 的实现就是回答三个问题:数据版本(eager/lazy)、冲突检测(悲观/乐观)、检测粒度(字/行/对象)。** 讲义 slide 3 的 "What you should know" 就是这三条;eager 与 lazy 的取舍可以用一句话记住——**eager 赌事务不会中止(提交快、回滚贵),lazy 赌事务会中止(回滚快、提交贵)**,本文档 3.1 节的实测给出了这张赌注的赔率(低争用时 eager 略快,高争用时 lazy 明显快)。
3. **事务不消灭同步,只是把同步的位置从"用户数据结构"搬到"运行时元数据"。** 软件开发者的直觉常常是"用了事务就没有锁的争用了"——错。本文档 STM 的实测表明:全局版本时钟(一行缓存)把 4 线程的加速比压在 1.74×(模型上限 2.7×);这也正是硬件 TM(没有全局版本时钟)在讲义 slide 27 的性能图中能胜过细粒度锁的结构性原因。**评估任何 TM 实现,第一件事是找到它的串行点。**
4. **"该不该用事务"的判断标准是读写集的语义重叠,而不是"这段代码看起来复杂"。** 如果重叠里主要是**读-读**,事务能拿到锁拿不到的并发(3.2 节的树:`aborts=0`,在该次运行中比全局锁快 3.8×);如果存在**真正的 W-W** 冲突(所有线程都改同一个队列头),事务和锁一样要串行化,谁也不会更快。**粒度(字 vs 缓存行)决定了"伪冲突"的数量**:HTM 的行粒度会把同一行里两个无关的字当成冲突,与伪共享同源。
5. **事务只覆盖内存,且必须回答"前进保证"的问题。** I/O、`malloc`、系统调用、页错误都在事务之外(不可回滚),必须移出事务体或设计好补偿逻辑;而乐观并发天然可能活锁,**必须由争用管理器(有界停滞/指数退避)加一条串行化回退路径**来兜底——本文档的 STM 与 `tsx.cpp` 的锁回退都是这一条的具体实现。
---
### 6. 常见陷阱与注意事项
- **陷阱 1:惰性版本下忘了"读己之所写"。** 写缓冲(write buffer)持有新值、内存里还是旧值,因此**读必须先查写缓冲,再查内存**。本文档实现的第一版把"已持有该字"的判断放在最前面并直接返回 `w->val`(内存里的旧值),在 `transfer` 里表现为"取款后重新读余额得到旧余额"。这类 bug 在"读改写同一个字"的代码里(几乎所有计数器、累加器)都会出现。修复后本文档用 `ryow_bad` 计数器长期守住这一点。
- **陷阱 2:回滚路径不完整(漏掉任何一条出口都是灾难)。** 事务在**每一条退出路径**上都必须释放写集的所有权/撤销已做的写,包括**应用层显式回滚**(如"余额不足")这一条。本文档实测中曾出现过:应用层 `abort` 分支直接 `return false` 而没有调用 `tx.abort()` → **那些字上的锁位永远保持为"占用中"**,此后任何碰到它的事务都只能一直失败(表现为**回退路径里的重试全部因"该字被独占"而失败**;调试该缺陷时实测到连续 10,000 次尝试没有一次成功)。**这类 bug 不会破坏数据,只会让系统永久性地"变慢直到卡死"**,比数据竞争更难发现。
- **陷阱 3:冲突后立即狂重试 → 活锁(livelock)。** 没有退避、也没有争用管理器的重试循环会让多个线程以相同节奏互相撞车。本文档实测中同样出现过"重试循环里连续 10,000 次尝试全部撞在别人持有的字上",直到加入 (a) **有界停滞**(未持有写集时可以等待持有者释放在讲义 slide 47 里是 Case 2 的 "early detect (and stall)")与 (b) **排空式全局串行回退**(进入串行模式 → 等在途事务排空 → 独占执行一次)才彻底消除。**顺带一个设计要点:只在"本事务还没有持有任何写集"时才允许等待,否则两个各持一把锁又互相等待的事务会形成环形等待而死锁。**
- **陷阱 4:在事务里等另一个线程(或另一个事务)。** 讲义 slide 36 的 `flagA/flagB` 例子就是标准反例:事务内的自旋等待,等的是另一个事务的**写**,而在提交之前那个写**永远不可见**(隔离性)→ 永不到头。事务不能替代条件变量/屏障/原子标志,**"等待"必须在事务之外(或者根本不使用事务)**。
- **陷阱 5:把本该原子的一段切成两个原子块(atomicity violation)。** 讲义 slide 37:`atomic { ptr = A; }` 与 `atomic { B = ptr->field; }` 之间,另一线程可以执行 `atomic { ptr = NULL; }`。事务保证的是"**你声明的那一段**"原子,**声明错了它救不了你**——`atomic` 消除数据竞争,但不消除原子性违规。
- **陷阱 6:混淆粒度带来的"伪冲突"与真正的冲突,或者把元数据与数据放在同一缓存行。** HTM 的检测粒度是缓存行:两个事务分别写同一行的不同字节,也会被判定为冲突(`W-W` 伪冲突)。软件 TM 也不能免疫:本文档每个字带 16 B 元数据(版本字),**4 个字共享一个 64 B 缓存行**,于是两个逻辑无关的账户也会让对方的版本字失效,产生与"伪共享(false sharing)"同源的一致性流量。**把热点元数据按缓存行隔离(padding)或与数据分离布局,是 STM 优化中最直接有效的一步。**
- **陷阱 7:混用事务访问与非事务访问。** 只要还有一条路径绕过事务直接读写共享数据,隔离性就无从谈起(本文档 STM 的正确性前提就是"所有共享访问都通过 `tx.read/tx.write`")。**这条约束在 HTM 上更严格**:事务外的普通访问可能让别的事务永远 abort(RTM 不保证前进)。同理,事务内的分配(`malloc`)、I/O、`printf` 都不受事务保护——本文档 `bst.cpp` 里把 `new TNode` 放在事务之外,就是为了这个原因。
- **陷阱 8(环境陷阱):把"HTM 可用"当成默认前提。** 在不支持 RTM 的处理器上执行 `XBEGIN` 会触发 `#UD` 非法指令异常——本文档的 `tsx.cpp` 第一次运行就是**直接被内核 SIGILL 杀掉**。必须先用 CPUID 探测,再决定走事务路径还是锁路径;也不要假设"探测一次就永远有效"(历史上部分型号通过微码更新禁用了 TSX)。
---
### 7. 思考题(带答案)
#### 思考题 1:为什么"事务化树更新"能赢过手递手锁,而"事务化的链表头插入"却赢不了?
【答案】
区别在**读写集重叠的语义类型**,而不是"事务"这个工具本身。
- **树更新(讲义 slide 23–25,本文 3.2 节实测)**:线程 A 的事务 `READ{1,2,3} WRITE{3}`、线程 B 的 `READ{1,2,4} WRITE{4}`。重叠部分(节点 1、2)**只有读**。事务只需要在提交时验证"我读过的版本没被换过",读-读不会使版本变化,所以 **`aborts = 0`**,两个事务并发提交。手递手锁则必须对路径上的**每一个**节点取**独占**锁——即使它只是想读一下指针——于是共享祖先成了串行点;更糟的是它每次插入要执行 `2h ≈ 34` 次锁操作(h≈17),实测 T=4 时比"一把全局锁"还慢(2532 ns vs 1119 ns/insert),而事务只有 296 ns/insert。
- **链表头插入(讲义 slide 28 的 `PushLeft(DQueue *q, int val)`)**:每一次 `PushLeft` 都要执行 `leftSentinel->right = qn; oldLeftNode->left = qn;`,即**所有线程都在写同一个字 `leftSentinel->right`**。这是货真价实的 **W-W 冲突**:语义上必须串行化(否则会丢掉一次插入)。事务只能把这个串行化做得更快(少了锁的获取/释放,直接靠版本检测),**但不可能让它并行**。所以两者都会退化成"队列头吞吐",上限约为 `1/c_commit`(事务)或 `1/c_lock`(锁)。
- **推论(判断准则)**:用事务之前先画读写集。**重叠里"读-读"占比高 → 事务收益大;重叠里有真写的冲突 → 事务只能改善常数因子**;而后者的正确解法通常是**改变数据结构**(分片队列、细化的头部、combining tree),不是换同步原语。
#### 思考题 2:给定中止率,选 eager 还是 lazy?请算出一个具体的交叉点。
【答案】
设事务"主体"(读/写/业务逻辑)需要 `B` 个周期,且每次中止后必须重试一遍。取一组可辩护的参数:`B = 100`,eager 的 `c_commit = 30, c_abort = 120`(提交便宜但回滚要按 undo log 逐个恢复旧值),lazy 的 `c_commit = 60, c_abort = 40`(提交要把写缓冲冲刷出去,回滚只需丢弃缓冲并解锁)。设每次尝试中止的概率为 `p`,则**每个成功事务的期望代价**为
```text
E = B + c_commit + (p/(1-p)) · (B + c_abort)
```text
- **p = 0.1(低争用)**:eager `= 100+30+0.111×220 = 154.4`;lazy `= 100+60+0.111×140 = 175.6` → **eager 快 12%**(与 3.1 节 N=1024 的实测一致:76.7 ns(lazy) vs 73.0 ns(eager))。
- **p = 0.5(高争用)**:eager `= 130 + 1.0×220 = 350`;lazy `= 160 + 1.0×140 = 300` → **lazy 快 17%**(与实测的 N=2/N=8 一致:lazy 169/176 ns vs eager 205/202 ns)。
- **交叉点**:令 `130 + (p/(1-p))·220 = 160 + (p/(1-p))·140`,得 `(p/(1-p))·80 = 30`,即 `p/(1-p) = 0.375`,**p ≈ 27%**。
**结论**:**中止率低于约 27% 时选 eager(undo log),高于 27% 时选 lazy(写缓冲)**;而真实系统通常做成混合(例如 HTM 天然是 lazy:新值留在缓存里,提交即"转正"),因为**中止率在工作负载之间差异巨大,而切换策略的代价又很高**。另外提醒:`p` 本身不是外生变量——它由数据结构的争用结构决定,也受争用管理器影响(把 abort 变成 stall 会降低 `p`,但增加等待时间,**中止率低不等于性能好**,见 3.1 节 N=2 的实测)。
#### 思考题 3:一个事务要读/写 4096 个随机分布的 4 字节元素。用 HTM(L1D = 32 KB、8 路组相联、64 B 行)跑会发生什么?给出定量判断,并说明字粒度 STM 与行粒度 HTM 在这题上的差别。
【答案】
- **容量(capacity)判断**:L1D 32 KB / 64 B = **512 个缓存行容量**(8 路 × 64 组)。事务的足迹 = 读集行数 + 写集行数。一个 64 B 行能放 16 个 4 字节元素,所以随机取点会分散到很多行:若这 4096 个元素分布在一个 256 KB 的区域里(= 4096 行),则**被触及的行数约为 `4096 × (1 - e^(-4096/4096)) ≈ 4096 × 0.63 ≈ 2590` 行**。于是足迹 ≈ **2590 行 ≫ 512 行** → 事务**必然发生 capacity abort**,重试也永远不会成功(每次重试的足迹一样大)→ **必须回退到软件路径**。这是 HTM 的硬限制:**事务的读写集必须"装得进"缓存**(实践中安全的规模是几十到几百行,还要给其他线程的流量留余量)。
- **同理可见的行粒度陷阱**:即使把规模缩小到"每事务只碰 16 个随机元素"(约占 16 行,远小于 512),两个并发事务**逻辑上完全不相交**(不同的元素)时,仍可能落在**同一行**而互相冲突:数组有 1024 行时,两个事务各随机取 16 行,至少共享一行的概率约为
```text
1 - (1 - 16/1024)^16 ≈ 1 - 0.9844^16 ≈ 22%
也就是说,约五分之一的事务对会因为”行共享”而被判冲突,尽管它们从未碰过同一个元素——这就是”行粒度伪冲突”,与伪共享(false sharing)同源。缓解放法:让事务的数据对齐到缓存行并避免跨事务共享行(padding / 分段)。
- 字粒度 STM 的对照:本文档的 STM 按字(每个字有自己的版本字)判定冲突,因此上面那 22% 的”伪冲突”在正确性判断上根本不会发生;但它付出的代价是 (a) 16 B 元数据 / 8 B 数据的空间开销,以及 (b) 元数据与数据同行导致的缓存行互相失效(4 个字共享一行)——也就是说,字粒度并没有消除”行”层面的物理争用,只是把”逻辑冲突”与”物理抖动”分开了:前者避免了,后者仍在,只能靠布局(把版本字集中或按行隔离)去缓解。
- 一句话总结:粒度是”正确性判定”与”物理代价”之间的两端——粒度越细,逻辑判定越精确(伪冲突越少),但元数据越贵;粒度越粗(缓存行),元数据几乎免费,但伪冲突与容量上限随之而来。HTM 选了后者,STM 选了前者,这就是 2.6 节那张对比表背后的全部算术。
