《LINUX3.0内核源代码分析》第三章:内核同步(1)

简介: 摘要:本文主要讲述linux如何处理ARM cortex A9多核处理器的内核同步部分。主要包括其中的内存屏障、原子变量、每CPU变量。 自旋锁、信号量、complete、读写自旋锁、读写信号量、顺序锁、RCU放在后文介绍。

摘要:本文主要讲述linux如何处理ARM cortex A9多核处理器的内核同步部分。主要包括其中的内存屏障、原子变量、每CPU变量。

自旋锁、信号量、complete、读写自旋锁、读写信号量、顺序锁、RCU放在后文介绍。

法律声明LINUX3.0内核源代码分析》系列文章由谢宝友(scxby@163.com)发表于http://xiebaoyou.blog.chinaunix.net,文章中的LINUX3.0源代码遵循GPL协议。除此以外,文档中的其他内容由作者保留所有版权。谢绝转载。

本连载文章并不是为了形成一本适合出版的书籍,而是为了向有一定内核基本的读者提供一些linux3.0源码分析。因此,请读者结合《深入理解LINUX内核》第三版阅读本连载。

 

 

1       内核同步
1.1 内存屏障

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设计,mbrmbwmbread_barrier_depends主要用于CPU与外设IO之间。在arm及其他一些RISC系统中,通常将外设IO地址映射为一段内存地址。虽然这样的内存是非缓存的,但是仍然受到内存读写乱序的影响。例如,我们要读写一个外部IO端口的数据时,可能会先向某个寄存器写入一个要读写的端口号,再读取另一个端口得到其值。如果要读取值之前,设置的端口号还没有到达外设,那么通常读取的数据是不可靠的,有时甚至会损坏硬件。这种情况下,需要在读寄存器前,设置一个内存屏障,保证二次操作外部端口之间没有乱序。

Smp_mbsmp_rmbsmp_wmb仅仅用于SMP系统,它解决的是多核之间内存乱序的问题。其具体用法及原理,请参阅《深入理解并行编程》。

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

 

1.2 不是题外话的题外话

在描述原子变量和每CPU变量、其他内核同步方法之前,我们先看一段代码。假设有两个线程A和线程B,它们的执行代码分别是foo_afoo_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_acount_b次,您会认为g_a的值等于count_a + count_b吗?

恩,当您在一台真实的计算上测试这个程序的时候,也许您的直觉是对的,g_a的值确实等于count_a + count_b

但是,请您:

1、将测试程序运行的时间运行得久一点

2、或者将程序放到armpowerpc或者mips上运行

3、或者找一台运行linux的多核x86机器运行。

g_a的值还会等于count_a + count_b吗?

 

答案是不会。

 

原因是什么呢?

产生这个问题的根本原因是:

1、             在多核上,一个CPU在向内存写入数据时,它并不知道其他核在向同样的内存地址写入。某一个核写入的数据可能会覆盖其他核写入的数据。假说g_a当前值是0,那么线程A和线程B同时读取它的值,当内存中的值放入总线上后,两个线程都认为其值是0.并同时将其值加1后提交给总线并向内存中写入1.其中一个线程对g_a的递增被丢失了。

2、             Armpowerpcmips这些体系结构都是存储/加载体系结构,它们不能直接对内存中的值进行操作。而必须将内存中的值加载到寄存器中后,将寄存器中的值加1后,再存储到内存中。如果两个线程都读取0值到寄存器中,并将寄存器的值递增为1后存储到内存,那么也会丢失一次递增。

3、             即使在x86体系结构中,允许直接对内存进行递增操作。也会由于编译器的原因,将内存中的值加载到内存,同第二点,也可能造成丢失一次递增。

 

怎么解决这个问题呢?

聪明的读者会说了:是不是需要这样声明g_a

Unsigned long volatile g_a;

 

更聪明的读者会说,在写g_a时还需要锁住总线,使用汇编语句并在汇编前加lock前缀。

锁总线是正确的,但是也必须将g_a声明为valatile类型的变量。可是,在我们分析的ARM多核上,应该怎么办?

1.3 原子变量

原子变量就是为了解决我们遇到的问题:如果在共享内存的多核系统上正确的修改共享变量的计数值。

首先,我们看一下老版本是如何定义原子变量的:

/**

 * 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))

 

原子递增的实现比较精妙,理解它的关键是需要明白ldrexstrex这一对指令的含义。

/**

 * 原子的递增计数的值。

 */

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"

         /**

          * ldrexarm为了支持多核引入的新指令,表示"排它性"加载。与mipsll指令一样的效果。

          * 它与"排它性"存储配对使用。

          */

"1:    ldrex         %0, [%3]\n"

         /**

          * 原子变量的值已经加载到寄存器中,这里对寄存器中的值减去指定的值。

          */

"       add  %0, %0, %4\n"

         /**

          * strex"排它性"的存储寄存器的值到内存中。类似于mipssc指令。

          */

"       strex         %1, %0, [%3]\n"

         /**

          * 关键代码是这里的判断。如果在ldrexstrex之间,其他核没有对原子变量变量进行加载存储操作,

          * 那么寄存器中值就是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,其他原子变量的实现也就容易理解了。这里不再详述。

 

1.4 每CPU变量

 

原子变量是不是很棒?无论有多少个核,每个核都可以修改共享内存变量,并且这样的修改可以被其他核立即看到。多核编程原来so easy

不过还是不能太高兴了,原子变量虽然不是毒瘤,但是也差不多了。我曾经遇到一个兄弟,工作十多年了吧,得意的吹嘘:“我写的代码精细得很,统计计数都是用的汇编实现的,汇编加法指令还用了lock前缀。”呜呼,这个兄弟完全没有意识到在x86体系结构中,这个lock前缀对性能的影响。

不管哪种架构,原子计数(包含原子比较并交换)都是极耗CPU的。与单纯的加减计数指令相比,它消耗的CPU周期要高一到两个数量级。原因是什么呢?还是光信号(电信号)的传播速度问题。要让某个核上的修改被其他核发现,需要信号在整个系统中进行传播。这在几个核的系统中,可能还不是大问题,但是在1024个核以上的系统中呢?比如我们熟知的天河系统。

为了解决这个问题,内核引用入了每CPU变量。

 

可以将它理解为数据结构的数组。系统的每个CPU对应数组中的一个元素。每个CPU都只访问本CPU对应的数组元素。

每CPU数组中,确保每一个数组元素都位于不同的缓存行中。假如您有一个int型的每CPU数组,那么每个int型都会占用一个缓存行(很多系统中一个缓存行是32个字节),这看起来有点浪费。这样做的原因是:

ü  对每CPU数组的并发访问不会导致高速缓存行的失效。避免在各个核之间引起缓存行的抖动。

ü  这也是为了避免出现多核之间数据覆盖的情况。对这一点,可能您暂时不能理解。也许您在内核领域实际工作几年,也会觉得这有点难于理解。不过,现在您只需要知道有这么一个事实存在就行了。

关于第二个原因,您可以参考一个内核补丁:

99dcc3e5a94ed491fbef402831d8c0bbb267f995。据提交补丁的兄弟讲,这个补丁表面是一个性能优化的措施。但是,它实际上是一个BUG。该故障会引起内核内存分配子系统的一个BUG,最终会引起内存分配子系统陷入死循环。我实际的遇到了这个故障,可怜了我的两位兄弟,为了解决这个故障,花了近两个月时间,今天终于被我搞定了。

 

CPU变量的主要目的是对多CPU并发访问的保护。但是它不能防止同一核上的中断的影响。我们曾经讲过,在armmips等系统中,++--这样的简单计数操作,都需要几条汇编语句来完成。如果在从内存中加载数据到寄存器后,还没有将数据保存到内存中前,有中断将操作过程打断,并在中断处理函数中对同样的计数值进行操作,那么中断中的操作将被覆盖。

不管在多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 本次修改的值较大,需要同步到全局计数中 */

                   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、无锁编程。

 
相关文章
|
23天前
|
算法 Linux 调度
深入理解Linux内核调度器:从基础到优化####
本文旨在通过剖析Linux操作系统的心脏——内核调度器,为读者揭开其高效管理CPU资源的神秘面纱。不同于传统的摘要概述,本文将直接以一段精简代码片段作为引子,展示一个简化版的任务调度逻辑,随后逐步深入,详细探讨Linux内核调度器的工作原理、关键数据结构、调度算法演变以及性能调优策略,旨在为开发者与系统管理员提供一份实用的技术指南。 ####
61 4
|
27天前
|
缓存 算法 Linux
深入理解Linux内核调度器:公平性与性能的平衡####
真知灼见 本文将带你深入了解Linux操作系统的核心组件之一——完全公平调度器(CFS),通过剖析其设计原理、工作机制以及在实际系统中的应用效果,揭示它是如何在众多进程间实现资源分配的公平性与高效性的。不同于传统的摘要概述,本文旨在通过直观且富有洞察力的视角,让读者仿佛亲身体验到CFS在复杂系统环境中游刃有余地进行任务调度的过程。 ####
43 6
|
12天前
|
缓存 网络协议 Linux
深入探索Linux操作系统的内核优化策略####
本文旨在探讨Linux操作系统内核的优化方法,通过分析当前主流的几种内核优化技术,结合具体案例,阐述如何有效提升系统性能与稳定性。文章首先概述了Linux内核的基本结构,随后详细解析了内核优化的必要性及常用手段,包括编译优化、内核参数调整、内存管理优化等,最后通过实例展示了这些优化技巧在实际场景中的应用效果,为读者提供了一套实用的Linux内核优化指南。 ####
38 1
|
17天前
|
算法 Linux 开发者
Linux内核中的锁机制:保障并发控制的艺术####
本文深入探讨了Linux操作系统内核中实现的多种锁机制,包括自旋锁、互斥锁、读写锁等,旨在揭示这些同步原语如何高效地解决资源竞争问题,保证系统的稳定性和性能。通过分析不同锁机制的工作原理及应用场景,本文为开发者提供了在高并发环境下进行有效并发控制的实用指南。 ####
|
25天前
|
缓存 资源调度 安全
深入探索Linux操作系统的心脏——内核配置与优化####
本文作为一篇技术性深度解析文章,旨在引领读者踏上一场揭秘Linux内核配置与优化的奇妙之旅。不同于传统的摘要概述,本文将以实战为导向,直接跳入核心内容,探讨如何通过精细调整内核参数来提升系统性能、增强安全性及实现资源高效利用。从基础概念到高级技巧,逐步揭示那些隐藏在命令行背后的强大功能,为系统管理员和高级用户打开一扇通往极致性能与定制化体验的大门。 --- ###
58 9
|
24天前
|
缓存 负载均衡 Linux
深入理解Linux内核调度器
本文探讨了Linux操作系统核心组件之一——内核调度器的工作原理和设计哲学。不同于常规的技术文章,本摘要旨在提供一种全新的视角来审视Linux内核的调度机制,通过分析其对系统性能的影响以及在多核处理器环境下的表现,揭示调度器如何平衡公平性和效率。文章进一步讨论了完全公平调度器(CFS)的设计细节,包括它如何处理不同优先级的任务、如何进行负载均衡以及它是如何适应现代多核架构的挑战。此外,本文还简要概述了Linux调度器的未来发展方向,包括对实时任务支持的改进和对异构计算环境的适应性。
39 6
|
25天前
|
缓存 Linux 开发者
Linux内核中的并发控制机制:深入理解与应用####
【10月更文挑战第21天】 本文旨在为读者提供一个全面的指南,探讨Linux操作系统中用于实现多线程和进程间同步的关键技术——并发控制机制。通过剖析互斥锁、自旋锁、读写锁等核心概念及其在实际场景中的应用,本文将帮助开发者更好地理解和运用这些工具来构建高效且稳定的应用程序。 ####
39 5
|
25天前
|
算法 Unix Linux
深入理解Linux内核调度器:原理与优化
本文探讨了Linux操作系统的心脏——内核调度器(Scheduler)的工作原理,以及如何通过参数调整和代码优化来提高系统性能。不同于常规摘要仅概述内容,本摘要旨在激发读者对Linux内核调度机制深层次运作的兴趣,并简要介绍文章将覆盖的关键话题,如调度算法、实时性增强及节能策略等。
|
26天前
|
存储 监控 安全
Linux内核调优的艺术:从基础到高级###
本文深入探讨了Linux操作系统的心脏——内核的调优方法。文章首先概述了Linux内核的基本结构与工作原理,随后详细阐述了内核调优的重要性及基本原则。通过具体的参数调整示例(如sysctl、/proc/sys目录中的设置),文章展示了如何根据实际应用场景优化系统性能,包括提升CPU利用率、内存管理效率以及I/O性能等关键方面。最后,介绍了一些高级工具和技术,如perf、eBPF和SystemTap,用于更深层次的性能分析和问题定位。本文旨在为系统管理员和高级用户提供实用的内核调优策略,以最大化Linux系统的效率和稳定性。 ###
|
25天前
|
Java Linux Android开发
深入探索Android系统架构:从Linux内核到应用层
本文将带领读者深入了解Android操作系统的复杂架构,从其基于Linux的内核到丰富多彩的应用层。我们将探讨Android的各个关键组件,包括硬件抽象层(HAL)、运行时环境、以及核心库等,揭示它们如何协同工作以支持广泛的设备和应用。通过本文,您将对Android系统的工作原理有一个全面的认识,理解其如何平衡开放性与安全性,以及如何在多样化的设备上提供一致的用户体验。