在Linux内核代码中,经常可以看到读取一个变量时,不是直接读取的,而是需要借助一个叫做READ_ONCE的宏;同样,在写入一个变量的时候,也不是直接赋值的,而是需要借助一个叫做WRITE_ONCE的宏。

代码分析

READ_ONCE宏定义如下(代码位于include/linux/compiler.h中):

#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();                                     \
	__u.__val;							\
})
#define READ_ONCE(x) __READ_ONCE(x, 1)

READ_ONCE宏直接调用了另一个内部定义的宏__READ_ONCE,第二个参数check传的是1。

在__READ_ONCE宏中其实定义了一个大的表达式,表达式的值是最后一个语句的值。这个表达式中先定义了一个联合体,联合体的第一个组成部分是__val,它的类型就是要读取变量的类型;联合体的第二个组成部分是一个只包含一个元素的字符数组__c,这样定义的话,__c就可以当做这个联合体的指针来使用了。然后,还用这个联合体定义了一个变量__u,这个变量是一个局部变量,因此是定义在栈上的。由于check传的是1,接着将调用__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();						\
	}								\
})

static __always_inline
void __read_once_size(const volatile void *p, void *res, int size)
{
	__READ_ONCE_SIZE;
}

这个函数的函数体是在宏__READ_ONCE_SIZE中定义的,传入的参数是要读取变量的指针,定义的联合体变量的指针,以及要读取变量的大小。在调用__read_once_size函数时,就将要读取变量的指针转换成了指向volatile变量的指针,告诉编译器要读取的这个变量是volatile的。在C语言中,volatile关键字的作用是:

  1. 声明这个变量易变,不要把它当成一个普通的变量,做出错误的优化。
  2. 保证CPU每次都从内存重新读取变量的值,而不是用寄存器中暂存的值。注意,这里说的是寄存器中缓存的值,而不是CPU缓存中存的值。很多英文文档里面都说了Cache,容易让人产生误解。

__read_once_size函数要完成的操作是将要读取的变量的值拷贝到临时定义的局部联合体变量__u中。如果要读取变量的长度是1、2、4、8字节的时候,直接使用取指针赋值就行了,由于要读取变量的指针已经被转成了volatile的,编译器保证这个操作不会被优化。如果要读取的变量不是上面说的整字节,那么就要用__builtin_memcpy操作进行拷贝了,但前后都需要加上编译器屏障barrier(),这样就可以保证__builtin_memcpy函数调用本身不会被编译器优化掉。

接下来__READ_ONCE宏调用了smp_read_barrier_depends函数,这个函数是为了解决某些特殊CPU架构下的缓存一致性问题的(主要是Alpha),也就是所谓的数据依赖内存屏障,在绝大多数CPU架构下都没什么用处。

__READ_ONCE宏中定义的最后一条语句,就是直接返回局部联合体变量__u中的__val部分,也就是返回要读取变量被拷贝好了的值。由于它是这个表达式的最后一个语句,所以__READ_ONCE宏中定义的表达式的值就是这个值,从而保证了要读取值的变量在使用了READ_ONCE宏后能读取到正确的值。

分析完READ_ONCE宏,那WRITE_ONCE宏就很简单了,基本上就是把READ_ONCE宏要做的事情反过来:

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

还是定义了一个联合体变量__u,然后直接将要赋值的值读进来。接着调用了__write_once_size函数:

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();
	}
}

这次换成了把要赋值变量的指针转换成了指向volatile变量的指针。

WRITE_ONCE宏的最后一条语句还是会返回要赋值的值的,因此也就是说WRITE_ONCE宏是返回要赋值的值的,只不过一般都没什么用。

为什么要用READ_ONCE和WRITE_ONCE宏

通常编译器是以函数为单位对代码进行优化编译的,而且编译器在优化的时候会假设被执行的程序是以单线程来执行的。基于这个假设优化出来的汇编代码,很有可能会在多线程执行的过程中出现严重的问题。可以举几个例子:

1)编译器可以随意优化不相关的内存访问操作,打乱它们的执行次序。

例如,对于如下代码:

a[0] = x;
a[1] = x;

编译器可能会将其优化成:

a[1] = x;
a[0] = x;

这对单线程的程序来说没有问题,因为变量x的值是不会改变的。但是,对于多线程的程序来说,变量x的值可能会被别的线程改变,如果要保证它们的执行顺序,必须加上READ_ONCE宏:

a[0] = READ_ONCE(x);
a[1] = READ_ONCE(x);

注意,一定要两个语句都用READ_ONCE宏,这样才能保证次序,单独用一个还是没法保证。当然,在两条语句中间插入编译器屏障也可以解决这个问题。

又或者,比如下面的程序:

void process_level(void)
{
	msg = get_message();
	flag = true;
}

void interrupt_handler(void)
{
	if (flag)
		process_message(msg);
}

编译器在编译的时候,有可能会把process_level函数优化成:

void process_level(void)
{
	flag = true;
	msg = get_message();
}

因为它发现这两条语句没有任何关系,而且第二条语句比第一条语句执行速度要快,但是它并不知道flag位其实是一个标志位,必须要在获得消息后才能被设置成真。这时只能将process_level函数改成:

void process_level(void)
{
	WRITE_ONCE(msg, get_message());
	WRITE_ONCE(flag, true);
}

2)如果在编译的时候就能确定某些代码不会被执行到那可能会完全把代码删除。

例如,对于如下的代码:

while (tmp = a)
	do_something_with(tmp);

如果编译的时候,编译器发现变量a的值永远都是0,那么这条语句就会被优化成:

do { } while (0);

直接删除,什么都不做。这时候,为了保留一定会按照代码执行,那么必须改写成:

while (tmp = READ_ONCE(a))
		do_something_with(tmp);

还有,对于如下代码:

a = 0;
/* 中间代码没有对变量a赋值 */
...... 
a = 0;

编译器发现,变量a的值一直是0,那后面再对变量a赋值0就是没有必要的,会直接删除掉最后一个赋值。但是,在多线程程序中,有可能另一个线程更改了变量a。为了保证一定赋值,可以用下面的代码:

WRITE_ONCE(a, 0);
/* 中间代码没有对变量a赋值 */
...... 
WRITE_ONCE(a, 0);

还存在许多奇奇怪怪的编译器优化,都可以用READ_ONCE和WRITE_ONCE宏告诉编译器别这么做。

不过,READ_ONCE和WRITE_ONCE宏只能保证读写操作不被编译器优化掉,造成多线程执行中出问题,但是它并不能干预CPU执行编译出来的程序,也就是不能解决CPU重排序的问题和缓存一致性的问题,这类问题还是需要使用内存屏障来解决。

而且,由于READ_ONCE和WRITE_ONCE宏的实现原理本身就是借助了C语言的volatile变量,因此如果要读取或者写入的变量本来就是volatile的就不需要再使用这两个宏了。

READ_ONCE和WRITE_ONCE宏与编译器屏障的关系

编译器屏障在Linux内核中是通过调用barrier()宏来实现的,其定义如下(代码位于include/linux/compiler-gcc.h中):

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

所以,其实barrier()宏就是往正常的C语言代码里插入了一条汇编指令。这条指令告诉编译器(上面的汇编指令只对GCC编译器有效,其它编译器有对应的别的方法),不要将这条汇编指令前的内存读写指令优化到这条汇编指令之后,同时也不能将这条汇编指令之后的内存读写指令优化到这条汇编指令之前。但是,对于这条汇编指令之前的内存读写指令,以及之后的内存读写指令,想怎么优化都行,没有任何限制。 

而READ_ONCE和WRITE_ONCE针对的是读写操作本身,只会影响使用这两个宏的内存访问操作,不能阻止对其它变量的优化操作。

Logo

开放原子开发者工作坊旨在鼓励更多人参与开源活动,与志同道合的开发者们相互交流开发经验、分享开发心得、获取前沿技术趋势。工作坊有多种形式的开发者活动,如meetup、训练营等,主打技术交流,干货满满,真诚地邀请各位开发者共同参与!

更多推荐