在我的 C++ 虚拟机项目 cilly-vm-cpp 中,垃圾回收(GC)模块经历了一次脱胎换骨的重构。从最初简单的单线程 Mark-Sweep,到引入全局锁的并行标记,再到如今借鉴工业级引擎(如 PrimJS/V8)实现的线程池 + 工作窃取架构,性能和并发度都得到了质的飞跃。
本文将详细复盘这一架构的完整实现细节,既作为我的学习笔记,也希望能为对 VM 开发感兴趣的朋友提供参考。
一、 架构演进与目标
我的目标非常明确:最大化利用多核 CPU,最小化 STW (Stop-The-World) 时间。
最终落地的架构包含以下核心特性:
- 并行标记 (Parallel Marking):多线程并发扫描对象图,利用 CAS 防止重入。
- 并发清除 (Concurrent Sweep):主线程快速摘除垃圾,后台线程异步释放内存。
- 线程池 (Thread Pool):常驻工作线程,消除线程创建/销毁开销。
- 工作窃取 (Work Stealing):每线程本地队列,无锁生产,加锁窃取,实现完美的负载均衡。
二、 核心组件详解
1. 线程池 (Thread Pool) —— 拒绝“临时工”
早期的实现是每次 GC 都 new std::thread,GC 结束就 join。这不仅有昂贵的系统调用开销,还无法利用 CPU 缓存热度。我将其重构为常驻线程池。
代码实现 (src/gc/gc.h & src/gc/gc.cc):
// 构造函数: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。
数据结构设计:
struct LocalQueue {
std::vector<GcObject*> tasks; // 本地任务栈
std::mutex m; // 保护该队列的锁(仅窃取时需要)
};
// 使用 unique_ptr 避免 std::mutex 不可拷贝导致 vector 扩容失败的问题
std::vector<std::unique_ptr<LocalQueue>> queue;
无锁生产 (Push): 当线程扫描到一个新对象时,直接放入自己的队列,完全不需要加锁(因为除了自己没人会 Push 进来)。
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):
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,势必会造成卡顿。
实现方案:
- 主线程: 快速遍历链表,将未标记的对象从链表中“摘除”(Unlink),放入一个临时
vector garbage。 - 后台线程: 启动一个
detach线程,接管这个garbage列表,在后台慢慢delete。
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 的雏形:
- Thread Pool: 解决了线程生命周期开销。
- Local Queues: 解决了 99% 的锁竞争问题。
- Work Stealing: 解决了多核负载不均衡问题。
- Concurrent Sweep: 解决了清理阶段的 STW 问题。