QEMU qmp 命令卡住问题分析与修复

 3个月前     488  

文章目录

最近遇到了 dummy qemu 进程 qmp 命令卡住的问题,发现社区 qemu 最新版本(10.x+)也存在该问题,便向社区提交了修复 patch,在此记录下问题分析过程与修复方案。

QEMU 环境如下:

  • Linux 6.6.87.2-microsoft-standard-WSL2 x86_64
  • QEMU emulator version 10.1.91 (v10.2.0-rc1-38-gfb241d0a1f),commit fb241d0a1f

问题现象

libvirt 使用 virsh version 或 virsh capabilities 命令去探测系统信息时,会启动一个 dummy qemu 进程,dummy qemu 启动后会监听 qmp 对应的 unix socket,然后 libvirt 再连接该 unix socket,并发送 qmp 命令获取系统相关信息。

dummy qemu 进程相应的命令行如下所示:

qemu-system-x86_64 -S -no-user-config -nodefaults -nographic -machine none,accel=kvm:tcg -qmp unix:/tmp/qmp-xxx/qmp.monitor,server=on,wait=off

问题现象是执行 virsh version 或 virsh capabilities 命令时卡住,也就是 qmp 命令没有返回,通过 gdb 和 nc 命令模拟复现的结果如下所示。

QEMU qmp 命令卡住问题分析与修复 QEMU qmp 命令卡住问题分析与修复

模拟复现方法步骤如下:

  1. gdb 启动 dummy qemu 进程(参考上述截图)
  2. monitor_init_qmp 处断点,然后执行完 monitor_data_init 即启用 io 线程后打开 scheduler-locking,这样可以让 io 线程停止运行,即不实际进行 qmp 初始化(模拟负载高时 io 线程处理慢,移除监听晚)
  3. qemu_accept 处断点,客户端模拟连接后会运行至该处
  4. 切换到 io 线程,同样运行至 qemu_accept
  5. 此时让主线程或 io 线程中的一个先执行完 qemu_accept,那么另外一个再执行时则会卡住
  6. 关闭 scheduler-locking,继续运行,客户端可以正常收到连接请求的响应内容
  7. 客户端再发送 qmp 命令,此时不管是前面让主线程或 io 线程卡住都会无法处理请求

具体原因分析如下。

问题分析

基于对 qemu 的 gdb 调试结果,确认 dummy qemu 卡住的原因为:io 线程初始化 qmp 监听时未及时移除主线程对 qmp fd 的监听,使得主线程和 io 线程同时监听同一个 qmp fd,从而产生竞态,导致 qmp 命令卡住。具体来说,触发 dummy qemu 卡住的基本流程如下:

1、dummy qemu 进程启动

  • 主线程初始化监听 qmp 对应 fd(qemu_chardev_new);
QEMU qmp 命令卡住问题分析与修复
  • 由于默认开启 io 线程,所以 io 线程会处理 qmp 监听任务,并移除主线程对 qmp 的监听(monitor_init_qmpmonitor_qmp_setup_handlers_bh);
QEMU qmp 命令卡住问题分析与修复
  • 由于 io 线程移除主线程对 qmp fd 的监听较晚(qio_net_listener_set_client_func_full 会移除已有监听并添加新的监听),导致存在一个较短的时间窗口【io 线程添加 qmp fd 监听到主线程更新监听 fd 之间】,主线程和 io 线程都会监听 qmp fd;

2、客户端发起连接

  • dummy qemu 的 io 线程(或主线程)首先监听到 fd 事件并调用 accept4 处理了对应监听的 fd 事件并添加新的监听请求,此时正常返回内容
QEMU qmp 命令卡住问题分析与修复 QEMU qmp 命令卡住问题分析与修复 QEMU qmp 命令卡住问题分析与修复
  • dummy qemu 的主线程(或 io 线程)也监听到 fd 事件,此时再次调用 accept4 时已无可读内容,导致主线程(或 io 线程)阻塞
QEMU qmp 命令卡住问题分析与修复
  • 注:主线程先处理则 io 线程卡,io 线程先处理则主线程卡

3、客户端连接成功后发送 qmp 命令

  • 当主线程卡住时, io 线程监听到新的请求后调用 tcp_chr_read 读取相关内容后将实际的处理函数 handle_qmp_command 加到队列中,等待协程唤醒后处理,但此时主线程卡住,协程无法唤醒,导致无法响应请求
QEMU qmp 命令卡住问题分析与修复
  • 当 io 线程卡住时,由于前面主线程处理连接事件时 fd 事件源绑定的 context 已经由主线程变成 io 线程(io 线程初始化 qmp 时 qio_net_listener_set_client_func_full 的处理),所以添加 tcp_chr_read 监听时主线程不会监听该新的可读事件,由 io 线程监听,但是 io 线程卡住,同样无法响应请求
QEMU qmp 命令卡住问题分析与修复

流程图如下所示,由 Gemini 生成,只做了简单修改。

QEMU qmp 命令卡住问题分析与修复

解决方案

由前面的分析可以发现以下两种处理方法:

1、单主线程和单 io 线程都可以完成 qmp 事件处理,所以最简单的方法是在 dummy qemu 场景下(machine none)不开启 io 线程,让主线程去处理 qmp 事件即可,示例伪代码如下。

# monitor/qmp.c

void monitor_data_init(Monitor *mon, bool is_qmp, bool skip_flush,
                       bool use_io_thread)
{
    if (use_io_thread && !mon_iothread) {
        monitor_iothread_init();
    }
    ...
}

// machine_is_none 初始化为 false,在 qemu_create_machine 中通过判断
// machine_class->name 是否为 none 来更新它的值
bool machine_is_none = False;

void monitor_init_qmp(Chardev *chr, bool pretty, Error **errp)
{
    MonitorQMP *mon = g_new0(MonitorQMP, 1);
    ...
    /* Note: we run QMP monitor in I/O thread when @chr supports that */
    // 添加 machine_is_none 判断,在 machine_is_none 为 false 时开启 io 线程
    // 在 machine_is_none 为 true 时则不开启 io 线程
    monitor_data_init(&mon->common, true, false,
                      qemu_chr_has_feature(chr, QEMU_CHAR_FEATURE_GCONTEXT)
                      && !machine_is_none);
    ...
}

2、问题本质上是由io 线程初始化 qmp 监听时未及时移除主线程对 qmp fd 的监听造成的,所以在 io 线程初始化 qmp 监听前就移除主线程对 qmp fd 的监听可以解决该问题,示例伪代码如下。

void socket_chr_listener_cleanup(Chardev *chr)
{
    SocketChardev *s = SOCKET_CHARDEV(chr);

    if (s->listener) {
        QIONetListener *listener = s->listener;
        size_t i;

        for (i = 0; i < listener->nsioc; i++) {
            if (listener->io_source[i]) {
                g_source_destroy(listener->io_source[i]);
                g_source_unref(listener->io_source[i]);
                listener->io_source[i] = NULL;
            }
        }
    }
}

void monitor_init_qmp(Chardev *chr, bool pretty, Error **errp)
{
    MonitorQMP *mon = g_new0(MonitorQMP, 1);
    ...
    /* Note: we run QMP monitor in I/O thread when @chr supports that */
    monitor_data_init(&mon->common, true, false,
                      qemu_chr_has_feature(chr, QEMU_CHAR_FEATURE_GCONTEXT));
    if (mon->common.use_io_thread) {
        remove_fd_in_watch(chr);

        // 在 io 线程初始化 qmp 监听前添加 socket_chr_listener_cleanup 
        // 移除主线程对 qmp fd 的监听
        socket_chr_listener_cleanup(chr);

        aio_bh_schedule_oneshot(iothread_get_aio_context(mon_iothread),
                                monitor_qmp_setup_handlers_bh, mon);
    } 
}

总的来说,第一种方法虽然可以解决问题,但比较粗暴,适用于 dummy qemu 这种场景,第二种方法则可以从根本上解决问题。

社区提交

在确认社区 qemu 最新版本(10.x+)也存在该问题后,决定向社区提交 patch,采用的是上述第二种方法。

V1、直接移除主线程对 qmp fd 的监听

这一版过于粗暴,就是上述第二种方法的实现。

社区大佬们指出了其中存在的两个关键问题:

  1. Daniel 指出未判断 chr 是否是 SocketChardev 类型,可能导致 crash,另外可以和remove_fd_in_watch(chr)一样考虑更通用的做法,而不只是针对 SocketChardev
  2. Eric 指出代码在 net-listener.c 之外直接访问 listener->nsioc,他提了个新的 patch 禁止这种做法,并提到可以通过使用 qio_net_listener_set_client_func_full 将回调函数设置为 NULL 来移除主线程对 qmp fd 的监听

V2、通过将回调设置为 NULL 来移除监听

这一版根据 Daniel 和 Eric 的意见进行了修改,判断了 chr 的类型并使用 qio_net_listener_set_client_func_full来移除主线程的监听。

@@ -537,6 +539,16 @@ void monitor_init_qmp(Chardev *chr, bool pretty, Error 
**errp)
          * e.g. the chardev is in client mode, with wait=on.
          */
         remove_fd_in_watch(chr);
+        /*
+         * Clean up SocketChardev listener IO sources early to prevent
+         * racy fd handling between the main thread and the I/O thread.
+         */
+        if (object_dynamic_cast(OBJECT(chr), TYPE_CHARDEV_SOCKET)) {
+            SocketChardev *s = SOCKET_CHARDEV(chr);
+            if (s->listener)
+                qio_net_listener_set_client_func_full(s->listener, NULL, NULL,
+                                                      NULL, chr->gcontext);
+        }

虽然比较简洁,但是不够优雅,没有采用通用的做法,这一版在社区无人回复。

V3、chardev 类添加清理 handler

这一版采用了更通用的做法,通过在 Chardev 类添加清理 handler 函数,并在 SocketChardev 实现清理函数来移除监听。

社区大佬 Marc-André 提到能否分享 qmp 卡住时的调用堆栈(直接邮件回复了),并指出了其中存在的 typo 小问题:

  • 一是 if 单行语句也加上大括号
  • 二是 listener 拼写错误(我写成了 listaner :))

此外,他还提到移除监听是否也放到 remove_fd_in_watch 中,但是这会影响所有调用到它的地方,所以目前还是 nevermind

V4、V3 版本问题修改

这一版就是修改了 V3 中 Marc-André 指出的问题。

目前还在等待回复中。

12月3日更新:社区 maintainer Markus 询问该 bug 是否严重以及 fix 是否安全,回复了生产环境该 bug 出现概率不高,但其存在时间较长,以及测试环境验证正常,但如果有更多人提出建议会更好。Markus 回复说会跟进到 11.0 版本,我也会持续关注,有问题及时修改。

2026年1月8日更新:已经合并了😊

补充

最近一位朋友反馈了社区之前也修复过一次 qmp 卡住的 bug,详情如下(流程图由 Gemini 生成),是特定场景下 qmp 处理队列满了挂起后没有正确恢复导致的,在此也记录下。

QEMU qmp 命令卡住问题分析与修复 QEMU qmp 命令卡住问题分析与修复
  1. qmp 处理队列满了(mon->qmp_requests->length == QMP_REQ_QUEUE_LEN_MAX - 1)之后调用monitor_suspend挂起(handle_qmp_command);
  2. 出现 CHR_EVENT_CLOSED 事件,调用monitor_qmp_cleanup_queues清空队列(monitor_qmp_event),但是没有resume;
  3. monitor_qmp_bh_dispatcher中resume时已经不满足条件(队列空了,req_obj为空);
  4. 一直处于挂起状态,可以返回 greeting,但是无法正常处理 qmp 请求。
  5. 通过在 monitor_qmp_cleanup_queues 中清空队列后添加 resume 操作进行修复。

monitor/qmp: resume monitor when clearing its queue

When a monitor's queue is filled up in handle_qmp_command()
it gets suspended. It's the dispatcher bh's job currently to
resume the monitor, which it does after processing an event
from the queue. However, it is possible for a
CHR_EVENT_CLOSED event to be processed before before the bh
is scheduled, which will clear the queue without resuming
the monitor, thereby preventing the dispatcher from reaching
the resume() call.
Any new connections to the qmp socket will be accept()ed and
show the greeting, but will not respond to any messages sent
afterwards (as they will not be read from the
still-suspended socket).
Fix this by resuming the monitor when clearing a queue which
was filled up.

总结

总之,第一次给 qemu 上游社区做贡献,还是很开心的😊。

此外,由衷感谢社区大佬们的指点,他们能一针见血地指出问题所在并提供可行的建议,让我在这种迭代优化过程中收获很多。

最后,虽然整个周期有些漫长,但还是希望这个 patch 最终能被接受(已接受)。

版权声明:小傅 发表于 3个月前,共 7014 字。
转载请注明:QEMU qmp 命令卡住问题分析与修复 | 太傅博客

暂无评论

暂无评论...