公众号

点击「查看原文」跳转到 GitHub 上对应文件,链接就可以点击了
qq群 753792291 答疑在这里
欢迎投稿,推荐或自荐文章/软件/资源等,评论区留言
https://cppstat.dev/ 分享c++进展的网站
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++ WIL的scope_exit提供的保证严格,但实践中已经够用了。ReactiveExtensions库(Rx.NET)也提供了完全相同的helper功能
GPU基准测试遇到的大坑:动态频率调整
作者的RTX 2080空闲时GPU跑300MHz(显存100MHz),负载下应该是1650-1815MHz(显存1937MHz)。这差异达到5-6倍(GPU)和19倍(显存)!场景渲染时间在2ms、4ms、6ms之间不稳定跳动,根本没法做性能测试
常见解决方案:
ID3D12Device::SetStablePowerState固定频率。简单,但容易忘记关闭作者的解决方案 - 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基准测试必须固定时钟频率,这是业界公认的做法。动态频率调整对测试结果影响太大了
WebGL性能暴跌的诡异案例:从60FPS掉到1-2FPS
作者在移植游戏”You Are Circle”到浏览器时遇到灾难性性能问题。添加可破坏岩石功能后,某些机器上帧率暴跌,背景音乐都卡了
场景规模:
问题定位:
原始代码长这样:
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, ...);
}
性能数据:
MaxForRange<>函数上根本原因:
WebGL需要验证索引缓冲区有效性以防越界访问(安全原因)。浏览器用缓存机制避免重复验证。作者用4MB的per-frame buffer流式传输顶点和索引数据。问题是:
为什么开发机器没发现?作者推测是因为开发机器的CPU有更大的L2/L3缓存,索引扫描很快
解决方案:
批处理(Batching)- 把所有岩石几何体合并成单个网格,用一次绘制调用搞定
进一步优化建议:
“缓存是计算机科学中最难的两件事之一”,这案例再次证明了这句话。看似简单的immediate mode渲染,在WebGL的安全验证机制下,因为缓存策略的交互,导致了灾难性的性能下降
Antithesis分享如何把单线程C++与多线程异步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(); // 在主线程执行销毁
}
}
}
关键点:
&T(当T: Sync时),永不暴露&mut T挑战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() }
}
}
核心哲学转变:
最终方案通过类型系统和编译器强制执行安全性,而不是依赖程序员的小心谨慎。这才是Rusty的做法
该方案已从研究代码转向生产环境使用。为C++代码定义了明确的安全义务和规范,让团队其他成员也能安全使用
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上差异巨大:
差异达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::memory_order_acquire) != 0)
{
do {
yield.do_yield();
} while (isLocked.load(std::memory_order_relaxed) != 0); // 先读
}
}
问题7 & 8:优先级反转和唤醒风暴
高优先级线程空转,低优先级线程持锁但被抢占。yield()可能唤醒无关线程导致调度风暴
问题9:正确的解决方案 - 使用OS原语
void do_yield(int32_t* address, int32_t comparisonValue, uint32_t timeoutMs)
{
do_yield_expo_and_jitter();
if (nbPauses >= maxPauses)
{
// 等待地址值变化或被唤醒
WaitOnAddress(address, &comparisonValue, sizeof(comparisonValue), timeoutMs);
nbPauses = 1;
}
}
void lock()
{
while (isLocked.exchange(1, std::memory_order_acquire) != 0)
{
do {
yield.do_yield(&isLocked, 1, 1);
} while (isLocked.load(std::memory_order_relaxed) != 0);
}
}
void unlock()
{
isLocked = 0;
WakeByAddressSingle(&isLocked); // 通知等待的线程
}
先自旋一会儿,退避到一定程度就调用OS原语休眠。两者结合
问题11:伪共享
alignas(std::hardware_destructive_interference_size) MyLock lock1;
alignas(std::hardware_destructive_interference_size) MyLock lock2;
避免不同锁在同一缓存行
金句(Linus Torvalds):
“你永远不应该认为自己足够聪明到可以编写自己的锁机制…这真的很难。”
文章列举的错误实现案例:
何时可以考虑自旋锁:
最佳建议:
自旋锁看似简单,实则极其复杂
用Lambda立即调用来初始化const变量的技巧
你有没有遇到过这种情况:想让变量保持const,但初始化逻辑又得用if/else?传统做法是先声明成non-const,初始化完再假装它是const。IIFE让你能用多行代码初始化const变量
传统写法的问题:
int myVariable = 0; // 本应该是const...
if (bFirstCondition)
myVariable = bSecondCondition ? computeFunc(inputParam) : 0;
else
myVariable = inputParam * 2;
// 更多代码...
// 我们假装 myVariable 现在是const
IIFE写法:
const int myVariable = [&] {
if (bFirstCondition)
return bSecondCondition ? computeFunc(inputParam) : 0;
else
return inputParam * 2;
}(); // 立即调用!
// 更多代码...
基本语法就是:
const auto var = [&...] {
return /* 复杂逻辑 */;
}(); // 别忘了()调用
实战案例 - HTML字符串构建:
传统写法:
std::string BuildStringTest(std::string link, std::string text) {
std::string html;
html = "<a href=\"" + link + "\">";
if (!text.empty())
html += text;
else
html += link;
html += "</a>";
return html;
}
IIFE版本:
std::string BuildStringTestIIFE(std::string link, std::string text) {
const std::string html = [&link, &text] {
std::string out = "<a href=\"" + link + "\">";
if (!text.empty())
out += text;
else
out += link;
out += "</a>";
return out;
}();
return html;
}
提高可读性:
末尾的()容易被忽略,可以用std::invoke:
const auto var = std::invoke([&] {
return /* 复杂逻辑 */;
});
或者命名lambda:
auto initialiser = [&] {
return /* 复杂逻辑 */;
};
const auto var = initialiser();
最佳实践:
[&inputParam, &anotherValue]而不是[&],让依赖关系更明确()直接写[] noexcept {...}()性能:基准测试显示编译器能很好地优化,IIFE版本有时反而更快10%左右
这个技巧挺实用的,特别是想让变量保持const但初始化逻辑又有点复杂的时候。虽然一开始看着有点怪,但用熟了会发现这种写法既保证了不可变性,又避免了为一小段逻辑单独创建函数的麻烦
Sandor Dargo的时钟系列第9部分:怎么测试带时间戳的代码
想象一下,你写了个函数,它会把数据连同当前时间一起存到数据库。问题来了:你怎么断言那个时间戳是对的?用时间范围?太脆弱了
问题代码:
struct Record {
int value;
std::chrono::system_clock::time_point created_at;
};
std::vector<Record> db;
void store(int value) {
db.emplace_back(value, std::chrono::system_clock::now()); // 失去控制
}
一旦调用了now(),你就失去了控制权,测试就遭殃了
方案1:基于继承的时钟层次
struct Clock {
virtual ~Clock() = default;
virtual std::chrono::system_clock::time_point now() const = 0;
};
struct RealClock : Clock {
std::chrono::system_clock::time_point now() const override {
return std::chrono::system_clock::now();
}
};
struct FakeClock : Clock {
std::chrono::system_clock::time_point provided{};
std::chrono::system_clock::time_point now() const override {
return provided;
}
};
void store(int value, const Clock& clock) {
db.emplace_back(value, clock.now());
}
经典OOP做法,明确易懂
方案2:使用C++20 Concepts
template <class C>
concept SystemClockLike = std::chrono::is_clock_v<C> && requires {
{ C::now() } -> std::same_as<std::chrono::system_clock::time_point>;
};
struct FakeClock {
using rep = std::chrono::system_clock::rep;
using period = std::chrono::system_clock::period;
using duration = std::chrono::system_clock::duration;
using time_point = std::chrono::system_clock::time_point;
static constexpr bool is_steady = false;
static inline time_point provided{};
static time_point now() { return provided; }
};
template<SystemClockLike Clock = std::chrono::system_clock>
void store(int value) {
db.emplace_back(value, Clock::now());
}
零开销,编译期保证,适合模板密集的代码
方案3:Lambda时间提供器(最简单)
using TimeProvider = std::function<std::chrono::system_clock::time_point()>;
void store(int value, TimeProvider time_provider) {
db.emplace_back(value, time_provider());
}
// 测试
void testStore() {
auto expected_created_at = std::chrono::system_clock::now();
TimeProvider time_provider = [&expected_created_at]() {
return expected_created_at;
};
store(42, time_provider);
assert(db[0].value == 42);
assert(db[0].created_at == expected_created_at);
}
连FakeClock都不需要了,最简单最直接
作者建议:从时间提供器方法开始。它最简单、可读性强,以后重构也容易。核心思想:不要硬编码时间来源!把时间当作可注入的依赖
测试带时间的代码就像测试随机数生成器 - 你得能控制住它才行。记住:把时间当朋友注入进来,别让它成为测试的敌人
Louis Baragwanath分析虚函数表性能:打破”虚函数慢”的常见误解
核心结论:虚函数的真正成本不在运行时的两次间接跳转,而在于阻止了编译器的跨函数优化
基础示例:
class Animal {
public:
virtual ~Animal() = default;
virtual void speak() const = 0;
};
class Dog : public Animal {
public:
void speak() const override { std::println("woof"); }
};
void make_noise(Animal** animals, int n) {
for (int i = 0; i < n; ++i) {
animals[i]->speak();
}
}
编译后的汇编(-O3):
.L10:
mov rdi, [rbx] ; 加载 Animal*
mov rax, [rdi] ; 加载 vtable 指针
call [rax+16] ; 调用 speak()
add rbx, 8
dec rcx
jnz .L10
两次间接加载:animals[i] → vtable → 函数指针
三个潜在性能问题分析:
1. CPU后端(依赖链)- 基本不是问题
现代CPU用乱序执行,将指令流解释为依赖图。vtable查找链与speak()函数的工作并行执行。除非函数极短或端口饱和,vtable开销会被有效隐藏
2. CPU前端(分支预测)- 表现良好
间接分支预测器(IBP)使用全局分支历史。控制流通常与数据相关:
Animal* a;
if (some_condition) {
a = new Dog();
} else {
a = new Cat();
}
a->speak(); // 预测器能学习这个模式
在循环中,前几次迭代会建立历史,预测器能捕捉数组中派生类的相对顺序。分支误预测惩罚约15个周期
3. 编译器优化损失 - 真正的成本
虚函数调用目标不透明,编译器无法:
优化是相互依赖的:不能内联就不能优化调用者
对象和vtable布局:
Dog: [vtable*][...Animal...][...Dog...]
Cat: [vtable*][...Animal...][...Cat...]
Dog vtable: [~Dog][Dog::speak]
Cat vtable: [~Cat][Cat::speak][Cat::other]
内存开销很小:只需几个缓存行存储vtable,会进入L1d和L1i缓存
优化建议:
const、noexcept、final实际影响:
只要虚函数不是极其简单的几条指令,而且数据不是完全随机的,vtable的性能影响几乎可以忽略。CPU层面的开销(额外加载、分支预测)在大多数实际场景中都会消失在噪音中
当然性能极致的场景还是需要devirtual优化的
用C++实现前向模式自动微分,整个核心实现就两个字段
三种求导方法对比:
核心数据结构简单到爆:
template <class T>
struct fwd_diff
{
T value;
T deriv;
};
就这样!传播导数只需要对每个操作应用链式法则
乘法的实现(乘积法则):
template <class T>
fwd_diff<T> operator*(fwd_diff<T> const& f, fwd_diff<T> const& g)
{
return {
.value = f.value * g.value,
.deriv = f.deriv * g.value + f.value * g.deriv, // (f·g)' = f'·g + f·g'
};
}
平方根:
template <class T>
fwd_diff<T> sqrt(fwd_diff<T> const& f)
{
auto sqrt_f = sqrt(f.value);
return {
.value = sqrt_f,
.deriv = f.deriv / (2 * sqrt_f), // (√f)' = f'/(2√f)
};
}
工厂函数:
// x' = 1
static fwd_diff<T> input(T value)
{
return { .value = value, .deriv = T(1) };
}
// c' = 0
static fwd_diff<T> constant(T value)
{
return { .value = value, .deriv = T(0) };
}
实战案例:样条路径的相机动画
camera_frame camera_frame_at(std::span<pos3f const> control_pts, float t)
{
using fdd = fwd_diff<float>;
// 用fwd_diff求值
auto p = camera_path(control_pts, fdd::input(t));
// 解包位置和切线
pos3f pos = pos3f(p.x.value, p.y.value, p.z.value);
vec3f forward = normalize(vec3f(p.x.deriv, p.y.deriv, p.z.deriv)); // 切线方向!
// 构建相机坐标系
vec3f world_up = {0, 1, 0};
vec3f left = normalize(cross(world_up, forward));
vec3f up = cross(forward, left);
return { .pos = pos, .left = left, .up = up, .forward = forward };
}
只需一次求值就同时得到位置和切线方向。手动推导样条导数?不存在的
实战案例2:高度场法线
template <class HeightFn>
std::pair<float, vec3f> heightfield_with_normal_at(float x, float y, HeightFn&& h)
{
using fd32 = fwd_diff<float>;
// 对x求导
auto const hx = h(fd32::input(x), fd32::constant(y));
vec3f tx = {1.0f, 0.0f, hx.deriv}; // ∂/∂x
// 对y求导
auto const hy = h(fd32::constant(x), fd32::input(y));
vec3f ty = {0.0f, 1.0f, hy.deriv}; // ∂/∂y
// 法线是两个切线的叉积
auto const normal = normalize(cross(tx, ty));
return std::make_pair(hx.value, normal);
}
两次传播就得到完整梯度,从精确梯度计算的法线比有限差分平滑多了
支持任意控制流:
auto foo(auto v)
{
if (v < 0)
return v * v;
else
return sqrt(v);
}
// 直接工作!
auto result = foo(fwd_diff<float>::input(x));
甚至循环也没问题。导数会沿着实际执行的路径传播
性能优势:
应用场景:
只要理解链式法则,实现就显而易见。作者第一次看到时觉得像魔法,但现在看来不过是”足够先进的技术”罢了。完整代码:https://godbolt.org/z/3eMecWPYx
PVS-Studio静态分析器发现的对齐相关陷阱
基础概念:对齐是数据在内存中按指定边界(2的幂)组织和访问。char对齐1字节,int对齐4字节,指针对齐8字节。编译器会自动插入padding字节满足对齐要求
问题1:结构体字段顺序导致空间浪费
struct Example
{
short int sh; // 2字节
char* ptr; // 8字节(需要8字节对齐)
char symbol; // 1字节
long ln; // 8字节(Clang)或4字节(MSVC)
};
Clang:32字节(sh后6字节padding,symbol后7字节padding) MSVC:24字节(使用LLP64模型,long只有4字节)
优化后:
struct Example
{
char* ptr; // 8字节
long ln; // 8字节
short int sh; // 2字节
char symbol; // 1字节
// padding: 5字节
};
Clang:24字节,MSVC:16字节。按对齐要求降序排列,减少padding
问题2:未对齐的指针转换(FreeCAD项目)
template <int N>
void TRational<N>::ConvertTo (double& rdValue) const
{
unsigned int auiResult[2]; // 4字节对齐
....
rdValue = *(double*)auiResult; // 错误!double需要8字节对齐
....
}
auiResult按4字节对齐,强制转成double指针会导致未定义行为。现代CPU可能崩溃或性能下降
问题3:结构体比较包含padding(TDengine项目)
typedef struct STreeNode {
int32_t index;
void *pData;
} STreeNode;
int32_t tMergeTreeAdjust(SMultiwayMergeTreeInfo* pTree, int32_t idx) {
STreeNode kLeaf = pTree->pNode[idx];
if (memcmp(&kLeaf, &pTree->pNode[1], sizeof(kLeaf)) != 0) { // 错误!
....
}
memcmp比较包括padding字节,而padding内容未定义。即使字段相同,padding不同也会导致比较失败
#pragma pack的陷阱:
#pragma pack(push, 1)
struct PackedStruct
{
char a;
int b;
double c;
};
#pragma pack(pop)
强制1字节对齐,大小从16字节降到13字节。看起来省空间,实际:
PVS-Studio检测规则:
优化建议:
Vinnie Falco:std::stop_token不仅是线程取消原语,而是通用的单次信号机制
核心观点:stop_token实现了经典的观察者模式(GoF 1994),但被”stop”这个名字掩盖了通用性
基本用法:
// 发布者
std::stop_source signal;
// 订阅者注册回调
std::stop_callback cb1(signal.get_token(), []{ initialize_subsystem_a(); });
std::stop_callback cb2(signal.get_token(), []{ initialize_subsystem_b(); });
// 触发信号(所有回调被调用)
signal.request_stop();
问题1:命名误导
虽然叫”stop”,但实际可以用来”启动”:
std::stop_source ready_signal; // 实际是"准备好"信号
std::stop_callback worker1(ready_signal.get_token(), []{
initialize_subsystem_a();
});
ready_signal.request_stop(); // 名字说"停止",实际在"启动"
问题2:单次限制
std::stop_source signal;
bool first = signal.request_stop(); // 返回true,回调被调用
bool second = signal.request_stop(); // 返回false,什么都不发生
一旦信号过,就不能重置。这阻止了:
用例1:配置加载通知
std::stop_source config_ready;
std::stop_callback ui_cb(config_ready.get_token(), [&]{
apply_theme(config.theme);
});
std::stop_callback net_cb(config_ready.get_token(), [&]{
set_timeout(config.timeout);
});
config_ready.request_stop(); // 配置就绪,通知所有组件
用例2:类型擦除的多态回调
std::stop_source event;
// 不同类型的callable共存
std::stop_callback cb1(event.get_token(), []{ /* lambda */ });
std::stop_callback cb2(event.get_token(), std::bind(&Foo::bar, &foo));
std::stop_callback cb3(event.get_token(), my_functor{});
// stop_source不知道具体类型,但能调用所有回调
event.request_stop();
等价于std::vector<std::function<void()>>但更高效(无虚函数,栈上分配)
跨平台对比:
Set()和Reset()方法.NET文档承认:”CancellationToken可以解决超出其原始范围的问题,包括应用程序运行状态订阅、使用不同触发器超时操作以及通过标志进行一般进程间通信。”
作者建议:
短期方案 - 类型别名:
namespace std {
using one_shot_signal_source = stop_source;
using one_shot_signal_token = stop_token;
template<class Callback>
using one_shot_signal_callback = stop_callback<Callback>;
}
长期方案 - 可重置信号:
class signal_source {
public:
bool signal() noexcept; // 设置为已信号,调用回调
void reset() noexcept; // 回到未信号状态
bool is_signaled() const noexcept;
};
stop_token是被严重低估的宝藏。它不是”仅用于取消的工具”,而是成熟的观察者模式实现,只是被名字掩盖了通用性。理解这一点能解锁很多新用例
Roth Michaels分享在Native Instruments(音频软件公司)处理25年老代码库中的UB(未定义行为)的实战经验
核心观点:不要试图理解UB怎么工作的,just fix it
几个经典UB bug案例:
1. Xcode更新导致的UI变形
更新Xcode后,UI的梯形标签变成诡异的三角形,圆形按钮变成长椭圆。用cruse工具删代码找不到问题,最后发现是这段代码:
// 使用AG绘图库绘制椭圆
ag::ellipse e(x, y, radius);
ag::stroke stroke(e);
ag::conv_transform<ag::stroke> transform(stroke, get_transform()); // 临时对象!
rasterizer.add_path(transform);
get_transform()返回临时对象,conv_transform构造函数存了指针,临时对象销毁后,rasterizer实际应用transform时读取悬空指针。编译器在UB情况下把x坐标传了两次(而不是x和y),所以椭圆变形了
修复:别用临时对象,在栈上存transform
2. Windows更新前一天的发布日崩溃
产品要发布,前一天Windows更新导致插件每次启动就崩。问题出在未初始化的snapshot_color:
struct SnapshotData {
std::array<float, 512> frequencies;
std::array<float, 512> decibels;
float opacity;
Color snapshot_color; // 没有默认值!
};
序列化时调用了两次压缩来计算大小,两次读取的垃圾值不同,导致压缩后大小不一致,第二次写入时buffer overflow崩溃:
// 坑爹的序列化代码
auto data = get_state();
auto compressed_size = compress(data).size(); // 第一次压缩,读垃圾值A
auto buffer = allocate(compressed_size);
compress_into(data, buffer); // 第二次压缩,读垃圾值B,大小可能不同!
Mac和旧Windows上碰巧两次读取的垃圾值导致相同压缩大小,新Windows不行了
修复:给snapshot_color加默认值
3. 非确定性DSP回归测试失败
测试有时红有时绿。用Address Sanitizer + UBSan发现内存对齐问题:
// 内存池分配临时buffer
auto analysis_buffer = pool.alloc<float>(512); // 512个float
auto calc_buffer = pool.alloc<double>(256); // 紧接着分配double
当buffer大小不是2的幂时(如257个float),第二个double buffer的起始地址不是8字节对齐的,读写double时发生UB。Intel CPU能读不对齐地址,但C++标准不允许,Accelerate库和IPP库可能期望对齐
修复:内存池分配时保证类型对齐
4. std::sort的坑
// 错误:用了 <=
std::sort(v.begin(), v.end(), [](auto a, auto b) {
return a <= b; // 应该是 <,这不是严格弱序
});
大部分情况能工作,但不符合sort的契约。新版Xcode或UBSan会报错
文化和工具变革:
文化层面:
工具层面:
渐进式推广:
结果:
金句:
一个活生生的案例:25年老代码库,从”UB是nerd话题”到”全公司都怕UB”的转变过程
Bjarne的Generic Programming教学talk,讲concepts的设计理念和实际应用
核心理念:
Alex Stepanov的目标是”最通用、最高效、最灵活的概念表示”。规则是:
Bjarne的补充:
实战案例:修复C++的narrowing conversion
从一个简单问题开始:C++的隐式类型转换太危险了
// 这些都能编译通过,但结果可能不是你想的
int x = 3.14; // 截断
unsigned u = -1; // 变成超大正数
char c = 1000; // 溢出
解决方案 - Number类型:
template <typename T>
concept Number = std::integral<T> || std::floating_point<T>;
template <Number T>
class number {
T val;
public:
template <Number U>
number(U v) {
if (can_narrow<T, U>()) {
if (narrows(v)) throw narrowing_error{};
}
val = static_cast<T>(v);
}
};
can_narrow是编译期检查,只有可能narrowing的情况才做运行时检查。大部分转换在编译期就能确定安全,零开销
类型推导简化语法:
// 不用写
number<int> x = 5;
// 直接写
number x = 5; // 推导为number<int>
number y = 5.0; // 推导为number<double>
修复比较操作:
// C++的坑
-1 < 2u // false!因为-1转成unsigned变超大数
// number的修复
template <Number T, Number U>
bool operator<(number<T> a, number<U> b) {
if constexpr (/* mixed signed/unsigned */) {
if (/* 特殊情况检查 */) return true;
}
return a.val < b.val; // 安全的比较
}
Range checking - 修复span:
template <spannable R>
class span {
T* data;
number<size_t> size; // 用number保证size非负
T& operator[](number<size_t> i) {
if (i >= size) throw range_error{};
return data[i];
}
};
现在span s(data, -500)会在构造时就抛异常,不会等到运行时才崩溃
类型推导让代码更简洁:
// 不用写类型
span s1{aa}; // 推导为span<int>
span s2 = s1.first(10); // 前10个元素
span s3 = s1.subspan(5, 10); // 中间10个元素
经典STL算法加concepts:
老版本sort:
template <typename RandomIt, typename Compare>
void sort(RandomIt first, RandomIt last, Compare comp);
问题:如果传list的iterator,错误信息是”no operator+ for list::iterator”,看不出是你传错了类型
新版本sort:
template <std::random_access_iterator RandomIt,
std::indirect_strict_weak_order<RandomIt> Compare>
requires sortable<RandomIt, Compare>
void sort(RandomIt first, RandomIt last, Compare comp);
传list的iterator会得到清晰错误:”list::iterator is not a random_access_iterator”
Range版本更简洁:
template <sortable_range R>
void sort(R& r);
// 使用
std::vector<double> v = {3.14, 1.41, 2.71};
sort(v); // 就这么简单
Forward iterator的sort(为list特化):
template <forward_sortable_range R>
void sort(R& r) {
std::vector temp(r.begin(), r.end()); // 拷到vector
std::sort(temp.begin(), temp.end());
std::copy(temp.begin(), temp.end(), r.begin()); // 拷回去
}
Overload resolution会自动选最匹配的:vector用random_access版本,list用forward版本
Static Reflection预览(C++26):
// 自动生成class的member描述
template <typename T>
auto layout_of() {
constexpr auto members = nonstatic_data_members_of(^T); // ^ 是reflection operator
std::array<MemberDescriptor, members.size()> result;
for (size_t i = 0; i < members.size(); ++i) {
result[i] = {
.name = identifier_of(members[i]),
.offset = offset_of(members[i]),
.size = size_of(type_of(members[i]))
};
}
return result;
}
struct X { int a; double b; char c; };
auto xd = layout_of<X>(); // 自动生成member信息
不再需要宏,编译器直接告诉你class的结构
设计决策:
a + b能工作就行,不管是member还是free functionadvance对random_access_iterator可以用+=)OOP vs Generic Programming:
它们是互补的!可以写一个generic的draw_all,既支持虚函数的class hierarchy,也支持任何有draw()方法的类型
总结:
Generic Programming不是模板编程,是concept-based programming。Concepts让你:
37行代码实现number类型,修复了C++ 50年的narrowing conversion问题。这就是Generic Programming的威力