C++ 中文周刊 2026-03-27 第199期

周刊项目地址

公众号

点击「查看原文」跳转到 GitHub 上对应文件,链接就可以点击了

qq群 753792291 答疑在这里

RSS

欢迎投稿,推荐或自荐文章/软件/资源等,评论区留言

本期文章没人赞助


资讯

标准委员会动态/ide/编译器信息放在这里

编译器信息最新动态推荐关注hellogcc公众号 本周更新 2025-01-08 第288期

性能周刊

文章

Optimizing a Lock-Free Ring Buffer

从头写一个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的技巧在高频交易系统里很常见,第一次见到可以好好看看

C++26: A User-Friendly assert() macro

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月)编译器还没实现。省流:小修小补但实用。

Virtual Memory Tricks

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就行,不用分两段拷贝。

比较硬核的游戏引擎底层文章,适合想了解内存布局那层的同学

JSON and C++26 compile-time reflection: a talk

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赶紧实现吧,标准都出了编译器还没跟上

Understanding Safety Levels in Physical Units Libraries

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没有物理意义

六级下来了解一下还是可以的,做物理仿真/科学计算的强烈建议看看

From error-handling to structured concurrency

单线程里的错误处理大家都懂:出错了沿调用栈往上传,每一帧做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为什么难的同学值得一读

Everything old is new again: memory optimization

开头就很接地气: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++程序员的机会来了

glib与wxWidgets交互的crash调查

感谢群友投稿。感兴趣可以看看

San Diego C++ Meetup #84: UNDO – Agentic Debugging Using Time Travel

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的优势在于它特别擅长”给我工具,按工具的约束推理”——可以自动化地做”向前运行到某个条件,然后反向追这个值从哪里来”这类反复操作。

C++/WinRT Plus:将C++标准模块引入Windows开发

群友花了36~40小时给C++/WinRT加了标准模块(import)支持,写了这篇记录。

为什么难: C++/WinRT是纯头文件库,要模块化面临几个问题:

  1. 原来naive的方案是把所有头文件合成一个模块,导致BMI(.ifc)文件高达260MB,是STL的9倍,编译速度是灾难
  2. C++/WinRT里有些地方没加std::前缀,模块化后会找不到符号
  3. 一个声明错误地放在了只应该定义宏的文件里

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++/WinRT 中的异步

感谢群友投稿

Writing Integration Tests – Heuristics of what to Aim for

讲集成测试写法启发式原则,非C++特有的通用内容。几个核心观点:

了解一下还是可以的

From Coding to Orchestrating: AI文章两篇 1 2

cppdepend发了两篇AI相关的文章,一篇是”AI在软件开发里的100+种用法”,一篇是”AI写文章、发文章、读文章——人类帖子的终结”。

我就不看了

开源项目介绍


上一期

本期

下一期

看到这里或许你有建议或者疑问或者指出错误,请留言评论! 多谢! 你的评论非常重要!也可以帮忙点赞收藏转发!多谢支持! 觉得写的不错那就给点吧, 在线乞讨 微信转账