楚天

惟楚有材,于斯为盛

constexpr、consteval 与编译期计算实践

时间:2026/05/08

关键词:constexprconstevalconstinitstatic_assertif constexpr、编译期校验
核心目标:掌握现代 C++ 里“能在编译期算清楚的,就不要拖到运行期”的实用写法。


1. 编译期计算解决什么问题

有些值在编译时就已经确定:

  • 数组大小
  • 协议字段长度
  • 哈希表种子
  • 配置上限
  • 类型分支
  • 查表数据
  • 模板泛型里的策略选择

如果能在编译期完成,就能得到几个收益:

  1. 运行期少做重复计算
  2. 错误更早暴露
  3. 常量能进入类型系统
  4. 优化器更容易生成好代码

但也不要把所有东西都搬到编译期。
编译期计算会增加编译时间,也会让错误信息变复杂。


2. constexpr 变量

constexpr 变量必须能在编译期初始化:

1
2
3
4
constexpr int max_clients = 1024;
constexpr double pi = 3.141592653589793;

std::array<int, max_clients> counters{};

const 的区别:

1
2
const int runtime_value = read_config(); // 运行期常量
constexpr int compile_value = 42; // 编译期常量

const 只表示之后不能改。
constexpr 还要求初始化结果能作为编译期常量使用。


3. constexpr 函数:既能编译期,也能运行期

1
2
3
4
5
6
7
8
9
constexpr int square(int x) {
return x * x;
}

static_assert(square(5) == 25);

int runtime(int x) {
return square(x); // x 运行期才知道,也可以调用
}

constexpr 函数不是“只能编译期调用”。
它的意思是:

如果参数在编译期已知,并且函数满足规则,就可以在编译期求值。


4. 编译期校验:static_assert

static_assert 适合把约束写在代码里:

1
2
3
4
constexpr std::size_t packet_header_size = 8;

static_assert(packet_header_size % 4 == 0,
"packet header must be 4-byte aligned");

模板里更常见:

1
2
3
4
5
6
7
template <class T>
void serialize(const T& value) {
static_assert(std::is_trivially_copyable_v<T>,
"serialize requires trivially copyable type");

write_bytes(&value, sizeof(T));
}

错误会在编译期出现,而不是等到线上数据坏掉。


5. 一个实用例子:编译期单位换算

1
2
3
4
5
6
7
8
9
#include <chrono>

constexpr std::chrono::milliseconds frame_time(int fps) {
return std::chrono::milliseconds(1000 / fps);
}

static_assert(frame_time(60).count() == 16);

constexpr auto tick = frame_time(50);

这类小函数比魔法数字更清晰:

1
2
constexpr auto network_timeout = std::chrono::seconds(5);
constexpr auto render_budget = std::chrono::milliseconds(16);

6. constexpr 容器和查表

C++20 之后,很多标准库类型的 constexpr 能力更强。
实际工程里最常见的是用 std::array 做编译期表:

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <array>

constexpr std::array<int, 10> make_squares() {
std::array<int, 10> out{};
for (std::size_t i = 0; i < out.size(); ++i) {
out[i] = static_cast<int>(i * i);
}
return out;
}

constexpr auto squares = make_squares();

static_assert(squares[4] == 16);

这种写法适合:

  • 小型查表
  • 编码映射
  • 固定协议表
  • 编译期测试数据

7. consteval:必须编译期执行

consteval 函数被称为立即函数。
它必须在编译期求值。

1
2
3
4
5
6
7
8
consteval int port_literal(int port) {
if (port <= 0 || port > 65535) {
throw "invalid port";
}
return port;
}

constexpr int http_port = port_literal(80);

如果这样写:

1
2
int p = read_port();
int x = port_literal(p); // 错:p 不是编译期常量

会编译失败。

适合 consteval 的场景:

  • 编译期字面量校验
  • 生成强类型常量
  • 只允许编译期构造的描述符
  • 防止运行期误用

8. consteval 做字符串校验

例如要求日志分类名非空且不超过长度:

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <string_view>

consteval std::string_view category(std::string_view name) {
if (name.empty()) {
throw "empty category";
}
if (name.size() > 16) {
throw "category too long";
}
return name;
}

constexpr auto net = category("network");

如果写成:

1
constexpr auto bad = category("");

编译期就能报错。
这种写法适合把约束提前到编译阶段。


9. constinit:保证静态对象静态初始化

constinit 用于静态存储期变量,保证它不会发生动态初始化。

1
constinit int global_counter = 0;

它不是 const

1
2
3
4
5
constinit int value = 1;

void f() {
++value; // 可以修改
}

但初始化必须是编译期可完成的:

1
2
3
int read_config();

constinit int x = read_config(); // 错:不能动态初始化

适合:

  • 全局计数器
  • 静态状态
  • 需要避免静态初始化顺序问题的对象

注意:

  • constinit 保证初始化时机
  • constexpr 保证值是常量表达式并隐含 const
  • 两者关注点不同

10. if constexpr:编译期分支

泛型代码中,经常根据类型选择实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <type_traits>
#include <string>

template <class T>
std::string to_text(const T& value) {
if constexpr (std::is_integral_v<T>) {
return std::to_string(value);
} else if constexpr (std::is_floating_point_v<T>) {
return std::to_string(value);
} else {
return value.to_string();
}
}

if constexpr 的未选分支不会实例化。
所以当 T 是整数时,编译器不会要求整数有 to_string() 成员函数。

这比以前用复杂 SFINAE 更容易读。


11. 编译期 hash:实用但要谨慎

一个简单的 FNV-1a 字符串 hash:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <cstdint>
#include <string_view>

constexpr std::uint32_t fnv1a(std::string_view s) {
std::uint32_t hash = 2166136261u;
for (char c : s) {
hash ^= static_cast<unsigned char>(c);
hash *= 16777619u;
}
return hash;
}

static_assert(fnv1a("GET") != fnv1a("POST"));

constexpr auto get_id = fnv1a("GET");

可以用于:

  • 协议命令 id
  • 资源名映射
  • switch 前的分类

但不要把 hash 当成绝对无冲突。
如果冲突会造成严重问题,必须保留字符串二次校验。


12. 编译期解析小配置

可以写一个非常小的编译期 parser:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <string_view>

consteval int parse_digit(char c) {
if (c < '0' || c > '9') {
throw "not a digit";
}
return c - '0';
}

consteval int parse_two_digits(std::string_view s) {
if (s.size() != 2) {
throw "expected two digits";
}
return parse_digit(s[0]) * 10 + parse_digit(s[1]);
}

constexpr int major = parse_two_digits("23");

这种代码适合非常小、非常固定的格式。
不要把复杂 JSON/YAML 解析器搬到编译期,除非真的有明确收益。


13. 编译期和运行期复用同一套逻辑

constexpr 函数的一个好处是可以复用:

1
2
3
4
5
6
7
8
9
constexpr bool is_power_of_two(std::size_t n) {
return n != 0 && (n & (n - 1)) == 0;
}

static_assert(is_power_of_two(64));

bool validate_buffer_size(std::size_t n) {
return is_power_of_two(n);
}

同一段逻辑:

  • 编译期检查常量
  • 运行期检查用户输入

这比维护两套实现更稳。


14. 类型级配置:用常量表达式控制模板

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
#include <array>
#include <cstddef>
#include <stdexcept>

template <std::size_t Capacity>
class FixedBuffer {
public:
static_assert(Capacity > 0);
static_assert(Capacity <= 4096);

constexpr std::size_t capacity() const noexcept {
return Capacity;
}

void push(char c) {
if (size_ == Capacity) {
throw std::runtime_error("buffer full");
}
data_[size_++] = c;
}

private:
std::array<char, Capacity> data_{};
std::size_t size_ = 0;
};

FixedBuffer<256> buffer;

Capacity 是类型的一部分。
FixedBuffer<128>FixedBuffer<256> 是不同类型。

适合:

  • 固定容量队列
  • 小缓冲
  • 协议字段
  • 数值维度

不适合运行期才知道大小的情况。


15. 编译期计算的常见限制

现代 C++ 的 constexpr 已经很强,但仍要注意:

  • 不能做不允许的运行期 I/O
  • 不能依赖运行期输入
  • 编译期异常只用于让求值失败,不是运行期异常机制
  • 编译期计算过重会拖慢编译
  • 错误信息可能比运行期更难读

判断标准:

如果某个值是稳定常量、约束明确、错误越早越好,就适合编译期。


16. 常见误区

16.1 constexpr 一定更快

不一定。
如果参数是运行期值,函数仍然运行期执行。

16.2 所有配置都放进模板参数

模板参数会制造更多类型和更多编译实例。
运行期配置就老实用运行期数据。

16.3 constinit 等于不可修改

不等于。
constinit 管初始化时机,const 管可修改性。

16.4 编译期 hash 可以完全替代字符串

不能。
hash 有冲突风险,重要路径要二次校验。

16.5 编译期越多越现代

不是。
工程里还要考虑编译时间、可读性和调试成本。


17. 一页总结

编译期计算最常用的工具:

  1. constexpr:能编译期算,也能运行期用
  2. consteval:必须编译期算
  3. constinit:保证静态变量静态初始化
  4. static_assert:编译期校验约束
  5. if constexpr:泛型代码里的编译期分支

一句话:

编译期计算的价值不是炫技,而是把稳定规则提前验证,把重复计算提前完成。

C++20 concepts 与泛型接口约束

时间:2026/05/08

关键词:concept、requires、泛型约束、std::integralstd::ranges、重载选择、错误信息
核心目标:用 concepts 把模板参数的要求写清楚,让泛型接口更像普通接口一样可读、可诊断。


1. concepts 解决什么问题

传统模板很强,但错误信息经常很难读。

1
2
3
4
template <class T>
auto add(T a, T b) {
return a + b;
}

如果传入不支持 + 的类型,错误可能出现在很深的模板实例化里。
concepts 的目标是把约束提前写出来:

1
2
3
4
5
6
7
#include <concepts>

template <class T>
requires std::integral<T>
T add(T a, T b) {
return a + b;
}

现在接口明确表示:

这里只接受整数类型。


2. 标准库已有 concepts

<concepts> 里有很多常用概念:

1
2
3
4
5
6
#include <concepts>

static_assert(std::integral<int>);
static_assert(std::floating_point<double>);
static_assert(std::same_as<int, int>);
static_assert(std::convertible_to<int, double>);

常见的有:

  • std::same_as<T, U>
  • std::derived_from<T, U>
  • std::convertible_to<T, U>
  • std::integral<T>
  • std::floating_point<T>
  • std::regular<T>
  • std::predicate<F, Args...>
  • std::invocable<F, Args...>

优先用标准库已有概念,别急着自己造。


3. 三种常见写法

3.1 requires 子句

1
2
3
4
5
template <class T>
requires std::integral<T>
T twice(T x) {
return x * 2;
}

3.2 约束模板参数

1
2
3
4
template <std::integral T>
T twice(T x) {
return x * 2;
}

3.3 约束 auto

1
2
3
std::integral auto twice(std::integral auto x) {
return x * 2;
}

工程里常见选择:

  • 简单函数:约束模板参数或约束 auto
  • 约束复杂:写 requires
  • 公共库接口:倾向写得更明确

4. 自定义 concept:从最小需求开始

假设你只要求类型能调用 .size() 并返回可转成 std::size_t 的值:

1
2
3
4
5
6
7
8
9
10
11
12
#include <concepts>
#include <cstddef>

template <class T>
concept Sized = requires(const T& x) {
{ x.size() } -> std::convertible_to<std::size_t>;
};

template <Sized T>
std::size_t length(const T& x) {
return static_cast<std::size_t>(x.size());
}

可以调用:

1
2
3
4
5
std::string s = "hello";
std::vector<int> v{1, 2, 3};

length(s);
length(v);

注意:concept 应该描述“你真正需要什么”,不要过度指定类型。


5. requires 表达式怎么读

1
2
3
4
5
6
7
8
#include <concepts>
#include <cstddef>
#include <functional>

template <class T>
concept Hashable = requires(T x) {
{ std::hash<T>{}(x) } -> std::convertible_to<std::size_t>;
};

含义:

  • 给定一个 T x
  • 表达式 std::hash<T>{}(x) 必须合法
  • 返回值必须能转成 std::size_t

更复杂一点:

1
2
3
4
5
6
7
#include <concepts>
#include <ostream>

template <class T>
concept Printable = requires(std::ostream& os, const T& value) {
{ os << value } -> std::same_as<std::ostream&>;
};

这个 concept 表示对象能被输出到流。


6. 用 concept 改善错误信息

没有约束的版本:

1
2
3
4
template <class T>
void dump(const T& value) {
std::cout << value << "\n";
}

约束后的版本:

1
2
3
4
5
6
7
8
9
10
11
12
#include <concepts>
#include <iostream>

template <class T>
concept StreamWritable = requires(std::ostream& os, const T& value) {
{ os << value } -> std::same_as<std::ostream&>;
};

template <StreamWritable T>
void dump(const T& value) {
std::cout << value << "\n";
}

当类型不满足条件时,编译器更容易告诉你:

1
T does not satisfy StreamWritable

这比在 operator<< 深处爆炸更友好。


7. concept 参与重载选择

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <concepts>
#include <iostream>

void print_value(std::integral auto x) {
std::cout << "integer: " << x << "\n";
}

void print_value(std::floating_point auto x) {
std::cout << "float: " << x << "\n";
}

void print_value(const std::string& s) {
std::cout << "string: " << s << "\n";
}

调用:

1
2
3
print_value(42);
print_value(3.14);
print_value(std::string("hello"));

这比写一堆 enable_if 更直观。


8. 约束函数对象:std::invocable

泛型算法经常接收回调。

1
2
3
4
5
6
7
8
9
10
11
#include <concepts>
#include <functional>
#include <vector>

template <class F>
requires std::invocable<F, int>
void repeat(int n, F f) {
for (int i = 0; i < n; ++i) {
std::invoke(f, i);
}
}

使用:

1
2
3
repeat(3, [](int i) {
std::cout << i << "\n";
});

如果还要求返回 bool

1
2
3
4
5
6
7
8
9
10
11
template <class F>
requires std::predicate<F, int>
int count_if_index(int n, F pred) {
int count = 0;
for (int i = 0; i < n; ++i) {
if (std::invoke(pred, i)) {
++count;
}
}
return count;
}

9. 约束 range:和 ranges 配合

<ranges> 里也有很多 concept:

1
2
3
4
5
6
7
8
9
10
#include <ranges>
#include <vector>
#include <iostream>

template <std::ranges::input_range R>
void print_all(const R& r) {
for (const auto& x : r) {
std::cout << x << "\n";
}
}

如果要求能随机访问:

1
2
3
4
template <std::ranges::random_access_range R>
auto middle(const R& r) {
return r[std::ranges::size(r) / 2];
}

如果还要求元素类型是整数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <concepts>
#include <ranges>

template <class R>
concept IntegralRange =
std::ranges::input_range<R> &&
std::integral<std::ranges::range_value_t<R>>;

template <IntegralRange R>
long long sum_integrals(const R& r) {
long long sum = 0;
for (auto x : r) {
sum += x;
}
return sum;
}

10. 避免过度约束

坏例子:

1
2
3
4
5
template <class T>
concept VectorInt = std::same_as<T, std::vector<int>>;

template <VectorInt T>
int sum(const T& xs);

这个接口只能接收 std::vector<int>
但你真正需要的可能只是“一段整数 range”。

更好的写法:

1
2
3
4
5
6
7
8
template <IntegralRange R>
long long sum(const R& xs) {
long long out = 0;
for (auto x : xs) {
out += x;
}
return out;
}

这样可以接收:

  • std::vector<int>
  • std::array<int, N>
  • std::span<int>
  • ranges view

约束应该贴近需求,而不是贴近你当前想到的实现类型。


11. concept 不是运行期检查

concept 在编译期检查类型能力:

1
2
3
4
template <std::integral T>
T divide(T a, T b) {
return a / b;
}

它保证 T 是整数,但不保证:

1
divide(10, 0); // 运行期仍然可能错误

所以概念约束不能替代运行期校验。


12. 用 concept 表达策略对象接口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <concepts>
#include <string_view>

struct Request {
std::string_view path;
};

struct Response {
int status = 200;
};

template <class H>
concept Handler = requires(H h, const Request& req) {
{ h.handle(req) } -> std::same_as<Response>;
};

template <Handler H>
Response dispatch(H& handler, const Request& req) {
return handler.handle(req);
}

实现一个 handler:

1
2
3
4
5
6
7
8
struct HealthHandler {
Response handle(const Request& req) {
if (req.path == "/health") {
return Response{200};
}
return Response{404};
}
};

这个接口没有强迫继承,也没有虚函数。
只要类型满足结构要求,就能用。

这就是静态多态的一种实践。


13. concepts 和多态的选择

用 concepts:

  • 编译期确定类型
  • 性能敏感
  • 希望内联
  • 模板库
  • 策略对象

用虚函数:

  • 运行期动态选择类型
  • ABI 边界更稳定
  • 插件式加载
  • 需要统一容器保存不同派生对象

两者不是谁取代谁。
它们分别服务于静态多态和动态多态。


14. 一个更完整的泛型算法例子

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
#include <concepts>
#include <cstddef>
#include <ranges>
#include <vector>

template <class T>
concept Number = std::integral<T> || std::floating_point<T>;

template <class R>
concept NumberRange =
std::ranges::input_range<R> &&
Number<std::ranges::range_value_t<R>>;

template <NumberRange R>
auto mean(const R& xs) {
using T = std::ranges::range_value_t<R>;

T sum{};
std::size_t count = 0;

for (const auto& x : xs) {
sum += x;
++count;
}

if (count == 0) {
return T{};
}

return sum / static_cast<T>(count);
}

使用:

1
2
std::vector<double> xs{1.0, 2.0, 3.0};
auto m = mean(xs);

这个例子里:

  • 算法不关心具体容器
  • 只要求输入是数字 range
  • 空 range 有定义好的行为

15. 常见误区

15.1 concepts 会让代码自动更快

不会。
它主要改善接口约束和错误诊断。性能仍取决于代码结构和优化器。

15.2 concept 越细越好

不一定。
过细会让接口僵硬,调用者很难满足。

15.3 concept 能替代单元测试

不能。
它只能检查类型能力,不能验证业务逻辑。

15.4 所有模板都必须加 concept

内部小模板如果很清楚,可以不加。
公共接口、错误难读的接口更值得加。

15.5 用 same_as 锁死具体类型

除非必须是这个类型,否则优先描述能力。


16. 一页总结

concepts 最重要的实践原则:

  1. 优先用标准库已有 concept
  2. 自定义 concept 描述最小必要能力
  3. 公共泛型接口值得加约束
  4. 不要用 concept 替代运行期检查
  5. 不要过度约束到具体容器类型
  6. concepts 适合静态多态,虚函数适合动态多态

一句话:

concept 是把模板的“隐含要求”变成“显式接口”的工具。

依赖管理与包管理:FetchContent、vcpkg、Conan

时间:2026/05/08

关键词:CMake、FetchContent、find_package、vcpkg manifest、Conan 2、版本固定、可复现构建
核心目标:理解 C++ 项目如何管理第三方库,避免“我机器上能编”的依赖混乱。


1. 为什么 C++ 依赖管理值得单独学

C++ 依赖管理比很多语言麻烦,常见原因是:

  • 编译器和标准库 ABI 会影响二进制兼容
  • Debug / Release 可能需要不同二进制
  • 静态库、动态库、头文件库混在一起
  • CMake target 传播 include path、宏、链接库
  • 不同平台依赖安装方式不同
  • 依赖版本不固定会导致构建不可复现

现代 C++ 工程通常围绕 CMake target 管依赖。
包管理工具的最终目的也是让你能写:

1
2
find_package(fmt CONFIG REQUIRED)
target_link_libraries(app PRIVATE fmt::fmt)

而不是到处手写 include path 和 library path。


2. 先分清三种依赖接入方式

2.1 系统已安装依赖

适合:

  • 系统库
  • 团队统一开发镜像
  • Linux 发行版包
1
2
find_package(OpenSSL REQUIRED)
target_link_libraries(app PRIVATE OpenSSL::SSL OpenSSL::Crypto)

优点:简单。
缺点:版本和平台差异容易失控。

2.2 CMake 拉源码

适合:

  • 小型依赖
  • 测试库
  • 纯 CMake 项目
  • 需要和主项目一起构建

典型工具:FetchContent

2.3 包管理器

适合:

  • 依赖较多
  • 跨平台
  • 需要固定版本
  • 需要预编译二进制或统一构建选项

常见工具:

  • vcpkg
  • Conan

3. CMake 依赖使用的核心:target

现代 CMake 里,依赖最好表现为 imported target:

1
2
3
4
find_package(fmt CONFIG REQUIRED)

add_executable(app main.cpp)
target_link_libraries(app PRIVATE fmt::fmt)

fmt::fmt 这个 target 里通常带着:

  • include 目录
  • 编译定义
  • 编译选项
  • 链接库
  • 传递依赖

不要写成:

1
2
3
include_directories(/usr/local/include)
link_directories(/usr/local/lib)
target_link_libraries(app PRIVATE fmt)

这种写法会污染全局,且不容易跨平台。


4. FetchContent:配置期拉取源码

FetchContent 适合把第三方源码在 CMake configure 阶段引入。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
cmake_minimum_required(VERSION 3.20)
project(demo LANGUAGES CXX)

include(FetchContent)

FetchContent_Declare(
fmt
GIT_REPOSITORY https://github.com/fmtlib/fmt.git
GIT_TAG 10.2.1
)

FetchContent_MakeAvailable(fmt)

add_executable(app main.cpp)
target_link_libraries(app PRIVATE fmt::fmt)

FetchContent_Declare() 记录依赖来源。
FetchContent_MakeAvailable() 让依赖可用,并尽量加入当前构建。


5. FetchContent 要固定版本

不要这样:

1
2
3
4
5
FetchContent_Declare(
fmt
GIT_REPOSITORY https://github.com/fmtlib/fmt.git
GIT_TAG master
)

master 会变,今天能编不代表明天能编。

更好的写法:

1
2
3
4
5
FetchContent_Declare(
fmt
GIT_REPOSITORY https://github.com/fmtlib/fmt.git
GIT_TAG 10.2.1
)

更严谨时用 commit hash:

1
2
3
4
5
FetchContent_Declare(
fmt
GIT_REPOSITORY https://github.com/fmtlib/fmt.git
GIT_TAG 0c9fce2ffefecfdce794e1859584e25877b7b592
)

原则:

依赖版本必须可复现,不要让构建悄悄追随远端分支。


6. FetchContent 适合和不适合什么

适合:

  • googletest
  • 小型头文件库
  • CMake 支持好的库
  • 项目内工具库

不太适合:

  • 依赖树很大的库
  • 需要复杂系统依赖的库
  • 多项目共享同一套二进制依赖
  • 构建时间很敏感的大工程

原因是 FetchContent 通常把依赖纳入当前构建,依赖多了以后配置和编译时间会变重。


7. vcpkg manifest 模式

vcpkg 推荐项目用 vcpkg.json 描述依赖。

1
2
3
4
5
6
7
8
{
"name": "demo",
"version-string": "0.1.0",
"dependencies": [
"fmt",
"nlohmann-json"
]
}

在包含 vcpkg.json 的项目目录中安装:

1
vcpkg install

CMake 配置时使用 vcpkg toolchain:

1
2
3
cmake -S . -B build \
-DCMAKE_TOOLCHAIN_FILE=/path/to/vcpkg/scripts/buildsystems/vcpkg.cmake
cmake --build build

CMakeLists:

1
2
3
4
5
6
7
8
cmake_minimum_required(VERSION 3.20)
project(demo LANGUAGES CXX)

find_package(fmt CONFIG REQUIRED)
find_package(nlohmann_json CONFIG REQUIRED)

add_executable(app main.cpp)
target_link_libraries(app PRIVATE fmt::fmt nlohmann_json::nlohmann_json)

8. vcpkg features

有些包提供 feature:

1
2
3
4
5
6
7
8
9
10
{
"name": "demo",
"version-string": "0.1.0",
"dependencies": [
{
"name": "boost",
"features": ["filesystem"]
}
]
}

feature 用来控制可选组件。
不要盲目打开所有 feature,否则依赖树会变大,构建时间也会变长。


9. vcpkg 版本固定和 registry

仅写:

1
2
3
{
"dependencies": ["fmt"]
}

不能完整表达“用哪一组端口版本”。
工程上更可复现的做法是配合 baseline 或自己的 registry。

vcpkg-configuration.json 可以指定 registry 和 baseline:

1
2
3
4
5
6
7
{
"default-registry": {
"kind": "git",
"repository": "https://github.com/microsoft/vcpkg",
"baseline": "7476f0d4e77d3333fbb249657df8251c28c4faae"
}
}

思路和锁文件类似:

不只记录“我要 fmt”,还要记录“从哪一版依赖索引解析 fmt”。


10. Conan 2:用配置生成 CMake 依赖文件

Conan 2 常见方式是用 conanfile.txtconanfile.py 描述依赖,并生成 CMake 所需文件。

一个简单 conanfile.txt

1
2
3
4
5
6
7
8
9
10
[requires]
fmt/10.2.1
nlohmann_json/3.11.3

[generators]
CMakeDeps
CMakeToolchain

[layout]
cmake_layout

安装依赖:

1
conan install . --build=missing -s build_type=Release

配置 CMake:

1
2
3
4
cmake -S . -B build/Release \
-DCMAKE_TOOLCHAIN_FILE=build/Release/generators/conan_toolchain.cmake \
-DCMAKE_BUILD_TYPE=Release
cmake --build build/Release

CMakeLists:

1
2
3
4
5
6
7
8
cmake_minimum_required(VERSION 3.20)
project(demo LANGUAGES CXX)

find_package(fmt CONFIG REQUIRED)
find_package(nlohmann_json CONFIG REQUIRED)

add_executable(app main.cpp)
target_link_libraries(app PRIVATE fmt::fmt nlohmann_json::nlohmann_json)

Conan 2 的 CMake 集成重点是:

  • CMakeToolchain 生成 toolchain 文件
  • CMakeDeps 生成 find_package() 能找到的配置文件
  • CMakeLists 本身尽量不感知 Conan

11. Conan 用 conanfile.py 表达更复杂逻辑

如果依赖需要条件判断、选项、打包,就用 conanfile.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
from conan import ConanFile
from conan.tools.cmake import CMakeToolchain, CMakeDeps, cmake_layout

class DemoRecipe(ConanFile):
settings = "os", "compiler", "build_type", "arch"
requires = (
"fmt/10.2.1",
"nlohmann_json/3.11.3",
)

def layout(self):
cmake_layout(self)

def generate(self):
deps = CMakeDeps(self)
deps.generate()

tc = CMakeToolchain(self)
tc.generate()

适合:

  • 条件依赖
  • 自定义选项
  • 需要发布包
  • cross build
  • tool requirements

12. FetchContent、vcpkg、Conan 怎么选

简单对比:

场景 更合适
小项目拉一个测试库 FetchContent
跨平台应用,依赖很多开源库 vcpkg
需要二进制包、私有包、复杂构建矩阵 Conan
公司内部 C++ 包生态 Conan 或私有 vcpkg registry
依赖是项目内源码模块 add_subdirectory

没有绝对答案。
最关键的是团队统一一种主路径,不要每个依赖一种接入方式。


13. 依赖封装:不要让第三方库扩散到所有文件

坏模式:

1
2
3
4
// 到处 include 第三方库头文件
#include <nlohmann/json.hpp>

void handle(const nlohmann::json& j);

更稳的边界:

1
2
3
4
5
6
7
// config.h
struct Config {
std::string host;
int port = 0;
};

Config parse_config(std::string_view text);
1
2
3
4
5
6
7
8
9
10
11
// config.cpp
#include "config.h"
#include <nlohmann/json.hpp>

Config parse_config(std::string_view text) {
auto j = nlohmann::json::parse(text);
return Config{
.host = j.at("host").get<std::string>(),
.port = j.at("port").get<int>(),
};
}

好处:

  • 第三方依赖集中在实现文件
  • 以后替换 JSON 库成本低
  • 编译依赖更少
  • 公共 API 更稳定

14. CMake target 封装第三方依赖

可以把第三方库包在自己的库 target 后面:

1
2
3
4
5
6
add_library(config config.cpp)
target_include_directories(config PUBLIC include)
target_link_libraries(config PRIVATE nlohmann_json::nlohmann_json)

add_executable(app main.cpp)
target_link_libraries(app PRIVATE config)

注意这里 JSON 库是 PRIVATE
如果 config 的 public header 没暴露 nlohmann::json,调用者就不需要知道它。

如果 public header 暴露了第三方类型:

1
2
3
#include <nlohmann/json.hpp>

nlohmann::json to_json(const Config&);

那 CMake 就必须:

1
target_link_libraries(config PUBLIC nlohmann_json::nlohmann_json)

依赖是否 PUBLIC,取决于它是否出现在你的公开接口里。


15. 版本策略

依赖版本管理的几个原则:

  1. 应用项目应固定版本
  2. 库项目应谨慎扩大版本范围
  3. 不要默认追随 master/main
  4. 升级依赖要有测试
  5. 安全更新要单独跟踪
  6. 记录每次升级原因和影响

示例升级记录:

1
2
3
4
fmt 10.1.1 -> 10.2.1
reason: fix compiler warning on Clang 17
checked: unit tests, sanitizer build, linux/macOS CI
impact: no public API change

依赖升级不是“顺手改一下版本号”,它是工程变更。


16. 私有库和内部包

团队内部库常见做法:

  • Git submodule
  • FetchContent 指向内部仓库
  • vcpkg custom registry
  • Conan remote
  • monorepo 里 add_subdirectory

选择时看:

  • 是否需要独立版本
  • 是否需要二进制缓存
  • 是否跨多个项目复用
  • 是否要支持多个编译器和平台
  • 是否需要访问控制

小团队可以先简单,别一开始就搭很重的平台。
但只要项目多起来,就要尽早统一依赖入口。


17. 一个推荐的 CMake 工程结构

1
2
3
4
5
6
7
8
9
10
demo/
CMakeLists.txt
vcpkg.json 或 conanfile.txt
include/
demo/config.h
src/
config.cpp
main.cpp
tests/
config_test.cpp

顶层 CMake:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
cmake_minimum_required(VERSION 3.20)
project(demo LANGUAGES CXX)

find_package(fmt CONFIG REQUIRED)
find_package(nlohmann_json CONFIG REQUIRED)

add_library(demo_config src/config.cpp)
target_include_directories(demo_config PUBLIC include)
target_link_libraries(demo_config
PRIVATE
nlohmann_json::nlohmann_json
)

add_executable(demo_app src/main.cpp)
target_link_libraries(demo_app
PRIVATE
demo_config
fmt::fmt
)

main.cpp:

1
2
3
4
5
6
7
#include <demo/config.h>
#include <fmt/format.h>

int main() {
auto cfg = parse_config(R"({"host":"127.0.0.1","port":8080})");
fmt::print("{}:{}\n", cfg.host, cfg.port);
}

18. 常见误区

18.1 依赖版本不固定

构建不可复现,问题会在未来某一天突然出现。

18.2 到处写 include path

现代 CMake 应该靠 target 传播依赖信息。

18.3 public header 暴露第三方类型

这会让第三方库变成你的 API 一部分,替换成本很高。

18.4 Debug 链接 Release 依赖

有些平台和库会出 ABI 或运行时问题。
要让包管理器按 build type 安装匹配依赖。

18.5 混用多套包管理器且没有边界

FetchContent、vcpkg、Conan 可以共存,但要有明确规则。
例如测试库用 FetchContent,第三方运行库统一用 vcpkg。


19. 一页总结

依赖管理的核心原则:

  1. CMake 里优先使用 target
  2. find_package() 就不要手写路径
  3. 版本要固定,构建要可复现
  4. 第三方类型尽量别扩散到公共 API
  5. 小依赖可用 FetchContent
  6. 跨平台应用可考虑 vcpkg
  7. 私有包和复杂构建矩阵可考虑 Conan

一句话:

依赖管理不是把库下载下来,而是让依赖版本、构建选项、链接方式和 API 边界都可控。


20. 参考资料

  1. CMake FetchContent
    https://cmake.org/cmake/help/latest/module/FetchContent.html

  2. vcpkg manifest mode
    https://learn.microsoft.com/en-us/vcpkg/concepts/manifest-mode

  3. Conan 2 CMake integration
    https://docs.conan.io/2/integrations/cmake.html

  4. Conan 2 CMakeDeps
    https://docs.conan.io/2/reference/tools/cmake/cmakedeps.html

常用标准库组件:format、chrono、filesystem 与 source_location

时间:2026/05/08

关键词:std::formatstd::chronostd::filesystemstd::source_locationstd::bit、随机数
核心目标:把现代 C++ 标准库里最常用于工程代码的组件串起来,减少手写工具函数和平台相关代码。


1. 为什么要单独整理这些组件

很多 C++ 工程里会重复造这些小轮子:

  • 字符串格式化
  • 时间统计
  • 路径拼接
  • 文件遍历
  • 日志行号
  • 位操作
  • 随机数

现代标准库已经提供了不少可用组件。
掌握它们不一定会让代码更“高级”,但能让代码更少错、更统一、更容易跨平台。


2. std::format:类型安全格式化

传统 printf 容易出现格式和参数不匹配:

1
std::printf("%d\n", "hello"); // 错误但可能编译过

C++20 提供 std::format

1
2
3
4
#include <format>
#include <string>

std::string msg = std::format("user={}, score={}", "alice", 95);

输出:

1
user=alice, score=95

基本格式:

1
2
3
4
5
auto a = std::format("{}", 42);
auto b = std::format("{:04}", 7); // 0007
auto c = std::format("{:.2f}", 3.14159); // 3.14
auto d = std::format("{:<10}", "cpp"); // 左对齐
auto e = std::format("{:>10}", "cpp"); // 右对齐

注意:如果你的编译器/标准库版本还没完整支持 std::format,工程里常用 {fmt} 作为替代。


3. 自定义类型的格式化

可以给类型提供 formatter:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <format>
#include <string>

struct Point {
int x = 0;
int y = 0;
};

template <>
struct std::formatter<Point> : std::formatter<std::string> {
auto format(const Point& p, std::format_context& ctx) const {
return std::formatter<std::string>::format(
std::format("({}, {})", p.x, p.y),
ctx
);
}
};

int main() {
Point p{3, 4};
auto s = std::format("point={}", p);
}

对于业务类型,建议优先提供明确的格式:

1
auto s = std::format("id={}, name={}", user.id, user.name);

只有类型本身经常需要统一展示时,再专门写 formatter。


4. std::chrono:不要再裸写毫秒整数

坏接口:

1
void set_timeout(int timeout_ms);

调用时容易搞错单位:

1
set_timeout(5); // 5 ms 还是 5 s?

更好的接口:

1
2
3
4
5
6
#include <chrono>

void set_timeout(std::chrono::milliseconds timeout);

set_timeout(std::chrono::seconds(5));
set_timeout(std::chrono::milliseconds(500));

chrono 把单位放进类型系统,能减少大量隐形 bug。


5. 计时优先用 steady_clock

测耗时不要用系统时间。
系统时间可能被 NTP 或用户调整。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <chrono>
#include <iostream>

class Timer {
public:
Timer() : start_(clock::now()) {}

double elapsed_ms() const {
auto end = clock::now();
std::chrono::duration<double, std::milli> d = end - start_;
return d.count();
}

private:
using clock = std::chrono::steady_clock;
clock::time_point start_;
};

int main() {
Timer t;
do_work();
std::cout << "cost=" << t.elapsed_ms() << "ms\n";
}

常用选择:

  • steady_clock:测耗时
  • system_clock:表示日历时间、日志时间
  • high_resolution_clock:不一定比前两者更适合,实际可能只是别名

6. chrono 字面量

1
2
3
4
5
using namespace std::chrono_literals;

auto timeout = 500ms;
auto interval = 2s;
auto one_day = 24h;

可以写出更清楚的代码:

1
std::this_thread::sleep_for(100ms);

如果在头文件里,不建议直接写:

1
using namespace std::chrono_literals;

可以在函数内部使用,减少命名污染。


7. std::filesystem::path:跨平台路径拼接

不要手动拼路径分隔符:

1
std::string full = dir + "/" + file;

filesystem

1
2
3
4
5
6
7
#include <filesystem>

namespace fs = std::filesystem;

fs::path dir = "logs";
fs::path file = "app.txt";
fs::path full = dir / file;

常用操作:

1
2
3
4
5
6
fs::path p = "/tmp/demo.txt";

auto filename = p.filename(); // demo.txt
auto stem = p.stem(); // demo
auto ext = p.extension(); // .txt
auto parent = p.parent_path(); // /tmp

8. 创建目录和遍历文件

创建目录:

1
2
3
4
5
6
7
8
9
#include <filesystem>

namespace fs = std::filesystem;

void ensure_dir(const fs::path& dir) {
if (!fs::exists(dir)) {
fs::create_directories(dir);
}
}

遍历目录:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <filesystem>
#include <iostream>

namespace fs = std::filesystem;

void list_cpp_files(const fs::path& root) {
for (const auto& entry : fs::recursive_directory_iterator(root)) {
if (!entry.is_regular_file()) {
continue;
}

if (entry.path().extension() == ".cpp") {
std::cout << entry.path() << "\n";
}
}
}

注意:

  • 文件系统操作可能抛异常
  • 权限、符号链接、循环链接都要考虑
  • 遍历大目录时不要默认全量递归

9. filesystem 的错误处理版本

如果不想用异常,可以传 std::error_code

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <filesystem>
#include <system_error>

namespace fs = std::filesystem;

bool try_remove(const fs::path& p) {
std::error_code ec;
bool removed = fs::remove(p, ec);
if (ec) {
log_error(ec.message());
return false;
}
return removed;
}

这和错误处理章节能接上:

  • 异常适合少见失败
  • error_code 适合你想显式处理每一步失败

10. std::source_location:日志自动带位置

C++20 的 source_location 可以捕获调用点文件、行号、函数名。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <source_location>
#include <iostream>
#include <string_view>

void log(std::string_view msg,
const std::source_location& loc = std::source_location::current()) {
std::cout << loc.file_name() << ":" << loc.line()
<< " " << loc.function_name()
<< " - " << msg << "\n";
}

void f() {
log("hello");
}

比宏更类型安全,也更容易封装。

注意:

1
2
3
4
5
6
7
void log_impl(std::string_view msg,
std::source_location loc);

void log(std::string_view msg,
std::source_location loc = std::source_location::current()) {
log_impl(msg, loc);
}

默认参数要放在最外层 API 上,才能捕获真正调用点。


11. std::bit:位操作不要手写太多

C++20 <bit> 提供常用位工具:

1
2
3
4
5
6
#include <bit>
#include <cstdint>

static_assert(std::has_single_bit(8u));
static_assert(std::bit_width(8u) == 4);
static_assert(std::popcount(0b1011u) == 3);

常见用途:

1
2
3
4
5
6
7
bool is_power_of_two(std::uint32_t x) {
return std::has_single_bit(x);
}

std::uint32_t next_capacity(std::uint32_t n) {
return std::bit_ceil(n);
}

比自己写位运算更不容易错,也更能表达意图。


12. std::bit_cast:安全表达按位转换

以前很多人用 reinterpret_cast 或 union 做位解释。
C++20 提供 std::bit_cast

1
2
3
4
5
#include <bit>
#include <cstdint>

float f = 1.0f;
std::uint32_t bits = std::bit_cast<std::uint32_t>(f);

要求:

  • 源类型和目标类型大小相同
  • 类型通常应是 trivially copyable

它比直接乱用 reinterpret_cast 更安全、更清楚。


13. 随机数:不要用 rand()

现代 C++ 随机数由两部分组成:

  • 引擎:生成随机位
  • 分布:把随机位映射成目标分布
1
2
3
4
5
6
7
8
9
10
11
12
#include <random>
#include <iostream>

int main() {
std::random_device rd;
std::mt19937 rng(rd());
std::uniform_int_distribution<int> dist(1, 6);

for (int i = 0; i < 5; ++i) {
std::cout << dist(rng) << "\n";
}
}

如果需要可复现测试,固定 seed:

1
std::mt19937 rng(12345);

如果是安全随机数,标准库随机数通常不够,要用系统或密码学库提供的安全随机接口。


14. 一个小工具组合示例:扫描目录并打印报告

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
#include <chrono>
#include <filesystem>
#include <format>
#include <iostream>
#include <source_location>

namespace fs = std::filesystem;

void log(std::string_view msg,
const std::source_location& loc = std::source_location::current()) {
std::cout << std::format("{}:{} {}\n",
loc.file_name(),
loc.line(),
msg);
}

std::size_t count_files(const fs::path& root, std::string_view ext) {
std::size_t count = 0;

for (const auto& entry : fs::recursive_directory_iterator(root)) {
if (entry.is_regular_file() && entry.path().extension() == ext) {
++count;
}
}

return count;
}

int main() {
auto start = std::chrono::steady_clock::now();

fs::path root = "src";
auto count = count_files(root, ".cpp");

auto end = std::chrono::steady_clock::now();
std::chrono::duration<double, std::milli> ms = end - start;

log(std::format("found {} cpp files in {:.2f} ms", count, ms.count()));
}

这里组合了:

  • filesystem 管路径和遍历
  • chrono 测耗时
  • format 构造消息
  • source_location 自动带调用点

15. 常见误区

15.1 手写路径分隔符

跨平台路径用 std::filesystem::path,不要自己拼 /\

15.2 用 system_clock 测耗时

测耗时优先 steady_clock

15.3 string_view 传给异步日志后再保存

异步日志如果晚点再格式化,string_view 可能已经悬空。
跨线程保存时通常要复制成 std::string

15.4 以为 std::format 所有环境都可用

不同标准库支持进度可能不同。
工程里要用 CI 验证目标平台,必要时用 {fmt}

15.5 随机测试每次都用随机 seed

测试失败后很难复现。
测试用例建议记录 seed 或固定 seed。


16. 一页总结

现代标准库里最值得日常使用的组件:

  1. std::format:类型安全格式化
  2. std::chrono:把时间单位放进类型系统
  3. std::filesystem:跨平台路径与文件操作
  4. std::source_location:日志和诊断自动带调用点
  5. <bit>:标准位操作工具
  6. <random>:引擎 + 分布的随机数模型

一句话:

这些组件的价值在于减少自制小工具,让常见工程代码更清楚、更可移植、更容易测试。

C++20/23 并发工具

时间:2026/05/08

关键词:jthread、stop_token、latch、barrier、semaphore、atomic wait、atomic_ref、shared_mutex、osyncstream
核心目标:在 C++11 基础线程、锁、条件变量之上,掌握现代标准库更安全、更直接的并发组件。


1. 为什么需要 C++20/23 并发工具

C++11 已经提供了:

  • std::thread
  • std::mutex
  • std::condition_variable
  • std::future
  • std::atomic

但工程里还会遇到一些不够顺手的问题:

  • std::thread 析构前忘记 join()std::terminate
  • 线程停止缺少统一取消协议
  • 一组线程需要等所有人到齐
  • 多个阶段反复同步
  • 想限制同时访问资源的线程数
  • 原子标志位变化前不想忙等
  • 多线程打印输出互相打乱

C++20/23 的新工具正好补这些空位。


2. std::jthread:自动 join 的线程

std::thread 的一个经典坑:

1
2
3
4
5
6
7
void f() {
std::thread t([] {
work();
});

// 如果这里忘了 join/detach,t 析构会 terminate
}

std::jthread 的析构函数会自动请求停止并 join:

1
2
3
4
5
6
7
8
#include <thread>
#include <iostream>

int main() {
std::jthread t([] {
std::cout << "work\n";
});
}

离开作用域时,t 会自动等待线程结束。
这和 RAII 的方向一致:资源生命周期交给对象管理。

适合:

  • 明确属于某个作用域的后台线程
  • 服务对象内部 worker
  • 测试代码中的临时线程

3. stop_token:协作式取消

jthread 可以把 std::stop_token 自动传给线程函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <thread>
#include <chrono>
#include <iostream>

int main() {
std::jthread worker([](std::stop_token st) {
while (!st.stop_requested()) {
std::cout << "tick\n";
std::this_thread::sleep_for(std::chrono::milliseconds(100));
}
std::cout << "stopped\n";
});

std::this_thread::sleep_for(std::chrono::milliseconds(350));
worker.request_stop();
}

注意:

  • 停止请求不是强杀线程
  • 被取消的代码需要自己定期检查
  • 阻塞等待也要设计成可唤醒

这叫协作式取消。


4. stop_sourcestop_tokenstop_callback

三者关系:

  • stop_source:发起停止请求
  • stop_token:观察是否请求停止
  • stop_callback:停止请求发生时执行回调

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <stop_token>
#include <iostream>

int main() {
std::stop_source src;
std::stop_token tok = src.get_token();

std::stop_callback cb(tok, [] {
std::cout << "stop requested\n";
});

src.request_stop();
}

工程意义:

  • 可以把取消信号穿过多个模块
  • 不需要共享一个 atomic<bool>
  • 多个观察者可以响应同一次停止请求

5. condition_variable_any 与可取消等待

普通 condition_variable 等待时,如果没有通知,线程会一直睡。
使用 condition_variable_any 的 C++20 stop-token 版本,可以让等待响应取消:

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 <condition_variable>
#include <mutex>
#include <stop_token>
#include <thread>
#include <queue>

std::mutex m;
std::condition_variable_any cv;
std::queue<int> q;

void worker(std::stop_token st) {
std::unique_lock lk(m);

while (true) {
bool ready = cv.wait(lk, st, [] {
return !q.empty();
});

if (!ready) {
// stop requested
break;
}

int x = q.front();
q.pop();
lk.unlock();

process(x);

lk.lock();
}
}

这样线程既能等任务,也能被取消。


6. std::latch:一次性倒计时门闩

latch 适合“一组线程都完成某件事后,主线程继续”。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <latch>
#include <thread>
#include <vector>
#include <iostream>

int main() {
constexpr int n = 4;
std::latch done(n);
std::vector<std::jthread> threads;

for (int i = 0; i < n; ++i) {
threads.emplace_back([&, i] {
work(i);
done.count_down();
});
}

done.wait();
std::cout << "all workers finished\n";
}

特点:

  • 计数只减少
  • 不能重复使用
  • 适合启动完成、初始化完成、一次性汇合

7. std::barrier:可重复阶段同步

barrier 适合多阶段迭代。

例如仿真:

1
2
3
4
5
阶段 1:每个线程计算局部更新
barrier
阶段 2:交换边界
barrier
阶段 3:进入下一轮

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <barrier>
#include <thread>
#include <vector>

int main() {
constexpr int n = 4;
std::barrier sync(n);
std::vector<std::jthread> threads;

for (int tid = 0; tid < n; ++tid) {
threads.emplace_back([&, tid] {
for (int step = 0; step < 100; ++step) {
compute_local(tid, step);
sync.arrive_and_wait();

exchange_boundary(tid, step);
sync.arrive_and_wait();
}
});
}
}

barrier 可以带完成函数:

1
2
3
std::barrier sync(n, [] {
finish_one_phase();
});

完成函数会在每一轮所有参与者到达后执行一次。


8. std::counting_semaphore:限制并发数量

信号量适合控制同时进入某个区域的线程数。

1
2
3
4
5
6
7
8
9
10
11
#include <semaphore>
#include <thread>
#include <vector>

std::counting_semaphore<8> slots(3); // 最多 3 个线程同时进入

void task() {
slots.acquire();
access_limited_resource();
slots.release();
}

适合:

  • 限制数据库连接数
  • 限制同时进行的 I/O
  • 控制 GPU/网络/文件句柄等稀缺资源
  • 生产者消费者中的空槽/满槽计数

二值信号量:

1
std::binary_semaphore sem(0);

它可以当作轻量事件使用。


9. atomic::wait / notify:原子上的阻塞等待

C++20 给原子变量增加了等待和通知。

传统写法可能忙等:

1
2
3
while (!ready.load(std::memory_order_acquire)) {
// spin
}

如果等待时间可能较长,可以用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <atomic>
#include <thread>

std::atomic<bool> ready = false;

void consumer() {
bool expected = false;
while (!ready.load(std::memory_order_acquire)) {
ready.wait(expected);
}
consume_data();
}

void producer() {
produce_data();
ready.store(true, std::memory_order_release);
ready.notify_one();
}

要点:

  • wait(old) 会在原子值仍等于 old 时阻塞
  • 被唤醒后仍要重新检查条件
  • 内存序仍然要写对
  • 适合状态位、序号、轻量事件

它经常能替代“原子标志 + 自旋 + sleep”的土办法。


10. 用原子序号做生产者通知

一个常见模式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <atomic>
#include <cstdint>

std::atomic<std::uint64_t> version{0};

void publish() {
update_shared_data();
version.fetch_add(1, std::memory_order_release);
version.notify_all();
}

void wait_next(std::uint64_t seen) {
while (version.load(std::memory_order_acquire) == seen) {
version.wait(seen);
}
read_shared_data();
}

相比单纯 bool,版本号可以表达“状态变化了几次”。
适合配置刷新、数据快照发布、轻量通知。


11. std::atomic_ref:把已有对象当原子访问

atomic_ref 可以对一个已有对象建立原子视图:

1
2
3
4
5
6
7
8
#include <atomic>

int counter = 0;

void inc() {
std::atomic_ref<int> ref(counter);
ref.fetch_add(1, std::memory_order_relaxed);
}

适合:

  • 数据结构字段不能改成 std::atomic<T>
  • 需要临时以原子方式操作某个对象
  • 与 C API 或共享内存布局兼容

但要非常小心:

  1. 对象生命周期必须覆盖所有 atomic_ref
  2. 对象地址必须满足原子操作的对齐要求
  3. 同一对象不能同时被普通非原子访问造成数据竞争
  4. 它不是让任意复杂对象都 magically thread-safe

如果你能直接把字段设计成 std::atomic<T>,通常更清晰。


12. std::shared_mutex:读多写少

shared_mutex 是 C++17 引入的,但很适合补在并发工具里。

场景:

  • 很多线程读
  • 偶尔一个线程写

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <shared_mutex>
#include <mutex>
#include <unordered_map>
#include <string>

std::unordered_map<std::string, int> table;
std::shared_mutex m;

int get(const std::string& key) {
std::shared_lock lk(m);
auto it = table.find(key);
return it == table.end() ? -1 : it->second;
}

void put(std::string key, int value) {
std::unique_lock lk(m);
table[std::move(key)] = value;
}

注意:

  • 读锁太多可能饿死写者,具体策略看实现
  • 读操作必须真的不修改共享状态
  • 如果临界区很小,普通 mutex 可能更快

13. std::osyncstream:多线程输出不串行乱套

多线程直接写 std::cout,经常输出互相穿插:

1
std::cout << "thread " << id << " value " << x << "\n";

osyncstream 可以让一次输出在释放时整体写入:

1
2
3
4
5
6
7
#include <syncstream>
#include <iostream>

void log(int id, int value) {
std::osyncstream(std::cout)
<< "thread " << id << " value " << value << "\n";
}

它适合调试和日志示例。
性能敏感日志系统仍要用专门的异步日志方案。


14. 这些工具该怎么选

常见选择:

场景 优先工具
作用域内启动线程并自动等待 std::jthread
请求后台任务停下 stop_token
一组线程一次性汇合 std::latch
多阶段反复同步 std::barrier
限制同时访问资源数 std::counting_semaphore
等待原子状态变化 atomic::wait/notify
读多写少共享表 std::shared_mutex
多线程调试输出 std::osyncstream

经验:

  • 能用更高层并行算法或 TBB,就不要手写复杂同步
  • 手写同步时,优先使用表达意图明确的工具
  • 取消、生命周期、唤醒路径要一开始就设计好

15. 和线程池、TBB 的关系

这些标准组件是基础积木。
线程池和 TBB 是更高层的任务调度系统。

适合直接用标准组件的场景:

  • 少量长期线程
  • 简单后台 worker
  • 明确的阶段同步
  • 资源数量限制
  • 轻量状态通知

更适合 TBB/线程池的场景:

  • 大量短任务
  • 数据并行循环
  • 递归分治
  • work stealing
  • 复杂 pipeline

一句话:

标准并发工具负责“正确同步”,任务框架负责“高效调度”。


16. 常见误区

16.1 “jthread 会强制杀死线程”

不会。
它只会请求停止并 join,线程函数必须配合检查 stop_token

16.2 “barrier 到了就一定安全访问所有数据”

barrier 只解决阶段同步。
阶段内部仍然不能有数据竞争。

16.3 “semaphore 可以替代所有锁”

不行。
信号量控制数量,不直接保护复杂不变量。

16.4 “atomic::wait 不需要内存序”

仍然需要。
发布数据和读取数据时的 release/acquire 关系不能省。

16.5 “atomic_ref 可以给普通对象补上线程安全”

只能保证通过这个 atomic view 做的操作是原子的。
其他普通访问如果并发发生,仍然可能数据竞争。


17. 一页总结

现代标准并发工具补齐了 C++11 的几个工程短板:

  1. jthread 让线程生命周期更 RAII
  2. stop_token 提供协作式取消协议
  3. latchbarrier 表达汇合与阶段同步
  4. semaphore 表达资源数量限制
  5. atomic::wait/notify 避免粗糙忙等
  6. atomic_ref 能对既有对象做原子视图
  7. osyncstream 让多线程输出更干净

一句话:

C++20/23 并发工具的价值不是“更底层”,而是把常见同步意图写得更直接、更不容易漏生命周期。


18. 建议继续补充的相关主题

和本篇衔接最紧密的内容:

  1. jthread + condition_variable_any 实现可取消线程池
  2. barrier 在 stencil / 仿真中的阶段同步
  3. atomic::wait 实现轻量 MPMC 通知
  4. shared_mutex 与 RCU 思路对比
  5. C++ executors / sender-receiver 的后续演进

19. 参考资料

  1. cppreference: std::jthread
    https://en.cppreference.com/w/cpp/thread/jthread

  2. cppreference: std::barrier
    https://en.cppreference.com/w/cpp/thread/barrier

  3. cppreference: std::counting_semaphore
    https://en.cppreference.com/w/cpp/thread/counting_semaphore

  4. cppreference: atomic wait
    https://en.cppreference.com/w/cpp/atomic/atomic/wait

标准库并行算法与执行策略

时间:2026/05/08

关键词:std::executionparpar_unseqreducetransform_reduce、scan、无副作用、确定性
核心目标:理解 C++ 标准库并行算法能做什么、不能做什么,以及什么时候用它、什么时候换 TBB。


1. 为什么需要标准库并行算法

手写线程经常会遇到这些问题:

  • 如何分块
  • 如何处理尾部
  • 如何归约
  • 如何控制任务粒度
  • 如何避免共享写
  • 如何处理异常和生命周期

标准库并行算法的思路是:

把“我要对一段数据做什么”交给算法,把“怎么并行”交给实现。

例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <algorithm>
#include <execution>
#include <vector>

std::vector<float> a, b, c;

std::transform(std::execution::par,
a.begin(), a.end(),
b.begin(),
c.begin(),
[](float x, float y) {
return x + y;
});

它比手写线程更短,也更容易表达意图。


2. 执行策略有哪些

常见执行策略:

策略 含义
std::execution::seq 顺序执行
std::execution::par 允许多线程并行
std::execution::unseq 允许单线程内无序/向量化执行
std::execution::par_unseq 允许并行 + 向量化 + 无序执行

直觉:

1
2
3
4
seq       最保守
par 多线程
unseq SIMD/无序
par_unseq 多线程 + SIMD,限制也最多

注意:

  • 标准允许并行,不等于实现一定并行
  • 标准库实现和编译器支持会影响实际效果
  • 性能结论必须 benchmark

3. 第一个并行 for_each

1
2
3
4
5
6
7
8
9
10
11
#include <algorithm>
#include <execution>
#include <vector>

void scale(std::vector<float>& a, float k) {
std::for_each(std::execution::par,
a.begin(), a.end(),
[=](float& x) {
x *= k;
});
}

这个例子是安全的,因为每次迭代只修改当前元素。

危险写法:

1
2
3
4
5
6
7
float sum = 0.0f;

std::for_each(std::execution::par,
a.begin(), a.end(),
[&](float x) {
sum += x; // 数据竞争
});

并行算法里最重要的一条规则:

每个迭代最好只操作自己拥有的数据,不要随手写共享状态。


4. 用 reduce 替代共享累加

不要在 for_each(par) 里写共享 sum
std::reduce

1
2
3
4
5
6
7
8
9
10
#include <execution>
#include <functional>
#include <numeric>
#include <vector>

float sum(const std::vector<float>& a) {
return std::reduce(std::execution::par,
a.begin(), a.end(),
0.0f);
}

reduceaccumulate 的关键区别:

  • accumulate 严格按顺序从左到右
  • reduce 允许重排和分块合并

所以 reduce 更适合并行,但结果可能不逐位等同于顺序浮点求和。


5. transform_reduce:map + reduce 合在一起

点积:

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <execution>
#include <numeric>
#include <vector>

float dot(const std::vector<float>& a,
const std::vector<float>& b) {
return std::transform_reduce(std::execution::par,
a.begin(), a.end(),
b.begin(),
0.0f,
std::plus<>{},
std::multiplies<>{});
}

等价于:

1
sum += a[i] * b[i]

但实现可以:

  • 分块
  • 局部求和
  • 最后合并
  • 可能向量化

这是数值代码里非常常用的模式。


6. transform:并行生成输出

逐元素计算:

1
2
3
4
5
6
7
std::transform(std::execution::par,
x.begin(), x.end(),
y.begin(),
out.begin(),
[](float a, float b) {
return a * a + b;
});

安全前提:

  • 输出范围足够大
  • 输入输出重叠关系符合算法要求
  • lambda 不修改共享状态
  • 每个输出元素只由一个迭代写入

在高性能 C++ 中,这类“输入数组到输出数组”的形式非常适合:

  • SIMD
  • GPU
  • TBB
  • 标准并行算法

因为数据流清楚,副作用少。


7. sort 也有并行版本

1
2
3
4
5
6
7
#include <algorithm>
#include <execution>
#include <vector>

void sort_data(std::vector<int>& a) {
std::sort(std::execution::par, a.begin(), a.end());
}

注意:

  • 并行 sort 可能消耗更多临时内存
  • 小数组不一定更快
  • 比较函数必须是严格弱序
  • 比较函数不要有共享副作用

危险比较函数:

1
2
3
4
5
6
7
8
int count = 0;

std::sort(std::execution::par,
a.begin(), a.end(),
[&](int x, int y) {
++count; // 数据竞争
return x < y;
});

比较函数应该像纯函数一样使用。


8. scan:前缀和

前缀和是并行算法里的经典基础操作。

输入:

1
1 2 3 4

inclusive scan:

1
1 3 6 10

exclusive scan:

1
0 1 3 6

C++ 写法:

1
2
3
4
5
6
7
8
9
10
#include <execution>
#include <numeric>
#include <vector>

std::vector<int> in{1, 2, 3, 4};
std::vector<int> out(in.size());

std::inclusive_scan(std::execution::par,
in.begin(), in.end(),
out.begin());

scan 常用于:

  • 压缩数组
  • stream compaction
  • 构建偏移表
  • 稀疏数据结构
  • GPU 并行算法

9. par_unseq 的限制更严格

par_unseq 允许并行和向量化,意味着不同迭代之间:

  • 可能并行
  • 可能交错
  • 可能无序
  • 可能在同一线程内以 SIMD lane 执行

所以 lambda 里不要做这些事:

  • 加锁
  • 等待条件变量
  • 访问线程局部但有顺序依赖的状态
  • 修改共享非原子变量
  • 依赖执行顺序
  • 调用不适合向量化的复杂副作用函数

适合 par_unseq 的形状:

1
2
3
4
5
6
7
std::transform(std::execution::par_unseq,
a.begin(), a.end(),
b.begin(),
c.begin(),
[](float x, float y) {
return x + y;
});

不适合:

1
2
3
4
5
6
std::for_each(std::execution::par_unseq,
a.begin(), a.end(),
[&](float x) {
std::lock_guard<std::mutex> lk(m);
log.push_back(x);
});

如果 lambda 里需要锁,通常就不该用 par_unseq


10. 异常处理要特别注意

使用标准执行策略时,如果并行算法中的函数对象抛出异常,程序可能调用 std::terminate
这和普通顺序算法的异常传播直觉不同。

所以并行算法里的 callable 最好满足:

  • 不抛异常
  • 不做复杂资源操作
  • 不把错误藏在共享状态里

如果确实需要错误处理,常见做法:

  1. 在函数对象内部捕获异常
  2. 写入线程安全错误标志
  3. 算法结束后统一检查

但这会增加复杂度,也可能影响性能。
热路径里更常见的是让输入先被验证好。


11. 迭代器和数据结构选择

并行算法最喜欢:

  • std::vector
  • std::array
  • 连续内存
  • 随机访问迭代器
  • 每个元素处理成本相近

不太适合:

  • 链表
  • 大量指针追逐
  • 迭代器解引用很贵的结构
  • 每个元素工作量差异巨大
  • 需要频繁共享写的数据结构

虽然有些算法只要求 ForwardIterator,但从性能角度看,连续数组仍然是第一选择。


12. 并行算法和数据竞争

标准并行算法不会自动保护你的共享数据。

安全:

1
2
3
4
5
6
std::transform(std::execution::par,
in.begin(), in.end(),
out.begin(),
[](int x) {
return x * 2;
});

危险:

1
2
3
4
5
6
7
std::vector<int> out;

std::for_each(std::execution::par,
in.begin(), in.end(),
[&](int x) {
out.push_back(x * 2); // 数据竞争
});

更好的做法:

1
2
3
4
5
6
7
8
std::vector<int> out(in.size());

std::transform(std::execution::par,
in.begin(), in.end(),
out.begin(),
[](int x) {
return x * 2;
});

核心原则:

并行算法优先写“已分配好的输出位置”,不要在热路径里共享 push_back。


13. 确定性和浮点误差

并行算法可能改变操作顺序。
整数加法在不溢出的理想数学意义下问题不大,但 C++ 有类型边界和溢出规则。
浮点更明显:

1
2
auto s1 = std::accumulate(a.begin(), a.end(), 0.0);
auto s2 = std::reduce(std::execution::par, a.begin(), a.end(), 0.0);

s1s2 可能不逐位相同。

如果你需要:

  • 完全可复现
  • 严格顺序结果
  • 数值误差受控

就要慎用并行归约,或者使用确定性归约策略。


14. 什么时候标准并行算法很合适

适合:

  • 对连续数组做逐元素变换
  • 简单归约
  • 点积、范数、统计量
  • 排序
  • scan
  • 没有复杂副作用的批处理

典型场景:

1
2
3
4
std::transform(std::execution::par_unseq, ...);
std::reduce(std::execution::par, ...);
std::transform_reduce(std::execution::par, ...);
std::sort(std::execution::par, ...);

这种代码表达清楚,可读性高,也方便先写顺序版再切策略。


15. 什么时候更适合 TBB 或手写调度

更适合 TBB:

  • 需要控制 grain size
  • 需要任务嵌套
  • 需要 pipeline
  • 需要 task arena 限制并行度
  • 需要 thread local / combinable
  • 需要更清楚的负载均衡策略
  • 需要和已有 TBB 系统融合

更适合手写同步:

  • 少量长期 worker
  • 特定资源生命周期
  • 特定低延迟通信协议
  • 自定义 lock-free 结构

经验:

标准并行算法适合“形状规则”的数据并行;TBB 适合“调度复杂”的任务并行。


16. 性能检查清单

使用标准并行算法后,至少检查:

  1. 是否真的比 seq
  2. 小数据规模是否被调度开销拖慢
  3. callable 是否无共享副作用
  4. 输入输出是否连续
  5. 是否有 false sharing
  6. 浮点结果是否允许重排误差
  7. 标准库实现是否真的启用了并行后端
  8. 线程数是否和其他线程池冲突

一个常用对照实验:

1
2
3
auto r1 = std::reduce(std::execution::seq, a.begin(), a.end(), 0.0);
auto r2 = std::reduce(std::execution::par, a.begin(), a.end(), 0.0);
auto r3 = std::reduce(std::execution::par_unseq, a.begin(), a.end(), 0.0);

同时比较:

  • 耗时
  • 结果误差
  • CPU 使用率
  • profiler 热点

17. 常见误区

17.1 “加上 par 就一定多线程”

不一定。
标准允许并行,但实现可以根据情况选择策略。

17.2 “并行算法会自动避免数据竞争”

不会。
lambda 里的共享写仍然是你的责任。

17.3 “reduceaccumulate 完全一样”

不一样。
reduce 允许重排,适合并行;accumulate 是顺序左折叠。

17.4 “par_unseq 是最快的默认选择”

不是。
它限制最多,而且对 callable 要求更严格。

17.5 “标准算法能替代所有并行框架”

不能。
复杂调度、pipeline、NUMA-aware 设计仍然需要更强的框架或工程代码。


18. 一页总结

标准库并行算法的核心理解链:

  1. 用执行策略表达顺序、并行、向量化意图
  2. transform/reduce/scan/sort 表达规则数据并行
  3. callable 尽量无副作用
  4. 输出位置提前分配,避免共享 push_back
  5. 浮点归约要接受重排误差
  6. 是否真的并行和更快,要靠 benchmark 验证

一句话:

标准并行算法最适合把规则数组计算写得清楚;一旦调度和局部性成为主角,就该考虑 TBB 或更专门的方案。


19. 建议继续补充的相关主题

和本篇衔接最紧密的内容:

  1. reduce / transform_reduce 的数值稳定性
  2. par_unseq 与 SIMD 自动向量化
  3. 标准库并行后端差异
  4. TBB 与标准算法的替换边界
  5. ranges 与并行算法的组合限制

20. 参考资料

  1. cppreference: execution policy
    https://en.cppreference.com/w/cpp/algorithm/execution_policy_tag

  2. cppreference: std::reduce
    https://en.cppreference.com/w/cpp/algorithm/reduce

  3. cppreference: std::transform_reduce
    https://en.cppreference.com/w/cpp/algorithm/transform_reduce

  4. cppreference: numeric operations
    https://en.cppreference.com/w/cpp/algorithm#Numeric_operations

NUMA 与多路 CPU 访存

时间:2026/05/08

关键词:NUMA、socket、本地内存、远端内存、first touch、线程亲和性、内存带宽、cache coherence
核心目标:理解多路 CPU 机器上“内存离哪个核更近”,以及怎样避免并行程序被远端访存拖慢。


1. NUMA 是什么

NUMA 是 Non-Uniform Memory Access。
意思是:

CPU 访问不同位置的内存,成本不一定一样。

在单路桌面机器上,可以先粗略认为 CPU 访问内存的距离差异不明显。
但在多路服务器上,每个 CPU socket 往往连接着自己附近的一组内存通道。

如果线程运行在 socket 0,却频繁访问 socket 1 附近的内存,就会发生远端访存。
远端访存通常有更高延迟,也会消耗 socket 之间互连带宽。


2. UMA 和 NUMA 的直觉差异

UMA:

1
所有核心访问内存成本近似一样

NUMA:

1
2
核心访问本地内存更快
核心访问远端内存更慢

一个很粗略的模型:

1
2
3
4
5
socket 0 ---- local memory 0
|
interconnect
|
socket 1 ---- local memory 1

线程、数据、内存页的位置都开始影响性能。


3. 为什么高性能 C++ 要关心 NUMA

很多并行程序在单 socket 内扩展很好,但跨 socket 后突然变差:

1
2
3
4
1 线程:  1x
8 线程: 7x
16 线程: 12x
32 线程: 13x

可能原因不是线程库不好,而是:

  • 远端内存访问增加
  • socket 间互连带宽被打满
  • 跨 socket 共享数据导致 cache line 来回迁移
  • 锁或原子变量集中在一个 NUMA node
  • 内存初始化在一个线程完成,页面都落在一个 node

NUMA 问题常常不是小数据问题,而是大数据、高吞吐、多线程问题。


4. first touch 原则

很多操作系统采用 first touch 策略:

内存页第一次被哪个 CPU 附近的线程写入,就倾向于分配到那个 NUMA node。

坏模式:

1
2
3
4
5
6
7
8
9
std::vector<double> a(n);

// 单线程初始化
for (std::size_t i = 0; i < n; ++i) {
a[i] = 0.0;
}

// 后面多线程处理
parallel_work(a);

如果初始化线程在 socket 0 上运行,大量页面可能被放到 socket 0。
后续 socket 1 上的线程访问自己负责的数据时,也可能是在远端读写。

更好的模式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
std::vector<double> a(n);

// 让将来负责这段数据的线程先触摸这段数据
parallel_for_chunks(n, [&](std::size_t begin, std::size_t end) {
for (std::size_t i = begin; i < end; ++i) {
a[i] = 0.0;
}
});

parallel_for_chunks(n, [&](std::size_t begin, std::size_t end) {
for (std::size_t i = begin; i < end; ++i) {
a[i] = compute(i);
}
});

核心思想:

谁负责处理某段数据,最好也由谁先初始化那段数据。


5. NUMA 下最重要的数据划分方式

最常见的好模式是静态分区:

1
2
socket 0/thread group 0 处理 a[0, mid)
socket 1/thread group 1 处理 a[mid, n)

每个分区尽量满足:

  • 本线程组初始化
  • 本线程组主要读写
  • 本线程组局部归约
  • 最后只合并小结果

坏模式通常是:

1
2
3
4
所有线程随机访问整个大数组
所有线程写一个共享队列
所有线程更新同一个全局计数器
所有 socket 频繁读写同一批对象

这些模式会把本地访问变成远端访问,把局部缓存变成跨 socket 通信。


6. 跨 socket 共享 cache line 的成本

已有的 false sharing 在 NUMA 上会更痛。

例如每个线程写自己的计数器:

1
2
3
std::vector<std::uint64_t> counters(num_threads);

// 每个线程写 counters[tid]

如果多个计数器落在同一 cache line,cache line 会在核心之间迁移。
如果这些核心跨 socket,迁移成本更高。

更好的做法:

1
2
3
4
5
struct alignas(64) Counter {
std::uint64_t value = 0;
};

std::vector<Counter> counters(num_threads);

这不能解决所有 NUMA 问题,但能避免最典型的 false sharing。


7. 局部归约优于全局原子

坏模式:

1
2
3
4
5
std::atomic<double> global_sum{0.0};

parallel_for(..., [&] {
global_sum.fetch_add(value);
});

即使类型支持原子加,这也往往很慢。
所有线程都争一个位置,cache line 会在核心和 socket 之间来回迁移。

更好的模式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
std::vector<double> partial(num_threads, 0.0);

parallel_for_threads([&](int tid, std::size_t begin, std::size_t end) {
double local = 0.0;
for (std::size_t i = begin; i < end; ++i) {
local += work(i);
}
partial[tid] = local;
});

double sum = 0.0;
for (double x : partial) {
sum += x;
}

NUMA 机器上还可以再进一步:

1
2
3
线程本地归约
socket 本地归约
全局最终归约

8. 线程亲和性是什么

线程亲和性是把线程尽量固定在某些 CPU core 上运行。

如果线程一直搬家,会带来:

  • cache 热数据失效
  • first touch 布局和执行位置不匹配
  • benchmark 波动更大

亲和性不是所有程序都要手动设置。
但在多路 CPU、高吞吐、低延迟系统里,它很常见。

常见工具或 API:

  • numactl
  • taskset
  • pthread_setaffinity_np
  • sched_setaffinity
  • hwloc
  • TBB task_arena 和 affinity 相关机制

注意:

  • 绑定过死可能影响系统调度
  • 容器和虚拟机环境里 CPU 拓扑可能被隐藏
  • benchmark 环境和生产环境要区分

9. 用 numactl 观察和控制

Linux 上常用:

1
numactl --hardware

查看 NUMA node、CPU 和内存分布。

只在某个 node 上运行:

1
numactl --cpunodebind=0 --membind=0 ./app

交错分配内存:

1
numactl --interleave=all ./app

这些命令适合做实验:

  • 绑定同一 node
  • 故意绑定远端内存
  • 交错分配
  • 比较不同策略的带宽和延迟

如果不同策略差异很大,说明程序对 NUMA 很敏感。


10. 常用观察工具

10.1 lscpu

1
lscpu

可以看:

  • socket 数
  • core 数
  • thread 数
  • NUMA node

10.2 numastat

1
2
numastat
numastat -p <pid>

可以观察进程内存分布和远端访问线索。

10.3 hwloc

1
lstopo

可以看更完整的硬件拓扑:

  • NUMA node
  • socket
  • core
  • cache 层级
  • PCIe 设备

10.4 perf c2c

perf c2c 可以帮助分析 cache line 争用。
它对定位 false sharing 和跨核 cache line 迁移很有用。


11. NUMA 和内存分配器

内存分配器也会影响 NUMA。

关注点:

  • 分配发生在哪个线程
  • 初始化发生在哪个线程
  • 对象之后主要由哪个线程使用
  • 是否有全局 allocator 锁
  • 是否使用线程本地 cache

工程里常见策略:

  1. 每个线程或每个 socket 使用本地 arena
  2. 避免一个线程分配、另一个 socket 大量使用
  3. 大数组并行初始化
  4. 热对象不要在 socket 间频繁转移所有权

这和 std::pmr、内存池、线程本地缓存都能接起来。


12. NUMA 和任务调度

任务调度器喜欢负载均衡。
NUMA 喜欢数据本地性。

这两者有时会冲突。

例如 work stealing 可以让空闲线程偷任务,提高 CPU 利用率。
但如果偷来的任务访问远端 node 的大数据,可能导致远端访存增加。

工程上常见折中:

  • 大任务先按 NUMA node 分区
  • node 内部再做 work stealing
  • 小结果最后跨 node 合并
  • 对内存带宽型任务减少跨 node 迁移

TBB 这类调度器已经做了很多局部性优化,但仍需要你给出合理的数据划分和任务粒度。


13. 哪些程序最容易受 NUMA 影响

高风险场景:

  • 大数组扫描
  • 稀疏矩阵
  • 图算法
  • 数据库 / KV 存储
  • 大规模仿真
  • 日志/网络包批处理
  • 高频共享队列
  • 多线程内存分配密集程序

低风险场景:

  • 数据很小
  • 主要瓶颈是 I/O
  • 单 socket 内运行
  • 线程数不高
  • 工作集大多在 cache 中

判断标准很朴素:

如果程序已经被内存带宽或 cache coherence 限制,NUMA 很可能重要。


14. 一个简单的 NUMA 排查流程

  1. 确认机器拓扑
    lscpunumactl --hardwarelstopo

  2. 测单线程和多线程扩展性
    看跨 socket 后是否突然变差。

  3. 测不同绑定策略
    同 node、跨 node、interleave。

  4. 检查内存初始化
    是否单线程 first touch。

  5. 检查共享写
    是否有全局原子、锁、队列、计数器。

  6. 改成分区处理
    本地初始化、本地计算、本地归约。

  7. 复测
    看吞吐、延迟和波动是否改善。


15. 常见误区

15.1 “多线程慢就是锁的问题”

不一定。
跨 socket 远端访存和 cache line 迁移也会让程序很慢。

15.2 “内存分配在哪里不重要”

在 NUMA 上很重要。
分配和 first touch 可能决定页面位置。

15.3 “线程越均匀越好”

计算量均匀还不够。
数据位置也要尽量匹配。

15.4 “interleave 一定最好”

不一定。
交错分配能平摊带宽,但也可能破坏本地性。

15.5 “NUMA 只和服务器有关”

主要出现在多路服务器,但任何有明显内存拓扑差异的系统都值得留意。
只是普通桌面小程序通常不需要先处理它。


16. 一页总结

NUMA 的核心理解链:

  1. 不同 CPU 到不同内存的距离可能不同
  2. first touch 会影响页面放在哪里
  3. 线程位置和数据位置不匹配会导致远端访存
  4. 跨 socket 共享 cache line 很贵
  5. 本地分区、本地初始化、本地归约通常是第一解
  6. 绑定和工具只是验证手段,数据布局才是长期方案

一句话:

NUMA 优化的核心不是“绑核”,而是让计算尽量发生在数据旁边。


17. 建议继续补充的相关主题

和本篇衔接最紧密的内容:

  1. Linux numactl / numastat 实战
  2. hwloc 拓扑建模
  3. TBB task arena 与 NUMA-aware 调度
  4. 多 socket 上的内存带宽 benchmark
  5. 数据库和图计算中的 NUMA 分区设计

18. 参考资料

  1. hwloc
    https://www.open-mpi.org/projects/hwloc/

  2. Linux numactl
    https://github.com/numactl/numactl

  3. Intel Optimization Manual
    https://www.intel.com/content/www/us/en/developer/articles/technical/intel-sdm.html

Benchmark 与性能分析方法

时间:2026/05/08

关键词:benchmark、profiling、火焰图、硬件计数器、统计、吞吐、延迟、Google Benchmark
核心目标:建立“先测量、再定位、再优化、再验证”的性能工程闭环。


1. 为什么需要单独学性能测量

高性能编程里最危险的一句话是:

我感觉这样应该更快。

现代 CPU、编译器、缓存、线程调度都很复杂。
很多优化会出现反直觉结果:

  • 少了一次拷贝,但破坏了连续访问
  • 减少了锁,但引入 false sharing
  • 手写 SIMD,但寄存器压力变大
  • 多开线程,但调度和内存带宽抵消了收益
  • -O3-O2

所以性能优化必须依赖测量。


2. Benchmark、Profiling、Tracing 的区别

2.1 Benchmark

Benchmark 回答:

这个操作到底有多快?

常见输出:

  • 每次调用耗时
  • 每秒吞吐量
  • 带宽 GB/s
  • cycles / element
  • 不同输入规模下的曲线

2.2 Profiling

Profiling 回答:

时间花在哪里?

常见输出:

  • 哪些函数占 CPU 时间最多
  • 哪些调用链最热
  • cache miss、branch miss、锁等待等指标

2.3 Tracing

Tracing 回答:

一段时间线上发生了什么?

适合观察:

  • 线程之间何时等待
  • I/O 与计算是否重叠
  • GPU kernel 和 memcpy 是否并发
  • 请求链路中的长尾延迟

三者关系:

1
2
3
benchmark 判断变快还是变慢
profiling 找到该改哪里
tracing 解释并发系统为什么卡住

3. 一个最小手写计时器

小实验可以先用 steady_clock

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <chrono>
#include <iostream>
#include <vector>

int main() {
std::vector<float> a(1 << 24, 1.0f);
std::vector<float> b(1 << 24, 2.0f);

auto t0 = std::chrono::steady_clock::now();

float sum = 0.0f;
for (std::size_t i = 0; i < a.size(); ++i) {
sum += a[i] * b[i];
}

auto t1 = std::chrono::steady_clock::now();
std::chrono::duration<double, std::milli> ms = t1 - t0;

std::cout << "sum=" << sum << ", cost=" << ms.count() << " ms\n";
}

这里故意输出 sum,避免编译器发现结果没用而删掉整个循环。

手写计时适合:

  • 粗看数量级
  • 验证大改动
  • 测一段完整业务流程

不适合:

  • 纳秒级函数
  • 很短的循环
  • 需要稳定统计的微基准

4. 手写 benchmark 的常见陷阱

4.1 Debug 构建

Debug 构建的结果通常没有性能意义。
要测优化后的构建:

1
cmake -DCMAKE_BUILD_TYPE=Release ..

或直接:

1
clang++ -O3 -DNDEBUG main.cpp

4.2 死代码消除

如果结果没有被使用,编译器可能直接删掉计算。

坏例子:

1
2
3
for (int i = 0; i < n; ++i) {
x += i;
}

如果 x 最终无用,循环可能消失。

4.3 常量折叠

如果输入全是编译期常量,编译器可能提前算好结果。

4.4 只跑一次

第一次运行可能包含:

  • 缺页
  • cache 冷启动
  • 动态链接开销
  • 内存分配初始化
  • CPU 频率尚未稳定

4.5 输入规模太小

小数据可能完全在 L1 cache 里,大数据可能受内存带宽限制。
只测一个规模很容易得出片面结论。

4.6 把 I/O 算进热循环

std::cout、日志、文件读写通常会淹没真正的计算成本。


5. 更推荐的微基准:Google Benchmark

微基准建议用成熟框架。
Google Benchmark 会处理很多基础工作:

  • 多轮运行
  • 自动调整迭代次数
  • 防止常见优化误删
  • 参数化输入规模
  • 输出统计指标

典型写法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <benchmark/benchmark.h>
#include <vector>

static void BM_sum(benchmark::State& state) {
const std::size_t n = static_cast<std::size_t>(state.range(0));
std::vector<float> a(n, 1.0f);

for (auto _ : state) {
float sum = 0.0f;
for (float x : a) {
sum += x;
}
benchmark::DoNotOptimize(sum);
}

state.SetItemsProcessed(state.iterations() * static_cast<long long>(n));
}

BENCHMARK(BM_sum)->Range(1 << 10, 1 << 26);

BENCHMARK_MAIN();

如果 benchmark 里包含写内存,可以用:

1
benchmark::ClobberMemory();

它提示编译器:不要假设内存状态没有被外部观察。


6. 应该看哪些指标

不同任务要看不同指标。

6.1 延迟

单次操作要多久。
适合:

  • RPC 请求
  • 交互式操作
  • 单帧渲染
  • 实时控制

6.2 吞吐

单位时间处理多少工作。
适合:

  • 批处理
  • 编解码
  • 数据转换
  • 大规模数值计算

6.3 带宽

常用于访存密集代码:

1
GB/s = 读写总字节数 / 时间

例如:

1
c[i] = a[i] + b[i];

每个 float 元素大约:

  • a[i]:4 字节
  • b[i]:4 字节
  • c[i]:4 字节

理论上至少 12 字节/元素。

6.4 cycles / element

适合比较热循环:

1
cycles_per_element = CPU_cycles / element_count

它比单纯毫秒更接近机器成本。

6.5 并行加速比

1
2
speedup = T1 / Tp
efficiency = speedup / p

如果 8 线程只快 2 倍,要继续问:

  • 是锁争用?
  • 是内存带宽?
  • 是任务太小?
  • 是 false sharing?
  • 是负载不均?

7. Profiling 的基本流程

一个稳定流程:

  1. 用 benchmark 或业务指标确认问题存在
  2. 用 profiler 找热点
  3. 根据热点提出假设
  4. 改一处
  5. 重新 benchmark
  6. 记录结果

不要一上来重构一大片。
性能优化要像做实验:每次只改变一个主要变量。


8. 常用 profiling 工具

8.1 Linux

常见工具:

  • perf
  • gprof
  • Valgrind / Callgrind
  • Intel VTune
  • flamegraph 工具链

perf 基本用法:

1
2
3
perf stat ./app
perf record -g ./app
perf report

看硬件计数器:

1
perf stat -e cycles,instructions,cache-misses,branches,branch-misses ./app

8.2 macOS

常见工具:

  • Instruments
  • Xcode profiling
  • sample
  • time

粗看一个进程:

1
sample <pid> 5

8.3 Windows

常见工具:

  • Visual Studio Profiler
  • Windows Performance Analyzer
  • Intel VTune

8.4 GPU

CUDA 常见工具:

  • Nsight Systems:看 CPU/GPU 时间线、stream、memcpy、kernel 重叠
  • Nsight Compute:看单个 kernel 的 occupancy、访存、warp、指令指标

9. 火焰图怎么看

火焰图常见规则:

  • 横向宽度表示采样占比
  • 纵向表示调用栈深度
  • 顶部通常是最终执行的函数

看火焰图时不要只找最高的一根。
真正重要的是宽:

1
越宽,说明越多采样落在这条调用链里。

常见判断:

  • 宽而浅:可能是某个热循环本身重
  • 宽而深:可能是抽象层/调用链成本
  • 多处同类热点:可能要改数据布局或整体算法
  • 锁相关函数很宽:可能是争用
  • allocator 很宽:可能是频繁分配

10. 硬件计数器的第一批指标

10.1 IPC

1
IPC = instructions / cycles

IPC 低可能意味着:

  • cache miss 多
  • 分支预测差
  • 依赖链长
  • 指令等待资源

但 IPC 不是越高越好。
有些内存带宽型代码 IPC 天然不高。

10.2 cache miss

cache miss 高时,要回到访存优化:

  • 顺序访问
  • blocking
  • SoA
  • 减少指针追逐
  • 降低工作集大小

10.3 branch miss

branch miss 高时,考虑:

  • 分组处理
  • 降低热循环分支
  • 用查表或 mask
  • 把少见路径挪出去

10.4 context switch

上下文切换多时,考虑:

  • 线程数是否太多
  • 是否频繁阻塞唤醒
  • 是否锁竞争严重
  • 是否把短任务切得过碎

11. 并行 benchmark 的特殊坑

11.1 线程池预热

第一次提交任务可能包含线程创建成本。
测吞吐时通常要先预热。

11.2 任务粒度

并行框架不是免费午餐。
任务太小会被调度开销吃掉收益。

11.3 false sharing

每个线程写自己的变量也可能慢。
如果这些变量落在同一 cache line,就会互相抢缓存行所有权。

11.4 内存带宽上限

很多 O(n) 数组操作扩展到一定线程数后就不再加速。
不是线程库失效,而是内存带宽被打满。

11.5 负载不均

平均每个线程分到一样多元素,不代表工作量一样。
例如图算法、稀疏矩阵、变长字符串处理都容易负载不均。

11.6 绑定 CPU 和 NUMA

多路 CPU 机器上,线程和内存放错位置会让结果非常不稳定。
这时要结合 NUMA 工具和线程亲和性分析。


12. 优化前先保证正确性

性能优化会放大未定义行为。
建议先跑基础检查:

1
2
clang++ -fsanitize=address,undefined -g main.cpp
clang++ -fsanitize=thread -g main.cpp

常见 sanitizer:

  • AddressSanitizer:越界、use-after-free
  • UndefinedBehaviorSanitizer:未定义行为
  • ThreadSanitizer:数据竞争

如果代码本身有 UB,benchmark 结果可能完全没有意义。


13. Microbenchmark 和真实场景的关系

微基准适合回答:

  • 这个循环的上限在哪里?
  • 数据布局 A 和 B 哪个更好?
  • SIMD 是否生效?
  • 线程数扩展性如何?

但真实系统还包含:

  • I/O
  • 内存分配
  • 调度
  • cache 污染
  • 多模块交互
  • 异常路径

所以要同时保留:

  1. microbenchmark:定位局部性能
  2. macrobenchmark:验证真实收益
  3. regression benchmark:防止后续退化

14. 一个推荐优化工作流

可以按这个顺序走:

  1. 明确性能目标
    例如一帧低于 16ms,或吞吐达到 5GB/s。

  2. 建立可重复 benchmark
    固定输入、构建类型、运行环境。

  3. 找热点
    用 profiler,而不是猜。

  4. 判断瓶颈类型
    算术、访存、分支、锁、分配、I/O、调度。

  5. 小步修改
    每次只改一个主要假设。

  6. 复测并记录
    保存命令、机器、编译器、输入规模、结果。

  7. 回归保护
    把关键 benchmark 纳入日常检查。


15. 结果记录建议

性能结论最好包含:

  • 日期
  • 机器型号
  • CPU/GPU
  • 编译器版本
  • 编译选项
  • 输入规模
  • 线程数
  • benchmark 命令
  • 旧结果和新结果
  • 波动范围

不要只写:

1
优化后快了很多

更好的记录:

1
2
3
4
5
n=2^26, clang 17, -O3 -march=native
old: 12.4 ms median
new: 7.1 ms median
speedup: 1.75x
note: memory bandwidth from 64 GB/s to 112 GB/s

16. 常见误区

16.1 “一次运行快就是快”

不够。
要看多次运行、波动范围和输入规模。

16.2 “微基准快,真实系统就一定快”

不一定。
真实系统可能被 I/O、分配、锁、调度或数据转换主导。

16.3 “profiler 显示哪里热就直接重写哪里”

先判断热点是否可优化。
有些热点是算法本质,有些热点只是被调用次数多。

16.4 “CPU 使用率高就是好事”

CPU 忙不代表有效工作多。
忙等、自旋、cache miss、锁争用都可能让 CPU 看起来很忙。

16.5 “所有 benchmark 都要追求纳秒”

不需要。
用户关心的是端到端延迟或吞吐时,宏基准更重要。


17. 一页总结

性能工程最重要的是闭环:

  1. 先建立可重复测量
  2. 用 profiler 找热点
  3. 根据数据提出假设
  4. 小步修改
  5. 复测验证
  6. 记录结果和环境

一句话:

没有测量的优化只是猜测,没有复测的优化只是愿望。


18. 建议继续补充的相关主题

和本篇衔接最紧密的内容:

  1. Google Benchmark 深入用法
  2. Linux perf 与硬件计数器
  3. 火焰图生成和解读
  4. Nsight Systems / Nsight Compute
  5. 性能回归测试与 CI 集成

19. 参考资料

  1. Google Benchmark
    https://github.com/google/benchmark

  2. Brendan Gregg: Flame Graphs
    https://www.brendangregg.com/flamegraphs.html

  3. Linux perf wiki
    https://perf.wiki.kernel.org/

  4. LLVM Sanitizers
    https://clang.llvm.org/docs/index.html

SIMD 与自动向量化

时间:2026/05/08

关键词:SIMD、自动向量化、数据并行、AVX、NEON、对齐、别名、SoA、reduction
核心目标:理解 CPU 如何在一个线程里一次处理多个数据,以及怎样写出更容易被编译器向量化的 C++。


1. SIMD 解决什么问题

普通标量代码通常可以粗略理解成:

1
2
3
for (std::size_t i = 0; i < n; ++i) {
c[i] = a[i] + b[i];
}

每次循环处理一个元素。

SIMD 的想法是:

一条指令处理多个同类型数据。

例如 CPU 可以一次加载多个 float,一次完成多组加法,再一次写回多组结果。
这不是开更多线程,而是让单个线程内部的算术单元更满。


2. SIMD 和多线程不是一回事

可以把并行粗略分成几层:

  1. 指令级并行:CPU 自己乱序执行、流水线、预测
  2. SIMD:一条指令多个 lane
  3. 多线程:多个 CPU core 同时执行
  4. 多进程 / 分布式:多台机器或多个进程协作

高性能数值代码经常同时利用这些层:

1
2
多线程负责切大块数据
每个线程内部再用 SIMD 处理连续元素

所以 SIMD 不是 TBB、线程池、CUDA 的替代品,而是 CPU 热循环里的另一个加速层。


3. 自动向量化是什么

自动向量化是编译器把标量循环改写成 SIMD 指令的优化。

比如下面的循环:

1
2
3
4
5
void add(float* c, const float* a, const float* b, std::size_t n) {
for (std::size_t i = 0; i < n; ++i) {
c[i] = a[i] + b[i];
}
}

在打开优化并允许目标架构指令后,编译器可能生成:

  • SSE / AVX / AVX2 / AVX-512
  • ARM NEON / SVE
  • 其他平台的向量指令

常见编译选项:

1
2
clang++ -O3 -march=native main.cpp
g++ -O3 -march=native main.cpp

-O3 不保证一定更快,但它通常会更积极地尝试向量化。
-march=native 允许编译器使用当前机器支持的指令集,适合本机部署或实验。


4. 编译器最喜欢什么样的循环

自动向量化喜欢这类循环:

1
2
3
for (std::size_t i = 0; i < n; ++i) {
y[i] = a * x[i] + y[i];
}

它有几个好特征:

  1. 每次迭代之间基本独立
  2. 访问连续内存
  3. 分支少
  4. 操作类型统一
  5. 不明显违反别名假设

反过来,这些情况会让编译器犹豫:

  • 循环间有依赖
  • 指针可能互相重叠
  • 访问步长不规则
  • 循环体里有复杂函数调用
  • 分支很多且路径差异大
  • 浮点归约需要改变加法顺序

5. 循环依赖是第一道门槛

这个循环很难普通向量化:

1
2
3
for (std::size_t i = 1; i < n; ++i) {
a[i] = a[i - 1] + 1.0f;
}

因为 a[i] 依赖上一轮刚写出的 a[i - 1]
i + 1 轮不能脱离第 i 轮独立执行。

而这个循环更友好:

1
2
3
for (std::size_t i = 0; i < n; ++i) {
out[i] = in[i] + 1.0f;
}

每个元素只依赖同位置输入,编译器可以把多个元素打包处理。

判断口诀:

如果调换几次迭代的执行顺序不会影响结果,通常更容易向量化。


6. 别名会让编译器保守

看这个函数:

1
2
3
4
5
void saxpy(float* y, const float* x, float a, std::size_t n) {
for (std::size_t i = 0; i < n; ++i) {
y[i] += a * x[i];
}
}

如果 xy 指向同一片或部分重叠的内存,循环语义可能发生变化。
编译器如果不能证明它们不重叠,就可能生成更保守的代码。

工程里常见做法:

1
2
3
4
5
6
7
8
void saxpy(float* __restrict y,
const float* __restrict x,
float a,
std::size_t n) {
for (std::size_t i = 0; i < n; ++i) {
y[i] += a * x[i];
}
}

__restrict 不是标准 C++,但 GCC、Clang、MSVC 都有类似扩展。
它的含义是向编译器承诺:这些指针访问的对象没有互相重叠。

注意:

  • 只有你真的能保证不重叠时才用
  • 承诺错了就是未定义行为或错误结果
  • API 边界上更要谨慎

7. 对齐会影响加载效率

SIMD 一次加载多个元素。
如果数据地址和向量宽度对齐,CPU 通常更容易高效处理。

常见 cache line 大小是 64 字节,常见向量宽度有:

  • SSE:128 bit
  • AVX / AVX2:256 bit
  • AVX-512:512 bit
  • NEON:128 bit

栈上对象可以用 alignas

1
alignas(64) float data[1024];

结构体也可以显式对齐:

1
2
3
struct alignas(64) Block {
float x[16];
};

不过不要迷信“对齐一定大幅提升”。现代 CPU 对非对齐加载已经友好很多。
真正更常见的收益通常来自:

  • 连续访问
  • 少别名
  • 少分支
  • 更好的数据布局

8. AoS 和 SoA 对 SIMD 的影响

AoS:

1
2
3
4
5
6
7
8
9
10
struct Particle {
float x, y, z;
float vx, vy, vz;
};

std::vector<Particle> ps;

for (auto& p : ps) {
p.x += p.vx * dt;
}

如果热循环只处理 xvx,那么 y/z/vy/vz 会跟着被搬进 cache line。
SIMD 也更难一次拿到一串连续的 x

SoA:

1
2
3
4
5
6
7
8
struct Particles {
std::vector<float> x, y, z;
std::vector<float> vx, vy, vz;
};

for (std::size_t i = 0; i < ps.x.size(); ++i) {
ps.x[i] += ps.vx[i] * dt;
}

这里 xvx 都是连续数组。
编译器更容易生成连续加载、乘加、写回的向量代码。

经验:

  • 热循环按字段批量处理时,SoA 通常更适合 SIMD
  • 对象经常整体读写时,AoS 可能更自然
  • 工程里也常用 AoSoA,把数据按小块组织,兼顾局部性和向量宽度

9. 分支和掩码

分支不一定完全阻止向量化。

例如:

1
2
3
4
5
6
7
for (std::size_t i = 0; i < n; ++i) {
if (x[i] > 0.0f) {
y[i] = x[i];
} else {
y[i] = 0.0f;
}
}

编译器可能用向量比较和 mask 选择来实现。

但是复杂分支会带来几个问题:

  • 每个 lane 走不同路径时,硬件执行效率下降
  • 分支内部如果有不同内存访问,向量化更难
  • 函数调用、异常、锁等操作通常不适合 par_unseq 或 SIMD

常见优化思路:

  1. 把数据按类型或状态分组,减少热循环内分支
  2. 用简单数学表达替代小分支
  3. 将慢路径挪出热循环
  4. 先过滤索引,再对紧凑数组做批量处理

10. 浮点归约要小心

这个循环看起来很简单:

1
2
3
4
float sum = 0.0f;
for (std::size_t i = 0; i < n; ++i) {
sum += a[i];
}

但浮点加法不满足严格结合律:

1
(a + b) + c 可能不等于 a + (b + c)

向量化归约通常会改变加法顺序。
所以在严格浮点语义下,编译器可能比较保守。

如果你打开了类似 -ffast-math 的选项,编译器会获得更多重排空间,但代价是:

  • NaN / Inf 语义可能变化
  • 舍入误差可能变化
  • 结果不一定逐位稳定

数值代码里要先问清楚:

我需要逐位一致,还是只需要误差在可接受范围内?


11. 显式 SIMD 的几种路线

自动向量化不够时,可以考虑显式 SIMD。

常见路线:

  1. 编译器 intrinsics
    例如 _mm256_loadu_ps_mm256_add_ps,控制力强,可移植性差。

  2. 第三方 SIMD 抽象库
    例如 xsimd、EVE、Highway,用统一接口适配不同指令集。

  3. 数值库
    例如 Eigen、xtensor、Blaze,很多表达式内部已经做了向量化。

  4. 标准化数据并行类型
    不同编译器和标准库支持程度会变化,工程使用前要查当前实现状态。

经验上:

  • 能靠数据布局和自动向量化解决,就先不写 intrinsics
  • 热点明确、收益巨大、平台明确时,再考虑 intrinsics
  • 显式 SIMD 代码一定要配 benchmark 和汇编检查

12. 如何看编译器有没有向量化

Clang 常用:

1
clang++ -O3 -Rpass=loop-vectorize -Rpass-missed=loop-vectorize main.cpp

GCC 常用:

1
g++ -O3 -fopt-info-vec -fopt-info-vec-missed main.cpp

也可以看汇编:

1
clang++ -O3 -march=native -S main.cpp -o main.s

常见 SIMD 指令线索:

  • x86 SSE:xmm
  • x86 AVX:ymm
  • x86 AVX-512:zmm
  • ARM NEON:v0q0 等寄存器形式

不过不要只靠看到 SIMD 指令就判定成功。
最终仍要看 benchmark,因为向量化也可能被额外搬运、分支、尾处理抵消。


13. 写给自动向量化的 C++ 习惯

高频有效习惯:

  1. 用连续容器存热数据
  2. 让循环边界简单清楚
  3. 把循环体写小
  4. 减少热循环里的虚调用和复杂回调
  5. 尽量表达不重叠的输入输出
  6. 避免不必要的指针追逐
  7. 把 AoS 调整为 SoA 或 AoSoA
  8. 对归约明确接受的数值误差

一个比较友好的函数形状:

1
2
3
4
5
6
7
8
void update(float* __restrict x,
const float* __restrict v,
float dt,
std::size_t n) {
for (std::size_t i = 0; i < n; ++i) {
x[i] += v[i] * dt;
}
}

它给了编译器几个重要信息:

  • 输入输出连续
  • 指针不重叠
  • 循环体简单
  • 没有跨迭代依赖

14. 常见误区

14.1 “用了 SIMD 就不用管内存”

错。
SIMD 提升算术吞吐,但如果瓶颈是内存带宽,收益会被带宽上限卡住。

14.2 “手写 intrinsics 一定比编译器强”

不一定。
手写代码可能破坏调度、增加寄存器压力、写坏尾处理,最后反而更慢。

14.3 “-O3 -march=native 可以随便用于发布”

本机部署可以。
但如果二进制要跑在不同 CPU 上,-march=native 可能生成目标机器不支持的指令。

14.4 “浮点向量化结果必须和标量逐位一致”

不一定。
尤其是归约和 fast-math 场景,结果顺序和舍入可能变化。

14.5 “只要循环独立就一定会向量化”

还要看别名、对齐、函数调用、分支、目标架构、编译选项和成本模型。


15. 一页总结

SIMD 的核心不是记住某个指令名字,而是建立这条链:

  1. 数据连续,CPU 才容易批量加载
  2. 循环独立,编译器才敢重排
  3. 别名清晰,优化器才不必保守
  4. 分支简单,lane 利用率才高
  5. 归约要接受可能改变顺序
  6. 最终效果必须用 benchmark 和汇编检查确认

一句话:

SIMD 是 CPU 端数据并行的基础能力,最先改的往往不是指令,而是数据布局和循环形状。


16. 建议继续补充的相关主题

和本篇衔接最紧密的内容:

  1. Benchmark 与性能分析方法
  2. restrict、strict aliasing 与 noalias 约束
  3. AoSoA 数据布局
  4. 矩阵乘、卷积、stencil 的向量化
  5. 显式 SIMD 库 xsimd / EVE / Highway

17. 参考资料

  1. Intel Intrinsics Guide
    https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html

  2. GCC Vectorization
    https://gcc.gnu.org/projects/tree-ssa/vectorization.html

  3. Clang Auto-Vectorization
    https://llvm.org/docs/Vectorizers.html

内存泄漏检测与管理

时间:2026/04/16

关键词:RAII、unique_ptrshared_ptr 循环引用、Sanitizer、Valgrind、资源封装
核心目标:把“谁释放、何时释放、怎么定位泄漏”变成工程上可检查的规则。


1. 什么才叫内存泄漏

最典型的内存泄漏是:

  • 一块堆内存已经没有任何有效路径再访问它
  • 但它也永远不会被释放

例如:

1
2
3
void bad() {
int* p = new int(42);
}

函数结束后,p 没了,这块内存也没人能再 delete
这就是最标准的泄漏。

但工程里还要区分另一类问题:

  • 对象严格来说还“可达”
  • 但长期不释放,内存占用持续上涨

这未必是严格意义上的 leak,但同样会把服务拖垮。


2. 最常见的泄漏来源

2.1 裸 new / delete 配对失败

最常见的问题不是“不会 delete”,而是:

  • 提前 return
  • 中途 throw
  • 多分支路径漏掉释放

2.2 容器里放 owning raw pointer

1
2
std::vector<Foo*> items;
items.push_back(new Foo());

这种写法会把“谁来删”变成记忆题。

2.3 shared_ptr 循环引用

两个对象互相持有 shared_ptr,引用计数永远不会归零。

2.4 C 风格资源没及时封装

比如:

  • FILE*
  • malloc/free
  • socket / fd
  • 第三方库句柄

如果它们在业务代码里裸奔,后面很容易漏掉释放。


3. 第一原则:先别写出会泄漏的代码

现代 C++ 管理泄漏,重点不是“人工记得回收”,而是默认采用不容易泄漏的结构。

优先顺序通常是:

  1. 能值语义就值语义
  2. 能栈对象就栈对象
  3. 必须动态分配时优先 std::unique_ptr
  4. 确实需要共享拥有时才用 std::shared_ptr
  5. 裸指针和引用默认只表达观察,不表达拥有

这背后的核心思想就是 RAII:

  • 对象析构时自动释放资源

只要生命周期跟对象绑在一起,泄漏风险会大幅下降。


4. 一个典型泄漏例子

错误写法:

1
2
3
4
5
6
7
8
9
#include <memory>

struct Widget {};

Widget* create_widget(bool failed) {
Widget* p = new Widget();
if (failed) return nullptr; // 泄漏
return p;
}

问题不在 new 本身,而在:

  • 释放依赖调用路径是否完整

更稳妥的写法:

1
2
3
4
5
6
7
8
9
#include <memory>

struct Widget {};

std::unique_ptr<Widget> create_widget(bool failed) {
auto p = std::make_unique<Widget>();
if (failed) return nullptr;
return p;
}

这样即使中途提前返回,局部对象也会自动清理。


5. 智能指针也不是绝对安全

unique_ptr 很少造成泄漏,真正容易出问题的是 shared_ptr

1
2
3
4
5
6
#include <memory>

struct Node {
std::shared_ptr<Node> next;
std::shared_ptr<Node> prev; // 错:可能形成环
};

如果两个节点互相持有,引用计数就会卡住。

更常见的修正方式是:

1
2
3
4
5
6
#include <memory>

struct Node {
std::shared_ptr<Node> next;
std::weak_ptr<Node> prev; // 观察,不拥有
};

经验上:

  • 形成树状、图状、双向关系时,先主动检查是否存在环
  • “回指”通常更适合 weak_ptr

6. 泄漏和“内存一直涨”不是一回事

下面这些情况不一定是严格意义上的 leak:

  • 全局缓存只增不减
  • std::vector / std::string 容量长期不回收
  • 任务队列积压
  • 对象池尺寸只扩不缩

它们的问题是:

  • 生命周期策略不合理
  • 上限控制缺失

所以排查内存问题时,要先分清两类:

  1. 对象已经不可达,但没释放
  2. 对象还可达,但系统把它留得太久

前者更像 bug,后者更像管理问题,但两者都要处理。


7. 怎么检测泄漏

7.1 先上 Sanitizer

本地开发最常用的办法通常是编译时打开 Sanitizer:

1
2
clang++ -std=c++20 -g -O1 -fno-omit-frame-pointer -fsanitize=address main.cpp -o app
ASAN_OPTIONS=detect_leaks=1 ./app

如果你的工具链把 leak 检测拆开,也可以按需使用:

  • -fsanitize=leak

它的优点是:

  • 定位快
  • 栈回溯清楚
  • 很适合集成到测试里

7.2 再用 Valgrind 看存量问题

1
valgrind --leak-check=full --show-leak-kinds=all ./app

它更慢,但对一些历史代码排查仍然很有价值。

7.3 别只测“正常退出”

很多泄漏只在这些场景出现:

  • 异常路径
  • 超时取消
  • 重试逻辑
  • 长时间运行
  • 高并发压力

所以测试不能只跑正常路径。


8. Valgrind 基础应用

Valgrind 是一套动态分析工具,最常用的是:

  • memcheck:检查内存泄漏、越界访问、使用未初始化内存、重复释放等
  • helgrind:检查多线程数据竞争、锁使用问题
  • drd:检查多线程数据竞争和线程 API 使用问题
  • massif:分析堆内存占用峰值
  • callgrind:分析函数调用和性能热点

平时排查 C++ 内存问题,先掌握 memcheck 就够;涉及多线程再看 helgrinddrd

注意:

  • Valgrind 主要适合 Linux 环境
  • macOS 上支持不如 Linux 稳定,尤其新系统和 Apple Silicon 经常不方便
  • 如果本机是 macOS,工程实践里更推荐用 Linux 虚拟机、Docker、远程 Linux 机器或 WSL
  • Valgrind 会让程序慢很多,通常比原始运行慢几十倍,不适合直接跑线上服务

8.1 编译时建议加调试信息

Valgrind 不需要重新编译插桩,但为了看到清楚的源码行号,建议这样编译:

1
g++ -std=c++20 -g -O0 -fno-omit-frame-pointer main.cpp -o app

参数含义:

  • -g:保留调试信息,Valgrind 才能显示文件名和行号
  • -O0:关闭优化,回溯更接近源码
  • -fno-omit-frame-pointer:保留栈帧信息,回溯更完整

如果项目使用 CMake,可以临时打开 Debug 构建:

1
2
cmake -S . -B build -DCMAKE_BUILD_TYPE=Debug
cmake --build build

8.2 Memcheck 查内存泄漏

最常用命令:

1
2
3
4
5
6
7
valgrind \
--tool=memcheck \
--leak-check=full \
--show-leak-kinds=all \
--track-origins=yes \
--num-callers=30 \
./app

常用参数:

  • --tool=memcheck:使用内存检查工具,默认也是它
  • --leak-check=full:显示每个泄漏点的详细调用栈
  • --show-leak-kinds=all:显示所有泄漏类型
  • --track-origins=yes:追踪未初始化值来自哪里,速度更慢但定位更准
  • --num-callers=30:调用栈显示更多层

如果希望 CI 或脚本根据是否有泄漏返回失败,可以加:

1
2
3
4
valgrind \
--leak-check=full \
--error-exitcode=1 \
./app

只要 Valgrind 检测到错误,进程退出码就是 1,方便自动化检查。

8.3 一个最小泄漏例子

示例代码:

1
2
3
4
5
6
7
8
9
10
11
#include <iostream>

void leak() {
int* p = new int[10];
p[0] = 42;
}

int main() {
leak();
std::cout << "done\n";
}

编译并运行:

1
2
g++ -std=c++20 -g -O0 leak.cpp -o leak
valgrind --leak-check=full --show-leak-kinds=all ./leak

典型输出里会看到类似信息:

1
2
3
4
5
6
7
8
9
10
HEAP SUMMARY:
in use at exit: 40 bytes in 1 blocks

40 bytes in 1 blocks are definitely lost
at 0x...: operator new[](unsigned long)
by 0x...: leak() (leak.cpp:4)
by 0x...: main (leak.cpp:9)

LEAK SUMMARY:
definitely lost: 40 bytes in 1 blocks

重点看两处:

  • definitely lost:明确泄漏,优先修
  • 栈回溯:从 operator new[] 往下看,找到自己代码里的分配位置

修正方式:

1
2
3
4
5
6
7
8
9
10
11
12
#include <iostream>
#include <memory>

void no_leak() {
auto p = std::make_unique<int[]>(10);
p[0] = 42;
}

int main() {
no_leak();
std::cout << "done\n";
}

更推荐从所有权上修掉问题,而不是简单在所有路径上补 delete[]

8.4 Valgrind 的泄漏类型怎么看

Valgrind 常见泄漏分类:

类型 含义 优先级
definitely lost 已经没有任何指针能指向这块内存,明确泄漏 最高
indirectly lost 因为上层对象泄漏,导致它指向的子对象也泄漏
possibly lost Valgrind 只能找到疑似指针,比如指向内存中间位置
still reachable 程序退出时仍然有指针能访问,严格说不一定是泄漏 低到中
suppressed 被 suppression 规则忽略的报告 视情况

排查顺序通常是:

  1. 先修 definitely lost
  2. 再看 indirectly lost
  3. 根据业务判断 possibly lost
  4. 最后再处理 still reachable

still reachable 常见于:

  • 全局单例
  • 进程级缓存
  • 第三方库退出时没有显式释放的全局资源
  • 日志库、线程池、运行时库内部对象

它不一定要立刻修,但如果服务长期运行内存持续上涨,就不能只因为它是 still reachable 就忽略。

8.5 Memcheck 不只查泄漏

Memcheck 还能查很多典型内存错误。

越界写

1
2
3
int* p = new int[3];
p[3] = 10; // 越界
delete[] p;

Valgrind 可能报告:

1
Invalid write of size 4

越界读

1
2
3
int* p = new int[3];
int x = p[3]; // 越界
delete[] p;

Valgrind 可能报告:

1
Invalid read of size 4

Use After Free

1
2
3
int* p = new int(42);
delete p;
std::cout << *p << "\n"; // 释放后继续使用

Valgrind 可能报告:

1
2
Invalid read of size 4
Address ... is 0 bytes inside a block of size 4 free'd

重复释放

1
2
3
int* p = new int(42);
delete p;
delete p; // double free

Valgrind 可能报告:

1
Invalid free() / delete / delete[] / realloc()

new / delete[] 不匹配

1
2
int* p = new int[10];
delete p; // 错:应该 delete[]

Valgrind 可能报告:

1
Mismatched free() / delete / delete []

使用未初始化值

1
2
3
4
int x;
if (x > 0) {
std::cout << x << "\n";
}

Valgrind 可能报告:

1
Conditional jump or move depends on uninitialised value(s)

这种问题建议加 --track-origins=yes,否则只知道哪里用了未初始化值,不一定知道它最早从哪里来。

8.6 多进程和子进程

如果程序会 fork 或启动子进程,可以加:

1
2
3
4
valgrind \
--trace-children=yes \
--leak-check=full \
./app

--trace-children=yes 会让 Valgrind 继续跟踪子进程。

如果子进程很多,输出会很乱,建议先缩小测试范围,或者给不同进程写不同日志:

1
2
3
4
valgrind \
--trace-children=yes \
--log-file=valgrind.%p.log \
./app

其中 %p 会替换成进程 ID。

8.7 多线程程序能用 Memcheck 吗

能。

Memcheck 可以检查多线程程序里的:

  • 泄漏
  • 越界访问
  • use-after-free
  • double free
  • 未初始化内存使用

但 Memcheck 不是专门的数据竞争检测器。

也就是说:

1
2
多线程程序的内存错误 -> Memcheck 能查一部分
多线程数据竞争 / 锁问题 -> 用 Helgrind 或 DRD

8.8 Helgrind 查数据竞争

Helgrind 用来检查线程之间是否存在 data race。

典型命令:

1
2
3
4
valgrind \
--tool=helgrind \
--history-level=full \
./app

示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <thread>

int counter = 0;

void worker() {
for (int i = 0; i < 100000; ++i) {
++counter; // 多线程同时读写,没有同步
}
}

int main() {
std::thread t1(worker);
std::thread t2(worker);
t1.join();
t2.join();
}

编译时要带 pthread:

1
2
g++ -std=c++20 -g -O0 race.cpp -pthread -o race
valgrind --tool=helgrind ./race

典型输出里会看到:

1
2
Possible data race during read of size 4
Possible data race during write of size 4

核心意思是:

  • 至少两个线程访问了同一块内存
  • 至少一个访问是写
  • Helgrind 没看到足够的同步关系

修正方式之一是加锁:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <mutex>
#include <thread>

int counter = 0;
std::mutex m;

void worker() {
for (int i = 0; i < 100000; ++i) {
std::lock_guard<std::mutex> lock(m);
++counter;
}
}

int main() {
std::thread t1(worker);
std::thread t2(worker);
t1.join();
t2.join();
}

如果只是计数器,也可以用原子变量:

1
2
3
4
5
6
7
8
9
10
#include <atomic>
#include <thread>

std::atomic<int> counter{0};

void worker() {
for (int i = 0; i < 100000; ++i) {
counter.fetch_add(1, std::memory_order_relaxed);
}
}

8.9 Helgrind 能发现哪些线程问题

Helgrind 常见能发现:

  • 读写同一变量但没有锁
  • 一个线程写、另一个线程读但没有同步
  • 锁顺序不一致,存在潜在死锁风险
  • pthread mutex 使用错误
  • 条件变量使用不当

例如锁顺序不一致:

1
2
3
4
5
6
7
8
9
10
11
12
std::mutex a;
std::mutex b;

void f1() {
std::lock_guard<std::mutex> l1(a);
std::lock_guard<std::mutex> l2(b);
}

void f2() {
std::lock_guard<std::mutex> l1(b);
std::lock_guard<std::mutex> l2(a);
}

f1 是先锁 a 再锁 bf2 是先锁 b 再锁 a
如果两个线程同时执行,就可能互相等待。

修正方式:

1
2
3
void safe() {
std::scoped_lock lock(a, b);
}

std::scoped_lock 可以一次性锁多个 mutex,避免手写锁顺序。

8.10 DRD 查多线程问题

DRD 和 Helgrind 类似,也用于检查多线程程序。

命令:

1
2
3
valgrind \
--tool=drd \
./app

一般经验:

  • helgrind 对锁顺序、happens-before 分析比较常用
  • drd 对 pthread 使用问题也比较敏感
  • 两者可能报告不同问题
  • 多线程疑难问题可以两个都跑一遍

DRD 常见参数:

1
2
3
4
5
6
valgrind \
--tool=drd \
--check-stack-var=yes \
--exclusive-threshold=100 \
--shared-threshold=100 \
./app

含义粗略理解:

  • --check-stack-var=yes:检查栈变量上的数据竞争,可能产生更多报告
  • --exclusive-threshold:锁被某线程独占持有太久时报告
  • --shared-threshold:读写锁共享持有太久时报告

初学时先不用加太多参数,先跑:

1
valgrind --tool=drd ./app

8.11 Helgrind / DRD 的误报和限制

多线程检测工具不是绝对真理。

常见误报来源:

  • 使用了工具不认识的自定义同步原语
  • lock-free 数据结构
  • 原子操作和内存序比较复杂
  • 第三方库内部同步方式特殊
  • 线程池、协程运行时、系统库内部实现

排查时不要只看最后一行错误,要看:

  • 哪块内存被多个线程访问
  • 哪些线程在读写
  • 是否真的缺少同步
  • 是否所有访问都使用同一把锁
  • 是否有 happens-before 关系

如果确认是第三方库内部误报,可以使用 suppression 文件过滤。

8.12 Suppression 文件

有些报告来自标准库、第三方库或已确认的无害路径。
可以用 suppression 文件减少噪音。

先生成候选规则:

1
2
3
4
valgrind \
--leak-check=full \
--gen-suppressions=all \
./app

把确认要忽略的规则保存到:

1
valgrind.supp

运行时使用:

1
2
3
4
valgrind \
--leak-check=full \
--suppressions=valgrind.supp \
./app

注意:

  • suppression 只应该用于降低噪音
  • 不要把自己业务代码里的真实泄漏压掉
  • 每条 suppression 最好写注释说明为什么忽略

8.13 和 Sanitizer 怎么配合

Sanitizer 和 Valgrind 不是谁替代谁。

更实用的分工:

工具 优点 缺点 适合
ASan / LSan 快,适合开发和 CI 需要重新编译,某些环境受限 新代码、单元测试
TSan 查数据竞争强 慢,内存开销大,需要编译插桩 多线程数据竞争
Valgrind Memcheck 不需要插桩,历史二进制也能查一部分 很慢,对平台支持有限 存量代码、泄漏排查
Helgrind / DRD 不需要 TSan 编译,能查线程问题 慢,可能误报 多线程疑难问题辅助排查

一个常见工程策略:

1
2
3
开发阶段:ASan / LSan / TSan
存量排查:Valgrind Memcheck
多线程疑难问题:TSan + Helgrind / DRD 交叉验证

8.14 实战排查流程

推荐流程:

  1. 先写一个能稳定复现问题的最小场景
  2. 用 Debug 构建编译,保留 -g
  3. 先跑 memcheck,修掉明确内存错误
  4. 再看 definitely lostindirectly lost
  5. 如果是多线程问题,再跑 helgrinddrd
  6. 对每条报告定位到自己的代码调用栈
  7. 修复后重新跑同一个场景确认报告消失
  8. 最后把关键场景固化成测试

命令模板:

1
2
3
4
5
6
7
8
9
10
11
12
13
# 1. 内存泄漏和内存错误
valgrind --tool=memcheck \
--leak-check=full \
--show-leak-kinds=all \
--track-origins=yes \
--error-exitcode=1 \
./app

# 2. 多线程 data race
valgrind --tool=helgrind ./app

# 3. 另一种线程检查
valgrind --tool=drd ./app

8.15 Valgrind 报告怎么看

看报告时按这个顺序:

  1. 错误类型
    例如 Invalid writeInvalid readdefinitely lostPossible data race

  2. 访问大小
    例如 size 4 通常对应 intsize 8 可能对应指针或 long long

  3. 错误地址属于哪里
    Valgrind 经常会提示这块地址是在已释放内存里、堆块边界外,还是栈上。

  4. 分配栈
    这块内存在哪里 new / malloc 出来的。

  5. 释放栈
    如果是 use-after-free,要看它在哪里已经被释放。

  6. 当前访问栈
    这次非法读写发生在哪里。

实际修 bug 时,最关键的是:

1
分配位置 + 释放位置 + 出错访问位置

把这三条路径串起来,通常就能看出生命周期哪里错了。


9. 工程里的泄漏管理清单

真正有效的治理,通常不是靠某一个工具,而是靠几条长期规则:

  • 新代码默认不写“拥有语义的裸指针”
  • 业务代码里尽量不直接出现 new / delete
  • 工厂接口优先返回 std::unique_ptr
  • 容器里优先放对象或智能指针,不放“需要人工回收”的裸指针
  • 第三方资源在进入系统边界时立即封装成 RAII 类型
  • shared_ptr 关系图需要专门检查循环引用
  • 测试或 CI 中固定开启 Sanitizer 版本
  • 对缓存、对象池、队列设置上限,而不是默认无限增长

这才叫“管理”,不是出了问题再临时抓日志。


10. 一页总结

最值得记住的五条:

  1. 泄漏治理的核心不是“记得释放”,而是明确所有权
  2. 默认优先值语义、栈对象和 RAII
  3. 动态分配优先 unique_ptr,不是裸指针
  4. shared_ptr 最大的风险是循环引用
  5. 用 Sanitizer 和 Valgrind 查问题,不要靠肉眼猜

参考:https://zhuanlan.zhihu.com/p/15101814919
如果只记一句:

预防内存泄漏最有效的方法,不是手写更多 delete,而是让代码结构默认不需要手写 delete