用 RCU 发布内存映射视图并延后回收
QEMU 重建 FlatView 后发布新指针,已有读者继续使用旧视图,直到引用归还且 RCU 宽限期结束才销毁。
工程问题#
设备热插拔或内存映射变更会更新地址空间,而 vCPU 与 IOThread 还在读取映射。逐次保护 current_map 的互斥锁会让这些读者竞争;直接替换并释放又会留下悬空指针。
实际实现#
构造新的 FlatView 与 dispatch,更新者在 BQL 保护下执行区域差异回调并通过 qatomic_rcu_set 发布。读侧在 RCU 临界区中读取固定的视图指针,旧视图最后一次 unref 使用 call_rcu 延后销毁。
处理流程#
- 在旧视图之外构造新映射和 dispatch
- 更新者在 BQL 保护下执行区域差异回调并发布 current_map
- 读者在 RCU 临界区内使用所取得的视图
- 旧视图引用归零后,在 RCU 宽限期结束时销毁
源码片段#
固定版本 · 原文摘录2942MemTxResult address_space_read_full(AddressSpace *as, hwaddr addr,2943 MemTxAttrs attrs, void *buf, hwaddr len)2944{2945 MemTxResult result = MEMTX_OK;2946 FlatView *fv;29472948 if (len > 0) {2949 RCU_READ_LOCK_GUARD();2950 fv = address_space_to_flatview(as);2951 result = flatview_read(fv, addr, attrs, buf, len);2952 }29532954 return result;2955}读侧先进入 RCU 临界区,再取得当前 FlatView,并在同一临界区内完成读取。保护的是映射对象生命周期;设备回调和 RAM 内容仍有各自同步规则。
1099 if (old_view) {1100 flatview_ref(old_view);1101 }11021103 flatview_ref(new_view);11041105 if (!QTAILQ_EMPTY(&as->listeners)) {1106 FlatView tmpview = { .nr = 0 }, *old_view2 = old_view;11071108 if (!old_view2) {1109 old_view2 = &tmpview;1110 }1111 address_space_update_topology_pass(as, old_view2, new_view, false);1112 address_space_update_topology_pass(as, old_view2, new_view, true);1113 }11141115 /* Writes are protected by the BQL. */1116 qatomic_rcu_set(&as->current_map, new_view);1117 if (old_view) {1118 flatview_unref(old_view);1119 }更新者持有 BQL,先持有新旧视图引用并调用 MemoryListener 的区域差异回调,再用 qatomic_rcu_set 发布新视图。事务级 commit 回调在所有 AddressSpace 更新后执行。旧视图的临时引用在完整函数末尾另行归还,不能把片段中的一次 unref 理解为全部生命周期处理。
310void flatview_unref(FlatView *view)311{312 if (qatomic_fetch_dec(&view->ref) == 1) {313 trace_flatview_destroy_rcu(view, view->root);314 assert(view->root);315 call_rcu(view, flatview_destroy, rcu);316 }317}最后一个引用归还后,call_rcu 登记延迟销毁回调;回调在登记时已经存在的 RCU 读侧临界区全部结束后执行。普通引用计数和 RCU 各自保护不同的使用方式。
758 view = flatview_new(mr);759760 if (mr) {761 render_memory_region(view, mr, int128_zero(),762 addrrange_make(int128_zero(), int128_2_64()),763 false, false, false);764 }765 flatview_simplify(view);766767 view->dispatch = address_space_dispatch_new(view);768 for (i = 0; i < view->nr; i++) {769 MemoryRegionSection mrs =770 section_from_flat_range(&view->ranges[i], view);771 flatview_add_to_dispatch(view, &mrs);772 }773 address_space_dispatch_compact(view->dispatch);774 g_hash_table_replace(flat_views, mr, view);775776 return view;777}先把 MemoryRegion 拓扑展开为互不重叠的地址区间,再合并可兼容的相邻区间并构造 dispatch。构造完成后才供 AddressSpace 发布,不在现有读者使用的视图上逐项修改。
读侧、发布、回收与视图构造分成连续短片段。发布函数还有引用归还逻辑,需结合完整函数阅读。
收益与取舍#
工程分析带来的收益
- 读者无需为取得 current_map 竞争历史实现中的 flat_view_mutex
- 视图发布与旧对象销毁分离,避免读者拿到映射后对象立即被释放
付出的代价
- 新旧视图可能同时占用内存,回收会被尚未退出的读侧临界区推迟
- RCU 只覆盖相应映射对象访问;写侧仍需 BQL,设备寄存器和 RAM 数据仍需各自同步
可比较的选择
flat_view_mutexQEMU 历史实现曾用该互斥锁取得 current_map 引用;原作者改用 RCU 的理由是减少大系统、多 IOThread 场景的 futex 争用。
替换指针后立即释放旧视图虽然代码短,却不能保护已经读取旧指针的线程,不是可用的并发实现。
版本与适用范围#
固定于 QEMU v9.2.0 的 FlatView 生命周期。2015 年提交用于说明互斥锁对照方案与设计动机;未对该版本的性能进行实测。
参考来源#
源码、官方文档与原始论文相关实现与比较#
4 个相关案例nginx 将小额分配变成池内游标推进,并以池的生命周期统一释放;较大的分配另建记录管理。
Linux 通过 RCU 保护 PID 查找期间的对象存活,再用引用计数延长返回对象的生命;移除 PID 后先等待 RCU 宽限期,再释放索引持有的引用。
virCommandSetSendBuffer 统一接管数据和管道,让虚拟 TPM 调用方只负责取得口令、传入缓冲区并组装参数。
libvirt 把 FD 和定时器接到 GLib 主循环,将停止监听与释放对象分开,并提高连接清理任务的优先级,避免资源用尽时清理一直排不上。