Memory Barrier 内存屏障 和 OUT OF ORDER EXECUTION OOOE OOE 乱序执行 幻读 脏读

发布时间 2023-03-27 16:47:03作者: 秋来叶黄

问题

开发过程中,同一系统上,两个进程,使用了共享内存方式通信。为了追求性能,一个进程是生产者,一个进程是消费者;一个负责写,一个负责读,没有锁。写入完成后,再更新写的标识;读取数据并操作完成后,再更新读取标识,理论上没有问题,但是服务器上运行起来后,会读取到无意义的内容。

读取数据很大或者无效数据,常见的情况:

  • 越界——读到了其他地方
  • 竞争资源——多线程同时写了相同内存
  • 野指针——操作的时候数据已经释放
  • 脏读——已经完成了写操作,但是数据还没有同步到内存或者cpu执行命令乱序导致标志位的代码先执行

越界

调试方法

在读取长度的地方增加判断,查看是否查处了原来的长度。判断空间大小和读写的数据大小必须一致,避免判断空间足够,读写的不一致越界。

如何避免

开发过程中,对于写入空间的大小做好判断,注意边界问题,判断空间大小和写入数据保持一致,避免多读多写的情况

竞争资源

调试方法

可以减少线程查看问题,或者在每个线程操作的地方打印操作的内存和线程id,然后对比是否有同时修改的问题。

如何避免

多线程读写数据时,一定要注意隔离,每个线程单独一个数据空间或者使用锁,避免两个线程同时在操作数据。

野指针

调试方法

野指针一般会触发内存错误,可以编译debug版本,在崩溃是打印堆栈,定位问题,或者在关键函数地方增加进出日志,确定崩溃的地方。

如何避免

不使用的指针最好初始化为空,竞争资源的指针操作必须在锁内,最常见的问题是获取指针的时候加了锁,操作却在外部执行,很有可能操作过程中指针被释放,访问了野指针。

脏读

调试方法

把空闲的空间设置为特殊字符,读取数据后进行判断是否有特殊字符。写入数据可以控制为特定字符,减少调试困难。或者正常写入,读取数据时,判断是否有连续的特定字符。因为不可避免写入数据也会有相同字符,而连续出现概率就比较低,多次连续出现或者连续出现的空间越大,越能说明读取了脏数据。

检测到脏数据,再输出的时候,有可能就正常了,因为输出内容是非常耗时的,这时数据可能已经落盘了,有概率能打印出来异常数据。

如何避免

增加内存屏障。由于系统缓存和乱序等问题,在操作内存和文件时,最好能确保数据已经刷新落盘。如果无法避免问题,可以增加内存屏障或者锁的形式,确保数据落盘。也可以增加校验,确保读取到正确的数据。

内存屏障

内存屏障类似与锁,只不过是cpu级别的,更轻更小一些。主要作用就是确保在屏障之前,一些缓存等操作已经同步完成,而不会出现cpu1和cpu2对于同一个数据,内容不一致的问题。
在linux内核的/usr/src/系统内核版本名称/arch/系统架构x86或者arm64/include/asm/barrier.h文件中有具体的实现,可以参考

arm的内存屏障

内存屏障是比较底层的逻辑,实现方式和使用发放与cpu的架构相关,这里主要介绍一下arm架构下的内存屏障

指令同步屏障 Instruction Synchronization Barrier ISB

汇编指令是isb
指令同步屏障的作用是在屏障前,所有与指令相关的缓存操作都必须已经完成,比如上下文相关的寄存器操作等。从屏障后,所有的指令从新开始——从新从程序加载获取,分配新的寄存器缓存等,新的权限管理。

数据内存屏障 Data Memory Barrier DMB

汇编指令是dmb
数据内存屏障的作用是保证屏障前的内存操作顺序必须在屏障后内存操作之前,也就是保证内存操作顺序与程序代码数序一致,避免出现乱序执行的情况。对每个cpu都是可见的。该屏障不能保证后面的内存操作开始时,前面的内存操作已经完成。

#define smp_mb()  asm volatile("dmb ish" : : : "memory")

保证屏障前后的内存读写顺序,作用范围——cpu

#define smp_wmb()  asm volatile("dmb ishst" : : : "memory")

保证屏障前后内存写顺序,作用范围——cpu

#define smp_rmb()  asm volatile("dmb ishld" : : : "memory")

保证屏障前内存读顺序,作用范围——cpu

#define dma_wmb()  asm volatile("dmb oshst" : : : "memory")

保证屏障前后内存写顺序,作用范围——cpu和外部设备

#define dma_rmb()  asm volatile("dmb oshld" : : : "memory")

保证屏障前内存读顺序,作用范围——cpu和外部设备

数据同步屏障 Data Synchronization Barrier DSB

汇编指令是dsb
数据同步屏障的作用是屏障前的内存访问,以及缓存等操作都已经完成,才能继续后面的操作,比数据内存屏障更严格。

#define mb()  asm volatile("dsb sy" : : : "memory")

保证屏障前读写操作已经完成,作用范围——系统

#define wmb()  asm volatile("dsb st" : : : "memory")

保证屏障前写操作已经完成,作用范围——系统

#define rmb()  asm volatile("dsb ld" : : : "memory")

保证屏障前读操作已经完成,作用范围——系统

乱序

编译器在编译代码的时候会做很多优化,目的很简单,就是为了减少操作,提高效率。但是优化不可避免的会更改我们的逻辑,那就有可能引入新的问题。有时候debug没有问题而release有问题,有可能就是优化导致的。

在计算机工程领域,乱序执行(错序执行,英语:out-of-order execution,简称OoOE或OOE)是一种应用在高性能微处理器中来利用指令周期以避免特定类型的延迟消耗的范式。在这种范式中,处理器在一个由输入数据可用性所决定的顺序中执行指令,而不是由程序的原始数据所决定。在这种方式下,可以避免因为获取下一条程序指令所引起的处理器等待,取而代之的处理下一条可以立即执行的指令。

示例一

没有内存屏障

int a = 0;
int b = 0;
void test()
{
    a = b + 1;
    b = 0;
}

gcc -c -S test.c编译,会在目录下生成一个test.s文件。-c表示只编译,生成目标文件,不链接;-S表示生成汇编代码文件。从test.s文件中可以看到

movl    b(%rip), %eax
addl    $1, %eax
movl    %eax, a(%rip)
movl    $0, b(%rip)

a的赋值和b的赋值顺序与代码中的一致。如果用gcc -c -S -O2 test.c进行编译,可以看到

movl    b(%rip), %eax
movl    $0, b(%rip)
addl    $1, %eax
movl    %eax, a(%rip)

b的赋值被放到了前面,如果代码逻辑与a和b赋值的顺序有关,这时候就会报错。至于为什么b的赋值放到了前面,猜测是为了更高效率,肯定是针对同一块内存的操作,都放到一起更合理,避免多次查询,所以把b的赋值放到了前面一起操作,因为结果上并没有区别。

增加内存屏障

int a = 0;
int b = 0;
void test()
{
    a = b + 1;
    __asm__ __volatile__("": : :"memory");
    b = 0;
}

使用gcc -c -S -O2 test.c编译,可以看到顺序又变回了原来的样子。

movl    b(%rip), %eax
addl    $1, %eax
movl    %eax, a(%rip)
movl    $0, b(%rip)

示例二

不做处理

#include <stdio.h>
#include <pthread.h>
#include <unistd.h>
int num = 1;

void* thread_test1(void* tdata)
{
    while (num == 1) ;
}

void* thread_test2(void* tdata)
{
    sleep(1);
    num = 0;
}

int main()
{
    pthread_t pt[2];
    pthread_create(&pt[0], NULL, thread_test1, NULL);
    pthread_create(&pt[1], NULL, thread_test2, NULL);
    pthread_join(pt[0], NULL);
    pthread_join(pt[1], NULL);
}

创建两个线程,一个是读取num的数值,如果是1,就继续,如果不是,就退出;另一个是sleep 1秒,然后修改num的值。如果是gcc test.c -lpthread编译,程序运行起来后,等1秒,会自动结束。如果用gcc test.c -O2 -lpthread编译,会发现程序一直在运行,没有退出。

这是因为优化后,线程会把num加载到缓存或者寄存器中,然后就不再从内存读取数据,即使后续其他线程修改了这个值。

增加内存屏障

#include <stdio.h>
#include <pthread.h>
#include <unistd.h>
int num = 1;

void* thread_test1(void* tdata)
{
    while (num == 1)
    {
        asm volatile("": : :"memory");
    }
}

void* thread_test2(void* tdata)
{
    sleep(1);
    num = 0;
}

int main()
{
    pthread_t pt[2];
    pthread_create(&pt[0], NULL, thread_test1, NULL);
    pthread_create(&pt[1], NULL, thread_test2, NULL);
    pthread_join(pt[0], NULL);
    pthread_join(pt[1], NULL);
}

使用gcc test.c -O2 -lpthread编译,程序也会正常退出

使用volatile

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

volatile int num = 1;

void* thread_test1(void* tdata)
{
    while (num == 1)
    {
        ;
    }
}

void* thread_test2(void* tdata)
{
    sleep(1);
    num = 0;
}

int main()
{
    pthread_t pt[2];
    pthread_create(&pt[0], NULL, thread_test1, NULL);
    pthread_create(&pt[1], NULL, thread_test2, NULL);
    pthread_join(pt[0], NULL);
    pthread_join(pt[1], NULL);
}

使用gcc test.c -O2 -lpthread编译,程序同样可以正常退出

从上面示例我们可以了解到volatile并不能解决乱序的问题,它只是告诉cpu,每次读取数据的时候都要从内存获得,而不是从缓存获取。

__sync_fetch_and_add

T __sync_fetch_and_add (T* __p, U __v, ...); 

int a = 0;
__sync_fetch_and_add(&a, 1);

gcc自带的线程安全的原子操作,获取原始值返回,再原子增加。这个有一系列其他的操作,比如减,与,或等等。比常见的锁更轻一些,传入指针和需要增加的数值,保证多线程操作下的原子性。

https://developer.arm.com/documentation/100941/0101/Barriers
https://www.intel.com/content/www/us/en/developer/articles/technical/intel-sdm.html