CPU的原子指令

写在最前面

原子指令是构成所有锁的基础,没有原子指令就不可能有锁,特别是在SMP的环境下尤其重要。

linux内核的锁
更重要的是原子指令不是内核的专享,用户态也是可以使用的,所以才成就了JUC这种用户级的无锁算法,成就了一众编程语言高并发的梦想。

我们来看一段直接使用原子指令来累加的代码,我在“多线程编程那些事”一文中有阐释过:

#include <iostream>
#include <string>
#include <thread>
#include <mutex>
std::mutex g_display_mutex;
const int TC = 4;
int count = 0;
bool flag = true;
void inc_count()
{
   // count += 1; //不使用原子指令
   asm volatile(  //使用原子指令
	"lock addq $1, %0"
	:"+m"(count)
	: :"memory","cc"
	);
}
void testRun()
{
    for(int i=0; i<10000;i++)
    {
        inc_count();
        std::cout << "child thread id:" << std::this_thread::get_id() <<" count is:" <<count<<std::endl;
   }
}

int main()
{
    std::thread threads[TC]; // 默认构造线程
    for (int i = 0; i < TC; ++i)
    {
        threads[i] = std::thread(testRun); // move-assign threads
    }

    std::this_thread::sleep_for(std::chrono::seconds(2));
    std::cout << "main thread id:" << std::this_thread::get_id() << std::endl;
    flag = false;
    for (auto &thread : threads)
    {
        thread.join();
    }

    std::cout << "All threads joined!"
              << " count:" << count<<std::endl;
}
child thread id:139640765933248 count is:2
child thread id:139640774325952 count is:3
child thread id:139640757540544 count is:4
child thread id:139640757540544 count is:5
child thread id:139640757540544 count is:6
child thread id:139640757540544 count is:7
child thread id:139640757540544 count is:8
child thread id:139640757540544 count is:9
child thread id:139640757540544 count is:10
child thread id:139640757540544 count is:11
child thread id:139640757540544 count is:12
child thread id:139640757540544 count is:13
child thread id:139640757540544 count is:14
child thread id:139640757540544 count is:15
child thread id:139640757540544 count is:16
......
child thread id:139640757540544 count is:39996
child thread id:139640757540544 count is:39997
child thread id:139640757540544 count is:39998
child thread id:139640757540544 count is:39999
child thread id:139640757540544 count is:40000
main thread id:139640781685760
All threads joined! count:40000

可见不用mutex只用汇编的lock addq也能实现多线程累加,这完全得益于原子指令的威力;而本质上来说,mutex其实是由粒度更细的spinlock实现的,而spinlock则由粒度最细的原子指令实现。

那么什么是原子指令的本质呢?

普通的add $1 count(%rip)为何不是原子的?这跟CPU执行指令的过程有关。加一指令其实远远没有看上去这么简单,它进过了三个步骤才能完成;

1、从内存load count变量到CPU寄存器;加入放入了a寄存器;

2、再将a寄存器的值发送到累加器执行加法操作;

3、将结果写回到count内存。

而这三个步骤不是原子的,多线程访问会出现竞争,但是问题又来了,那么多线程为什么会竞争count变量呢?

原来产生问题除开指令不是原子执行的,还有个关键的因素:变量共享多线程模型。这也是主流的线程模型:

1、线程由进程创建,也叫做Light weight process(LWP)

2、所有线程共享进程的资源,其中就包括虚拟地址空间,本质上是共享了进程的页表,现成可以访问进程所有的共有数据——变量;

3、每个线程共享进程的全局变量,但是每个线程有自己独占的本地栈,用来运行本地方法,所以count++起始是在线程本地栈中执行的,这才是问题的关键。

所以,当有2个线程来对count+1操作;考虑到这个场景:t1t2同时启动;t1的本地栈中count=0;t2的本地栈中count也=0;那么问题就产生了,经过他们+1操作以后都是1,然后会写公用内存就产生了重复+1的问题了。这才是问题的根源,我们总结下就是两个:

1、count+1指令不是原子指令;

2、多线程共享变量count,但是不共享操作栈。

如何开发出高并发的程序

所以,按照这个逻辑,如何开发出高并发的程序?无非就是解决这两个问题:

1、设计更快的原子指令;

2、不让多线程共享变量。

  • 更快的原子指令涉及到哪些优化?加快指令的速度(减少context-switch),lock-free自旋无锁编程等等;
  • 不共享变量涉及到哪些优化?map-reduce的分布式框架大概就是这个思想,先map load,将load切分;然后分发给每个进程的本地栈完成计算;最后reduce回来汇总结果;Linux的PERCPU结构的设计等等,只要彻底放飞每个CPU core就行(原子指令是有很重的开销的,linux内核的RCU就是宁愿损失一些一致性也要拉高吞吐量)。

如果你考虑自己设计高并发,高吞吐的组件,可以用类似的分析方法来找到解决思路。

X86的原子指令有哪些?

每个CPU都有自己的原子指令,Linux会用宏将需要原子操作的函数封装起来,再根据不同CPU构架编写不同的原子代码。

READ_ONCE

读取一个内存地址的函数READ_ONCE就是linux平台的原子load指令,用法比如arch_atomic_read函数:

int val = arch_atomic_read(v);
...
#define arch_atomic_read atomic_read
...
#define atomic_read(v)  READ_ONCE((v)->counter)

本质上是调用了READ_ONCE宏,而READ_ONCE在x86下展开就是这个:

#define __READ_ONCE(x)	(*(const volatile __unqual_scalar_typeof(x) *)&(x))

起始啥也没干,就是输出变量本身。我们再看看arm64平台:

#define __READ_ONCE(x)							\
({									\
	typeof(&(x)) __x = &(x);					\
	int atomic = 1;							\
	union { __unqual_scalar_typeof(*__x) __val; char __c[1]; } __u;	\
	switch (sizeof(x)) {						\
	case 1:								\
		asm volatile(__LOAD_RCPC(b, %w0, %1)			\
			: "=r" (*(__u8 *)__u.__c)			\
			: "Q" (*__x) : "memory");			\
		break;							\
	case 2:								\
		asm volatile(__LOAD_RCPC(h, %w0, %1)			\
			: "=r" (*(__u16 *)__u.__c)			\
			: "Q" (*__x) : "memory");			\
		break;							\
	case 4:								\
		asm volatile(__LOAD_RCPC(, %w0, %1)			\
			: "=r" (*(__u32 *)__u.__c)			\
			: "Q" (*__x) : "memory");			\
		break;							\
	case 8:								\
		asm volatile(__LOAD_RCPC(, %0, %1)			\
			: "=r" (*(__u64 *)__u.__c)			\
			: "Q" (*__x) : "memory");			\
		break;							\
	default:							\
		atomic = 0;						\
	}								\
	atomic ? (typeof(*__x))__u.__val : (*(volatile typeof(__x))__x);\
})

可见内容就丰富了很多,至少编译了一条__LOAD_RCPC指令,这就是ARM平台的ldar屏障指令。所以,我们可以得出结论,x86平台下,通过地址load内存数据的指令是原子的。手册的第三卷第8章有详细的解释,这里截取部分说明:

x86原子指令

主要讲了两点:

1、只要是64byte对齐的(后面单独讲什么是字节对齐,这里不展开)byte,word,doubleword或者qword位地址,它们的赋值与加载x86 CPU都保证原子性;(其实就是Cacheline大小对齐的数据结构);

2、对于跨越Cacheline边界的数据结构CPU不保证,请自己用mutex或者spinlock去维护原子性;

3、你可能会问什么是cacheline?看这里

你可能会问,为什么会造成这个情况?为啥armx86会不一样?因为,同样一条movq $0x0,0x8(%rax)的指令,其实在CPU内部的译码模块会继续分解成很多条的微指令(micro code),这才是真正的可以执行的代码,所以这些微指令执行是不是原子的才能决定mov指令是不是原子执行的。

可见,复杂指令集的x86比精简指令集的arm废了不少心,多了很多功能。

最后,看个例子:

#define smp_cond_load_relaxed(ptr, cond_expr) ({		\
	typeof(ptr) __PTR = (ptr);				\
	__unqual_scalar_typeof(*ptr) VAL;			\
	for (;;) {						\
		VAL = READ_ONCE(*__PTR);			\/*对指针的赋值指令在x86-64下是原子的*/
		if (cond_expr)					\ /* cond_expr就是VAL,当VAL也就是*ptr变成1的时候,获取了锁。*/
			break;					\
		cpu_relax();	 /*NOP指令空转,让CPU歇会儿。*/				\
	}							\
	(typeof(*ptr))VAL;					\
})

这就是spinlock的一个自旋逻辑。注意看:这里没有用CAS指令,而是用了READ_ONCE,原因是即便是CAS这种CPU级别的操作,linux都觉得重了,毕竟是一条总线锁。

WRITE_ONCE

WRITE_ONCE是READ_ONCE的相反操作,原子地将值写入内存。比如:

static __always_inline void arch_atomic_set(atomic_t *v, int i)
{
	__WRITE_ONCE(v->counter, i);
}
#define __WRITE_ONCE(x, val)						\
do {									\
	*(volatile typeof(x) *)&(x) = (val);				\
} while (0)

跟上面一样,x86下的代码,就是一条漫不经心的赋值操作,即可保证在SMP下的原子性。

bus lock vs cache lock

这里说说bus lock与cache lock都是啥?当然有个前提,CPU是SMP构架的——就是多核。

  • 通过锁总线的方式实现的原子性,叫做bus lock;比如著名的lock prefix信号,CAS操作;
  • 通过缓存一致性协议保证的原子性,叫做cacheline lock,比如READ_ONCE与WRITE_ONCE都是。

注意:bus lock因为比cache lock层次更高,所以性能更差,这跟缓存金字塔密切相关,越远离CPU访问的延迟越高。

但是值得注意的是,x86是很智能的,如果你用bus lock实现一个原子操作,cpu发现要操作的数据已经cache了,就不会去锁总线,而是直接走MESI协议保证原子性了。

bus locking to cache lock

CMPXCHG

或者叫做CAS,其实是很简单的,但作用非常大,可以说有了这条指令,spinlock就诞生了。我们看看Linux中的包装:

static __always_inline void queued_spin_lock(struct qspinlock *lock)
{
	int val = 0;
	//原子指令来比较是否获得了锁。如果lock->val是0(val),则说明获取了锁,把lock->val变成_Q_LOCKED_VAL==1。
	//如果lock->val == val==0 则lock->val=1,返回val==0;获得锁;
	//如果lock->val != val,则val=lock->val,返回lock->val

	if (likely(atomic_try_cmpxchg_acquire(&lock->val, &val, _Q_LOCKED_VAL)))
		return;
	//走到这里来val的值是lock->val的最新值. 肯定不是0. val一共4个字节,公用了很多信息,不一定就是1了。
	queued_spin_lock_slowpath(lock, val);
}

这是linux下spinlock的实现其中atomic_try_cmpxchg_acquire(ptr , old , NEW)就是一条CAS指令,语义是:

1、原子的load *(ptr)的最新值;

2、这个值跟old的值比较,如果*ptr == *old,则原子地用NEW值更新ptr的值;并返回old值;

3、如果*ptr != *old,则返回 *ptr的最新值。往往后面会跟上 *old = *ptr以方便进行自旋。

4、也是最关键的一条,上面三条在SMP中是原子的,对应的汇编是:lock cmpxchg

举个自旋锁的小栗子:

for(;;){
if(!cmpxchg(lock,0,1))
 	break;
}

可以看到这条指令其实就是为自旋这个业务场景设计的。

总结

盘点那些CPU的原子操作:(参考x86手册第3卷第8章内容)

原子指令集合

  • XCHG指令,不用加lock就是原子的——bus lock

  • 设置TSS段的B flag——bus lock

  • 更新GDT —— bus lock

  • 更新页表或者页目录——bus lock

  • 发生中断时,中断信号路由到特定CPU,CPU回复ack信号给PIC是原子的——bus lock

  • 加了lock prefix的普通指令——bus lock

    • lock指令
  • 对包含在同一个Cacheline的数据结构进行读写都是原子的——cache lock。

引申

高并发分布式系统的核心不在于响应速度,而在于scalability——可扩展性——难点也在这里,当系统从10台服务器扩展到100台,再扩展到1000或者10000台的时候,整个系统的构架都在维护与保障硬件的投入产出的比例在可控范围内,但是,工程就是这样,边际效应会递减;有时候因为只增加了少量的资源而使得系统效能急剧下降,从而不得不彻底重新设计构架,也就是每个构架都是有极限的。所以,最近10年比较火热的技术都集中在云原生,容器这些提高分布式扩展性的工具上。

但是,事情真的不是偶然的,就在这10年,Linux内核本身也在可扩展性上激烈的前进着:

  • Linux内核就像一个小的高并发系统,后台服务器程序对应Linux kernel;内核对对多核的协调控制已经做的十分极致了,从spinlock的发展就能看出,从最初的CAS到后面的ticket spinlockMCS spinlock到最后的qspinlock都提现了极致的优化过程,不断地在提高linux内核的吞吐量与扩展性。
  • 为了获得极致的业务与性能的平衡,Linux还发展并完善了RCU子系统——一个真正的无锁并发控制机制,并在内核大量使用,性能有比spinlock进步很多,已经逼近极限了。但是RCU的前提也是构建在原子指令上的——同一个Cacheline中的数据读写是原子的(所以内核中大量出现了____cacheline_aligned的标签,如果你对内核感兴趣可以了解下)——所以指针的赋值与更新操作是原子的——所以RCU是可行的。

世界几乎所有软件知识都能在Linux中找到原型,找到历史。

  • 1
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值