内存屏障
摘要:本文主要讲述linux如何处理ARM cortex A9多核处理器的内核同步部分。主要包括其中的内存屏障、原子变量、每CPU变量。自旋锁、信号量、complete、读写自旋锁、读写信号量、顺序锁、RCU放在后文介绍。法律声明:《LINUX3.0内核源代码分析》系列文章由谢宝友(scxby@163.com)发表于http://xiebaoyou.blog.chinaunix.net,文
摘要:本文主要讲述linux如何处理ARM cortex A9多核处理器的内核同步部分。主要包括其中的内存屏障、原子变量、每CPU变量。
自旋锁、信号量、complete、读写自旋锁、读写信号量、顺序锁、RCU放在后文介绍。
法律声明:《LINUX3.0内核源代码分析》系列文章由谢宝友(scxby@163.com)发表于http://xiebaoyou.blog.chinaunix.net,文章中的LINUX3.0源代码遵循GPL协议。除此以外,文档中的其他内容由作者保留所有版权。谢绝转载。
本连载文章并不是为了形成一本适合出版的书籍,而是为了向有一定内核基本的读者提供一些linux3.0源码分析。因此,请读者结合《深入理解LINUX内核》第三版阅读本连载。
Paul曾经讲过:在建造大桥之前,必须得明白力学的原理。要理解内存屏障,首先得明白计算机硬件体系结构,特别是硬件是如何管理缓存的。缓存在多核上的一致性问题是如何产生的。
要深入理解内存屏障,建议大家首先阅读以下资料:
1、《深入理解并行编程》,下载地址是:http://xiebaoyou.download.csdn.net.
2、内核自带的文档documentation/memory-barriers.txt.
内存屏障是如此难此理解也难以使用,为什么还需要它呢?硬件工程师为什么不给软件开发者提供一种程序逻辑一致性的内存视图呢?归根结底,这个问题受到光速的影响。在1.8G的主频系统中,在一个时钟周期内,光在真空中的传播距离只有几厘米,电子的传播距离更短,根本无法传播到整个系统中。
Linux为开发者实现了以下内存屏障:
名称 | 函数名 | 作用 |
读写屏障 | mb | 在多核和IO内存、缓存之间设置一个完全读写屏障 |
读屏障 | rmb | 在多核和IO内存、缓存之间设置一个读屏障 |
写屏障 | wmb | 在多核和IO内存、缓存之间设置一个写屏障 |
读依赖屏障 | read_barrier_depends | 在多核和IO内存、缓存之间设置一个读依赖屏障 |
多核读写屏障 | Smp_mb | 在多核之间设置一个完全读写屏障 |
多核读屏障 | Smp_rmb | 在多核之间设置一个读屏障 |
多核写屏障 | Smp_wmb | 在多核之间设置一个写屏障 |
多核读依赖屏障 | Smp_read_barrier_depends | 在多核之间设置一个读依赖屏障 |
按照linux设计,mb、rmb、wmb、read_barrier_depends主要用于CPU与外设IO之间。在arm及其他一些RISC系统中,通常将外设IO地址映射为一段内存地址。虽然这样的内存是非缓存的,但是仍然受到内存读写乱序的影响。例如,我们要读写一个外部IO端口的数据时,可能会先向某个寄存器写入一个要读写的端口号,再读取另一个端口得到其值。如果要读取值之前,设置的端口号还没有到达外设,那么通常读取的数据是不可靠的,有时甚至会损坏硬件。这种情况下,需要在读寄存器前,设置一个内存屏障,保证二次操作外部端口之间没有乱序。
Smp_mb、smp_rmb、smp_wmb仅仅用于SMP系统,它解决的是多核之间内存乱序的问题。其具体用法及原理,请参阅《深入理解并行编程》。
read_barrier_depends和smp_ read_barrier_depends是读依赖屏障。除了在DEC alpha架构外,linux支持的其他均不需要这个屏障。Alpha需要它,是因为alpha架构中,使用的缓存是split cache.所谓split cache,简单的说就是一个核的缓存不止一个.在arm架构下,我们可以简单的忽略这个屏障。
虽然linux分读写屏障、读屏障、写屏障,但是在ARM中,它们的实现都是一样的,没有严格区别不同的屏障。
内存屏障也隐含了编译屏障的作用。所谓编译屏障,是为了解决编译乱序的问题。这个问题的根源在于:在发明编译器的时候,多核还未出现。编译器开发者认为编译出来的二进制代码只要在单核上运行正确就可以了。甚至,只要保证单线程内的程序逻辑正确性即可。例如,我们有两句赋值语句:
A = 1;
B = 2;
编译器并不保证生成的汇编是按照C语句的顺序。为了效率或者其他原因,它生成的汇编语句可能与下面的C代码是一致的:
B = 2;
A = 1;
要防止编译乱序,可以使用编译屏障指令barrier();
在描述原子变量和每CPU变量、其他内核同步方法之前,我们先看一段代码。假设有两个线程A和线程B,它们的执行代码分别是foo_a、foo_b,它们都操作一个全局变量g_a,如下:
Unsigned long g_a;
Int stoped = 0;
Void foo_a(void *unused)
{
While (stopped == 0)
{
G_a++;
}
}
Void foo_b(void *unused)
{
While (stopped == 0)
{
G_a++;
}
}
假设当stopped被设置为1后,线程A和线程B执行了count_a、count_b次,您会认为g_a的值等于count_a + count_b吗?
恩,当您在一台真实的计算上测试这个程序的时候,也许您的直觉是对的,g_a的值确实等于count_a + count_b。
但是,请您:
1、将测试程序运行的时间运行得久一点
2、或者将程序放到arm、powerpc或者mips上运行
3、或者找一台运行linux的多核x86机器运行。
g_a的值还会等于count_a + count_b吗?
答案是不会。
原因是什么呢?
产生这个问题的根本原因是:
1、 在多核上,一个CPU在向内存写入数据时,它并不知道其他核在向同样的内存地址写入。某一个核写入的数据可能会覆盖其他核写入的数据。假说g_a当前值是0,那么线程A和线程B同时读取它的值,当内存中的值放入总线上后,两个线程都认为其值是0.并同时将其值加1后提交给总线并向内存中写入1.其中一个线程对g_a的递增被丢失了。
2、 Arm、powerpc、mips这些体系结构都是存储/加载体系结构,它们不能直接对内存中的值进行操作。而必须将内存中的值加载到寄存器中后,将寄存器中的值加1后,再存储到内存中。如果两个线程都读取0值到寄存器中,并将寄存器的值递增为1后存储到内存,那么也会丢失一次递增。
3、 即使在x86体系结构中,允许直接对内存进行递增操作。也会由于编译器的原因,将内存中的值加载到内存,同第二点,也可能造成丢失一次递增。
怎么解决这个问题呢?
聪明的读者会说了:是不是需要这样声明g_a?
Unsigned long volatile g_a;
更聪明的读者会说,在写g_a时还需要锁住总线,使用汇编语句并在汇编前加lock前缀。
锁总线是正确的,但是也必须将g_a声明为valatile类型的变量。可是,在我们分析的ARM多核上,应该怎么办?
原子变量就是为了解决我们遇到的问题:如果在共享内存的多核系统上正确的修改共享变量的计数值。
首先,我们看一下老版本是如何定义原子变量的:
/**
* 将counter声明成volatile是为了防止编译器优化,强制从内存中读取counter的值
*/
typedef struct { volatile int counter; } atomic_t;
在linux3.0中,已经有所变化:
typedef struct {
int counter;
} atomic_t;
已经没有volatile来定义counter了。难道不需要禁止编译优化了吗?答案不是的。这是因为linux3.0已经修改了原子变量相关的函数。
Linux中的基本原子操作
宏或者函数 | 说明 |
Atomic_read | 返回原子变量的值 |
Atomic_set | 设置原子变量的值。 |
Atomic_add | 原子的递增计数的值。 |
Atomic_sub | 原子的递减计数的值。 |
atomic_cmpxchg | 原子比较并交换计数值。 |
atomic_clear_mask | 原子的清除掩码。 |
除此以外,还有一组操作64位原子变量的变体,以及一些位操作宏及函数。这里不再罗列。
/**
* 返回原子变量的值。
* 这里强制将counter转换为volatile int并取其值。目的就是为了避免编译优化。
*/
#define atomic_read(v) (*(volatile int *)&(v)->counter)
/**
* 设置原子变量的值。
*/
#define atomic_set(v,i) (((v)->counter) = (i))
原子递增的实现比较精妙,理解它的关键是需要明白ldrex、strex这一对指令的含义。
/**
* 原子的递增计数的值。
*/
static inline void atomic_add(int i, atomic_t *v)
{
unsigned long tmp;
int result;
/**
* __volatile__是为了防止编译器乱序。与"#define atomic_read(v) (*(volatile int *)&(v)->counter)"中的volatile类似。
*/
__asm__ __volatile__("@ atomic_add\n"
/**
* ldrex是arm为了支持多核引入的新指令,表示"排它性"加载。与mips的ll指令一样的效果。
* 它与"排它性"存储配对使用。
*/
"1: ldrex %0, [%3]\n"
/**
* 原子变量的值已经加载到寄存器中,这里对寄存器中的值减去指定的值。
*/
" add %0, %0, %4\n"
/**
* strex是"排它性"的存储寄存器的值到内存中。类似于mips的sc指令。
*/
" strex %1, %0, [%3]\n"
/**
* 关键代码是这里的判断。如果在ldrex和strex之间,其他核没有对原子变量变量进行加载存储操作,
* 那么寄存器中值就是0,否则非0.
*/
" teq %1, #0\n"
/**
* 如果其他核与本核冲突,那么寄存器值为非0,这里跳转到标号1处,重新加载内存的值并递增其值。
*/
" bne 1b"
: "=&r" (result), "=&r" (tmp), "+Qo" (v->counter)
: "r" (&v->counter), "Ir" (i)
: "cc");
}
atomic_add_return递增原子变量的值,并返回它的新值。它与atomic_add的最大不同,在于在原子递增前后各增加了一句:smp_mb();
这是由linux原子操作函数的语义规定的:所有对原子变量的操作,如果需要向调用者返回结果,那么就需要增加多核内存屏障的语义。通俗的说,就是其他核看到本核对原子变量的操作结果时,本核在原子变量前的操作对其他核也是可见的。
理解了atomic_add,其他原子变量的实现也就容易理解了。这里不再详述。
原子变量是不是很棒?无论有多少个核,每个核都可以修改共享内存变量,并且这样的修改可以被其他核立即看到。多核编程原来so easy!
不过还是不能太高兴了,原子变量虽然不是毒瘤,但是也差不多了。我曾经遇到一个兄弟,工作十多年了吧,得意的吹嘘:“我写的代码精细得很,统计计数都是用的汇编实现的,汇编加法指令还用了lock前缀。”呜呼,这个兄弟完全没有意识到在x86体系结构中,这个lock前缀对性能的影响。
不管哪种架构,原子计数(包含原子比较并交换)都是极耗CPU的。与单纯的加减计数指令相比,它消耗的CPU周期要高一到两个数量级。原因是什么呢?还是光信号(电信号)的传播速度问题。要让某个核上的修改被其他核发现,需要信号在整个系统中进行传播。这在几个核的系统中,可能还不是大问题,但是在1024个核以上的系统中呢?比如我们熟知的天河系统。
为了解决这个问题,内核引用入了每CPU变量。
可以将它理解为数据结构的数组。系统的每个CPU对应数组中的一个元素。每个CPU都只访问本CPU对应的数组元素。
每CPU数组中,确保每一个数组元素都位于不同的缓存行中。假如您有一个int型的每CPU数组,那么每个int型都会占用一个缓存行(很多系统中一个缓存行是32个字节),这看起来有点浪费。这样做的原因是:
ü 对每CPU数组的并发访问不会导致高速缓存行的失效。避免在各个核之间引起缓存行的抖动。
ü 这也是为了避免出现多核之间数据覆盖的情况。对这一点,可能您暂时不能理解。也许您在内核领域实际工作几年,也会觉得这有点难于理解。不过,现在您只需要知道有这么一个事实存在就行了。
关于第二个原因,您可以参考一个内核补丁:
99dcc3e5a94ed491fbef402831d8c0bbb267f995。据提交补丁的兄弟讲,这个补丁表面是一个性能优化的措施。但是,它实际上是一个BUG。该故障会引起内核内存分配子系统的一个BUG,最终会引起内存分配子系统陷入死循环。我实际的遇到了这个故障,可怜了我的两位兄弟,为了解决这个故障,花了近两个月时间,今天终于被我搞定了。
每CPU变量的主要目的是对多CPU并发访问的保护。但是它不能防止同一核上的中断的影响。我们曾经讲过,在arm、mips等系统中,++、--这样的简单计数操作,都需要几条汇编语句来完成。如果在从内存中加载数据到寄存器后,还没有将数据保存到内存中前,有中断将操作过程打断,并在中断处理函数中对同样的计数值进行操作,那么中断中的操作将被覆盖。
不管在多CPU还是单CPU中,内核抢占都可能象中断那样破坏我们对计数的操作。因此,应当在禁用抢占的情况下访问每CPU变量。内核抢占是一个大的话题,我们在讲调度的时候再提这个事情。
相关宏和函数:
宏或者函数 | 说明 |
DEFINE_PER_CPU | 静态定义一个每CPU变量数组 |
per_cpu | 获得每CPU数组中某个CPU对应的元素 |
__this_cpu_ptr | 获得当前CPU在数组中的元素的指针。 |
__get_cpu_var | 获得当前CPU在数组中的元素的值。 |
get_cpu_ptr | 关抢占,并获得CPU对应的元素指针。 |
put_cpu_var | 开抢占,与get_cpu_ptr配对使用。 |
看到这里,也许大家会觉得,用每CPU变量来代替原子变量不是很好么?不过,存在的东西就必然在存在的理由,因为每CPU变量用于计数有一个致使的弊端:它是不精确的。我们设想:有32个核的系统,每个核更新自己的CPU计数,如果有一个核想知道计数总和怎么办?简单的用一个循环将计数加起来吗?这显然是不行的。因为某个核修改了自己的计数变量时,其他核不能立即看到它对这个核的计数进行的修改。这会导致计数总和不准。特别是某个核对计数进行了大的修改的时候,总计数看起来会严重不准。
为了使总和大致可信,内核又引入了另一种每CPU变量:percpu_counter。
percpu_counter的详细实现在percpu_counter.c中。有兴趣的同学可以研究一下。下面我们讲一个主要的函数,希望起个抛砖引玉的作用:
/**
* 增加每CPU变量计数
* fbc: 要增加的每CPU变量
* amount: 本次要增加的计数值
* batch: 当本CPU计数超过此值时,要确保其他核能及时看到。
*/
void __percpu_counter_add(struct percpu_counter *fbc, s64 amount, s32 batch)
{
s64 count;
/**
* 为了避免当前任务飘移到其他核上,或者被其他核抢占,导致计数丢失
* 这里需要关抢占。
*/
preempt_disable();
/**
* 获得本CPU计数值并加上计数值。
*/
count = __this_cpu_read(*fbc->counters) + amount;
if (count >= batch || count <= -batch) {/* 本次修改的值较大,需要同步到全局计数中 */
spin_lock(&fbc->lock);/* 获得自旋锁,这样可以避免多核同时更新全局计数。 */
fbc->count += count;/* 修改全局计数,并将本CPU计数清0 */
__this_cpu_write(*fbc->counters, 0);
spin_unlock(&fbc->lock);
} else {
__this_cpu_write(*fbc->counters, count);/* 本次修改的计数较小,仅仅更新本CPU计数。 */
}
preempt_enable();/* 打开抢占 */
}
大家现在觉得多核编程有那么一点难了吧?一个简单的计数都可以搞得这么复杂。
复杂的东西还在后面。接下来我们新开一帖,讨论内核同步的其他技术:自旋锁、信号量、RCU、无锁编程。
更多推荐
所有评论(0)