实战复盘:手写 C++ 虚拟机的高性能并行 GC (Thread Pool + Work Stealing)

在我的 C++ 虚拟机项目 cilly-vm-cpp 中,垃圾回收(GC)模块经历了一次脱胎换骨的重构。从最初简单的单线程 Mark-Sweep,到引入全局锁的并行标记,再到如今借鉴工业级引擎(如 PrimJS/V8)实现的线程池 + 工作窃取架构,性能和并发度都得到了质的飞跃。

本文将详细复盘这一架构的完整实现细节,既作为我的学习笔记,也希望能为对 VM 开发感兴趣的朋友提供参考。

一、 架构演进与目标

我的目标非常明确:最大化利用多核 CPU,最小化 STW (Stop-The-World) 时间

最终落地的架构包含以下核心特性:

  1. 并行标记 (Parallel Marking):多线程并发扫描对象图,利用 CAS 防止重入。
  2. 并发清除 (Concurrent Sweep):主线程快速摘除垃圾,后台线程异步释放内存。
  3. 线程池 (Thread Pool):常驻工作线程,消除线程创建/销毁开销。
  4. 工作窃取 (Work Stealing):每线程本地队列,无锁生产,加锁窃取,实现完美的负载均衡。

二、 核心组件详解

1. 线程池 (Thread Pool) ------ 拒绝"临时工"

早期的实现是每次 GC 都 new std::thread,GC 结束就 join。这不仅有昂贵的系统调用开销,还无法利用 CPU 缓存热度。我将其重构为常驻线程池。

代码实现 (src/gc/gc.h & src/gc/gc.cc):

cpp 复制代码
// 构造函数:VM 启动时创建常驻线程
Collector::Collector() {
  // 根据硬件并发数创建线程
  for (int i = 0; i < N; i++) {
    workers_.emplace_back([this, i] {
      tls_gc_id = i; // 设置线程局部 ID
      int my_epoch_ = 0;
      while (!stop_flag_) {
        std::unique_lock<std::mutex> lk(cv_m_);
        // 等待唤醒信号:版本号更新 或 退出标志
        cv_start_.wait(lk, [&] { return my_epoch_ < gc_epoch_ || stop_flag_; });

        if (stop_flag_) break;

        // 醒来干活!
        ProcessWorkStack();

        // 干完活,更新状态并通知主线程
        my_epoch_++;
        active_workers_.fetch_sub(1);
        cv_done_.notify_one();
      }
    });
  }
}

同步机制 : 使用了 Epoch (版本号) 机制来精确控制线程的唤醒。每次 GC 开始时,主线程执行 gc_epoch_++notify_all,Worker 发现版本号落后了就会醒来干活。

2. 本地队列与数据结构 ------ 规避锁竞争

为了消除全局锁竞争,我摒弃了 global_work_stack_,改为给每个线程分配一个独立的 LocalQueue

数据结构设计:

cpp 复制代码
struct LocalQueue {
  std::vector<GcObject*> tasks; // 本地任务栈
  std::mutex m;                 // 保护该队列的锁(仅窃取时需要)
};

// 使用 unique_ptr 避免 std::mutex 不可拷贝导致 vector 扩容失败的问题
std::vector<std::unique_ptr<LocalQueue>> queue;

无锁生产 (Push) : 当线程扫描到一个新对象时,直接放入自己的队列,完全不需要加锁(因为除了自己没人会 Push 进来)。

cpp 复制代码
void Collector::MarkParallel(GcObject* obj) {
  if (obj->TryMark()) { // CAS 原子抢占,防止多线程重复标记
    std::unique_lock<std::mutex> lk(queue[tls_gc_id]->m);
    queue[tls_gc_id]->tasks.push_back(obj); // 放入自己的队列
    active_tasks_.fetch_add(1);
  }
}

3. 工作窃取 (Work Stealing) ------ 负载均衡的核心

这是整个架构中最复杂、最精彩的部分。当一个线程干完自己的活后,它不会立刻躺平,而是去"偷"别人的活干。

实现细节 (ProcessWorkStack):

cpp 复制代码
void Collector::ProcessWorkStack() {
  while (true) {
    GcObject* obj;
    {
      std::unique_lock<std::mutex> lk(queue[tls_gc_id]->m);
      
      // 1. 自己的队列空了?尝试窃取!
      if (queue[tls_gc_id]->tasks.empty()) {
        for (int i = N; i >= 0; i--) {
          if (i == tls_gc_id) continue;
          
          // 关键点:防止死锁!必须先释放自己的锁,再去锁别人
          lk.unlock(); 
          
          std::vector<GcObject*> stolen;
          {
            std::unique_lock<std::mutex> lk_steal(queue[i]->m);
            // 如果对方任务够多,偷走一半 (Steal Half)
            if (queue[i]->tasks.size() >= 10) {
               // ... 移动一半任务到 stolen ...
            }
          }
          
          lk.lock(); // 重新锁自己

          // 如果偷到了,加入自己的队列并跳出窃取循环
          if (!stolen.empty()) {
            queue[tls_gc_id]->tasks.insert(..., stolen...);
            break; 
          }
        }
      }

      // 2. 窃取一圈还没偷到?
      if (queue[tls_gc_id]->tasks.empty()) {
        // 终止检测:如果全局也没任务了,说明 GC 结束
        if (active_tasks_.load() == 0) return;
        
        // 否则只是暂时没活,让出 CPU 避免忙等待 (Busy Wait)
        lk.unlock();
        std::this_thread::yield();
        continue;
      }

      // 3. 取任务
      obj = queue[tls_gc_id]->tasks.back();
      queue[tls_gc_id]->tasks.pop_back();
    }

    // 4. 处理任务
    obj->Trace(*this);
    active_tasks_.fetch_sub(1);
  }
}

关键技术点:

  • Unlock-Relock : 在尝试锁别人的队列前,必须先释放自己的锁。否则,如果 A 锁自己想锁 B,B 锁自己想锁 A,就会死锁
  • Steal Half: 每次偷一半任务。这比"偷一个"效率高得多,能显著减少窃取频率和锁竞争。
  • Yield : 当没活干且不能退出时,调用 std::this_thread::yield() 主动让出 CPU 时间片,避免空转烧 CPU。

三、 并发清除 (Concurrent Sweep)

标记完成后,会有成千上万个死对象需要释放。如果由主线程逐个 delete,势必会造成卡顿。

实现方案:

  1. 主线程 : 快速遍历链表,将未标记的对象从链表中"摘除"(Unlink),放入一个临时 vector garbage
  2. 后台线程 : 启动一个 detach 线程,接管这个 garbage 列表,在后台慢慢 delete
cpp 复制代码
if (!garbage.empty()) {
  std::thread([g = std::move(garbage)]() {
    for (auto* dead : g) {
      delete dead;
    }
  }).detach();
}

利用 C++14 的 Lambda Init Capture (g = std::move(garbage)),实现了零拷贝的所有权转移。


四、 总结

通过这次重构,cilly-vm-cpp 的 GC 模块已经具备了工业级 GC 的雏形:

  1. Thread Pool: 解决了线程生命周期开销。
  2. Local Queues: 解决了 99% 的锁竞争问题。
  3. Work Stealing: 解决了多核负载不均衡问题。
  4. Concurrent Sweep: 解决了清理阶段的 STW 问题。
相关推荐
肆忆_1 小时前
虚函数进阶答疑:把上一篇博客评论区里最容易卡住的问题,一次追到底
c++
saltymilk18 小时前
使用 C++ 模拟 ShaderLanguage 的 swizzle
c++·模板元编程
xlp666hub1 天前
Leetcode第五题:用C++解决盛最多水的容器问题
linux·c++·leetcode
得物技术1 天前
搜索 C++ 引擎回归能力建设:从自测到工程化准出|得物技术
c++·后端·测试
xlp666hub2 天前
Leetcode 第三题:用C++解决最长连续序列
c++·leetcode
会员源码网2 天前
构造函数抛出异常:C++对象部分初始化的陷阱与应对策略
c++
xlp666hub2 天前
Leetcode第二题:用 C++ 解决字母异位词分组
c++·leetcode
不想写代码的星星2 天前
static 关键字:从 C 到 C++,一篇文章彻底搞懂它的“七十二变”
c++
xlp666hub3 天前
Leetcode第一题:用C++解决两数之和问题
c++·leetcode