x86_64处理器的指针赋值是原子操作吗?

如题, x86_64处理器的指针赋值是原子操作吗?

说实话我很讨厌参与讨论那些似乎不确定东西,倒不是说我对未知不敬畏,而是参与讨论的人大多数都是似懂非懂,对,我说的不确定性指的是参与讨论的人的认知的不确定,如果你自己都似懂非懂,那么我说什么你都可以反驳我,说些 “貌似,可能,并不绝对” 的词汇来让事情变得混乱。

最近写了一篇文章:
https://blog.csdn.net/dog250/article/details/103911008
里面提到了一句:
x86_64处理器的指针赋值是原子操作吗?
然后有人提出了质疑,其实我这句话后面还有个引用呢:
x86_64处理器的指针赋值是原子操作吗?
后面还夹杂了我的评价。

所以说,我的意思是, 以Intel的手册为准。 我觉得我应该收回上面的那句 “which is a pointer assignment operation, on the x86_64 platform which is an atomic operation.” 然后重新说出个所以然来。

那么,我们就看看Intel的手册怎么说:
https://www.intel.com/content/dam/www/public/us/en/documents/manuals/64-ia-32-architectures-software-developer-vol-3a-part-1-manual.pdf
x86_64处理器的指针赋值是原子操作吗?

有了这段 权威描述 ,本文似乎该结束了。


但是…

show me the code 是个金句,它鄙视的是 talk, 因为 talk is cheap.

那好吧,按照这样看来,上面那一段都是废话,非常cheap,那么下面就用代码说话吧。

声明一下:

  • 以下无术语,也不再引用任何cheap的东西。
  • 本文虽然以“指针赋值”为例,但其实任何“int,long,long long”赋值均试用
  • 我在代码里强转来强转去的,实际在暗示什么呢?CPU和内存不了解什么是指针,只有在执行的时候,一个unsigned long才会 变成 指针。
  • 网络协议,序列化时,请 谨慎相信任何原子操作的承诺!

考虑一个指针p,两个或者多个线程分别对它进行赋值:

long *p;

// thread 1
p = a1;

// thread 2
p = a2

结果可以预期吗?如果你笃信指针赋值是原子操作,那么最终结果,p不是a1,便是a2,这是确定的。

然而Intel手册里说,如果指针p跨越了cacheling的边界,便不能保证赋值操作是原子的,为了复现Intel的说法,从而证明指针赋值并非原子的,只需要给出一个反例,即p既不是a1,也不是a2。

在编码之前,我们先查一下自己实验的机器上的cacheline的大小:

root@zhaoya-VirtualBox:~/xdp/msvc# cat root@zhaoya-VirtualBox:~# cat /sys/devices/system/cpu/cpu1/cache/index0/coherency_line_size
64

OK,64字节的cacheline,我们下面就制造一个跨越64字节边界的指针,直接看代码:

#include <pthread.h>
#include <stdio.h>

// 不让编译器自动扩充结构体对齐特征,这个在网络协议以及序列号操作中很常见。
#pragma pack(1)
struct pack {
	// 60字节的padding
	char unsued[60];
	// cacheline仅剩下4字节,仅够装指针p的4字节,剩余4字节跨越到另一个cacheline
	long *p;
};
#pragma pack()

// 内存对齐粒度最小为1字节,我设置64个元素的数组,总有一个元素恰好是在64字节边界的!
struct pack pa[64];

// 保存64字节边界的元素
struct pack *used;

long a = 0x1122334455667788;
long b = 0x8877665544332211;

void *write_value1(void *param)
{
    for (;;) {
	used->p = (long *)a;
	// 不让编译器优化
	asm volatile("" ::: "memory");
    }
    return NULL;
}

void *write_value2(void *param)
{
    for (;;) {
	used->p =(long *)b;
	asm volatile("" ::: "memory");
    }
    return NULL;
}

int main(int argc, char **argv)
{
	int i;
	long *p;
	pthread_t t1, t2;

	// for循环找到那个64字节边界的pack结构体元素
	for (i = 0; i < 64; i++) {
		unsigned long addr;
		addr = (unsigned long)&pa[i];
		if (addr%64 == 0) {
			// 赋值给used
			used = (struct pack *)addr;
			break;
		}
	}

	pthread_create(&t1, NULL, write_value1, NULL);
	pthread_create(&t2, NULL, write_value2, NULL);

	while (1) {
		p = used->p;
		// 我们看能不能找到既不是a,又不是b的p
		if (p != (long *)a && p != (long *)b) {
			printf("%lx\n", (unsigned long)p);
		}
	}

	return 0;
}

跑一下呗:
x86_64处理器的指针赋值是原子操作吗?
我的天!

这意味着什么?

这意味着不加锁的指针赋值,极其危险!

什么?难道编译器不帮忙?

编译器只能尽量帮忙,哦,对了,还有Linux内核的伙伴系统,slab系统,都只是尽量帮忙,其余的事,只能看造化。谁也不能保证你不会写出上面类似pack的结构体,因此结构体的字段布局非常重要。

即便这样,你也不能保证结构体对象恰好被载入你希望的位置,只要超过一个cacheline大小的结构体,内部字段的赋值就一定小心再小心:

  • 加锁影响性能
  • 不加锁怕跨越边界

说白了这就是个手艺活。

如果你想快速复现指针跨越cacheline的赋值非原子性,直接加个修饰即可:

// 你也可以直接用aligned来固化地址对齐特征,然则不真实!
struct pack pa __attribute__ ((aligned(64)));
...
int main(...
	...
	used = &pa;

不多说了,深入什么单条指令的原子执行,LOCK引脚,cache一致性原子操作粒度等等细节无益于解决实际问题,至于其它体系结构,摸都摸不到(我是说我自己),更显得纸上谈兵,到此为止了。


浙江温州皮鞋湿,下雨进水不会胖!

x86_64处理器的指针赋值是原子操作吗?x86_64处理器的指针赋值是原子操作吗? dog250 博客专家 发布了1546 篇原创文章 · 获赞 4762 · 访问量 1057万+ 他的留言板 关注
上一篇:【Pytorch】详解RNN网络中文本的pack和pad操作


下一篇:java中的包的作用