多线程 TCG

本文概述多线程 TCG(MTTCG)系统模拟设计。 用户模式一直映射被翻译程序的线程结构, 不过为 MTTCG 所作的部分修改也改善了 linux-user 稳定性。

最初系统模式 TCG 只有单线程,通过轮转调度多个 CPU。 这简化了许多工作,但模拟系统核心数增长、主机单核性能提升趋缓后,限制日益明显。

vCPU 调度

新模式让每个 vCPU 在自己的用户态线程中运行。 对主机内存模型足以满足客户机的前后端组合, 即 TCGCPUOps::guest_default_memory_order & ~TCG_TARGET_DEFAULT_MO 为零, 且客户机已完成安全支持工作、设置 TCGCPUOps::mttcg_supported 时,默认启用。

以下情况回退到原轮转方式:

  • 使用 --accel tcg,thread=single 强制单线程;

  • 启用 --icount。

一般翻译代码执行不应有 vCPU 间依赖,各 vCPU 可全速运行。 只有访问内部共享结构,或目标架构要求机器状态一致时,才需要同步。

共享数据结构

主执行循环

即使不生成代码,主循环热路径也会访问下一 TB 查找相关结构,包括:

tb_jmp_cache:各 vCPU 的近期跳转缓存。 tb_ctx.htable:全局物理地址到 TB 的哈希表。

TB 只在同页时连接,因此查找下一块是退出生成代码最常见的原因,此处对性能至关重要。

设计要求:查找结构必须支持多读者、多写者安全访问,并尽量减少锁竞争。

热路径尽可能避免锁。tb_jmp_cache 通过原子访问保证一致, 回退使用的 QHT 哈希表也支持无锁查找。 只有生成代码或修补 TB 间跳转时才加锁。

全局 TCG 状态

用户模式模拟

需要保护整个代码生成周期,包括生成后的修补。 所有核心共用翻译缓冲区,因此进入主循环进行代码生成的路径都需持有互斥锁, 刷新代码或共享查找缓存条目时也如此。 各 vCPU 私有结构不需要锁,除非其他 vCPU 也会修改。

设计要求:全部代码生成和 TB 修补都必须加锁。

当前方案:

通过 mmap_lock() 串行化代码生成。

非用户模式模拟

每个 vCPU 有独立 TCG 上下文和区域,因此翻译期间无需加锁。

翻译块

当前系统共享一个代码生成缓冲区,填满后清空全部翻译并重新开始。 以下操作也可能强制全量刷新:

  • 断点插入或移除等调试操作;

  • 某些 CPU 辅助函数;

  • linux-user 创建第一个线程;

  • TCG 插件相关操作。

通过 async_safe_run_on_cpu(),确保修改全局共享结构时全部 vCPU 静止。

更细粒度的失效通常源于物理页状态变化:

  • 自修改或打补丁等代码修改;

  • 页面变化,例如 linux-user 的新映射。

设置 TranslationBlock 的无效标志可防止热路径查找再次使用它, 但还需安全清理其他管理结构。

已修补为直接跳到失效 TB 的其他块,必须撤销跳转修补,重新返回 C 代码。

还需更新:

  • 跳转查找缓存;

  • 物理地址到 TB 的哈希表;

  • 全局页表。

全局页表 l1_map 提供多级 PageDesc 查找。 PageDesc 保存该页全部 TB 链表的起点,见 page_next。 跳转修补和页缓存均涉及链表,失效 TB 需从中移除。

设计要求:安全处理 TB 失效,包括安全修补/恢复直接跳转、 移除中央 PageDesc 查找条目,以及安全更新缓存和哈希表。

当前方案:

TCG 的 tb_set_jmp_target() 原子更新直接跳转。 用于搜索链接页面的链表,在目标 TB 的 tb->jmp_lock 保护下修改。 每个源块保留目标指针,以便遍历跳转链表前获取正确锁。

全局页表为无锁基数树,通过 cmpxchg 原子插入元素。 查找缓存原子更新,哈希表使用支持并发安全查找的 QHT。

支持并行生成代码。插入 QHT 时作为跨线程同步点, 确保每个客户机代码块只保留一个 TranslationBlock。

内存映射与 TLB

内存处理对模拟系统速度非常关键。 SoftMMU 使热路径完全在翻译代码中处理, 通过各 vCPU 的 TLB,在填充后允许连续访问该页而无需退出翻译代码。 可在 TLB 地址中设置标志,使每次访问进入慢路径,以支持:

  • 区分 PIO、MMIO、RAM 的内存区域;

  • 代码生成、自修改检测、迁移和显示所需的脏页跟踪;

  • 将客户机地址转换为真实地址的虚拟 TLB。

其他 vCPU 线程更新某 vCPU 的 TLB 时,必须保证该 vCPU 看不到不一致状态。 部分操作还要求同步更新多个 vCPU 的 TLB。

设计要求:

  • 全量或单页 TLB 刷新:可能跨 vCPU,可能需要停止其他 vCPU, 修改可能需要立即对调用者可见。

  • TLB 标志更新:通常跨 vCPU,希望尽快可见。

  • 通过 tlb_set_page_with_attrs 更新 CPUTLBEntry: 表为 vCPU 私有,按定义不存在竞争,由所属线程在强制慢路径时更新。

当前方案:

tlb_flush_*_all_cpus_synced 将源 vCPU 工作设为安全工作, 并退出 CPU 执行循环,强制同步。重新开始执行前,全部刷新都已完成。

TLB 标志更新均为原子操作,并受对应页锁保护。

已知限制:

严格来说不算限制,但部分架构只要求刷新在某屏障指令前完成, 当前等待机制过于严格,未来可优化。

模拟硬件状态

得益于 KVM 相关工作,I/O 内存访问自动受 BQL 保护, 不使用 BQL 的 I/O 区域需要自行加锁。

但 I/O 内存不是修改硬件状态的唯一途径。 部分架构通过模型专用寄存器触发硬件功能。 一般来说,需要更新多个 vCPU 状态的翻译辅助函数都应持有 BQL。

BQL 即全局 iothread mutex,全系统共享, 因此应尽量在 TCG 深层、接近实际需要的位置使用,减少竞争。

当前方案:

MMIO 自动通过 BQL 串行化硬件模拟。 Arm 目标串行化全部 ARM_CP_IO 寄存器访问, 并通过 async_run_on_cpu() 将 vCPU 复位和启动交给对应上下文。

中断状态更新也受 BQL 保护,因为经常跨 vCPU。

内存一致性

客户机与主机可能采用多种一致性模型。 即使在强序主机模拟弱序系统,也必须在客户机要求时阻止存储越过此前加载等重排。

内存屏障

屏障让软件从其他核心等外部观察者的角度,为内存操作强制特定顺序。 可以作用于全部内存操作,也可仅作用于加载或存储。

Linux 内核的 内存屏障说明 详细介绍不同屏障及其保证。

屏障常封装在同步原语周围,提供明确顺序语义; 也可单独实现安全无锁访问,例如确保载荷修改可见后,通知标志才变为可见。

设计要求:增加 tcg_memory_barrier 操作, 强制较强读写顺序,使此前加载和存储在屏障处完成。 单核、非 SMP、强序后端上可以退化为无操作。

除显式屏障,客户机每条内存访问指令也可能隐含顺序语义。 例如 x86 读写有较强顺序一致性保证,Arm 则有隐含 acquire/release 的特殊变体。

弱序主机模拟强序客户机时,性能损失可能很大。

设计要求:高效使用屏障。 主机已有更强隐含保证时可跳过部分屏障;连续屏障合并为最强形式。

当前方案:

tcg_gen_mb() 在并行代码生成上下文中添加屏障操作。 tcg_optimize() 尝试在加载/存储前合并屏障为最强形式。 方案最初为 linux-user 开发和测试,全部后端已支持按需生成屏障。 目前以下前端也已完成相应转换:

  • target-i386

  • target-arm

  • target-aarch64

  • target-alpha

  • target-mips

内存控制与维护

这类指令控制系统缓存行为。 虽然 QEMU 不建模缓存,但软件修改代码后常用这些指令确保变化生效。

同步原语

现代指令集主要有原子指令和独占区域两类同步原语。

第一类以单条指令保证测试和条件存储相对于其他核心共享访问确实原子, 典型例子为 x86 cmpxchg。

第二类通过一对加载/存储保证期间相关内存未被其他访问触及。 例如 Arm ldrex/strex,只有 ldrex 后没有其他 CPU 访问该区域, strex 才返回成功存储标志。

传统 TCG 生成的一组操作位于同一 TB,其他 CPU 被调度前已全部完成,因而有效。 多线程模拟多个 CPU 后,必须显式表达这些语义。

设计要求:支持传统原子指令、独占加载/存储或 LL/SC, 并提供足够通用的基础设施以覆盖全部客户机架构。 当前开放问题是:ABA 问题总体上会有多大影响?

当前方案:

TCG 提供 tcg_gen_atomic_* 辅助函数,可直接使用, 或组合模拟 Arm ldrex/strex 等指令。 虽然可能受 ABA 问题影响,常见客户机目前未使用会触发问题的模式, 通常暴露的是假定 cmpxchg 类似语义的加锁 ABI。

无法使用多线程原子操作时,例如客户机原子宽度大于主机, 会通过 EXCP_ATOMIC 退出,在独占锁下模拟指令,使全部模拟串行化。

当前辅助函数似乎够用,但未来可能需要更精确地建模客户机架构语义。