如题, x86_64处理器的指针赋值是原子操作吗?
说实话我很讨厌参与讨论那些似乎不确定东西,倒不是说我对未知不敬畏,而是参与讨论的人大多数都是似懂非懂,对,我说的不确定性指的是参与讨论的人的认知的不确定,如果你自己都似懂非懂,那么我说什么你都可以反驳我,说些 “貌似,可能,并不绝对” 的词汇来让事情变得混乱。
最近写了一篇文章:
https://blog.csdn.net/dog250/article/details/103911008
里面提到了一句:
然后有人提出了质疑,其实我这句话后面还有个引用呢:
后面还夹杂了我的评价。
所以说,我的意思是, 以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
有了这段 权威描述 ,本文似乎该结束了。
但是…
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;
}
跑一下呗:
我的天!
这意味着什么?
这意味着不加锁的指针赋值,极其危险!
什么?难道编译器不帮忙?
编译器只能尽量帮忙,哦,对了,还有Linux内核的伙伴系统,slab系统,都只是尽量帮忙,其余的事,只能看造化。谁也不能保证你不会写出上面类似pack的结构体,因此结构体的字段布局非常重要。
即便这样,你也不能保证结构体对象恰好被载入你希望的位置,只要超过一个cacheline大小的结构体,内部字段的赋值就一定小心再小心:
- 加锁影响性能
- 不加锁怕跨越边界
说白了这就是个手艺活。
如果你想快速复现指针跨越cacheline的赋值非原子性,直接加个修饰即可:
// 你也可以直接用aligned来固化地址对齐特征,然则不真实!
struct pack pa __attribute__ ((aligned(64)));
...
int main(...
...
used = &pa;
不多说了,深入什么单条指令的原子执行,LOCK引脚,cache一致性原子操作粒度等等细节无益于解决实际问题,至于其它体系结构,摸都摸不到(我是说我自己),更显得纸上谈兵,到此为止了。
浙江温州皮鞋湿,下雨进水不会胖!
dog250 博客专家 发布了1546 篇原创文章 · 获赞 4762 · 访问量 1057万+ 关注