C++ 中文周刊 2026-02-20 第196期

周刊项目地址

公众号

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

qq群 753792291 答疑在这里

RSS

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

本期文章由 机械工业出版社 赞助 ,他们送了我好多书,在此表示感谢


资讯

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

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

性能周刊

文章

Falsehoods programmers believe about undefined behavior

Predrag Gruevski写的经典文章(PVS-Studio转载),列了43条程序员对UB的”错觉”,逐条打脸

首先搞清三个概念:

程序行为分三个桶,不是两个:

关于”什么时候触发UB”的错觉:

  1. UB只在-O2/O3才会触发 — 错
  2. 关掉优化用-O0就没UB了 — 错
  3. 加调试符号就安全 — 错
  4. 在调试器下跑就没UB了 — 错
  5. 好吧有UB,但代码还是会”做正确的事” — 错
  6. 最多崩溃(SIGSEGV)— 错
  7. 最多崩溃或死循环 — 错

关于”UB会不会执行奇怪的代码”:

  1. 至少不会跑到程序中其他不相干的代码 — 错
  2. 至少不会执行程序中理论上不可达的代码 — 错

关于”UB的影响范围”的错觉:

  1. 之前”正常工作”的UB代码,下次还能正常工作 — 不保证
  2. UB的影响至少局限于使用了UB值的代码 — 错
  3. 至少局限于同一个编译单元 — 错
  4. 至少只影响UB之后的代码 — 大错特错! UB可以“时间旅行”,编译器可以基于”不存在UB”的假设优化UB之前的代码

关于”可能后果”的错觉:

17-24. 至少不会损坏内存/堆/栈/栈帧/CPU状态 — 都不保证

  1. 至少不会把硬盘擦了 — 不保证(虽然不太可能)
  2. 至少不会损坏硬件 — 不保证

“之前好好的”系列:

31-36. 不改代码重新编译还能好好工作吗?用同样编译器?同一台机器?同一时间编译?在月食期间献祭一根新内存条?— 统统不保证

关于”自我一致性”的错觉:

37-40. 相同二进制 + 相同输入重复跑,行为一样吗?即使程序是确定性的?即使是单线程?即使不读任何外部数据?— 统统不保证

社区贡献的错觉:

  1. 调试器里看到的程序状态跟源码是对应的 — 错。UB可以时间旅行,导致调试器里的变量值和代码逻辑对不上
  2. 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中连续排列,天然适合列扫描

优化路径(每步都有火焰图验证):

  1. Transaction Put → SST Writer(180s → 19.5s):Transaction Put每次插入都要锁key、排序,120列的场景下开销爆炸。改用SST Writer直接写SST文件,每列一个SST,后续compaction时合并
  2. 关掉过滤器和压缩(19.5s → 14.3s):火焰图发现Standard128RibbonBitsBuilder(类Bloom filter)吃了20%CPU,LZ4压缩也在热路径上。这两者在导入阶段不需要,compaction时会重建
  3. 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);
  1. 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));
}
  1. 去掉热路径的运行期检查(10.6s → 8.7s,18%提速):rocksdb::SstFileWriter::Rep::AddImpl里一堆key有序性检查和虚函数调用的status方法(只读一个atomic_bool但用了memory_order_relaxed),火焰图显示这些检查吃了20%CPU。改成debug-only assert,虚函数改成编译期static_cast
  2. 消除key的隐藏拷贝(8.7s → 7.8s,10%提速):每行每列都要构建key并调用ikey.Set(key, sn, vt),暗含一次字符串拷贝。120列 × 100万行 = 1.2亿次分配。改成预创建key复用

Key takeaways:

  1. 避免热路径中的虚函数
  2. 别不必要地拷贝字符串
  3. 运行期检查能改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提议用算术版本替换查表版本

三种方案:

  1. 查表法 - Node.js目前使用的方法,16字符查找表
  2. 算术nibble法 - Skovoroda提议的纯算术运算,无查表
  3. 手写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);
	uint8x16_t low2 = vandq_u8(val2, vdupq_n_u8(15));
	uint8x16_t high_chars1 = vqtbl1q_u8(table, high1);
	uint8x16_t low_chars1 = vqtbl1q_u8(table, low1);
	uint8x16_t high_chars2 = vqtbl1q_u8(table, high2);
	uint8x16_t low_chars2 = vqtbl1q_u8(table, low2);
	uint8x16x2_t zipped1 = {high_chars1, low_chars1};
	uint8x16x2_t zipped2 = {high_chars2, low_chars2};
	vst2q_u8((uint8_t*)dst + i*2, zipped1);
	vst2q_u8((uint8_t*)dst + i*2 + 32, zipped2);
}

结论:直觉是很差的性能指导。查表看起来应该更快(O(1)嘛),但实际上纯算术版本对编译器更友好,自动向量化后快了一个数量级。如果用x64的AVX-512手写向量化,性能还能更高

Converting floats to strings quickly

Daniel Lemire介绍他们最新发表的论文Converting Binary Floating-Point Numbers to Shortest Decimal Strings的研究成果

算法演进:

从1990年Steele和White的Dragon4算法开始,到Grisu3、Ryū(2018)、Schubfach、Grisu-Exact、Dragonbox,三十年间性能提升约10倍 — 相当于每年8%的纯算法+实现改进

转换的两个步骤:

  1. 数字计算:把浮点数拆成有效数字和10的幂(比如π → 31415927-7
  2. 字符串生成:把数字写成ASCII字符串,放小数点,必要时切换科学计数法

当前状况:

有趣的发现:

没有任何现有实现能总是生成最短字符串。比如std::to_chars把0.00011渲染成0.00011(7个字符),但科学计数法1.1e-4更短。不过按惯例指数要补到两位(1.1e-04),这个trick就不总管用了

评论区Victor Zverovich({fmt}作者)提到了zmij新算法即将集成到{fmt},替代当前的Dragonbox with compact cache

结论:浮点转字符串这个看似简单的问题,算法和实现细节仍能带来数量级提升

Deferred member initialization

Sandor Dargo在Meeting C++ 2025上和人讨论的一个问题:有个控制硬件的类,需要一个map<int, string>存硬件模块ID和名称。这个map运行时不会变,理论上应该是const。但由于硬约束,不能在构造时初始化,只能后续通过init()设置

问题:怎么在类型系统中表达”初始化前可写,初始化后只读”?

方案1 - 私有非const成员:

最简单的做法。_available_modules是private的,不通过getter暴露,初始化后不再修改 — 但这靠的是纪律和约定,类型系统没有强制保证:

std::map<int, std::string> list_available_modules() {
	return { {1, "widget"}, {2, "gadget"}, {42, "bar"} };
}

class MyHardwareController {
   public:
	void init() { _available_modules = list_available_modules(); }

	std::optional<std::string> get_module_name(int id) const {
		if (_available_modules.contains(id)) {
			return _available_modules.at(id);
		}
		return std::nullopt;
	}

   private:
	std::map<int, std::string> _available_modules;
};

方案2 - optional<const map>

语义非常清晰:可能还没有map,但一旦有了就不可变。optional::emplace()设置值,但底层数据不可修改。加_already_initialized标志防止重复初始化:

class MyHardwareController {
   public:
	void init() { 
		if (_already_initialized) {
			throw std::logic_error{"Object already initialized"};
		}
		_available_modules.emplace(list_available_modules()); 
		_already_initialized = true;
	}

	std::optional<std::string> get_module_name(int id) const {
		if (_available_modules->contains(id)) {
			return _available_modules->at(id);
		}
		return std::nullopt;
	}

   private:
	bool _already_initialized {false};
	std::optional<std::map<int, std::string>> _available_modules;
};

方案3 - 专用Registry类:

把”只初始化一次”的语义完全封装到ModuleRegistry中。调用方不可能部分修改或误用。还可以delete赋值运算符让替换变得不可能(但别忘了rule of five):

class ModuleRegistry {
   public:
	void set_once(std::map<int, std::string> m) {
		if (modules) {
			throw std::logic_error("Modules already initialized");
		}
		modules = std::move(m);
	}

	bool is_initialized() const { return modules.has_value(); }
	const std::map<int, std::string>& get_modules() const {
		if (!modules) {
			throw std::logic_error("Modules not initialized yet");
		}
		return modules.value();
	}

   private:
	std::optional<std::map<int, std::string>> modules;
};

class MyHardwareController {
   public:
	void init() { 
		if (_module_registry.is_initialized()) {
			throw std::logic_error{"Module registry already initialized"};
		}
		_module_registry.set_once(list_available_modules()); 
	}

	std::optional<std::string> get_module_name(int id) const {
		if (_module_registry.is_initialized() && _module_registry.get_modules().contains(id)) {
			return _module_registry.get_modules().at(id);
		}

		return std::nullopt;
	}

   private:
	ModuleRegistry _module_registry;
};

总结:即使const不能在构造时设置,也能通过类型设计表达不可变意图。核心思想 — 通过类型表达意图 — 是现代C++最强大的工具之一

The cost of a function call

Daniel Lemire用实际基准测试量化函数调用的开销

核心观点:函数调用本身不贵(跳转+保存寄存器),真正的成本在于阻止编译器做跨函数优化,特别是自动向量化

场景1 - 极小函数:内联带来20倍加速

一个简单的加法函数,不内联时编译器无法向量化循环,内联后可以用SIMD:

int add(int x, int y) {
	return x + y;
}

int add3(int x, int y, int z) {
	return add(add(x, y), z);
}

内联后等价于:

int add3(int x, int y, int z) {
	return x + y + z;
}

在循环中用add对数组求和,差异巨大:

for (int x : numbers) {
  sum = add(sum, x);
}

不内联时的汇编(M4/LLVM)—— 每次加法6条指令,约3个周期:

ldr    w1, [x19], #0x4
bl     0x100021740    ; add(int, int)
cmp    x19, x20
b.ne   0x100001368    ; <+28>

add函数本身只有两条指令:

add    w0, w1, w0
ret

内联后 —— 编译器自动向量化,SIMD一次处理16个整数,只需8条指令:

ldp    q4, q5, [x12, #-0x20]
ldp    q6, q7, [x12], #0x40
add.4s v0, v4, v0
add.4s v1, v5, v1
add.4s v2, v6, v2
add.4s v3, v7, v3
subs   x13, x13, #0x10
b.ne   0x1000013fc    ; <+104>

每个整数从6条指令降到0.5条指令,加速超过20倍

版本 每元素纳秒
不内联 0.7
内联 0.03
内联(禁SIMD) 0.07

即使禁用SIMD,内联也快10倍

场景2 - 较重的函数:内联收益取决于输入规模

一个统计空格数的函数:

size_t count_spaces(std::string_view sv) {
	size_t count = 0;
	for (char c : sv) {
		if (c == ' ') ++count;
	}
	return count;
}

长字符串(1000个字符):内联反而略慢(可能因为指令缓存压力)

版本 每次调用纳秒
不内联 111
内联 115

短字符串(0-6个字符):内联快了60%

版本 每次调用纳秒
不内联 1.6
内联 1.0

Takeaways:

  1. 短而简单的函数在性能关键路径上一定要内联,收益可以很惊人
  2. 对于可快可慢的函数(如字符串处理),是否内联取决于输入规模。字符串长度可能决定了内联是否必要

UB系列:

John Regehr 的经典三部曲,深入讲解 C/C++ 中未定义行为的方方面面

PVS-Studio 的 12 篇系列文章,从 C++ 程序员实战角度全面梳理各类 UB 陷阱

内存分配策略系列:

gingerbill 的 6 篇系列文章,从零开始用 C 实现各种内存分配器,循序渐进

开源项目介绍


上一期

本期

下一期

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