背景
- Libvirt是一个虚拟机管理的开源项目,它的实现涉及到对虚拟机任务的管理,为了提高Libvirtd服务并发执行API的效率,同时又能保证多线程访问同一个虚机数据结构的一致性,Libvirt基于不同场景设计实现了不同粒度的同步机制,本文主要分析这些同步机制的设计原理。
VM同步原理
virDomainObj 是Libvirt对虚机数据结构的抽象,包含了通用信息(比如虚机xml加载到内存信息)和每个虚机私有的driver信息(比如qemu driver的私有qemuDomainObjPrivate )。当多个API同时发起,可能出现多线程并发修改VM信息的场景,virDomainObj 的基类parent中包含一把互斥锁virMutex,多线程访问VM时通过这把锁来保证数据同步。下图是一个示例,解释两个Libvirt的API qemuDomainGetInfo 并发执行时的加锁顺序。 - 如上图所示,thread1是Libvirtd服务收到客户端的rpc调用后fork的线程,开始执行
qemuDomainGetInfo ,这个API的功能是获取虚机的内存、CPU运行状态等信息,这些信息在VM的结构体中都有保存,因此API的主要逻辑就是查询VM的数据结构。 - 步骤如下:
- 通过
qemuDomainObjFromDomain 接口获取vm结构体,过程中对virDomainObj.parent 加锁 - 获取虚机总内存:virDomainObj.def->mem.total_memory
- 获取虚机状态:virDomainObj.state.state
- 获取proc文件系统下虚机Qemu进程运行时间
- 通过
virDomainObjEndAPI 释放virDomainObj.parent锁
- 以上步骤中,所有Libvirt API都必须执行1、5这两部,保证所有API对VM数据结构的视图一致。
- 现在假设Libvirtd服务在API执行过程中又收到了客户端的rpc调用消息,针对同一个虚机开始执行
qemuDomainGetInfo ,由于Libvirt API编程规范中要求在修改VM信息前必须先获取virDomainObj.parent 锁,而此时virDomainObj.parent 锁被先前的API持有,因此后来的API只能等待先前API执行结束,释放锁之后才能开始。
任务同步原理
- VM同步保证了虚机信息在多线程修改时保持一致,但有的时候,针对虚机的一些操作是比较耗时的,并且时间开销不在对数据结构的修改上,而是其它动作,比如执行QMP、QGA等命令或者执行迁移、备份这种操作。这个时候VM同步的接口会显得粒度太大,需要允许在以上这些操作的同时,其它API也能并发执行。因此,Libvirt设计了针对虚机的任务同步机制和接口并定义了一系列同步任务,对于同一个虚机而言,同一时间只允许有其中一个任务在执行。
同步任务
- 我们以查询虚机内存统计信息接口
qemuDomainMemoryStats 举例,解释一个简单的任务同步,如下图所示: - VM的内存统计接口核心实现是通过QMP的命令query-balloon 查询内存信息。这个接口不涉及任何对Libvirt VM信息的修改,执行过程中允许其它API修改VM的信息,在执行过程中如果继续持有VM的大锁,会导致其它API无法并发执行。看一下API整个执行步骤:
- 通过
qemuDomainObjFromDomain 接口获取vm结构体,过程中对virDomainObj.parent 加锁。 - 通过调用
qemuDomainObjBeginJob(driver, vm, QEMU_JOB_QUERY) 标记VM正在执行查询任务。查询任务QEMU_JOB_QUERY 会被设置到VM的私有结构体obj->privateData->job.active 中。 - 调用
qemuDomainObjEnterMonitor 为执行qmp命令做准备,因为执行qmp命令并不会对VM信息做修改,所以在执行qmp命令完成之前,允许其它虚机对虚机VM信息进行修改,qemuDomainObjEnterMonitor 的核心动作就是释放virDomainObj.parent 这把锁。 - 执行qmp命令
query-balloon 查询虚机内存信息。 - 完成qmp命令后,API需要修改VM信息了,因此调用
qemuDomainObjExitMonitor ,里面的核心动作是对virDomainObj.parent 加锁。一旦拿到这把锁,其它针对这个VM的API就再也无法对VM信息进行修改了。因为Libvirt API编程规程要求其它API修改VM信息前需要拿到virDomainObj.parent 才可以。 - API的核心逻辑在步骤4已经完成,查询的任务也该结束了,因此调用
qemuDomainObjEndJob 标记VM结束任务。函数的核心动作是将VM的任务设置为QEMU_JOB_NONE ,表明当前VM没有任务在执行。
- 我们假设上述步骤4在Thread 2中被执行时,此使针对VM新来了一个API调用,fork了一个新的线程Thread 1,同样要求查询内存统计信息,我们分析Libvirt的如何利用同步机制实现对数据保护的同时允许最大限度并发执行:
- Thread 1按照正常流程通过
qemuDomainObjFromDomain 接口获取vm结构体,过程中对virDomainObj.parent 加锁。因为Thread 2此时正在执行qmp命令,将锁释放了,因此Thread 1可以轻易持有该锁。 - Thread 1 通过调用
qemuDomainObjBeginJob(driver, vm, QEMU_JOB_QUERY) 期望标记VM正在执行查询任务,但此时发现VM上已经有一个查询任务了,因此会Thread 1会等在priv->job.cond 这个信号量上,同时释放自己拿到的virDomainObj.parent ,如果等待QEMU_JOB_WAIT_TIME (30s)信号量还没有准备号,就会唤醒,然后重新查询一次VM上是否有任务。 - 当Thead 2执行完qmp命令后,在调用
qemuDomainObjExitMonitor 时会尝试拿virDomainObj.parent 锁,此时因为Thread 1在睡眠,Libvirt编程规则时要求将virDomainObj.parent 锁释放的,因此Thread 2可以轻易拿到。 - 当Thread 2执行完
qemuDomainObjExitMonitor ,会继续调用qemuDomainObjEndJob 标记VM结束任务,这个时候它会将VM的任务设置为QEMU_JOB_NONE ,同时通过调用virCondBroadcast(&priv->job.cond) 将所有等待在该信号量上的线程唤醒,这里就包括Thread 1。 - Thread 1被唤醒后,继续检查VM上是否有任务,发现任务已经被设置为
QEMU_JOB_NONE ,因此会结束等待,再次标记VM当前的任务为QEMU_JOB_QUERY 。这样后来的线程又会继续等待当前任务的结束。
异步任务
- Libvirt引入同步任务后,可以针对同一个VM并发执行数据结构的修改和非数据结构修改的动作。但如果两个任务,都对VM的数据结构没有修改,并且允许任务并发执行,这个时候,利用现有的同步任务机制就没法实现了,因为同步任务机制要求只要VM上有一个线程在执行任务,其它线程就无法执行任务。还有一种情况是,如果VM当前在执行一个任务,并且这个任务错误了,永远无法结束,这时必须要发起另外一个任务来终止出错的任务,如果按照Libvirt提供的上述同步机制,是做不到的,因为后面一个任务必须等到前一个任务结束才能发起。综上原因,Libvirt在同步任务中又特别定义一个
QEMU_JOB_ASYNC 任务,引入了异步任务的概念,需要注意的是,异步任务和所有其它同步任务是互斥的,即针对同一个VM,同一时间还是只能有一个任务,无论是异步任务还是同步任务。除非其中一个异步任务允许其它任务并发执行,才可能被允许同时执行。 - 我们以快照和迁移两个任务举例,来说明异步任务的使用规则,如下图所示:
- 首先原理上,Qemu不允许针对一个虚机同时执行快照和迁移,因此这两个任务肯定不能并发执行,但他们是通过异步任务来保持执行顺序的。我们先分析快照的步骤:
- 通过
qemuDomainObjFromDomain 接口获取vm结构体,过程中对virDomainObj.parent 加锁。 - 调用
qemuDomainObjBeginAsyncJob ,标记同步任务是QEMU_JOB_ASYNC ,异步任务是QEMU_ASYNC_JOB_SNAPSHOT ,这里会调用qemuDomainObjCanSetJob 判断VM是否有正在执行的同步任务,如果有,则异步任务必须等待当前的同步任务完成。 - 标记VM正在执行快照任务之后,便是快照的核心实现,调用qmp命令发起快照。
- 快照执行完成后,调用
qemuDomainObjEndAsyncJob 标记VM已经结束快照这个异步任务。同时唤醒所有等待在异步任务信号量priv->job.asyncCond 上的所有异步任务。
- 假设在Qemu执行快照过程中,虚机发起了迁移,快照在Thread 2中执行,迁移在Thread 1中执行,我们继续分析迁移步骤,解释两者如何保证顺序的:
- Thread 2调用
qemuDomainObjFromDomain 接口获取vm结构体,过程中对virDomainObj.parent 加锁。此时虚机正在Qemu中做快照,virDomainObj.parent 释放了,因此可以正常获取。 - Thread 2调用
qemuDomainObjBeginAsyncJob ,打算标记同步任务为QEMU_JOB_ASYNC ,异步任务QEMU_ASYNC_JOB_MIGRATION_OUT ,此时通过调用qemuDomainNestedJobAllowed 判断VM上是否有正在执行的异步任务,如果有异步任务,并且这个异步任务不允许其它异步任务并发执行,那么要发起的异步任务就必须等待当前异步任务结束。显然当前VM存在快照这个异步任务,并且不允许迁移异步任务并发执行,因此迁移任务必须等待。睡在priv->job.asyncCond 信号量上。 - 当快照任务结束后,会唤醒睡在
priv->job.asyncCond 信号量上的线程,这是迁移线程会被唤醒,开始执行迁移的核心流程。
- 总结上面的步骤,快照和迁移被定义为了异步任务,并且通过异步任务的机制保证两者顺序执行,但这个效果同步任务机制也可以实现,这里为什么要这样做呢?因为Libvirt将快照和迁移定义为异步任务,是保留了其它任务并发执行的机会,这里的其它任务包括查询任务、销毁任务和终止任务。这样查询、销毁和终止任务永远可以在异步任务执行的同时并发执行。这个机制通过
priv->job->mask 来实现。我们可以跟踪快照函数qemuSnapshotCreateXML ,当它执行完qemuDomainObjBeginAsyncJob 之后,还会调用qemuDomainObjSetAsyncJobMask 来设置允许并发执行的任务。qemuDomainObjSetAsyncJobMask 函数默认会把QEMU_JOB_DESTROY 设置为快照允许的任务。从而保证快照可以被终止。另外一个允许两个异步任务并发执行的场景,出现在备份任务上,备份任务(QEMU_JOB_SUSPEND )允许其它任务比如虚机挂起(QEMU_JOB_SUSPEND )、虚机修改(QEMU_JOB_MODIFY )任务并发执行。详细流程可以参考qemuDomainBackupBegin API。
嵌套异步任务
- 除了以上同步场景,再考虑一种情况,当一个线程正在执行一个异步任务,这个过程中它不允许别的线程并发执行任务,但允许自己发起同样的任务,即所谓嵌套异步任务。这种情况下按照之前的设计,可以将自己发起的任务设置为允许异步执行的任务,但这样可能会导致其他执行相同任务的线程也被允许,因此需要设计一种同步机制只允许任务嵌套执行。这就是
QEMU_JOB_ASYNC_NESTED 的定义。下面我们以迁移任务举例进行说明: - 首先我们分析Libvirt迁移API的实现逻辑和具体步骤,假设这在Thread 2中执行:
- 同样,API通过
qemuDomainObjFromDomain 接口获取vm结构体,过程中对virDomainObj.parent 加锁。 - 之后,通过调用
qemuMigrationJobStart 标记VM的任务。目标是将同步任务标记为QEMU_JOB_ASYNC ,异步任务标记为QEMU_ASYNC_JOB_MIGRATION_OUT ,在这个过程中检查到VM上没有其它同步或异步任务,标记成功。 - 准备通过qmp命令发起迁移,因为迁移是比较耗时的任务且不涉及VM信息的修改,因此该过程需要放开VM锁,否则影响其它API的并发执行,这个动作在
qemuDomainObjEnterMonitorAsync 中被执行,它将VM的同步任务标记为QEMU_JOB_ASYNC_NESTED ,在标记过程中,qemuDomainObjBeginNestedJob 还会判断当前虚机上的异步任务和想要发起的异步任务是不是同一个,如果不是同一个则不被允许,而且也会判断发起异步任务的线程ID和正在VM上执行异步任务的线程ID是否相同,如果不相同,会又警告打印,但任务仍然被允许继续执行。qemuDomainObjEnterMonitorAsync 在完成任务的标记后会释放virDomainObj.parent 的锁。走完整个函数逻辑。 - 执行迁移的核心逻辑,通过qmp的migrate命令发起虚机迁移。
- 虚机迁移发起后,就是等待迁移结束,这个过程中会不断查询虚机的迁移状态并更新VM的数据结构,因此需要再次获得
virDomainObj.parent 这把锁,这个在qemuDomainObjExitMonitor 执行,同时结束嵌套任务QEMU_JOB_ASYNC_NESTED 。 - 调用
qemuMigrationSrcWaitForCompletion 等待迁移任务结束,等待的过程中因为要睡眠,而Libvirtd的Monitor poll线程在更新迁移状态时又需要拿锁,因此等待的过程就是放锁,睡眠,超时唤醒,检查迁移是否结束这个循环执行。 - 迁移结束后,调用
qemuMigrationJobFinish 标记迁移异步任务完成。 - 整个API逻辑执行结束后,调用
virDomainObjEndAPI 释放VM锁。
|