公众号

点击「查看原文」跳转到 GitHub 上对应文件,链接就可以点击了
qq群 753792291 答疑在这里
欢迎投稿,推荐或自荐文章/软件/资源等,评论区留言
本期文章没人赞助
标准委员会动态/ide/编译器信息放在这里
编译器信息最新动态推荐关注hellogcc公众号 本周更新 2025-01-08 第288期
从头写一个SPSC ring buffer,然后一步步把它榨干。全文有benchmark,有代码,值得一读。
第一版:单线程
留一个slot不用来区分full和empty,head写、tail读:
template <typename T, std::size_t N>
class RingBufferV1 {
std::array<T, N> buffer_;
std::size_t head_{0};
std::size_t tail_{0};
public:
auto push(const T& value) noexcept -> bool {
auto new_head = head_ + 1;
if (new_head == buffer_.size()) [[unlikely]] new_head = 0;
if (new_head == tail_) [[unlikely]] return false; // Full
buffer_[head_] = value;
head_ = new_head;
return true;
}
auto pop(T& value) noexcept -> bool {
if (head_ == tail_) [[unlikely]] return false; // Empty
value = buffer_[tail_];
auto next_tail = tail_ + 1;
if (next_tail == buffer_.size()) [[unlikely]] next_tail = 0;
tail_ = next_tail;
return true;
}
};
第二版:加mutex,直接加锁,12M ops/s,正确但慢。
第三版:换atomic,两个index分别alignas到不同cache line防false sharing:
template <typename T, std::size_t N>
class RingBufferV3 {
std::array<T, N> buffer_;
alignas(std::hardware_destructive_interference_size) std::atomic_size_t head_{0};
alignas(std::hardware_destructive_interference_size) std::atomic_size_t tail_{0};
};
去掉锁直接35M ops/s,翻了将近三倍。但这里用的是默认memory_order_seq_cst,最保守的。
第四版:手调memory order,生产者只需要relaxed读自己的head,acquire读对方的tail;反之亦然:
auto push(const T& value) noexcept -> bool {
const auto head = head_.load(std::memory_order_relaxed);
auto next_head = head + 1;
if (next_head == buffer_.size()) [[unlikely]] next_head = 0;
if (next_head == tail_.load(std::memory_order_acquire)) [[unlikely]]
return false;
buffer_[head] = value;
head_.store(next_head, std::memory_order_release);
return true;
}
108M ops/s,又是3倍,mutex的9倍。
第五版:缓存对方index,每次push都要acquire读tail,这个原子读还是贵。在本地缓存一份,只有真的full时才重新acquire:
// 生产者侧增加cached tail
alignas(...) std::size_t tail_cached_{0};
if (next_head == tail_cached_) [[unlikely]] {
tail_cached_ = tail_.load(std::memory_order_acquire);
if (next_head == tail_cached_) return false;
}
305M ops/s,mutex的25倍。
| 版本 | 方案 | 吞吐 |
|---|---|---|
| V1 | 单线程 | N/A |
| V2 | mutex | 12M ops/s |
| V3 | atomic (seq_cst) | 35M ops/s |
| V4 | relaxed/acquire/release | 108M ops/s |
| V5 | 加cache | 305M ops/s |
这种cached index的技巧在高频交易系统里很常见,第一次见到可以好好看看
P2264R7,把assert从普通宏改成可变参数宏。
问题: assert是个宏,预处理器只认括号,不认模板尖括号和花括号,所以这些都会炸:
assert(std::is_same<int, Int>::value); // 逗号让宏以为是两个参数
assert([x, y]() { return x < y; }() == 1); // lambda里的逗号
assert(std::vector<int>{1, 2, 3}.size() == 3); // 花括号里的逗号
修法: 把assert(expression)改成assert(...),用__VA_ARGS__接收,三行事情搞定。上面的代码一律直接通过,不需要手动加多余的括号。
注意事项: 原来提案想支持assert(x > 0, "诊断消息"),仿static_assert,但没过。原因是assert(x > 0, "msg")在宏展开后是对逗号表达式求值,结果永远是true。为了防止这种坑,C++26禁止顶层逗号,所以诊断消息还是要用&&:
assert(x > 0 && "x must be positive"); // 还是这样写
不破坏现有代码,截至文章发布(2026年2月)编译器还没实现。省流:小修小补但实用。
Our Machinery博客的经典文章(游戏引擎开发者的博客)。虚拟内存不只是防止程序崩溃的保底机制,用好了可以做很多骚操作。
骚操作一:超大数组
想要一个能放10亿个对象指针的查找表,但不想提前分配8GB物理内存?直接虚拟分配:
#define MAX_OBJECTS 1000000000ULL
object_o **objects = virtual_alloc(MAX_OBJECTS * sizeof(object_o *));
保留了8GB地址空间,但物理内存只按实际使用量分配(按页)。64位进程地址空间有2^64这么大,256TB都是常规限制,随便造。
这种思路可以彻底干掉MAX_TANKS这种老派C写法的抱怨——不用vector,直接给每个数组1GB虚拟内存:
#define GB 1000000000
uint32_t num_tanks;
tank_t *tanks = virtual_alloc(GB);
注意Windows需要区分MEM_RESERVE和MEM_COMMIT;Linux支持overcommit,更简单。
骚操作二:虚拟地址当唯一ID
不用uint64_t next_id++,直接从虚拟内存系统分配地址作为唯一ID。这些地址从不被实际使用,不消耗物理内存,但在进程内全局唯一,还附赠类型安全:
system_id_t *allocate_id(system_t *sys) {
if (!sys->id_block || sys->id_block_used == PAGE_SIZE) {
sys->id_block = virtual_alloc(PAGE_SIZE);
sys->id_block_used = 0;
}
return (system_id_t *)(sys->id_block + sys->id_block_used++);
}
骚操作三:内存越界检测
给只读内存区域前后放guard page,越界写直接segfault,比valgrind快多了。
骚操作四:ring buffer的虚拟内存实现
把同一块物理内存映射到两段连续的虚拟地址,这样ring buffer的wrap-around就消失了——直接memcpy就行,不用分两段拷贝。
比较硬核的游戏引擎底层文章,适合想了解内存布局那层的同学
Lemire介绍了一个利用C++26编译期反射(P2996)做JSON序列化的演讲。
C++26反射能做什么:在编译期拿到一个struct的成员信息,然后基于这些信息自动生成代码。放在JSON序列化上,就是不需要手写任何宏或者注册代码:
struct Point { int x, y; };
// C++26反射可以这样写:
template<typename T>
std::string to_json(const T& val) {
std::string result = "{";
bool first = true;
[:expand(std::meta::nonstatic_data_members_of(^T)):] >> [&]<auto member>{
if (!first) result += ",";
result += '"';
result += std::meta::identifier_of(member);
result += "\":";
result += std::to_string(val.[:member:]);
first = false;
};
result += "}";
return result;
}
// to_json(Point{1,2}) → {"x":1,"y":2}
不用宏,不用代码生成器,不用手动注册成员,完全自动。反射出来的东西是编译期常量,理论上可以全部在编译期展开。
reflect赶紧实现吧,标准都出了编译器还没跟上
mp-units作者 Mateusz Pusz 写的,系统梳理了物理单位库能提供的六个安全级别。
大多数人知道的只有”维度安全”(length + time会报错),但其实还有更多层次:
Level 1:维度安全 — 加法只在相同维度间有效
quantity speed = 100 * km / h;
quantity time = 2 * h;
// quantity d = speed / time; // ❌ 维度不对
quantity<si::kilo<si::metre>> d = speed * time; // ✅
Level 2:单位安全 — 编译期阻止单位不一致,消灭手动换算系数
Level 3:表示安全 — 防溢出、防精度损失(int存speed然后乘time可能溢出)
Level 4:量种安全 — 这个很少人意识到:torque(扭矩)和energy(能量)的维度完全相同(N·m = J),但物理上是完全不同的量,不应该相加。Hz和Bq也是同样问题。mp-units把这些区分开来:
auto e = 10 * J; // energy
auto t = 10 * N*m; // torque
// auto sum = e + t; // ❌ 量种不同,不能相加
Level 5:量安全 — 强化量之间的关系和方程式约束
Level 6:数学空间安全 — 区分点(position)和差值(delta):温度273K和温差5K不是同一个东西,273K + 5K = 278K,但273K + 273K没有物理意义
六级下来了解一下还是可以的,做物理仿真/科学计算的强烈建议看看
单线程里的错误处理大家都懂:出错了沿调用栈往上传,每一帧做cleanup(RAII/finally/defer),找到第一个能处理的地方。Go、Rust、Python、C++、Java都是这套,只是API不同。
并发程序里怎么搞?没有”单一的栈”。背景线程出了异常,主线程怎么知道?
两种糟糕的现有方案:
结构化并发(Structured Concurrency)是解:
核心思路是Python里的asyncio.TaskGroup、Swift的async let、Java的StructuredTaskScope:把并发任务的生命周期和作用域绑定在一起,任意子任务出错,整个task group按受控方式失败:
async with asyncio.TaskGroup() as tg:
task1 = tg.create_task(some_coro())
task2 = tg.create_task(another_coro())
# 出了with块:两个task要么都完成,要么某个出错后整个tg.cancel()其他的
文章分析了各语言的现状和挑战,C++这边P2300(std::execution)在做类似的事情,但路还长。想搞清楚concurrent error handling为什么难的同学值得一读
开头就很接地气:AI公司把世界上的RAM都买光了,消费电子的内存反而在往下走,so内存优化又变成了一件要紧的事。
同一个任务(统计文本词频)的内存用量对比:
Python实现(< 30行):峰值内存 1.3 MB
C++实现(用string_view + mmap + 惰性split):峰值内存 ~100 kB,是Python的7.7%
核心技巧是全程不创建string对象,只用string_view(pointer + size),唯一的动态内存分配是hash table和最后排序用的vector:
// 大意是这样的
auto file = mmap_file(path); // 直接映射,不拷贝
auto view = utf8_view(file); // 零拷贝view
for (auto word : split_whitespace(view)) {
counts[word]++; // key是string_view,不分配
}
如果去掉exception支持,C++运行时不需要预留unwind用的内存,能压到 21 kB,比Python少98.4%。
确实有点不公平,Python运行时本身有固定开销。但如果你真的只需要干这件事,这差距就是真实存在的。
家人们,C++程序员的机会来了
感谢群友投稿。感兴趣可以看看
Greg Law(Undo公司CEO,time travel debugger做了25年的老哥)飞到San Diego讲”时间旅行调试+AI”。
Undo的工作原理: 不是每步保存snapshot(太慢),而是用JIT Binary Translation只记录non-deterministic事件(syscall、线程切换、信号、共享内存访问),然后确定性重放。开销低,可以production环境录制。
最好玩的demo: 用Chocolate Doom的录像,追踪”哪行代码把某个像素变成了特定颜色”,从像素值变化反向找到僵尸被击杀的代码行。这种问题传统调试根本做不到。
AI + time travel: 给Claude这样的LLM配上reverse-step、trace、watch等工具,让AI来驱动调试器。演示用的是诊断CPython里一个引用计数的bug,这个bug人工花了几周没找到,AI agent几分钟搞定。
AI的优势在于它特别擅长”给我工具,按工具的约束推理”——可以自动化地做”向前运行到某个条件,然后反向追这个值从哪里来”这类反复操作。
群友花了36~40小时给C++/WinRT加了标准模块(import)支持,写了这篇记录。
为什么难: C++/WinRT是纯头文件库,要模块化面临几个问题:
std::前缀,模块化后会找不到符号bizwen的方案: 每个命名空间一个模块,用宏切换头文件模式和模块实现模式:
// 头文件用这种模式兼容两种用法:
#pragma push_macro("WINRT_EXPORT")
#undef WINRT_EXPORT
#if !defined(WINRT_MODULE)
#define WINRT_EXPORT // 普通头文件路径
#include <winrt/base.h>
#else
#define WINRT_EXPORT export // 模块路径,声明都带export
#endif
// ... 声明 ...
#pragma pop_macro("WINRT_EXPORT")
对应的.ixx:
module;
#define WINRT_MODULE
#include <intrin.h>
#include "winrt/module.h"
export module Windows.XX;
import 依赖项;
#include "实现文件"
因为全局模块片段里的宏对后续#include可见,这个trick能让同一个头文件在两种模式下行为不同。
还支持通过CppWinRT.config排除Windows.UI.Xaml这种巨大命名空间(90MB BMI单独占三分之一),CMake 4.3起支持。
MSVC对模块的支持这几年进步明显,但跨模块using声明的bug到文章发布时刚标记修复,还没发版。
感兴趣的Windows C++开发可以关注一下这个分支
感谢群友投稿
讲集成测试写法启发式原则,非C++特有的通用内容。几个核心观点:
了解一下还是可以的
cppdepend发了两篇AI相关的文章,一篇是”AI在软件开发里的100+种用法”,一篇是”AI写文章、发文章、读文章——人类帖子的终结”。
我就不看了