C++ 中文周刊 2026-01-31 第195期

周刊项目地址

公众号

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

qq群 753792291 答疑在这里

RSS

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


资讯

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 
    }
}

清理逻辑就近放置,不用深层嵌套,代码结构清晰多了

技术改进(评论区讨论):

  1. Interlocked.Exchange(ref action, null)?.Invoke()代替简单的action.Invoke(),确保Dispose只执行一次
  2. C# 13允许ref struct实现接口,可以显式实现IDisposable
  3. 异常安全性问题: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之间不稳定跳动,根本没法做性能测试

常见解决方案:

作者的解决方案 - 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”到浏览器时遇到灾难性性能问题。添加可破坏岩石功能后,某些机器上帧率暴跌,背景音乐都卡了

场景规模:

问题定位:

原始代码长这样:

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, ...);
}

性能数据:

根本原因:

WebGL需要验证索引缓冲区有效性以防越界访问(安全原因)。浏览器用缓存机制避免重复验证。作者用4MB的per-frame buffer流式传输顶点和索引数据。问题是:

  1. 每次传输索引数据都使整个索引缓冲区缓存失效
  2. 缓存失效后需要重新扫描整个大缓冲区重建缓存
  3. 每个岩石都触发一次,100个岩石就是100次

为什么开发机器没发现?作者推测是因为开发机器的CPU有更大的L2/L3缓存,索引扫描很快

解决方案:

批处理(Batching)- 把所有岩石几何体合并成单个网格,用一次绘制调用搞定

进一步优化建议:

“缓存是计算机科学中最难的两件事之一”,这案例再次证明了这句话。看似简单的immediate mode渲染,在WebGL的安全验证机制下,因为缓存策略的交互,导致了灾难性的性能下降

How we interfaced single-threaded C++ with multi-threaded Rust

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();  // 在主线程执行销毁
        }
    }
}

关键点:

挑战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++代码定义了明确的安全义务和规范,让团队其他成员也能安全使用

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上差异巨大:

差异达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):

“你永远不应该认为自己足够聪明到可以编写自己的锁机制…这真的很难。”

文章列举的错误实现案例:

何时可以考虑自旋锁:

最佳建议:

  1. 优先使用OS锁原语(mutex、futex)
  2. 最好的锁是不使用锁 - 考虑无锁数据结构
  3. 如果真要自己实现,至少要做到上面提到的所有点

自旋锁看似简单,实则极其复杂

IIFE for Complex Initialization

用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();

最佳实践:

性能:基准测试显示编译器能很好地优化,IIFE版本有时反而更快10%左右

这个技巧挺实用的,特别是想让变量保持const但初始化逻辑又有点复杂的时候。虽然一开始看着有点怪,但用熟了会发现这种写法既保证了不可变性,又避免了为一小段逻辑单独创建函数的麻烦

Time in C++: Once More About Testing

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都不需要了,最简单最直接

作者建议:从时间提供器方法开始。它最简单、可读性强,以后重构也容易。核心思想:不要硬编码时间来源!把时间当作可注入的依赖

测试带时间的代码就像测试随机数生成器 - 你得能控制住它才行。记住:把时间当朋友注入进来,别让它成为测试的敌人

vtables aren’t slow (usually)

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缓存

优化建议:

  1. 给编译器尽可能多的信息
    • 使用constnoexceptfinal
    • 简化控制流
    • 启用LTO(链接时优化)
  2. 如果数据真的随机,按派生类类型排序数组
    • 预测器会快速学习当前目标
    • 误预测只在类型切换时发生
  3. 重新设计极小的虚函数
    • 如果虚函数体极简单,考虑是否真的需要多态

实际影响:

只要虚函数不是极其简单的几条指令,而且数据不是完全随机的,vtable的性能影响几乎可以忽略。CPU层面的开销(额外加载、分支预测)在大多数实际场景中都会消失在噪音中

当然性能极致的场景还是需要devirtual优化的

[A Simple fwd_diff for Forward-Mode AD in C++](https://solidean.com/blog/2026/fwd-diff-autodiff-cpp/)

用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

Silent foe or quiet ally: Brief guide to alignment in C++

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检测规则:

优化建议:

Recognizing stop_token as a General-Purpose Signaling Mechanism

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()>>但更高效(无虚函数,栈上分配)

跨平台对比:

.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是被严重低估的宝藏。它不是”仅用于取消的工具”,而是成熟的观察者模式实现,只是被名字掩盖了通用性。理解这一点能解锁很多新用例

视频

Purging Undefined Behavior and Intel Assumptions in Legacy Codebase - Roth Michaels

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会报错

文化和工具变革:

文化层面:

  1. 恐吓战术 - 告诉所有人(包括QA和PM)UB有多可怕
  2. 重新优先级 - 闻起来像UB的bug提高优先级
  3. 教育全公司 - 给QA讲什么是”UB”(有人问”什么是UB?”)
  4. 核心准则 - 添加规则”不允许编写UB”,代码审查中不可讨论

工具层面:

  1. Clang-tidy - 只在新代码上运行
  2. Address Sanitizer (ASan) - CI中运行单元测试
  3. UB Sanitizer (UBSan) - CI中运行
  4. Thread Sanitizer (TSan) - 手动运行,为Apple Silicon做准备时大量使用

渐进式推广:

结果:

金句:

一个活生生的案例:25年老代码库,从”UB是nerd话题”到”全公司都怕UB”的转变过程

Generic Programming with Concepts - Bjarne Stroustrup

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的结构

设计决策:

  1. Concepts是函数 - 可以有多个参数,返回bool
  2. Use patterns而非固定接口 - a + b能工作就行,不管是member还是free function
  3. 不需要完美的concepts - 可以partial constraint,后续再完善
  4. Template不应该独立编译 - 需要看到实际使用才能做优化(如advance对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的威力

开源项目介绍


上一期

本期

下一期

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