Skip to content

Releases: wanghenshui/cppweeklynews

C++ 中文周刊 2026-05-23 第202期

23 May 14:46
12a4c0e

Choose a tag to compare

qq群 753792291 答疑在这里

RSS

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

本期文章由 赞助老爷 赞助 在此表示感谢


资讯

标准委员会动态/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。headtailnext 都是热 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...

Read more

C++ 中文周刊 2026-05-15 第201期

23 May 14:43
12a4c0e

Choose a tag to compare

qq群 753792291 答疑在这里

RSS

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


资讯

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

性能周刊

GCC 16.1 发布

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_accessstd::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,链接进新的模块对象,运行时内存corruptionmake 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);
}

第三篇:写锁吞吐量差的问题

修完死锁后,写锁吞吐还是很差——写...

Read more

C++ 中文周刊 2026-04-17 第200期

23 May 14:42
6885433

Choose a tag to compare

周刊项目地址

公众号

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

qq群 753792291 答疑在这里

RSS

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

第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",而是"编译器知道怎么做"。

后果一连串:

  1. 不能用 memcpy,必须用 std::relocate/std::uninitialized_relocate,强制依赖标准库头文件
  2. 不能用 realloc——P1144 至少开了这扇门,P2786 直接锁死
  3. 没有谓词版本(context-sensitive keyword 引入新的 most-vexing-parse 问题),SBO 类型要绕路写 SFINAE dummy struct
  4. 没有任何实际 benchmark 证明支持 polymorphic 类型有收益
  5. 对 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, ...
Read more

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

27 Mar 14:08
05f8842

Choose a tag to compare

qq群 753792291 答疑在这里

RSS

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

本期文章没人赞助


资讯

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

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

性能周刊

文章

Optimizing a Lock-Free Ring Buffer

从头写一个SPSC ring buffer,然后一步步把它榨干。全文有benchmark,有代码,值得一读。

第一版:单线程

留一个slot不用来区分full和empty,head写、tail读:

template <typename T, std::size_t N>
class RingBufferV1 {
    std::array<T, N> buffer_;
    std::size_t head_{0};
    std::size_t tail_{0};
public:
    auto push(const T& value) noexcept -> bool {
        auto new_head = head_ + 1;
        if (new_head == buffer_.size()) [[unlikely]] new_head = 0;
        if (new_head == tail_) [[unlikely]] return false; // Full
        buffer_[head_] = value;
        head_ = new_head;
        return true;
    }
    auto pop(T& value) noexcept -> bool {
        if (head_ == tail_) [[unlikely]] return false; // Empty
        value = buffer_[tail_];
        auto next_tail = tail_ + 1;
        if (next_tail == buffer_.size()) [[unlikely]] next_tail = 0;
        tail_ = next_tail;
        return true;
    }
};

第二版:加mutex,直接加锁,12M ops/s,正确但慢。

第三版:换atomic,两个index分别alignas到不同cache line防false sharing:

template <typename T, std::size_t N>
class RingBufferV3 {
    std::array<T, N> buffer_;
    alignas(std::hardware_destructive_interference_size) std::atomic_size_t head_{0};
    alignas(std::hardware_destructive_interference_size) std::atomic_size_t tail_{0};
};

去掉锁直接35M ops/s,翻了将近三倍。但这里用的是默认memory_order_seq_cst,最保守的。

第四版:手调memory order,生产者只需要relaxed读自己的head,acquire读对方的tail;反之亦然:

auto push(const T& value) noexcept -> bool {
    const auto head = head_.load(std::memory_order_relaxed);
    auto next_head = head + 1;
    if (next_head == buffer_.size()) [[unlikely]] next_head = 0;
    if (next_head == tail_.load(std::memory_order_acquire)) [[unlikely]]
        return false;
    buffer_[head] = value;
    head_.store(next_head, std::memory_order_release);
    return true;
}

108M ops/s,又是3倍,mutex的9倍。

第五版:缓存对方index,每次push都要acquire读tail,这个原子读还是贵。在本地缓存一份,只有真的full时才重新acquire:

// 生产者侧增加cached tail
alignas(...) std::size_t tail_cached_{0};

if (next_head == tail_cached_) [[unlikely]] {
    tail_cached_ = tail_.load(std::memory_order_acquire);
    if (next_head == tail_cached_) return false;
}

305M ops/s,mutex的25倍

版本 方案 吞吐
V1 单线程 N/A
V2 mutex 12M ops/s
V3 atomic (seq_cst) 35M ops/s
V4 relaxed/acquire/release 108M ops/s
V5 加cache 305M ops/s

这种cached index的技巧在高频交易系统里很常见,第一次见到可以好好看看

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

P2264R7,把assert从普通宏改成可变参数宏。

问题: assert是个宏,预处理器只认括号,不认模板尖括号和花括号,所以这些都会炸:

assert(std::is_same<int, Int>::value);       // 逗号让宏以为是两个参数
assert([x, y]() { return x < y; }() == 1);  // lambda里的逗号
assert(std::vector<int>{1, 2, 3}.size() == 3); // 花括号里的逗号

修法:assert(expression)改成assert(...),用__VA_ARGS__接收,三行事情搞定。上面的代码一律直接通过,不需要手动加多余的括号。

注意事项: 原来提案想支持assert(x > 0, "诊断消息"),仿static_assert,但没过。原因是assert(x > 0, "msg")在宏展开后是对逗号表达式求值,结果永远是true。为了防止这种坑,C++26禁止顶层逗号,所以诊断消息还是要用&&

assert(x > 0 && "x must be positive");  // 还是这样写

不破坏现有代码,截至文章发布(2026年2月)编译器还没实现。省流:小修小补但实用。

Virtual Memory Tricks

Our Machinery博客的经典文章(游戏引擎开发者的博客)。虚拟内存不只是防止程序崩溃的保底机制,用好了可以做很多骚操作。

骚操作一:超大数组

想要一个能放10亿个对象指针的查找表,但不想提前分配8GB物理内存?直接虚拟分配:

#define MAX_OBJECTS 1000000000ULL
object_o **objects = virtual_alloc(MAX_OBJECTS * sizeof(object_o *));

保留了8GB地址空间,但物理内存只按实际使用量分配(按页)。64位进程地址空间有2^64这么大,256TB都是常规限制,随便造。

这种思路可以彻底干掉MAX_TANKS这种老派C写法的抱怨——不用vector,直接给每个数组1GB虚拟内存:

#define GB 1000000000
uint32_t num_tanks;
tank_t *tanks = virtual_alloc(GB);

注意Windows需要区分MEM_RESERVE和MEM_COMMIT;Linux支持overcommit,更简单。

骚操作二:虚拟地址当唯一ID

不用uint64_t next_id++,直接从虚拟内存系统分配地址作为唯一ID。这些地址从不被实际使用,不消耗物理内存,但在进程内全局唯一,还附赠类型安全:

system_id_t *allocate_id(system_t *sys) {
    if (!sys->id_block || sys->id_block_used == PAGE_SIZE) {
        sys->id_block = virtual_alloc(PAGE_SIZE);
        sys->id_block_used = 0;
    }
    return (system_id_t *)(sys->id_block + sys->id_block_used++);
}

骚操作三:内存越界检测

给只读内存区域前后放guard page,越界写直接segfault,比valgrind快多了。

骚操作四:ring buffer的虚拟内存实现

把同一块物理内存映射到两段连续的虚拟地址,这样ring buffer的wrap-around就消失了——直接memcpy就行,不用分两段拷贝。

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

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

Lemire介绍了一个利用C++26编译期反射(P2996)做JSON序列化的演讲。

C++26反射能做什么:在编译期拿到一个struct的成员信息,然后基于这些信息自动生成代码。放在JSON序列化上,就是不需要手写任何宏或者注册代码:

struct Point { int x, y; };

// C++26反射可以这样写:
template<typename T>
std::string to_json(const T& val) {
    std::string result = "{";
    bool first = true;
    [:expand(std::meta::nonstatic_data_members_of(^T)):] >> [&]<auto member>{
        if (!first) result += ",";
        result += '"';
        result += std::meta::identifier_of(member);
        result += "\":";
        result += std::to_string(val.[:member:]);
        first = false;
    };
    result += "}";
    return result;
}
// to_json(Point{1,2}) → {"x":1,"y":2}

不用宏,不用代码生成器,不用手动注册成员,完全自动。反射出来的东西是编译期常量,理论上可以全部在编译期展开。

reflect赶紧实现吧,标准都出了编译器还没跟上

Understanding Safety Levels in Physical Units Libraries

mp-units作者 Mateusz Pusz 写的,系统梳理了物理单位库能提供的六个安全级别。

大多数人知道的只有"维度安全"(length + time会报错),但其实还有更多层次:

Level 1:维度安全 — 加法只在相同维度间有效

quantity speed = 100 * km / h;
quantity time = 2 * h;
// quantity d = speed / time; // ❌ 维度不对
quantity<si::kilo<si::metre>> d = speed * time; //

Level 2:单位安全 — 编译期阻止单位不一致,消灭手动换算系数

Level 3:表示安全 — 防溢出、防精度损失(int存speed然后乘time可能溢出)

Level 4:量种安全 — 这个很少人意识到:torque(扭矩)和energy(能量)的维度完全相同(N·m = J),但物理上是完全不同的量,不应该相加。Hz和Bq也是同样问题。mp-units把这些区分开来:

auto e = 10 * J;    // energy
auto t = 10 * N*m;  // torque
// auto sum = e + t; // ❌ 量种不同,不能相加

Level 5:量安全 — 强化量之间的关系和方程式约束

Level 6:数学空间安全 — 区分点(position)和差值(delta):温度273K和温差5K不是同一个东西,273K + 5K = 278K,但273K + 273K没有物理意义

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

From error-handling to structured concurrency

单线程里的错误处理大家都懂:出错了沿调用栈往上传,每一帧做cleanup(RAII/finally/defer),找到第一个能处理的地方。Go、Rust、Python、C++、Java都是这套,只是API不同。

并发程序里怎么搞?没有"单一的栈"。背景线程出了异常,主线程怎么知道?

两种糟糕的现有方案:

  • 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是纯头文件库,要模块化面临几个问题:

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

bizwen的方案: 每个命名空间一个模块,用宏切换头文件模式和模块实现模式:

// 头文件用这种模式兼容两种用法:
#pragma push_macro("WINRT_EXPORT")
#undef WINRT_EXPORT
#if !defined(WINRT_MODULE)
#define WINRT_EXPORT          // 普通头文件路径
#include <winrt/base.h>
#else
#define WINRT_EXPORT export   // 模块路径,声明都带export
#endif
// ... 声明 ...
#pragma pop_mac...
Read more

C++ 中文周刊 2026-03-21 第198期

23 Mar 04:03
39632eb

Choose a tag to compare

qq群 753792291 答疑在这里

RSS

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

本期文章没人赞助


资讯

标准委员会动态/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后OK

P2821R5: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_range

string、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...

Read more

C++ 中文周刊 2026-03-15 第197期

15 Mar 17:14

Choose a tag to compare

qq群 753792291 答疑在这里

RSS

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

本期文章没人 赞助

上期文章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用了285msstd::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<<重载歧义

问题根源:代码里有条件编译的__int64operator<<,只在非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>falsestd::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_t1.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

上面OptBoolhas_value()里运行时分支 return c != 2; 看起来像访问了union的非活跃成员,应该UB才对?

其实不是UB! C++标准有个例外:通过charunsigned charstd::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三连:

  1. substitute(^^fmt_types, arg_types) — 把reflection值替换进变量模板
  2. extract<T const*>(r) — 从reflection中拉出实际值
  3. 调用提取出的函数指针

最后通过consteval构造函数把格式化字符串解析和函数指针提取都在编译期完成

文章还讨论了如果C++有constexpr函数参数和consteval mutable变量(像Zig那样),实现可以简化多少

抽象程度很高,值得反复看。Compiler Explorer demo可以直接跑

C++ Reflection: Another Monad

Ben Deane看了Barry上面那篇文章后直接拍桌子:C++26 reflection就是个monad!

^^pure(把类型提升到reflection-land),substitutefmap(甚至是n-ary的,等价于applicative functor),extract<std::meta::info>join

template <typename T>
c...
Read more

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

15 Mar 17:13

Choose a tag to compare

qq群 753792291 答疑在这里

RSS

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

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


资讯

标准委员会动态/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"的错觉:

  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状态 — 都不保证
28. 至少不会把硬盘擦了 — 不保证(虽然不太可能)
29. 至少不会损坏硬件 — 不保证

"之前好好的"系列:

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);...
Read more

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

15 Mar 17:11

Choose a tag to compare

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之间不稳定跳动,根本没法做性能测试

常见解决方案:

  • 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流式传输顶点和索引数据。问题是:

  1. 每次传输索引数据都使整个索引缓冲区缓存失效
  2. 缓存失效后需要重新扫描整个大缓冲区重建缓存
  3. 每个岩石都触发一次,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...
Read more

C++ 中文周刊 2026-01-24 第194期

15 Mar 17:10

Choose a tag to compare

qq群 753792291 答疑在这里

RSS

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

一月邮件 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::RefCountedbase::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/stringsabsl::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避免复制:

Read more

C++ 中文周刊 2026-01-09 第193期

10 Jan 08:22

Choose a tag to compare

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种新时钟,因为"世界运行在多个时间尺度上"

  1. utc_clock:有闰秒的UTC时间
  2. tai_clock:国际原子时间,1958年开始,不含闰秒
  3. gps_clock:GPS时间,1980年开始
  4. file_clock:文件系统时钟,跟 std::filesystem配套
  5. 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只需要前向迭代器,但常见的实现方法需要双向迭代器

相邻块交换

设三个指针 firstmidlast,要把块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...
Read more