1. 原子性的破坏
例子1:代码原子性的破坏
#include <thread.h>
unsigned long balance = 100;
void Money_withdraw(int amt) {
if (balance >= amt) {
balance -= amt;
}
}
void Tmoney(int id) {
Money_withdraw(100);
}
int main() {
create(Tmoney);
create(Tmoney);
join();
printf("balance = %lu\n", balance);
}
balance = 0
balance = 18446744073709551516 整数溢出
状态机分析
例子2: 指令原子性的破坏
#define N 100000000
long sum = 0;
void Tsum() { for (int i = 0; i < N; i++) sum++; }
int main() {
create(Tsum);
create(Tsum);
join();
printf("sum = %ld\n", sum);
}
sum++ 至少需要三条 CPU 指令。
- 指令 1:首先,需要把变量sum从内存加载到 CPU 的寄存器;
- 指令 2:之后,在寄存器中执行 +1 操作;
- 指令 3:最后,将结果写入内存(缓存机制导致可能写入的是 CPU 缓存而不是内存)。
如果线程 A 和线程 B 按照上图的序列执行,那么我们会发现两个线程都执行了 sum+=1 的操作,但是得到的结果不是我们期望的 2,而是 1。
问题的本质:原子性的丧失,单线程里 程序(Money_withdraw的一段代码)甚至是一条指令(sum 的一条add指令)独占处理器执行 的基本假设不再成立。
- 单处理器多线程:线程运行时会被中断,切换到其他的线程。
- 多处理器多线程:根本就是并发执行的。
2. 顺序性的丧失
还是求和的例子:
#define N 100000000
long sum = 0;void Tsum() { for (int i = 0; i < N; i++) sum++; }
int main() {
create(Tsum);
create(Tsum);
join();
printf("sum = %ld\n", sum);
}
编译优化
gcc -O1 sum.c -lpthread && ./a.out
gcc -O2 sum.c -lpthread && ./a.out
sum = 100000000
sum = 200000000
- -O1: R[eax] = sum; R[eax] += N; sum = R[eax]
- -O2: sum += N; 内存直接加N 你的编译器可能会出现不同的结果。
顺序的丧失: 编译器对内存访问 “eventually consistent(最终一致)” 的处理导致共享内存作为线程同步工具的失效。
3. 可见性
例子:
int x = 0, y = 0;
void T1() {
x = 1;
asm volatile("" : : "memory"); // compiler barrier, 编译器不会优化
printf("y = %d\n", y);
}
void T2() {
y = 1;
asm volatile("" : : "memory"); // compiler barrier
printf("x = %d\n", x);
}
理论能看到几种结果?
- 1 0
- 0 1
- 1 1
#include "thread.h"
int x=0, y=0;
atomic_int flag;
#define FLAG atomic_load(&flag)
#define FLAG_XOR(val) atomic_fetch_xor(&flag, val)
#define WAIT_FOR(cond) while (!(cond));
__attribute__((noinline))
void write_x_read_y() {
int y_val;
asm volatile(
"movl $1, %0;" // x=1
"movl %2, %1;" // y_val = y
: "=m"(x), "=r"(y_val): "m"(y) // %x 按顺序 x(%0), y_val(%1), y(%2)
);
printf("%d ", y_val);
}
__attribute__((noinline))
void write_y_read_x() {
int x_val;
asm volatile(
"movl $1, %0;" // y=1
"movl %2, %1;" // x_val = x
: "=m"(y), "=r"(x_val): "m"(x)
);
printf("%d ", x_val);
}
void T1(int id) {
while (1) {
WAIT_FOR((FLAG & 1)); // wait X1, 等待T1的开关被推上去
write_x_read_y();
FLAG_XOR(1); //X1 -> X0, T1开关推下去
}
}
void T2(int id) {
while (1) {
WAIT_FOR((FLAG & 2)); // wait 1X, 等待T2的开关被推上去
write_y_read_x();
FLAG_XOR(2); // //1X -> 0X, T2开关推下去
}
}
void Tsync() {
while(1) {
x = y = 0;
__sync_synchronize(); // full barrier, 确保x=y=0 写入内存
usleep(1);
assert(FLAG == 0); // 确定开关属于关闭的状态
FLAG_XOR(3); // 开关 00 -> 11
// 此时 Tsync 等待 T1 T2 处理Flag 为 00
WAIT_FOR(FLAG == 0);
printf("\n");
fflush(stdout);
}
}
int main() {
create(T1);
create(T2);
create(Tsync);
}
实际能看到几种结果?
- 0 0
- 1 0
- 0 1
- 1 1 Why 0 0? 现代处理器也是编译器!
为了帮助理解后面的内容,这里补充一点汇编知识:
好了,我们重新回到这个问题:
mov $1, x(%rip)
mov y(%rip), %rax
cpu 从icache 把这些指令读出来,
cpu 会把这些汇编代码用电路翻译成更小的 uops,
mov $1, x(%rip)
变成:
RF[7] = X + RF[9] // 比如rip就在9号寄存器里面
RF[8] = RF[7] + 1
这样处理器就看得懂这些 uops,处理器会fetch, issue, execute, commit
这里这两条uops是有互相的数据依赖,但是如果这些“顺序”的uops没有数据依赖呢?
处理器在一个时钟周期会同时issue多个uops!
同时执行多个uops,这些uops都是来自于同一个 icache。
可见性 的定义是:一个线程对共享变量的修改,另外一个线程能够立刻看到。在单核时代,所有线程都在一个CPU上执行,所以一个线程的写,一定是对其它线程可见的。
满足单处理器 eventual memory consistency 的执行,在多处理器上可能无法序列化!
当 x≠y(内存地址) 时,对 x, y 的内存读写可以交换顺序
- 它们甚至可以在同一个周期里完成 (只要 load/store unit 支持)
- 如果写 x 发生 cache miss ,处理器可以看到下一条指令,可以让读 y 先执行
- 满足 “尽可能执行 μop” 的原则,最大化处理器性能 (注释:cache miss 意味着即时可见性的丧失,就是这个cache line不是S状态,而变成E状态,且需要通过MESI缓存一致性协议使得其他cpu也感知到这个变量的变化并设置成I。但是由于MESI经过了store buffer优化,不能及时更新cache 和内存,还有invalid queue 优化,有一段时间内,其他CPU并不能把自己cache line标记为失效,即无法感知到这个变化。 zhuanlan.zhihu.com/p/84500221 最终不同处理器数据不一致。)
# <-----------+
movl $1, (x) # |
movl (y), %eax # --+ // 先执行了
- 在多处理器上的表现
- 两个处理器分别看到 y=0 和 x=0
宽松内存模型 (Relaxed/Weak Memory Model)
宽松内存模型的目的是使单处理器的执行更高效。 x86 已经是市面上能买到的 “最强” 的内存模型了 😂
实现顺序一致性
软件做不到,硬件来帮忙
- Memory barrier: __sync_synchronize() (RTFM)
- Compiler barrier + fence 指令
- 插入 fence 指令后,将阻止 x=y=0
__attribute__((noinline))
void write_x_read_y() {
int y_val;
asm volatile(
"movl $1, %0;" // x=1
"mfence;"
"movl %2, %1;" // y_val = y
: "=m"(x), "=r"(y_val): "m"(y) // %x 按顺序 x(%0), y_val(%1), y(%2)
);
printf("%d ", y_val);
}
这样结果就没有0 0 了
- 原子指令 (lock prefix, lr/sc, ...)
- stdatomic.h
reference: anguei.blog.luogu.org/optimize-O2 stackoverflow.com/questions/2… juejin.cn/post/684490… 可见性 juejin.cn/post/684490…