内存屏障

起因

在学习linux kernel开发时, 学习了环形缓冲区的使用, 参考Circular Buffers.
生产者:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
spin_lock(&producer_lock);

unsigned long head = buffer->head;
/* The spin_unlock() and next spin_lock() provide needed ordering. */
unsigned long tail = READ_ONCE(buffer->tail);

if (CIRC_SPACE(head, tail, buffer->size) >= 1) {
/* insert one item into the buffer */
struct item *item = buffer[head];

produce_item(item);

smp_store_release(buffer->head,
(head + 1) & (buffer->size - 1));

/* wake_up() will make sure that the head is committed before
* waking anyone up */
wake_up(consumer);
}

spin_unlock(&producer_lock);

消费者:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
spin_lock(&consumer_lock);

/* Read index before reading contents at that index. */
unsigned long head = smp_load_acquire(buffer->head);
unsigned long tail = buffer->tail;

if (CIRC_CNT(head, tail, buffer->size) >= 1) {

/* extract one item from the buffer */
struct item *item = buffer[tail];

consume_item(item);

/* Finish reading descriptor before incrementing tail. */
smp_store_release(buffer->tail,
(tail + 1) & (buffer->size - 1));
}

spin_unlock(&consumer_lock);

疑惑

可以看到源码中使用了自旋锁, 但是锁的作用仅仅只是用来保证同一时间只有一个消费者或者生产者. 消费者和生产者同时运作是可以的, 也就是对head、tail变量的读取与存储可以同时发生.
但是可以看到代码中包含了”READ_ONCE”、”smp_load_acquire”、”smp_store_release”的使用, 它们又是什么作用.
如果去搜索这几个关键字, 大概率会看到一个关键词”内存屏障”.

理解

关于内存屏障的理解, Memory Barriers这篇文章已经讲得很详细了.
简单概括一下, 就是对内存的一系列读取存储操作的顺序是不可预测的, 这和编译器的优化有关, 也和多个CPU同时运行但拥有独立的内存高速缓冲区相关.

编译器优化

1
2
3
4
void main() {
int x = 1;
int y = 2;
}

编译器对于两个无关的复制, 在开启性能优化时, 不保证生成的机器码顺序和源码中的顺序相同. 所以在生成的程序中, 机器码的顺序可能是”y=1”在”x=1”的前面. 但是在这个例子中, 这无关紧要.
看看这个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
//thread 1
void run1()
{
x = 1;
r1 = y;
}

//thread 2
void run2
{
y = 1;
r2 = x;
}

两个线程同时运行, 那么有多种情况可能发生. 但是我们编写代码时, 人为的肯定这些代码是按照顺序执行的. 所以不论两个线程如何交替, r1和r2不可能同时为0. 但实际上编译器并不知道这些变量会在别处修改, 所以它按照自己的想法进行优化, 打乱代码的顺序.
最后的结果可能是”r2=x”和”r1=y”最先交替执行, 这样的结果是不可预期的.

CPU

每个CPU可能拥有独立的高速缓冲区, 所以对内存的操作通常会先转化为对缓存的操作. 于是可能发生CPU0往地址x写入值1, 但是只写入了缓冲区. 此时CPU1读取地址x, 但是可惜缓冲区中的值并没有及时刷新到内存中, 所以读出来的值是不正确的.

限制

有没有用什么手段可以限制上面所说的乱序问题, 针对编译器的限制方法十分简单, 就是使用”volatile”. volatile关键字对于编译器而言, 是开发者告诉编译器, 这个变量内存的修改, 可能不再你可视范围内, 不要对这个变量相关的代码进行优化.
针对CPU的限制, CPU本身提供内存屏障指令:

  • 写屏障sfence
  • 读屏障lfence
  • 读写屏障mfence

源码

现在来看看”READ_ONCE”、”smp_load_acquire”、”smp_store_release”的源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// READ_ONCE核心代码
#define barrier() __asm__ __volatile__("": : :"memory")
#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(); \
} \
})

可以看到READ_ONCE针对标准大小的类型, 直接使用volatile实现编译器内存屏障, 保证编译出的机器码中内存访问顺序与源码一致. 而针对其余大小的类型, 使用barrier让编译器保证其之前的内存访问先于其之后的内存访问完成.
所以”READ_ONCE”只是针对于编译器的内存屏障, 并不包含内存屏障指令.
内核使用宏CONFIG_SMP来判断CPU是否使用了SMP, 在SMP架构下,每个CPU与内存之间,都配有自己的高速缓存. 根据是否使用SMP, “smp_load_acquire”的定义也不同.
无SMP:

1
2
3
4
5
6
7
8
#ifndef smp_store_release
#define smp_store_release(p, v) \
do { \
compiletime_assert_atomic_type(*p); \
barrier(); \
WRITE_ONCE(*p, v); \
} while (0)
#endif

可以看到无SMP的时候, smp_store_release只是简单的使用了barrier+”WRITE_ONCE”进行编译器层面的内存屏障.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#define mb() alternative("lock; addl $0,0(%%esp)", "mfence", X86_FEATURE_XMM2)
#define __smp_mb() mb()

#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; \
})

而有SMP的时候, 会使用mb插入mfence指令.