0%

概念

Core Bound 指 CPU 核心计算能力受限。包括两种:

  1. 硬件的计算资源不足(吞吐量受限)
  • port 冲突:例如 Intel Skylake 架构上,除法和平方根运算都会被分配到 port 0 上,如果大量的类似耗时操
    作排队,那么就会体现为硬件算力的限制。

Port 指的是 CPU 内部的 执行端口(execution ports)。

现代 CPU(尤其是 Intel、AMD 的 x86-64 架构)采用 超标量、乱序执行,有多个功能单元可以并行执行不同
类型的指令。

每个功能单元挂在一个 端口 (port) 上,负责特定类型的操作,比如:

  • 整数运算端口(加减、逻辑运算)
  • 浮点运算端口(乘法、除法、加法)
  • 加载端口(从内存读取数据)
  • 存储端口(写数据到内存)

CPU 的调度器会把指令分配到合适的端口执行。

如果某类端口资源不足,就会出现 port bound(端口受限),性能瓶颈来自于某个端口的拥塞。

👉 举例: Intel Skylake 架构有 8 个端口:

  • Port 0/1:整数和浮点运算
  • Port 2/3:加载(Load)
  • Port 4:存储地址计算
  • Port 5:存储数据
  • Port 6:分支预测
  • Port 7:整数运算
  1. 指令间的依赖(增加延迟)

例如链表的遍历,CPU 无法对其并行。

1
2
3
while (n) {
n = n->next;
}

优化方法

第 1 种计算能力受限的问题,最好的办法是升级 CPU,换成具有更多除数的型号,或者将计算任务卸载到加速器
上。

第 2 种数据依赖链的问题,可能需要重写算法。下面介绍一些有名的优化方法:

  1. 向量化
  2. 函数 inline 化
  3. 循环转换
  4. 编译器内建函数
  5. 其他

目的是减少执行的指令或用更好的汇编指令替代。

CPU 硬件层次概念

  • Package → 整个处理器封装,包含一个或多个 die。
  • Socket (S) → 主板上的物理 CPU 插槽
  • Die (D) → 封装里的裸片(可能有多个 chiplet/die)
  • Core (C) → die 上的计算核心
  • Thread (T) → 核心里的硬件线程(SMT/超线程),即逻辑处理器

假设一台双路服务器,每个 socket 上的 CPU 封装里有两个 die,每个 die 有 8 个核心:

  • S0-D0-C0 → Socket 0 上 Die 0 的 Core 0
  • S0-D1-C3 → Socket 0 上 Die 1 的 Core 3
  • S1-D0-C7 → Socket 1 上 Die 0 的 Core 7

如果开启超线程,还可能进一步细分为:

  • S0-D0-C0-T0 → Socket 0, Die 0, Core 0, Thread 0
  • S0-D0-C0-T1 → Socket 0, Die 0, Core 0, Thread 1

可以用命令 lscpucat /proc/cpuinfo 来查看逻辑 CPU ID 与 Socket/Core/Thread 的对应关系。

1
2
3
4
5
6
$ lscpu
...
Thread(s) per core: 2
Core(s) per socket: 4
Socket(s): 1
...
1
总逻辑 CPU 数量 = 1 × 4 × 2 = 8

nproc 输出的是 逻辑 CPU 数

1
2
$ nproc
8

CPU 微架构概念

  • Pipeline:指令执行的分阶段过程(取指、解码、执行、写回)。
  • Width:每周期最多能发射多少条 uops(如 4‑wide)。
  • Slot:每周期的发射机会,宽度决定 slot 数。
  • ROB (Reorder Buffer):乱序执行的关键结构,保证指令按程序顺序退休。
  • Scheduler:调度器,决定哪些 uops 在某周期进入执行端口。
  • Execution Ports:后端的执行单元入口,比如整数 ALU、浮点 FPU、Load/Store。
  • Branch Predictor:预测分支走向,减少流水线停顿。

存储与层次结构

  • Registers:CPU 内部的寄存器,最快的存储。
  • Cache:分层缓存(L1、L2、L3),用于减少访存延迟。
  • LLC (Last Level Cache):最后一级缓存,通常是 L3,多个核心共享。
  • Memory Controller:负责和 DRAM 通信。
  • NUMA (Non-Uniform Memory Access):多 socket 系统里,内存访问延迟因位置不同而不同。

性能分析相关

  • IPC (Instructions Per Cycle):每周期平均执行的指令数。
  • ILP (Instruction-Level Parallelism):指令级并行度,程序能提供多少独立指令。
  • Topdown Metrics:Retiring、Bad Speculation、Frontend Bound、Backend Bound。
  • BE/Core vs BE/Mem:后端瓶颈是算力不足还是访存延迟。
  • CPI (Cycles Per Instruction):每条指令平均耗费的周期数。

Top-Down Microarchitecture Analysis (TMA)

这是 Intel 提出的一个 CPU 性能瓶颈分析框架。它的核心思想是:把 CPU 每个周期的 发射机会(slot) 分门
别类,逐层细分,最终定位到性能瓶颈的根源。

Topdown 是分层树状结构:

  • Level 1:Retiring / Bad Speculation / Frontend Bound / Backend Bound
  • Level 2:Backend Bound → BE/Core、BE/Mem
  • Level 3:BE/Mem → L1 Bound、L2 Bound、DRAM Bound
  1. Level 1: 顶层四大分类

在每个周期的 slot 中,CPU 的工作被划分为四类:

  • Retiring

    • 指令成功退休(完成执行并写回结果)。
    • 这是“有用工作”,比例越高说明 CPU 利用率越好。
  • Bad Speculation

    • 由于错误预测(如分支预测失败、错误路径执行)导致的浪费。
    • 这些 slot 最终没有产生有效结果。
  • Frontend Bound (FE)

    • 前端受限:取指、解码、指令缓存不足。
    • CPU 等待指令进入流水线。
  • Backend Bound (BE)

    • 后端受限:执行单元或数据不可用。
    • CPU 等待算力资源或内存数据。

这四类加起来 ≈ 100%,覆盖了所有 slot 的去向。

  1. Level 2: Backend 的进一步细分
  • BE/Core
    • 后端瓶颈主要来自核心执行资源不足(算术逻辑单元、浮点单元、端口冲突)。
    • 程序算力密集。
  • BE/Mem
    • 后端瓶颈主要来自访存延迟(缓存未命中、DRAM 访问慢)。
    • 程序内存密集。
  1. Level 3: BE/Mem 的进一步细分
  • L1 Bound
  • L2 Bound
  • L3 Bound
  • DRAM Bound

Roofline

性能分析工具

  1. perf (Linux)
  2. Intel® pmu-tools :对 perf 的封装
  3. Intel Advisor

参考

  1. perf-ninja: 代码 +
    视频

relaxed

  • 问:load(relaxed) 看到的是不是最新值?
  • 答:是的。

经过多次测试(包括 TSAN),CAS(relaxed) 计数器都是正确的:

1
g++ -fsanitize=thread test_memory_order_relaxed.cpp -lpthread
test_memory_order_relaxed.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
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
#include <atomic>
#include <chrono>
#include <iostream>
#include <random>
#include <thread>
using namespace std;

constexpr int run_times = 10;
constexpr uint32_t num_threads = 10;
constexpr uint64_t increments_per_thread = 1024 * 1024 * 32;
constexpr uint64_t max_count = increments_per_thread * num_threads;

atomic<uint64_t> counter;

/** Random Delay in microseonds */
std::random_device rd;
thread_local std::mt19937 gen(rd());
thread_local std::uniform_int_distribution<> dis(0, 4095);

void unpredictableDelay(int extra = 0) {
if (dis(gen) == 0) {
this_thread::sleep_for(chrono::nanoseconds(2000 + extra));
}
}

/** thread function for counting */
void worker(int id) {
for (int i = 0; i < increments_per_thread; ++i) {
uint64_t old = counter.load(memory_order_relaxed);
// 如果 load(relaxed) 不能看到当前最新值
// 那么 CAS 就会加多次,最终结果会大于 max_count
while (old < max_count &&
!counter.compare_exchange_weak(old, old + 1, memory_order_relaxed)) {
// old is updated with the current value of counter
unpredictableDelay(dis(gen));
}
}
// cout << "Worker " << id << " done." << endl;
}

/** main function */
int main() {
for (int run = 0; run < run_times; ++run) {
counter.store(0, memory_order_relaxed);
cout << "Run " << run << ": ";
thread threads[num_threads];

for (int i = 0; i < num_threads; ++i) {
threads[i] = thread(worker, i);
}

for (int i = 0; i < num_threads; ++i) {
threads[i].join();
}

cout << (max_count == counter.load() ? "Correct" : "Wrong") << endl;
}

return 0;
}

RDMA vs TCP/IP 连接标识字段

字段名称 全称 作用说明
GID Global Identifier 标识设备,类似于 IP 地址,128 位;RoCEv2 中为 IPv6 映射地址,InfiniBand 中由设备 GUID + 路由信息生成
QPN Queue Pair Number 标识会话,每个 RDMA 会话使用一个 QP,QPN 是其唯一编号,类似于 TCP 的端口号
PSN Packet Sequence Number 初始化连接时使用,用于数据包排序和连接建立,类似于 TCP 的序列号
LID Local Identifier(可选) InfiniBand fabric 中的本地路由地址,类似于交换机端口编号,仅在 InfiniBand 中使用

InfiniBand 的连接建立流程(RC 模式)

阶段 状态 含义 关键配置项
1️⃣ INIT IBV_QPS_INIT 初始化 QP 本地端口、访问权限、P_Key 等
2️⃣ RTR IBV_QPS_RTR Ready to Receive 远端 QPN、LID/GID、PSN、MTU 等
3️⃣ RTS IBV_QPS_RTS Ready to Send 本地 PSN、重试参数、超时设置等

类比 TCP 的三次握手。

为什么不像 TCP 那样自动握手? InfiniBand 的设计目标是:

  • 极低延迟:避免协议协商,直接由应用控制连接建立。
  • 高吞吐:硬件直接处理数据流,减少内核干预。
  • 可预测性强:状态转换明确,调试和优化更容易。
  • 适用于受控环境:如 HPC、AI 训练、数据库加速,通常用于 HPC 或数据中心,不像 TCP 那样暴露在公网,不
    需要防止恶意连接。(所以 QPN 一般是用户指定,且常常是顺序的;这与 TCP 的初始序列号截然不同)

注:MTU 对比表

技术类型 支持的 MTU 值(字节) 说明
Ethernet(标准) 1500 最常见的默认值,适用于大多数网络设备
InfiniBand 256, 512, 1024, 2048, 4096 固定值,由硬件和驱动支持
RoCE (RDMA over Converged Ethernet) 通常 ≤ Ethernet MTU(如 1500 或 9000) 实际使用值需减去 RoCE 协议头和 CRC,常见为 1024 或 4096
iWARP 受 TCP/IP 栈限制,通常 ≤ 1500 依赖传统以太网 MTU,性能受限

InfiniBand 的 MTU 值由头文件 <infiniband/verbs.h> 定义:

1
2
3
4
5
6
7
enum ibv_mtu {
IBV_MTU_256 = 1,
IBV_MTU_512 = 2,
IBV_MTU_1024 = 3,
IBV_MTU_2048 = 4,
IBV_MTU_4096 = 5
};

步骤

源码

resources_create

  1. TCP 建立连接 → 保存到 res.sock
  2. ibv_get_device_list
  3. ibv_get_device_name → 保存到 res.dev_name
  4. ibv_open_device → 保存到 res.ib_ctx
  5. ibv_query_port → 保存到 res.ib_port, res.port_attr
  6. ibv_alloc_pd → 保存到 res.pd
  7. ibv_create_cq → 保存到 res.cq
  8. malloc → 保存到 res.buf
  9. ibv_reg_mr → 保存到 res.mr
  10. ibv_create_qp → 保存到 res.qp

connect_qp

  1. 如果指定 gid_idx ,则以 ib_ctx 和 ib_port 调用 ibv_query_gid 查询本地的 gid;否则用 0 初始化
    gid。
  2. 填充 cm_con_data_t 结构体:addr(即 res.buf)、mr->rkeyqp->qp_numport_attr.lid
  3. 通过 TCP socket 交换 cm_con_data_t 的信息,将远端 cm_con_data_t 保存到 res.remote_props
  4. 调用 modify_qp_to_init(使用 ibv_modify_qp 修改 QP 的状态:RESET → INIT)
参数字段 作用描述 是否必须设置 设置原因
attr.qp_state = IBV_QPS_INIT 设置 QP 的目标状态为 INIT 明确告诉 HCA 要将 QP 转换到 INIT 状态
attr.port_num = config.ib_port 指定使用的物理端口号 多端口设备必须指定端口,否则无法建立连接
attr.pkey_index = 0 设置 P_Key 索引 InfiniBand 使用 P_Key 进行分区管理,默认使用索引 0
attr.qp_access_flags = IBV_ACCESS_LOCAL_WRITE | IBV_ACCESS_REMOTE_READ | IBV_ACCESS_REMOTE_WRITE 设置本地和远程访问权限 决定远程节点是否能读写你的内存,必须在 INIT 状态设置
flags = IBV_QP_STATE | IBV_QP_PKEY_INDEX | IBV_QP_PORT | IBV_QP_ACCESS_FLAGS 指定哪些字段有效并应用到 QP 属性结构体中 ibv_modify_qp 需要知道哪些字段是有效的,否则不会应用这些设置

注:InfiniBand 的分区类似以太网的 VLAN

  • 同一分区内的节点可以互通
  • 不同分区之间默认无法通信
  • 分区成员可以通过默认分区与 Subnet Manager 通信(如 IO 节点)
  1. 客户端调用 post_receive
    1. 准备 scatter/gather entry:sge.addr, sge.length, seg.lkey
    2. 准备 receive work request: wr_id, next(多个 receive wr 可以组成一个链表,一起提交),
      sge_list(sge 数组,多个不同内存区域的 sge 可一起提交)。
    3. 调用 ibv_post_recv 提交 receive wr。
  2. modify_qp_to_rtr:修改 QP 状态为 Readay to Receive:INIT → RTR
字段/参数名 类型/值 说明
remote_qpn uint32_t 远端 QP 编号,用于建立连接。
dlid uint16_t Destination LID:远端设备的本地标识符,用于本地路由。d 表示 destination(目的地)。
dgid uint8_t* Destination GID:远端设备的全局标识符,用于全局寻址。d 表示 destination(目的地)。
attr.qp_state IBV_QPS_RTR 设置 QP 状态为 Ready to Receive。
attr.path_mtu IBV_MTU_256 设置路径最大传输单元为 256 字节。
attr.dest_qp_num remote_qpn 指定远端 QP 编号。
attr.rq_psn 0 接收队列的初始包序号。
attr.max_dest_rd_atomic 1 远端最多可处理的 RDMA Read/Atomic 请求数。
attr.min_rnr_timer 0x12 最小 RNR 重试等待时间(单位为 655.36 微秒)。
attr.ah_attr.is_global 0 或 1 是否启用全局路由(GID)。
attr.ah_attr.dlid dlid 设置远端 LID。
attr.ah_attr.sl 0 服务等级(Service Level)。
attr.ah_attr.src_path_bits 0 源路径位,通常为 0。
attr.ah_attr.port_num config.ib_port 本地端口号。
attr.ah_attr.grh.dgid dgid 设置远端 GID(如果启用全局路由)。
attr.ah_attr.grh.flow_label 0 GRH 流标签。
attr.ah_attr.grh.hop_limit 1 GRH 跳数限制。
attr.ah_attr.grh.sgid_index config.gid_idx 本地 GID 索引。
attr.ah_attr.grh.traffic_class 0 GRH 流量类别。
flags 多个宏组合 指定哪些属性字段有效。
ibv_modify_qp() 函数调用 执行 QP 状态修改操作。
  1. modify_qp_to_rts
参数字段 含义 说明
attr.qp_state = IBV_QPS_RTS 设置 QP 状态为 RTS 表示该 QP 已准备好发送数据,是连接建立的最后一步
attr.timeout = 0x12 超时时间 单位为 4.096μs,表示发送请求等待 ACK 的最大时间。0x12 ≈ 75μs
attr.retry_cnt = 6 重试次数 如果未收到 ACK,最多重试 6 次
attr.rnr_retry = 0 RNR 重试次数 对方未准备好接收时的重试次数。0 表示不重试(通常用于 UC 类型)
attr.sq_psn = 0 Send Queue 的初始 PSN PSN(Packet Sequence Number)用于包顺序控制,需与远端匹配
attr.max_rd_atomic = 1 最大 RDMA Read 请求数 表示本端最多允许一个未完成的 RDMA Read 请求
flags 标志位 指定哪些字段在 ibv_modify_qp() 中有效,必须与设置的字段匹配
  1. 用 TCP socket 同步交换 dummy 数据,以确保双方都进入 RTS 状态。

post_send

  1. 准备 scatter/gather entry: addr, length, lkey
  2. 准备 send work request: remote_addr, rkey, opcode, send_flags

IBV_SEND_SIGNALED 的作用

功能 说明
请求生成 CQE 告诉 HCA:这个发送请求完成后,请在 CQ 中生成一个完成事件。
用于异步通知 应用程序可以通过轮询或事件机制检测哪些请求完成了。
提高可控性 你可以选择只对关键请求设置该标志,减少 CQE 数量,降低开销。

性能考虑:每个 CQE 都会占用资源,频繁生成会增加 CQ 处理负担。

  • 周期性 Signaled WR:可以只对每 N 个请求设置 IBV_SEND_SIGNALED,比如每 64 个请求生成一个 CQE。
  • 通过检查这个 WC 的 wr_id,你可以间接推断前面的 N 个 WR 已经完成(因为 InfiniBand 保证顺序完成)。
  • 避免 CQ 溢出:如果 CQ 太小而你对每个请求都设置了 SIGNALED,可能导致 CQ 溢出(CQ overrun)。
  1. 调用 ibv_post_send

RNR 状态: Receiver Not Ready(接收方未准备好)

触发条件:

  • 当发送方发送消息时,接收方的接收队列中没有足够的接收请求(Receive Request)来处理该消息。
  • 接收方会返回一个 RNR NACK(Negative Acknowledgement) 给发送方,表示暂时无法接收。

发送方的处理流程:

  • 收到 RNR NACK 后,发送方会根据该 NACK 中指定的 RNR 重试等待时间(RNR Timer)进行等待。
  • 等待时间结束后,发送方会尝试重新发送消息。
  • 如果接收方在重试期间发布了新的接收请求,消息将被成功接收并返回 ACK。
  • 如果多次重试后仍未成功(超过最大 RNR 重试次数),发送方会报告一个 RNR 重试错误 的工作完成状态
    (Work Completion)。

poll_completion

ibv_poll_cq

resources_destroy

  • ibv_destroy_qp
  • ibv_dereg_mr
  • ibv_destroy_cq
  • ibv_dealloc_pd
  • ibv_close_device
  • close: socket

命令速查表

  • ib_Benchmark:性能测试工具
  • ibv_Verbs:编程与设备信息
  • ibFabric:网络拓扑与诊断
  • opensm管理器:子网控制核心

网络拓扑与状态诊断(ib 前缀)

命令 功能说明 推荐场景
ibstat 查看本地 HCA 的端口状态和速率 快速确认端口是否 LinkUp
iblinkinfo 显示整个网络的链路连接拓扑 检查交换机与节点连接情况
ibnetdiscover 构建子网拓扑图(基于 SM) 网络可视化、拓扑分析
ibdiagnet 深度诊断 InfiniBand 网络问题 性能瓶颈、错误帧分析
ibsysstat 查看系统级 InfiniBand 状态 多节点状态汇总

RDMA 编程与设备信息(ibv_ 前缀)

命令 功能说明 推荐场景
ibv_devinfo 查看本地 RDMA 网卡设备信息 网卡识别、端口状态确认
ibv_devices 列出所有支持 RDMA 的设备 多网卡环境初始化
ibv_rc_pingpong 使用 RC QP 测试点对点通信 RDMA 编程验证
ibv_srq_pingpong 使用共享接收队列测试通信 SRQ 场景验证
ibv_ud_pingpong 使用 UD QP 测试通信 多播或无连接场景测试

性能测试与基准评估(ib_ 前缀)

命令 功能说明 推荐场景
ib_send_bw 测试 RDMA 发送带宽 点对点性能评估
ib_read_bw 测试 RDMA 读带宽 存储访问场景评估
ib_write_bw 测试 RDMA 写带宽 写密集型应用测试
ib_send_lat 测试发送延迟 延迟敏感应用调优
ib_read_lat 测试读延迟 存储延迟分析
ib_write_lat 测试写延迟 写延迟分析
ib_pingpong 简单点对点通信测试(旧版) 快速连通性验证

管理与配置辅助工具

命令 功能说明 推荐场景
opensm 启动子网管理器(SM) 小规模集群或测试环境
perfquery 查询端口性能计数器 错误帧、丢包分析
saquery 查询子网代理信息 LID/GID 映射验证
sminfo 查看 SM 状态信息 SM 健康检查

物理硬件

  • HCA 可能有多个物理端口 (port):每个 port 有一个 LID 。
  • 每个 port 可能有多个通道 (lane) :用 1x, 2x, 4x, 8x 等表示。
  • 总速率:总速率 = 每个 port 的 lane 数 × 每个 lane 的速率 × port 数量
  • port 和 lane 是有物理硬件决定的。

部署

  • 通道是物理层的能力

    • 每个 InfiniBand 或 PCIe 接口由若干个 lane 组成,每个 lane 是一对差分信号线(发送 + 接收)。
    • 比如:
      • x1 表示 1 个 lane → 最低带宽
      • x4 表示 4 个 lane → 常见于 EDR(100G)
      • x8 表示 8 个 lane → HDR/NDR(200G/400G)
      • x12 是更高端的聚合方式 → 超大带宽(如 600G)
  • “部署”决定你能否用满这些通道

    • 如果你部署的是 x4 的 HCA + x4 的交换机 + x4 的线缆,你就能跑满 4 个 lane。
    • 如果你部署的是 x8 的 HCA,但只用了 x4 的线缆,你只能跑 4 个 lane,剩下的通道闲置。
    • 如果你部署的是 x12 的交换机,但 HCA 只有 x4,你也只能用其中一部分。

举个例子:HDR 200G 的部署选择

部署方式 通道数 实际带宽
HCA: x4 + Cable: x4 + Switch: x4 4 200 Gbps
HCA: x8 + Cable: x4 + Switch: x4 4 200 Gbps(x8 闲置一半)
HCA: x8 + Cable: x8 + Switch: x8 8 400 Gbps

只有在部署中三者都匹配时,才能真正跑满所有通道。

程序员关注的层面

层级 程序员是否需要控制
Lane(物理通道) ❌ 不需要
Port(物理接口) ✅ 可选(如多 port HCA)
QP(逻辑通信通道) ✅ 必须管理
Memory Region(MR) ✅ 必须注册
Completion Queue(CQ) ✅ 必须轮询或处理

带宽测试

理论带宽

1
lspci | grep -i infiniband

然后通过阅读产品说明书,确定理论带宽。

协商带宽

实际部署的速率,可能与 HCA 、线缆、交换机都相关,所以实际会有一个协商速率。

1
ibstat

实际带宽测试

发送速率:

1
2
3
4
5
# Server 端(等待连接)
ib_send_bw -d mlx5_0 -a

# Client 端(发起连接)
ib_send_bw -d mlx5_0 -a <server_ip>

令所有 C/C++ 程序员头疼的事,莫过于环境的搭建和兼容。这往往发生在链接阶段,分为两种:

  • 编译时链接
  • 运行时链接

读完本文,你将能解决大部分的环境问题。

运行时 so 的加载顺序

优先级 来源 说明
🔺 1 LD_PRELOAD 环境变量 强制优先加载指定库,最高优先级,可用于函数替换
🔺 2 rpath(编译时指定) 使用 -Wl,-rpath=… 编译时嵌入的路径,仅作用于当前程序(已废弃:因为优先级过高,且影响间接依赖项。推荐使用 runpath,没有这两个缺点。)
🔺 3 LD_LIBRARY_PATH 环境变量 运行时设置的库搜索路径,影响当前 shell 会话
🔺 4 runpath(编译时指定) 使用 -Wl,-rpath 设置的路径,但优先级低于 LD_LIBRARY_PATH
🔺 5 /etc/ld.so.cache 由 ldconfig 生成的缓存,包含 /etc/ld.so.conf 中的路径
🔺 6 默认系统路径 /lib, /usr/lib, /lib64, /usr/lib64 等标准目录

动态链接器(Dynamic Linker)

以上的 so 的加载顺序,是由 动态链接器(Dynamic Linker) 完成的。

每个 ELF 可执行文件在头部指定了它的动态链接器路径:

1
2
$ readelf -l your_program | grep interpreter
[Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]

这个链接器负责在程序启动时加载所需的动态库,包括 libc.so.6(glibc 的主库)。

注意:ld.so(这是一个简称)和 ld 完全不是一个东西,ld 是一个可执行程序,在编译期使用,见下
编译时链接器(ld)

  • ld.so 既是 so 也是可执行文件:
1
2
$ file /lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2: ELF 64-bit LSB shared object, x86-64, version 1 (GNU/Linux), dynamically linked, BuildID[sha1]=e4de036b19e4768e7591b596c4be9f9015f2d28a, stripped

普通程序的 ELF 文件中有一个 PT_INTERP 段,指定了这个链接器的路径。

1
2
3
4
5
$ file /usr/bin/ls
/usr/bin/ls: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=36b86f957a1be53733633d184c3a3354f3fc7b12, for GNU/Linux 3.2.0, stripped

$ file /lib/x86_64-linux-gnu/libc.so.6
/lib/x86_64-linux-gnu/libc.so.6: ELF 64-bit LSB shared object, x86-64, version 1 (GNU/Linux), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=cd410b710f0f094c6832edd95931006d883af48e, for GNU/Linux 3.2.0, stripped
  • 动态链接器 ld.so 本身也依赖共享库(如 libc.so.6),但它又是负责加载共享库的工具

动态链接器是一个“自举”程序:

  • 它是由 Linux 内核直接加载并执行,不依赖其他库来启动。
  • 它的启动代码是 静态编译的,也就是说,它的最小启动逻辑不依赖 libc.so.6。
  • 在启动后,它才会去加载 libc.so.6 和其他 .so 文件,完成符号解析和初始化。
  • 有趣的是,libc.so.6 又依赖 ld.so 来加载。
1
2
3
4
5
6
7
8
9
你运行 ./myapp

内核读取 ELF → 找到 PT_INTERP 段 → 加载 ld.so

ld.so 启动(靠自身静态代码)

ld.so 加载 libc.so.6、libstdc++.so.6 等共享库

ld.so 跳转到 myapp 的入口地址(main)

如果 libc.so.6 丢失怎么办?

有开发者分享过真实案例,当系统误删了 libc.so.6 ,几乎所有命令都无法运行。但你仍然可以:

1
/lib64/ld-linux-x86-64.so.2 /bin/ln -s /lib64/libc-2.33.so /lib64/libc.so.6

这利用了 ld.so 的自举能力,手动加载 libc 并执行命令,从而恢复系统。

ldd 检查

ldd (List Dynamic Dependencies) 是一个 shell 脚本,用于查看一个可执行文件或共享库所依赖的动态链接
库的命令。

它的思路是:模拟 运行时链接器 的行为。

  1. 设置环境变量来触发动态链接器 (ld-linux.so) 输出依赖信息,但是不真正执行程序。如:

    • LD_TRACE_LOADED_OBJECTS=1
    • LD_WARN, LD_BIND_NOW, LD_VERBOSE
  2. 调用动态链接器查找 so .

局限性:

  • 使用 dlopen() 动态加载库,ldd 是无法检测到的

补充工具

  • readelf -d binary | grep -i rpath:查看 ELF 文件中的 RPATH 或 RUNPATH
  • ldconfig -p:查看系统缓存中有哪些库
  • strace -e openat ./your_program:查看运行时实际打开了哪些库文件
  • LD_DEBUG=libs ./your_program:调试动态库加载过程

编译时 so 的查找顺序

GCC 查找 glibc 的路径顺序:

🧠 编译阶段(查找头文件)

优先级 路径来源 示例路径 说明
1️⃣ 显式指定 -I 参数 gcc -I/custom/include 用户手动指定,优先级最高
2️⃣ -isystem 指定系统头文件路径 gcc -isystem /custom/sysinclude 优先级高于默认路径但低于 -I
3️⃣ 环境变量 CPATH /opt/common/include 适用于所有语言,优先于默认路径
4️⃣ 环境变量 C_INCLUDE_PATH /opt/glibc-2.28/include 仅对 C 文件有效,优先于默认路径
5️⃣ 环境变量 CPLUS_INCLUDE_PATH /opt/cpp/include 仅对 C++ 文件有效,优先于默认路径
6️⃣ GCC 默认系统路径 /usr/include 最后兜底路径
7️⃣ 内核头文件路径(特殊场景) /usr/src/linux/include 编译内核模块或驱动时使用
  • -I-isystem 都是命令行参数,但 -isystem 会将路径标记为“系统路径”,避免某些警告。
  • 环境变量如 CPATHC_INCLUDE_PATH 是在没有显式参数时的补充手段。
  • 默认路径 /usr/include 是 GCC 安装时配置的,可以通过 gcc -xc -E -v - 查看完整搜索路径。

🔗 链接阶段(查找库文件)

优先级 来源类型 是否可覆盖 说明
1️⃣ 显式命令行参数 -L 用户指定的库路径,最高优先级
2️⃣ 链接器参数 -rpath-link 链接器查找间接依赖库时使用
3️⃣ 环境变量 LIBRARY_PATH 编译器查找库时使用,优先于默认路径
4️⃣ –sysroot 指定根路径 用于交叉编译,影响所有路径解析
5️⃣ GCC 安装路径默认搜索目录 来自 –prefix 和 –libdir 配置
6️⃣ specs 文件配置 GCC 内部配置,可自定义行为
7️⃣ 默认系统路径 /lib, /usr/lib, /lib64 最后兜底路径,系统环境提供的库

说明:

在编译安装 GCC 时,通常会使用 configure 脚本来指定安装路径:

1
./configure --prefix=/custom/gcc --libdir=/custom/gcc/lib64
参数 作用说明
–prefix 指定 GCC 的安装根目录。GCC 的可执行文件、头文件、库文件等都会安装到这个目录下的子目录中。
–libdir 指定库文件的安装目录,通常是 lib 或 lib64,用于放置 .so 或 .a 文件。

当你使用这个 GCC 编译器时,它会自动使用这些路径来查找头文件和库文件。例如:

  • 查找头文件时,会优先使用 /custom/gcc/include
  • 查找库文件时,会优先使用 /custom/gcc/lib64
  • 链接器(如 ld)也会被 GCC 告知去这些路径找库

这些路径是 编译器内部写死的默认值,你可以通过以下命令查看:

1
gcc -print-search-dirs

输出示例:

1
2
3
install: /custom/gcc/lib/gcc/x86_64-linux-gnu/12.2.0/
programs: /custom/gcc/libexec/gcc/x86_64-linux-gnu/12.2.0/:...
libraries: /custom/gcc/lib/gcc/x86_64-linux-gnu/12.2.0/:/custom/gcc/lib64/:..

gcc -v 会显示详细的编译和链接过程,包括实际使用了哪些路径和库文件。

1
gcc -v hello.c -o hello

使用 –sysroot 指定根路径

1
gcc --sysroot=/path/to/custom/root hello.c -o hello

编译时链接器(ld)

编译时链接器(ld) 和 运行时动态链接器 不是同一个东西:

特性 编译时链接器 (ld) 运行时动态链接器 (ld-linux.so)
执行时机 编译阶段 程序启动时
作用 生成可执行文件 加载 .so 库并绑定符号
由谁调用 编译器(如 gcc) 操作系统加载器
是否参与运行过程 ❌ 不参与 ✅ 参与

glibc

glibc 是特殊的 so ,它封装了系统调用,所以 Linux 系统本身也依赖它。

C 语言允许编译时链接和运行时链接分开,所以常常遇到这样奇怪的问题:

  • 在本机编译、链接,在本机不能运行:
1
./myapp: /lib/x86_64-linux-gnu/libc.so.6: version `GLIBC_2.33' not found (required by ./myapp)

这是因为 gcc 在编译时指定的 glibc 和系统运行时的 glibc 不一样:

1
2
3
4
5
6
7
8
9
$ strings /lib/x86_64-linux-gnu/libc.so.6 | grep GLIBC_
GLIBC_2.2.5
...
GLIBC_2.31

$ strings ./myapp | grep GLIBCXX_
GLIBC_2.3
...
GLIBCXX_2.33

/lib/x86_64-linux-gnu/libc.so.6 中确实没有 GLIBC_2.33 的符号。

如果你的系统中有多个 glibc ,那么这种问题可以通过上面“运行时 so 的加载顺序”,使用环境变量调整。

但是,这种对 glibc 的依赖可能是间接的:

1
2
3
4
5
6
7
8
9
10
$ ls /path/to/gcc/v14.2.0/lib64 | grep libstdc++.so
libstdc++.so
libstdc++.so.6

$ ldd /path/to/gcc/v14.2.0/lib64/libstdc++.so.6
linux-vdso.so.1 (0x00007ffd965f4000)
libm.so.6 => /usr/lib64/libm.so.6 (0x0000147a213a9000)
libc.so.6 => /usr/lib64/libc.so.6 (0x0000147a20fe4000)
/lib64/ld-linux-x86-64.so.2 (0x0000147a219ea000)
libgcc_s.so.1 => /path/to/gcc/v14.2.0/lib64/libgcc_s.so.1 (0x0000147a21be4000)

此时,如果你的编译和运行的机器不是同一台(意味着运行时 /usr/lib64/libc.so.6 文件和编译期不一致),这
时候就基本没有没有办法了。理论上,你可以下载一个与编译期相同的 libc.so.6 ,但是你无法保证系统的动态
链接器 /lib64/ld-linux-x86-64.so.2 与所下载的 libc.so.6 兼容。而 /lib64/ld-linux-x86-64.so.2
径是无法通过环境变量修改的,因为它是内核在程序启动时直接加载的,它的路径是硬编码在 ELF 可执行文件中
的。

所以最好的方式,是在编译时就确认 GCC 的 glibc 和系统运行时一致。

检查当前 GCC 使用的 glibc 路径

1
2
3
$ gcc --print-file-name=libc.so.6
$ g++ --print-file-name=libstdc++.so.6
$ gcc -print-search-dirs

示例:

1
2
$ /path/to/gcc/v14.2.0/bin/g++ --print-file-name=libstdc++.so.6
/path/toData/gcc-v14.2.0/x86_64/lib64/libstdc++.so.6

确认当前系统的 glibc 和 libstdc++ 版本

✅ 查看 glibc 版本(即 libc.so)

1
2
3
ldd --version
# 或者
getconf GNU_LIBC_VERSION

✅ 查看 libstdc++ 版本(C++ 标准库)

1
strings /usr/lib*/libstdc++.so.6 | grep GLIBCXX

你会看到类似:

1
2
GLIBCXX_3.4.21
GLIBCXX_3.4.26

这些是你系统支持的 C++ ABI 版本。

系统默认路径

由 /etc/ld.so.conf 和 ldconfig 决定

通常包括:

  • /lib64/
  • /usr/lib64/
  • /lib/x86_64-linux-gnu/(Debian/Ubuntu)

你可以运行:

1
ldconfig -p | grep libc.so.6

来查看系统当前可用的 libc.so.6 版本和路径。

Hazard Pointers(危险指针)

✅ 作用:保护正在访问的指针,防止它被其他线程释放。

每个线程维护一组“hazard slots”,用于声明当前正在使用的节点。

在释放节点前,必须检查是否有线程将其标记为 hazard。如果有,就延迟释放。

📌 优点:精细控制:每个指针都可以独立保护。

快速释放:一旦没有线程标记该节点,就可以立即释放。

⚠️ 缺点:每个线程需要维护额外的指针集合。

实现复杂度较高,尤其在高并发场景下。

Epoch-Based Reclamation(基于世代的回收)

✅ 作用:将所有线程的操作划分到一个“epoch”(时间段)中。

每个线程在进入临界区时记录当前 epoch,退出时清除。

被删除的节点会挂到当前 epoch 的垃圾列表中,只有当所有线程都离开该 epoch,才安全释放这些节点。

📌 优点:实现简单,性能好,适合高吞吐量场景。

不需要每个指针都单独保护。

⚠️ 缺点:回收延迟:必须等待所有线程退出当前 epoch。

对长时间运行的线程敏感,可能阻止垃圾回收。

🧠 为什么需要它们?

在 lock-free 数据结构中,节点可能被多个线程同时访问或修改。没有互斥锁保护时,直接释放内存可能导致:

  • 悬挂指针:其他线程还在访问被释放的节点。
  • ABA 问题:节点被释放后地址复用,导致逻辑错误。
ebr.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
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
// EBR: Epoch-Based Reclamation
#include <atomic>
#include <iostream>
#include <mutex>
#include <thread>
#include <unordered_map>
#include <vector>

constexpr int MAX_THREADS = 4;

std::atomic<int> global_epoch{0};
std::atomic<bool> active[MAX_THREADS];
std::atomic<int> local_epoch[MAX_THREADS];

std::mutex retire_mutex;
std::unordered_map<int, std::vector<int*>> retire_list[3]; // 每个 epoch 的垃圾列表

void enter_critical(int tid) {
active[tid].store(true, std::memory_order_relaxed);
local_epoch[tid].store(global_epoch.load(std::memory_order_relaxed),
std::memory_order_relaxed);
}

void exit_critical(int tid) {
active[tid].store(false, std::memory_order_relaxed);
}

void retire_node(int tid, int* node) {
std::lock_guard<std::mutex> lock(retire_mutex);
int epoch = global_epoch.load(std::memory_order_relaxed);
retire_list[epoch][tid].push_back(node);
}

void try_advance_epoch() {
int current = global_epoch.load(std::memory_order_relaxed);
for (int i = 0; i < MAX_THREADS; ++i) {
if (active[i].load(std::memory_order_relaxed) &&
local_epoch[i].load(std::memory_order_relaxed) != current) {
return; // 有线程还在旧 epoch,不能推进
}
}

int next_epoch = (current + 1) % 3;
global_epoch.store(next_epoch, std::memory_order_relaxed);

// 回收两代前的垃圾
int reclaim_epoch = (next_epoch + 1) % 3;
std::lock_guard<std::mutex> lock(retire_mutex);
for (auto& [tid, nodes] : retire_list[reclaim_epoch]) {
for (auto node : nodes) {
delete node;
}
nodes.clear();
}
}

前言

源码:https://github.com/cameron314/concurrentqueue

原文
Solving the ABA Problem for Lock-Free Free Lists

共识

  • CAS 足以保证线程安全;
  • 我们要做的是消除 CAS 的 ABA 问题。

常见的解决方案

Tagged / versioned 指针

每次修改 head 的同时也变更一个版本号(tag/version),把 (pointer, version) 当作 CAS 的对象。即使指针
回到同一个 A,只要版本号不同,CAS 就会失败,防止 ABA。

缺点包括:在某些机器上需要双字宽度(两个 word)CAS,如果 硬件 / 编译器 / 平台 不支持就只能 模拟 / 锁
住;或者要压缩 pointer 或 tag 大小,可能限制可管理的节点数量 / 地址空间。

LL/SC(Load-Linked / Store-Conditional)原语

在支持 LL/SC 的架构上,它自然就可以阻止 ABA:因为 store-conditional 会检测在 load 和 store 之间地址
是否被“写过”,哪怕写了后来改回原来的值也不行。缺点是很多主流架构(尤其 x86)不支持或者支持有限。

作者的方法:以引用计数+“should be on freelist”标志(标志 + 引用计数)

作者提出了一个适用于 free list 的通用方法来避免 ABA,且保持 lock‐free 特性。

提示:该代码应该从 try_get() 开始阅读,然后回到 add() ,这样才能理解设计引用计数的意图,否则很容
易迷惑。

  • 引用计数是状态标志:
    • 表示当前有多少线程正在操作节点;
    • 空闲链表本身也持有一个对节点的引用计数
  • 任何线程尝试摘取空闲链表的节点(总是从头部开始)时(try_get()):
    • 必须先增加 head 的引用计数(不同线程可能并发地将节点的引用计数增加到某个值);→ (3)
    • next = head->next;
    • 然后 CAS 竞争,尝试将 freeListHead 调整为 next;
      • 如果 CAS 竞争成功,则成功将 freeListHead 调整为 next,该节点被摘下;引用计数减 2(自己增加 的
        引用计数 + 链表本身持有的引用计数),我们称该线程为线程 1。→ (1)
      • 如果 CAS 竞争失败,则回退:将自己所增加的引用计数减去;用 CAS 返回的新的 head 重试(下一次获取
        新的 head 节点),这是线程 2;→ (2)
      • 注意:这两步之间以及每一步自身内,对引用计数的操作都有中间状态,但是此时引用计数必然大于 0 (
        重点! )。
  • ABA 问题:如果成功摘取节点的线程 1完成节点的使用,将节点又添加回空闲链表(add()):
    • 它会发现某个线程正在第 (2) 步的回退过程中(还没有将自身增加的引用计数减回去),所以此时引用计
      数还是大于 0(也就是说线程 1使用该节点的整个过程,该节点的引用计数都是大于 0 的,但是不影响)
    • 此时它可以选择自旋地等待引用计数降为 0 (即所有之前在 try_get() 中 CAS 竞争失败的线程(
      线程 2)回退成功)。因为只有引用计数降为 0 了,才能说明自己是唯一一个尝试在链表上操作该节点
      的线程。但是这从技术上说,就是一个 lock。
    • 作者设计了一个更聪明的办法:add() 设置一个 SHOULD_BE_ON_FREELIST 标志,然后直接放弃
      add()的任务已经完成了)。
    • 让处于回退中的 线程 2,在回退结束的时候,检查 SHOULD_BE_ON_FREELIST 标志,如果有该标志,并且
      自己就是最后一个操作该节点的线程的话(即此时的引用计数已经降为 0 了),就帮助 add() 把该节点添
      加到 free list。

引用计数

现在我们回过头来看引用计数的作用:

  • 如果 refs > 0

    • (1) 要么该节点在链表上;
    • (2) 要么有线程正在操作该节点(准确来讲,是在 try_get());
    • (3) 要么以上两种情况同时存在。
    • 注:(1)(2) 可以只存在一种,见上面的 ABA 问题的描述。
  • 如果 refs == 0

    • 节点已经被从空闲链表上取下;
    • 并且其他线程都已经从 try_get() 成功回退了。

如果第三个线程,与线程 1线程 2一起进入上面的 try_get() 竞争,但是当线程 3即将执行步
(3)时,发现 refs == 0,它就不能够再增加 head 的引用计数了,因为节点已经被成功取下。否则当它成
功增加引用计数,再去拿取 head->next 的时候,是未定义行为。

正是基于此,在步骤(3)增加引用计数的时候,我们需要判断 refs == 0 ? 和使用 CAS 增加引用计数,如果
不符合预期,则放弃增加,进入下一轮重试。

1
2
3
4
if ((refs & REFS_MASK) == 0 || !head->freeListRefs.compare_exchange_strong(refs, refs + 1, std::memory_order_acquire)) {
head = freeListHead.load(std::memory_order_acquire);
continue;
}

ABA 问题

在典型的无锁栈/队列里,指针会被反复复用(比如 pop 出再 push 回去)。如果只用 compare_exchange 比较指
针,那么:

  • T1 读到 A
  • T2 把 A → B → A 改一圈
  • T1 CAS 时看到还是 A,以为结构没变,但其实已经发生变化

这就是 ABA 问题。

解决方案

版本标记指针方案

‌ 核心思想 ‌:为每个指针附加一个版本号标记,每次修改时递增版本号。

‌ 实现要点 ‌:

  • 需要双字 CAS(DCAS)硬件支持
  • 每次修改头指针时递增标记值
  • 即使指针地址相同,标记值不同也会使 CAS 失败
  • 适用于支持 DCAS 的平台(如某些 ARM 架构)
  • 在非 DCAS 平台也可用,前提是将 data + version 放入一个机器字中,以维持操作的原子性

‌ 限制 ‌:

  • 在某些机器上需要双字宽度(两个 word)CAS;
  • 如果硬件/编译器/平台不支持就只能模拟/锁住;或者要压缩 pointer 或 tag 大小,可能限制可管理的节点数量/地址空间。

引用计数方案

‌ 核心思想 ‌:为每个节点维护引用计数,防止节点在被使用时被重新添加到列表。

‌ 关键实现细节 ‌:

每个节点包含 freeListRefs(引用计数)和 freeListNext(下一节点指针) 引用计数高位用作 “应返回自由列表”
标志位 try_get()操作前确保引用计数不为零 add()操作使用原子操作管理引用计数和标志位 ‌ 优势 ‌:

完全通用的解决方案,不依赖特定硬件特性保持真正的锁无关性质正确处理并发场景下的各种竞争条件

Hazard Pointer(危险指针)

思路:每个线程在访问共享指针之前,把自己正在访问的指针写到一个全局可见的“hazard pointer”里。

作用:其他线程在想要回收这个节点内存时,必须检查所有线程的 hazard pointers,如果发现有人还在用这个节
点,就不能释放。

优点:简单直接,内存可以安全回收。

缺点:维护 hazard pointers 有一定开销,每次回收都要检查所有线程。

Epoch-Based Reclamation(基于世代的回收,简称 Epoch GC)

思路:把时间切分成 epoch(世代)。线程进入临界区时声明自己在某个 epoch。当一个节点被删除后,先放到一
个“延迟回收队列”,等到所有线程都离开这个 epoch 之后,才能真正释放这些节点。

作用:保证没有线程会在旧 epoch 中访问到已经释放的节点。

优点:比 hazard pointer 更高效(不用逐个检查指针)。

缺点:需要所有线程都周期性地报告自己活跃的 epoch,否则内存可能迟迟回收不了。

🚩 为什么会和 ABA 有关?

像 Michael-Scott 队列这种链表结构,节点被 pop 出队后地址可能被重用。如果没有安全的内存回收,另一个线
程可能 CAS 成功指向了一个“已经被释放并重用的地址”,这就是 ABA 的根源。所以 hazard pointer 或 epoch
GC 是在链表队列里用来避免这种 悬空引用 + ABA 的。

而 moodycamel:: ConcurrentQueue 因为用的是 环形 buffer + sequence number,节点不会反复 malloc/free,
所以根本就不需要 hazard pointer 或 epoch GC。

TBB 的特殊性

本文的法则是以线程为调度单位的。

如果你使用的是 TBB ,那么请将 “线程” 对应为 “任务”,而将本文的“任务” 对应为“载荷”( payloads)。

因为 TBB 是以任务为调度单位的:

  • 每个 “任务” 是并发运行的最小单位,必须保证数据独立或线程安全。
  • TBB 采用 “任务窃取” 算法来保证线程的复杂均衡,所以若干任务可能被同一线程或不同线程运行。

经验法则

以 tasksPerThread 按需分配线程

  • tasksPerThread:均匀性。如果每个线程分配的任务数不均匀,那么任务数最多的线程就会成为瓶颈。
  • 按需分配线程:如果任务可以很快完成,那么没有必要开启过多的线程,否则调度开销也不可小觑。

以空间换时间

为了任务能并发运行,进行任务分隔时,必须尽可能减少数据共享。
所以不要吝惜空间,为每个任务单独开辟内存(不论是为输入还是输出目的)都是值得且必要的。

长临界区使用 lock + 条件变量

如果一个任务的临界区比较大,意味着该任务执行时,其他线程在短时间内无法进入临界区。
与其让这些线程忙等,不如释放 CPU 进入阻塞 / 休眠。