读书笔记(1)—— kernel 屏障问题

内核中的同步和并发

其实并发和同步是操作系统设计中的一个核心问题,随着CPU的发展,多核多线程已经成为了主流,因此并发和同步是操作系统和内核开发者必须面临的问题。
其实并发包括两类:
一类是真正的同时发生, 在宏观尺度和微观尺度上都是并行执行的,这是多核CPU多级流水的情况;
另一类是时分复用导致的交错发生,并不是真正的并发。在宏观尺度上是并行 但在微观尺度上是串行,这是单核处理器,只有一个流水线在执行。
不管是哪类并发,都存在一个共享资源(临界区)保护的问题。临界区保护通常采用的手段就是串行化,其中最常见的串行化手段就是锁(但锁并不是唯一的保护共享资源的手 段)。锁分很多种,但是各自有不同的特征和适宜场景,而且有些锁并不绝对禁止并发(比如读写 自旋锁、读写信号量等等)。除了锁以外,其他各种类型的原语也被用于对共享资源的保护(也就是同一时刻资源只能被一个对象所访问)。在实践开发中,开发者经常碰到并发与同步相关的问题, 如各种操作原语的特征、区别、局限性以及使用上的各种权衡。这些问题往往比较微妙而且难以彻底理解,一旦不正确地使用操作原语,往往会导致各种奇奇怪怪并且难以调试的BUG。下面简单介绍几种同步和原子操作,是在内核中经常被使用的,说的不正确的请批评指点。

屏障问题

编译器在优化过程中会对指令重新排序,高性能处理器在执行指令的时候也会引入乱序执行。这些行为理论上都是不应该改变源代码逻辑行为的,但实际上编译器和处理器只能保证显式的控制依赖和数据依赖没有问题。而源代码中往往存在各种隐式的依赖,这些依赖性如果被破坏就会产生逻辑错误。
举个简单的生产者-消费者案例:
生产者和消费者是两个进程,运行在不同的 CPU 上, 它们通过一个缓冲区 buffer 交换共享数据,另有一个变量 flag 来标识缓冲区是否准备好。 源代码顺序如下:

int flag = 0;
char buffer[32] = {0}; 
void producer(void)
{ 
  memset(buffer, 1, 32); 
  flag = 1;
  } 
  
void consumer(void)
{ 
  char data[32] = {0};
  
  while (!flag) ;
  memcopy(&data,buffer,sizeof(buff);
} 

按源代码的字面顺序,comsumer()进程应该得到 data 中的各个元素都为1的结果,实际上却未必。因为:第一, 在producer()中 buffer 和 flag 本身没有任何显式的控制依赖和数据依赖,因此 flag 的 赋值可能先于 memset()执行;第二,comsumer()中 buffer 和 flag 本身也没有任何显式的 控制依赖和数据依赖,因此 data 的赋值可能先于 while()循环执行。
可能到这里有点懵,为什么明明在代码上写的,逻辑顺序生产者的flag的赋值操作要优先于buff的赋值,消费者上,flag的读取要优先于data的读取,为什么还会出现上面逻辑顺序无法保证的问题呢?其实问题就在编译器的优化和CPU的乱序执行上。在逻辑上 buffer 和 flag 是存在依赖的,data和flag也是存在依赖的,但这种依赖无法被编译器和处理器识别。
因此,我们必须人工加上屏障来保证顺序,以防止指令重排和乱序执行对程序产生的影响。
防止编译器对指令重排的屏障叫优化屏障,防止处理器乱序执行的屏障叫内存屏障

(1)优化屏障

优化屏障定义如下:

#define barrier() __asm__ __volatile__("": : :"memory")

优化屏障 barrier()是一个__asm__内嵌汇编语句,并不产生任何额外的指令。但是内嵌汇编中的__volatile__关键字可以禁止__asm__语句与其他指令重新组合;而memory 关键字强制让编译器假定__asm__语句修改了内存单元,让本语句前后的访存操作生成真实的访存指令而不会通过寄存器来进行优化。

优化屏障可以防止编译器对前后的访存指令重新排序,但并不能防止处理器的乱序执行
这里拿比较经典的问题,生产者和消费者问题为例:在生产者-消费者中,如果处理器是顺序执行的,那么插入优化屏障即可保证程序的执行逻辑正确,代码如下:

int flag = 0;
char buffer[32] = {0}; 
void producer(void)
{ 
  memset(buffer, 1, 32); 
  barrier();
  flag = 1;
  } 
  
void consumer(void)
{ 
  char data[32] = {0};
  
  while (!flag) ;
  barrier()memcopy(&data,buffer,sizeof(buff);
} 

为了防止单个变量读写的编译器优化,barrier()还有三个变种:
READ_ONCE()、WRITE_ONCE()和 ACCESS_ONCE(),使用方法如下:
a = READ_ONCE(x):功能上等同于 a = x,但保证对 x 生成真实的读指令而不被优化。
代码实现如下:

#define READ_ONCE(x)   __READ_ONCE(x, 1)

#define __READ_ONCE(x, check)___________\
({__________________\
__union { typeof(x) __val; char __c[1]; } __u;______\
__if (check)______________\
______read_once_size(&(x), __u.__c, sizeof(x));___\
__else________________\
______read_once_size_nocheck(&(x), __u.__c, sizeof(x));_\
__smp_read_barrier_depends(); /* Enforce dependency ordering from x */ \
____u.__val;______________\
})

void __read_once_size(const volatile void *p, void *res, int size)
{
____READ_ONCE_SIZE;
}

#define __READ_ONCE_SIZE____________\
({__________________\
__switch (size) {_____________\
__case 1: *(__u8 *)res = *(volatile __u8 *)p; break;____\
__case 2: *(__u16 *)res = *(volatile __u16 *)p; break;____\
__case 4: *(__u32 *)res = *(volatile __u32 *)p; break;____\
__case 8: *(__u64 *)res = *(volatile __u64 *)p; break;____\
__default:______________\
____barrier();____________\
______builtin_memcpy((void *)res, (const void *)p, size);_\
____barrier();____________\
__}_______________\
})

WRITE_ONCE(x, b):功能上等同于 x = b,但保证对 x 生成真实的写指令而不被优化。
代码实现如下:

#define WRITE_ONCE(x, val) \
({______________\
__union { typeof(x) __val; char __c[1]; } __u =_\
____{ .__val = (__force typeof(x)) (val) }; \
____write_once_size(&(x), __u.__c, sizeof(x));__\
___})

static __always_inline void __write_once_size(volatile void *p, void *res, int size)
{
__switch (size) {
__case 1: *(volatile __u8 *)p = *(__u8 *)res; break;
__case 2: *(volatile __u16 *)p = *(__u16 *)res; break;
__case 4: *(volatile __u32 *)p = *(__u32 *)res; break;
__case 8: *(volatile __u64 *)p = *(__u64 *)res; break;
__default:
____barrier();
______builtin_memcpy((void *)p, (const void *)res, size);
____barrier();
__}
}

READ_ONCE()和 WRITE_ONCE()是 Linux-3.19 开始才引入的,在那之前只能用 ACCESS_ONCE():a = ACCESS_ONCE(x)等价于 a = READ_ONCE(x),ACCESS_ONCE(x) = b 等价于 WRITE_ONCE(x, b)。
注意:ACCESS_ONCE()是有缺陷的,只能针对不超过处理器字长的数据类型,否则无法保证原子性。目前在最新的内核代码(linux_5.2.1)中已经废除了ACCESS_ONCE()代码实现这里不做详细介绍了。READ_ONCE()和 WRITE_ONCE()没有这样的缺陷,它们在超过处理器字长的数据类型(比如在结构体和联合体)上会退化成使用 memcpy()来读写。

(2)内存屏障

内存屏障用于解决内存一致性(即内存有序性)问题。CPU 的内存一致性模型有严格 一致、处理器一致、松散一致等模型,这些模型具体的实现,这里不做详细的介绍。现代高性能 CPU 包括龙芯在内大都使用松散一致性模型,访存指令会存在乱序执行的情况。
为了防止访存指令在处理器上乱序执行的内存屏障有很多种,主要有:
mb(): 全屏障,可以防止读内存操作(Load)和写内存操作(Store)的乱序执行。

#ifndef mb
#define mb()__barrier()
#endif

rmb(): 读屏障,可以防止读内存操作(Load)乱序执行,不干预写内存操作(Store)。

#ifndef rmb
#define rmb()_mb()
#endif

wmb(): 写屏障,可以防止写内存操作(Store)乱序执行,不干预写内存操作(Load)。

#ifndef wmb
#define wmb()_mb()
#endif

smp_mb():多处理器版全屏障,在多处理器系统上等价于 mb(),可以防止读内存操作
(Load)和 写内存操作(Store)的乱序执行;在单处理器上等价于优化屏障 barrier()。

#ifndef __smp_mb
#define __smp_mb()__mb()
#endif

smp_rmb():多处理器版读屏障,在多处理器系统上等价于 rmb(),可以防止读内存操作(Load) 乱序执行,不干预写内存操作(Store);在单处理器上等价于优化屏障 barrier()。

#ifndef __smp_rmb
#define __smp_rmb()_rmb()
#endif

smp_wmb():多处理器版写屏障,在多处理器系统上等价于 wmb(),可以防止写内存操作(Store) 乱序执行,不干预读内存操作(Load);在单处理器上等价于优化屏障 barrier()。

#ifndef __smp_wmb
#define __smp_wmb()_wmb()
#endif

除了多处理器之间存在内存一致性问题,处理器与外设之间(主要是 DMA 控制 器)也存在内存一致性问题,因此我们需要强制性内存屏障 mb()/rmb()/wmb()来解决。读屏障和写屏障应当成对使用,写端 CPU 上必须用写屏障,读端 CPU 上必须用读屏 障。也就是说在生产者-消费者示例中,如果处理器是乱序执行的,那么生产者(写端 CPU) 插入写屏障,消费者(读端 CPU)插入读屏障才可以保证逻辑正确(缺一不可,用错也不行)。代码实现如下:

int flag = 0;
char buffer[32] = {0}; 
void producer(void)
{ 
  memset(buffer, 1, 32); 
  smp_wmb();
  flag = 1;
  } 
  
void consumer(void)
{ 
  char data[32] = {0};
  
  while (!flag) ;
  smp_rmb();
  memcopy(&data,buffer,sizeof(buff);
} 

当然,强屏障总是可以代替弱屏障。比如全屏障可以代替读屏障和写屏障,而强制性屏障可以代替多处理器版屏障,内存屏障可以代替优化屏障。只不过强屏障一般会比弱屏障更慢,性能损失更多。在龙芯上面,读屏障、写屏障和全屏障都是一条 sync 指令,但在语义 上,不同的屏障其功能要求是不一样的。
多处理器版屏障还有一些变种,比如 smp_mb__before_atomic()和 smp_mb__after_ atomic(),分别用在原子操作的前后,在实现上大都等价于 smb_mb()。

#ifndef __smp_mb__before_atomic
#define __smp_mb__before_atomic()___smp_mb()
#endif

#ifndef __smp_mb__after_atomic
#define __smp_mb__after_atomic()____smp_mb()
#endif

另外有一些内存屏障是用来解决 CPU 与外设之间内存一致性问题的,比如:
dma_rmb():DMA 读屏障,在设备CPU 方向(From Device)的 DMA 中,设备是写端,CPU 是读端,CPU 在读取标识变量和读取数据之间,必须插入 DMA 读屏障。

#ifndef dma_rmb
#define dma_rmb()_rmb()
#endif

dma_wmb():DMA 写屏障。在 CPU设备方向(To Device)的 DMA 中,CPU 是写端,设备是读 端,CPU 在写入数据和写入标识变量之间,必须插入 DMA 写屏障。

#ifndef dma_wmb
#define dma_wmb()_wmb()
#endif

mmiowb():MMIO 寄存器写屏障。对于设备的 MMIO 寄存器的写操作有时候是不允许乱序的,在这些场景下需要用 MMIO 寄存器写屏障。
注意:以上提到的所有内存屏障都是双向的,也就是说,内存屏障既要关注屏障前的访存操作, 也要关注屏障后的访存操作。
但是内核也提供一些隐式的单向屏障功能,比如 ACQUIRE 操作和 RELEASE 操作。ACQUIRE 的语义是 ACQUIRE 操作后面的访存必须在 ACQUIRE操作之后完成,但并不关注 ACQUIRE 操作前面的访存
RELEASE 的语义是 RELEASE 操作前面的访存必须在 RELEASE 操作之前完成,但并不关注 RELEASE 操作后面的访存

#ifndef __smp_store_release
#define __smp_store_release(p, v)_________\
do {__________________\
__compiletime_assert_atomic_type(*p);_______\
____smp_mb();_____________\
__WRITE_ONCE(*p, v);____________\
} while (0)
#endif

#ifndef __smp_load_acquire
#define __smp_load_acquire(p)___________\
({__________________\
__typeof(*p) ___p1 = READ_ONCE(*p);_______\
__compiletime_assert_atomic_type(*p);_______\
____smp_mb();_____________\
_____p1;________________\
})

另外,加锁操作通常意味着 ACQUIRE 操作,而解锁操作通常意味着 RELEASE 操作。
关于内存屏障的更多信息可参阅内核文档 Documentation/memory-barriers.txt。

  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值