0%

内存模型的定义

内存模型(Memory Model)是定义数据一致性执行顺序规则的一组规范。

备忘录

  • 编程语言保证单线程的“程序顺序一致性”,比如为了防止CPU乱序执行,甚至主动对有依赖关系的变量插入内存屏障。

多核缓存

分层:

  • 寄存器:最快,由重命名机制管理
  • L1/L2/L3 Cache:核心本地缓存,按缓存行一致性(MESI)维护共享状态
  • 主内存(DRAM):CPU访问需经总线或内存控制器
缓存级别 位置 速度(快) 容量(小) 每核私有/共享 说明
L1 Cache CPU核心内部 ✅最快 ❌最小 每核私有 通常分为指令缓存(L1I)和数据缓存(L1D)
L2 Cache 核心内部或旁边 较快 较小 每核私有或共享 有助于减缓L1未命中后的访问压力
L3 Cache 核心集群共享 慢一些 更大 多核共享 减少跨核访问主存的延迟,常位于芯片中心
L4 Cache 一些高端CPU或封装外DRAM 最慢 最大 多芯片共享 非常少见(例如Intel的eDRAM)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
[CPU Core] 
├── [Load Buffer] ← 处理 load 指令
├── [Store Buffer] ← 处理 store 指令
├── [L1 Cache]
└── [Execution Unit]

⬇️

[Shared L2 Cache] / [L3 Cache]

⬇️

[Memory Bus] ↔ [Main Memory (DRAM)]



[Other CPU Cores]

Store/Load Buffer

  • Load Buffer:

    • 位置:在 CPU 核心内部,靠近 Load/Store Unit。
    • 功能:暂存“load”(读取)指令的结果,等待内存或缓存返回数据。
    • 使用场景:比如你访问一个变量 x,如果还没拿到内存值,它就被放在 load buffer 等待。
  • Store Buffer:保存尚未提交的 store 写操作(延迟写回),可被后续 load 查询(store-to-load forwarding)。内存屏障(memory fence)可强制“写入完成后再继续”。

    • 位置:同样在 CPU 核心内部,紧挨 Load Buffer。
    • 功能:当执行 x = 5 这样的写操作时,并不会立刻写入内存,而是先写入 store buffer。
    • 延迟写回:写操作会被延后,在合适时机批量提交到缓存或主存中。
    • 注意:这就是为什么需要内存屏障(memory fence)来强制“写入完成后再继续”。

Store/Load Buffer是一个具备调度逻辑的指令队列,不是为了“缓存数据”。
之所以叫”Buffer”而不是”Queue”,是强调其“乱序 + 并发调度能力”。

与普通队列的对比:

对比项 普通队列 Load Buffer
数据结构 FIFO 队列 类似 CAM(Content Addressable Memory)或乱序数组
访问方式 一次 pop 一个 任意条目都能被访问、发射、取消
顺序 严格顺序 支持乱序发射、乱序完成、乱序提交
功能 暂存数据 追踪地址、状态、与 Store Buffer 比较、rollback 等

Intel 和 ARM 的实际叫法

  • Intel 官方架构手册:

    • 用词是 Load Buffer / Store Buffer
    • 有时叫 “Memory Ordering Buffer”
  • ARM 架构(如 Cortex-A76)

    • 常叫:Load-Store Queue(LSQ)
    • 还会细分为:
      • Load Queue(LQ)
      • Store Queue(SQ)

但即使叫“Queue”,也不一定是严格的队列行为,而是调度窗口结构。

缓存行(Cache Line)

  • 位置:在 CPU 的缓存(L1/L2/L3)中,缓存是以行(line)为单位存储的。
  • 功能:每行通常是 64 字节,它是 CPU 和内存之间最小的同步单位。
  • 状态(MESI 协议):
    • M(Modified): 修改过,主存未同步
    • E(Exclusive): 独占但未修改
    • S(Shared): 可共享读取
    • I(Invalid): 已失效

总线(Memory Bus / Interconnect)

  • 位置:连接多个 CPU 核心、缓存、主存的通信通道。
  • 功能:
    • 数据在 CPU 和内存之间传输
    • 执行原子操作时用于发送 LOCK# 或缓存一致性请求

🔗 示例:Intel 使用 Ring Bus、AMD 使用 Infinity Fabric。

主内存(Main Memory / DRAM)

  • 位置:通常是系统主板上的 DRAM 芯片。
  • 功能:存储所有不在 cache 中的数据,访问速度比 cache 慢很多。

🕓 延迟:访问主存通常需要 100ns ~ 几百ns,而访问 L1 Cache 只需几个 cycle。

内存控制器通道与总线的区别

维度 总线(Bus) 内存控制器通道(Memory Channel)
硬件层级 CPU、缓存、内存之间的共享通信总线 内存控制器内部的通路,直接连接 DRAM 芯片
作用范围 跨多核、跨缓存,整个系统范围 只负责访问主内存的某一部分,细粒度数据路径
连接对象 CPU核心、缓存、内存控制器等 内存控制器与物理内存条之间的接口
作用 传递内存访问请求,实现缓存一致性协议 调度内存读写,执行具体内存命令
原子操作锁定对象 锁定总线,阻止其他核访问内存 锁定内存通道,防止同通道冲突访问

乱序执行

现代 CPU 采用乱序执行(Out-of-Order Execution, OoO)优化性能:

  • 发射顺序有序(程序顺序)
  • 执行顺序无序(根据数据/资源依赖动态调度)
  • 提交顺序有序(借助 ROB 保证提交的程序语义)

核心机制:

  • 指令乱序执行:由调度器和执行单元动态决定执行顺序。
  • 寄存器重命名:使用临时物理寄存器消除写后写、写后读冲突。
  • Load/Store Buffer:用于暂存尚未提交的读写操作。
  • ROB(Reorder Buffer):按程序顺序提交指令(写入寄存器/内存),确保最终结果可预测。

程序顺序一致性(program-order consistency):

  • 在本线程中观察到的顺序要“看起来像顺序执行”。
  • 注意:多线程中可能出现乱序提交。
1
2
3
4
5
6
7
1. Fetch         <-- ✅ 顺序取指,从指令缓存中取出下一条指令
2. Decode <-- 顺序解码,分析操作数和目的寄存器
3. Rename <-- 寄存器重命名
4. Dispatch <-- 投递到调度窗口,等待执行条件满足
5. Execute <-- ✅ 乱序执行(由调度器决定),实际在执行单元上运行指令
6. Writeback <-- 写结果到 ROB
7. Commit <-- ✅ 按程序顺序提交(retire),更新寄存器状态或进行内存写入

TODO: 内存控制器

扩展:寄存器重命名(Register Renaming)

现代 CPU 为了支持乱序执行(Out-of-Order Execution)和指令级并行(ILP),会引入比架构寄存器更多的物理寄存器,来存储中间值。

寄存器重命名:CPU 用一张 寄存器映射表(Register Alias Table, RAT) 来将架构寄存器映射到物理寄存器。

  • 架构寄存器:由 ISA 定义,固定不变。x86-64 是 16 个通用寄存器。
  • 物理寄存器:由具体 CPU 实现,通常远多于架构寄存器。例如:
    • Intel 的 Skylake 有 180 个左右物理寄存器(整数+浮点)
    • ARM 的 Cortex-A 系列也有几百个物理寄存器(实际数量通常保密)
类型 说明
物理寄存器(Physical Register) CPU 内部实际使用的、很多个
架构寄存器(Architectural Register) 程序看到的,例如 RAX、RBX 等,一般只有 16~32 个

举例:

1
2
3
MOV RAX, 1
MOV RAX, 2
ADD RAX, 3

执行流程:

指令 分配的物理寄存器 对应动作
1 RAX → P1 P1 ← 1
2 RAX → P2 P2 ← 2(与1可并行执行)
3 用 P2 + 3 写入 P3,RAX → P3

注意:

  • 三条指令都写的是 RAX,但实际上写的是 不同的物理寄存器(P1、P2、P3)
  • 只有最后提交阶段才把 RAX 映射为最终结果的寄存器(P3)

内存屏障

屏障类型 会阻止 作用说明
SFENCE Store → Store 重排序 前面的写必须完成再执行后面的写
LFENCE Load → Load 重排序 后面的读必须等前面的读完成
MFENCE 所有内存访问 保证前面的 load/store 全部完成
问题 答案
内存屏障是运行时插入的吗? ❌ 插入时机是编译期,但运行时会执行它
屏障如何阻止后面指令提前? ✅ 屏障执行时会强制刷新缓冲区、阻止乱序执行

缓存一致性协议(MESI)

MESI 协议的核心操作:

  • 读(load):如果某核 cache 没有,就从主内存或其他核拉一份。
  • 写(store):必须先让其他核的对应 cache line 失效(invalidate),否则就可能出现数据竞争。

atomic(哪怕是 relaxed)为什么会触发 invalidate?

当你写入一个 std::atomic(哪怕是 relaxed):

1
x.store(123, std::memory_order_relaxed);
  • 这个 store 会编译为一条特殊的汇编指令(如 MOV 带 lock 前缀,或 STLR, STR 等)
  • CPU 会发出总线事务(bus transaction)或利用缓存一致性协议 广播这个写入:
    • 让其他核心中缓存这个变量所在 cache line 的副本 全部失效(Invalidate)
  • 所有核心必须从这个核心或主内存读取最新的值(下一次 load)

内存序

内存序 = 程序中读写内存操作的可见顺序。

在理想世界(顺序一致的架构)中,你写的代码顺序就是 CPU 执行和其他线程观察到的顺序。但现实中:

  • CPU 会乱序执行(out-of-order)
  • 编译器会重排指令
  • 缓存系统和 store/load buffer 会拖延内存访问
  • 多核之间访问的是缓存,而不是主内存

👉 所以,需要有一套规则来定义:“内存操作到底是何时、如何被其他线程看见的”,这就是内存序的目标。

概念 ^1 ^2

  • load: 从内存变量的值到CPU:内存 ➝ CPU。

  • store: 把一个值从 CPU 的寄存器或处理单元中,到内存中的某个地址(变量):CPU ➝ 内存。

  • relaxed: 对其他线程的读写没有同步或顺序约束,仅仅保证本操作的原子性。

  • acquire: 对 load 操作的内存序语义约束。

    • 当前线程中的任何读写都不能重排序到此 load 之前;
    • 在其他线程中 release 同一原子变量的所有写入在当前线程中可见。
  • release: 对 store 操作的内存序语义约束。

    • 当前线程中的任何读写都不能重排序到此 store 之后;
    • 当前线程的写入对 acquire 同一原子变量的其他线程可见。
  • acquire-release: 对 read-modify-write 操作的内存序语义约束。

    • 当前线程中的任何读写不能重排序到此 load 之前,也不能重排序到此 store 之后。
    • 其他线程 release 同一原子变量的写操作,对该 modification 可见;
    • modificationacquire 同一原子变量的其他线程可见。
  • sequentially-consistent:

    • load 操作遵循 acquire 语义;
    • store 操作遵循 release 语义;
    • read-modify-write 操作同时遵循 acquirerelease 语义;
    • 存在一个全序,其中所有线程都以相同的顺序观察所有修改。
内存序 插入屏障 特点
relaxed ❌ 无 只保证原子性,不保证顺序
acquire ✅ 读屏障 阻止后续操作提前
release ✅ 写屏障 阻止前面操作延后
acquire-release ✅ 两者都有 双向有序
seq_cst ✅ 全屏障 全局顺序一致性
步骤 普通变量 atomic(relaxed)
1 编译器可优化 编译器必须保留这个操作
2 CPU 可能重排 CPU 仍可能重排(因为是 relaxed)
3 缓存写入 可能停在本核 cache 中,不通知他人
4 不触发 invalidate ✅ 会触发 cache line invalidate
5 其他线程可见性低 ✅ 其他线程可读取到最新的值(但时序不保证)

私注:

  • acquire: 当本线程读取后,本线程之后的操作都可以依赖这次读取(“后面的读写不得提前”);
  • release: 当本线程允许写后,本线程之前的读写都完成了,即之后的操作都可以依赖此前的写入(“前面的读写不得滞后”)。

和普通变量相比,atomic(即使是 relaxed)做了哪些额外工作?

atomic(即使使用 memory_order_relaxed)和普通变量相比,最核心的区别是:原子变量保证原子性(atomicity)和可见性,而普通变量不能。

  • 保证操作的原子性

    所谓“原子性”,是指某个操作(如 load、store、CAS)要么全部完成、要么完全不做,不会出现中间状态。

    • atomic x;

      1
      x.store(42, std::memory_order_relaxed);

      这个 store 是原子的:不会被打断、不会被分拆。

    • 普通变量可能被编译器或 CPU 拆成多条指令(比如低 4 字节和高 4 字节分开发),多线程下可能看到“半成品”状态。

  • 触发 CPU 的 cache coherence 协议(MESI等)

    原子变量的访问会参与硬件层的缓存一致性协议,以保证多核 CPU 看到一致的值。

    • 即使是 relaxed,store 也会触发对应 cache line 的写入通知(invalidate);
    • 普通变量(非 volatile、非 atomic)的访问,编译器会缓存到寄存器中,不一定会回写。
  • 禁止编译器优化(只针对原子变量本身)

    编译器不能将多个 atomic store 合并,也不能删去它(不会当作“死代码”)。

    • x.store(42, relaxed) 每次都必须发出一条指令;

    • 对普通变量:

      1
      2
      x = 42;
      x = 43;

      可能直接编译为 x = 43;,前一条被优化掉。

atomic<T, relaxed> 不做的事:

功能 atomic(relaxed) 普通变量
保证原子性 ✅ 是 ❌ 否
保证可见性(跨线程) ✅ 是(通过 cache 协议) ❌ 否
禁止指令重排(保证顺序) ❌ 否(只 relaxed) ❌ 否

屏障为什么要设置两种?

性能优化 + 最小化开销 + 精确控制重排序方向。

屏障的本质是“控制指令重排序”
但它并不一刀切地“全部阻止”,而是:

屏障类型 阻止的方向(简化理解)
读屏障 阻止 读-after-读 重排序(即前面的读取不能晚于后面的读取)
写屏障 阻止 写-after-写 重排序(即前面的写不能晚于后面的写)
全屏障 阻止 读/写 与 所有后续指令的重排序

为什么分开这么细?(硬件视角)

现代 CPU 架构(如 x86, ARM)中:

  • 读和写通路是分离的

    • 读用 load buffer
    • 写用 store buffer
  • 两者乱序的模式、处理机制、性能代价都不同!

所以,硬件支持你只加读屏障(LFENCE)或写屏障(SFENCE),能以更小代价完成你想要的顺序语义。

举例:

操作 屏障类型 效果 例子
flag==1 后再读取 data 读屏障 禁止 data = ? 提前执行 load-acquire fence
data=42 后,才写 flag=1 写屏障 禁止 flag=1 提前写出 store-release fence
多线程通信中,强一致顺序 全屏障 所有读写顺序完全禁止重排 x86 MFENCE
  • 你可以只阻止读乱序 → 读屏障
  • 你可以只阻止写乱序 → 写屏障
  • 你可以全部阻止 → 全屏障

原子操作

层次 机制 说明
指令层 原子指令(如 lock cmpxchgxaddldrex/strex 提供不可中断的执行
缓存层 MESI 协议 保证多个CPU缓存数据一致
总线层 总线锁定(旧方式) 强制所有核等待该操作完成
内存层 内存屏障 保证内存操作顺序正确可见

CPU的原子指令如何实现的?

CPU 的原子指令是在硬件微架构层面通过微操作(micro-ops)调度、缓存控制、总线协议协调等机制联合实现的。我们可以从以下几个层面来详细剖析:

微架构层面:锁住特定资源

当执行原子指令时,CPU 内部会采取 锁定机制,确保该操作的所有子步骤在执行时不会被打断。

  • 对于访问内存的原子操作,CPU 会尝试锁定:
    • 缓存行(现代 CPU)
    • 或内存总线(旧 CPU)

以 x86 的 LOCK CMPXCHG 为例,它内部其实是:

1
2
3
4
5
6
7
if (mem == eax) {
mem = reg;
ZF = 1;
} else {
eax = mem;
ZF = 0;
}

这个流程在硬件中不是三条指令,而是一个专门的微操作指令组合包,称为“fused micro-op”,由 CPU 保证“执行期间中断关闭、禁止抢占”。

缓存一致性协议(MESI)保障原子性

原子指令通常访问共享变量,这就需要解决缓存一致性问题。

举例:两个 CPU 都缓存了某个共享变量的值
当 CPU1 执行原子写入(例如 lock xadd)时:

CPU1 会尝试将该缓存行从 Shared → Modified 状态

MESI 协议要求其他 CPU(如 CPU2)将该缓存行标记为 Invalid

一旦独占,CPU1 就能安全完成读-改-写过程

这种方式实现了 原子访问缓存行。

总线或互联互锁(bus lock / interconnect lock)

对于不在缓存中的内存地址(如非一致内存访问 NUMA、I/O 内存等),现代 CPU 仍可能退回到:

  • 总线锁定(Bus Lock)
  • 内存控制器互锁(memory interconnect lock)

比如 x86 的 lock 前缀会让 CPU 发出 LOCK# 信号,告诉其他核暂时禁止访问这段地址。

这是一种慢但可靠的 fallback 机制,避免跨节点访问带来的竞态。

禁止乱序与流水线干预

现代 CPU 为了性能采用乱序执行(Out-of-Order Execution)和流水线(Pipelining)机制。但原子操作必须:

  • 执行期间不得乱序
  • 禁止乱序缓存访问(如 Store Buffer 提前写入)
  • 强制所有早于它的读写完成,之后的读写等待

这依赖于:

  • 微指令插入内存屏障(memory fence)
  • 暂停 load/store buffer 的重排
  • Flush pipeline / reorder buffer

这就是为什么原子操作通常比普通操作要慢的原因之一。

例子:lock cmpxchg 的硬件执行流程

假设 CPU1 执行 lock cmpxchg [X], eax,大致流程如下:

  • CPU1 发出 LOCK# 信号或锁住缓存行 X(取决于地址是否在 cache)
  • 向 MESI 控制器发请求,抢占地址 X 的 cache line 独占权限
  • 在其他核心收到 invalidation 后,CPU1 获得修改权限
  • CPU1 比较 X 与 eax 的值,如果相等则更新为新值
  • 完成后通知 MESI controller,X 的状态设为 Modified
  • 操作结束,缓存一致性仍然维护

整个过程是由硬件控制单元(Load/Store Queue、Execution Unit、Bus Interface Unit)协调完成的。

小结:CPU 实现原子操作的关键机制

机制 功能
原子指令(如 lock cmpxchg, ldrex/strex 提供原子读-改-写
微操作调度锁定资源 保证在流水线中不可拆分
MESI 协议 多核共享缓存一致性
总线锁或互联锁 防止跨核心或I/O地址出现竞态
内存屏障 & 禁止乱序 防止指令与缓存失序访问

线程状态

  • New(新建):线程对象刚创建,还未开始执行。
  • Ready(就绪):线程准备好运行,等待调度器分配 CPU。
  • Running(运行):线程正在使用 CPU 执行指令。
  • Blocked(阻塞):线程在等待某个事件(如 I/O、锁、条件变量等),不能运行。
  • Terminated(终止):线程执行完毕或被强制结束。
  • Sleeping(休眠):线程主动暂停一段时间(如 sleep()),不能被调度。

线程状态转换图

当前状态 触发条件 转换后状态 说明
New 调用 start() 或 std::thread 构造函数 Ready 线程被创建,等待调度
Ready 被调度器选中 Running 获得 CPU,开始执行
Running 时间片耗尽 / yield() Ready 主动或被动让出 CPU,重新排队
Running 调用 sleep() / wait() / 等待锁 Blocked / Sleeping 线程不能继续执行,等待事件或时间
Blocked / Sleeping 条件满足 / 时间到 Ready 线程重新具备运行条件,等待调度
Running 执行结束或异常 Terminated 线程生命周期结束

性能指标

CPU时间

函数调用的 CPU 时间:

  • Inclusive time: total cpu time, include all functions it calls.
  • Exclusive time: only the time used by the function itself, exclusive all its children.

Refer to here.

  1. Wall time: total time the process used, containing IO time.

  2. CPU usage (CPU利用率) = CPU time / Wall time.

  3. real/user/system time

    • Real is wall clock time - time from start to finish of the call. This is all elapsed time including time slices used by other processes and time the process spends blocked (for example if it is waiting for I/O to complete).
    • User is the amount of CPU time spent in user-mode code (outside the kernel) within the process. This is only actual CPU time used in executing the process. Other processes and time the process spends blocked do not count towards this figure.
    • Sys is the amount of CPU time spent in the kernel within the process. This means executing CPU time spent in system calls within the kernel, as opposed to library code, which is still running in user-space. Like ‘user’, this is only CPU time used by the process. See below for a brief description of kernel mode (also known as ‘supervisor’ mode) and the system call mechanism.

    Refer to here.

  4. CPU 时间可能大于墙上时间:

    这是因为 CPU 时间是所有 CPU 核的运行时间的累加和,墙上时间则是实际的时间。此时 CPU 利用率大于 100%. (这是自己的理解)

  5. TODO: Is CPU time in flame graph sum of all the CPU time? Or is it the wall time when CPU works?

计时工具:timer

1
$ /usr/bin/time -p ls

Or,

1
$ time ls

其中(参考链接),

1
2
3
$ type -a time
time is a shell keyword
time is /usr/bin/time

分析工具

  • Performance Analyzer
  • Thread Analyzer
  • gprof
  • DDT/gdb

Performance Analyzer

Oracle Developer Studio 提供了多种工具:Performance Analyzer、Thread Analyzer。

Performance Analyzer 官方文档

1. 收集数据

使用 collect 命令收集数据(官方文档)。

1
collect collect-options program program-arguments

2. 开始性能分析

使用 analyzer 命令进行性能分析(官方文档)。

1
analyzer [control-options] [experiment | experiment-list]

例如:

1
analyzer -c test.1.er test.4.er

Thread Analyzer

Thread Analyzer 官方文档

gprof

gprof(GNU profiler)是GNU binutils工具集中的一个工具,linux系统当中会自带这个工具。它可以分析程序的性能,能给出函数调用时间、调用次数和调用关系,找出程序的瓶颈所在。在编译和链接选项中都加入-pg之后,gcc会在每个函数中插入代码片段,用于记录函数间的调用关系和调用次数,并采集函数的调用时间。

DDT

DDT

简介

“Hybrid Build”(混合构建)是指一种 兼顾调试能力与运行性能 的构建方式,常用于需要在 Release 环境中进行问题分析,但又不能完全使用 Debug 构建的场景。

🧩 Hybrid Build 的核心特征

特性 描述
优化等级 使用 -O2 或 -O3,保持接近 Release 的性能
调试信息 保留 -g,生成 DWARF 调试符号,便于分析 core dump 或使用 gdb
符号表 可选开启 -fno-omit-frame-pointer,便于栈回溯
日志/保护机制 可嵌入轻量级日志、ASAN-lite、canary 等机制,增强可观测性
部署策略 仅在内部或特定客户环境中使用,避免暴露完整源码结构或调试符号

✅ 编译示例(GCC/Clang)

1
g++ -O2 -g -fno-omit-frame-pointer -DNDEBUG -o your_program main.cpp
  • -O2:优化代码,接近 Release 性能
  • -g:保留调试信息
  • -fno-omit-frame-pointer:保留帧指针,便于栈追踪
  • -DNDEBUG:关闭断言(assert)

🎯 使用场景

场景 是否推荐使用 Hybrid Build 原因说明
客户现场复现崩溃 可通过 core dump + 符号表快速定位问题
内部性能测试 保持优化效果,同时可调试
正式发布给客户(大规模部署) 不建议,可能暴露符号信息,体积较大
与 ASAN/TSAN 联合使用 ⚠️(需关闭优化) ASAN/TSAN 更适合 Debug 构建,Hybrid 会影响检测精度

🛠️ 实践建议

  • 符号分离:使用 objcopy --only-keep-debug 分离调试符号,避免暴露给客户。
  • 符号服务器:内部维护符号服务器,支持通过 core 文件自动符号化。
  • 结合日志框架:Hybrid Build 可与轻量级日志框架结合,增强问题定位能力。

符号分离(Split Debug Info)

✅ 为什么要分离?

  • Release 版本:体积小,性能高,但缺少调试信息
  • 调试符号文件:保留完整符号信息,用于内部分析 core dump

🛠️ 分离流程(以 GCC/Clang 为例)

1
2
3
4
5
6
7
8
9
10
11
# 编译时保留调试信息
g++ -O2 -g -o your_program main.cpp

# 分离调试符号
objcopy --only-keep-debug your_program your_program.debug

# 去除主程序中的调试信息
strip --strip-debug --strip-unneeded your_program

# 添加调试符号关联信息
objcopy --add-gnu-debuglink=your_program.debug your_program

或者:

1
2
3
4
5
6
7
8
9
10
11
# 编译 Debug 版本(带调试信息)
gcc -g -O2 -o myapp_debug myapp.c

# 提取调试信息
objcopy --only-keep-debug myapp_debug myapp.debug

# 编译 Release 版本(去除调试信息)
strip --strip-debug myapp_debug -o myapp_release

# 使用 GDB 加载 Release + 调试符号
gdb --exec=myapp_release --symbols=myapp.debug

这样你就可以像调试 Debug 版本一样使用 GDB,但运行的是 Release 版本。

如果你有 Debug 和 Release 两个版本:

1
gdb --exec=myapp_release --symbols=myapp_debug

这会让 GDB 使用 Debug 版本的符号信息来调试 Release 程序。

✅ 最终产物:

  • your_program:可交付给客户的 Release 可执行文件
  • your_program.debug:内部使用的调试符号文件

core dump 分析

1. 客户侧设置 core dump

1
ulimit -c unlimited

2. 客户运行程序并生成 core 文件

1
2
./your_program crash_input.txt
# 程序崩溃后生成 core 文件,如 core.12345

3. 客户提供 core 文件 + 可执行文件

  • core.12345
  • your_program(Release 版本)

4. 你在内部分析 core 文件

1
2
3
4
gdb your_program core.12345
# 加载调试符号
(gdb) symbol-file your_program.debug
(gdb) bt # 查看调用栈

栈指针寄存器

TODO:

  • rsp

  • rbp

  • 压栈和出栈的过程

  • 使用 rsp 可以节省指令和寄存器

工具

valgrind 是一个强大的内存调试和性能分析工具,它的不同子工具用于不同的分析目的。--memcheck--massif 是其中两个常用的工具。

ASAN(AddressSanitizer) 和 TSAN(ThreadSanitizer) 都是由现代编译器(如 Clang 和 GCC)提供的 运行时检测工具,用于帮助开发者在开发阶段发现内存错误和线程问题。

  • Clang:全面支持 ASAN 和 TSAN
  • GCC:也支持,但 TSAN 的支持略逊于 Clang

博客:How to get a core dump for a segfault on Linux

Memcheck:内存错误检查工具

官方文档:4. Memcheck: a memory error detector

  • 用途:检测内存相关的错误,如内存泄漏、越界访问、未初始化内存读取等。
  • 适用场景:调试程序中的内存错误,确保程序的内存使用是安全的。
  • 输出内容:
    • 哪些内存没有释放(内存泄漏)
    • 哪些内存被非法访问(越界、未初始化等)
    • 哪些内存访问是未定义行为
  • 常用命令:
1
valgrind --tool=memcheck ./your_program

Massif:堆内存使用分析工具

官方文档:9. Massif: a heap profiler

  • 用途:分析程序在运行过程中堆内存的使用情况,帮助优化内存占用。
  • 适用场景:性能分析,找出内存占用高的代码路径或数据结构。
  • 输出内容:
    • 堆内存使用随时间的变化(快照):需用 ms_printPostScript Viewer 查看
    • 哪些函数或调用路径分配了最多的内存
    • 常用命令:
1
2
valgrind --tool=massif ./your_program
ms_print massif.out.<pid>

🆚 Memcheck vs Massif 对比总结

特性 --memcheck --massif
工具类型 内存错误检查工具 堆内存使用分析工具
主要用途 检查内存泄漏、越界访问、未初始化读取等 分析堆内存使用趋势和峰值
检测内存泄漏 ❌(不直接检测)
分析内存增长趋势
输出格式 错误报告(文本) 内存快照(需用 ms_print 查看)
性能开销 高(运行速度变慢) 中等

ThreadSanitizer(TSAN)

  • 用途:检测多线程程序中的 数据竞争(data race) 和其他线程同步问题。
  • 适用场景:并发程序调试,尤其是当多个线程访问共享变量时。
  • 检测内容:
    • 数据竞争(两个线程同时访问同一变量,且至少一个是写操作)
    • 锁使用错误(死锁、双重解锁等)
  • 启用方式(Clang/GCC):
1
clang -fsanitize=thread -g your_program.c -o your_program

AddressSanitizer(ASAN)

AddressSanitizer 文档

  • 用途:检测内存访问错误。
  • 适用场景:单线程或多线程程序中调试内存问题。
  • 检测内容:
    • 越界访问(stack/heap/global)
    • use-after-free(释放后使用)
    • 内存泄漏(可选)
    • use-after-scope(作用域结束后使用局部变量)
  • 启用方式:
1
clang -fsanitize=address -g your_program.c -o your_program

🆚 TSAN vs ASAN 对比总结

特性 TSAN(ThreadSanitizer) ASAN(AddressSanitizer)
检测目标 多线程数据竞争 内存访问错误
是否支持多线程 ✅(专为多线程设计) ✅(支持,但不检测数据竞争)
性能开销 较高(10x~40x) 中等(2x~3x)
内存开销 中等 较高(增加约2倍内存使用)
是否检测内存泄漏 ❌(不检测) ✅(可选开启)
是否检测数据竞争

🆚 Memcheck vs Massif vs TSAN vs ASAN 对比总结

工具名称 类型 主要用途 检测内容 是否支持多线程 性能开销 内存开销 是否检测内存泄漏 是否检测数据竞争
Memcheck Valgrind 工具 内存错误检测 内存泄漏、越界访问、未初始化读取、非法释放等 ✅(有限支持) 高(10x+)
Massif Valgrind 工具 堆内存使用分析 堆内存分配趋势、内存峰值、调用路径分析 中等 中等
ASAN 编译器工具 内存访问错误检测 越界访问、use-after-free、use-after-scope、内存泄漏(可选) 中等(2x~3x) 高(约2倍) ✅(可选)
TSAN 编译器工具 多线程数据竞争检测 数据竞争、死锁、错误的锁使用等 ✅(强支持) 高(5x~40x) 中等

UBSan (Unifined Behavior Sanitizer)

Sanitizer 检测内容 Google 贡献
ASAN 内存访问错误 ✅ 是
UBSan 未定义行为 ✅ 是
TSAN 数据竞争 ✅ 是
MSAN 未初始化内存使用 ✅ 是

__attribute__((__visibility__("default")))

__attribute__((__visibility__("default"))) 是 GCC 编译器的一个属性,用于控制符号的可见性。它指定了一个符号(如函数或变量)在共享库中的可见性。

含义:

  • default 可见性
    • 符号可以被其他共享库或可执行文件访问。
    • 符号会被导出到共享库的符号表中。

用途:

  • 用于显式导出符号,使其在动态链接时可被其他模块使用。

  • 在共享库中,默认情况下符号是 default 可见的,但使用 -fvisibility=hidden 编译选项时,所有符号会被隐藏,只有显式标记为 default 的符号才会导出。

  • 编译器优化的影响:即使没有 -fvisibility=hidden,某些编译器优化(如 -flto)可能会影响符号的导出。
    显式使用 __attribute__((__visibility__("default"))) 可以避免这些问题。

示例:

1
2
3
4
__attribute__((__visibility__("default")))
void myFunc() {
// Function implementation
}

可以通过以下步骤确认 myFunc 是否在被链接时可见:

方法 1: 使用 nm 工具检查符号

nm 是一个工具,可以列出目标文件或共享库中的符号表。

  1. 检查 libchip.so 中是否导出了 myFunc

    1
    nm -D libchip.so | grep myFunc
    • 如果 myFunc 出现在输出中,说明它被导出并且可见。
    • 如果没有出现,可能是符号被隐藏(例如使用了 -fvisibility=hidden)。
  2. 如果 myFunc 没有导出:

    • 确认是否在代码中使用了 __attribute__((__visibility__("default")))
    • 确认编译时是否启用了 -fvisibility=hidden

方法 2: 使用 readelf 检查动态符号

readelf 可以显示共享库的动态符号表。

  1. 检查 libchip.so 的动态符号表:
    1
    readelf -Ws libchip.so | grep myFunc
    • 如果 myFunc 出现在输出中,说明它是动态可见的。
    • 如果没有出现,说明符号被隐藏。

方法 3: 检查链接依赖关系

  1. 检查 libdbg.so 是否声明了对 libchip.so 的依赖:

    1
    readelf -d libdbg.so | grep NEEDED
    • 如果 libchip.so 出现在 NEEDED 列表中,说明 libdbg.so依赖 libchip.so
  2. 如果 libchip.so 在依赖列表中,但链接时仍然报错:

    • 确认 myFunc 是否在 libchip.so 中导出(使用 nmreadelf 检查)。

方法 4: 检查链接器错误

如果链接器报错 undefined reference to myFunc

  • 确认链接命令是否显式包含 libchip.so
    1
    g++ -o executable main.o -L/path/to/libs -ldbg -lchip
  • 如果没有显式链接 libchip.so,动态链接器可能无法解析 myFunc

总结:

  • 使用 nmreadelf 检查 libchip.so 是否导出了 myFunc
  • 如果符号未导出,确保代码中使用了 __attribute__((__visibility__("default"))) 并检查编译选项。

📝 现象描述

当你运行一个 ELF 可执行文件(比如一个 32-bit 的程序),而它依赖的 loader 或共享库找不到 时,Shell 会提示:

1
bash: ./a.out: No such file or directory

但其实,这个“文件”确实存在,问题出在它 依赖的 loader 文件或库文件找不到,进而导致 execve() 系统调用失败。

我们从 内核执行路径 角度来解析这背后的原理。

🧠Linux 内部逻辑解析

  1. Shell 运行程序的过程
1
./my_app

Shell 实际做的是调用 execve() 系统调用:

1
execve("./my_app", argv, envp);

这是 Linux 加载并运行一个程序的唯一入口。

  1. execve() 做了什么?

内核执行 execve() 时,它会:

🔹 Step 1: 打开并读取 ELF 头(文件的前几个字节)
从而判断这是一个 ELF 可执行文件、脚本,还是其他格式。

🔹 Step 2: 检查 ELF 的架构位数、ABI、动态链接需求
对于动态链接程序,它会在 ELF 头中读取如下字段:

  • e_ident[EI_CLASS]: 32-bit 或 64-bit
  • e_interpreter: 这是最关键的!
  1. e_interpreter 是什么?

这是 ELF 文件里定义的 “程序解释器路径”(interpreter path)。即这个程序运行时,系统需要先加载谁来帮它加载剩下的动态库。

可以通过 readelf -l my_app 查看:

1
2
3
$ readelf -l ./my_app | grep interpreter

[Requesting program interpreter: /lib/ld-linux.so.2]

比如:/lib/ld-linux.so.2 是 32-bit 程序使用的 loader。

  1. 如果这个 loader 不存在,会发生什么?
  • execve() 尝试打开 ELF 指定的解释器(/lib/ld-linux.so.2)
  • 如果该文件不存在,open() 失败
  • 整个 execve() 调用失败
  • 错误代码是 ENOENT(2),代表:

“No such file or directory”

但这并不是说 你运行的那个文件 ./my_app 不存在,而是 它依赖的解释器不在系统中,导致整个执行失败。

🤯 为什么这么误导?

因为 shell(bash/zsh)调用 execve() 失败后,只看到了 errno = ENOENT,它默认解释为:

“你指定的那个文件路径不存在”

而不是更深层次的:

“文件存在,但它需要的 loader 不存在”

这是 shell 的 历史遗留行为,没有细分 errno 背后语义。

🧪 举个实际例子

1
2
3
4
5
6
7
8
9
10
11
$ file ./a.out
./a.out: ELF 32-bit LSB executable, Intel 80386, ...

$ readelf -l ./a.out | grep interpreter
[Requesting program interpreter: /lib/ld-linux.so.2]

$ ls /lib/ld-linux.so.2
ls: cannot access '/lib/ld-linux.so.2': No such file or directory

$ ./a.out
bash: ./a.out: No such file or directory ❌

实际上是 缺少解释器 /lib/ld-linux.so.2,而不是 a.out 本身。

✅ 总结

层级 行为
用户 输入 ./prog
Shell 调用 execve("./prog", ...)
内核 解析 ELF,发现 interpreter/lib/ld-linux.so.2
内核 找不到解释器,返回 ENOENT
Shell No such file or directory ❌(误导性)

🛠️ Bonus:编译 32-bit 程序

  1. 编写一个简单的 C 程序
1
2
3
4
5
6
7
// hello.c
#include <stdio.h>

int main() {
printf("Hello from 32-bit program!\n");
return 0;
}
  1. 编译成 32 bit 程序

需要先安装 32-bit 编译支持:

1
2
sudo apt update
sudo apt install gcc-multilib

然后编译:

1
gcc -m32 hello.c -o hello32
  1. 验证是否是 32-bit ELF 可执行文件
1
file hello32

输出应类似:

1
hello32: ELF 32-bit LSB executable, Intel 80386, ...

Makefileview raw
1
2
3
4
5
6
7
8
9
10
11
12
13
14
CXX = g++
CXXFLAGS = -fPIC -Wall -Wextra
LDFLAGS = -shared

all: main libhello.so

main: main.cpp
$(CXX) main.cpp -o main -ldl

libhello.so: hello.cpp
$(CXX) $(CXXFLAGS) $(LDFLAGS) hello.cpp -o libhello.so

clean:
rm -f main libhello.
main.cppview raw
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
#include <dlfcn.h>

#include <iostream>

typedef void (*HelloFunc)();

int main() {
// 打开共享库
void* handle = dlopen("./libhello.so", RTLD_LAZY);
if (!handle) {
std::cerr << "dlopen failed: " << dlerror() << std::endl;
return 1;
}

// 清除之前的错误
dlerror();

// 获取函数指针
HelloFunc hello = (HelloFunc)dlsym(handle, "hello");
const char* dlsym_error = dlerror();
if (dlsym_error) {
std::cerr << "dlsym failed: " << dlsym_error << std::endl;
dlclose(handle);
return 1;
}

// 打印函数地址
std::cout << "Address of hello function: " << reinterpret_cast<void*>(hello) << std::endl;

// 调用函数
hello();

// 关闭共享库
dlclose(handle);
return 0;
}

定义

当两个线程同时修改原子变量时,以下操作会发生:

原子性:原子变量的修改是原子的,即每次修改都是一个不可分割的操作。这意味着在任何时刻,只有一个线程能够成功地修改原子变量,而不会出现竞争条件。

同步机制:原子变量使用硬件级别的同步机制(如锁、比较并交换操作等)来确保修改的原子性。这些机制确保线程在修改原子变量时不会干扰其他线程的操作。

内存可见性:原子变量的修改会确保内存可见性,即一个线程对原子变量的修改会立即对其他线程可见。这意味着其他线程可以看到最新的修改结果,而不会读取到过时的数据。

举个例子,假设我们有一个原子变量 std::atomic<int> counter,两个线程同时执行 counter++ 操作:

atomic1.cppview raw
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <atomic>
#include <thread>
#include <iostream>

std::atomic<int> counter(0);

void increment() {
for (int i = 0; i < 1000; ++i) {
counter++;
}
}

int main() {
std::thread t1(increment);
std::thread t2(increment);

t1.join();
t2.join();

std::cout << "Final counter value: " << counter << std::endl;
return 0;
}

在这个例子中:

两个线程 t1 和 t2 同时执行 increment 函数,尝试修改原子变量 counter。
原子性 确保每次 counter++ 操作都是不可分割的,避免竞争条件。
同步机制 确保两个线程不会同时修改 counter,而是一个线程成功修改后,另一个线程才能继续修改。
内存可见性 确保 counter 的最新值对两个线程都是可见的。

实现机制

让我们深入探讨一下两个线程同时修改原子变量时的底层机制。

  1. 原子操作的硬件支持
    原子操作通常由硬件指令支持,这些指令确保操作的原子性。例如,x86架构提供了 LOCK 前缀,用于确保指令在多处理器环境中的原子性。常见的原子指令包括 LOCK XADD(原子加法)和 LOCK CMPXCHG(原子比较并交换)。

  2. 缓存一致性协议
    多核处理器使用缓存一致性协议(如MESI协议)来确保各个处理器缓存中的数据一致性。当一个处理器修改原子变量时,缓存一致性协议会确保其他处理器的缓存中对应的数据无效或更新。

  3. 内存屏障
    内存屏障(Memory Barrier)是一种指令,用于防止编译器和处理器对内存操作进行重排序。原子操作通常会使用内存屏障来确保操作的顺序性和可见性。例如,std::atomic 在实现时会使用内存屏障来确保操作的正确性。

  4. 比较并交换(CAS)
    比较并交换(Compare-And-Swap, CAS)是一种常用的原子操作,它通过比较变量的当前值和预期值,如果相等则更新变量的值。CAS操作是无锁编程的基础,用于实现许多并发数据结构。

  5. 伪代码示例
    以下是一个伪代码示例,展示了CAS操作的工作原理:

cas_mock.cppview raw
1
2
3
4
5
6
7
bool compare_and_swap(int* ptr, int old_value, int new_value) {
if (*ptr == old_value) {
*ptr = new_value;
return true;
}
return false;
}

在多线程环境中,CAS操作可以确保只有一个线程成功修改变量的值,而其他线程会失败并重试。

  1. 实际应用
    在实际应用中,原子变量的操作可能如下所示:
atomic2.cppview raw
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include <atomic>
#include <thread>
#include <iostream>

std::atomic<int> counter(0);

void increment() {
for (int i = 0; i < 1000; ++i) {
int expected = counter.load();
while (!counter.compare_exchange_weak(expected, expected + 1)) {
// 重试,直到成功
}
}
}

int main() {
std::thread t1(increment);
std::thread t2(increment);

t1.join();
t2.join();

std::cout << "Final counter value: " << counter << std::endl;
return 0;
}

在这个例子中,compare_exchange_weak 使用CAS操作来确保 counter 的原子性修改。每次修改失败时,线程会重试,直到成功。

缓存一致性协议

缓存一致性协议是确保多核处理器中各个缓存之间数据一致性的机制。最常见的缓存一致性协议是MESI协议。让我们详细解释一下。

MESI协议

MESI协议是由四种状态组成的缓存一致性协议,每个缓存行(Cache Line)可以处于以下四种状态之一:

  • Modified(修改):缓存行中的数据已被修改,与主内存中的数据不一致,且该数据仅存在于当前缓存中。
  • Exclusive(独占):缓存行中的数据与主内存中的数据一致,且该数据仅存在于当前缓存中。
  • Shared(共享):缓存行中的数据与主内存中的数据一致,且该数据可能存在于多个缓存中。
  • Invalid(无效):缓存行中的数据无效。

状态转换

缓存行的状态会根据不同的操作进行转换。以下是一些常见的状态转换:

从主内存读取数据:如果一个缓存行处于Invalid状态,当一个处理器读取该数据时,缓存行会变为Shared或Exclusive状态。
修改数据:如果一个缓存行处于Shared或Exclusive状态,当一个处理器修改该数据时,缓存行会变为Modified状态,并通知其他缓存将该缓存行置为Invalid状态。
写回数据:如果一个缓存行处于Modified状态,当该缓存行被替换时,数据会被写回主内存,缓存行变为Invalid状态。

工作原理

假设有三个处理器A、B和C,它们各自有自己的缓存。主内存中有一个变量x,初始值为0。

读取数据:

处理器A从主内存读取x,缓存行状态变为Exclusive。
处理器B从主内存读取x,处理器A检测到冲突,将缓存行状态变为Shared,处理器B的缓存行状态也变为Shared。
修改数据:

处理器A修改x,缓存行状态变为Modified,并通知处理器B将其缓存行状态变为Invalid。
处理器B尝试读取x时,检测到缓存行无效,从主内存重新读取数据。

写回数据:

处理器A将修改后的数据写回主内存,缓存行状态变为Invalid。

优化和问题

缓存一致性协议通过总线嗅探(Bus Snooping)和状态机机制来实现数据的一致性,但也会引入一些性能问题,如总线带宽压力和处理器等待时间。为了解决这些问题,现代处理器引入了存储缓冲区(Store Buffer)和写回策略(Write Back)等优化技术。

CAS

比较并交换(Compare-And-Swap, CAS)操作在并发编程中有许多好处:

  1. 无锁编程
    CAS操作是无锁编程的基础,它允许多个线程在不使用锁的情况下安全地修改共享数据。无锁编程可以减少锁的开销,避免死锁,提高系统的并发性能。

  2. 原子性
    CAS操作是原子的,即它确保比较和交换操作在硬件级别上是不可分割的。这意味着在任何时刻,只有一个线程能够成功地修改变量的值,避免竞争条件。

  3. 高效性
    CAS操作通常由硬件指令支持(如x86架构中的 LOCK CMPXCHG 指令),这些指令非常高效,能够快速完成比较和交换操作。相比于使用锁,CAS操作的开销更低。

  4. 可扩展性
    CAS操作允许多个线程同时尝试修改变量,而不会阻塞其他线程。这种特性使得CAS操作在高并发环境中具有良好的可扩展性,能够处理大量线程的并发访问。

  5. 实现复杂的并发数据结构
    CAS操作可以用于实现复杂的并发数据结构,如无锁队列、无锁栈等。这些数据结构能够在高并发环境中提供高效的操作,而不会引入锁的开销。

以下是一个使用CAS操作实现无锁计数器的示例:

cas_counter.cppview raw
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include <atomic>
#include <thread>
#include <iostream>

std::atomic<int> counter(0);

void increment() {
for (int i = 0; i < 1000; ++i) {
int expected = counter.load();
while (!counter.compare_exchange_weak(expected, expected + 1)) {
// 重试,直到成功
}
}
}

int main() {
std::thread t1(increment);
std::thread t2(increment);

t1.join();
t2.join();

std::cout << "Final counter value: " << counter << std::endl;
return 0;
}

在这个例子中,compare_exchange_weak 使用CAS操作来确保 counter 的原子性修改。每次修改失败时,线程会重试,直到成功。

compare_exchange_weak 和 fetch_add

compare_exchange_weak 和 fetch_add 是两种不同的原子操作,它们在功能和使用场景上有明显的区别。让我们详细解释一下它们的区别以及如何选择。

compare_exchange_weak

功能:

compare_exchange_weak 是一种CAS(Compare-And-Swap)操作,用于比较原子变量的当前值与预期值,如果相等,则将新值存储到变量中,并返回 true;如果不相等,则返回 false 并更新预期值。
该操作允许偶尔的失败(即使当前值与预期值相等,也可能返回 false),这在一些循环算法中是可以接受的。

使用场景:

适用于需要在循环中反复尝试更新变量的场景,例如实现无锁数据结构(如无锁队列、栈等)。
在高并发环境中,compare_exchange_weak 通常比 compare_exchange_strong 性能更高,因为它允许偶尔的失败,从而减少了总线锁定的开销.

fetch_add

功能:

fetch_add 是一种原子加法操作,它将给定的值加到原子变量的当前值中,并返回变量的旧值。
该操作是原子的,确保在多线程环境中不会出现竞争条件。

使用场景:

适用于简单的计数器或累加器场景,例如统计访问次数、累加总和等。
在需要对变量进行简单的加法操作时,fetch_add 是一个高效的选择2.

如何选择

选择 compare_exchange_weak 还是 fetch_add 取决于具体的使用场景和需求:

如果需要实现复杂的无锁数据结构(如无锁队列、栈等),并且需要反复尝试更新变量,选择 compare_exchange_weak。
如果需要对变量进行简单的加法操作(如计数器、累加器),选择 fetch_add。
示例代码
以下是使用 compare_exchange_weak 和 fetch_add 的示例代码:

compare_exchange_weak 示例:

compare_exchange_weak_eg.cppview raw
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include <atomic>
#include <thread>
#include <iostream>

std::atomic<int> counter(0);

void increment() {
for (int i = 0; i < 1000; ++i) {
int expected = counter.load();
while (!counter.compare_exchange_weak(expected, expected + 1)) {
// 重试,直到成功
}
}
}

int main() {
std::thread t1(increment);
std::thread t2(increment);

t1.join();
t2.join();

std::cout << "Final counter value: " << counter << std::endl;
return 0;
}

fetch_add 示例:

fetch_add_eg.cppview raw
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <atomic>
#include <thread>
#include <iostream>

std::atomic<int> counter(0);

void increment() {
for (int i = 0; i < 1000; ++i) {
counter.fetch_add(1);
}
}

int main() {
std::thread t1(increment);
std::thread t2(increment);

t1.join();
t2.join();

std::cout << "Final counter value: " << counter << std::endl;
return 0;
}

如果我们只需要简单地递增计数器,并且没有其他条件限制,fetch_add 确实是一个更直接和高效的选择。fetch_add 能够确保每次递增操作都是原子的,避免了竞争条件。

然而,在某些特定场景下,compare_exchange_weak 可能更为合适。例如,当我们需要基于特定条件进行更新时,compare_exchange_weak 可以确保只有在满足条件时才进行更新操作。

示例:基于条件的更新
假设我们有一个计数器,需要在满足特定条件时进行递增操作:

cas_case.cppview raw
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include <atomic>
#include <thread>
#include <iostream>

std::atomic<int> counter(0);

void conditional_increment() {
for (int i = 0; i < 1000; ++i) {
int expected = counter.load();
while (expected < 500 && !counter.compare_exchange_weak(expected, expected + 1)) {
// 重试,直到成功或条件不满足
}
}
}

int main() {
std::thread t1(conditional_increment);
std::thread t2(conditional_increment);

t1.join();
t2.join();

std::cout << "Final counter value: " << counter << std::endl;
return 0;
}

在这个例子中,只有当 counter 的值小于500时,才会进行递增操作。compare_exchange_weak 确保条件更新的原子性和安全性。

使用 fetch_add 的场景
如果我们不需要基于条件进行更新,只是简单地递增计数器,那么 fetch_add 是一个更好的选择:

fetch_add_case.cppview raw
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <atomic>
#include <thread>
#include <iostream>

std::atomic<int> counter(0);

void increment() {
for (int i = 0; i < 1000; ++i) {
counter.fetch_add(1);
}
}

int main() {
std::thread t1(increment);
std::thread t2(increment);

t1.join();
t2.join();

std::cout << "Final counter value: " << counter << std::endl;
return 0;
}

在这个例子中,fetch_add 确保每次递增操作都是原子的,避免了竞争条件。

总结来说,如果你的操作只是简单的递增,fetch_add 是更高效的选择;如果需要基于条件进行更新,compare_exchange_weak 则更为合适。

ABA

ABA问题在并发编程中是一个常见的问题,尤其是在使用CAS(Compare-And-Swap)操作时。ABA问题的本质是一个变量的值在两次比较之间发生了变化,但最终值又回到了原始值,导致CAS操作无法检测到这种变化。为了避免ABA问题,可以使用带有版本号或标记的原子引用,例如 AtomicStampedReference。

示例:使用 AtomicStampedReference 避免ABA问题
以下是一个使用 AtomicStampedReference 避免ABA问题的示例:

atomic_stamped_reference.cppview raw
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#include <atomic>
#include <thread>
#include <iostream>
#include <vector>

std::atomic<int> counter(0);
std::atomic<int> stamp(0);

void conditional_increment() {
for (int i = 0; i < 1000; ++i) {
int expected = counter.load();
int expected_stamp = stamp.load();
while (expected < 500 && !counter.compare_exchange_weak(expected, expected + 1) && !stamp.compare_exchange_weak(expected_stamp, expected_stamp + 1)) {
// 重试,直到成功或条件不满足
}
}
}

int main() {
std::thread t1(conditional_increment);
std::thread t2(conditional_increment);

t1.join();
t2.join();

std::cout << "Final counter value: " << counter << std::endl;
std::cout << "Final stamp value: " << stamp << std::endl;
return 0;
}

在这个例子中,我们使用了一个额外的 stamp 变量来记录每次更新的版本号。每次更新 counter 时,同时更新 stamp。这样,即使 counter 的值回到了原始值,stamp 的值也会不同,从而避免了ABA问题。

解释
版本号:每次更新 counter 时,同时更新 stamp,确保每次更新都有一个唯一的版本号。
CAS操作:使用 compare_exchange_weak 确保 counter 和 stamp 的更新是原子的。
重试机制:如果CAS操作失败,线程会重试,直到成功或条件不满足。
这种方法通过引入版本号或标记,确保即使变量的值回到了原始值,版本号也会不同,从而避免了ABA问题。

ABA问题不仅存在于CAS操作中,还可能在其他并发编程场景中出现。以下是一些可能出现ABA问题的情景:

  1. 双重检查锁定(Double-Checked Locking)
    在双重检查锁定模式中,ABA问题可能会导致错误的判断。例如,在初始化单例对象时,如果两个线程同时检查对象是否为空,并且一个线程在检查后立即创建对象,而另一个线程在检查后发现对象已经被创建并且再次检查时发现对象为空,这可能会导致错误的初始化。

  2. 无锁队列和栈
    在无锁队列和栈的实现中,ABA问题可能会导致节点的插入和删除操作出现错误。例如,一个线程在删除节点时,另一个线程可能会插入和删除相同的节点,导致第一个线程无法正确判断节点的状态。

  3. 版本控制
    在版本控制系统中,ABA问题可能会导致错误的版本判断。例如,一个线程在检查版本号时,另一个线程可能会更新版本号并且再次更新回原始版本号,导致第一个线程无法正确判断版本是否被修改。

解决方案
为了避免ABA问题,可以使用以下解决方案:

版本号或标记:使用版本号或标记来记录每次更新的状态。例如,使用 AtomicStampedReference 来确保每次更新都有一个唯一的版本号。
时间戳:使用时间戳来记录每次更新的时间,确保每次更新都有一个唯一的时间标记。
逻辑时钟:使用逻辑时钟来记录每次更新的顺序,确保每次更新都有一个唯一的顺序标记。
示例:使用 AtomicStampedReference 避免ABA问题
以下是一个使用 AtomicStampedReference 避免ABA问题的示例:

atomic_stamped_reference.javaview raw
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import java.util.concurrent.atomic.AtomicStampedReference;

public class ABAExample {
private static AtomicStampedReference<Integer> atomicStampedRef = new AtomicStampedReference<>(0, 0);

public static void main(String[] args) {
Thread t1 = new Thread(() -> {
int stamp = atomicStampedRef.getStamp();
Integer value = atomicStampedRef.getReference();
System.out.println("Thread 1 initial value: " + value + ", stamp: " + stamp);
atomicStampedRef.compareAndSet(value, value + 1, stamp, stamp + 1);
System.out.println("Thread 1 updated value: " + atomicStampedRef.getReference() + ", stamp: " + atomicStampedRef.getStamp());
});

Thread t2 = new Thread(() -> {
int stamp = atomicStampedRef.getStamp();
Integer value = atomicStampedRef.getReference();
System.out.println("Thread 2 initial value: " + value + ", stamp: " + stamp);
atomicStampedRef.compareAndSet(value, value + 1, stamp, stamp + 1);
System.out.println("Thread 2 updated value: " + atomicStampedRef.getReference() + ", stamp: " + atomicStampedRef.getStamp());
});

t1.start();
t2.start();
}
}

在这个例子中,AtomicStampedReference 使用版本号来避免ABA问题,确保每次更新都有一个唯一的版本号。

C++ 标准库中没有直接等同于 Java 的 AtomicStampedReference 的类,但你可以通过组合 std::atomic 和自定义结构体来实现类似的功能。以下是一个示例,展示如何在 C++ 中实现带有版本号的原子引用,以避免 ABA 问题:

示例:实现带有版本号的原子引用

cas_with_version.cppview raw
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
#include <atomic>
#include <iostream>
#include <thread>

struct StampedValue {
int value;
int stamp;
};

std::atomic<StampedValue> atomicStampedValue{StampedValue{0, 0}};

void increment() {
for (int i = 0; i < 1000; ++i) {
StampedValue expected = atomicStampedValue.load();
StampedValue newValue;
do {
newValue = {expected.value + 1, expected.stamp + 1};
} while (!atomicStampedValue.compare_exchange_weak(expected, newValue));
}
}

int main() {
std::thread t1(increment);
std::thread t2(increment);

t1.join();
t2.join();

StampedValue finalValue = atomicStampedValue.load();
std::cout << "Final value: " << finalValue.value << ", Final stamp: " << finalValue.stamp << std::endl;
return 0;
}

解释
StampedValue 结构体:包含一个值和一个版本号(或时间戳)。
std::atomic:使用 std::atomic 包装 StampedValue 结构体,以确保原子性操作。
compare_exchange_weak:在更新值和版本号时使用 CAS 操作,确保操作的原子性和避免 ABA 问题。
使用场景
这种方法适用于需要避免 ABA 问题的场景,例如无锁队列、无锁栈等复杂并发数据结构。

compare_exchange_weak 和 compare_exchange_strong

区别
可靠性:

compare_exchange_weak:允许偶尔失败,即使当前值与期望值相等,也可能返回 false。这种设计是为了提高性能,适用于循环中的自旋锁等场景。
compare_exchange_strong:保证操作成功,如果当前值与期望值相等,则一定返回 true。适用于需要确保操作成功的场景。
性能:

compare_exchange_weak:通常比 compare_exchange_strong 性能更高,因为它允许偶尔失败。
compare_exchange_strong:在某些平台上性能可能较低,因为它需要确保操作成功。
适用场景
compare_exchange_weak:

  • 适用于循环中的自旋锁。
  • 适用于高性能要求的场景。

compare_exchange_strong:

  • 适用于需要确保操作成功的场景。
  • 适用于线程同步。

compare_exchange_weak 允许虚假失败的底层实现基于 CAS(Compare-And-Swap) 操作和硬件指令的特性。以下是详细解释:

CAS操作
CAS 操作有三个参数:内存地址、预期值和新值。其基本原理是:

比较内存地址中的值是否与预期值相等。
如果相等,则将内存地址中的值更新为新值。
如果不相等,则不做任何操作,并返回当前内存地址中的值。
虚假失败的原因
compare_exchange_weak 允许虚假失败,即使当前值与预期值相等,也可能返回 false。这种情况通常发生在以下几种情况:

缓存一致性问题:

在多核处理器中,缓存一致性协议可能导致 CAS 操作失败,即使内存中的值没有改变。例如,在 ARM 架构中,使用 LDXR/STXR 指令时,其他线程对同一缓存行的访问可能导致 CAS 操作失败1。
硬件指令的实现:

在某些平台上,CAS 操作可能被实现为单次 LL/SC(Load-Linked/Store-Conditional)尝试。这种实现允许 CAS 操作偶尔失败,以提高性能

为什么compare_exchange_strong不被用于实现自旋锁?
Copilot said:
Copilot
compare_exchange_strong 通常不用于实现自旋锁,主要原因在于其性能和设计目的:

性能
compare_exchange_strong 保证操作成功,如果当前值与预期值相等,则一定返回 true1。这种保证需要更多的硬件资源和时间来完成操作,可能导致性能下降1。
compare_exchange_weak 允许偶尔失败,即使当前值与预期值相等,也可能返回 false1。这种设计减少了硬件资源的消耗,提高了性能1。
设计目的
自旋锁 的设计目的是在短时间内频繁尝试获取锁,而不是长时间等待。因此,自旋锁更适合使用 compare_exchange_weak,因为它可以在失败时快速重试1。
compare_exchange_strong 更适合需要确保操作成功的场景,如线程同步1。在这些场景中,操作的成功率比性能更重要。
代码示例
以下是一个使用 compare_exchange_weak 实现自旋锁的示例:

cas_spin_lock.cppview raw
1
2
3
4
5
6
7
8
9
10
11
12
13
14
class SpinLock {
private:
std::atomic<bool> flag = {false};
public:
void lock() {
bool expected = false;
while (!flag.compare_exchange_weak(expected, true, std::memory_order_acquire)) {
expected = false;
}
}
void unlock() {
flag.store(false, std::memory_order_release);
}
};

在这个示例中,compare_exchange_weak 被用来实现自旋锁,因为它在失败时可以快速重试,从而提高性能

内存屏障

内存屏障(Memory Barrier),也被称为内存栅栏(Memory Fence)或内存围栏(Memory Fence),是一种硬件或软件的同步机制,用于在并发系统中保持内存操作的顺序性。这是多核和多线程环境中至关重要的,因为现代处理器会对指令进行重排序以提高执行效率。

内存屏障的作用

  • 防止指令重排序:

    编译器和处理器可能会对指令进行重排序,以优化性能。内存屏障确保特定的内存操作在屏障之前完成,而不会被重排序到屏障之后。

  • 保证内存可见性:

    在多线程环境中,一个线程对内存的修改必须对其他线程可见。内存屏障确保在屏障之前的所有写操作对其他线程可见。

类型

  • 加载屏障(Load Barrier):

    确保在屏障之前的所有加载操作完成后,才开始执行屏障之后的加载操作。

  • 存储屏障(Store Barrier):

    确保在屏障之前的所有存储操作完成后,才开始执行屏障之后的存储操作。

  • 全屏障(Full Barrier):

    结合了加载屏障和存储屏障的功能,确保在屏障之前的所有加载和存储操作完成后,才开始执行屏障之后的加载和存储操作。

代码示例

以下是一个简单的代码示例,展示了如何使用内存屏障:

memory_barrier.cppview raw
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <atomic>

std::atomic<int> a{0};
std::atomic<int> b{0};

void thread1() {
a.store(1, std::memory_order_relaxed);
std::atomic_thread_fence(std::memory_order_seq_cst); // 全屏障
b.store(1, std::memory_order_relaxed);
}

void thread2() {
while (b.load(std::memory_order_relaxed) == 0);
std::atomic_thread_fence(std::memory_order_seq_cst); // 全屏障
assert(a.load(std::memory_order_relaxed) == 1);
}

在这个示例中,std::atomic_thread_fence 用于插入内存屏障,确保在屏障之前的存储操作完成后,才开始执行屏障之后的存储操作

内存序

内存序(Memory Order)描述了在多线程环境中,内存操作的顺序和可见性。它决定了一个线程对内存的修改何时以及如何对其他线程可见。C++11 引入了六种内存序,以便程序员在并发编程中根据需求选择合适的同步机制。

六种内存序

  • memory_order_relaxed:

    只保证当前操作的原子性,不考虑线程间的同步。适用于不需要同步的场景,如计数器的增加。

  • memory_order_consume:

    确保当前操作依赖的所有写操作在此操作之前完成。主要用于依赖关系较强的场景。

  • memory_order_acquire:

    确保在此操作之后的所有读写操作不会被重排序到此操作之前。常用于获取锁的操作。

  • memory_order_release:

    确保在此操作之前的所有读写操作不会被重排序到此操作之后。常用于释放锁的操作。

  • memory_order_acq_rel:

    结合了 acquire 和 release 的语义,确保在此操作之前的写操作不会被重排序到此操作之后,同时在此操作之后的读操作不会被重排序到此操作之前。适用于读-修改-写操作。

  • memory_order_seq_cst:

    顺序一致性,确保所有线程看到的内存操作顺序一致。这是最严格的内存序,适用于需要强一致性的场景。

代码示例

以下是一个简单的代码示例,展示了如何使用不同的内存序:

memory_order.cppview raw
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#include <atomic>
#include <thread>
#include <cassert>

std::atomic<int> data{0};
std::atomic<bool> ready{false};

void producer() {
data.store(42, std::memory_order_relaxed);
ready.store(true, std::memory_order_release);
}

void consumer() {
while (!ready.load(std::memory_order_acquire));
assert(data.load(std::memory_order_relaxed) == 42);
}

int main() {
std::thread t1(producer);
std::thread t2(consumer);
t1.join();
t2.join();
return 0;
}

在这个示例中,producer 线程使用 memory_order_release 来发布数据,而 consumer 线程使用 memory_order_acquire 来确保读取到的数据是最新的

TODO

TCL_Main

Tcl_Main的简化流程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void Tcl_Main(int argc, char *argv[], Tcl_AppInitProc *appInitProc) {
Tcl_Interp *interp;

// 创建一个新的 Tcl 解释器
interp = Tcl_CreateInterp();

// 调用应用程序初始化函数
if ((*appInitProc)(interp) != TCL_OK) {
fprintf(stderr, "Application initialization failed: %s\n", Tcl_GetStringResult(interp));
exit(1);
}

// 进入 Tcl 事件循环
Tcl_MainLoop();
}

花括号的用法

在Tcl中,花括号 {} 有多种用法,主要用于分组、延迟解析、创建列表和字典等。以下是花括号的主要用法及示例:

  1. 分组代码块
    花括号用于将一段代码分组,使其作为一个整体传递或执行。

    1
    2
    3
    4
    5
    if {[catch {myProc} msg]} {
    puts "An error occurred: $msg"
    } else {
    puts "myProc executed successfully"
    }
  2. 延迟解析
    花括号内的内容不会立即解析,直到需要时才会被解析。这在处理包含特殊字符的字符串时非常有用。

    1
    2
    set script {puts "Hello, World!"}
    eval $script ;# 输出 "Hello, World!"
    1
    2
    3
    if {[catch {myProc} msg]} {
    puts "An error occurred: $msg"
    }
  3. 创建列表
    花括号用于创建列表,列表中的元素可以包含空格或特殊字符。

    1
    2
    set myList {one two three}
    puts [lindex $myList 1] ;# 输出 "two"
  4. 创建字典
    花括号用于创建字典,字典中的键值对可以包含空格或特殊字符。

    1
    2
    set myDict {key1 value1 key2 value2}
    puts [dict get $myDict key1] ;# 输出 "value1"
  5. 多行字符串
    花括号用于创建多行字符串,字符串中的换行符和空格会被保留。

    1
    2
    3
    4
    5
    set multiLineString {
    This is a multi-line
    string in Tcl.
    }
    puts $multiLineString
  6. 保护特殊字符
    花括号用于保护特殊字符,使其不被解释为命令或变量。

    1
    2
    set specialChars {This is a {special} string with [brackets] and $dollar signs.}
    puts $specialChars
  7. 在控制结构中使用
    花括号用于控制结构(如 if、while、for 等)中的条件和代码块。

    1
    2
    3
    4
    5
    6
    set x 10
    if {$x > 5} {
    puts "x is greater than 5"
    } else {
    puts "x is 5 or less"
    }
  8. 定义过程
    花括号用于定义过程的参数和主体。

    1
    2
    3
    4
    proc greet {name} {
    puts "Hello, $name!"
    }
    greet "Tcl User" ;# 输出 "Hello, Tcl User!"

global 和 varaible

globalvariable 用于声明该变量来自全局还是当前命名空间。

注意:两者都是声明,不是定义。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
set i 20

namespace eval test {
;# variable i
;# i没有被事先声明为名字空间的变量,则会引用全局变量i
for { set i 1} { $i <=5} { incr i} {
puts -nonewline "i=$i; "
}
puts "\n"
;# 由于没有全局的j,所以j被默认视作命名空间内部变量,引用时必须加上命名空间为前缀
for {set j 1} { $j<=5} {incr j} {
puts -nonewline "j=$j; "
}
puts "\n"
}

puts $i
puts $test::j
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
;#注意i的定义处被注释了
#set i 20

namespace eval test {
;# 这里的 global 声明无效,因为没有在全局定义i
global i
;# 此处的i仍然是命令空间内部的
for { set i 1} { $i <=5} { incr i} {
puts -nonewline "i=$i; "
}
}

;# 错误!
puts $i

;# 正确
puts $test::i