Releases: wanghenshui/cppweeklynews
C++ 中文周刊 2026-05-23 第202期
qq群 753792291 答疑在这里
欢迎投稿,推荐或自荐文章/软件/资源等,评论区留言
本期文章由 赞助老爷 赞助 在此表示感谢
资讯
标准委员会动态/ide/编译器信息放在这里
编译器信息最新动态推荐关注hellogcc公众号 本周更新 2025-01-08 第288期
文章
从零写一个快一点的 lock-free queue
从 mutex + std::queue 讲到 Michael-Scott queue、ABA、batching、hazard pointer。主题很老,但代码贴得多,适合复习 lock-free 基础。
基准版:
template <typename T>
class SimpleQueue {
public:
void Push(T* item, uint32_t threadId) {
(void)threadId;
std::unique_lock lock(m_Mutex);
m_Queue.push(*item);
}
bool Pop(T* item, uint32_t threadId) {
(void)threadId;
std::unique_lock lock(m_Mutex);
if (m_Queue.empty()) return false;
*item = std::move(m_Queue.front());
m_Queue.pop();
return true;
}
private:
std::queue<T> m_Queue;
mutable std::mutex m_Mutex;
};低竞争下没问题,高竞争下锁竞争导致 context switch,队列操作几纳秒,切线程几微秒,直接高几个数量级。我谢谢你。
CAS 基础:
std::atomic<int> value{42};
int expected = 42;
int desired = 100;
if (value.compare_exchange_strong(expected, desired)) {
// success! value was 42, now it is 100
} else {
// failure, `expected` now holds the actual current value
}朴素 lock-free queue:
template <typename T>
struct NaiveNode {
T data;
std::atomic<NaiveNode*> next;
};
template <typename T>
class NaiveLockFreeQueue {
public:
NaiveLockFreeQueue() {
auto dummy = new NaiveNode<T>();
dummy->next.store(nullptr);
head.store(dummy);
tail.store(dummy);
}
void Push(T* value, uint32_t /*threadId*/) {
auto newNode = new NaiveNode<T>();
newNode->data = *value;
newNode->next.store(nullptr, std::memory_order::relaxed);
while (true) {
auto currentHead = head.load(std::memory_order::acquire);
auto currentNext = currentHead->next.load(std::memory_order::acquire);
if (currentHead != head.load(std::memory_order::acquire)) continue;
if (currentNext == nullptr) {
NaiveNode<T>* expected = nullptr;
if (currentHead->next.compare_exchange_strong(expected, newNode)) {
head.compare_exchange_strong(currentHead, newNode);
return;
}
} else {
head.compare_exchange_strong(currentHead, currentNext);
}
}
}
bool Pop(T* value, uint32_t /*threadId*/) {
while (true) {
auto currentTail = tail.load(std::memory_order::acquire);
auto currentHead = head.load(std::memory_order::acquire);
auto next = currentTail->next.load(std::memory_order::acquire);
if (currentTail != tail.load(std::memory_order::acquire)) continue;
if (currentTail == currentHead) {
if (next == nullptr) return false;
head.compare_exchange_strong(currentHead, next);
} else {
if (!next) continue;
*value = next->data;
if (tail.compare_exchange_strong(currentTail, next)) {
delete currentTail;
return true;
}
}
}
}
private:
std::atomic<NaiveNode<T>*> head;
std::atomic<NaiveNode<T>*> tail;
};坑也很经典:
- 每个 Push/Pop 都
new/delete,锁从队列搬到 malloc 里了 - 链表节点散在堆上,cache locality 很差
- ABA/use-after-free:地址从 A 变 B 又变 A,CAS 看地址没变,实际对象早不是那个对象了
Part II:Batching。 朴素版本三个问题:分配太多、cache locality 差、内存回收不安全。先解决前两个:一个节点不存一个元素,存一整个固定大小数组。比如 block size=1024,那就是 1024 次 push 才需要一次节点分配,pointer chasing 也从每个元素一次变成每 1024 个元素一次。
每个 slot 里有数据和一个 commit flag。注意“抢到 slot index”和“数据已经写完”不是一回事,中间有窗口,所以 consumer 不能抢到 index 就直接读。
template <typename T, bool BlockingRead>
requires std::is_trivially_copyable_v<T>
class FastQueueNodeSlot {
public:
FORCE_INLINE FastQueueNodeSlot() {
commited.clear(std::memory_order::relaxed);
}
FORCE_INLINE void Commit(T* value) {
memcpy(&data, value, sizeof(T));
commited.test_and_set(std::memory_order::release);
if constexpr (BlockingRead) {
commited.notify_one();
}
}
FORCE_INLINE bool IsCommited() const {
return commited.test(std::memory_order::acquire);
}
FORCE_INLINE void WaitTillCommited() const {
while (!IsCommited()) {
commited.wait(false, std::memory_order::relaxed);
}
}
FORCE_INLINE bool TryRead(T* value) const {
if (IsCommited()) {
memcpy(value, &data, sizeof(T));
return true;
}
return false;
}
FORCE_INLINE void Read(T* value) const {
WaitTillCommited();
memcpy(value, &data, sizeof(T));
}
FORCE_INLINE void Reset() { commited.clear(std::memory_order::relaxed); }
private:
T data;
std::atomic_flag commited;
};这里限制 T 必须 trivially copyable,所以能直接 memcpy。你不能直接塞 std::string/unique_ptr,要么塞 POD,要么塞指针。换来的是没有构造/析构/异常安全那一堆麻烦,hot path 更干净。
C++20 的 atomic_flag::wait/notify_one 也挺有意思,blocking 版本不用 mutex + condition_variable,底下可以走 futex/WaitOnAddress 这类 atomic wait。
节点结构:
template <typename T, uint32_t Size, bool BlockingRead>
class FastQueueNode {
public:
using Self = FastQueueNode<T, Size, BlockingRead>;
FORCE_INLINE FastQueueNode() { Reset(); }
FORCE_INLINE bool IsUsedUp(std::memory_order memoryOrder) {
return (tail.load(memoryOrder) >= Size);
}
FORCE_INLINE bool Push(T* value) {
auto oldHead = head.load(std::memory_order::acquire);
while (true) {
if (oldHead == Size) {
return false;
}
auto newHead = oldHead + 1;
if (head.compare_exchange_strong(oldHead, newHead,
std::memory_order::seq_cst,
std::memory_order::acquire)) {
slots[oldHead].Commit(value);
return true;
}
}
}
FORCE_INLINE bool Pop(T* value) {
auto currentTail = tail.load(std::memory_order::seq_cst);
while (true) {
auto currentHead = head.load(std::memory_order::acquire);
if (currentTail >= currentHead) {
return false;
}
if constexpr (!BlockingRead) {
if (!slots[currentTail].IsCommited()) {
return false;
}
}
auto newTail = currentTail + 1;
if (tail.compare_exchange_strong(currentTail, newTail,
std::memory_order::seq_cst,
std::memory_order::acquire)) {
slots[currentTail].Read(value);
return true;
}
}
return false;
}
FORCE_INLINE void Reset() {
head.store(0, std::memory_order::release);
tail.store(0, std::memory_order::release);
next.store(nullptr, std::memory_order::release);
for (auto& slot : slots) {
slot.Reset();
}
}
FORCE_INLINE Self* Next(std::memory_order memoryOrder) {
return next.load(memoryOrder);
}
FORCE_INLINE bool TrySetNext(Self*& expected, Self* value) {
return next.compare_exchange_strong(expected, value,
std::memory_order::seq_cst,
std::memory_order::acquire);
}
private:
alignas(64) std::array<FastQueueNodeSlot<T, BlockingRead>, Size> slots;
alignas(64) std::atomic<uint32_t> head;
alignas(64) std::atomic<uint32_t> tail;
alignas(64) std::atomic<Self*> next;
};alignas(64) 是为了防 false sharing。head、tail、next 都是热 atomic,放同一个 cache line 上就是互相扇嘴巴,cache coherence 会疯狂 invalidate。浪费一点 padding,比 cache line ping-pong 强。
Part III:Hazard Pointer。 batching 降低了分配频率,但 delete oldTail 仍然不安全。hazard pointer 的规则是:我要 dereference 某个共享指针之前,先把这个指针发布到一个全局可见的位置,告诉所有线程“这个地址我正在看,别删”。
每线程状态:
template <typename T, uint32_t PointerCount>
struct alignas(64) HazardThreadState {
std::array<std::atomic<T*>, PointerCount> hazardPointers;
std::vector<T*> retiredPointers;
std::vector<T*> scratchBuffer;
};核心 Protect:
T* Protect(const std::atomic<T*>& ptr, uint32_t threadId, uint32_t slotId) {
assert(threadId < ThreadCount);
assert(slotId < PointerCount);
T* p = nullptr;
do {
p = ptr.load(std::memory_order::acquire);
state[threadId].hazardPointers[slotId].store(p, std::memory_order::seq_cst);
} while (p && p != ptr.load(std::memory_order::seq_cst));
return p;
}
void Release(uint32_t threadId, uint32_t slotId) {
state[threadId].hazardPointers[slotId].store(nullptr,
std::memory_order::release);
}重点是“load → publish → reload 检查是否还一样”。如果第一次 load 之后别的线程已经把指针换掉并 retire 了,第二次检查会发现不一致,重来。这样 Protect 返回时,至少能保证:我发布 hazard 之后,这个 atomic 仍然指向它。别人 Scan 时会看到我的 hazard,不会 delete。
删除逻辑不是直接 delete,而是 Retire 进 retired list,攒到阈值再 Scan:
template <typename Deleter = std::nullptr_t>
requires(std::invocable<Deleter, T*> ||
std::same_as<Deleter, std::nullptr_t>)
void Scan(uint32_t threadId, Deleter deleter) {
auto& activePointersTracker = state[threadId].scratchBuffer;
activePointersTracker.clear();
for (const auto& list : state) {
for (const auto& pointer : list.hazardPointers) {
T* ptr = pointer.load(std::memory_order::seq_cst);
if (ptr) activePointersTracker.push_back(ptr);
}
}
std::sort(activePointersTracker.begin(), activePointersTracker.end());
auto& retiredList = state[threadId].retiredPointers;
for (uint32_t i = 0; i < retiredList.size();) {
if (!std::binary_search(activePointersTracker.begin(),
activePointersTracker.end(), retiredList[i])) {
if constexpr (!std::same_as<Deleter, std::nullptr_t>) {
deleter(retiredList[i]);
} else {
delete retiredList[i];
}
retiredList[i] = retiredList.back();
retiredList.pop_back();
} else {
i++;
}
}
}这里两个性能点:scratch buffer 预分配复用;sort + binary_search 把朴素 O(N*R) 变成 O((N+R)logN)。另外 Retire 只能在 CAS 成功、当前线程真的“拿到这个节点所有权”之后调用,不然多个线程 retire 同一个指针就等着 d...
C++ 中文周刊 2026-05-15 第201期
qq群 753792291 答疑在这里
欢迎投稿,推荐或自荐文章/软件/资源等,评论区留言
资讯
编译器信息最新动态推荐关注hellogcc公众号 本周更新 2025-01-08 第288期
GCC 16.1 正式发布,几个大改动:
- C++20 成为默认标准(-std=gnu++20),对应标准库不再是实验性的
- C++26 实验性支持:Reflection(
-freflection)、Contracts、expansion statements、std::simd - 诊断输出新增 HTML 格式,SARIF 支持控制流信息
- 静态分析器开始能跑小型 C++ 示例了
- 新增 Algol68 语言前端
ga68(……嗯) - 向量化支持 uncounted loop,改进 early exit 处理
isocpp 的摘要 写得更清楚一些,感兴趣直接看那个。
C++20 终于成默认了,embed 赶紧来吧。
文章
ARM 处理器上字符匹配最快的方式?
Daniel Lemire 老熟人,这次讲的是在 ARM 上用 SVE2 指令集加速 JSON 字符分类的问题。
背景: simdjson 在索引 JSON 文档时,需要对每个字节判断它是不是结构字符(: , [ ] { })或者空白字符(\t \n \r ' ')。这叫 vectorized classification,是解析速度的关键路径。
传统 NEON 做法: Langdale & Lemire 2019 年的论文给出的方案是 table-driven 的 nibble lookup,对每个字节拆成高低 nibble 分别查表取 bitmask 再合并,没有分支,挺好用。
SVE2 新做法: SVE2 有个指令叫 svmatch_u8,直接一条指令就能做"这个字节在不在这个集合里"的判断——a 向量里每个位置,和 b 向量里所有字节比较,只要有一个相等就命中。这比 NEON 的 nibble lookup 清晰多了:
// 结构字符集合
uint8_t op_chars_data[16] = {
0x3a, 0x2c, 0x5b, 0x5d, 0x7b, 0x7d, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0
};
// 空白字符集合
uint8_t ws_chars_data[16] = {
0x09, 0x0a, 0x0d, 0x20, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0
};
svuint8_t op_chars = svld1_u8(svptrue_b8(), op_chars_data);
svuint8_t ws_chars = svld1_u8(svptrue_b8(), ws_chars_data);
svbool_t pg = svptrue_pat_b8(SV_VL16);
svuint8_t d = svld1_u8(pg, input);
svbool_t op = svmatch_u8(pg, d, op_chars); // 一条指令搞定
svbool_t ws = svmatch_u8(pg, d, ws_chars);SVE/SVE2 现状碎碎念: 设计上寄存器宽度可变(像 RISC-V 那套),结果现在所有商用芯片还是 128-bit,不如 AVX-512 的 512-bit 宽。但优点是和 NEON 可以互操作,能直接在 NEON 代码里插 SVE2 指令。Graviton4/Cobalt 100/Google Axion 都支持了,就是 Apple 一直没跟进 SVE2,理由不明,可能是他们有自己的路线。
值得关注的方向,SVE2 在字符处理场景确实有优势。
C++26: 结构化绑定可以引入参数包了
C++26 的 P1061R10 让结构化绑定可以引入参数包,简单来说就是 Python 那套 *rest 解包语法进 C++ 了。
std::tuple<X, Y, Z> f();
auto [x, y, z] = f(); // 今天就能用
auto [... xs] = f(); // C++26: xs 是长度为3的包 (X, Y, Z)
auto [x, ... rest] = f(); // C++26: x是X,rest是包(Y, Z)
auto [x, ... rest, z] = f(); // C++26: x是X,rest是包(Y),z是Z
auto [... a, ... b] = f(); // ill-formed,多个包不行实际用处看 dot product: 以前用 tuple 做点积,得嵌两层 std::apply,丑死了:
// C++26 前:
template<class P, class Q>
auto dot_product(P p, Q q) {
return std::apply([&](auto... p_elems){
return std::apply([&](auto... q_elems){
return (... + (p_elems * q_elems));
}, q)
}, p);
}
// C++26:
template<class P, class Q>
auto dot_product(P p, Q q) {
auto && [... p_elems] = p;
auto && [... q_elems] = q;
return (... + (p_elems * q_elems));
}省流:可以,写起来清爽很多。历史上 pack 只能在模板里用,这个提案是在慢慢放开这个限制。非模板上下文的支持因为实现复杂度被砍掉了,下次再说。
xor eax, eax 和 sub eax, eax 清零,为什么大家都用 xor?
Raymond Chen 的历史八卦。Compiler Explorer 作者 Matt Godbolt 写了篇文章解释为什么编译器用 xor eax, eax 清零——因为比 mov eax, 0 省几个字节(不用编码4字节立即数)。但 Raymond 说,那 sub eax, eax 也是同样字节数,执行周期也一样,而且 flag 行为还更好:
| 指令 | OF | SF | ZF | AF | PF | CF |
|---|---|---|---|---|---|---|
xor eax, eax |
clear | clear | set | undefined | set | clear |
sub eax, eax |
clear | clear | set | clear | set | clear |
xor 会让 AF 标志位变成 undefined,sub 则是干净地 clear。理论上 sub 更好。
但 xor 赢了,Raymond 的猜测是纯粹的历史惯性——早期有人用了 xor,感觉"聪明",编译器跟进,大家看见编译器用 xor 就以为 xor 有什么特别的,然后全倒向 xor。Intel 后来给两个指令都加了 zero-register detection(识别为清零,bypass 执行),但其他 CPU 厂商可能只识别了 xor 没识别 sub,所以用 xor 保险。
他的一个前同事就习惯用 sub r, r,Raymond 说读汇编的时候一眼就能认出是他写的。
另一个 bonus:Itanium 的 xor r, r 清零不管用,因为数学运算不会 reset NaT bit,但 Itanium 有专用零寄存器,所以也不需要这个 trick。
又是 WinAPI/x86 历史考古,我谢谢你。不过挺有意思,就是那种"为什么大家都这么写"的问题往往答案都是"历史惯性"。
给 C++ 异常加上 Stack Trace
Python/Java 抛异常会给你完整 stack trace,C++ 就给你个 what(): bad optional access,然后你看着 crash log 一脸懵。这篇文章讲怎么优雅地补上这个能力。
简单方案: 自定义异常类,构造的时候 std::stacktrace::current() 抓栈:
class ExceptionWithStackTrace : public std::exception {
public:
ExceptionWithStackTrace(std::string what) {
m_what = std::format("what(): {}\n{}", what,
std::stacktrace::current(1)); // skip自身frame
}
auto what() const noexcept -> const char* override {
return m_what.c_str();
}
private:
std::string m_what;
};问题是标准库的异常(std::bad_optional_access、std::out_of_range 等)你改不了。
更好的方案:hook __cxa_throw。 Itanium ABI 下所有 throw 都会调用 __cxa_throw,用链接器的 --wrap 功能劫持它:
-Wl,--wrap=__cxa_throw
然后实现 __wrap___cxa_throw,在里面抓 stacktrace 存到 thread_local 变量,再转发给原始函数:
thread_local std::array<std::stacktrace, 5> s_stacktraces;
extern "C" auto __real___cxa_throw(
void* thrown_object, std::type_info* tinfo,
void (*dest)(void*)) -> void;
extern "C" auto __wrap___cxa_throw(
void* thrown_object, std::type_info* tinfo,
void (*dest)(void*)) -> void
{
auto exception_count = std::uncaught_exceptions();
if (exception_count < ssize_t(s_stacktraces.size())) {
s_stacktraces[exception_count] = std::stacktrace::current(1);
}
__real___cxa_throw(thrown_object, tinfo, dest);
}catch 块里调 print_stacktrace() 就能拿到抛出点的完整栈了。包括标准库异常也生效。
限制: 只适用于 GCC/Clang + Itanium ABI(Linux/macOS/MinGW),MSVC 要 hook 不同的函数。
这个 trick 挺实用的,线上排查 crash 的时候 stack trace 是刚需。值得复现一下。
C++20 Modules:工具链的空白地带
Memgraph 从 2025 年底开始在生产代码里逐步引入 C++20 modules,编译器这边还好,工具链这边——一言难尽。作者踩了 6 个坑:
问题一:clangd 不会自己构建 PCM 文件
clangd 需要预编译的 .pcm 文件才能理解 import,但它不会触发 build。解决方法:开编辑器前先手动 build 一遍模块:
ninja -C build -t inputs \
| grep -E '\.cppm\.o$|\.o\.modmap$' \
| xargs -r ninja -C build问题二:头文件 flag 推断出错
compile_commands.json 只有 .cpp/.cppm 的条目,没有头文件。clangd 用启发式找"最近的同名 .cpp"来借 flag,但每个 TU 的 -fmodule-file= 参数可能不一样,推断错了就到处报找不到模块的红线,而 clang 编译完全正常。
问题三:import through #include 不传播(clangd bug,已有 fix)
A.cppm export 一个类型,B.h 里 import A,C.cpp 里 #include "B.h" 用这个类型,clangd 报错说没 import,但 clang 编译正常。原因是 preamble PCH 和 C++20 modules 互相打架,#include 进来的 import 丢了。workaround 是在 C.cpp 里再加一行多余的 import A;。
问题四:ccache 不追踪 PCM 文件内容
ccache 只 hash 编译参数字符串(包括 -fmodule-file=mod.pcm 路径),不 hash PCM 文件的内容。改了模块接口重新生成 PCM,ccache 还是返回旧的缓存,结果是 stale object file 里用的是旧的 struct layout,链接进新的模块对象,运行时内存corruption。make clean 没用,因为旧缓存在 ccache 里不在 build 目录里。典型的"我这里能跑"bug。
问题五:clang-tidy 也需要 artifact 先存在
和 clangd 一样,要先 build 出 .bmi 等文件才能跑。
问题六:clang-tidy fix 冲突
同一个头文件可能同时被普通 TU 和 module global fragment 看到,clang-tidy 如果要 apply fix 会搞出冲突。
总结就是:modules 编译器支持已经基本到位,但clangd / ccache / clang-tidy 这一整套工具链还在追赶中,生产环境引入要做好踩坑准备。embed 赶紧来吧,modules 这一关过完还有 modules 工具链这一关……
GCC 16 改进了 C++ 错误信息和 SARIF 输出
Red Hat 的 David Malcolm(GCC 诊断系统主要贡献者)写的文章,讲他在 GCC 16 里做的几个改进:
- C++ 模板错误信息层级化显示:GCC 15 加了实验性选项,GCC 16 进一步完善,把嵌套的模板错误按层级结构展示,不再是一坨平铺的文字
- SARIF 输出增强控制流信息:SARIF 是静态分析结果交换格式,方便 IDE 集成;GCC 16 在 SARIF 里加了控制流图信息,可以把诊断和代码路径关联起来
- HTML 格式诊断输出:
-fdiagnostics-format=html,输出带语法高亮和行号链接的 HTML,适合在 CI 报告里展示 - 静态分析器(
-fanalyzer)开始支持小型 C++ 代码
C++ 错误信息可读性终于有人认真搞了,之前那个模板错误展开五屏的体验确实不是人过的。
C++26: string 和 string_view 的改进
三个小改动,都是"为什么这么基础的东西以前没有"系列:
P2495R3:stringstream 支持 string_view 构造
以前 stringstream 只能从 string 构造,传 string_view 要先转一次 string。C++26 直接加了 string_view 构造函数。Clang 19 已经有了。
P2697R1:bitset 支持 string_view 构造
同一个作者,同一个问题,bitset 从字符串构造也要转 string。C++26 修了。Clang 18 已经有了。
P2591R5:string + string_view 终于能拼接了
这个才是长期的痛:
std::string s = "hello ";
std::string_view sv = "world";
return s + sv; // C++26 之前:编译错误!以前 operator+ 两边必须都是 string,加 string_view 不行,只能 s + std::string(sv) 或者 s += sv。C++26 加了 operator+ 重载,GCC 15/Clang 19 已经有了。
这设计问题被某个永远没实现的"string builder 特性"卡了好多年。省流:可以了。
用信号量实现跨进程读写锁(共三篇)
Raymond Chen 这周连出三篇,讲怎么用 Windows 信号量搭跨进程读写锁,是一个演进系列:
第一篇:基本思路
SRWLOCK 不跨进程,但可以用有名信号量模拟。核心想法:设置最大读者数 N,信号量初始 token 数为 N:
- 读锁:拿 1 个 token
- 写锁:拿全部 N 个 token(把读者全挤走)
#define MAX_SHARED 100
void AcquireShared() { WaitForSingleObject(sharedSemaphore, INFINITE); }
void ReleaseShared() { ReleaseSemaphore(sharedSemaphore, 1, nullptr); }
void AcquireExclusive() {
for (unsigned i = 0; i < MAX_SHARED; i++)
WaitForSingleObject(sharedSemaphore, INFINITE);
}
void ReleaseExclusive() { ReleaseSemaphore(sharedSemaphore, MAX_SHARED, nullptr); }第二篇:修死锁
两个线程同时拿写锁会死锁——各拿了一半 token,互相等对方放。解法:加一个 mutex 序列化写锁请求,同时只有一个线程贪婪地抢 token:
HANDLE sharedMutex;
void AcquireExclusive() {
WaitForSingleObject(sharedMutex, INFINITE);
for (unsigned i = 0; i < MAX_SHARED; i++)
WaitForSingleObject(sharedSemaphore, INFINITE);
ReleaseMutex(sharedMutex);
}第三篇:写锁吞吐量差的问题
修完死锁后,写锁吞吐还是很差——写...
C++ 中文周刊 2026-04-17 第200期
公众号
点击「查看原文」跳转到 GitHub 上对应文件,链接就可以点击了
qq群 753792291 答疑在这里
欢迎投稿,推荐或自荐文章/软件/资源等,评论区留言
第200期了,感谢大家一路支持,我自己也没想到能写这么多期
资讯
标准委员会动态/ide/编译器信息放在这里
编译器信息最新动态推荐关注hellogcc公众号 本周更新 2025-01-08 第288期
听说 cppreference 活了,详见官方公告,只读了一年多终于有动静,embed赶紧来吧
C++26 is done! Herb Sutter 的伦敦会议 trip report,C++26 技术工作正式完成。四个核心大功能:反射(P2996,自模板发明以来最大升级)、内存安全(UB减少 + 标准库hardening,Google已在生产部署,降低segfault率30%)、合约(pre/post/contract_assert)、std::execution。这是C++11以来最重要的版本,吗。
文章
What reinterpret_cast doesn't do
Andreas Fertig 在教嵌入式课程时发现一个普遍误解:很多人以为 reinterpret_cast 可以把 raw bytes 直接变成目标类型,实际上这是 UB。
常见写法(UB):
bool ProcessData(std::span<unsigned char> bytes) {
// ⚠️ UB:reinterpret_cast 只转换了指针,没有创建 ConfigValues 对象
ConfigValues* cfgValues = reinterpret_cast<ConfigValues*>(bytes.data());
return HandleConfigValues(cfgValues);
}标准怎么说:[expr.reinterpret.cast] p7 说的是可以把指针转成另一种指针类型,然后转回来。它允许的是指针的类型别名,不是创建新对象。通俗说:reinterpret_cast 给了你一个"我说这块内存是 ConfigValues 的权利",但没给你"在这块内存上真正存在一个 ConfigValues 对象的权利"。
用苹果橙子类比:如果 reinterpret_cast<Orange*>(apple) 真能创建 Orange,那它就必须销毁原来的 Apple,这样 std::any 这类 type-erasure 工具就没法实现了。
正确做法:C++23 的 std::start_lifetime_as:
bool ProcessData(std::span<unsigned char> bytes) {
// ✅ 正式开始 ConfigValues 的生命周期,不调用构造函数
ConfigValues* cfgValues = std::start_lifetime_as<ConfigValues>(bytes.data());
return HandleConfigValues(cfgValues);
}start_lifetime_as 隐式在目标地址创建对象,同时结束源对象生命周期,不调用构造/析构函数,纯粹是 C++ 对象模型层面的操作。
值得一看,做嵌入式或者喜欢 type punning 的必看
Learning to read C++ compiler errors: Illegal use of -> when there is no -> in sight
Raymond Chen 的侦探故事。客户报告包含 ole2.h 时编译器报错:error C3927: '->': trailing return type is not allowed,但代码里根本没有 ->。
结论:有个宏叫 AddError,展开后包含 -> token。客户一开始声称没有这样的宏,让他们生成预处理结果才发现确实有。
教训:编译器报说什么东西非法,那个东西大概率存在于某个宏展开里。当报错信息看起来和代码完全对不上时,先查宏。
A Critique Of The Two Trivial Relocatability Papers
一个 C++ 用户对两个 trivial relocatability 提案的业余批评,写得很到位。
背景:move 一个对象通常等价于 memcpy + 把旧位置设成 moved-from 状态(让析构变成 no-op)。对 vector 这类容器来说,resize 时可以直接 memcpy 数据再 free 旧内存,根本不需要调那些通常是 no-op 的析构函数——但前提是类型是 trivially relocatable 的。不是所有类型都行,比如 std::list 的尾节点指向 list 对象本身,std::string SSO 时数据指针也指向 string 对象本身,move 之后必须更新指针,不能直接 memcpy。
P1144("sharp knife"方案):用属性标注 [[trivially_relocatable]],简单类型好用。但一旦成员包含非 trivially relocatable 的类型,属性就要写成带谓词的条件形式:
struct [[trivially_relocatable(
std::is_trivially_relocatable_v<std::string>
&& std::is_trivially_relocatable_v<std::list<int>>
&& std::is_trivially_relocatable_v<library::sbo_container>
&& std::is_trivially_relocatable_v<std::unordered_map<std::string, int>>
)]] Example2 {
std::string str;
std::list<int> list;
library::sbo_container other;
std::unordered_map<std::string, int> map;
Example2(); Example2(Example2&&); ~Example2();
};漏掉一个成员 → 数据损坏、野指针,而且很难 debug。另外,这等于剥夺了库作者以后改成 non-trivially relocatable 的权利(Qt 为了 ABI 稳定性会故意声明空的构造/析构函数,留后路)。
作者建议:把属性改成自动条件版,叫 memberwise_trivially_relocatable。
P2786("弯曲定义"方案):换了关键字 trivially_relocatable_if_eligible replaceable_if_eligible(是的,两个关键字),但更大的问题是重新定义了"trivial"的含义——为了支持 ARM64e 平台的 signed vtable pointer(Apple 的需求),P2786 的"trivial"不再意味着"可以 memcpy",而是"编译器知道怎么做"。
后果一连串:
- 不能用
memcpy,必须用std::relocate/std::uninitialized_relocate,强制依赖标准库头文件 - 不能用
realloc——P1144 至少开了这扇门,P2786 直接锁死 - 没有谓词版本(context-sensitive keyword 引入新的 most-vexing-parse 问题),SBO 类型要绕路写 SFINAE dummy struct
- 没有任何实际 benchmark 证明支持 polymorphic 类型有收益
- 对 union 内含 polymorphic 类型的情况:implementation-defined(这其实是个逃生舱,但不应该是)
作者总结:P2786 感觉是委员会为标准库自己加的特性,普通 C++ 用户几乎捞不到任何好处。
结论:应该标准化 P1144 的定义(trivially relocatable = 可以 memcpy),把属性拆成 [[trivially_relocatable_if_eligible]] 和 [[trivially_relocatable_if(predicate)]];P2786 在没有实际收益证明之前不该推进。
P2786 差点进了 C++26,最后一刻撤回,P1144 从2018年起卡在委员会里已经8年了。这两个提案的故事是 C++ 委员会里最典型的互相否决死锁案例之一
Hashing in C++26
系列第二篇。作者之前用 tied() 返回 std::tuple of references 来做泛型哈希(每个类都得自己写这个函数)。C++26 反射出来之后可以直接干掉这个要求。
C++26 反射基础
反射操作符 ^^ 获取实体的编译期表示(std::meta::info 类型),配合 splicer 语法 [: r :] 生成代码:
struct Data { int id; std::string name; };
std::meta::info r_id = ^^Data::id;
Data d{42, "hello"};
d.[: r_id :] = 100; // 等价于 d.id = 100反射遍历所有成员,自动计算哈希
template <typename T>
requires std::is_class_v<T>
size_t calculate_hash(const T& obj, size_t seed = 0)
{
constexpr auto ctx = std::meta::access_context::unchecked();
// subobjects_of 同时包含基类子对象 + 非静态数据成员
static constexpr auto r_subobjects = std::define_static_array(
std::meta::subobjects_of(^^T, ctx));
// template for:编译期展开,每个成员生成一条 hash_combine
template for (constexpr auto r_sub : r_subobjects)
{
using Subobject_t = typename[:std::meta::type_of(r_sub):];
static_assert(Hashable<Subobject_t>, "Subobject must be hashable");
Utility::hash_combine(seed, obj.[:r_sub:]); // splicer 访问成员值
}
return seed;
}access_context::unchecked() 让 private 成员也能访问(不然 private member 的 hash 会被漏掉)。
opt-in 机制:变量模板特化一行搞定:
template <typename T>
inline constexpr bool enabled_for_hashing_v = false;
template <EnabledForHashing T>
struct std::hash<T> {
size_t operator()(const T& obj) const { return calculate_hash(obj); }
};
template <>
inline constexpr bool enabled_for_hashing_v<Person> = true; // 启用以前写 std::hash<T> 特化要手写 N 行,新加成员还容易漏。现在加一行特化就行,新成员自动进哈希计算。(Godbolt)
Search optimization journey 3: Optimize norm gathering
SereneDB 系列的第三篇。背景:BM25 评分要读每个文档的 norm 值,128个文档一个块,每次需要从 columnar storage 按偏移量 gather 128 个值:
for (scores_size_t i = 0; i != kPostingBlock; ++i) {
values[i] = ReadValue(origin, docs[i] - doc_base);
}这是 gather pattern,每次迭代访问不同内存地址,编译器拒绝自动向量化(就算加 #pragma clang loop vectorize(enable) 也不行)。
AVX2 手写 gather intrinsics:
auto indices = _mm256_loadu_si256(reinterpret_cast<const __m256i*>(docs + i));
indices = _mm256_sub_epi32(indices, base);
auto gathered = _mm256_i32gather_epi32(
reinterpret_cast<const int*>(origin), indices, std::to_underlying(Encoding));
gathered = _mm256_and_si256(gathered, mask);
_mm256_storeu_si256(reinterpret_cast<__m256i*>(values + i), gathered);x86 能跑,但 ARM NEON 没有 gather 指令(只有 SVE 才有)。于是做了 benchmark,结果出乎意料:
关键洞察:sorted posting list 里有大量 contiguous block([1100, 1101, ..., 1227])。连续 block 可以用 sequential read,编译器能完全向量化。
加一个简单 check:
if (docs[kPostingBlock-1] - docs[0] == kPostingBlock-1) {
// contiguous: 编译器自动 vmovdqu + vpmovzxwd,完美 SIMD
} else {
// sparse: 逐元素 gather
#pragma clang loop unroll(full)
for (...) values[i] = ReadValue(origin, docs[i] - base);
}Benchmark 结果(AMD Ryzen 9 9950X,clang 21):
| 方案 | Dense | Sparse | Mixed |
|---|---|---|---|
| Scalar | 25.7 ns | 25.7 ns | 26.6 ns |
| Gather (AVX2) | 23.5 ns | 23.4 ns | 24.1 ns |
| Hybrid(无 unroll) | 2.7 ns | 26.0 ns | 17.8 ns |
| HybridUS(sparse unroll) | 2.7 ns | 18.9 ns | 13.8 ns |
Dense 路径:2.7 ns vs 25 ns,快 8x!
vpgatherdd 看起来是一条指令,但内部仍然是 8 个单独 micro-load 串行执行,它打不过编译器自动生成的 vpmovzxwd(一次 load 8 个 short + zero-extend 成 32-bit)。
坑:把 dense 路径也 unroll(full) 会变成 5x 更慢(13.5 ns vs 2.7 ns)——编译器展开后看到 128 个独立的常量偏移访问,反而不认识连续 load 模式了,退化成 128 个 scalar load。
最终方案:zero intrinsics,portable,每个路径都赢。反直觉的地方在于 AVX2 gather 根本没想象中快
Top 20 C++ multithreading mistakes and how to avoid them
2026年更新版,加入了 C++20/23 的新内容。挑几个典型的:
坑1:忘记 join()
// 崩溃
std::thread t1(LaunchRocket);
// 忘了 t1.join(),析构时 terminate()
// C++20 解法:jthread 自动 join
std::jthread t1(LaunchRocket); // 析构时自动 join,没事坑8:加锁顺序不一致导致死锁
std::mutex muA, muB;
void CallHome_AB(const std::string& msg) {
muA.lock(); // 先锁A
std::this_thread::sleep_for(100ms);
muB.lock(); // 再锁B
// ...
muB.unlock(); muA.unlock();
}
void CallHome_BA(const std::string& msg) {
muB.lock(); // 先锁B
std::this_thread::sleep_for(100ms);
muA.lock(); // 再锁A — 死锁!
// ...
}t1 持有 A 等 B,t2 持有 B 等 A,经典哲学家就餐。
C++17 解法:std::scoped_lock 原子性获取多锁
void CallHome(const std::string& msg) {
std::scoped_lock lock(muA, muB); // 原子性获取两把锁,内部用死锁规避算法
std::cout << msg << "\n";
} // 自动释放坑9:mutex 双重加锁 UB
异常路径最容易触发:正常路径加锁+解锁没问题,但异常路径触发第二次 lock 导致 UB(debug 实现通常是 crash)。解法是用 std::recursive_mutex 或者提取 RAII 包装。
坑6/7:不用 RAII 锁
直接调 mu.lock()/unlock() 一旦中间抛异常就死锁。lock_guard 解决单锁,scoped_lock 解决多锁,现代 C++ 根本不应该手动 lock/unlock。
老文经典,新人必看,老人也有拿来复盘的价值
You're absolutely right, no one can tell if C++ is AI generated
Mathieu Ropert 分析了一条推文:两段实现同一功能的代码,一段 AI 生成,一段人工写,猜哪个是哪个。
// Option 1
Node* get_or_create(Nodes& nodes, ...C++ 中文周刊 2026-03-27 第199期
qq群 753792291 答疑在这里
欢迎投稿,推荐或自荐文章/软件/资源等,评论区留言
本期文章没人赞助
资讯
标准委员会动态/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不同。
并发程序里怎么搞?没有"单一的栈"。背景线程出了异常,主线程怎么知道?
两种糟糕的现有方案:
- Java/Python:打印异常,线程自己死,主程序继续跑。坑在于:程序可能进入从未被测试过的状态,死锁风险大
- Go/Rust/C++:背景goroutine/thread panic,整个进程挂。坑在于:一个监控goroutine的小bug会把关键服务搞垮
结构化并发(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是纯头文件库,要模块化面临几个问题:
- 原来naive的方案是把所有头文件合成一个模块,导致BMI(.ifc)文件高达260MB,是STL的9倍,编译速度是灾难
- 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_mac...C++ 中文周刊 2026-03-21 第198期
qq群 753792291 答疑在这里
欢迎投稿,推荐或自荐文章/软件/资源等,评论区留言
本期文章没人赞助
资讯
标准委员会动态/ide/编译器信息放在这里
编译器信息最新动态推荐关注hellogcc公众号 本周更新 2025-01-08 第288期
文章
Chromium's span-over-initializer-list success story
Arthur O'Dwyer记录了C++26 P2447(给span加上从initializer_list构造的能力)在Chromium里的实际收益
这个特性允许span<const T>直接从{a, b, c}这样的初始化列表构造,不需要先建个临时vector或array。Chromium在2024年底给base::span加了同样的构造函数,顺带清理了一大堆调用点
典型的before/after:
// Before:先建vector,再传给CertCache
std::vector<scoped_refptr<const Cert>> certs(
{kcer_cert_0, kcer_cert_1, kcer_cert_2, kcer_cert_3,
kcer_cert_3, kcer_cert_2, kcer_cert_1, kcer_cert_0,
kcer_cert_0, kcer_cert_2, kcer_cert_3, kcer_cert_1});
CertCache cache(certs);
// After:直接传
CertCache cache({kcer_cert_0, kcer_cert_1, kcer_cert_2, kcer_cert_3,
kcer_cert_3, kcer_cert_2, kcer_cert_1, kcer_cert_0,
kcer_cert_0, kcer_cert_2, kcer_cert_3, kcer_cert_1});// Before:临时std::array
ASSERT_TRUE(ConfigureAppContainerSandbox(
std::array<const base::FilePath*, 2>{&pathA, &pathB}));
// After:直接传
ASSERT_TRUE(ConfigureAppContainerSandbox({&pathA, &pathB}));文章还总结了几个"失败案例"——主要是CTAD的缺失。比如span(box.type) == {'f','t','y','p'}过不了,原因是语法规定==后面不能跟braced-initializer-list(除co_yield和赋值运算符外)
有趣的是,这些调用点为了表达"两个元素的视图",各自发明了三种不同的workaround(临时array、双括号、显式cast),一有了正确的语法,全都收敛到同一种写法了。语言就应该这样
省流:C++26之后span<const T>参数直接传{a, b}就行了
How many branches can your CPU predict?
Lemire测了现代处理器的分支预测器到底能记住多少条分支
测试套路:把同一段随机数序列重复跑,让CPU有机会"学习"分支模式,看多少分支能被完美预测
while (howmany != 0) {
val = generate_random_value();
if (val is odd) write to buffer;
decrement howmany;
}结果:
| 处理器 | 可完美预测的分支数 |
|---|---|
| AMD Zen 5 | 30,000 |
| Apple M4 | 10,000 |
| Intel Emerald Rapids | 5,000 |
Lemire:我又一次对Intel感到失望。AMD在这个benchmark上干得非常好
对做benchmark的人来说有个实际意义:小数据集测出来的结果可能完全不代表真实场景,因为CPU学会了你的测试数据。
BIO: The Bao I/O Coprocessor
bunnie(知名硬件黑客,写过《芯片设计》那本书)在设计自己的22nm SoC Baochip-1x时,造了个I/O协处理器BIO作为Raspberry Pi PIO的替代品
先研究PIO: 发现PIO虽然只有9条指令,但是典型CISC思路——每条指令带barrel shifter、wrap-around、FIFO管理、side-set……移植到FPGA后发现PIO用的逻辑单元比RISC-V CPU核还多,critical path是RISC-V的两倍
BIO的设计: 直接用PicoRV32(RV32E,16个寄存器),然后把r16-r31扩展成特殊功能寄存器:
x16-x19:8深度FIFO的头/尾访问,空时read-halt,满时write-halt
x20:snap-to-quantum,halt直到下一个时钟量子
x21-x26:GPIO读写/方向控制
x27-x30:事件寄存器,条件阻塞
x31:core ID + 时钟计数
面积对比:BIO 14597 cells vs PIO 39087 cells,BIO大约是PIO一半面积,时钟速率是PIO的4倍以上
代码用标准RV32E汇编写,甚至支持C(通过Zig的clang编译器),不需要学PIO那套私有指令集。三个核并行做DMA、SPI等外设协议
这是硬件设计文章,不是纯C++内容。不过思路很清晰,RISC vs CISC的经典权衡在I/O协处理器上的体现。感兴趣的可以点进去看看
Looking at Unity finally made me understand the point of C++ coroutines
Mathieu Ropert终于找到了coroutines在游戏里的具体用武之地
问题: 游戏里有很多跨帧的特效/行为,比如一个物体先左移一格,再右移四次,再旋转。写成state machine极其丑陋:
class TimeWarp {
enum class State { Jump, StepRight, HandsOnHips, DoAgain };
State _state = State::Jump;
int _i = 0;
Transform* _transform;
bool operator()() {
switch (_state) {
case State::Jump:
_transform->position.x -= 1.f;
_state = State::StepRight;
break;
case State::StepRight:
_transform->position.x += 0.2f;
if (++_i == 4) { _state = State::HandsOnHips; _i = 0; }
break;
// ...
}
return false;
}
};用C++23 generator写:
std::generator<std::monostate> TimeWarp(GameObject& obj)
{
// It's just a jump to the left
obj.transform.position.x -= 1.f;
co_yield {};
// Then a step to the right
for (int i = 0; i < 4; ++i) {
obj.transform.position.x += 0.2f;
co_yield {};
}
// Let's do the time warp again!
for (int i = 0; i < 4; ++i) {
obj.transform.Rotate(0.f, 90.f * i, 0.f);
co_yield {};
}
}co_yield {}的语义是"本帧做到这里,下帧继续"。跟Unity的yield return null一毛一样——Unity当年也是因为没有await,用yield hack了这个语义,作者亲切地称之为"The Unity Hack"
调度器不到100行:
class effects_manager {
public:
void add(std::generator<std::monostate> effect) {
_effects.push_back(std::move(effect));
_iterators.push_back(_effects.back().begin());
}
void run() {
// 清理已完成的coroutine
int first = 0;
for (; first != _effects.size()
&& _iterators[first] != _effects[first].end(); ++first);
if (first != _effects.size()) {
for (int i = first; ++i != _effects.size(); ) {
if (_iterators[i] != _effects[i].end()) {
_effects[first] = std::move(_effects[i]);
_iterators[first] = std::move(_iterators[i]);
++first;
}
}
_effects.erase(begin(_effects) + first, end(_effects));
_iterators.erase(begin(_iterators) + first, end(_iterators));
}
// 推进所有特效一帧
for (int i = 0; i < _effects.size(); ++i)
++_iterators[i];
}
private:
std::vector<std::generator<std::monostate>> _effects;
using effect_iterator =
decltype(std::declval<std::generator<std::monostate>>().begin());
std::vector<effect_iterator> _iterators;
};用法:effects.add(TimeWarp(obj)),主循环里调effects.run(),完活
文章末尾还给出了更纯粹的版本——让coroutine yield一个Draw对象,run()收集所有Draw对象,顺手还能parallel_for并行推进
这个use case比Fibonacci生成器实在多了,不用co_await也不用搞一套executor。做游戏/动画的同学强烈推荐
Serenely Fast I/O Buffer (With Benchmarks)
SereneDB团队造了个新的I/O buffer(sdb::message::Buffer),跟folly::IOBuf、absl::Cord等比了一下
设计: 本质是链表形式的chunk队列,chunk大小指数增长(bounded by min_growth和max_growth)。关键点:这是个SPSC(单生产者单消费者),只有一个atomic变量_send_end,lock-free极简
支持三种数据状态:
- uncommitted:写入但未提交,reader不可见
- committed:提交,reader可读,非紧急
- flushed:紧急,立即触发发送回调
class Buffer {
public:
Buffer(size_t min_growth, size_t max_growth,
size_t flush_size = std::numeric_limits<size_t>::max(),
std::function<void(SequenceView)> send_callback = {});
void WriteUncommited(std::string_view data);
void Write(std::string_view data, bool need_flush);
void Commit(bool need_flush);
[[nodiscard]] uint8_t* GetContiguousData(size_t capacity);
template<typename Op>
void WriteContiguousData(size_t capacity, Op op);
};benchmark数据(AMD Ryzen 9 9950X + jemalloc):
| 场景 | folly appender | sdb buffer | sdb快多少 |
|---|---|---|---|
| 1亿包,中位14B | 887ms | 884ms | 0.28% |
| 100万包,中位0.3KB | 28ms | 26ms | 6.96% |
| 100万包,中位2.6KB | 366ms | 234ms | 36.09% |
| 1万包,中位82KB | 90ms | 50ms | 45.06% |
大包场景下比folly快接近一半。SPSC限制有点强,但场景合适的时候很香
代码:https://github.com/serenedb/serenedb
C++26: Span improvements
Sandor Dargo总结了C++26给std::span带来的四个改进
P2447R6:span从initializer_list构造(上面Arthur那篇讲过了)
void take(std::span<const int> v);
take({1, 2, 3}); // C++26前报错,C++26后OKP2821R5:span.at()
终于有bounds-checked访问了:
std::array<int, 4> arr = {1, 2, 3, 4};
std::span<int> s{arr};
s[10]; // undefined behaviour
s.at(10); // throws std::out_of_rangestring、string_view、vector、array、deque都有at(),span一直是孤儿,现在补上了
P2833R2:Freestanding里的span/expected
嵌入式场景能用了(span::at()因为会throw,不进freestanding子集)
P3029R1:更智能的mdspan CTAD
int arr[5] = {1, 2, 3, 4, 5};
int* p = arr;
// C++26后:deduced as span<int, 5>,保留编译期大小信息
std::span s(p, std::integral_constant<std::size_t, 5>{});都是实用的小修小补,感兴趣的可以点进去看看
视频
Can Standard C++ Replace CUDA for GPU Acceleration? - Elmar Westphal - CppCon 2025
Elmar Westphal在Forschungszentrum Jülich做了15年GPU程序员,主要是分子动力学/微磁学模拟,CUDA老手一枚,讲的是用标准C++的execution policy做GPU编程
GPU编程的演化路径:
- 上古时代:kernel、warp、block、独立内存域,苦不堪言
- 中间时代:各种pragma(OpenMP、OpenACC),希望编译器帮你干活
- 现在:现代驱动(NVIDIA HPC SDK)把CPU/GPU内存域的边界基本抹平了,
nvc++这类编译器支持直接用std::execution::par_unseq跑到GPU上
核心思路:你写的是标准C++,换个编译器flag就能跑在GPU上:
std::for_each(std::execution::par_unseq, data.begin(), data.end(),
[](auto& x) { x = compute(x); });用nvc++ -stdpar=gpu编译,上面这段代码就会生成GPU kernel。不写CUDA、不写kernel,代码可以同时跑CPU和GPU
实际效果: 对很多workload,生成的GPU代码性能和手写CUDA相当。不是所有场景都适合(legacy代码、复杂内存访问模式有限制),但对新代码来说portability极高
这个话题隔一段时间就有人拿出来讲一次,但这次讲的人是实际用CUDA多年的人,可信度高一些。Slides: Back_to_the_Standard.pdf
"But my tests passed!" - Exploring C++ Test Suite Weaknesses with Mutation Testing - Nico Eichhorn - Meeting C++ 2025
Nico Eichhorn(Bosch)讲Mutation Testing,这个是比代码覆盖率更狠的测试质量评估手段
原理: 工具自动对你的代码做微小的"变异"(mutant):
// 原代码
if (x > 0) return true;
// 变异1:改运算符
if (x >= 0) return true;
// 变异2:翻转条件
if (x <= 0) return true;
// 变异3:直接返回
return true;每个mutant跑一遍测试套件:
- 测试失败:mutant被"kill"了,说明测试有效
- 测试仍然通过:mutant"存活"了,说明测试有盲区
最终得到一个mutation score = killed / total,越高越好
为什么比coverage更有用: 100%覆盖率只说明每行代码被执行过,不说明你的断言足够强。Mutation testing直接验证测试能不能发现bug
演讲包含Bosch真实项目的case study。C++主流工具是mull(LLVM-based)
[Building Bridges: C++ Interop, Foreign Func...
C++ 中文周刊 2026-03-15 第197期
qq群 753792291 答疑在这里
欢迎投稿,推荐或自荐文章/软件/资源等,评论区留言
本期文章没人 赞助
上期文章UB介绍太多,群友严重抗议,以后不发太多UB鉴赏来
资讯
标准委员会动态/ide/编译器信息放在这里
编译器信息最新动态推荐关注hellogcc公众号 本周更新 2025-01-08 第288期
文章
C++26: The Oxford variadic comma
C++26要废弃省略号参数前不加逗号的写法(P3176R1)。名字来自英文里的"Oxford comma"——列表里最后一个"and"前面的逗号,这里要求省略号前必须加逗号
现状: 目前C++允许两种写法:
void foo(int, ...); // 有逗号(C兼容)
void foo(int...); // 没逗号(仅C++)没逗号的写法来自前标准时代的C++,C从来不允许省逗号
混乱来源: C++11引入了参数包,T...在不同上下文含义完全不同:
template<class Ts>
void f(Ts...); // 注意!不是参数包,是Ts类型后跟省略号参数
template<class... Ts>
void f(Ts... args); // 这才是参数包更离谱的是abbreviated function template的写法:
void g(auto... args); // variadic function template
void g(auto args...); // 非variadic + 省略号参数还有终极混沌——六个点:
void h(auto......); // 等价于 (auto..., ...)C++26的处理: 废弃不加逗号的写法,不是移除。加个逗号就行了,工具可以自动化
这个废弃还为未来的语言特性(比如P1219R2"同构可变参数")腾出了语法空间
省流:以后省略号前面记得加逗号就对了
C++26: std::is_within_lifetime
C++26在<type_traits>里加了bool std::is_within_lifetime(const T* p),用来在编译期检查指针指向的对象是否在生命周期内,最常见的用途——检查union的当前活跃成员
union Storage {
int i;
double d;
};
constexpr bool check_active_member() {
Storage s;
s.i = 42;
return std::is_within_lifetime(&s.i); // true
}设计细节:
- 是
consteval的,只能编译期使用,运行时不能调 - 接收指针而不是引用,避免临时对象和生命周期延长的麻烦
- 名字叫
is_within_lifetime而不是is_union_member_active,因为委员会选择在更底层解决问题
核心动机: 实现一个空间最优的Optional<bool>
struct OptBool {
union { bool b; char c; };
constexpr auto has_value() const -> bool {
if consteval {
return std::is_within_lifetime(&b);
} else {
return c != 2; // 哨兵值
}
}
constexpr bool value() const {
return b;
}
};编译期用is_within_lifetime检查活跃成员,运行时用哨兵值。两全其美
目前(2026年2月)主流编译器还没支持。等吧
Understanding std::shared_mutex from C++17
std::shared_mutex科普文。读写锁嘛,读多写少的场景用它
问题: std::mutex所有访问都互斥,即使多个线程只是读,也得排队等
方案: std::shared_mutex支持两种锁模式:
- shared ownership:多线程同时持有(读)
- exclusive ownership:单线程独占(写)
代码改动很小:
class Counter {
public:
int get() const {
std::shared_lock lock(mutex_); // 共享锁
return value_;
}
void increment() {
std::unique_lock lock(mutex_); // 独占锁
++value_;
}
private:
mutable std::shared_mutex mutex_;
int value_{0};
};benchmark结果:在read-heavy场景下,std::mutex用了285ms,std::shared_mutex只要102ms
常见陷阱:
- 不能递归锁,UB
- 不能从shared lock升级到unique lock,会死锁
- 写操作频繁或锁竞争低的场景,
std::mutex可能反而更快
该measure还得measure
How do compilers ensure that large stack allocations do not skip over the guard page?
Raymond Chen又在讲Windows栈的故事
系统在栈底维护一个guard page,触碰到它就会把它提交为已用内存并在下面创建新的guard page。但如果一个函数局部变量太大(超过一页,通常4KB),直接跳过guard page访问到了reserved区域就会crash
解决方案: 编译器在栈指针需要移动超过一页大小时,会插入一个_chkstk辅助函数调用。这个函数按顺序逐页touch所有需要的页面,让guard page机制正常工作
又是WinAPI,了解一下底层还是可以的
Prefix sums at tens of gigabytes per second with ARM NEON
Daniel Lemire讲如何用ARM NEON SIMD指令加速前缀和
标量版本: 简单循环,每个元素依赖前一个,理论上限 = CPU频率 × 1 entry/cycle。4GHz处理器约 3.9 GB/s
朴素SIMD: 对4个元素做前缀和需要2次shift+2次add,加上carry传递,并不比标量快:
input = [A B C D]
shift1 = [0 A B C]
sum1 = [A A+B B+C C+D]
shift2 = [0 0 A B+A]
result = [A A+B A+B+C A+B+C+D]
4条sequential指令处理4个值,还不如标量一条一个
关键优化: 利用NEON的interleaved load/store(vld4q_u32)一次加载16个值,自动deinterleave成4组,按组并行做prefix sum,然后汇总。理论快2倍
original data : ABCD EFGH IJKL MNOP
loaded data : AEIM BFJN CGKO DHLP
完整实现:
void neon_prefixsum_fast(uint32_t *data, size_t length) {
uint32x4_t zero = {0, 0, 0, 0};
uint32x4_t prev = {0, 0, 0, 0};
for (size_t i = 0; i < length / 16; i++) {
uint32x4x4_t vals = vld4q_u32(data + 16 * i);
// Prefix sum inside each transposed ("vertical") lane
vals.val[1] = vaddq_u32(vals.val[1], vals.val[0]);
vals.val[2] = vaddq_u32(vals.val[2], vals.val[1]);
vals.val[3] = vaddq_u32(vals.val[3], vals.val[2]);
// Now vals.val[3] contains the four local prefix sums:
// vals.val[3] = [s0=A+B+C+D, s1=E+F+G+H,
// s2=I+J+K+L, s3=M+N+O+P]
// Compute prefix sum across the four local sums
uint32x4_t off = vextq_u32(zero, vals.val[3], 3);
uint32x4_t ps = vaddq_u32(vals.val[3], off);
off = vextq_u32(zero, ps, 2);
ps = vaddq_u32(ps, off);
// Add the incoming carry from the previous 16-element block
ps = vaddq_u32(ps, prev);
// Prepare carry for next block: broadcast the last lane of ps
prev = vdupq_laneq_u32(ps, 3);
// The add vector to apply to the original lanes
uint32x4_t add = vextq_u32(prev, ps, 3);
// Apply carry/offset to each of the four transposed lanes
vals.val[0] = vaddq_u32(vals.val[0], add);
vals.val[1] = vaddq_u32(vals.val[1], add);
vals.val[2] = vaddq_u32(vals.val[2], add);
vals.val[3] = vaddq_u32(vals.val[3], add);
// Store back the four lanes (interleaved)
vst4q_u32(data + 16 * i, vals);
}
}核心就是8条sequential指令处理16个值,理论上比标量快2倍
在Apple M4上的结果:
| 方法 | GB/s |
|---|---|
| scalar | 3.9 |
| naive SIMD | 3.6 |
| fast SIMD | 8.9 |
2.3倍加速,不错
Learning to read C++ compiler errors: Ambiguous overloaded operator
Raymond Chen讲怎么读编译器错误。一个老代码加了C++/WinRT后,32位构建炸了——operator<<重载歧义
问题根源:代码里有条件编译的__int64版operator<<,只在非64位、非STL7.0、非STL11.0时编译。升级到C++17(C++/WinRT要求)后STL版本变成12.0,条件不匹配又激活了
#if !defined(_WIN64) && !defined(_STL70_) && !defined(_STL110_)
// These are already defined in STL
std::ostream& operator<<(std::ostream&, const __int64& );
std::ostream& operator<<(std::ostream&, const unsigned __int64& );
#endif解法: 直接删掉整个块。都2026了不可能回退到C++03了
Raymond Chen说的好:历史总在重演。五年前加了_STL110_,现在又得打补丁。与其贴胶带不如直接删
Partial Truth vs Explicit Failure: Designing Honest System Responses
API设计的取舍讨论:系统部分失败时,是返回不完整的"成功"响应,还是直接报错?
核心观点:
- 成功响应隐含了完整性承诺,下游会当真
- 部分失败伪装成成功,监控是绿的但正确性在默默恶化
- partial response不是不能用,但必须明确标注什么是缺失的
optional<bool>里false和std::nullopt完全是两回事——"不"和"不知道"不一样
Is resize + assign faster than reserve + emplace_back for vector?
Jens Weller在quick-bench上测了两种vector填充策略
用clang 17:
size_t:resize + assign快1.1倍- 4个
size_t的struct:reserve + emplace_back快1.2倍
有个转折点,数据类型大小不同结论不同。让vector增长的测试中差异更小
Follow up: resize + assign is often faster than reserve + emplace_back for vector
上面那篇的后续。换成GCC(13.2),resize + assign成了稳赢:
size_t快1.9倍- struct快1.6倍
clang + libc++也类似,size_t最高快2.1倍
结论:你要是有大块填充vector的场景,值得测一下
Convenience Gone Wrong: A C++ auto Story
嵌入式老哥花了两小时debug一个BME280传感器驱动,I2C数据明明正确但校准数据始终为空
罪魁祸首:
auto cal = inst.calibration; // BUG: 这是个拷贝!修复:
- auto cal = inst.calibration;
+ auto& cal = inst.calibration;少了一个&,auto默默做了个拷贝,校准数据写到了局部变量里,函数结束就析构了
评论区有人说得好:删掉copy/move constructor就能在编译期发现这种问题。道理都懂,但就是会栽坑。这就是"图省事"的代价——写auto省了几个字符,debug花了两小时
Accessing inactive union members through char: the aliasing rule you didn't know about
上面OptBool的has_value()里运行时分支 return c != 2; 看起来像访问了union的非活跃成员,应该UB才对?
其实不是UB! C++标准有个例外:通过char、unsigned char或std::byte类型的glvalue,可以合法访问任何对象的byte表示。char本质上就是"指向byte的指针"
bool b活跃时通过char c读:合法(char可以alias任何类型)char c活跃时通过bool b读:UB(bool不在aliasing例外列表里)
这个知识点挺冷门的,但搞union的时候得知道
Behold the power of meta::substitute
Barry Revzin展示C++26 reflection的std::meta::substitute有多强大
核心思路: 用substitute把reflection值替换进模板,用extract把值拉出来,实现"函数而非函数模板"的编程范式
具体实现了一个highlight_print——解析format string在编译时完成,运行时直接调用生成好的函数指针。整个解析引擎是普通函数而不是函数模板:
consteval auto parse_information(std::span<std::meta::info const> arg_types,
std::string_view sv)
-> std::meta::info;关键的substitute/extract/invoke三连:
substitute(^^fmt_types, arg_types)— 把reflection值替换进变量模板extract<T const*>(r)— 从reflection中拉出实际值- 调用提取出的函数指针
最后通过consteval构造函数把格式化字符串解析和函数指针提取都在编译期完成
文章还讨论了如果C++有constexpr函数参数和consteval mutable变量(像Zig那样),实现可以简化多少
抽象程度很高,值得反复看。Compiler Explorer demo可以直接跑
C++ Reflection: Another Monad
Ben Deane看了Barry上面那篇文章后直接拍桌子:C++26 reflection就是个monad!
用^^做pure(把类型提升到reflection-land),substitute做fmap(甚至是n-ary的,等价于applicative functor),extract<std::meta::info>做join:
template <typename T>
c...C++ 中文周刊 2026-02-20 第196期
qq群 753792291 答疑在这里
欢迎投稿,推荐或自荐文章/软件/资源等,评论区留言
本期文章由 机械工业出版社 赞助 ,他们送了我好多书,在此表示感谢
资讯
标准委员会动态/ide/编译器信息放在这里
编译器信息最新动态推荐关注hellogcc公众号 本周更新 2025-01-08 第288期
文章
Falsehoods programmers believe about undefined behavior
Predrag Gruevski写的经典文章(PVS-Studio转载),列了43条程序员对UB的"错觉",逐条打脸
首先搞清三个概念:
程序行为分三个桶,不是两个:
- Specification-defined:语言标准定义了会发生什么,占绝大部分
- Implementation-defined:由编译器/OS/硬件定义,比如
char到底几位 - Undefined behavior:什么都可能发生,没有任何保证
关于"什么时候触发UB"的错觉:
- UB只在-O2/O3才会触发 — 错
- 关掉优化用-O0就没UB了 — 错
- 加调试符号就安全 — 错
- 在调试器下跑就没UB了 — 错
- 好吧有UB,但代码还是会"做正确的事" — 错
- 最多崩溃(SIGSEGV)— 错
- 最多崩溃或死循环 — 错
关于"UB会不会执行奇怪的代码":
- 至少不会跑到程序中其他不相干的代码 — 错
- 至少不会执行程序中理论上不可达的代码 — 错
关于"UB的影响范围"的错觉:
- 之前"正常工作"的UB代码,下次还能正常工作 — 不保证
- UB的影响至少局限于使用了UB值的代码 — 错
- 至少局限于同一个编译单元 — 错
- 至少只影响UB之后的代码 — 大错特错! UB可以"时间旅行",编译器可以基于"不存在UB"的假设优化UB之前的代码
关于"可能后果"的错觉:
17-24. 至少不会损坏内存/堆/栈/栈帧/CPU状态 — 都不保证
28. 至少不会把硬盘擦了 — 不保证(虽然不太可能)
29. 至少不会损坏硬件 — 不保证
"之前好好的"系列:
31-36. 不改代码重新编译还能好好工作吗?用同样编译器?同一台机器?同一时间编译?在月食期间献祭一根新内存条?— 统统不保证
关于"自我一致性"的错觉:
37-40. 相同二进制 + 相同输入重复跑,行为一样吗?即使程序是确定性的?即使是单线程?即使不读任何外部数据?— 统统不保证
社区贡献的错觉:
- 调试器里看到的程序状态跟源码是对应的 — 错。UB可以时间旅行,导致调试器里的变量值和代码逻辑对不上
- UB纯粹是运行时现象 — 错。C++的ODR(One Definition Rule)违规就是编译期/链接期UB,编译器甚至不需要报错就能造成混乱
最后一条特别假设:
"如果程序编译没报错就没有UB" — 在C/C++中100%是错的。编译器没有义务检测UB。在Rust中,只要不用unsafe,编译通过基本就没有UB — 这是Rust社区付出巨大努力的成果
**核心观点:**编译器的保证列表是空的。一旦有UB,所有行为都是合规的。不管你觉得多离谱
Implementing C++ Coroutines
Rhidian De Wit用最小实现讲清楚C++20协程的完整组成,从回调地狱到协程的优雅转变
背景设定:
假设你有个嵌入式系统要和10个硬件板通信,每个板操作耗时约1秒。同步阻塞?启动要10秒,用户会暴动。用线程?线程创建开销大(Linux默认每个线程2MB栈内存),还有竞态条件和死锁。用Promise的.then()回调?代码一嵌套起来就地狱了:
MySocketType socket{};
socket.connect("MyServer:1234").then(
[&socket]() {
socket.send("FirstPartOfData").then(
[]() {
socket.send("SecondPartOfData").then(
); // and many more ...
}
).catch(
[](std::runtime_error const & ex) {
std::cerr << "Error sending data to server: " << ex.what() << "\n";
}
);
}
).catch(
[](std::runtime_error const & ex) {
std::cerr << "Creating connection failed: " << ex.what() << "\n";
}
);用协程重写,彻底消除嵌套,写出来的异步代码看起来像同步的:
MySocketType socket{};
try {
co_await socket.connect("MyServer:1234");
} catch (std::runtime_error const & ex) {
std::cerr << "Creating connection failed: " << ex.what() << "\n";
}
try {
co_await socket.send("FirstPartOfData);
co_await socket.send("SecondPartOfData");
// and more!
} catch (std::runtime_error const & ex) {
std::cerr << "Error sending data to server: " << ex.what() << "\n";
}最小Promise/Awaiter实现:
Promise代表异步工作的状态 — 存一个布尔标志和回调函数。Awaiter负责检查是否就绪(await_ready)、挂起协程(await_suspend中注册回调,Promise完成时调用handle.resume()恢复协程)、以及恢复后的处理(await_resume)。operator co_await把两者连接起来:
class Promise {
private:
bool m_isReady;
std::function<void()> m_callback;
public:
Promise() = default;
bool IsReady() const {
return m_isReady;
}
void AddCallback(std::function<void()> cb) {
m_callback = std::move(cb);
}
void Set() {
// we can only execute a Promise once
if (m_isReady) return;
m_isReady = true;
m_callback();
}
Awaiter operator co_await() {
return Awaiter{ *this };
}
};
class Awaiter {
private:
Promise & m_promise;
public:
Awaiter(Promise & promise) : m_promise(promise) {}
bool await_ready() {
return m_promise.IsReady();
}
void await_suspend(std::coroutine_handle<> handle) {
m_promise.AddCallback([handle]() {
handle.resume();
});
}
void await_resume() {}
};promise_type挂载协程语义:
通过特化coroutine_traits,告诉编译器"返回Promise类型的函数就是协程"。initial_suspend/final_suspend返回suspend_never表示eager模式(立即执行),返回suspend_always就是lazy模式。return_void()在协程结束时触发Promise的Set(通知等待者),unhandled_exception()重新抛出未捕获的异常:
template<typename ... Args>
struct std::coroutine_traits<Promise, Args...> {
struct promise_type {
Promise promise; // The Promise object associated with our coroutine
Promise get_return_object() {
return promise;
}
std::suspend_never initial_suspend() noexcept { return {}; }
std::suspend_never final_suspend() noexcept { return {}; }
void return_void() {
promise.Set();
}
void unhandled_exception() {
std::rethrow_exception(std::current_exception());
}
};
};**最后的建议:**实际做异步工作需要事件循环或线程池,作者推荐Boost.Asio(功能完整但不太友好)和cppcoro(更易用),不要自己造轮子
From 3 Minutes to 7.8 Seconds: Improving on RocksDB performance
SereneDB用RocksDB做存储引擎,在ClickBench数据集(120列、650MB、约100万行的裁剪版)上从180秒优化到7.8秒的完整路径
SereneDB的列式存储方案:
RocksDB本身只是KV存储,SereneDB通过复合key (table_id, column_id, primary_key) 实现列式存储。同一列的数据自然在RocksDB中连续排列,天然适合列扫描
优化路径(每步都有火焰图验证):
- Transaction Put → SST Writer(180s → 19.5s):
Transaction Put每次插入都要锁key、排序,120列的场景下开销爆炸。改用SST Writer直接写SST文件,每列一个SST,后续compaction时合并 - 关掉过滤器和压缩(19.5s → 14.3s):火焰图发现
Standard128RibbonBitsBuilder(类Bloom filter)吃了20%CPU,LZ4压缩也在热路径上。这两者在导入阶段不需要,compaction时会重建 - fast_float替换sscanf(14.3s → 12s,16%提速):
fast_float::parse_options options{
fast_float::chars_format::general |
fast_float::chars_format::skip_white_space
};
auto [parseEnd, ec] = fast_float::from_chars_advanced(ptr, end, v, options);- std::string → vector<char>(12s → 10.6s,12%提速):热路径中频繁的单字节
append调用,std::string每次都要维护null terminator,换成vector<char>直接把字符写入次数减半:
while (true) {
auto v = th.getByteOptimized(delim);
if (!th.isNone(delim)) {
break;
}
th.ownedString_.append(1, static_cast<char>(v));
}- 去掉热路径的运行期检查(10.6s → 8.7s,18%提速):
rocksdb::SstFileWriter::Rep::AddImpl里一堆key有序性检查和虚函数调用的status方法(只读一个atomic_bool但用了memory_order_relaxed),火焰图显示这些检查吃了20%CPU。改成debug-only assert,虚函数改成编译期static_cast - 消除key的隐藏拷贝(8.7s → 7.8s,10%提速):每行每列都要构建key并调用
ikey.Set(key, sn, vt),暗含一次字符串拷贝。120列 × 100万行 = 1.2亿次分配。改成预创建key复用
Key takeaways:
- 避免热路径中的虚函数
- 别不必要地拷贝字符串
- 运行期检查能改assert就改assert
**结论:**火焰图定位 + 每步小改,总共23倍加速(180s → 7.8s)。不要害怕改成熟项目的代码(包括RocksDB这种),仔细测量+精准修改就能带来巨大收益
The Reset trick
Andreas Fertig上个月写了篇Singleton done right in C++,收到大量评论质疑:为什么把拷贝/移动构造放到private里并用=default?直接=delete不好吗?这篇是回应
原始代码(引发争论):
先看引起争议的Logger单例:
class Logger {
Logger() = default;
Logger(const Logger&) = default;
Logger(Logger&&) = default;
Logger& operator=(const Logger&) = default;
Logger& operator=(Logger&&) = default;
public:
static Logger& Instance() {
static Logger theOneAndOnlyLogger{};
return theOneAndOnlyLogger;
}
};作者承认,出于"best by default"的精神,现在他会改成=delete + public。但接下来解释了为什么有人需要private + default
真正的原因 - ConfigManager的Reset():
看这个有Reset()方法的ConfigManager,Reset()创建一个新的默认构造对象然后move到this,这样对象就回到了默认状态。要做到这一点,move操作必须在类内部可用:
class ConfigManager {
std::unordered_map<std::string, std::string> mConfig{};
ConfigManager() = default;
ConfigManager(const ConfigManager&) = default;
ConfigManager(ConfigManager&&) = default;
ConfigManager& operator=(const ConfigManager&) = default;
ConfigManager& operator=(ConfigManager&&) = default;
public:
void Reset() {
ConfigManager fresh; // 创建默认构造对象
*this = std::move(fresh); // move赋值到this
}
// Get(), Set() 等其他方法...
};**为什么不直接=delete?**因为Reset()通过move赋值一个新构造的对象来重置状态。用move而不是手动清理每个成员,可以保证Reset后对象一定处于默认构造态,不需要碰析构逻辑,也不会漏掉新增的成员变量
用swap还是move取决于你对异常的态度:move失败程序终止(简单粗暴),swap留了恢复的余地
总结:=delete确实是更好的默认选择。但当类内部需要借用拷贝/移动语义(比如Reset、swap)时,private + default是合理的
Converting data to hexadecimal outputs quickly
Daniel Lemire对比三种hex编码方案的性能,起因是Skovoroda给Node.js提议用算术版本替换查表版本
三种方案:
- 查表法 - Node.js目前使用的方法,16字符查找表
- 算术nibble法 - Skovoroda提议的纯算术运算,无查表
- 手写SIMD(NEON) - 用ARM NEON指令手写向量化
在10000随机字节上的基准测试:
| 方案 | 吞吐量 | 每字节指令数 |
|---|---|---|
| 查表 | 3.1 GB/s | 9 |
| 算术nibble | 23 GB/s | 0.75 |
| NEON手写 | 42 GB/s | 0.69 |
算术版本为什么比查表快近8倍?因为查表有内存依赖,阻碍了编译器的自动向量化。纯算术操作没有这个问题,编译器可以轻松用SIMD指令一次处理多个字节
查表版本(Node.js当前用的):
static const char hex[] = "0123456789abcdef";
for (size_t i = 0, k = 0; k < dlen; i += 1, k += 2) {
uint8_t val = src[i];
dst[k + 0] = hex[val >> 4];
dst[k + 1] = hex[val & 15];
}算术nibble版本(Skovoroda提议的):
关键trick:x + '0'处理0-9的情况,(x > 9) * 39再加39跳到'a'-'f'的ASCII区间。纯算术,无分支,编译器一看就能向量化:
char nibble(uint8_t x) { return x + '0' + ((x > 9) * 39); }
for (size_t i = 0, k = 0; k < dlen; i += 1, k += 2) {
uint8_t val = src[i];
dst[k + 0] = nibble(val >> 4);
dst[k + 1] = nibble(val & 15);
}NEON手写向量化:
一次处理32字节,用vqtbl1q_u8做NEON表查找+vst2q_u8做交织写入(评论区有人建议用ST2替代ZIP,代码更干净且性能不变):
size_t maxv = (slen - (slen%32));
for (; i < maxv; i += 32) {
uint8x16_t val1 = vld1q_u8((uint8_t*)src + i);
uint8x16_t val2 = vld1q_u8((uint8_t*)src + i + 16);
uint8x16_t high1 = vshrq_n_u8(val1, 4);
uint8x16_t low1 = vandq_u8(val1, vdupq_n_u8(15));
uint8x16_t high2 = vshrq_n_u8(val2, 4);...C++ 中文周刊 2026-01-31 第195期
qq群 753792291 答疑在这里
欢迎投稿,推荐或自荐文章/软件/资源等,评论区留言
资讯
https://cppstat.dev/ 分享c++进展的网站
文章
C++ has scope_exit for running code at scope exit. C# says "We have scope_exit at home."
Raymond Chen讲怎么在C#里实现类似C++的scope_exit功能
C#的try-finally有个问题:清理代码离资源创建太远,代码审查时容易漏掉。而且多层嵌套的try-finally简直不忍直视:
var gadget = widget.GetActiveGadget(Connection.Secondary);
if (gadget != null) {
try {
⟦ lots of code ⟧
if (gadget.IsEnabled()) {
try {
⟦ lots more code ⟧
} finally {
gadget.Disable();
}
}
} finally {
widget.SetActiveGadget(Connection.Secondary, null);
}
}解决方案 - ScopeExit类:
.NET 8引入了using语句和ref struct,可以这么玩:
public ref struct ScopeExit
{
public ScopeExit(Action action)
{
this.action = action;
}
public void Dispose()
{
action.Invoke();
}
Action action;
}用起来就清爽多了:
var gadget = widget.GetActiveGadget();
if (gadget != null) {
using var clearActiveGadget = new ScopeExit(() => widget.SetActiveGadget(null));
⟦ lots of code ⟧
if (gadget.IsEnabled()) {
using var disableGadget = new ScopeExit(() => gadget.Disable());
⟦ lots more code ⟧
}
}清理逻辑就近放置,不用深层嵌套,代码结构清晰多了
技术改进(评论区讨论):
- 用
Interlocked.Exchange(ref action, null)?.Invoke()代替简单的action.Invoke(),确保Dispose只执行一次 - C# 13允许ref struct实现接口,可以显式实现IDisposable
- 异常安全性问题:lambda的delegate分配可能失败(OutOfMemoryException),这会在设置scope-exit之前引入异常窗口
不如C++ WIL的scope_exit提供的保证严格,但实践中已经够用了。ReactiveExtensions库(Rx.NET)也提供了完全相同的helper功能
Benchmarking with Vulkan, or the curse of variable GPU clock rates
GPU基准测试遇到的大坑:动态频率调整
作者的RTX 2080空闲时GPU跑300MHz(显存100MHz),负载下应该是1650-1815MHz(显存1937MHz)。这差异达到5-6倍(GPU)和19倍(显存)!场景渲染时间在2ms、4ms、6ms之间不稳定跳动,根本没法做性能测试
常见解决方案:
- SetStablePowerState.exe:用DX12 API的
ID3D12Device::SetStablePowerState固定频率。简单,但容易忘记关闭 - nvidia-smi命令行工具:Nvidia推荐的新方法,但更麻烦,需要手动重置
作者的解决方案 - gpu_stable_power库:
在Vulkan应用中创建DX12设备上下文来调用SetStablePowerState:
#include <gpu_stable_power/gpu_stable_power.h>
int main()
{
// Defaults to off
gpu_stable_power::Context stable_power;
// Lock clock speeds
stable_power.set_enabled( true );
// Do benchmark
// Optional: manual toggle off
stable_power.set_enabled( false );
// Automatically disables itself on destruction
}用RAII模式,构造时启用,析构时自动禁用。跨平台兼容(非Windows平台自动变为no-op)。在Release构建中自动变为no-op。
库已开源:https://github.com/mropert/gpu_stable_power
GPU基准测试必须固定时钟频率,这是业界公认的做法。动态频率调整对测试结果影响太大了
Breaking WebGL Performance
WebGL性能暴跌的诡异案例:从60FPS掉到1-2FPS
作者在移植游戏"You Are Circle"到浏览器时遇到灾难性性能问题。添加可破坏岩石功能后,某些机器上帧率暴跌,背景音乐都卡了
场景规模:
- 约100个岩石
- 每个最多11个三角形(非常低多边形)
- 这点东西不应该卡啊
问题定位:
原始代码长这样:
for (auto& each : rock)
{
auto vertex_source = per_frame_buffer.transfer_vertex_data(each.vertex_data);
auto index_source = per_frame_buffer.transfer_index_data(each.index_data);
device.draw(vertex_source, index_source, ...);
}看起来没问题?实际上每次transfer_index_data都触发了隐藏的缓存操作:
for (auto& each : rock)
{
auto vertex_source = per_frame_buffer.transfer_vertex_data(each.vertex_data);
invalidate_per_frame_index_buffer_cache(); // 隐藏操作!
auto index_source = per_frame_buffer.transfer_index_data(each.index_data);
fill_cache_again_for_the_whole_big_index_buffer(); // 又是隐藏操作!
device.draw(vertex_source, index_source, ...);
}性能数据:
- 对于100个小岩石,循环每帧处理约400MB的额外数据
- 相当于每秒24GB!
- Firefox Gecko Profiler显示图形线程卡在
MaxForRange<>函数上
根本原因:
WebGL需要验证索引缓冲区有效性以防越界访问(安全原因)。浏览器用缓存机制避免重复验证。作者用4MB的per-frame buffer流式传输顶点和索引数据。问题是:
- 每次传输索引数据都使整个索引缓冲区缓存失效
- 缓存失效后需要重新扫描整个大缓冲区重建缓存
- 每个岩石都触发一次,100个岩石就是100次
为什么开发机器没发现?作者推测是因为开发机器的CPU有更大的L2/L3缓存,索引扫描很快
解决方案:
批处理(Batching)- 把所有岩石几何体合并成单个网格,用一次绘制调用搞定
进一步优化建议:
- 重新排序更新和绘制调用:所有buffer更新放在所有绘制调用之前
- 保持索引缓冲区尽可能小
"缓存是计算机科学中最难的两件事之一",这案例再次证明了这句话。看似简单的immediate mode渲染,在WebGL的安全验证机制下,因为缓存策略的交互,导致了灾难性的性能下降
How we interfaced single-threaded C++ with multi-threaded Rust
Antithesis分享如何把单线程C++与多线程异步Rust对接的实战经验
背景:
- C++端:Fuzzer用单线程编写,通过回调接口与控制器交互
- Rust端:新的控制策略用多线程异步实现
- 核心矛盾:单线程同步C++ vs 多线程异步Rust
挑战1:线程不安全的对象
C++的State对象用非线程安全的引用计数(类似Rc),不能直接跨线程传:
struct State {
ref_ptr<StateImpl> impl; // 类似Rc,不是Arc
...
}直接标记unsafe impl Send for State {}会段错误!
第一个解决方案 - CppOwner/CppBorrower:
pub struct CppOwner<T> {
value: Arc<T>
}
impl<T> CppOwner<T> {
pub fn borrow(&self) -> CppBorrower<T> {
CppBorrower { value: self.value.clone() }
}
pub fn has_borrowers(&self) -> bool {
Arc::strong_count(&self.value) > 1
}
}
impl<T> Drop for CppOwner<T> {
fn drop(&mut self) {
if self.has_borrowers() {
panic!("No!"); // 还有借用者就panic
}
}
}
pub struct CppBorrower<T> {
value: Arc<T>
}
unsafe impl<T: Sync> Send for CppBorrower<T> {}主线程持有CppOwner,其他线程用CppBorrower。定期垃圾回收:
self.in_flight.retain(|s| s.has_borrowers());问题:垃圾回收效率低,工作量与对象数量成正比
更好的解决方案 - SendWrapper + DropQueue:
pub struct SendWrapper<T>(T);
unsafe impl<T> Send for SendWrapper<T> {} // 强制可Send
pub struct CppOwner<T> {
value: ManuallyDrop<SendWrapper<T>>,
}
impl<T> Drop for CppOwner<T> {
fn drop(&mut self) {
let value = unsafe { ManuallyDrop::take(&mut self.value) };
DROP_QUEUE.push(value); // 送回主线程销毁
}
}
static DROP_QUEUE: DropQueue = DropQueue::new();
pub struct DropQueue {
queue: Mutex<Vec<Box<dyn FnOnce()>>>,
}
impl DropQueue {
pub fn drain(&self, _token: MainThreadToken) {
let mut queue = self.queue.lock().unwrap();
for f in queue.drain(..) {
f(); // 在主线程执行销毁
}
}
}关键点:
- SendWrapper让非Send类型也能跨线程传
- 只暴露
&T(当T: Sync时),永不暴露&mut T - 通过DropQueue把对象送回主线程销毁
- 效率提升:工作量与删除数量成正比,不是对象总数
挑战2:线程不安全的函数
某些C++方法只能在主线程调用。如何在编译期保证?
MainThreadToken - 零开销的编译时保证:
pub struct MainThreadToken {
_marker: PhantomData<*mut ()>,
}
impl MainThreadToken {
/// Safety: Only call this on the main thread
pub unsafe fn new() -> Self {
assert!(std::thread::current().id() == MAIN_THREAD_ID);
Self { _marker: PhantomData }
}
}使用方式:
impl DropQueue {
pub fn drain(&self, _token: MainThreadToken) { // 需要token
// ...
}
}C++端的SYNC/UNSYNC标记:
#define SYNC /* marker for thread-safe const methods */
#define UNSYNC /* marker for thread-unsafe const methods */
class MyClass {
int get_immutable_data() const SYNC;
int get_mutable_data_unsync() const UNSYNC;
};Rust端对应的安全包装:
extern "C++" {
/// Safety: Only call on the main thread
unsafe fn get_mutable_data_unsync(&self) -> i32;
}
impl MyClass {
pub fn get_mutable_data(&self, _token: MainThreadToken) -> i32 {
unsafe { self.get_mutable_data_unsync() }
}
}核心哲学转变:
- C++风格:"非常小心地思考你在做什么"
- Rust风格:"利用编译器帮你发现问题"
最终方案通过类型系统和编译器强制执行安全性,而不是依赖程序员的小心谨慎。这才是Rusty的做法
该方案已从研究代码转向生产环境使用。为C++代码定义了明确的安全义务和规范,让团队其他成员也能安全使用
Spinning around: Please don't!
Clément GRÉGOIRE(性能与优化专家)的劝退文:为什么你不应该自己实现自旋锁
核心观点:在大多数情况下,你不应该使用自旋锁,应该使用OS提供的原语(如futex、WaitOnAddress)
文章列举了11种常见的自旋锁陷阱,每一个都能让你的锁变成灾难
问题1:破损的自旋锁(缺乏原子性)
class BrokenSpinLock
{
int32_t isLocked = 0;
public:
void lock()
{
while (isLocked != 0) {} // 检查
// 其他线程可能在这里插入!
isLocked = 1; // 设置
}
};竞态条件,两个线程可以同时获得锁。修复:
void lock()
{
while (isLocked.exchange(1) != 0) {} // 原子操作
}问题2:烧毁CPU
空转循环让CPU以最高频率运行,功耗爆炸。需要用PAUSE指令:
void cpu_pause()
{
#if defined(__x86_64__)
_mm_pause();
#elif defined(__aarch64__)
__yield();
#endif
}
void lock()
{
while (isLocked.exchange(1) != 0)
{
cpu_pause();
}
}问题3 & 4:等待时间
PAUSE指令的延迟在不同CPU上差异巨大:
- 老Intel: ~10周期,老AMD: ~3周期
- Skylake及更新Intel: 140-160周期
- 现代AMD Zen: ~60-65周期
**差异达10倍以上!**固定计数不行,需要指数退避+抖动+基于TSC周期:
struct Yielder
{
static const int maxPauses = 64;
int nbPauses = 1;
const int maxCycles = /*根据CPU测算*/;
void do_yield()
{
uint64_t beginTSC = __rdtsc();
uint64_t endTSC = beginTSC + maxCycles;
const int jitter = static_cast<int>(beginTSC & (nbPauses - 1));
const int nbPausesThisLoop = nbPauses - jitter;
for (int i = 0; i < nbPausesThisLoop && before(__rdtsc(), endTSC); i++)
cpu_pause();
nbPauses = nbPauses < maxPauses ? nbPauses * 2 : nbPauses;
}
};问题5:内存顺序
用SeqCst太重了,用Acquire/Release就够:
void lock()
{
while (isLocked.exchange(1, std::memory_order_acquire) != 0)
{
yield.do_yield();
}
}
void unlock()
{
isLocked.store(0, std::memory_order_release);
}性能对比:
| 锁类型 | 无竞争 (ops/s) | 有竞争 (ops/s) |
|---|---|---|
| SeqCst | 313M | 55.3M |
| AcqRel | 612M | 58.7M |
| Acquire | 652M | 65.3M |
问题6:Test-and-Test-and-Set
频繁的exchange会锁住缓存行。先用load检查再exchange:
void lock()
{
while (isLocked.exchange(1, std::mem...C++ 中文周刊 2026-01-24 第194期
qq群 753792291 答疑在这里
欢迎投稿,推荐或自荐文章/软件/资源等,评论区留言
一月邮件 https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2026/#mailing2026-01
资讯
标准委员会动态/ide/编译器信息放在这里
编译器信息最新动态推荐关注hellogcc公众号 本周更新 2025-01-08 第288期
文章
Building Your Own Efficient uint128 in C++
想要128位整数但不想用编译器扩展?自己实现一个,性能还和 __uint128_t一样!
核心思路很简单:用两个64位limb表示,就像两位数的base 2^64数:
structu128 {
u64low=0;
u64high=0;
};
加法 - 用carry intrinsic:
u128operator+(u128 a, u128 b) {
u128r;
unsignedcharc=_addcarry_u64(0, a.low, b.low, &r.low);
(void)_addcarry_u64(c, a.high, b.high, &r.high);
returnr;
}
生成的汇编完美:
mov rax, rdi
add rax, rdx
adc rsi, rcx ; add with carry
mov rdx, rsi
ret
减法同理用 _subborrow_u64,一条 sub+一条 sbb搞定。
乘法 - 利用BMI2的_mulx_u64:
展开 (a.low + 2^64·a.high) × (b.low + 2^64·b.high) 后,只有三项贡献低128位:
- a.low × b.low 的两个limb都要
- a.low × b.high 贡献高64位
- a.high × b.low 贡献高64位
u128operator*(u128 a, u128 b) {
u128r;
u64p0_hi;
r.low=_mulx_u64(a.low, b.low, &p0_hi);
u64t1_hi, t2_hi;
u64t1_lo=_mulx_u64(a.low, b.high, &t1_hi);
u64t2_lo=_mulx_u64(a.high, b.low, &t2_hi);
r.high=p0_hi+t1_lo+t2_lo; // carry会被丢弃,正好
returnr;
}
比较 - 用borrow技巧:
a < b 等价于 a - b 产生borrow:
booloperator<(u128 a, u128 b) {
u64dont_care;
unsignedcharborrow=_subborrow_u64(0, a.low, b.low, &dont_care);
borrow=_subborrow_u64(borrow, a.high, b.high, &dont_care);
returnborrow!=0;
}
汇编只有四条指令:cmp, sbb, setb, ret,完美!
固定宽度big integer的优势:性能可预测,没有动态分配,完全内联。生产环境用256位、512位都是这个套路扩展。比那些通用big integer库快多了,因为编译器能把一切都优化成直线指令。
C++26 Reflection 💚 QRangeModel
Qt hackathon项目:用C++26反射让QRangeModel直接支持plain C++类,不需要Q_OBJECT宏!
**目标:**让这样的plain struct自动变成QAbstractItemModel:
structEntry {
QStringname;
doublevalue;
};
用于QML时自动有name和value两个role,用于TableView时自动两列。不需要moc,不需要tuple协议boilerplate。
C++26三大新语法:
1.反射操作符 ^^ - 获取类型的 std::meta::info
2.splice操作符 [: :] - 把反射信息注入代码
3.注解 [[=...]] - 比attribute更灵活
核心实现 - 计算成员数量:
staticconstevalautoelement_count() {
returnnonstatic_data_members_of(^^T,
std::meta::access_context::current()).size();
}
^^T 反射类型T,nonstatic_data_members_of 返回成员列表,.size() 获取数量。全是编译期!
获取列名:
staticQVariantcolumn_name(int section) {
QVariantresult;
QtPrivate::applyIndexSwitch<element_count()>(section, [&](auto idxConstant) {
constexprautomember=nonstatic_data_members_of(^^T,
std::meta::access_context::current()).at(idxConstant.value);
result=QString::fromUtf8(u8identifier_of(member).data());
});
returnresult;
}
用 applyIndexSwitch生成编译期switch,u8identifier_of获取成员名称。
读取成员值 - splice大显身手:
staticQVariantreadRole(constT&item, int role) {
constintindex=role-Qt::UserRole;
QVariantresult;
QtPrivate::applyIndexSwitch<element_count()>(index, [&](auto idxConstant){
constexprautomember=nonstatic_data_members_of(^^T,
std::meta::access_context::current()).at(idxConstant.value);
result=item.[:member:]; // splice!等价于item.name或item.value
});
returnresult;
}
item.[:member:] 这个splice操作把反射信息注入成实际的成员访问!
写入成员:
staticboolwriteRole(T&item, constQVariant&data, int role) {
QtPrivate::applyIndexSwitch<element_count()>(index, [&](auto idxConstant){
constexprautomember=nonstatic_data_members_of(^^T, ...).at(idxConstant.value);
usingMemberType= [:type_of(member):]; // splice type
result=data.canConvert<MemberType>();
if (result)
item.[:member:] =data.value<MemberType>();
});
returnresult;
}
[:type_of(member):] splice类型,item.[:member:] splice访问。
支持带getter/setter的类:
用注解标记property:
class [[=QRangeModel::RowCategory::MultiRoleItem]] Class {
public:
[[=Qt::Property{}]] QStringname() const { returnm_name; }
voidsetName(QString name) { m_name=name; }
private:
QStringm_name;
};
通过 annotations_of_with_type筛选property getter:
staticconstevalboolis_property_getter(std::meta::info member) {
returnis_const(member) &&is_function(member)
&&annotations_of_with_type(member, ^^Qt::Property).size();
}
找setter就用字符串匹配"set"+首字母大写:
staticconstevalstd::optional<std::meta::info> property_setter(std::size_t idx) {
autogetter=property_getter(idx);
autoproperty_name=identifier_of(*getter);
constautoset_prefix=std::string("set");
for (std::meta::infomember : members_of(^^T, ...)) {
if (has_identifier(member) &&is_function(member) &&!is_const(member)) {
autosetter_name=set_prefix+property_name;
setter_name[3] -=' '; // poor-man's uppercase
if (identifier_of(member) ==setter_name)
returnmember;
}
}
returnstd::nullopt;
}
结论:
C++26反射让我们能写出"看起来像魔法"的代码,不需要moc,不需要宏,类型安全还全是编译期。gcc trunk已经支持,clang应该很快跟上。等C++26正式发布(2026年3月),这些都能用到生产环境了!
能用反射替代moc吗?大部分可以,最大挑战可能是 signals:/slots:区块,可能需要逐个函数加注解。
Modern C++ use in Chromium
Chromium的C++特性管理手册,标准委员会的事是他们的事,能不能用是我们说了算。
版本支持时间线:
- C++11/14/17: 默认允许(除了禁止列表)
- C++20: 2023年11月开始支持,部分允许
- C++23: 2026年1月开始支持,还在TBD阶段
- C++26: Not yet
新标准刚出来不是马上能用的,要等工具链支持,然后还要过两年TBD期。如果TBD期满还没决定,默认允许(没理由就不禁)。
一些有趣的禁止项:
inline namespace - Google风格禁止,和component系统不兼容
long long - 直接禁止,用 int64_t
User-Defined Literals - 12_km 这种?禁了
<chrono> - 禁止!和 base/time重叠。C++20时区功能再好,Chromium有自己的时间库
<regex> - 禁止,用 third_party/re2
std::function, std::bind - 禁止,有 base::Callback
std::shared_ptr, std::weak_ptr - 禁止,用 base::RefCounted和 base::WeakPtr
<thread>, <mutex>, <condition_variable> - 全禁,用 base/synchronization
char8_t - 禁止!因为所有API都用 char*,加 char8_t只会到处都是cast
std::span - 禁止,base::span功能更丰富
std::filesystem - 禁止(估计是有 base/files)
并行算法 - 禁止,libc++支持不完整,且要用Chrome自己的线程池
允许的C++20特性:
Concepts - 允许!
template <HashableT>
voidf(T) { ... }
consteval - 允许
Default comparisons和 <=> - 允许
friendautooperator<=>(constS&, constS&) =default;
Designated initializers - 允许
Ss{ .y=3 };
Ranges算法 - 允许
std::ranges::all_of(kArr, is_even);
[[likely]], [[unlikely]] - 允许
std::bit_cast - 禁止!允许cast掉const很危险,用 base::bit_cast
C++23的新东西:
std::expected - TBD(有 base::expected)
std::flat_map系列 - TBD(有 base::flat_map)
std::print - TBD(有LOG())
std::generator - TBD(coroutine还是TBD)
std::move_only_function - TBD(有 base::OnceCallback)
Abseil的情况:
大部分被禁:
-absl::string_view - 用 std::string_view
-absl::optional - 用 std::optional
-absl::Span - 用 base::span(更符合std)
-absl::btree_* - 禁!会显著增加代码体积
- Abseil日志 - 禁,用
base/logging.h(但想迁移,没人做) - Abseil字符串库 - 禁,用
base/strings(absl::StrFormat例外允许) - Abseil同步原语 - 禁,用
base/synchronization - Abseil时间库 - 禁,用
base/time
唯一例外:absl::StrFormat - 允许(比printf类型安全)
第三方库的豁免:
第三方库内部可以用禁止特性,但Chromium调用它们的接口时,禁止类型只能在边界转换:
// 第三方函数返回std::shared_ptr
autoresult=third_party_func();
automy_ptr=ConvertToBaseRefCounted(result); // 立刻转换
// 后续只用my_ptr
不允许std::shared_ptr扩散到Chromium代码里。
提案流程:
想解禁某个特性?发邮件到cxx@chromium.org,说明理由,附上讨论链接。如果达成共识,提交CR修改这个文档。
总之:Chromium不追新,稳定压倒一切,base库 > std库 > abseil。毕竟是几千万行代码的项目,牵一发动全身。
Understanding C++ Ownership System
从GC语言转C++最难理解的就是ownership - 谁拥有这个对象?谁负责清理?
核心问题:
char*get_name(File* file);
返回的字符串是新分配的(调用者要free)还是file内部的(调用者不能动)?GC语言不用管,C++必须搞清楚。
RAII - 自动清理的魔法:
voidfoo(std::size_t buffer_size) {
std::unique_ptr<char[]> buffer=std::make_unique<char[]>(buffer_size);
intresult=0;
while (has_more) {
read(buffer.get());
result+=process(buffer.get());
}
returnresult; // buffer自动释放,即使有异常也不会泄漏
}
对比手动管理:
voidfoo(std::size_t buffer_size) {
char*buffer=newchar[buffer_size];
intresult=0;
try {
while (has_more) {
read(buffer);
result+=process(buffer);
}
} catch (std::exceptione) {
deletebuffer; // 异常路径要记得delete
throwe;
}
deletebuffer; // 正常路径也要delete
returnresult;
}
手动管理容易漏,RAII让编译器帮你管理。
Destructor怎么工作:
classRAIIMutex {
private:
std::mutex&mutex;
public:
RAIIMutex(std::mutex& m): mutex{m} {
mutex.lock();
}
~RAIIMutex() { // 析构函数
mutex.unlock();
}
};
voidfoo() {
RAIIMutexguard(global_variable_mutex);
process(global_variable);
// 离开scope自动unlock
}
析构函数在对象lifetime结束时自动调用,编译器会在scope末尾插入destructor调用。
Lifetime - 何时销毁:
局部变量的lifetime是它的声明block。但RAII不是GC:
intmain() {
Bb;
if (some_condition) {
std::vector<int> vec{1, 2, 3};
b.set_vec(vec);
}
b.bar(); // UB!vec已经销毁,b里的引用悬空
}
GC会延长vec的生命周期,RAII不会。reference的lifetime必须≤对象的lifetime。
Move - 偷资源:
vector扩容时要把元素从旧buffer复制到新buffer:
template <typenameT>
voidvector<T>::grow(size_t new_capacity) {
T*new_buffer=newT[new_capacity];
for (size_ti=0; i<this->size; ++i) {
new_buffer[i] =this->buffer[i]; // 复制!
}
delete[]old_buffer; // 旧的马上就删,复制纯浪费
}
用move避免复制:
C++ 中文周刊 2026-01-09 第193期
qq群 753792291 答疑在这里
欢迎投稿,推荐或自荐文章/软件/资源等,评论区留言
懒狗忘了
资讯
标准委员会动态/ide/编译器信息放在这里
编译器信息最新动态推荐关注hellogcc公众号 本周更新 2025-01-08 第288期
文章
No Graphics API
现代GPU已经够成熟了,不需要那些复杂的图形API了。DirectX 12/Vulkan/Metal这些API是13年前为异构硬件设计的,现在GPU都统一了,还搞那么复杂干嘛?
最大的问题是PSO(管线状态对象)排列爆炸,搞出100GB+的缓存
他的想法是简化成类似CUDA的风格:
// 直接用指针分配内存
uint32* numbers = gpuMalloc(1024 * sizeof(uint32));
for (int i = 0; i < 1024; i++) numbers[i] = random();
gpuFree(numbers);
// 根参数直接传指针
struct alignas(16) Data {
float16x4 color;
const uint8* lut;
const uint32* input;
uint32* output;
};
void main(uint32x3 threadId : SV_ThreadID, const Data* data) {
uint32 value = data->input[threadId.x];
data->output[threadId.x] = value;
}
// 图形管线设置简化成这样
GpuRasterDesc rasterDesc = {
.depthFormat = FORMAT_D32_FLOAT,
.colorTargets = {{.format = FORMAT_RG11B10_FLOAT}},
};
GpuPipeline pipeline = gpuCreateGraphicsPipeline(vertexIR, pixelIR, rasterDesc);整个原型API只要150行代码,Vulkan可是20,000+行啊。能简化是好事,不过这得看硬件厂商买不买账了
Deducing the consequences of Windows clipboard text formats on UTF-8
Windows剪贴板对UTF-8的支持就是个坑。问题在于:
- Windows压根没有UTF-8的键盘布局或区域设置
- 转换UTF-16和8位编码时依赖CF_LOCALE,但默认值来自键盘布局语言,跟UTF-8没关系
- 现在per-process的代码页设置更乱了,不同进程对"ANSI"的理解都不一样
结论很简单:UTF-8程序就直接用CF_UNICODETEXT,别折腾其他格式了
不懂windows,就不多嘴了
Modern C++ Firmware 系列
这是个嵌入式固件开发系列,讲怎么在小型关键系统上用现代C++。Part 2讲为什么选C++20,Part 3讲具体的硬性规则
为什么C++20?
利用c++20 的新api来约束
嵌入式的约束很明确:
- WCET(最坏情况执行时间)要可预测
- 内存预算是死的,Flash和SRAM有多少是多少
- 固件要维护好多年
- 嵌入式编译器更新慢,用太新的标准编译器不支持
C++20有这些:
- Concepts做编译期检查
- std::span传缓冲区
- std::array管理固定容量
- std::chrono搞时间
- [[nodiscard]]强制检查返回值
硬性规则
1. 禁用异常和动态分配
热路径里绝对不能分配内存,要用固定容量容器:
class EventQueue final {
public:
static constexpr std::size_t kMaxEventsPerTick = 16U;
[[nodiscard]] bool try_push(std::uint16_t event) noexcept {
if(this->count_ >= kMaxEventsPerTick) {
++this->overflow_count_; // 溢出了就计数,别崩
return false;
}
this->events_[this->count_] = event;
++this->count_;
return true;
}
void clear() noexcept { this->count_ = 0U; }
[[nodiscard]] std::size_t size() const noexcept { return this->count_; }
private:
std::array<std::uint16_t, kMaxEventsPerTick> events_{};
std::size_t count_{0U};
std::uint32_t overflow_count_{0U}; // 记录溢出次数,调试用
};2. 热路径禁用虚函数
用Concepts做编译期多态:
template <typename T>
concept HardwarePlatform = requires(T p) {
{ p.initialize() } noexcept -> std::same_as<bool>;
{ p.read_inputs() } noexcept;
{ p.write_outputs() } noexcept;
};
template <HardwarePlatform P>
void run_tick(P& platform) noexcept {
platform.read_inputs();
platform.write_outputs();
}编译期就能确定调用哪个函数,不用虚函数表那一套
3. 用std::span传缓冲区
[[nodiscard]] bool build_status_line(std::span<char> out) noexcept {
if(out.size() < 8U) { return false; }
out[0] = 'O';
out[1] = 'K';
return true;
}Understanding and mitigating a stack overflow in our task sequencer
Raymond Chen发现他们的task_sequencer会栈溢出。问题在于一堆任务同步完成的时候,协程恢复会递归调用,栈越堆越深
解决办法是强制切换线程,打断递归:
struct task_sequencer
{
task_sequencer(winrt::DispatcherQueue const& queue = nullptr)
: m_queue(queue) {}
template<typename Maker>
auto QueueTaskAsync(Maker&& maker) ->decltype(maker())
{
auto task = [&]() -> Async
{
completer completer{ current };
auto local_maker = std::forward<Maker>(maker);
auto local_queue = m_queue;
co_await suspend;
if (m_queue == nullptr) {
co_await winrt::resume_background(); // 切到后台线程
} else {
co_await winrt::resume_foreground(local_queue); // 或者切到指定队列
}
co_return co_await local_maker();
}();
// ...
}
};线程切换会强制展开栈,就不会溢出了。
Parsing IP addresses quickly (portably, without SIMD magic)
Daniel Lemire测试了几种解析IPv4地址的方法,不用SIMD。结论是手动展开循环最快:
性能对比(Apple M4):
- 手动展开:114指令,3.3纳秒
- 手动循环:185指令,6.2纳秒
- std::from_chars:381指令,14纳秒
std::from_chars慢了4倍多,我操?
手动展开的代码长这样:
std::expected<uint32_t, parse_error> parse_manual_unrolled(const char *p, const char *pend) {
uint32_t ip = 0;
int octets = 0;
while (p < pend && octets < 4) {
uint32_t val = 0;
if (p < pend && *p >= '0' && *p <= '9') {
val = (*p++ - '0');
if (p < pend && *p >= '0' && *p <= '9') {
if (val == 0) { // 01.02这种不合法
return std::unexpected(invalid_format);
}
val = val * 10 + (*p++ - '0');
if (p < pend && *p >= '0' && *p <= '9') {
val = val * 10 + (*p++ - '0');
if (val > 255) { // 超过255不行
return std::unexpected(invalid_format);
}
}
}
} else {
return std::unexpected(parse_error::invalid_format);
}
ip = (ip << 8) | val;
octets++;
if (octets < 4) {
if (p == pend || *p != '.') {
return std::unexpected(invalid_format);
}
p++;
}
}
if (octets == 4 && p == pend) {
return ip;
} else {
return std::unexpected(invalid_format);
}
}就是把循环展开,少点分支判断
By how much does your memory allocator overallocate?
new char[4096]实际分配多少内存?答案是会多分配一点
Linux:请求4096字节,实际给4104字节,多8字节可以用。开销0.4%,还行
macOS:这个就夸张了。请求3585字节,给你4096字节,浪费14%!macOS喜欢按512字节对齐
可以用 malloc_usable_size查实际能用多少。这种过度分配对小对象影响大,大内存反而无所谓
Software taketh away faster than hardware giveth
Herb Sutter讲为什么C++和Rust还在快速增长
核心观点:软件消耗算力的速度比硬件进步快,性能需求永远满足不了
数据很有意思:
- 2022-2025开发者从3100万涨到4700万,增长50%
- C++和Rust增长最快
- 2025年最大瓶颈是电力供应,不是芯片
关于安全性,他的观点很实在:
- MITRE 2025报告里,10大危险问题只有3个跟语言安全有关
- 79%网络入侵是恶意软件,不是代码漏洞
- C++漏洞率比C低多了
C++26要加的新东西:
- 未初始化变量不再UB
- 标准库加边界检查(强化模式)
- 合约支持
性能需求一直在涨,C++不会过时
Unsigned char std::basic_string<> in C++
用 std::basic_string<uint8_t>处理二进制数据?别这么干了
问题在于 std::basic_string依赖 std::char_traits<T>,标准只保证 char/wchar_t这些类型有。uint8_t以前是"意外"能用,LLVM 19.1.0直接把基础模板删了
解决办法:
- 直接用
std::vector<uint8_t>,不要折腾 - 或者自己特化
std::char_traits<uint8_t>
vector够了
Implementing vector<T>
手写vector,教学向的文章。关键是要分离内存分配和对象构造,还要处理异常安全
template <typename T>
class vector {
private:
T* m_data;
std::size_t m_size; // 实际元素数量
std::size_t m_capacity; // 容量
};reserve要这么写才异常安全:
void reserve(size_type new_capacity){
if(new_capacity <= capacity()) return;
auto ptr = allocate_helper(new_capacity); // 先分配新内存
try {
copy_old_storage_to_new(m_data, m_size, ptr); // 拷贝可能抛异常
} catch(std::exception& ex){
deallocate_helper(ptr); // 失败了释放新内存
throw; // 继续抛,对象状态不变
}
std::destroy(m_data, m_data + m_size); // 成功了才销毁旧数据
deallocate_helper(m_data);
m_data = ptr;
m_capacity = new_capacity;
}先做可能失败的操作,成功了再修改状态。这是异常安全的基本套路
文章写得挺详细,不过说实话,谁手写啊。面试可能会考?
Time in C++: Additional clocks in C++20
C++20加了5种新时钟,因为"世界运行在多个时间尺度上"
- utc_clock:有闰秒的UTC时间
- tai_clock:国际原子时间,1958年开始,不含闰秒
- gps_clock:GPS时间,1980年开始
- file_clock:文件系统时钟,跟
std::filesystem配套 - local_t:本地时间,但不指定时区
local_t要跟时区配合用:
auto local = std::chrono::local_time<std::chrono::minutes>{ ... };
auto tz = std::chrono::locate_zone("Europe/Berlin");
auto sys_time = tz->to_sys(local); // 转成系统时间Raymond Chen的内存块交换系列
Raymond Chen写了一系列文章,讲怎么只用前向迭代器交换内存块。为什么要研究这个?因为 std::rotate只需要前向迭代器,但常见的实现方法需要双向迭代器
- How can you swap two adjacent blocks of memory using only forward iterators?
- How can you swap two non-adjacent blocks of memory using only forward iterators
- Swapping two blocks of memory that reside inside a larger block, in constant memory, refinement
相邻块交换
设三个指针 first、mid、last,要把块A和块B交换位置。思路是逐个交换元素,直到较小的块移动完,然后递归处理剩余部分
template<typename ForwardIt>
void rotate_adjacent(ForwardIt first, ForwardIt mid, ForwardIt last) {
if (first == mid || mid == last) return;
auto p = first;
auto q = mid;
while (true) {
std::iter_swap(p++, q++); // 交换元素
if (p == mid) {
// 块A用完了,块B还有剩余
if (q == last) return; // 都用完了
mid = q; // 递归处理剩余的
} else if (q == last) {
// 块B用完了,块A还有剩余
// 递归处理剩余的
return rot...