深入了解C++ memory_order

C++ memory_order

smp_wmb内存屏障

最近在研究DPDK的无锁环形队列代码时,看到个有趣的东西

https://github.com/torvalds/linux/blob/master/include/linux/kfifo.h

/**
 * kfifo_put - put data into the fifo
 * @fifo: address of the fifo to be used
 * @val: the data to be added
 *
 * This macro copies the given value into the fifo.
 * It returns 0 if the fifo was full. Otherwise it returns the number
 * processed elements.
 *
 * Note that with only one concurrent reader and one concurrent
 * writer, you don't need extra locking to use these macro.
 */
#define	kfifo_put(fifo, val) \
({ \
	typeof((fifo) + 1) __tmp = (fifo); \
	typeof(*__tmp->const_type) __val = (val); \
	unsigned int __ret; \
	size_t __recsize = sizeof(*__tmp->rectype); \
	struct __kfifo *__kfifo = &__tmp->kfifo; \
	if (__recsize) \
		__ret = __kfifo_in_r(__kfifo, &__val, sizeof(__val), \
			__recsize); \
	else { \
		__ret = !kfifo_is_full(__tmp); \
		if (__ret) { \
			(__is_kfifo_ptr(__tmp) ? \
			((typeof(__tmp->type))__kfifo->data) : \
			(__tmp->buf) \
			)[__kfifo->in & __tmp->kfifo.mask] = \
				*(typeof(__tmp->type))&__val; \
			smp_wmb(); \
			__kfifo->in++; \
		} \
	} \
	__ret; \
})

其中大部分的代码都可以理解,smp_wmb又是个什么鬼。。
后面百度了一下,smp_wmb的定义如下:

#define smp_wmb() __asm__ __volatile__ ("" : : : "memory")

其中,__asm__ 表示汇编指令,__volatile__表示编译器不能优化该段代码,如果没有该代码,则可能GCC会将其优化掉。
(“” : : : “memory”)表示生成内存屏障代码。
这些仅仅是C层面下的代码,将眼光放到C++上,代码或许会有所不同。

C++内存序

C++11中定义了内存序的基本枚举,其主要是用来做std::atomic类操作的参数。
在这里插入图片描述
英文太多,这里先简单有个印象。
现在看一个简单的例子:

std::atomic<int> g_x = { 0 }, g_y = { 0 };
// 线程1
void Thread1()
{
	g_x.store(1, memory_order_relaxed);
	g_y.store(2, memory_order_relaxed);
}
// 线程2
void Thread2()
{
	if (g_y.load(memory_order_relaxed) == 2)
	{
		assert(g_x.load(memory_order_relaxed) == 1);	// 这里会assert fail么	
	}
}

先说一下答案:有可能assert fail
看起来和代码的预期不一样,明明先执行的g_x.store,后执行的g_y.store,按理来说当g_y赋值成功时,g_x也应该已经被赋值了啊。
的确,从代码层面上看,是这样的。
但在编译器和CPU看来却不一定。

缓存一致性协议 MESI

在单线程程序中,整个程序运行在某一个特定CPU下,其所有数据对该CPU均是可见的。但在多线程程序中,每个CPU拥有自己独立的缓存,此时当A CPU修改了某个变量时,B CPU并不一定能及时知道。此时就产生了缓存一致性的问题。
目前解决该问题的通用办法是引入缓存一致性协议 MESI

缓存一致性协议的动画演示可以参考
https://www.scss.tcd.ie/Jeremy.Jones/VivioJS/caches/MESIHelp.htm

首先我们要明确,CPU缓存的速度是远远大于内存访问速度的。所以CPU会优先取自己缓存的数据。而当A CPU修改了某一个变量时,需要一个机制通知其他CPU去失效对应的数据,从而重新去内存中取。
缓存一致性协议中,规定了缓存的四种状态:

M: 被修改(Modified)
该缓存行只被缓存在该CPU的缓存中,并且是被修改过的(dirty),即与主存中的数据不一致,该缓存行中的内存需要在未来的某个时间点(允许其它CPU读取请主存中相应内存之前)写回(write back)主存。
当被写回主存之后,该缓存行的状态会变成独享(exclusive)状态。
E: 独享的(Exclusive)
该缓存行只被缓存在该CPU的缓存中,它是未被修改过的(clean),与主存中数据一致。该状态可以在任何时刻当有其它CPU读取该内存时变成共享状态(shared)。
同样地,当CPU修改该缓存行中内容时,该状态可以变成Modified状态。
S: 共享的(Shared)
该状态意味着该缓存行可能被多个CPU缓存,并且各个缓存中的数据与主存数据一致(clean),当有一个CPU修改该缓存行中,其它CPU中该缓存行可以被作废(变成无效状态(Invalid))。
I: 无效的(Invalid)
该缓存是无效的(可能有其它CPU修改了该缓存行)。

假如现在有ABC三个线程同时操作x变量。
下面用A.x,B.x和C.x来代表A CPU,B CPU和C CPU下x的缓存状态。

1.A线程读取x,此时A.x为E
2.B线程读取x,此时A.x和B.x均修改为S
3.C线程读取x,此时C.x修改为S
3.A线程修改x,此时A CPU发现有其他CPU同样拥有这个缓存数据。此时将A.x设置为E,并向其他CPU广播x失效消息,B.x和C.x修改为I
4.B线程修改x,因为状态为I,所以会检查其他CPU是否有x。发现A CPU拥有,且属于独占,说明已经修改过。A.x设置为S,并将数据同步到内存中。此时B再从内存中取出数据并写入缓存,B.x设置为E,B CPU发现其他CPU也拥有这个缓存,继而通知其他CPU x失效消息,A.x和C.x修改为I

状态切换比较复杂,这里不进行后续扩展。
不过从上面也可以看出来,一核修改多核联动,效率嘎嘎低。
这种强同步有时候并不是迫切需要的,所以编译器厂商后续引入了Store Bufferes,将所有修改都放入Store buffers中,所有CPU读值时优先读取该缓存的内容,而当所有CPU都成功将状态改为I时,再继续MESI协议。
但因为Store Buffers本身是异步的,A CPU修改了x的值后,线程切换到B CPU上,此时在B CPU上,x变量的cache或许仍然是S或者E,仍然是可信的。此时就会导致数据出错。
为了解决数据出错,及Store Buffers并不是无穷大,数据满了也需要等待的问题,又引入了失效队列。当收到Invalidate请求时,则立刻发送失效请求让其他CPU上的数据失效。
但失效的时机,CPU并不清楚。此时就引入了内存屏障,由程序员主动调用告诉编译器何时处理Invalidate。

指令重排

指令重排(instruction reorder),指的是当程序运行时,可能会将部分与后续执行无关联的指令优先执行,以达到提高效率的作用。
例如:

y = x;
z = 0;

此时如果CPU缓存里不存在x的值,则需要从内存中重新读取。如果按照顺序执行的话,等待内存返回x值的时间就会被白白浪费。此时优先执行z = 0则会大大提升效率。
在缓存一致性协议层面,当数据状态为I或为S的写入时,更容易导致指令重排。

回到上面的例子:
g_x.store(1, memory_order_relaxed);
g_y.store(2, memory_order_relaxed);

atomic::store本身为一个原子操作,但两个操作在memory_order_relaxed层面并不是保证执行顺序。即有可能g_x优先被执行,而g_y后被执行。
此时在B线程看来,g_y缓存已经被同步,而g_x有可能刚进Store Buffers里,B线程还没收到g_x的失效通知。此时assert就会fail。

那能不能解决这类问题呢。答案是可以。memory order就是解决这个事情的。

synchronize-with

字面意思为与。。。同步。其有一种情况。当满足这些情况时,则称该线程的修改操作synchronize-with另一个线程的写入操作

①将一个原子变量以memory_order_release或更强的memory_order的方式写入,并以memory_order_aquire或更强的memory_order的方式读取。

可能很难理解,下面上代码:

typedef struct stPoint {
	int x;
	int y;
}Point;
Point g_pt;
int g_unuse = 0;	// 一个未被读取的变量
std::atomic<int> g_x = { 0 };
// 线程1
void Thread1()
{
	g_pt.x = 5;
	g_pt.y = 6;
	g_unuse = 50;
	g_x.store(1, memory_order_release);
}

// 线程2
void Thread2()
{
	while ((g_x.load(memory_order_require) != 1);
	assert(g_pt.x == 5 && g_pt.y == 6);	// 不会assert fail
}

synchronize-with为两个线程提供了一个memory order的约束,1线程中memory_order_release保存前的所有写操作,对2线程中memory_order_require读取后的所有读操作均可见。这里可见指的是直接可以通过CPU缓存读取到最新的值,即缓存已经同步。且在memory_order_release之前的所有写操作均会被同步。哪怕最终g_unuse没有被使用到。这就会带来一定的冗余性能开销。
为了避免这种开销,使消费线程仅关注自己需要的数据,C++11引入了一种更弱的相关性,叫做dependency-ordered before

dependency-ordered before

dependency-ordered before描述了一种弱于synchronize-with的相关性。其有两种情况。当满足这些情况时,则称该线程的修改操作dependency-ordered before另一个线程的写入操作

①将一个原子变量以memory_order_release或更强的memory_order的方式写入,并以memory_order_consume或更强的memory_order的方式读取。
②在满足①条件的情况下,读线程中有其他变量依赖这个原子变量。(依赖即有直接或间接的引用或计算关系 carries dependency into

下面上代码:

typedef struct stPoint {
	int x;
	int y;
}Point;
std::atomic<Point*> g_pt = { nullptr };
int g_x = 0;
// 线程1
void Thread1()
{
	Point* pt = new Point;
	pt->x = 5;
	pt->y = 6;
	g_x = 100;
	g_pt.store(&pt, memory_order_release);
}

// 线程2
void Thread2()
{
	Point* pt = nullptr;
	while ((pt = g_pt.load(memory_order_consume)) == nullptr);
	assert(pt.x == 5 && pt.y == 6);	// 不会assert fail
	assert(g_x == 100);	// 可能assert fail
}

上述代码中,g_pt原子变量通过dependency-ordered before在1线程中写入,在2线程中读取。其中1线程的g_pt.store(&pt, memory_order_release)与2线程的pt = g_pt.load(memory_order_consume)具有dependency-ordered before关系,而2线程中pt.x == 5 && pt.y == 6也依赖于pt。
故对于**assert(pt.x == 5 && pt.y == 6)来说,1线程的g_pt.store(&pt, memory_order_release);是可见的,不会失败。
但因为g_x并不存在依赖关系,故
assert(g_x == 100)**是可能失败的。

常见和默认的memory ordering

C++为memory order定义了6个枚举值(见开头图),其操作原子变量时,读写使用的枚举通常需要配对使用,不能混用。

relaxed ordering

读写为memory_order_relaxed,这类操作没有任何约束,仅保证当前操作为原子操作。

std::atomic<int> g_x = { 0 }, g_y = { 0 };
int g_v1 = 0, g_v2 = 0;
// 线程1
void Thread1()
{
	g_v1 = g_x.load(memory_order_relaxed);	// I
	g_y.store(memory_order_relaxed);		// II
}
// 线程2
void Thread2()
{
	g_v2 = g_y.load(memory_order_relaxed);	// III
	g_x.store(memory_order_relaxed);		// IV
}

此时可能g_v1 == g_v2 == 42
因为memory_order_relaxed并不保证操作的顺序,故其经过重排后可能会出现IV->II->I->III的执行顺序。

release-aquire ordering

原子读操作为memory_order_aquire
原子写操作为memory_order_release
可以保证写操作前的所有写操作 在读操作后均可见

release-consume ordering

原子读操作为memory_order_consume
原子写操作为memory_order_release
可以保证写操作前的所有具有相关依赖的写操作 在读操作后均可见
不过目前C++的编译器均将memory_order_consume按照memory_order_aquire来实现,在C++17中也将memory_order_consume列为不推荐使用的特性

sequentially-consistent ordering

系统默认的内存序,约束性最强。中文翻译为顺序一致性。以memory_order_seq_cst为参数的原子操作(读写)均为此种内存序。
其操作的所有原子变量在程序启动时拥有一个全局顺序,且因为其约束性最强,会将缓存的读写全部同步,故在两个memory_order_seq_cst之间的代码不会被重排到外部去。

std::atomic<bool> g_b1 = { false }, g_b2 = { false };
std::atomic<int> g_v = 0;
// 线程1
void Thread1()
{
	g_b1.store(true, memory_order_seq_cst);
}
// 线程2
void Thread2()
{
	g_b2.store(true, memory_order_seq_cst);
}
// 线程3
void Thread3()
{
	while (!g_b1.load(memory_order_seq_cst));
	++g_v;
}
// 线程4
void Thread4()
{
	while (!g_b2.load(memory_order_seq_cst));
	++g_v;
}

int main()
{
	// ...
	// 省略线程代码
	assert(g_v.load(memory_order_seq_cst) != 0);	// 不会assert fail
	return 0;
}

由于g_b1和g_b2均为memory_order_seq_cst约束的操作,故g_b1和g_b2的操作是具有一定顺序的。
①g_b1.store比g_b2.store先执行 线程3条件通过 ++g_v
②g_b2.store比g_b1.store先执行 线程4条件通过 ++g_v
无论如何都可以保证g_v不为0

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值