前言

QEMU 采用了基于事件驱动的架构,其事件循环机制基于 glib 实现,因此 QEMU 中有大量的概念来源于 glib,在深入分析 QEMU 事件循环机制之前先了解 glib 事件循环的基本原理有助于增进我们对 QEMU 事件循环机制的理解,本文将基于 glib v2.25.7 源码介绍 glib 事件循环机制。

概述

glib 是一个跨平台的、使用 C 语言编写的若干底层库的集合,它实现了完整的事件循环分发机制,在这个机制中有一个主事件循环负责管理所有可用事件源。这些事件可以来自多种不同类型的源,如文件描述符(文件、管道或套接字)和超时,我们还可以通过调用 g_source_attach() 函数添加新类型的事件源。

glib 使用 GMainLoop 结构体来表示一个主事件循环,一个 GMainLoopg_main_loop_new() 函数创建,添加初始事件源后,调用 g_main_loop_run() 函数运行,该函数将持续检查每个事件源的新事件,并将其派发出去,直到处理完其中一个事件源的事件后,调用 g_main_loop_quit() 函数退出主循环,g_main_loop_run() 函数才会返回。

GMainContext 用于表示主事件循环的上下文,每一个 GMainLoop 都有一个对应的上下文 GMainContext,它封装了事件循环的状态和行为。事件源使用 GSource 表示,每个 GSource 可以关联多个文件描述符,为了能够在不同线程中处理多组独立的事件源,每个 GSource 会关联到一个 GMainContext,而一个 GMainContext 可以关联多个 GSource,具体关系如下图所示。需要注意的是,GMainContext 只能在单线程中运行,但是事件源可以由其他线程添加或删除。因此,所有对 GMainContext 以及内置的 GSource 进行操作的函数都是线程安全的。

glib 中每个事件源都有一个优先级,默认优先级 G_PRIORITY_DEFAULT 为 0,大于 0 表示优先级较低。来自高优先级事件源的事件总是先于来自低优先级事件源的事件得到处理。此外,还可以添加空闲函数(idle functions),并为其分配优先级,当没有来自更高优先级事件源的事件需要处理时,空闲函数就会运行。

状态机

glib 的核心是 poll 机制,通过 poll 检查用户注册的事件源,并执行对应的回调,用户不需要关注其具体实现,只需要按照要求注册对应的事件源和回调。

glib 事件循环机制管理所有注册的事件源,不同事件源可以在一个线程中处理,也可以在不同线程中处理,这取决于事件源所在的上下文,一个上下文只能运行在一个线程中,如果想要事件源在不同线程中并发被处理,可以将其放在不同的上下文中。

glib 中一次主循环分为 4 个阶段:prepare、query、check 和 dispatch,其状态转移如下图所示:

上述状态机中的四个阶段分别对应以下四个函数:

  • prepare:gboolean g_main_context_prepare(GMainContext *context, gint *priority)

    该函数接受两个参数:

    • context: 主循环的上下文,若为 NULL,则使用默认上下文
    • priority: 用于返回准备好的事件源的最高优先级

    函数执行过程中会循环遍历所有事件源,调用相应事件源的 prepare 函数检查事件源是否准备好被分派。如果准备好了,设置相应事件源的 G_SOURCE_READY 标志。此外,该函数还会计算下一个超时事件的时间。如果存在准备好的事件源,超时将被设置为 0,函数返回 TRUE,否则待超时后直接返回 FALSE

  • query:gint g_main_context_query(GMainContext *context, gint max_priority, gint *timeout_, GPollFD *fds, gint n_fds)

    该函数接受五个参数:

    • context: 主循环的上下文
    • max_priority: 要查询的最大优先级
    • timeout: 返回下一个超时的时间
    • fds: 用于存储查询结果的文件描述符数组
    • n_fds: 文件描述符数组的大小

    函数执行过程中会遍历上下文中的轮询记录,将每个记录转换为 GPollFD 结构,并存储在 fds 数组中,其中数组的大小由 n_fds 指定,如果轮询记录的数量超过 n_fds,则多余的记录将被忽略。如果 timeout 参数不为 NULL,则函数会将上下文的超时值复制到 timeout 指向的位置。如果超时不为 0,则设置 context->time_is_currentFALSE。最后,函数将返回填充到 fds 数组中的轮询记录数量。该函数将主循环上下文中的轮询记录转换为可以传递给底层轮询系统调用的格式,使得主循环可以等待多个事件源上的事件。

  • check:gint g_main_context_check(GMainContext *context, gint max_priority, GPollFD *fds, gint n_fds)

    该函数接受四个参数:

    • context: 主循环的上下文
    • max_priority: 要检查的最大优先级
    • fds: 包含轮询结果的文件描述符数组
    • n_fds: 文件描述符数组的大小

    函数执行过程中会遍历上下文中的轮询记录,并使用 fds 数组中的结果更新每个记录的 revents 字段。然后遍历上下文中的事件源,调用每个事件源的 check 函数来确定是否准备好。如果事件源准备好,则将其添加到待调度列表中,并更新准备好的事件源源的数量和最大优先级。最后,如果有一个或多个事件源准备好,则函数返回 TRUE,否则返回 FALSE。该函数保证了主循环能够根据事件源的优先级和准备状态来进行调度。

  • dispatch:void g_main_context_dispatch (GMainContext *context)

    该函数的主要负责调度主循环上下文中准备好的事件源,一般在调用 g_main_context_check 之后调用,确保事件源在准备好时得到适当的处理。在调度过程中,每个事件源的 dispatch 函数将被调用,以执行与事件源关联的特定操作。

自定义事件源

GMainLoop 除了内置的事件源类型外,还允许创建和使用自定义的事件源类型。自定义事件源必须将 glib 事件源作为"基类",具体到实现上就是把 GSource 作为自定义事件源结构体的第一个成员,在创建事件源时调用 g_source_new() 函数并以参数的形式传入自定义事件源结构体的大小和函数表 GSourceFuncs

自定义事件源一般通过两种方式与 GMainContext 进行交互。GSourceFuncs 函数表中的准备函数可以设置超时,以确定主循环在再次检查事件源之前的最长休眠时间。此外,自定义事件源还可以调用 g_source_add_poll() 函数将文件描述符添加到 GMainContext 的检查集合中。

代码示例

以上介绍了 glib 事件循环的一般流程以及自定义事件源的方法,我们需要做的就是将新的 GSource 加入其中,glib 会负责处理 GSource 上注册的各种事件源。glib 中有两个函数,分别实现将 GSource 加入到 GMainContext 和将 fd 加入到 GSource 的功能:

  • g_source_attach():source->poll_fds 中的文件描述符加入到 context->poll_records 中;source 添加到 contextsource 链表中
  • g_source_add_poll():fd 加入到 source->poll_fds 中,然后再加入到 context->poll_records 中,设置 context->poll_changedTRUE

下面将通过两个代码示例来具体说明,在使用 glib 库之前,首先使用以下命令安装开发包:

sudo apt install libglib2.0-dev

然后通过 pkg-config 工具查看 gcc 编译需要链接的动态库以及头文件:

$ pkg-config --libs glib-2.0
-lglib-2.0
$ pkg-config --cflags glib-2.0
-I/usr/include/glib-2.0 -I/usr/lib/x86_64-linux-gnu/glib-2.0/include

打印标准输入中的内容长度

本例通过注册自定义事件源回调函数实现了打印标准输入中读到的内容长度,具体代码如下:

#include <glib.h>

gboolean io_watch(GIOChannel *channel, GIOCondition condition, gpointer data)
{
    gsize len = 0;
    gchar *buffer = NULL;

    g_io_channel_read_line(channel, &buffer, &len, NULL, NULL);

    if (len > 0)
    {
        g_print("%d\n", len);
    }

    g_free(buffer);
    return TRUE;
}

int main(int argc, char *argv[])
{
    GMainLoop *loop = g_main_loop_new(NULL, FALSE);
    GIOChannel *channel = g_io_channel_unix_new(1);

    if (channel)
    {
        g_io_add_watch(channel, G_IO_IN, io_watch, NULL);
        g_io_channel_unref(channel);
    }

    g_main_loop_run(loop);
    g_main_context_unref(g_main_loop_get_context(loop));
    g_main_loop_unref(loop);

    return 0;
}

上述代码中首先定义了回调函数 io_watch 用于实现“打印标准输入中读到内容的长度”的功能。在程序的主函数中,首先通过 g_main_loop_new 函数获取一个上下文的事件循环实例,接着调用 g_io_channel_unix_new 函数将标准输入描述符转化成 GIOChannel 以便操作,然后将针对 channel 事件源的回调函数 io_watch 注册到默认上下文,告诉 glib 需要关注 channel 的输入 G_IO_IN,当输入准备好之后,调用自己注册的回调函数,并传入参数 NULL。在 main 函数的最后,执行执行默认上下文的事件循环。

使用如下命令编译上述代码:

cc `pkg-config --cflags glib-2.0` demo1.c -o demo1 `pkg-config --libs glib-2.0`

在终端运行 demo1 程序所得结果如下:

$ ./demo1
hello world
12
glib event loop
16
^C

打印指定输入源中的内容长度

本例在上一例的基础上更进一步,添加了自定义事件源和自定义状态机回调函数,实现了打印指定输入源中读取的内容长度,具体代码如下:

#include <glib.h>

typedef struct _MySource
{
    GSource _source;
    GIOChannel *channel;
    GPollFD fd;
} MySource;

static gboolean watch(GIOChannel *channel)
{
    gsize len = 0;
    gchar *buffer = NULL;

    g_io_channel_read_line(channel, &buffer, &len, NULL, NULL);
    if (len > 0)
    {
        g_print("%d\n", len);
    }
    g_free(buffer);

    return TRUE;
}

static gboolean prepare(GSource *source, gint *timeout)
{
    *timeout = -1;
    return FALSE;
}

static gboolean check(GSource *source)
{
    MySource *mysource = (MySource *)source;

    if (mysource->fd.revents != mysource->fd.events)
    {
        return FALSE;
    }

    return TRUE;
}

static gboolean dispatch(GSource *source, GSourceFunc callback, gpointer user_data)
{
    MySource *mysource = (MySource *)source;

    if (callback)
    {
        callback(mysource->channel);
    }

    return TRUE;
}

static void finalize(GSource *source)
{
    MySource *mysource = (MySource *)source;

    if (mysource->channel)
    {
        g_io_channel_unref(mysource->channel);
    }
}

int main(int argc, char *argv[])
{
    GError *error = NULL;
    GMainLoop *loop = g_main_loop_new(NULL, FALSE);
    GSourceFuncs funcs = {prepare, check, dispatch, finalize};
    GSource *source = g_source_new(&funcs, sizeof(MySource));
    MySource *mysource = (MySource *)source;

    if (!(mysource->channel = g_io_channel_new_file("test", "r", &error)))
    {
        if (error != NULL)
        {
            g_print("Unable to get test file channel: %s\n", error->message);
        }
        return -1;
    }
    mysource->fd.fd = g_io_channel_unix_get_fd(mysource->channel);
    mysource->fd.events = G_IO_IN;

    g_source_add_poll(source, &mysource->fd);
    g_source_set_callback(source, (GSourceFunc)watch, NULL, NULL);
    g_source_set_priority(source, G_PRIORITY_DEFAULT_IDLE);
    g_source_attach(source, NULL);
    g_source_unref(source);

    g_main_loop_run(loop);
    g_main_context_unref(g_main_loop_get_context(loop));
    g_main_loop_unref(loop);

    return 0;
}

上述代码首先定义了自定义事件源 MySource 以及事件源回调函数 watch,实现了“读出 iochannel 中内容并打印其长度”的功能。接着,代码定义了四个状态机回调函数:

  • prepare: 状态机 prepare 阶段的回调函数,timeout 等于 -1 告诉 poll 如果 IO 没有准备好,一直等待,即阻塞 IO。函数返回 FALSE 表示需要 poll 来检查事件源是否准备好,返回 TRUE 则表示跳过 poll
  • check: 状态机 check 阶段的回调函数,检查自己感兴趣的 fd 状态是否准备好,用户通过设置 events 标志设置感兴趣的 fd 状态(包括文件可读、可写、异常等),revents 是 poll 的返回值,由内核设置,表明 fd 哪些状态是准备好的。当感兴趣的状态和 poll 返回的状态不相同,则表示 fd 没有准备好,函数返回 FALSE,glib 不发起调度,反之函数返回 TRUE,glib 发起调度
  • diapatch: 状态机 dispatch 阶段的回调函数,只要函数 preparecheck 中有一个返回 TRUE,glib 就会直接调用此接口,执行用户注册的回调函数
  • finalize: 当事件源不再被引用时的回调函数

下面分析本例代码中主函数的执行逻辑:首先进行有关变量的初始化,包括从默认上下文获取事件循环实例、声明自定义的状态机回调函数以及定义自定义事件源。接着,创建一个文件类型的 GIOChannel,获取 GIOChannelfd,放到 GPollFDfd 域中,并设置感兴趣的文件状态,本例中为文件可读状态。这里 GIOChannel 是 glib 对文件描述符的封装,以实现平台可移植性,其 fd 类型可以是文件、管道或套接字。最终传给 poll 的文件描述符结构体如下:

struct GPollFD {
      gint        fd;            // 文件描述符
      gushort     events;        // 感兴趣的文件状态
      gushort     revents;    // 返回值,由内核设置
};

然后,程序将文件描述符添加到事件源中,设置事件源的回调函数和优先级,当位于同一个上下文中的多个事件源同时处于准备好了的状态时,优先级高的事件源会被 glib 优先调度。最后,glib 开始执行默认上下文的事件循环。

使用如下命令编译上述代码:

cc `pkg-config --cflags glib-2.0` demo2.c -o demo2 `pkg-config --libs glib-2.0`

在终端直接运行 demo2 程序所得结果如下:

$ ./demo2
Unable to get test file channel: No such file or directory

打开另一终端窗口,新建 test 文件,在前一窗口中再次运行 demo2 程序,然后在当前窗口向 test 文件中写入内容:

$ touch test
$ echo "hello world" > test
$ echo "RISC-V Linux" >> test

可在前一窗口中看到如下运行结果:

$ ./demo2
12
13
^C

总结

本文为分析 QEMU 事件循环机制补充了前置知识,基于 glib v2.25.7 源码介绍了 glib 事件循环机制的基本原理,梳理了主要数据结构之间的关系,详细描述了 glib 事件循环的状态转移图,并通过两个具体的代码示例进一步说明了 glib 事件循环机制的运行流程。在下一篇文章中,我们将详细分析 QEMU 事件循环机制。

参考资料