楚天

惟楚有材,于斯为盛

C++20 协程入门与实践

时间:2026/04/09

关键词:co_awaitco_returnco_yieldpromise_type、挂起点、异步流程、generator
核心目标:理解协程为什么不是“更轻的线程”,而是一种把异步控制流写成顺序代码的语言机制。


1. 协程到底解决什么问题

传统异步代码常见两个问题:

  • 回调层层嵌套
  • 状态机代码难写难读

协程的核心价值是:

  • 把“会暂停、稍后恢复”的流程写成看起来接近顺序代码的形式

所以它更像:

  • 语言级状态机生成器

而不是线程替代品。


2. 协程不是线程

这点最重要。

协程:

  • 默认不并行
  • 默认不自动切线程
  • 只是能挂起和恢复

线程:

  • 是操作系统调度实体
  • 真正涉及并行执行

所以:

  • 协程解决的是“控制流组织”
  • 线程解决的是“执行资源”

3. 三个关键字

3.1 co_await

等待某个可等待对象,并可能挂起当前协程。

3.2 co_return

从协程返回结果。

3.3 co_yield

常用于生成器场景,逐个产出值。


4. 编译器视角下协程发生了什么

当函数里出现协程关键字后,它会被编译器改写成:

  • 一个协程状态对象
  • 一个 promise_type
  • 若干挂起点
  • 一个恢复入口

也就是说,协程本质上是:

  • 编译器自动帮你拆出来的状态机

5. promise_type 是什么

如果一个返回类型想作为协程返回对象,就需要配套定义:

  • promise_type

它负责描述:

  • 协程如何创建
  • 如何返回值
  • 初始/最终是否挂起
  • 异常怎么处理

这是 C++ 协程里最“底层”的部分。


6. 一个最小协程返回类型骨架

1
2
3
4
5
6
7
8
9
10
11
#include <coroutine>

struct Task {
struct promise_type {
Task get_return_object() { return {}; }
std::suspend_never initial_suspend() noexcept { return {}; }
std::suspend_never final_suspend() noexcept { return {}; }
void return_void() {}
void unhandled_exception() { std::terminate(); }
};
};

这样一个返回 Task 的函数就可以写成协程。


7. co_await 的直觉

写:

1
co_await something;

编译器会尝试把 something 变成一个 awaitable,并调用:

  • await_ready()
  • await_suspend()
  • await_resume()

可以粗略理解为:

  1. 先问要不要挂起
  2. 如果挂起,如何安排恢复
  3. 恢复后返回什么结果

8. generator 场景为什么适合协程

例如想逐个生成值:

  • 传统写法要手工保存状态
  • 协程可以自然写成“产出一个,暂停,再继续”

这正是 co_yield 最直观的用法。

所以协程特别适合:

  • lazy sequence
  • parser
  • pipeline
  • 异步流

9. 协程最常见的工程用途

9.1 异步 I/O

把:

  • 发请求
  • 等待回包
  • 继续处理

写成线性流程。

9.2 生成器

按需逐个产出数据。

9.3 Actor / 任务系统

用协程表达暂停与恢复点。


10. 协程为什么很容易“看起来简单,实际上不简单”

因为源码很线性,但真实问题仍然存在:

  • 生命周期谁管
  • 在哪条线程恢复
  • 恢复时机谁触发
  • 异常怎么传播
  • 取消怎么处理

也就是说:

  • 协程简化的是控制流表达
  • 没有消灭异步系统的本质复杂度

11. 一个最重要的工程问题:恢复在哪发生

协程本身不决定线程。
真正决定“恢复在哪个执行器/事件循环/线程池”的,是 awaitable 或运行时框架。

所以写协程框架时一定要搞清楚:

  • 谁调 resume
  • 什么时候调
  • 在哪调

12. 常见坑

12.1 把协程当轻量线程

这是最常见误解。

12.2 忽略返回对象和 promise 生命周期

协程帧如果没人管理,容易泄漏或悬空。

12.3 只学语法,不理解恢复机制

这样很快就会在真实异步项目里迷路。

12.4 在协程里捕获悬空引用

因为协程可能挂起很久,引用生命周期尤其要小心。


13. 一页总结

协程最重要的理解链是:

  1. 协程不是线程,而是可挂起函数
  2. 编译器会把协程改写成状态机
  3. co_await 的核心是等待、挂起和恢复
  4. 真正工程难点在生命周期、调度器和恢复时机

如果只记一句:

协程的价值是把异步流程写直,而不是把并发问题自动解决掉。

现代 C++ 常用工具类型

时间:2026/04/09

关键词:optionalvariantanystring_viewspanexpected
核心目标:掌握几个现代 C++ 里非常高频、能直接改善接口表达和代码质量的标准库类型。


1. 为什么这些类型重要

现代 C++ 的很多进步,不只在语法,还在于:

  • 用更明确的类型表达意图

比如:

  • “可能没有值”
  • “可能是多种类型之一”
  • “只读字符串视图”
  • “一段连续内存视图”

这些都不该继续靠:

  • nullptr
  • 魔法值
  • void*
  • 裸指针 + 长度

来硬撑。


2. std::optional<T>:可能有,也可能没有

1
2
3
#include <optional>

std::optional<int> find_id();

它适合表达:

  • 成功返回一个值
  • 失败时没有值

比“返回 -1 表示失败”更清晰。

常用接口:

  • has_value()
  • value()
  • value_or(default)

3. std::variant:类型安全的联合体

1
2
3
#include <variant>

std::variant<int, std::string> v;

它表示:

  • 值一定是若干候选类型中的一个

相比传统 union,它:

  • 类型安全
  • 自动管理对象生命周期

配合 std::visit 很常见。


4. std::any:完全动态类型

1
2
3
4
#include <any>

std::any x = 42;
x = std::string("hello");

它适合:

  • 真正不知道运行期会是什么类型

但代价是:

  • 类型信息晚到运行期
  • 可读性和性能都不如 variant

经验上:

  • 能用 variant 就不要先上 any

5. std::string_view:无拷贝字符串视图

1
2
3
#include <string_view>

void print(std::string_view s);

优点:

  • 不拥有字符串
  • 不分配内存
  • 可接 std::string、字面量、子串视图

风险:

  • 它不延长底层字符串生命周期

所以不能把它保存得比源字符串活得更久。


6. std::span<T>:无拷贝连续内存视图

1
2
3
#include <span>

void process(std::span<const int> xs);

适合:

  • 数组
  • std::vector
  • std::array

相比传:

  • 裸指针 + 长度

更清晰,也更安全。


7. std::expected:值或错误

如果你的环境支持 C++23,可以关注:

1
std::expected<Value, Error>

它适合表达:

  • 成功时返回值
  • 失败时返回明确错误信息

相比 optional,它多了:

  • 为什么失败

8. 这些类型最核心的接口收益

8.1 optional

不再靠魔法值表示“没有结果”。

8.2 variant

不再靠手写 tag + union。

8.3 string_view

函数参数更轻、更泛化。

8.4 span

数组接口更现代。


9. 常见坑

9.1 string_view / span 生命周期错误

它们都只是视图,不拥有数据。

9.2 用 any 代替清晰设计

很多时候 any 只是把类型问题往后拖。

9.3 optional 里塞重对象却频繁拷贝

虽然语义清晰,但也要注意值类别和性能。


10. 一页总结

这几个类型的价值可以压缩成一句话:

用更准确的标准库类型表达接口语义,减少魔法值、裸指针和不透明约定。

最值得优先掌握的顺序通常是:

  1. optional
  2. string_view
  3. span
  4. variant
  5. any
  6. expected

网络服务基础:TCP 粘包、线程模型与 HTTP(S)

时间:2026/04/09

关键词:TCP stream、粘包拆包、长度前缀、分隔符、线程池、epoll、TLS、wrk
核心目标:建立服务端编程的基本工程直觉,先把“消息边界、并发模型、协议分层”分清楚。


1. TCP 为什么会有“粘包”问题

因为 TCP 是字节流,不是消息流。

这意味着:

  • 发送端发了两次 send
  • 接收端不一定就对应收到两次 recv

接收端看到的只是连续字节流,所以必须自己定义消息边界。


2. 两种最常见的拆包方案

2.1 长度前缀

格式示例:

1
[4字节长度][payload]

接收端流程:

  1. 先读够头部
  2. 解析长度
  3. 再继续读够 payload

这通常是最通用、最可靠的做法。

2.2 分隔符协议

例如按 \n 分隔:

1
hello\nworld\n

优点:

  • 简单直观

缺点:

  • payload 中若可能出现分隔符,需要转义或编码

3. 长度前缀接收缓冲的核心思路

接收端通常需要一个累积缓冲区:

1
std::vector<std::uint8_t> inbuf;

每次 recv 到数据后:

  • 先追加到 inbuf
  • 再循环解析完整包

关键不是“每次只解析一个包”,而是:

  • 一次 recv 可能带来 0.5 个包、1 个包、或者多个包

4. 为什么不能假设一次 recv 就是一条完整消息

因为可能出现:

  • 半包
  • 多包合并
  • 多次拆散

所以网络编程第一条纪律就是:

任何协议都必须先定义并实现消息 framing。


5. 线程模型的几种常见选择

5.1 每连接一个线程

优点:

  • 思维简单

缺点:

  • 连接数一大就撑不住

5.2 单线程事件循环

优点:

  • 资源利用率高

缺点:

  • 业务阻塞会拖住整个 loop

5.3 Reactor + 线程池

常见工程做法:

  • I/O 线程负责收发和事件分发
  • 工作线程池处理业务

这样可以同时兼顾:

  • 网络扩展性
  • 业务并发

6. epoll / kqueue / IOCP` 在解决什么问题

这些机制本质上都在解决:

  • 如何高效等待大量连接上的 I/O 事件

Linux 上最常见的是:

  • epoll

它不是协议,也不是线程池,而是:

  • 一个事件通知机制

7. HTTP 和 HTTPS 不要混成一层

7.1 HTTP

应用层协议,定义:

  • 请求行
  • 头部
  • body

7.2 HTTPS

可以粗略理解成:

  • HTTP over TLS

也就是:

  • 先做 TLS 加密通道
  • 再在其上跑 HTTP

所以 HTTPS 的复杂度不仅来自 HTTP,还来自:

  • 握手
  • 证书
  • 加解密

8. 一个服务端最基础的工程分层

可以先按这几层理解:

  1. socket 层
  2. 缓冲区与 framing 层
  3. 协议解析层
  4. 业务处理层
  5. 线程模型 / 调度层

很多初学者的问题是把所有逻辑都揉进一次 recv 回调里,后期几乎无法维护。


9. 线程池在网络服务里的角色

网络服务中,线程池通常不应该负责:

  • 直接阻塞式读写所有 socket

更常见的是负责:

  • 处理业务任务
  • 数据库访问
  • CPU 密集计算
  • 日志/异步处理

也就是说:

  • I/O 线程负责“把事情收进来”
  • 线程池负责“把事情做完”

10. TLS / HTTPS 的最小认知

只要记住这几点就够做入门框架理解:

  • TLS 负责加密通道
  • 证书用于身份认证
  • HTTPS 不是“新的 HTTP 消息格式”,而是多了一层安全传输

工程上常见做法是:

  • 直接用成熟库处理 TLS
  • 不自己从零实现加密协议

11. 压测为什么重要

服务端“能跑”不等于“能扛”。
至少要关注:

  • QPS
  • 延迟
  • P99
  • 错误率
  • CPU / 内存占用

wrk 是一个常用 HTTP 压测工具,适合快速做吞吐和延迟观察。


12. 常见坑

12.1 把 TCP 当消息协议

这会直接导致粘包拆包错误。

12.2 收到数据就立刻假设包完整

半包是常态,不是例外。

12.3 网络线程直接做重业务

会拖慢整个事件循环。

12.4 自己手写 TLS 协议栈

不现实,也没必要。


13. 一页总结

服务端编程最重要的理解链是:

  1. TCP 只有字节流,没有消息边界
  2. 所以必须先做 framing
  3. 高并发服务通常要把 I/O 与业务处理分层
  4. HTTPS = HTTP + TLS,不是单纯“更安全的 socket”

如果只记一句:

网络服务首先是“协议边界和并发模型”问题,其次才是 API 调用问题。

对象生命周期、特殊成员函数与移动语义

时间:2026/04/09

关键词:生命周期、RAII、拷贝构造、拷贝赋值、移动构造、移动赋值、Rule of Zero/Five、std::move
核心目标:搞清楚一个对象从创建到销毁会经历什么,以及类该如何正确管理资源。


1. 对象生命周期是什么

一个对象通常会经历:

  1. 构造
  2. 使用
  3. 析构

最重要的实践原则是:

  • 对象一旦构造完成,就应该处于“可用且满足类不变量”的状态
  • 对象一旦析构完成,就不该再被访问

局部对象在离开作用域时析构:

1
2
3
void f() {
std::string s = "hello";
} // 这里自动析构

动态对象则由拥有者负责释放:

1
auto p = std::make_unique<int>(42);

2. RAII 是生命周期管理的核心

RAII 的意思是:

  • 构造时获取资源
  • 析构时释放资源

典型资源包括:

  • 动态内存
  • 文件句柄
  • socket
  • 线程句柄

RAII 的价值不是“语法优雅”,而是:

  • 不容易忘记释放
  • 异常发生时也能自动清理

3. 六个特殊成员函数

一个类最重要的 6 个函数是:

  1. 默认构造函数
  2. 析构函数
  3. 拷贝构造函数
  4. 拷贝赋值运算符
  5. 移动构造函数
  6. 移动赋值运算符

它们决定了对象如何:

  • 创建
  • 复制
  • 转移资源
  • 销毁

4. 拷贝构造 vs 拷贝赋值

4.1 拷贝构造

用一个对象去初始化另一个“新对象”:

1
2
T b(a);
T c = a;

4.2 拷贝赋值

把一个已经存在的对象覆盖成另一个对象的状态:

1
2
T b;
b = a;

核心差异:

  • 拷贝构造是“从无到有”
  • 拷贝赋值是“已有对象被覆盖”

后者通常还要考虑:

  • 旧资源释放
  • 自赋值
  • 异常安全

5. 为什么移动语义很重要

如果一个类持有资源,单纯拷贝代价可能很大。

例如:

  • 动态数组
  • 大字符串
  • 文件句柄包装对象

移动语义的核心思想是:

  • 不复制资源内容
  • 直接转移所有权或资源句柄

这就需要:

  • 移动构造
  • 移动赋值

6. std::move 到底在做什么

std::move 本身不移动资源。
它只是把一个表达式转换成右值形式,让后续重载决议优先匹配移动版本。

1
2
std::string s = "hello";
std::string t = std::move(s);

真正移动的是:

  • std::string 的移动构造函数

不是 std::move 本身。


7. 一个最小资源类示例

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
#include <algorithm>
#include <cstddef>

class Buffer {
public:
Buffer() = default;

explicit Buffer(std::size_t n)
: size_(n), data_(n ? new int[n] : nullptr) {}

~Buffer() {
delete[] data_;
}

Buffer(const Buffer& other)
: size_(other.size_), data_(other.size_ ? new int[other.size_] : nullptr) {
std::copy(other.data_, other.data_ + size_, data_);
}

Buffer& operator=(const Buffer& other) {
if (this == &other) return *this;
Buffer tmp(other);
swap(tmp);
return *this;
}

Buffer(Buffer&& other) noexcept
: size_(other.size_), data_(other.data_) {
other.size_ = 0;
other.data_ = nullptr;
}

Buffer& operator=(Buffer&& other) noexcept {
if (this == &other) return *this;
delete[] data_;
size_ = other.size_;
data_ = other.data_;
other.size_ = 0;
other.data_ = nullptr;
return *this;
}

void swap(Buffer& other) noexcept {
std::swap(size_, other.size_);
std::swap(data_, other.data_);
}

private:
std::size_t size_ = 0;
int* data_ = nullptr;
};

这个例子体现了:

  • 深拷贝
  • 资源转移
  • 移动后源对象置空

8. Rule of Three / Five / Zero

8.1 Rule of Three

如果类手写了下面之一,通常就要认真考虑另外两个:

  • 析构
  • 拷贝构造
  • 拷贝赋值

因为这通常意味着类在管理资源。

8.2 Rule of Five

C++11 以后再加上:

  • 移动构造
  • 移动赋值

如果类显式管理资源,通常要一起考虑这五个。

8.3 Rule of Zero

现代 C++ 更推荐:

  • 尽量不要自己手写资源管理
  • 把资源交给现成 RAII 类型

例如:

  • std::vector
  • std::string
  • std::unique_ptr

这样很多特殊成员函数甚至可以完全默认生成。


9. 默认生成和 = default / = delete

9.1 = default

显式告诉编译器:

  • 用默认生成版本
1
MyType() = default;

9.2 = delete

显式禁止某种操作:

1
2
MyType(const MyType&) = delete;
MyType& operator=(const MyType&) = delete;

这在:

  • 独占资源类型
  • 锁对象
  • 文件句柄包装类

里很常见。


10. 拷贝省略与返回值优化

现代 C++ 里:

1
2
3
4
Buffer make_buffer() {
Buffer b(1024);
return b;
}

很多情况下不会真的发生拷贝,甚至连移动都可能被省掉。
这就是:

  • RVO
  • NRVO
  • guaranteed copy elision

所以写代码时不要过度手工干预,先让编译器优化。


11. 常见坑

11.1 移动后还把源对象当原值使用

移动后的对象通常只保证:

  • 仍然有效
  • 可以析构或重新赋值

但不保证保留原内容。

11.2 手写资源类却只写析构,不写拷贝/移动

这很容易造成:

  • 双重释放
  • 浅拷贝问题

11.3 拷贝赋值没处理自赋值和异常安全

尤其是手动 deletenew 的写法,容易把对象弄到半残状态。

11.4 本来可以 Rule of Zero,却硬写五个函数

不必要的手写资源管理会增加 bug 面积。


12. 一页总结

这篇最重要的是记住三件事:

  1. 生命周期就是“构造到析构”的可控过程
  2. 管资源的类必须认真处理拷贝和移动
  3. 最好的实践通常不是手写五件套,而是尽量 Rule of Zero

如果只记一个工程结论:

能把资源交给现成 RAII 类型,就不要自己手写裸资源生命周期。


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

  1. 智能指针与所有权
  2. 完美转发与引用折叠
  3. 异常安全保证
  4. noexcept move 与容器优化

allocator、自定义内存分配与 pmr 入门

时间:2026/04/09

关键词:std::allocatorallocator_traits、分配与构造分离、状态型分配器、std::pmr
核心目标:理解 STL 容器如何管理内存,以及什么时候值得定制分配器。


1. 为什么 allocator 存在

容器不仅要“放元素”,还要处理:

  • 申请原始内存
  • 在内存上构造对象
  • 销毁对象
  • 释放内存

标准库把这层职责抽象成 allocator。


2. std::allocator 的核心职责

可以粗略理解成四步:

  1. allocate(n):分配能容纳 n 个对象的原始内存
  2. construct(...):在指定位置构造对象
  3. destroy(...):调用析构
  4. deallocate(...):释放内存

现代实现中更常通过:

  • std::allocator_traits

来统一调度这些接口。


3. 为什么“分配”和“构造”是两件事

因为拿到一块内存,不等于对象已经存在。

1
2
T* p = alloc.allocate(4); // 只有内存
std::construct_at(p, value); // 这里对象才真正构造

这也是容器能高效管理未初始化存储区的基础。


4. 一个最小自定义分配器骨架

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

template <class T>
struct MyAllocator {
using value_type = T;

MyAllocator() = default;

template <class U>
MyAllocator(const MyAllocator<U>&) {}

T* allocate(std::size_t n) {
return static_cast<T*>(::operator new(n * sizeof(T)));
}

void deallocate(T* p, std::size_t) {
::operator delete(p);
}
};

配合容器使用:

1
std::vector<int, MyAllocator<int>> v;

5. 什么时候值得自定义 allocator

不是所有项目都需要。

更常见的适用场景:

  • 频繁小对象分配
  • 想用内存池减少碎片
  • 想把对象放到特定区域
  • 需要统计分配行为
  • 游戏/服务端有帧级或 arena 分配需求

如果只是普通业务代码,标准分配器通常已经够用。


6. 状态型分配器

有些分配器不仅有类型,还有内部状态,例如:

  • 指向某个内存池
  • 指向某个 arena

这类分配器要特别注意:

  • 拷贝行为
  • 容器复制/移动时状态如何传播

这也是 allocator_traits 很重要的原因之一。


7. std::pmr:现代 C++ 更实用的内存资源抽象

C++17 提供了 std::pmr

  • polymorphic memory resource

它把“分配策略”从模板参数层搬到了运行时资源对象层。

最常见组件:

  • std::pmr::memory_resource
  • std::pmr::polymorphic_allocator
  • std::pmr::vector
  • std::pmr::monotonic_buffer_resource

这通常比手写 allocator 模板更实用。


8. monotonic_buffer_resource 的直觉

这种资源很适合:

  • 批量分配
  • 很少单独释放
  • 整体回收

例如一次请求、一次帧更新、一次解析过程。

好处:

  • 分配快
  • 局部性好

代价:

  • 单个对象通常不能灵活归还给池

9. 和容器性能的关系

allocator 影响的通常不是接口语义,而是:

  • 分配次数
  • 分配成本
  • 碎片
  • 局部性

但要注意:

  • allocator 优化通常排在算法和数据布局之后
  • 不要在没有证据前,把 allocator 当成首要瓶颈

10. 常见误区

10.1 把 allocator 当成“所有性能问题的解药”

很多性能问题其实更可能出在:

  • 数据布局
  • 扩容策略
  • 锁争用
  • 随机访问

10.2 只会写 allocate/deallocate,却不理解对象构造时机

allocator 真正重要的是:

  • 内存和对象是分开的

10.3 在没有统一资源模型时滥用自定义 allocator

结果容易让代码复杂度大幅上升。


11. 一页总结

allocator 这篇最值得记住的是:

  1. 容器管理的是“原始内存 + 对象构造/销毁”
  2. allocator 负责内存来源
  3. allocator_traits 是现代实现的核心适配层
  4. 真正工程里,std::pmr 往往比手写 allocator 更实用

如果只记一句:

allocator 优化通常是进阶优化,前提是你已经把算法、数据布局和容器选择做对了。

智能指针与所有权

时间:2026/04/09

关键词:所有权、观察者、unique_ptrshared_ptrweak_ptr、自定义删除器
核心目标:把“谁负责释放资源”这件事表达清楚,而不是靠约定和记忆。


1. 为什么现代 C++ 强调所有权

裸指针只能表达:

  • “这里有个地址”

但它不能天然表达:

  • 谁拥有这个对象
  • 谁负责释放
  • 是否允许共享

现代 C++ 实践里,第一件要说清的就是所有权。


2. 三种常见关系

2.1 拥有(owning)

对象负责管理资源生命周期。

2.2 观察(non-owning)

对象只访问资源,不负责释放。

2.3 共享拥有(shared owning)

多个对象共同延长同一资源生命周期。


3. unique_ptr:默认首选

1
2
3
#include <memory>

auto p = std::make_unique<int>(42);

特点:

  • 独占所有权
  • 不可拷贝
  • 可移动
  • 开销低

经验上:

  • 只要不是明确需要共享,优先用 unique_ptr

4. shared_ptr:共享拥有

1
2
auto p1 = std::make_shared<std::string>("hello");
auto p2 = p1;

特点:

  • 引用计数
  • 多个拥有者
  • 生命周期更灵活

代价:

  • 控制块
  • 原子计数开销
  • 更复杂的所有权关系

所以不要把它当默认选项。


5. weak_ptr:打破循环

weak_ptr 不拥有对象,只是观察。

1
2
3
4
std::weak_ptr<Foo> weak = shared;
if (auto sp = weak.lock()) {
// 对象还活着
}

它最重要的作用是:

  • 避免两个 shared_ptr 互相引用导致循环泄漏

6. 原则:拥有和观察要分开

推荐的接口风格通常是:

1
2
3
4
void take(std::unique_ptr<Foo> p); // 接管所有权
void use(Foo& x); // 一定存在,只观察
void maybe(Foo* p); // 可为空观察
void share(std::shared_ptr<Foo> p);// 共享拥有

这比“什么都传裸指针”更清楚。


7. 自定义删除器

有些资源不是 delete 释放,例如:

  • FILE*fclose
  • malloc 对应 free

可以这样包装:

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

using FilePtr = std::unique_ptr<FILE, int(*)(FILE*)>;

FilePtr open_file(const char* path) {
return FilePtr(std::fopen(path, "r"), std::fclose);
}

8. 常见误区

8.1 裸指针默认表示拥有

不推荐。
裸指针更适合表达观察关系。

8.2 到处用 shared_ptr

这会让生命周期图变得混乱,还会带来额外开销。

8.3 从 unique_ptrget() 拿到裸指针后乱删

get() 只是观察,不转移所有权。


9. 一页总结

最值得记住的顺序是:

  1. 默认值语义
  2. 必须动态分配时优先 unique_ptr
  3. 确实共享拥有时才用 shared_ptr
  4. 观察关系用引用、裸指针或 weak_ptr

如果只记一句:

智能指针不是为了“更高级”,而是为了把所有权表达清楚。

高性能 C++ 并行编程导读

时间:2026/04/09

这组笔记现在按“从语言与内存基础,到 CPU 并行,再到 GPU 与实战”的顺序整理。
阅读时不建议完全按时间写作顺序看,而建议按下面这条主线走。


1. 推荐学习顺序

  1. C 指针与内存模型
  2. RAII 与智能指针
  3. 左右值、完美转发与引用折叠
  4. 模板与元编程
  5. vector 容器优化
  6. 编译器优化与汇编视角
  7. 访存优化
  8. C++11 多线程编程
  9. 原子操作、内存序与无锁基础
  10. TBB 并行编程
  11. CUDA 开启的 GPU 编程
  12. 流体仿真实战

2. 每篇笔记解决什么问题

编号 主题 核心问题
[01] C 指针与内存模型 地址、生命周期、数组退化、别名与缓存直觉
[02] RAII 与智能指针 资源如何自动释放,所有权如何表达
[03] 左右值、完美转发与引用折叠 std::move / std::forward 到底在做什么
[04] 模板与元编程 泛型、类型推导、if constexpr、traits、concepts
[05] vector 容器优化 连续内存、扩容、失效规则、并发写入模式
[06] 编译器优化与汇编视角 编译器如何重写 C++,哪些写法更利于优化
[07] 访存优化 cache、AoS/SoA、stride、blocking、false sharing
[08] C++11 多线程编程 thread/mutex/condition_variable/future/thread pool
[09] 原子操作、内存序与无锁基础 atomic、CAS、acquire/release、seq_cst
[10] TBB 并行编程之旅 task、并行循环、归约、scan、arena、pipeline
[11] CUDA 开启的 GPU 编程 kernel、内存、shared memory、stream、Thrust
[12] 流体仿真实战 把 CUDA、访存、数值迭代放到一个完整案例里

3. 这次整理做了什么

这次不是只改文件名,而是做了三类整理:

  1. 把空白或草稿笔记补成正式版:
    C 指针RAII编译器优化访存优化流体仿真
  2. 新增了一篇缺失但关键的笔记:
    原子操作、内存序与无锁基础
  3. 统一为按内容排序的 [NN]xxx.md 命名

4. 如果只想抓主干

时间有限时,可以先看这 6 篇:

  1. C 指针与内存模型
  2. RAII 与智能指针
  3. 左右值、完美转发与引用折叠
  4. vector 容器优化
  5. 访存优化
  6. C++11 多线程编程

这几篇能先把 C++ 高性能和并行里最常见的基础坑位补齐。


5. 接下来的自然扩展

如果后面继续补这套笔记,最值得优先新增的主题是:

  1. SIMD 与自动向量化
  2. Benchmark 与性能分析方法
  3. NUMA 与多路 CPU 内存访问
  4. std::pmr 与内存池
  5. C++20/23 并发工具:jthread / latch / barrier / semaphore

原子操作、内存序与无锁基础

时间:2026/04/09

关键词:std::atomic、CAS、memory order、acquire/release、seq_cst、false sharing、lock-free
核心目标:搞清楚“原子变量为什么不仅是线程安全的普通变量”,以及不同内存序到底在约束什么。


1. 为什么需要原子操作

并发程序里最核心的问题是:

  • 多个线程会同时访问共享数据
  • 如果至少一个线程写,且没有同步,就会产生数据竞争

例如:

1
2
int counter = 0;
// 多线程同时 ++counter;

这不是“结果偶尔不准”,而是未定义行为。

原子操作的价值就在于:

  • 某些共享读写可以不加互斥锁
  • 但仍然具备明确同步语义

2. std::atomic 是什么

最基本的用法:

1
2
3
#include <atomic>

std::atomic<int> counter{0};

它提供的不是“更快的 int”,而是:

  • 不会被撕裂的原子读写
  • 受内存模型约束的同步语义

常见操作:

  • load
  • store
  • fetch_add
  • fetch_sub
  • exchange
  • compare_exchange_weak
  • compare_exchange_strong

3. 原子不等于万能替代锁

原子适合的场景通常是:

  • 计数器
  • 标志位
  • 状态发布
  • 无锁数据结构中的基本原语

不适合的场景通常是:

  • 需要保护一整段复合逻辑
  • 需要同时维护多个共享变量的不变式
  • 业务逻辑复杂,容易写错同步关系

一句话:

  • 原子擅长保护“一个共享状态”
  • 锁擅长保护“一个临界区”

4. 最基本的原子操作

4.1 load / store

1
2
3
std::atomic<int> x{0};
x.store(10);
int v = x.load();

4.2 fetch_add

1
2
std::atomic<int> cnt{0};
cnt.fetch_add(1);

这比 cnt = cnt + 1 更重要,因为它是一个不可分割的原子读改写。

4.3 exchange

1
bool old = flag.exchange(true);

含义:

  • flag 设为 true
  • 返回旧值

5. CAS:无锁算法的核心原语

CAS 指 Compare-And-Swap(或 Compare-And-Exchange)。

5.1 直觉理解

1
2
3
如果当前值仍然等于 expected,
就把它改成 desired;
否则不改,并告诉我失败了。

5.2 C++ 里的写法

1
2
3
std::atomic<int> x{0};
int expected = 0;
bool ok = x.compare_exchange_strong(expected, 1);

如果成功:

  • x 变成 1
  • 返回 true

如果失败:

  • x 保持原值
  • expected 会被写成当前实际值

5.3 weakstrong

  • compare_exchange_weak:允许伪失败,适合循环重试
  • compare_exchange_strong:不允许伪失败,语义更直接

常见模式:

1
2
3
4
int expected = old;
while (!x.compare_exchange_weak(expected, new_value)) {
// expected 已被更新为当前值,继续重试
}

6. “原子”到底保证了什么

原子操作通常有两层含义:

6.1 操作本身不可分割

例如 fetch_add 不会被拆成“先读、再改、再写”被别的线程插进来。

6.2 它还可能携带同步顺序语义

这就进入内存序问题。

如果只知道“原子不会撕裂”,还远远不够。


7. 为什么会有内存序

现代 CPU 和编译器都会重排指令,只要不改变单线程可观察结果就行。
但并发下,如果没有同步约束,另一个线程看到的顺序可能和源码顺序不一样。

所以原子操作不只是“安全读写”,还承担:

  • 约束编译器重排
  • 约束 CPU 可见性顺序

这就是 memory_order 的意义。


8. 六种常见内存序

C++ 里最常见的有:

  • memory_order_relaxed
  • memory_order_consume
  • memory_order_acquire
  • memory_order_release
  • memory_order_acq_rel
  • memory_order_seq_cst

实际工程里最常用、最值得掌握的是:

  • relaxed
  • acquire
  • release
  • acq_rel
  • seq_cst

consume 在现代工程里很少主动使用。


9. relaxed:只保证原子性,不保证顺序

1
counter.fetch_add(1, std::memory_order_relaxed);

适合:

  • 纯计数
  • 统计量
  • 不需要借此发布其他数据

不适合:

  • “先写数据,再置位通知别人读取”

因为 relaxed 不保证其他普通内存写入的可见顺序。


10. release / acquire:发布与获取

这是最重要的一组。

10.1 发布方:release

1
2
data = 42;
ready.store(true, std::memory_order_release);

含义可以粗略理解为:

  • 在这之前的写入,不能被重排到这个 store 之后

10.2 获取方:acquire

1
2
3
if (ready.load(std::memory_order_acquire)) {
use(data);
}

如果这个 acquire load 读到了对应的 release store 写入的值,那么发布前的写入也对当前线程可见。

10.3 最经典的发布-订阅模式

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

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

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

void consumer() {
while (!ready.load(std::memory_order_acquire)) {}
// 这里可以安全看到 data = 42
}

这就是 acquire/release 最核心的使用场景。


11. acq_rel:读改写操作常用

对于像 fetch_addexchange、CAS 这样的读改写操作,经常用:

1
std::memory_order_acq_rel

它表示:

  • 对之前的写有 release 效果
  • 对之后的读有 acquire 效果

适合:

  • 需要同时承担“发布 + 获取”角色的 RMW 操作

12. seq_cst:最强也最直观

1
x.store(1, std::memory_order_seq_cst);

它提供最强、最容易理解的全局顺序语义。
可以先粗略理解为:

  • 所有 seq_cst 原子操作在所有线程看来像排成了一条总顺序

优点:

  • 最不容易想错

缺点:

  • 可能比更弱顺序更保守

经验上:

  • 不确定时可以先用 seq_cst
  • 真有性能证据,再考虑是否降到 acquire/release 或 relaxed

13. 一个常见误区:原子变量保护不了“旁边的普通变量”,除非顺序写对

例如:

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

如果你这样写:

1
2
data = 42;
ready.store(true, std::memory_order_relaxed);

另一个线程即便读到 ready == true,也不一定能可靠看见 data == 42
因为这里只保证 ready 本身原子,不保证普通变量 data 的可见顺序。

所以:

  • “用一个原子标志通知别人去读普通数据”
    这个模式必须认真选内存序

14. 自旋等待与忙等

原子标志很容易写出自旋:

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

这在短等待场景可能可接受,但有风险:

  • 浪费 CPU
  • 争抢资源
  • 等待时间一长非常低效

所以如果等待时间不可控,通常更适合:

  • 条件变量
  • futex / event
  • 更高层并发原语

15. atomic_flag 与自旋锁

最简单的原子标志是:

1
std::atomic_flag lock = ATOMIC_FLAG_INIT;

可实现一个最简单自旋锁:

1
2
3
4
5
6
while (lock.test_and_set(std::memory_order_acquire)) {
}

// critical section

lock.clear(std::memory_order_release);

但要明确:

  • 这只是帮助理解 acquire/release 的经典例子
  • 真实工程里自旋锁并不一定是好选择

原因包括:

  • 高竞争下性能糟糕
  • 容易烧 CPU
  • 可能不公平

16. lock-free 不等于更快

这是并发里最常见的误判之一。

“无锁”通常只表示:

  • 线程推进不依赖传统互斥锁

它不自动意味着:

  • 更低延迟
  • 更高吞吐
  • 更少 cache traffic

实际上,原子和 CAS 往往会带来:

  • cache line 抖动
  • 重试开销
  • 更难维护的代码

所以工程经验是:

先用正确、清晰的同步方案,再证明锁真的成了瓶颈,才考虑无锁化。


17. false sharing 在原子场景里更常见

多个线程频繁更新不同的原子变量,如果这些变量落在同一个 cache line,也会很慢。

例如:

1
2
3
4
struct Counters {
std::atomic<long long> a;
std::atomic<long long> b;
};

如果两个线程分别只改 ab,仍可能严重互相干扰。

常见缓解方式:

1
2
3
struct alignas(64) Counter {
std::atomic<long long> value{0};
};

或者:

  • 每线程本地累加
  • 最后统一合并

18. 原子引用计数与 shared_ptr

shared_ptr 之所以比 unique_ptr 重,很大一部分原因就在于:

  • 它内部常涉及线程安全的引用计数更新

也就是说,很多“我只是图省事用了 shared_ptr”的代码,实际上已经把原子开销带进来了。

所以在性能敏感路径里:

  • 不要把共享所有权当默认方案

19. 一个高频模式:单生产者发布结果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
struct Result {
int value;
};

Result result;
std::atomic<bool> done{false};

void producer() {
result.value = 123;
done.store(true, std::memory_order_release);
}

void consumer() {
while (!done.load(std::memory_order_acquire)) {}
// 这里读取 result.value
}

这类模式是 acquire/release 最值得优先熟练掌握的用法。


20. 一个高频模式:纯统计计数

1
2
3
4
5
6
7
std::atomic<long long> counter{0};

void worker() {
for (int i = 0; i < 100000; ++i) {
counter.fetch_add(1, std::memory_order_relaxed);
}
}

这里若只是最后读总数,relaxed 往往就足够,因为你并不依赖这个计数去同步其他数据。


21. 常见误区

21.1 “原子 = 不需要思考同步”

错。
原子只解决了一部分问题,真正难的是:

  • 你想建立什么可见性关系
  • 你要不要顺序保证

21.2 “我用了原子,就能保护一大片普通变量”

只有当发布/获取关系写对时才成立。

21.3 “lock-free 一定更先进”

并发工程里先进不先进不重要,重要的是:

  • 正确
  • 可维护
  • 有实际性能收益

21.4 “volatile 可以替代 atomic

不能。
volatile 不是线程同步原语。


22. 一页总结

原子操作最重要的不是 API 数量,而是建立这条理解链:

  1. 原子保证操作本身不会被数据竞争破坏
  2. 内存序决定不同线程看到这些操作及相关普通内存的顺序关系
  3. relaxed 只保原子性,不保发布顺序
  4. release/acquire 是最常用的发布-获取模型
  5. seq_cst 最直观,适合先写对
  6. 无锁不是性能银弹,false sharing 和 CAS 重试都可能很贵

如果只记两个最高频结论:

  • 纯计数:优先考虑 relaxed
  • 发布数据给别的线程:优先考虑 release/acquire

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

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

  1. C++ 内存模型正式定义
  2. ABA 问题
  3. 无锁队列 / Michael-Scott Queue
  4. hazard pointers / epoch reclamation
  5. atomic_refstd::barrier

24. 参考资料

  1. cppreference: std::atomic
    https://en.cppreference.com/w/cpp/atomic/atomic

  2. cppreference: memory order
    https://en.cppreference.com/w/cpp/atomic/memory_order

  3. C++ Core Guidelines, Concurrency
    https://isocpp.github.io/CppCoreGuidelines/CppCoreGuidelines

流体仿真实战

时间:2026/04/09

关键词:Eulerian grid、advection、diffusion、projection、pressure solve、ping-pong、texture object、CUDA
核心目标:从工程角度理解一个实时流体模拟在 GPU 上通常由哪些步骤组成,以及每一步为什么这样设计。


1. 先建立问题模型

实时流体仿真通常不是在“跟踪每一个分子”,而是在求解离散化后的场:

  • 速度场 u(x, y[, z])
  • 压力场 p
  • 密度/烟雾浓度 d
  • 温度、外力等附加场

在图形学实时仿真里,最常见的是 Eulerian 网格法

  • 空间被划分成规则网格
  • 每个网格单元保存局部状态
  • 关注的是“这个位置上的流体状态”,而不是“某个粒子去哪了”

2. 一帧流体模拟通常做什么

一个经典的 2D/3D 实时烟雾模拟步骤,通常长这样:

  1. 对速度场做平流(advection)
  2. 对密度/温度做平流
  3. 加入外力(force / impulse)
  4. 计算散度(divergence)
  5. 解压强泊松方程(pressure solve)
  6. 用压力梯度做投影(projection)
  7. 得到无散速度场
  8. 渲染密度或其他可视化结果

这条管线里最关键的思想是:

  • 平流负责“把东西搬着走”
  • 压力投影负责“让速度场满足不可压缩约束”

3. 为什么很多实时流体用 Stable Fluids

Jos Stam 的 Stable Fluids 思想之所以经典,是因为它强调:

  • 数值上更稳定
  • 能容忍较大的时间步长
  • 非常适合图形实时场景

它的代价通常是:

  • 会更粘、更耗散
  • 细节可能被抹平

所以工程上通常要在:

  • 稳定性
  • 速度
  • 视觉效果

之间做平衡。


4. 网格、边界与数据布局

4.1 最常见的数据

二维时经常有:

  • vel_x
  • vel_y
  • pressure
  • divergence
  • density

4.2 常见存储方式

对 GPU 更友好的方式通常是:

  • 扁平连续数组
  • 纹理/表面对象
  • ping-pong 双缓冲

例如二维数组常展平成:

1
idx = y * width + x;

4.3 为什么常用 SoA 思路

不同字段通常分开存:

  • 一个数组/纹理存 vel_x
  • 一个数组/纹理存 vel_y
  • 一个数组/纹理存 pressure

这比把所有字段塞进一个大结构更容易:

  • 控制带宽
  • 做批量 kernel
  • 针对单一字段优化访问

5. 平流(Advection)是什么

直觉上,平流就是:

按速度场把某个量“搬运”到下一个时刻。

对于密度场来说,可以理解成:

  • 烟雾随着流速移动

对于速度场本身来说:

  • 速度也会被自身流动搬运

5.1 半拉格朗日(Semi-Lagrangian)平流

实时图形里很常见的做法是“反向追踪”:

  • 对当前网格点 (x, y)
  • 沿速度反方向回溯到上一个时刻的位置
  • 从旧场中插值取样

公式直觉:

1
phi_new(x) = phi_old(x - dt * u(x))

5.2 为什么它适合 GPU

因为每个网格点都能独立算:

  • 回溯位置
  • 插值取样
  • 写回新值

这非常适合 data-parallel kernel。


6. 为什么纹理对象在流体里常见

流体平流特别需要“按浮点坐标插值采样旧场”。
这正是纹理对象很擅长的事情。

6.1 纹理对象的实际优势

  • 支持硬件插值
  • 支持边界寻址模式
  • 访问接口清晰
  • 对二维/三维场很自然

因此在 CUDA 流体项目里,经常会看到:

  • 旧场绑定成 texture
  • 新场写到 output buffer / surface

6.2 一个常见工程模式

  • src 作为只读纹理
  • dst 作为可写 buffer
  • kernel 结束后交换 src/dst

这就是典型的 ping-pong。


7. Ping-Pong 双缓冲为什么几乎是标配

平流、扩散、压力迭代这类步骤,通常不能一边读旧值一边原地覆盖写新值,否则会污染后续计算。

最常见做法:

  • A 存旧场
  • B 存新场
  • 一步计算后交换 A/B

例如:

1
2
3
4
advect:    vel0 -> vel1
swap
project: vel1 -> vel0
swap

双缓冲的代价是多占一份显存,但好处是:

  • 读写依赖清晰
  • kernel 更容易写对
  • 更适合并行

8. 散度(Divergence)为什么要算

不可压缩流体通常要求:

1
div(u) = 0

但经过加力、平流等步骤后,速度场往往不再满足这个条件。
所以需要先计算散度,看看当前速度场“有多不守恒”。

二维中心差分的直觉形式:

1
div = (u_right - u_left + v_up - v_down) * 0.5 / h

算出来的散度场会成为后面压力方程的右端项。


9. 压力求解(Pressure Solve)是核心瓶颈之一

为了让速度场重新无散,通常要解一个泊松方程:

1
∇²p = div

这里:

  • p 是压力
  • div 是上一阶段算出的散度

9.1 为什么这是难点

因为它不是一个局部一步完成的更新,而是一个全局耦合问题。
实时实现中常用近似迭代法,例如:

  • Jacobi
  • Gauss-Seidel
  • Red-Black Gauss-Seidel
  • Multigrid(更复杂也更强)

9.2 为什么很多教程先用 Jacobi

因为它简单、规则、容易并行:

  • 每次迭代只读旧压力
  • 写新压力
  • 适合 GPU 大规模并行

代价是收敛速度不算快。


10. 投影(Projection)在做什么

解出压力场后,用压力梯度修正速度:

1
u' = u - ∇p

直觉上就是:

  • 从原速度里减掉“造成体积膨胀/压缩”的那部分

投影之后,速度场更接近无散状态,也就更符合不可压缩流体假设。

这一步通常是:

  1. 读取速度
  2. 读取相邻压力
  3. 做梯度差分
  4. 写回新速度

11. 边界条件不能忽略

很多新手代码“能跑但看起来不对”,问题往往在边界。

常见边界处理包括:

  • 固壁边界
  • 开放边界
  • 周期边界
  • 障碍物边界

例如固壁边界时,速度在法向方向通常要满足不穿透约束。

如果边界没处理好,可能会出现:

  • 烟雾穿墙
  • 边缘数值爆炸
  • 压力解不稳定

12. CUDA 上通常如何拆 kernel

一个典型实时流体项目不会把全部逻辑塞进一个 kernel,而是拆成多步:

  • advect_velocity
  • advect_density
  • add_force
  • compute_divergence
  • jacobi_pressure
  • project_velocity
  • render_density

这样做的好处:

  • 每步逻辑清晰
  • 便于调试
  • 便于替换某一步算法

代价是:

  • kernel launch 次数变多
  • 中间结果要反复读写全局内存

所以工程优化通常是在“模块清晰”和“减少中间带宽”之间折中。


13. 一个典型的 CUDA 数据流

可以把一帧想成这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
velocity_src --advect--> velocity_dst
swap

density_src --advect--> density_dst
swap

velocity_src --add_force--> velocity_dst
swap

velocity_src --divergence--> divergence

pressure_src --jacobi--> pressure_dst
swap
... 迭代多次 ...

velocity_src + pressure_src --project--> velocity_dst
swap

density_src --render--> framebuffer

其中压力求解阶段往往会迭代很多轮,是核心带宽热点之一。


14. 纹理对象在这里最常见的落点

14.1 平流时

因为需要按浮点坐标回溯并插值取样旧场。

14.2 可视化时

例如把密度场映射到图像。

14.3 有时不用纹理也能做

完全可以手写双线性插值,但纹理对象通常更方便,也更贴近 GPU 图像管线思维。


15. 性能瓶颈通常在哪

15.1 不一定是算术,而是带宽

流体很多步骤都是 stencil / 邻域访问:

  • 读周围多个点
  • 写一个点

因此常见瓶颈是:

  • 全局内存带宽
  • 多次中间场读写

15.2 压力迭代轮数

Jacobi 每多迭代一轮,都意味着:

  • 再扫一遍整个压力场

所以视觉质量和帧率经常直接在这里博弈。

15.3 边界和条件判断

边界格子处理如果分支太多,也会影响 warp 执行效率。


16. 常见优化方向

16.1 减少全局内存往返

例如:

  • 更少的中间缓冲
  • 更合理的 kernel 融合

16.2 用 shared memory 做局部邻域缓存

对于 stencil 类计算,局部 tile 常能减少重复全局加载。

16.3 让访问尽量规则

例如:

  • 扁平数组
  • 合理线程块布局
  • 连续内存访问

16.4 区分“视觉上够用”和“数值上更准”

实时图形里很多时候不是求学术最优,而是:

  • 足够稳定
  • 足够快
  • 视觉上自然

17. 为什么这类项目特别适合做综合练习

流体仿真把很多高性能主题都串起来了:

  • 数据布局
  • 邻域访存
  • ping-pong 双缓冲
  • GPU kernel 设计
  • 纹理对象
  • 迭代求解器
  • 可视化与物理折中

所以它既是一个图形学题目,也是一个很典型的并行计算工程题。


18. 初学实现建议

如果你第一次做,比较稳的路线是:

  1. 先做 2D,不要一开始上 3D
  2. 先做密度平流,再加速度场
  3. 再补散度、压力、投影
  4. 压力求解先用 Jacobi 跑通
  5. 最后再考虑纹理对象、shared memory、性能优化

因为最难的不是“写出一个 kernel”,而是:

  • 整条数据流逻辑正确
  • 每步边界一致
  • 数值上稳定

19. 一页总结

实时流体仿真的工程骨架,可以压缩成下面几句:

  1. 用规则网格存速度、压力和密度场
  2. 用平流把量沿速度场搬运
  3. 用散度和压力解修正速度场
  4. 用投影得到近似不可压缩流体
  5. 用 ping-pong 双缓冲保证每一步读写分离
  6. 用纹理对象和连续布局提高 GPU 访问效率

如果只记一个最核心的工程关系,那就是:

平流负责“搬”,压力投影负责“稳”。


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

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

  1. CUDA texture / surface object 细节
  2. Staggered grid(MAC grid)
  3. Jacobi / Gauss-Seidel / Multigrid 对比
  4. Vorticity confinement 与视觉细节增强
  5. 3D 烟雾与体渲染

21. 参考资料

  1. Jos Stam, Stable Fluids
    https://www.dgp.toronto.edu/public_user/stam/reality/Research/pdf/GDC03.pdf

  2. GPU Gems: Fast Fluid Dynamics Simulation on the GPU
    https://developer.nvidia.com/gpugems/gpugems/part-vi-beyond-triangles/chapter-38-fast-fluid-dynamics-simulation-gpu

深入浅出访存优化

时间:2026/04/09

关键词:缓存、cache line、局部性、AoS/SoA、stride、blocking、false sharing、带宽、延迟
核心目标:理解“为什么同样是 O(n),不同内存访问方式会有非常大的性能差异”。


1. 为什么访存优化这么重要

很多程序并不是算得慢,而是等内存等得慢

现代 CPU 的特点是:

  • 算术单元非常快
  • 主存访问相对很慢
  • 一旦 cache miss,CPU 可能要空等很多周期

所以高性能编程里经常会出现这种现象:

  • 算法复杂度没变
  • 指令数也没怎么变
  • 只是换了数据布局和访问顺序
  • 性能却提升一大截

这背后的核心就是访存模式。


2. CPU 不是直接从内存一格一格取数据

2.1 缓存层级

可以先粗略理解成:

  • L1:最小、最快
  • L2:更大、稍慢
  • L3:更大、共享、再慢一些
  • DRAM:远慢于缓存

因此访问一个数据时:

  • 如果在缓存里,代价较低
  • 如果不在,就会发生 cache miss,从更慢层级取

2.2 cache line

CPU 取数据通常不是只取一个字节或一个 int,而是按缓存行成块搬运。

常见 cache line 大小是:

  • 64 字节

这意味着:

  • 如果你访问了 a[i]
  • 那么 a[i] 周围的一片数据往往也会一起进缓存

所以连续访问会特别受益。


3. 两种最重要的局部性

3.1 空间局部性

如果你访问了某个地址,附近地址也很可能马上会被访问。
连续数组遍历就是最典型例子。

3.2 时间局部性

如果你刚访问过某个数据,很可能短时间内还会再访问它。

例如:

1
2
3
4
for (size_t i = 0; i < n; ++i) {
sum += a[i];
sum += a[i];
}

第二次访问 a[i] 很可能仍在缓存里。


4. 为什么顺序访问通常比随机访问快

看两个模式:

1
2
3
for (size_t i = 0; i < n; ++i) {
sum += a[i];
}

和:

1
2
3
for (size_t i = 0; i < n; ++i) {
sum += a[idx[i]];
}

第二种如果 idx 很随机,就容易出现:

  • cache line 利用率低
  • 硬件预取器很难提前猜中
  • CPU 长时间等待内存

这就是为什么:

  • 链表遍历
  • 稀疏随机访问
  • 指针追逐

常常会显著慢于顺序数组遍历。


5. AoS 与 SoA

这是访存优化里最经典的话题之一。

5.1 AoS:Array of Struct

1
2
3
4
5
6
struct Particle {
float x, y, z;
float vx, vy, vz;
};

std::vector<Particle> a;

特点:

  • 单个对象的属性紧挨着存
  • 适合“经常整对象一起处理”的场景

5.2 SoA:Struct of Arrays

1
2
3
4
struct Particles {
std::vector<float> x, y, z;
std::vector<float> vx, vy, vz;
};

特点:

  • 同一属性的数据连续存储
  • 更适合批量处理同一字段
  • 常更利于 SIMD / GPU / cache

5.3 什么时候 SoA 更有优势

如果你的计算主要是:

1
2
3
for (...) {
x[i] += vx[i] * dt;
}

那么 SoA 往往更好,因为:

  • 访问字段连续
  • 每个 cache line 里装的都是“当前真正要用的数据”

5.4 什么时候 AoS 更自然

如果逻辑经常要完整处理单个对象,例如:

  • 读一个粒子的全部属性
  • 面向对象接口较多

AoS 可能更直观。

经验上:

  • 计算密集且按字段批量处理时,优先考虑 SoA
  • 业务逻辑围绕单对象时,AoS 更自然

6. stride(步长)为什么会直接影响性能

6.1 连续步长访问

1
2
3
for (size_t i = 0; i < n; ++i) {
sum += a[i];
}

这通常最好。

6.2 大步长访问

1
2
3
for (size_t i = 0; i < n; i += 16) {
sum += a[i];
}

问题在于:

  • 每次只用到一个 cache line 里的很少一部分
  • 带宽利用率低

6.3 二维数组里的列访问

假设按行连续存储:

1
2
3
4
5
for (size_t j = 0; j < m; ++j) {
for (size_t i = 0; i < n; ++i) {
sum += a[i][j];
}
}

如果内层循环跨行跳,会比按行访问差很多。

因此多维数据常见经验是:

  • 内层循环尽量走连续维

7. blocking / tiling:为什么分块常常更快

当工作集太大,无法完全放进缓存时,可以按块处理。

7.1 直觉

不要一次把全量数据扫完再回来重扫,而是:

  • 先拿一小块
  • 在这小块上做尽量多的计算
  • 让这小块在缓存中被充分复用

7.2 矩阵乘法是最典型场景

朴素矩阵乘法经常很慢,不是乘法本身慢,而是访存复用差。
分块后的核心收益是:

  • 提高缓存命中
  • 减少大跨度反复读

7.3 图像处理也一样

例如卷积、滤波、stencil 计算,都常通过 tile/block 改善访存。


8. 预取与硬件预取器

现代 CPU 会尝试根据访问规律提前把数据搬进缓存,这就是硬件预取。

它更擅长:

  • 连续访问
  • 稳定步长访问

它不擅长:

  • 随机访问
  • 指针跳转
  • 强数据依赖链

所以很多“看起来只是更整齐的循环”之所以更快,本质上是在帮硬件预取器工作。


9. false sharing:并发里最隐蔽的访存杀手之一

9.1 它是什么

两个线程虽然更新的是不同变量,但这两个变量落在同一个 cache line 里。
结果两个核心会频繁抢这个 cache line 的所有权,导致性能急剧下降。

9.2 例子

1
2
3
4
struct CounterPair {
std::atomic<int> a;
std::atomic<int> b;
};

如果线程 1 只写 a,线程 2 只写 b,理论上彼此独立;
但如果 ab 在同一 cache line,就可能 false sharing。

9.3 典型缓解方式

  • padding
  • alignas(64)
  • 每线程局部累加,最后汇总

这在并行归约、线程本地统计里特别常见。


10. 对齐、padding 与缓存布局

10.1 对齐影响加载效率

对齐良好的数据更利于:

  • 高效加载
  • SIMD 指令
  • cache line 对齐访问

10.2 结构体布局会影响缓存利用率

1
2
3
4
struct A {
char flag;
double value;
};

编译器可能为了对齐插入 padding。
如果一个结构体很大,而你每次只用其中一两个字段,就会带来缓存浪费。

这也是为什么“对象设计”会直接变成“性能设计”。


11. 分支与访存也常常绑定在一起

下面这类代码既影响分支预测,也影响访存效率:

1
2
3
if (a[i] > 0) {
sum += heavy_table[a[i]];
}

如果:

  • a[i] > 0 高度不可预测
  • heavy_table 访问又很随机

性能通常会明显变差。

所以优化时别把“计算”和“访存”割裂看,它们常是一起出问题的。


12. 为什么 vector 通常比 list 更快

很多人第一次接触会觉得:

  • list 插入删除是 O(1)
  • vector 中间插删是 O(n)

于是误以为 list 应该经常更快。
但现实里,很多遍历型或批量处理场景中,vector 常常更快,原因就在于:

  • 连续内存
  • cache 友好
  • 更利于预取与向量化

所以算法复杂度只是第一层,访存模式是第二层。


13. 多线程写入时为什么要分区

如果多个线程写同一个大数组,比较好的模式通常是:

  • 每个线程负责一个连续区间
  • 尽量避免多个线程反复改同一个 cache line

不好的模式则常见于:

  • 交错写入
  • 所有线程抢同一个计数器
  • 共享小对象频繁原子更新

这会带来:

  • cache line 争用
  • false sharing
  • 带宽浪费

14. GPU 视角下为什么更重视 SoA

GPU 更强调大规模并行和合并访问(coalescing)。
因此当一批线程访问同一字段的连续元素时,SoA 往往比 AoS 更有优势。

所以很多 CPU/GPU 共享的高性能数据结构设计原则其实是相通的:

  • 扁平化
  • 连续化
  • 尽量批处理同类字段

15. 访存优化的实战原则

可以直接记这几条:

15.1 尽量连续访问

先改访问顺序,再谈别的。

15.2 尽量缩小热数据集

把频繁访问的数据做得更紧凑。

15.3 尽量增加复用

让已经进缓存的数据多用几次。

15.4 尽量避免无意义共享

特别是在并行代码里。

15.5 用数据布局服务算法

不是“先写一个漂亮对象模型,再看性能”,而是:

  • 哪种布局更适合核心计算,就优先哪种

16. 常见误区

16.1 只盯算法复杂度,不看访存

两个同为 O(n) 的实现,真实速度可能差很多倍。

16.2 误以为 cache 是“编译器问题”

缓存问题本质上是:

  • 数据布局问题
  • 访问顺序问题
  • 并发共享问题

16.3 把所有对象都做成“大而全”

热路径只用两个字段时,大结构体会造成大量无效搬运。

16.4 并发场景只关注锁,不关注 cache line

很多“看起来没锁争用”的程序,其实卡在 false sharing。


17. 一页总结

访存优化可以压缩成一句话:

让 CPU/GPU 更容易在更少次、更连续、更可预测的内存访问中拿到真正需要的数据。

最关键的实践点有:

  1. 顺序访问优于随机访问
  2. 连续布局优于分散指针结构
  3. SoA 在按字段批量处理时常优于 AoS
  4. 分块能提升缓存复用
  5. 并发时要警惕 false sharing

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

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

  1. vector 容器优化
  2. 自动向量化与 SIMD
  3. NUMA 与多路 CPU 访存
  4. GPU 合并访存与 shared memory
  5. 矩阵分块与 stencil 优化

19. 参考资料

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

  2. cppreference: alignas
    https://en.cppreference.com/w/cpp/language/alignas