热迁移架构分析(postcoy)

热迁移架构分析(postcoy)
最近分析了qemu关于热迁移方面的实现,从qemu-2.12.1版本在热迁移方面的实现来看,其支持的迁移方式并不单一,而且可利用的特性也比较多,这可以执行virsh help migrate命令查看,比如,支持precopy、postcopy、postcopy-after-precopy等迁移模式,支持页的压缩及压缩算法、压缩level、多线程压缩、指定超时等等。这里简单说一下迁移的几种方式:
Precopy:precopy策略在迁移开始后, 在将内存状态拷贝到目的节点的同时, 源物理主机上的虚拟机仍在继续运行. 如果一个内存在拷贝之后再次被修改(dirtied), 那么将会在后续迁移过程中再次发送到目的节点. 整个内存拷贝是个迭代的过程. 直到最后剩余的脏页内存数量可以在一个短时间内迁移到目的主机时,才停止源虚拟机,迁移剩余内存页和虚拟机状态,启动目的虚拟机;
Postcopy:与precopy不同, postcopy首先把CPU状态和设备状态等复制到目的主机上,在目的主机上开启虚拟机, 然后源主机继续将剩余的内存页推送到目的主机上的虚拟机中. 与此同时, 目的主机上的虚拟机运行时可能会访问到不存在页面(还未被推送)而发生缺页异常, 虚拟机将向源主机发起请求传输相应的页,目的端在获取到相关页后继续pagefault的后续处理. 可以看到postcopy方式对每个内存页面只会传送一次, 降低了预拷贝中冗余拷贝的开销。
Postcopy-after-precopy:根据以上介绍, 我们知道Pre-Copy适用于内存read-intensive的虚拟机热迁移, 对于write-intensive的虚拟机, 在Pre-Copy的每轮迭代中都会有大量新的内存页被修改, 导致迁移的效率低下(当然也可以通过某种手段来控制脏页产生的速度,比如控制vcpu执行的时间,这个在本文后续章节会单独介绍). 对于Post-Copy则相反, 对于read-intensive的虚拟机, Post-Copy将会产生大量的缺页异常, 导致虚拟机内应用性能的降低. Post-Copy更适用于内存比较大, 内存write-intensive的虚拟机热迁移。为了使热迁移更具有一般性, 通用性, 一种自然的想法是将Pre-Copy和Post-Copy结合起来.具体过程如下:

  1. 采用Pre-Copy进行初次迭代, 将虚拟机的全部内存页拷贝到目的主机, 与此同时, 虚拟机在源主机上继续运行;
  2. 当第一轮拷贝结束后, 虚拟机被暂停, 其处理器状态和设备状态信息等拷贝到目的主机;
  3. 在目的主机上恢复虚拟机, 开始使用Post-Copy模式继续虚拟机迁移。
    本文主要分析postcopy模式,但由于qemu在实现虚拟机迁移的时候无论哪种模式都是在相同的架构下实现,所以只要一种模式分析清楚,其他的也就不难理解了。这里还需要说明一点,qemu中关于热迁移的代码中会根据当前迁移所具备的capability来做不同的迁移优化操作,比如是否启用线程压缩页等,这些都是通过参数指定的,具体在代码分析的时候再看。
    Qemu的迁移由发送端(源端)和接收端(目的端)构成,相应的源端的qemu和目的端qemu会分别做save和load虚拟机的操作,其大致示意图如下图所示,可以看到,在虚拟机的最小状态迁移奥目的主机后,便可以启动虚拟机运行。

发送端(src):
发送端核心线程主要有两个(不包括页压缩线程):
hmp_migrate->
qmp_migrate->
tcp_start_outgoing_migration->
socket_start_outgoing_migration->
socket_outgoing_migration->
migration_channel_connect->
migrate_fd_connect
在migrate_fd_connect中会注册迁移完成后的清理函数migrate_fd_cleanup以及起两个线程:
live_migration:负责主体迁移工作;
return path:负责处理dst对src发送的信息的响应信息和src的请求信息(如缺页异常等);
Live_migration线程的主体如下(省略了部分不重要的代码)
static void *migration_thread(void *opaque)
{
s->iteration_start_time = qemu_clock_get_ms(QEMU_CLOCK_REALTIME);

qemu_savevm_state_header(s->to_dst_file);
if (s->rp_state.from_dst_file) {
      qemu_savevm_send_open_return_path(s->to_dst_file);
}

if (migrate_postcopy()) {
    qemu_savevm_send_postcopy_advise(s->to_dst_file);
}

/遍历savevm_state中涉及的设备,调用对应的save_setup回调函数在dst建立对应设备基 本的信息,比如ram:向dst发送内存ram使用量、各个block的描述和使用量、block中* 的页大小等,如果具备MIGRATION_CAPABILITY_COMPRESS,则创建压缩内存页的线程(线 * 程函数:do_data_compress)
*/
qemu_savevm_state_setup(s->to_dst_file);
s->setup_time = qemu_clock_get_ms(QEMU_CLOCK_HOST) - setup_start;
/迁移进入MIGRATION_STATUS_ACTIVE状态/
migrate_set_state(&s->state, MIGRATION_STATUS_SETUP,
MIGRATION_STATUS_ACTIVE);
/*以下是迁移的核心处理部分:

  • 在遵守迁移带宽限制的情况下迁移虚拟机
    */
    while (s->state == MIGRATION_STATUS_ACTIVE ||
    s->state == MIGRATION_STATUS_POSTCOPY_ACTIVE) {
    int64_t current_time;

      if (!qemu_file_rate_limit(s->to_dst_file)) {
          MigIterateState iter_state = migration_iteration_run(s);
          if (iter_state == MIG_ITERATE_SKIP) {
              continue;
          } else if (iter_state == MIG_ITERATE_BREAK) {
              break;
          }
      }
      current_time = qemu_clock_get_ms(QEMU_CLOCK_REALTIME);
    

/这里动态的估算了当前迁移带宽大小及如果是precopy模式,根据指定的最小停机时间计 算the final stage需要传输的阈值,如果超过该值则不能停止当前虚拟机,否则停止虚拟 * 机进行最后的迁移*/
migration_update_counters(s, current_time);
if (qemu_file_rate_limit(s->to_dst_file)) {
/* usleep expects microseconds */
g_usleep((s->iteration_start_time + BUFFER_DELAY -
current_time) * 1000);
}
}
}
可以看到,迁移时支持限速功能,当BUFFER_DELAY时间内的流量值超过了阈值,则需要根据当前在BUFFER_DELAY内所占用的情况休眠剩余的时间值。而在正常的迁移流程下会进入migration_iteration_run做迭代迁移操作:
static MigIterateState migration_iteration_run(MigrationState *s)
{
uint64_t pending_size, pend_pre, pend_compat, pend_post;
bool in_postcopy = s->state == MIGRATION_STATUS_POSTCOPY_ACTIVE;
/*获取剩下需要迁移的dirty page数量,根据是pre、非pre迁移分别保存到pend_pre、

  • pend_compat中*/
    qemu_savevm_state_pending(s->to_dst_file, s->threshold_size, &pend_pre,
    &pend_compat, &pend_post);
    pending_size = pend_pre + pend_compat + pend_post;
    /dirty page还没有小于阈值,继续正常的迁移流程/
    if (pending_size && pending_size >= s->threshold_size) {
    /*对于postcopy,由于此时还处在MIGRATION_STATUS_ACTIVE状态,首次执行的时

  • 候触发该流程 */
    if (migrate_postcopy() && !in_postcopy &&
    pend_pre <= s->threshold_size &&
    atomic_read(&s->start_postcopy)) {
    if (postcopy_start(s)) {
    error_report(“%s: postcopy failed to start”, func);
    }
    return MIG_ITERATE_SKIP;
    }
    /正常的迁移迭代/
    qemu_savevm_state_iterate(s->to_dst_file,
    s->state == MIGRATION_STATUS_POSTCOPY_ACTIVE);
    } else {
    /最后一小部分内容的迁移/
    migration_completion(s);
    return MIG_ITERATE_BREAK;
    }
    return MIG_ITERATE_RESUME;
    }
    在migration_iteration_run中每次进入迭代之前,总是调用qemu_savevm_state_pending获取当前需要迁移的page数量(如果涉及块设备的迁移,还会包括块设备需要迁移的大小)。剩余需要迁移的量小于在migration_update_counters中根据当前传输带宽情况预估的阈值时,则调用migration_completion完成最终的迁移。这里首先分析postcopy_start部分,该部分是将迁移转换到postcopy模式,由于postcopy_start代码较长,但逻辑设计并不复杂,所以这里只给出其实现的基本流程:
    1、如果虚拟机不是处于running状态,则需要先唤醒;
    2、暂停当前虚拟机,因为postcopy模式会立刻在dst端启动运行虚拟机,不需要src端虚拟机继续运行;
    3、inactive块设备;
    4、同步内核态所有的vcpu状态到qemu中各vcpu的CPUState中保存,对于不支持postcopy的设备(如在开启了block设备迁移的情况下),调用其save_live_complete_precopy方法将其相关头部元数据先发送到dst;
    5、对于postcopy,在precopy后需要告知dst哪些页需要被discard;
    6、通过wrap设备状态信息到一个bioc buffer,并一次性发送到dst,其中包括MIG_CMD_POSTCOPY_LISTEN、MIG_CMD_POSTCOPY_RUN命令,需要特别注意的是这里将MIG_CMD_POSTCOPY_LISTEN、MIG_CMD_POSTCOPY_RUN捆绑在一起作为一条信息发送到dst,这一点很重要,会为dst端qemu主线程退出热迁移逻辑,创建单独线程接替qemu主线程继续热迁移后续工作创造条件;
    7、通知spice做相应的状态切换;
    8、通过madvice和fallocate释放RAMBlock bmap中bit位为0对应的page,释放部分内存,这主要是考虑src端内存紧张的情况;
    接下来qemu_savevm_state_iterate进入正式的热迁移迭代,其实该函数可简化为如下流程:
    int qemu_savevm_state_iterate(QEMUFile *f, bool postcopy)
    {
    。。。。。。。。。。。。。。
    QTAILQ_FOREACH(se, &savevm_state.handlers, entry) {
    /在设备支持迁移迭代且active、active_iterate的情况下/
    if (postcopy &&
    !(se->ops->has_postcopy && se->ops->has_postcopy(se->opaque))) {
    continue;
    }
    if (qemu_file_rate_limit(f)) {
    return 0;
    }
    save_section_header(f, se, QEMU_VM_SECTION_PART);

      ret = se->ops->save_live_iterate(f, se->opaque);
      trace_savevm_section_end(se->idstr, se->section_id, ret);
      save_section_footer(f, se);
    

/*这里遵循的原则是先迁移完一个vmstate,再继续下一个,这样做的好处是避免一些

  • 频繁被更改的数据被反复的迁移,比如在迁移的时候如果先迁移了部分块设备,又去
  • 迁移部分内存,这样会导致块设备和内存已迁移的数据又被写脏,存在重复迁移相同
  • 位置数据的情况,当然这主要针对precopy或postcopy after precopy的情况,毕竟
  • postcopy方式虚拟机已暂停,不过这样看来postcopy方式也是各个vmstate完成迁移
  • 后再进行的下一项 */
    if (ret <= 0) {
    break;
    }
    。。。。。。。。。。。。。
    }
    这里主要针对各个设备在支持save_live_iterate方法的情况下反复迭代,这样实现的目的是让各个设备单独实现自己的状态迭代函数,具体的实现后面结合ram的save_live_iterate做代表性的分析。同时需要注意后面有个判断ret如果不大于0则break,后续继续迭代该设备,这样确保在迁移完一个设备的数据后再继续下一项,至于这样做的好处,见上面代码处的分析。
    继续回到migration_iteration_run函数,在迭代迁移完成后,说明此时只剩下少部分数据可一次性迁移完成,便可调用migration_completion处理这部分工作,针对postcopy流程,可简化为如下实现:
    static void migration_completion(MigrationState *s)
    {
    int ret;
    int current_active_state = s->state;
    /postcopy,主要针对设备发送QEMU_VM_SECTION_END命令和剩下的少量页内容/
    if (s->state == MIGRATION_STATUS_POSTCOPY_ACTIVE) {
    qemu_savevm_state_complete_postcopy(s->to_dst_file);
    }
    if (s->rp_state.from_dst_file) {
    rp_error = await_return_path_close_on_source(s);
    }
    }
    Migration_completion实现方式同迭代迁移,回调各个设备的save_live_complete_postcopy完成最终的迁移工作,在完成迁移后dst端会向src端发送退出return path线程的命令,所以这里等待return path线程退出,表示dst迁移完成。当这一切都完成后,migration_thread线程来到了最后,调用migration_iteration_finish做最后的清理工作:主要是更新时间统计、占用带宽统计、迁移状态更新(RUN_STATE_POSTMIGRATE)及热迁移下半部分清理工作,该清理工作主要是migrate_fd_cleanup来完成,此函数的注册在谈及热迁移触发流程时有说到,在函数migrate_fd_connect中进行的注册。而migrate_fd_cleanup负责等待迁移线程的结束并关闭相关链接,notify migration state以及在开启块设备迁移的情况下关闭其迁移和增量迁移功能。

接下来我们再看看src端热迁移的另一个线程:return path,其创建流程为:
hmp_migrate->
qmp_migrate->
tcp_start_outgoing_migration->
socket_start_outgoing_migration->
socket_outgoing_migration->
migration_channel_connect->
migrate_fd_connect->
open_return_path_on_source
Return path线程主要负责接收来自dst的pagefault请求或相应返回值,其线程主体函数为:source_return_path_thread,主要处理dst对src发送的信息的响应信息和src的请求信息(如缺页异常的页请求),Return path线程大体内容如下:
static void *source_return_path_thread(void *opaque)
{
。。。。。。。。。。。。
while (!ms->rp_state.error && !qemu_file_get_error(rp) &&
migration_is_setup_or_active(ms->state)) {
header_type = qemu_get_be16(rp);/来自dst的信息/
header_len = qemu_get_be16(rp);

    /*接收指定大小的data*/
    res = qemu_get_buffer(rp, buf, header_len);

    switch (header_type) {
    /*dst告知src不会再发送消息,src可以结束当前线程*/
    case MIG_RP_MSG_SHUT:
        sibling_error = ldl_be_p(buf);
        goto out;

    /*对src ping dst的回应,主要用于调试*/
    case MIG_RP_MSG_PONG:
        tmp32 = ldl_be_p(buf);
        break;

    /*请求页(start: be64, len: be32)*/
    case MIG_RP_MSG_REQ_PAGES:
        start = ldq_be_p(buf);
        len = ldl_be_p(buf + 8);
        /*这里的rbname参数为NULL,表示Reuse last RAMBlock*/
        migrate_handle_rp_req_pages(ms, NULL, start, len);
        break;

    /*请求页,这里不同的是请求指定RAMBlock的对应页*/
    case MIG_RP_MSG_REQ_PAGES_ID:
        expected_len = 12 + 1; /* header + termination */

        if (header_len >= expected_len) {
            start = ldq_be_p(buf);
            len = ldl_be_p(buf + 8);
            /* Now we expect an idstr */
            tmp32 = buf[12]; /* Length of the following idstr */
            buf[13 + tmp32] = '\0';
            expected_len += tmp32;
        }
        migrate_handle_rp_req_pages(ms, (char *)&buf[13], start, len);
        break;

    default:
        break;
    }
}

out:
ms->rp_state.from_dst_file = NULL;
qemu_fclose(rp);
return NULL;
}
可以看到,return path线程所做的工作很清晰和简单,主要就是对四种类型信息的响应:
MIG_RP_MSG_PONG:由src migration线程ping dst,dst对ping请求的回应,主要用于调试;
MIG_RP_MSG_SHUT:在分析migration线程的时候已经提到,在迁移完成后,dst端会发送 shutdown命令给src端,也就是这里由return path线程,接收到该 命令后会退出线程;
MIG_RP_MSG_REQ_PAGES_ID:请求指定RAMBlock中的页;
MIG_RP_MSG_REQ_PAGES:请求页(start: be64, len: be32),这里没有指定RAMBlock id,表示 Reuse last RAMBlock;
这里关于特定页请求的处理和在dst端是如何发起缺页请求的整个流程,放在后续分析postcopy模式下的缺页异常时统一分析。
到现在为止,src端迁移的大概流程已经梳理完,涉及的页压缩线程等相关知识点,暂不分析,接下来分析dst端接收线程的处理逻辑。

接收端(dst):
这里以走tcp协议进行热迁移为例,在迁移之前,dst端将以migration-listen模式启动一个参数值和src端完全相同的qemu进程,例如:
-incoming tcp:0:4444 (or other PORT))
这样src端利用virsh或libvirt接口便可触发迁移:
migrate -d tcp:$(dst ip):4444 (or other PORT)
结合qemu的incoming参数可知,接收端的逻辑处理为:
Main->
qemu_start_incoming_migration->
tcp_start_incoming_migration->
socket_start_incoming_migration->
socket_accept_incoming_migration->
migration_channel_process_incoming->
migration_ioc_process_incoming->
migration_fd_process_incoming->
migration_incoming_process->
process_incoming_migration_co
以上便是从qemu的main函数接收incoming参数出发,到处理热迁移接收的主要流程,这之间的主要工作是和src端建立连接,以协程的方式启动热迁移接收。
在不考虑colo和错误处理等情况下,Process_incoming_migration_co可简化为:
static void process_incoming_migration_co(void *opaque)
{
MigrationIncomingState *mis = migration_incoming_get_current();
PostcopyState ps;
int ret;

mis->largest_page_size = qemu_ram_pagesize_largest();
postcopy_state_set(POSTCOPY_INCOMING_NONE);
migrate_set_state(&mis->state, MIGRATION_STATUS_NONE,
                  MIGRATION_STATUS_ACTIVE);/*状态切换*/

/接收端的主体部分/
ret = qemu_loadvm_state(mis->from_src_file);
/迁移后接收端的清理工作/
ps = postcopy_state_get();
if (ps != POSTCOPY_INCOMING_NONE) {
if (ps == POSTCOPY_INCOMING_ADVISE) {
/*
* Where a migration had postcopy enabled (and thus went to advise)
* but managed to complete within the precopy period, we can use
* the normal exit.
/
postcopy_ram_incoming_cleanup(mis);
} else if (ret >= 0) {
/
对于postcopy来讲,会走该分支,由创建的”postcopy/listen”线程来继续

  • 迁移并完成最后的清理工作
    * Postcopy was started, cleanup should happen at the end of the
    * postcopy thread.
    */
    return;
    }
    }
    mis->bh = qemu_bh_new(process_incoming_migration_bh, mis);
    qemu_bh_schedule(mis->bh);
    }
    这里涉及到迁移状态的切换,由MIGRATION_STATUS_NONE变为了MIGRATION_STATUS_ACTIVE,由qemu_loadvm_state负责迁移的主体部分,之后是一些清理工作,这些内容都将一一分析到,先来看看主体部分.
    Qemu_loadvm_state和src端的发送流程基本对应,主要有如下几方面工作:
    1、判断当前虚拟机设备是否有不能迁移的,如果有则报错,这和src端判断是否有设备可迁移相对应;
    2、判断接收的QEMU_VM_FILE_MAGIC、QEMU_VM_FILE_VERSION_COMPAT、QEMU_VM_FILE_VERSION等;
    3、针对支持load_setup的设备(当前只有ram支持),建立接收端的准备工作,针对内存来说:分配xbzrle的decode buf;如果src端采用页面压缩传输,那么dst端需要创建对应的解压缩线程;针对每个RAMBlock分配对应的记录已接收页面bitmap内存区;
    4、加载vm的配置信息;
    5、将vcpu状态的vcpu_dirty置为true,表示当前vcpu在运行时需要先加载用户态对应寄存器和状态到内核态才能继续运行;
    6、调用qemu_loadvm_state_main函数完成src发出的命令请求和接收、建立各个设备的基本信息等,并创建”postcopy/listen”线程接替qemu主线程继续热迁移的接收操作,腾出qemu主线程执行虚拟机正常逻辑,这里创建专门的线程来接替qemu主线程完成迁移是由于在qemu主线程加载一些设备状态信息的时候可能导致缺页异常,这样可通过”postcopy/listen”线程处理缺页,而主线程继续完成设备加载和后续正常的操作流程;
    7、在第6步qemu_loadvm_state_main中执行来自src的MIG_CMD_POSTCOPY_RUN命令后,qemu_loadvm_state退出,直到退回qemu main主线程处开始正常的执行流程。
    由于在qemu_loadvm_state函数里面涉及到迁移工作转移给”postcopy/listen”线程,而”postcopy/listen”线程的主体函数还是qemu_loadvm_state_main,所以这里需要分清楚哪些步骤是在qemu主线程中调用qemu_loadvm_state_main完成的,哪些是由”postcopy/listen”线程调用qemu_loadvm_state_main完成的,这之间的分界线无疑就是src端发送的MIG_CMD_POSTCOPY_RUN命令。回想src端发送的流程,直到发送MIG_CMD_POSTCOPY_RUN命令所做的工作包括:
    1、发送QEMU_VM_FILE_MAGIC、QEMU_VM_FILE_VERSION、QEMU_VM_CONFIGURATION等信息;
    2、发送MIG_CMD_OPEN_RETURN_PATH告知dst打开return path;
    3、在发送任何page之前先发送MIG_CMD_POSTCOPY_ADVISE命令告知dst端采用postcopy方式进行迁移,以及页大小等情况;
    4、遍历savevm_state中涉及的设备,调用对应的save_setup回调函数在dst建立对应设备基本的信息,这涉及到发送QEMU_VM_SECTION_START命令和调用设备SaveStateEntry的save_setup回调函数;
    5、对于不支持postcopy的设备(如block设备),调用其save_live_complete_precopy方法将其相关头部元数据先发送到dst,这里又涉及到针对设备发送QEMU_VM_SECTION_END命令;
    6、发送设备状态信息,这涉及到QEMU_VM_SECTION_FULL的section type;
    7、通过MIG_CMD_POSTCOPY_RAM_DISCARD命令告知dst哪些页需要被discard;
    8、通过MIG_CMD_PACKAGED打包发送MIG_CMD_POSTCOPY_LISTEN和MIG_CMD_POSTCOPY_RUN命令;
    以上便是在”postcopy/listen”线程承担起热迁移工作之前qemu主线程已经做了的工作,显然这之前已经完成了虚拟机的可运行态的迁移工作,不然src端也不会发送MIG_CMD_POSTCOPY_RUN命令在dst端启动虚拟机。再接下来的工作就是和src端qemu_savevm_state_iterate的迭代迁移配合,主要处理的也是QEMU_VM_SECTION_PART section type,比如处理ram的迁移,利用src的压缩线程做page数据压缩,然后传递到src端,再由对应的解压线程解压出page页。当迁移工作完成后,qemu_loadvm_state_main函数也收到QEMU_VM_EOF命令而退出到postcopy_ram_listen_thread开始做迁移后的清理工作,包括将迁移状态设置为MIGRATION_STATUS_COMPLETED。
    其他
    到这里为止,将热迁移的大体架构流程都梳理了一遍,关于热迁移所涉及的内容是非常多的,毕竟需要将一个系统所有层面的东西全部迁移到另一个机器上,qemu热迁移一个很好的实现即是统一了迁移的标准,各个模拟设备等只需要按标准实现自身的回调函数,便可无缝的迁移到目的主机上。就虚拟机迁移来讲,还有多个知识点没有分析到,但这些知识点不可能全都分析,一是有部分重复的工作,二是分析需要有侧重点,再者全面分析确实耗费时间,所以接下来分析两个重要重要且有意思的点:
    1、postcopy模式下pagefault的处理;
    2、Precopy下vcpu的抑制;
    Postcopy模式下的page fault
    我们知道postcopy模式和precopy模式不同,并不是将虚拟机所有的资源都迁移到dst端才启动,而是具备基本运行条件(如vcpu状态、设备状态、块设备元数据信息等)后便可启动虚拟机,此时剩余资源的迁移还在继续,但如果此时dst端虚拟机发生page fault,不可能等到src端按照正常逻辑传输完对应的page再继续执行,这样就失去了postcopy的优势。所以在发生pagefault的时候需要主动向src请求相应的page。
    Postcopy处理page fault的方式是由专门的线程来负责的,其创建是在创建”postcopy/listen”同一时期,在接收到MIG_CMD_POSTCOPY_LISTEN命令后:
    loadvm_postcopy_handle_listen->
    postcopy_ram_enable_notify:
    int postcopy_ram_enable_notify(MigrationIncomingState mis)
    {
    /
    Open the fd for the kernel to give us userfaults */
    /创建fd用于发生pagefault时候由内核态通知我们/
    mis->userfault_fd = syscall(__NR_userfaultfd, O_CLOEXEC | O_NONBLOCK);

/* Now an eventfd we use to tell the fault-thread to quit */
/利用该eventfd通知fault-thread线程的退出/
mis->userfault_event_fd = eventfd(0, EFD_CLOEXEC);

qemu_sem_init(&mis->fault_thread_sem, 0);
/创建”postcopy/fault”线程处理pagefault/
qemu_thread_create(&mis->fault_thread, “postcopy/fault”,
postcopy_ram_fault_thread, mis, QEMU_THREAD_JOINABLE);
/等待”postcopy/fault线程运行”/
qemu_sem_wait(&mis->fault_thread_sem);
qemu_sem_destroy(&mis->fault_thread_sem);
mis->have_fault_thread = true;/标识存在”postcopy/fault”线程/

/* Mark so that we get notified of accesses to unwritten areas */
/*向内核态注册各个RAMBlock地址区间,这样在发生pagefault时根据地址得到正确的

  • 页请求*/
    if (qemu_ram_foreach_block(ram_block_enable_notify, mis)) {
    return -1;
    }
    /*

    • Ballooning can mark pages as absent while we’re postcopying
    • that would cause false userfaults.
  • 禁止balloon驱动,因为balloon驱动会导致page的缺失,这会导致postcopy处理

  • pagefault时的异常
    */
    qemu_balloon_inhibit(true);
    return 0;
    }
    在page fault的处理方面,利用了内核态的userfault机制,通过userfault系统调用创建的文件和qmeu的mm_struct关联,这样也就和整个虚拟机内存关联在了一起。紧接着调用ram_block_enable_notify函数将各个RAMBlock的地址空间注册到userfault fd所负责监听的地址范围,注册的模式为UFFDIO_REGISTER_MODE_MISSING:
    reg_struct.range.start = (uintptr_t)host_addr;
    reg_struct.range.len = length;
    reg_struct.mode = UFFDIO_REGISTER_MODE_MISSING;
    ioctl(mis->userfault_fd, UFFDIO_REGISTER, ®_struct)
    接下来看看内核态的实现,内核根据ioctl操作调用到userfaultfd_register,该函数的实现很长,这里就不贴出其代码实现了,该函数实现的注册逻辑为:
    1、校验注册地址的有效性;
    2、如果是hugepage,则地址是否满足页对其;
    3、对qemu进程中的mm_struct中查找和RAMBlock地址空间有交集的vm_area_struct,并检查其兼容性,包括:vma是否支持userfault,VM_MAYWRITE校验,地址对齐等;
    4、对RAMBlock地址空间与mm_struct中涉及的vm_area_struct的合并与拆分,加入RAMBlock地址区间后,如果可让相邻的区间连接且属性一致,则可合并为一个vm_area_struct;而如果RAMBlock阻断了相关的vm_area_struct,其属于不同,则需要对其进行拆分;
    但这里能够和userfault_fd相关联,最终能因为pagefault通知到”postcopy/listen”的关联点为:
    static int userfaultfd_register(struct userfaultfd_ctx *ctx,
    unsigned long arg)
    {
    。。。。。。
    vma->vm_flags = new_flags;
    vma->vm_userfaultfd_ctx.ctx = ctx;
    。。。。。。
    }
    这样在发生pagefault时可利用ctx与userfault_fd关联,具体往下看:
    再来看看虚拟机因发生pagefault会导致怎样的一种响应流程(比如):
    do_page_fault->
    __do_page_fault->
    handle_mm_fault->
    __handle_mm_fault->
    handle_pte_fault->
    do_anonymous_page->
    handle_userfault:
    int handle_userfault(struct vm_fault *vmf, unsigned long reason)
    {
    。。。。。。。。。。。
    /*将当前线程挂载到等待队列,这是在请求的page由src传递给dst,

  • 加载成功后处理了pagefault异常再唤醒当前线程,以便继续后续的操作*/
    init_waitqueue_func_entry(&uwq.wq, userfaultfd_wake_function);
    uwq.wq.private = current;
    uwq.msg = userfault_msg(vmf->address, vmf->flags, reason,
    ctx->features);
    uwq.ctx = ctx;
    uwq.waken = false;
    __add_wait_queue(&ctx->fault_pending_wqh, &uwq.wq);
    。。。。。。。。。。。。。
    /*这里的wake_up_poll是在发生pagefault后需要唤醒”postcopy/fault”线程

  • 进行处理:主要是向src发送页请求*/
    if (likely(must_wait && !ACCESS_ONCE(ctx->released) &&
    (return_to_userland ? !signal_pending(current) :
    !fatal_signal_pending(current)))) {
    wake_up_poll(&ctx->fd_wqh, POLLIN);
    schedule();
    ret |= VM_FAULT_MAJOR;
    /这里等待pagefault处理后被唤醒/
    while (!READ_ONCE(uwq.waken)) {
    set_current_state(blocking_state);
    if (READ_ONCE(uwq.waken) ||
    READ_ONCE(ctx->released) ||
    (return_to_userland ? signal_pending(current) :
    fatal_signal_pending(current)))
    break;
    schedule();
    }
    }
    __set_current_state(TASK_RUNNING);
    。。。。。。。。。。。。。
    }
    可见这里进程因pagefault休眠,又因pagefault处理完成而被唤醒,涉及了多个任务的参与,接下来我再将”postcopy/listen”线程的处理逻辑和处理完pagefault后唤醒因pagefault而等待的线程逻辑做一下说明,然后给出总的pagefault处理逻辑图示。
    在分析”postcopy/listen”之前,需要明确的是其考虑了vhost-user等基于其他线程因共享虚拟机内存而出现的pagefault情况,所以”postcopy/listen”线程的实现逻辑如下(因其实现代码太长,仅做说明):总的来说“postcopy/listen”会以poll方式监听多个文件,然后针对不同的文件做相应的处理,包括:
    userfault_fd:负责监听虚拟机运行缺页时请求”postcopy/listen”的处理,根据请求的地址转换为对应RAMBlock的偏移,最后向src的”return path”线程发送请求;
    userfault_event_fd:迁移完成后由postcopy_ram_incoming_cleanup通过该eventfd通知”postcopy/listen”退出;
    postcopy_remote_fds:vhost-user模式下的缺页请求;根据pagefault地址计算其在RAMBlock中的偏移,再向src的”return path”线程发送请求;
    以上便是”postcopy/listen”线程的主要完成的任务,接下来看看当”postcopy/listen”收到对应的pagefault页后是如何唤醒等待线程的,这是在”postcopy/listen”线程处理接收到page进行load的过程中:
    ram_load->
    ram_load_postcopy->
    postcopy_place_page:
    int postcopy_place_page(MigrationIncomingState *mis, void *host, void *from,
    RAMBlock *rb)
    {
    size_t pagesize = qemu_ram_pagesize(rb);
    qemu_ufd_copy_ioctl(mis->userfault_fd, host, from, pagesize, rb)

    return postcopy_notify_shared_wake(rb,
    qemu_ram_block_host_offset(rb, host));
    }
    postcopy_place_page实际上负责了三部分工作:
    1、调用qemu_ufd_copy_ioctl加载内存页,建立页映射;
    2、如果虚拟机本身有线程由于pagefault而挂起,此时也会唤醒该线程;
    3、如果存在vhost-user的情况,则调用postcopy_notify_shared_wake唤醒vhost-user后端进程,这里唤醒的方式是vhost-user提供了waker回调函数vhost_user_postcopy_waker。
    不管是以上步骤中的2还是3中提到的唤醒方式,其实都是利用userfault_fd陷入到内核态,最终走到wake_userfault:
    static __always_inline void wake_userfault(struct userfaultfd_ctx *ctx,
    struct userfaultfd_wake_range *range)
    {
    unsigned seq;
    bool need_wakeup;
    。。。。。。。。。。。。
    do {
    seq = read_seqcount_begin(&ctx->refile_seq);
    need_wakeup = waitqueue_active(&ctx->fault_pending_wqh) ||
    waitqueue_active(&ctx->fault_wqh);
    cond_resched();
    } while (read_seqcount_retry(&ctx->refile_seq, seq));
    if (need_wakeup)
    __wake_userfault(ctx, range);
    }
    这里也就和do_page_fault处挂载线程等待pagefault处理相呼应,即唤醒等待的线程。
    通过以上的分析,可将postcopy中pagefault的处理流程表示如下图(pagefault下考虑到响应速度不再使用压缩、解压程序):

Precopy下vcpu的抑制
该需求主要是针对precopy模式下传输dirty page的时候由于src端写内存过快,导致新产生的脏页的速率比起传输脏页的速率来讲过快,导致需要过长时间才能迁移完成,或者极端情况下产生脏页的速度大于脏页传输速率,这样热迁移永远不可能收敛。因此这种情况下需要增加一种机制,抑制vcpu的执行,让脏页生成速度变慢。那么什么时候qemu知道产生了多少脏页呢,这就需要在发送的时候与内核交互,获取当前系统的脏页信息,而内核是如何知道哪些页被写脏了呢,简单来说就是在迁移的时候认为虚拟机所有的页都为脏页,在相关的页被迁移后,需要将该页置为只读,当再次写该页面时,由于权限异常而将页变为可写状态并对其进行修改,同时记录下该页为dirty page。同理,当该页被迁移后,仍然需要再次将该页置为只读,以免该页被再次修改。
static void migration_bitmap_sync(RAMState rs)
{
。。。。。。
/同步脏页信息/
memory_global_dirty_log_sync();
qemu_mutex_lock(&rs->bitmap_mutex);
rcu_read_lock();
RAMBLOCK_FOREACH(block) {
migration_bitmap_sync_range(rs, block, 0, block->used_length);
}
。。。。。。。。。
/如果满足条件则抑制vcpu/
if ((rs->num_dirty_pages_period * TARGET_PAGE_SIZE >
(bytes_xfer_now - rs->bytes_xfer_prev) / 2) &&
(++rs->dirty_rate_high_cnt >= 2)) {
trace_migration_throttle();
rs->dirty_rate_high_cnt = 0;
mig_throttle_guest_down();
}
。。。。。。
}
这里抑制的条件是在传输num_dirty_pages_period的时间内,如果新产生的脏页数量大于这段时间内传输的脏页数量的50%,那么需要考虑抑制vcpu以减少脏页产生的速率,这里抑制的程度会随着没能抑制住脏页产生的速率而逐渐加大抑制力度。
Mig_throttle_guest_down->
Cpu_throttle_set:
void cpu_throttle_set(int new_throttle_pct)
{
/
Ensure throttle percentage is within valid range */
new_throttle_pct = MIN(new_throttle_pct, CPU_THROTTLE_PCT_MAX);
new_throttle_pct = MAX(new_throttle_pct, CPU_THROTTLE_PCT_MIN);

atomic_set(&throttle_percentage, new_throttle_pct);

timer_mod(throttle_timer, qemu_clock_get_ns(QEMU_CLOCK_VIRTUAL_RT) +
                                   CPU_THROTTLE_TIMESLICE_NS);

}
通过设置throttle_percentage百分比,然后触发throttle_timer定时器,该定时器的回调函数为cpu_throttle_timer_tick:
static void cpu_throttle_timer_tick(void *opaque)
{
CPUState *cpu;
double pct;

/* Stop the timer if needed */
if (!cpu_throttle_get_percentage()) {
    return; 

}
/将cpu_throttle_thread函数挂载到cpu->queued_work_first队列/
CPU_FOREACH(cpu) {
if (!atomic_xchg(&cpu->throttle_thread_scheduled, 1)) {
async_run_on_cpu(cpu, cpu_throttle_thread,
RUN_ON_CPU_NULL);
}
}
/每CPU_THROTTLE_TIMESLICE_NS / (1-pct)时间触发timer,设置throttle/
pct = (double)cpu_throttle_get_percentage()/100;
timer_mod(throttle_timer, qemu_clock_get_ns(QEMU_CLOCK_VIRTUAL_RT) +
CPU_THROTTLE_TIMESLICE_NS / (1-pct));
}
针对每个vcpu,将cpu_throttle_thread函数挂载到cpu->queued_work_first队列,然后调用qemu_cpu_kick函数向vcpu发送SIG_IPI信号,然后vcpu会exit guest,发现在内核态无法处理SIG_IPI信号而退出到用户态:从qemu对SIG_IPI信号的注册函数可以看到:
static void kvm_ipi_signal(int sig)
{
if (current_cpu) {
assert(kvm_immediate_exit);
kvm_cpu_kick(current_cpu);
}
}
void kvm_init_cpu_signals(CPUState *cpu)
{
。。。。。。。
memset(&sigact, 0, sizeof(sigact));
sigact.sa_handler = kvm_ipi_signal;
sigaction(SIG_IPI, &sigact, NULL);
。。。。。。。。。
}
Qemu的SIG_IPI信号的响应函数kvm_cpu_kick只做了一件事:
atomic_set(&cpu->kvm_run->immediate_exit, 1)
这里就很明显了,当vcpu收到SIG_IPI信号后,guest VMexit,然后执行信号处理函数,之后继续在内核态运行,此时在内核态的执行上下文为:

int kvm_arch_vcpu_ioctl_run(struct kvm_vcpu *vcpu, struct kvm_run *kvm_run)
{

    if (kvm_run->immediate_exit)
        r = -EINTR;
    else
        r = vcpu_run(vcpu);

out:
    kvm_put_guest_fpu(vcpu);
    post_kvm_run_save(vcpu);
    kvm_sigset_deactivate(vcpu);

    return r;

}

这里因返回-EINTR导致qemu回到用户态,然后在用户态qemu有如下调用关系:

qemu_kvm_cpu_thread_fn->
qemu_wait_io_event->
qemu_wait_io_event_common->
process_queued_cpu_work

在process_queued_cpu_work中遍历cpu->queued_work_first队列并执行,这里也就调用到了cpu_throttle_thread函数,该函数抑制vcpu执行的原理是根据throttle_percentage计算出vcpu需要被抑制执行的时间值,然后休眠该时间长度,以这种方式来减少vcpu的执行,从而降低dirty page的产生速率。

后记:热迁移涉及的内容众多,这里只梳理了主体架构流程,其中很多细节和优化点都值得再去研究,但时间有限,有空再慢慢积累。

你可能感兴趣的:(架构)