CPU Memory: 为什么需要使用互斥量mutex

多线程的编程需要使用旗语、同步代码段、互斥量或者是任何调用它们的编程语言。我们大多数基本上知道为什么我们需要它们:为了阻止多线程访问同一块内存区域。你是否停下来真正想过,它们究竟是干什么的?为什么你需要保护存储空间?这个听上去应该是CPU该干的活。大多数数的时候,互斥量是一个简单的实现,但是理解为什么需要它却没那么容易。这个答案在于CPU是如何组织存储的,以及它是如何优化你的代码的。

Memory Caches
本文我们假设程序只写自己的私有memory。也就是说它和系统上其他的比如网卡声卡之类的器件,不共享memory。我们假设仅仅是这段程序对它所使用的memory感兴趣。所有和其他外部世界的交互都是经过操作系统的系统调用。当然,这是一个非常简单的场景。

内存颗粒离CPU一般都比较远。如果CPU非得直接从这些内存颗粒写或者读数据,那么我们的电脑将会运行的非常缓慢。为了能够省点儿力气,CPU会将内存颗粒的一部分数据缓存到自己的CPU内部。如果你想要从memory读取数据的时候,CPU以block的形式加载它。当想要写数据时,cpu会缓冲你想要更改的数据,然后在后面再修改。

Coherence
首先,当我们讨论并发程序的时候,我们指的是多核CPU同时执行代码。多线程的程序如果运行在一个核上,那么并不是真正的并发执行。从memory的角度看,单核CPU并不关心一致性,毕竟单核的CPU有着独占的cache line 状态。

此时如果加入第二个core,或者第二个CPU,情况立马就变了。每个core都有自己的cache,因此需要有将它们自己的cache中的数据进行同步的机制。从这个角度看,我们现在的芯片的设计就是非常有意思的了:memory也是同步的。也就是说,不管你怎么认为memory是如何工作的,其实每个core实际上都有自己的正确的,准确的内存视图。如果两个core恰好访问同一个内存空间,CPU会小心处理保证他它们都能获得正确的数据。这就是所谓的cache coherency。

原子性与Cache
每种基本类型,比如整型,字节行,long和double(按architecture而定)都可以在没有memory冲突的情况下自动的处理好。如果一个core想要写一个整数到cache,另一个core然后读取这个位置,那么就会读到新值。更准确的说,一个core不会cache中读到旧值。具有原子性意味着整个整型数是被一起写入的。不会发生只更新了2byte数据,另一个core就读走数据的情况。原子性也可以应用到读侧。如果一个core从cache中读取数据,那么它可以读到完整的数值,而不会一半是新的一半是旧的。

然而如果你有一个大型的数据结构,那么情况就变了。如果一个结构体由4个整型数组成,将它们写入到memory将会需要4次原子写操作(这里其实原作者可能还有问题,如果往cacheline里面考虑,那还是会按照cacheline的大小,64byte或者32byte对齐的,不过数据结构大小大于一个cacheline,应该也会遇到相同的问题)。然而4次原子写操作,这个序列自身,却不是原子的。在写入了4byte的时候可能另一个core就过来开始读数据了,这就会导致另一个core可能拿到两个旧值和两个新值。

这时,就到了lock登场的时候。如果多线程中的代码使用lock锁住这4个整型数,那么另一个core就无法读到这个数据,直到一个core已经完成了操作并且释放了锁。这就是多线程的同步的最常见的用途了。

Reorder
考虑一下的情况,如果我们想要在给链表增加一个元素,我们会进行以下操作
1)创建元素
2)初始化元素的所有成员
3)将链表尾部指向创建的元素
如果3)步骤是一个原子操作,那么另一个线程要么会看到新增加的元素,要么看不到。

然而事情并不是这样。另一个线程可能会看到新增加的元素,而后才看到元素的值被初始化(这应当是weekorder的cpu)。要记住从内存读取数据是很慢的,尽管cache做了很多,但是还不够。为了能够尽可能的的利用最高速的cache,CPU将会改变从cache读写data的顺序。当你按照1,2,3的顺序写的时候,CPU实际上写的顺序可能是1,3,2。这就导致另一个线程在看到3的时候,可能还没有看到2。

这就是为什么只是锁住memory的一部分还不够,考虑到cpu可以reorder写memory的顺序,我们标准的lock,比如mutex,还有另一项功能,fence(屏障)。如果两个线程都是使用了fence,那么第二个线程就会在数据完全写入之后看到,而不是单单的只看到一部分了。

Mutex
当然这是一个整体工作流程非常简单的概括,尤其是考虑到fence是如何解决这个问题的。我们需要mutex的关键原因一个是在于memory 访问的重排序,另一个就是大数据结构的缺乏原子性。典型的mutex都可以解决这些问题。它保证所有的线程都有着完全、准确的memory视角。

额外补充:barrier(fence)和mutex(lock)的区别

memory barrier是一个硬件操作,是一条指令,是用来设置memory访问顺序的(比如X86的LFENCE,SFENCE,MFENCE)。编译器和cpu都可以改变memory访问顺序以实现优化,但是在多线程时,这会带来问题。memory barrier与mutex的区别在于,另一个线程并不会被memory barrier挡住(当然如果要是有store指令,那可能还是会影响其他现线程的执行的。考虑到strong order,weak order,这就更加复杂了)它只会影响当前线程内部的对memory的访问顺序。在编程时,我们使用mutex和旗语,这些底层的实现可能是使用了memory barrier。

mutex则是更高级的原语,是由操作系统实现的。通过这个原语,可以实现多线程的同步,因为这个mutex在操作系统设计时,就已经加入了memory barrier的指令,所以使用mutex时,不需要额外的加入fence了。


欢迎关注我的公众号《处理器与AI芯片》

你可能感兴趣的:(C++,Microarchiture)