并发编程的痛点

210 阅读3分钟

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 整数溢出

状态机分析 image.png

例子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. 指令 1:首先,需要把变量sum从内存加载到 CPU 的寄存器;
  2. 指令 2:之后,在寄存器中执行 +1 操作;
  3. 指令 3:最后,将结果写入内存(缓存机制导致可能写入的是 CPU 缓存而不是内存)。

image.png 如果线程 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? 现代处理器也是编译器!

为了帮助理解后面的内容,这里补充一点汇编知识:

image.png

image.png

好了,我们重新回到这个问题:

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 已经是市面上能买到的 “最强” 的内存模型了 😂

image.png

实现顺序一致性

image.png 软件做不到,硬件来帮忙

  • 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…