【csdn转载】Linux 中的 READ_ONCE和WRITE_ONCE宏

原文链接:Linux 中的 READ_ONCE和WRITE_ONCE_linux write_once-CSDN博客
源码基于:Linux 5.4

0. 前言

Linux 内核代码中,经常会看到读取一个变量时,不是直接读取,而是通过 READ_ONCE 宏。同样的,在写入一个变量的时候,也不是直接赋值,而是通过 WRITE_ONCE宏。本文将详细分析下这两个宏的具体含义。

1. 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(); /* Enforce dependency ordering from x */ \
	__u.__val;							\
})
#define READ_ONCE(x) __READ_ONCE(x, 1)
    

通过上述代码可以看到 READ_ONCE 最终调用的是宏 __READ_ONCE,并多了一个 check 的参数,该check 值为1,即会进行检查。

当check 为真,则进行检查,调用 __read_once_size() 函数读取x 的值;

当check 为假,则不进行检查,调用 __read_once_size_nocheck() 函数读取x 的值;

在 __READ_ONCE() 中定义了一个 union 变量 __u,第一个成员是 x 类型的变量 __val,第二成员是只包含一个字符的字符数组 __c,这样定义的话,__c 可以当成这个联合体的指针来使用了。

另外,代码中还调用了 smp_read_barrier_depends() 函数,用于确保从 x 的读取操作与其他操作之间的依赖关系。该函数是为了解决某些特殊 CPU 架构下的缓存一致性问题,也就是所谓的数据依赖内存屏障,在绝大数 CPU 架构下都没有用处。

最后,会将联合体变量 __u 的 __val 返回,该值在 __read_once_size() 中已经拷贝好了。

1.1 __read_once_size()

当 check 为1时,会调用函数 __read_once_size():

include/linux/compiler.h
 
static __always_inline
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();						\
	}								\
})

最终调用的是宏 __READ_ONCE_SIZE,switch 会根据原始变量 x 的内存空间选择对应的case。该函数的目的是将原始变量的指针转换成 volatile 变量的指针,告诉编译器要读取的这个变量是 volatile 的。在 C 语言中 volatile 这个关键字的作用是:

  • 声明这个变量易变,不要把它当成一个普通的变量,做出错误的优化;
  • 保证CPU 每次从内存中重新读取变量的值,而不是用寄存器中暂存的值;

当原始变量 x 的size 满足1、2、4、8个字节时,会直接使用取指针的方式对联合体成员 __c 赋值,因为是 volatile 限制,编译器不会对这个操作优化

当原始变量 x 的size 不满足上面这些大小,则会进入default 流程,通过 __builtin_memcpy() 这个GCC 的内建函数进行拷贝了。而在此之前需要加上编译器屏障 barrier(),这样就可以保证 __builtin_memcpy() 函数本身不会被编译器优化掉。

最后,回到 __READ_ONCE(),在调用完 __read_once_size() 之后,会接着调用 smp_read_barrier_depends(),该函数是为了解决某些特殊 CPU 架构下的缓存一致性问题,也就是所谓的数据依赖内存屏障,在绝大数 CPU 架构下都没有用处。该函数执行完后,会将联合体变量 __u 的 __val 返回,该值在 __read_once_size() 中已经拷贝好了。

1.2 总结

READ_ONCE() 主要是通过 volatile 和 barrier() 限制编译器的优化,从而获取变量在内存中的实际内容。

2. WRITE_ONCE

include/linux/compiler.h
 
#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;					\
})

同 READ_ONCE,首先还是定义了一个联合体变量 __u,然后将要写入的值赋值给成员 __val,接着调用 __write_once_size() 函数:

include/linux/compiler.h
 
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() 类似,只不过这里是将原始变量 x 改成了需要赋值的一方,同样使用的是 volatile 关键字,保证使用的是变量实际的内存。

这里同样的,当原始变量 x 的size 不满足1、2、4、8个字节,则会进入default 流程,通过 __builtin_memcpy() 这个GCC 的内建函数进行拷贝了。而在此之前需要加上编译器屏障 barrier(),这样就可以保证 __builtin_memcpy() 函数本身不会被编译器优化掉。

最后,回到 WRITE_ONCE 同样是将新的值返回。

总结:

WRITE_ONCE() 主要是通过 volatile 和 barrier() 限制编译器的优化,从而将新的值写入到原始变量在内存中。

3. 为什么需要READ_ONCE和WRITE_ONCE

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

3.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);
}

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

例如,

while (tmp = a)
	do_something_with(tmp);

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

do { } while (0);

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

while (tmp = a)
	do_something_with(tmp);

总结:

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

4. barrier()

include/linux/compiler.h
 
#ifndef barrier
/* The "volatile" is due to gcc bugs */
# define barrier() __asm__ __volatile__("": : :"memory")
#endif

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

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

参考:

https://blog.csdn.net/Roland_Sun/article/details/107365134

https://blog.csdn.net/Roland_Sun/article/details/107468055

https://www.zhihu.com/question/404513670/answer/3323448915