使用基于共享内存的自旋锁在虚拟机间进行同步的可行性研究

概述

  • 在并行编程中进行同步互斥的操作原语是PV操作,而PV操作的关键是保证其原子性。

  • 原子操作是严重依赖硬件体系结构的,尤其是CPU的架构。

  • 在单核CPU中,单条CPU指令就是原子的,如果外加中断禁用,还可以保证多条指令的原子性。

  • 在SMP多核CPU上,单条指令也不一定就能保证其对内存操作的原子性(同样,禁用中断也是不行的)。

  • 为此必须在CPU指令集和软件算法上进行改进,根据CPU“原子操作”的特性实现“自旋锁”,典型的有x86架构下CAS算法和ARM架构下的LLSC算法。

  • 在SMP多任务操作系统上,所有多任务(多线程或多进程)间的通信都严重依赖基于共享内存的“自旋锁”的实现(其实就是在他们的基础上外加中断禁用进行封装而已)。

  • 至于在多个虚拟机间(使用硬件虚拟化或者软件虚拟化),这种方法是否依旧适用?就是文本探讨的主要内容。

特别说明:

这里讨论的虚拟机间基于共享内存的自旋锁都是在宿主机为SMP多核(不依赖中断来进行原子操作),同时宿主机和客户机使用的是相同架构(不进行指令翻译)的CPU。

CPU对原子操作的支持

在SMP中,对内存的原子操作的实现必须依赖CPU特性,下面主要以Intel x86架构和ARM v8架构为例进行说明。

Intel x86架构的CPU对原子操作的支持

  1. 读或写一个不大于机器字长的内存数据(32位CPU只支持8、16、32位数据,64位CPU则还可以支持64位),且该数据必须按照机器字长对齐、且不跨越Cache Line和内存页(也有部分型号CPU例外);

  2. 总线锁,可以在BTR、BTS和CMPXCHG等部分指令前加LOCK前缀,在指令执行期间,保证对内存的独占访问;

  3. 缓存一致性协议,确保即便是内存在缓存中也可以进行原子操作。

额外说明:

x86较新(P6以后)的SMP处理器都可以保证在LOCK时各个CPU内部Cache的一致性,同时具备内存屏障的功能。

参考资料:

Intel 64 and IA-32 Architectures Software Developer's Manual Volume 3: System Programming Guide - 8.1 Locked Atomic Operations

ARMv8架构的CPU(AArch32)对原子操作的支持

  1. 只对内存进行一次访问的指令都是原子操作的,如读写单字节数据、双字节对其的双字节数据、四字节对齐的四字节数据以及被LDREXD和STREXD访问的八字节对齐数据;

  2. 对内存进行多次访问时,满足一定要求的情况下也是原子操作的。比如一个核在一个内存位置顺序写入数据,如果其他所有核看到的顺序全部是完全相同的,则这个操作是原子的。

额外说明:

ARM的SMP处理器中都有一个叫Snooping的模块负责监视CPU和DMA的内存操作,用来保证Cache一致性。

参考资料:

ARM Architecture Reference Manual(DDI0487A) - E2.6 Atomicity in the ARM architecture

ARM平台下独占访问指令LDREX和STREX的原理与使用详解

MIPS中LL/SC指令介绍

自旋锁的实现

一般情况下x86架构下的自旋锁都是使用CMPXCHG指令加上LOCK指令前缀使用CAS算法实现的,而ARM架构下都是使用LDREX和STREX指令使用LLSC算法实现的。

x86架构的Linux(4.1.17)内核的自旋锁源码如下:

include/linux/spinlock.h

static inline void spin_lock(spinlock_t *lock)
{
		raw_spin_lock(&lock->rlock);
}
#define raw_spin_lock(lock)	_raw_spin_lock(lock)
		kernel/locking/spinlock.c
void __lockfunc _raw_spin_lock(raw_spinlock_t *lock)
{
	__raw_spin_lock(lock);
}
		include/linux/spinlock_api_smp.h
			static inline void __raw_spin_lock(raw_spinlock_t *lock)
{
				preempt_disable();
				spin_acquire(&lock->dep_map, 0, 0, _RET_IP_);
	LOCK_CONTENDED(lock, do_raw_spin_trylock, do_raw_spin_lock);
}

include/linux/spinlock.h

static inline int do_raw_spin_trylock(raw_spinlock_t *lock)
{
return arch_spin_trylock(&(lock)->raw_lock);
}

arch/x86/include/asm/spinlock.h

static __always_inline int arch_spin_trylock(arch_spinlock_t *lock)
{
	arch_spinlock_t old, new;

	old.tickets = READ_ONCE(lock->tickets);
	if (!__tickets_equal(old.tickets.head, old.tickets.tail))
		return 0;

	new.head_tail = old.head_tail + (TICKET_LOCK_INC << TICKET_SHIFT);
	new.head_tail &= ~TICKET_SLOWPATH_FLAG;

	/* cmpxchg is a full barrier, so nothing can move before it */
	return cmpxchg(&lock->head_tail, old.head_tail, new.head_tail) == old.head_tail;
}

arch/x86/include/asm/cmpxchg.h

#define cmpxchg(ptr, old, new)						\
				__cmpxchg(ptr, old, new, sizeof(*(ptr)))
#define __cmpxchg(ptr, old, new, size)					\
				__raw_cmpxchg((ptr), (old), (new), (size), LOCK_PREFIX)
/*
 * Atomic compare and exchange.  Compare OLD with MEM, if identical,
 * store NEW in MEM.  Return the initial value in MEM.  Success is
 * indicated by comparing RETURN with OLD.
 */
#define __raw_cmpxchg(ptr, old, new, size, lock)			\
({									\
	__typeof__(*(ptr)) __ret;					\
	__typeof__(*(ptr)) __old = (old);				\
	__typeof__(*(ptr)) __new = (new);				\
	switch (size) {							\
	case __X86_CASE_B:						\
	{								\
		volatile u8 *__ptr = (volatile u8 *)(ptr);		\
		asm volatile(lock "cmpxchgb %2,%1"			\
			     : "=a" (__ret), "+m" (*__ptr)		\
			     : "q" (__new), "0" (__old)			\
			     : "memory");				\
		break;							\
	}								\
	case __X86_CASE_W:						\
	{								\
		volatile u16 *__ptr = (volatile u16 *)(ptr);		\
		asm volatile(lock "cmpxchgw %2,%1"			\
			     : "=a" (__ret), "+m" (*__ptr)		\
			     : "r" (__new), "0" (__old)			\
			     : "memory");				\
		break;							\
	}								\
	case __X86_CASE_L:						\
	{								\
		volatile u32 *__ptr = (volatile u32 *)(ptr);		\
		asm volatile(lock "cmpxchgl %2,%1"			\
			     : "=a" (__ret), "+m" (*__ptr)		\
			     : "r" (__new), "0" (__old)			\
			     : "memory");				\
		break;							\
	}								\
	case __X86_CASE_Q:						\
	{								\
		volatile u64 *__ptr = (volatile u64 *)(ptr);		\
		asm volatile(lock "cmpxchgq %2,%1"			\
			     : "=a" (__ret), "+m" (*__ptr)		\
			     : "r" (__new), "0" (__old)			\
			     : "memory");				\
		break;							\
	}								\
	default:							\
		__cmpxchg_wrong_size();					\
	}								\
	__ret;								\
})

由此可见在4.1.17版的Linux内核中的SMP自旋锁使用的是CMPXCHG系列指令加上LOCK前缀实现的。

参考资料:

Linux的原子操作与同步机制

虽然这个版本与4.1.17版的内核不一样,但是都是使用加LOCK前缀的原子操作指令和CAS算法来实现的。

Linux 下用户态自旋锁的实现

从这里可以看出,自旋锁的实现是可以脱离操作系统而独立存在的(可以说是跨过操作系统,直接使用硬件)。

Linux自旋锁

多进程间在共享内存中使用自旋锁

用户态自旋锁一般被用在父子进程间,对于没有父子关系的进程间是否可以同样适用呢?

通过上面的原理分析可以得知自旋锁的本质是:

两个并行的CPU通过原子操作互斥的访问同一地址内存实现的(同时要保证这两个CPU的Cache一致性)。因此我们可以把自旋锁放在共享内存中,两个进程同时对同一地址内存进行lock和unlock即可。两个进程把同一个共享内存映射到地址mem,在开始处强制转换为两个自旋锁。然后分别负责读写,结果发现他们可以交替的进行读写操作,因此可以说明自旋锁是有效的(nginx中就使用了基于共享内存的自旋锁)。

测试代码如下:

int spinlock_write(void* mem, int size)
{
	pthread _spinlock_t* const p = (pthread _spinlock_t*)mem;
	pthread _spinlock_t* const v = p + 1;
	int* const count = (int*)(v + 1);
	
	pthread_spin_init(p);
	pthread_spin_init(v);
	*count = 0;
	
	pthread_spin_lock(v);
	while(1)
	{
		pthread_spin_lock(p);
		(*count)++;
		printf("Write count is %d.\n", *count);
		sleep(1);
		pthread_spin_unlock(v);
	}
	
	return 0;
}

int spinlock_read(void* mem, int size)
{
	pthread_spinlock_t* const p = (pthread_spinlock_t*)mem;
	pthread_spinlock_t* const v = p + 1;
	int* const count = (int*)(v + 1);
	
	while(1)
	{
		pthread_spin_lock(v);
		printf("Read count is %d.\n", *count);
		sleep(1);
		pthread_spin_unlock(p);
	}
	
	return 0;
}

在虚拟机间的共享内存中使用自旋锁

  1. 共享内存的本质是在内核中申请一块内存,然后内存映射到不同的进程空间,从而使得在不同的用户态进程的私有地址空间可以访问同一块内存。同时由于两个进程使用相同的指令集,只要能保证Cache的一致性和内存屏障,相互之间使用自旋锁就没有问题。

  2. 对于未使用硬件虚拟化的虚拟机,本质上也是一个用户态进程,所以两个虚拟机进程肯定是可以访问同一块内存的,关键在于他们访问这块内存的方式。

  • 异构虚拟机,例如:客户机是x86,宿主机是ARM,则由于指令集不同,一方面必须通过指令翻译(ARM翻译为x86指令)来执行,则面对总线锁这类功能是否能够正确翻译,待考究。另一方面,一个只能使用CAS算法(没有LDREX和STREX指令),一个只能使用LLSC算法(没有可以加LOCK的CMPXCHG指令,之前的SWP指令在V6以后不再提供),则导致难以使用一致的方法实现自旋锁(具体有没有变通的方法,待考究)。

  • 同构虚拟机,由于架构相同,则由于不存在指令翻译的问题,只要可以保证客户机的指令是不经修改而直接在宿主机上进行执行的,即可以保证自旋锁的有效性。

参考资料:

QEMU虚拟机的移植和优化 原子操作的实现 ARM的SWP和LDREX STREX指令

  1. 对于使用硬件虚拟化的同构虚拟机,Intel的x86(或者x64)上的VT-X是在传统的Ring 0基础上新增了一个root模式,ARM的虚拟化是原有的运行模式基础上新增了Hyp模式。本质上他们只是新增了CPU的运行模式,让VMM运行在比操作系统更高一级的特权模式中,因此他们对CPU指令访问内存的方式并没有改变,因此自旋锁在这种情况下也是有效的。

参考资料:

ARM的虚拟化原理 Intel VT-X

  1. 在基于Intel的x64的的SMP多核处理器的MAC或者PC上,宿主机分别安装MAC OS X和VMware Fusion或者Windows和VMware Workstation,同时开启虚拟化Intel VT-x支持,客户机安装CentOS 6.4 x64或者Ubuntu 14.04 LTS x64。在客户机中运行ivshmem-server,再使用Qemu 2.5.1安装两个CentOS6.4 x64位(同时设置好ivshmem的参数,开启或者不开启KVM),在Qeum虚拟机中加载UIO驱动,将UIO设备mmap到用户空间,运行上一章的自旋锁示例代码,可以得到相同的测试结果。

总结

  • 自旋锁作为SMP多核间同步互斥的基础,依赖硬件体系结构,是操作系统实现的基础。通过自己实现自旋锁,可以作为跨平台的同步通信方案。目前的研究及测试表明,在基于SMP的多核CPU上,使用同构虚拟机,无论是否使用硬件虚拟化,基于共享内存的自旋锁都是有效的。然而,在异构虚拟机上是否有效,或者说有什么改进方法可以实现,有待进一步研究。

  • 自旋锁本身也有自己的缺点,那就是调试不易,在虚拟机异常关闭时可能造成死锁问题。所以在使用自旋锁之前,需要对其进行进一步的封装,增加调试及其异常情况下的解锁功能。在代码设计时也要格外注意,尽量减少自旋锁的使用。由于自旋锁是忙等锁,所以也要尽量减少其锁定时间,在锁定时不要进行IO等耗时操作。

  • 由于自旋锁是基于CPU的原子操作实现的,所以在共享内存中也可以直接使用原子操作实现无锁编程,如实现一读一写的生产者消费者模式的循环缓冲。

由于个人学识有限,其中可能出现谬误,欢迎交流指正!

转载于:https://my.oschina.net/LastRitter/blog/1540270

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值