Linux内核37-内核数据的同步访问

简介: Linux内核37-内核数据的同步访问

每一种技术的出现必然是因为某种需求。正因为人的本性是贪婪的,所以科技的创新才能日新月异。


1 内核数据的同步访问


前面,我们学习了这么多内核同步技术。那我们该怎么选择呢?选择不同的内核同步技术,可能对系统的性能影响很大。根据经验,基本可以遵守这么一条准则:尽可能高地保证系统的并发性

而系统的并发水平又依赖于两个关键的因素:

  • 可以并发访问的I/O设备数量;
  • 能够执行有效工作的CPU数量。

为了最大化I/O的吞吐量,中断禁止的时间应该尽可能短。我们知道,如果中断被禁止,I/O设备发出的IRQ信号会被PIC中断控制器临时性地忽略,也就不会相应I/O设备的请求。

为了使CPU的效率最大化,基于自旋锁的内核同步原语尽可能不用。因为,当CPU忙等待自旋锁被释放的时候,其实浪费了珍贵的机器执行周期。甚至更糟糕的是,自旋锁还会影响硬件Cache,强迫Cache失效,从而从内存中重新读取数据,刷新Cache,这大大降低了系统的整体性能。这就是为什么多核系统不能达到1+1=2的效果的原因。

让我们举几个例子来说明如何在保持高并发水平的同时还能实现同步:

  • 如果共享的数据结构是一个简单的整数,那么可以使用atomic_t类型的原子变量声明它。原子操作比自旋锁和禁止中断都快,它只是降低了并发访问数据的内核控制路径的执行速度。
  • 但是,往链表中插入元素就不是原子的,因为至少包含两个指针赋值操作。然而,内核有时候可以在不使用锁或禁止中断的前提下执行这种插入操作。比如,系统调用服务例程中,系统调用插入新元素到一个单链表中,而中断处理程序或可延时函数异步遍历这个列表,就无须锁的保护。

另外,在内核的实现代码中,我们经常需要对列表进行插入操作,通常使用指针赋值的方式实现,如下所示:

new->next = list_element->next;
list_element->next = new;

将上面的代码转换成汇编语言之后,就成为2条连续的原子指令操作。第1条指令建立新元素的next指针,但是不会修改列表。第2条指令将其存入对应的内存位置。假设,在这2条指令执行之间来一个中断信号,则中断处理程序看到的列表没有新元素;如果中断信号在第2条指令执行之后到来,则中断处理程序看到是的已经插入新元素的列表。任何一种情况,列表的数据都是正确的,没有被破坏的。但是,必须保证中断处理程序不会修改这个列表。如果其修改了列表,next指针很可能就会变成非法值。

更重要的是,这两条指令是由时序关系的。只有先创建了next指针,才能给其赋值;否则,操作不合法。所以,对于上面的代码,内核开发者应该保证它们的执行顺序,不会被编译器或者CPU控制单元破坏。否则,在两条赋值语句之间插入进来执行的中断服务程序,会发现一个被破坏了的列表。这时候,往往需要一个写内存屏障原语,如下所示:

 

new->next = list_element->next;
    wmb();
    list_element->next = new;

到这儿,很多人可能会纳闷:为什么我在编写内核代码或者驱动程序的时候,怎么几乎不使用wmb()之类的内存屏障呢?那是因为,Linux内核提供的操作函数API已经封装了内存屏障原语。所以,大部分时候我们不需要关心它。

通过上面的分析,我们可以得出的结论就是:尽可能提高系统的并发性,也就是压榨CPU能够有效工作的时间。为此,在保护要访问的数据的同时,尽可能不要选择自旋锁、信号量和关闭中断之类的加锁机制。因为它们往往让CPU处于无效工作时间中,降低系统的性能。

但是,许多时候我们别无选择,只能使用这些降低系统性能的加锁机制。当我们不得不面对的时候,我们又该如何抉择呢?


2 如何选择自旋锁、信号量和禁止中断


不幸的是,访问内核数据结构的形式远远比上面的示例复杂多了,迫使内核开发者不得不启动信号量、自旋锁和中断禁止这些锁原语。通常来讲,具体选择哪种加锁机制,取决于访问数据的是哪种内核控制路径,如下表所示。但需要注意的一点是,无论何时,内核控制路径请求一个自旋锁(包括读写锁,seqlock和RCU)时,都会禁止局部中断或者软中断,从而禁止内核抢占。

表5-8 不同内核控制路径访问的数据结构需要的锁

内核控制路径 单核系统 多核系统
异常处理程序 信号量 信号量
中断处理程序 禁止中断 自旋锁
可延时函数 无/自旋锁
异常处理程序+
中断处理程序
禁止中断 自旋锁
异常处理程序+
可延时函数
禁止软中断 自旋锁
中断处理程序+
可延时函数
禁止中断 自旋锁
中断处理程序+
可延时函数+
异常处理程序
禁止中断 自旋锁

在了解这些不同的内核控制路径访问的数据结构应该如何保护之前,我们先来复习几个概念:

  1. 硬中断和软中断的区别
    严格意义上来说,中断可以分为同步中断和异步中断。而所谓的同步中断肯定就是CPU自身产生的中断,也就是所谓的异常。比如,除零操作就会产生硬件错误,在嵌入式内核中很常见这之类的错误。对于这类错误,首先应该能避免就避免,这是我们嵌入式开发者或者内核开发者必须要考虑的工作;实在无法避免(有时候可能还要故意产生硬件异常,比如Linux就利用页错误做特殊处理),就要编写异常处理程序进行必要处理,比如发送信号给当前进程等。
    对于异常,在此不做过多描述。所以,在此,所说的中断特指异步中断,主要用来服务I/O设备还有CPU之间的中断。为了及时响应外部I/O设备和其它CPU,中断直接打断CPU的执行,让其执行对应的中断处理程序。所以,中断处理程序必须占用CPU的时间极短,且不能发生阻塞操作,但允许嵌套中断执行。
    但是,有时候,中断信号所引发的操作比较复杂,但是可以分为需要及时处理和可以延时处理的部分。对于需要及时处理的部分就交给中断处理程序直接处理就好了,也就是我们常说的概念-顶半部。而对于可延时处理的部分,Linux提出了其它的概念来处理,比如说软中断、tasklet和工作队列。
  2. 软中断
    那软中断的工作原理又是什么呢?软中断是内核在编译阶段就预先定义好的,这是一个数组,数组元素个数正好是内核支持的软中断数量(Linux目前是32个,但实际只用了6个),而恰恰,内核为每个CPU都维护着一个表示软中断挂起标志位的32位变量,正好对应上面的数组元素个数。也就是说,哪个CPU将相应的bit位设置为1,这个CPU就需要处理这个软中断,至于软中断处理程序在预编译的时候已经写好了。这样的处理行为与硬中断完全一样,对于同一个软中断,每个CPU都有可能执行处理(所以,软中断要访问的数据结构必须使用自旋锁进行保护)。唯一不同的是,软中断的触发时机与硬中断不同:硬中断直接由硬件打断CPU的执行,调用相应的处理程序;而软中断的触发时机完全由内核设计者定义(也就是说,你可以让它任何时候触发)。但是,这样的机制也就固化了其处理行为,因为是预先定义好的。也就是说,用户无法根据自己的需要,设计自定义的软中断处理程序了。这怎么能行呢?于是,Linux在此基础上又提出了另一个概念,tasklet。
  3. tasklet
    Linux拿出其中的2个软中断,专门处理tasklet(一个高优先级,一个低优先级)。但是,tasklet的处理流程又大不一样。怎么不一样呢?就是哪个CPU激活的tasklet,一般就由哪个CPU执行,效率优先嘛。但是,不排除,在一个CPU上激活,在另一个CPU上执行的使用情况。但是,无论哪种情况,它们的执行都是与CPU绑定在一起的,也就是一一对应,也就是不存在并发访问同一个tasklet的时候。
  4. 工作队列
    其实工作队列与tasklet的行为极其类似,只是软中断和tasklet都是在中断上下文中调用的,也就是不允许阻塞;而工作队列是运行在进程上下文中,也就是说,这是为内核线程处理延时任务提供的一种机制。故暂时不在本文的讨论范畴之内。


2.1 异常程序访问的数据结构


只有异常处理程序访问的数据结构,可能产生的竞态条件简单易懂,也很容易保护。最常见的异常处理程序就是系统调用,因为它可能被多个进程并发调用,从而为用户态的程序提供内核服务。所以说,异常处理程序访问的数据结构就是可以分配给一个或多个进程的一种资源。

避免这种资源可能产生的竞态条件,可以选择信号量,因为大部分情况下,想要访问这个资源的进程如果没有得到资源的使用权的话会选择休眠等待。而恰好,信号量就是这样的一种加锁机制。如果请求信号量失败,进程挂起,让出CPU的使用权给其它进程。这种情况下,自旋锁是不合适的,因为它是忙等待,一直占用CPU。值得一提的是,不论是单核系统还是多核系统,信号量都能工作的很好。

即使是开启内核抢占,也不会产生问题。如果持有信号量的进程被抢占,新进程会尝试申请信号量。但是,这时候申请信号量肯定失败,从而新进程进入休眠,等待旧进程释放信号量。


2.2 中断程序访问的数据结构


我们这儿要讨论的数据结构只是被中断程序的顶半部访问,不涉及底半部访问的数据结构,这类数据结构属于可延时函数访问的数据结构的范畴,后面再讨论。我们在学习中断的时候,已经知道,中断处理程序中的处理是串行化的,也就是说不会发生并发访问。所以,也就不需要同步。

但是,当数据结构被多个中断程序访问的时候,就会发生并发访问产生的竞态问题。尤其是在多核系统中,一个数据结构可能被多个不同的中断程序并发访问。这时候就需要同步了。

单核系统,竞态条件很好避免,只要关闭中断即可。其它同步技术也不合适。信号量阻塞进程,而中断万万不能被阻塞。另一方面,自旋锁会冻结系统:如果中断中正在访问的数据结构被中断,它不会释放锁;而新的中断程序一直在忙等待这个锁。其实就是发生了死锁。

多核系统处理更为复杂一些。因为中断都是局部中断,也就是每个CPU独享的。所以,只是简单的关闭中断无法有效避免竞态条件。因为,即使中断被禁止,其它CPU上的中断处理程序还会继续执行。所以,这时候需要关闭中断的同时,再申请一个自旋锁或者读写自旋锁保护数据结构。值得注意的是,这类自旋锁不会冻结系统。首先,因为关闭局部中断,所以同一CPU上的中断程序不会执行,也就不会发生上面所说的死锁。其次,因为是多核系统,中断程序发现锁被占用了,也不会阻止其它CPU上的中断程序释放这个锁。所以,无论哪种情况都不会发生死锁的情况。

为了方便处理多核系统中这种局部中断禁止和自旋锁结合在一起使用的情况,Linux提供了一些宏,如下表所示。单核系统中,这些宏只能禁止中断或者禁止内核抢占。

表5-9 与中断有关的自旋锁宏

描述
spin_lock_irq(l) local_irq_disable();
spin_lock(l)
unlock_irq(l) spin_unlock(l);
local_irq_enable()
spin_lock_bh(l) local_bh_disable();
spin_lock(l)
spin_unlock_bh(l) spin_unlock(l);
local_bh_enable()
spin_lock_irqsave(l,f) local_irq_save(f);
spin_lock(l)
spin_unlock_irqrestore(l,f) spin_unlock(l);
local_irq_restore(f)
read_lock_irq(l) local_irq_disable( );
read_lock(l)
read_unlock_irq(l) read_unlock(l);
local_irq_enable( )
read_lock_bh(l) local_bh_disable( );
read_lock(l)
read_unlock_bh(l) read_unlock(l);
local_bh_enable( )
write_lock_irq(l) local_irq_disable();
write_lock(l)
write_unlock_irq(l) write_unlock(l);
local_irq_enable( )
write_lock_bh(l) local_bh_disable();
write_lock(l)
write_unlock_bh(l) write_unlock(l);
local_bh_enable( )
read_lock_irqsave(l,f) local_irq_save(f);
read_lock(l)
read_unlock_irqrestore(l,f) read_unlock(l);
local_irq_restore(f)
write_lock_irqsave(l,f) local_irq_save(f);
write_lock(l)
write_unlock_irqrestore(l,f) write_unlock(l);
local_irq_restore(f)
read_seqbegin_irqsave(l,f) local_irq_save(f);
read_seqbegin(l)
read_seqretry_irqrestore(l,v,f) read_seqretry(l,v);
local_irq_restore(f)
write_seqlock_irqsave(l,f) local_irq_save(f);
write_seqlock(l)
write_sequnlock_irqrestore(l,f) write_sequnlock(l);
local_irq_restore(f)
write_seqlock_irq(l) local_irq_disable();
write_seqlock(l)
write_sequnlock_irq(l) write_sequnlock(l);
local_irq_enable()
write_seqlock_bh(l) local_bh_disable();
write_seqlock(l)
write_sequnlock_bh(l) write_sequnlock(l);
local_bh_enable()

2.3 可延时函数访问的数据结构


通过前面软中断、tasklet等概念的梳理,想必你对它们要访问的数据需要的保护方式有了一些初步的理解:采用哪种同步技术保护数据结构,完全取决于是属于哪类可延时函数。接下来,我们详细一一分析。

单核系统,通过上面的分析,不论是哪种机制访问数据结构,都不会产生竞态条件。因为它不会被其它可延时函数中断。也就无需使用同步了。

相反,多核系统就可能发生并发访问所带来的竞态问题。如下表所示,根据可延时函数的类型进行了列举:

延时函数类型 保护机制
软中断 自旋锁
一个tasklet 无需锁
多个tasklet 自旋锁

如前所述,软中断总是需要自旋锁进行保护,因为即使是同一个软中断也有可能被多个CPU并发访问。相反,一个tasklet不需要锁的保护,因为同一个tasklet不会发生并发访问。但是,如果数据被多个tasklet访问,就需要加锁保护了。


2.4 异常和中断同时访问的数据结构


如果数据结构既被异常处理程序(如系统调用)访问,又被中断处理程序访问,那该怎么保护数据呢?

对于这种情况,单核系统的处理非常简单,关闭中断即可。因为中断程序不可重入,也不能被异常处理程序中断。所以只要关闭中断,内核访问数据就不会被中断。

多核系统,我们就不得不考虑多个CPU的并发访问了。所以与中断访问数据一样,采用关闭中断与自旋锁相结合的方式。

但是,有时候使用信号量代替上面的自旋锁可能更好。尤其是异常处理程序等不到锁需要挂起的时候。举例来说,系统调用和中断同时访问某个数据:中断处理程序尝试申请信号量(调用down_trylock()),失败就不断尝试,还是相当于自旋锁的忙等待;另一方面,系统调用如果申请信号量失败,就挂起,让CPU执行其它操作,这完全符合系统调用时的预期行为。这种情况,信号量优于自旋锁,因为它让系统有一个更高的并发性能。


2.5 异常和可延时函数同时访问的数据结构


异常和可延时函数同时访问数据时,处理方式与异常和中断同时访问数据时类似。因为可延时函数本质上都是中断激活的,也是运行在中断上下文中的,在运行期间不会被异常中断。也就是说,使用关闭中断和自旋锁相结合的方式就足够了。

实际上,不用关闭硬中断即可,也就是调用local_bh_disable宏,只关闭可延时函数的执行。因为中断处理程序并没有访问数据,所以,只禁止可延时函数比禁止中断更有效率,因为中断可以继续被CPU响应。而在单个CPU上执行可延时函数是串行执行的,没有竞态条件产生。(这儿,禁止可延时函数指的是禁止再激活软中断,tasklet之类的,但是之前已经激活的还是要执行的。)

正如多数情况一样,多核系统中,自旋锁保证任何时候只有一个内核控制路径访问数据。


2.6 中断和可延时函数同时访问的数据结构


这种情况与中断和异常同时访问数据相似。单核系统,禁止中断即可。多核系统需要再加上自旋锁。


2.7 中断、异常和可延时函数同时访问的数据结构


与上一种情况一样,故不再累述。


相关文章
|
15天前
|
Linux C语言
Linux内核队列queue.h
Linux内核队列queue.h
|
18天前
|
存储 缓存 Linux
Linux IO的奥秘:深入探索数据流动的魔法
Linux I/O(输入/输出)系统是其核心功能之一,负责处理数据在系统内部及与外界之间的流动。为了优化这一流程,Linux进行了一系列努力和抽象化,以提高效率、灵活性和易用性。🚀
Linux IO的奥秘:深入探索数据流动的魔法
|
1月前
|
存储 Shell Linux
【Shell 命令集合 磁盘管理 】Linux读取、转换并输出数据 dd命令使用教程
【Shell 命令集合 磁盘管理 】Linux读取、转换并输出数据 dd命令使用教程
31 0
|
1月前
|
存储 Shell Linux
【Shell 命令集合 系统设置 】Linux 生成并更新内核模块的依赖 depmod命令 使用指南
【Shell 命令集合 系统设置 】Linux 生成并更新内核模块的依赖 depmod命令 使用指南
32 0
|
1月前
|
算法 Shell Linux
【Shell 命令集合 备份压缩 】⭐Linux 压缩 恢复bzip2损坏数据 bzip2recover命令 使用指南
【Shell 命令集合 备份压缩 】⭐Linux 压缩 恢复bzip2损坏数据 bzip2recover命令 使用指南
33 0
|
1月前
|
Shell Linux C语言
【Shell 命令集合 系统设置 】⭐Linux 卸载已加载的内核模块rmmod命令 使用指南
【Shell 命令集合 系统设置 】⭐Linux 卸载已加载的内核模块rmmod命令 使用指南
29 1
|
8天前
|
算法 Linux 调度
深入理解Linux内核的进程调度机制
【4月更文挑战第17天】在多任务操作系统中,进程调度是核心功能之一,它决定了处理机资源的分配。本文旨在剖析Linux操作系统内核的进程调度机制,详细讨论其调度策略、调度算法及实现原理,并探讨了其对系统性能的影响。通过分析CFS(完全公平调度器)和实时调度策略,揭示了Linux如何在保证响应速度与公平性之间取得平衡。文章还将评估最新的调度技术趋势,如容器化和云计算环境下的调度优化。
|
13天前
|
缓存 运维 监控
Linux系统监控利器:探索常用命令及数据保存技巧
Linux系统监控利器:探索常用命令及数据保存技巧
29 4
Linux系统监控利器:探索常用命令及数据保存技巧
|
14天前
|
算法 Linux 调度
深度解析:Linux内核的进程调度机制
【4月更文挑战第12天】 在多任务操作系统如Linux中,进程调度机制是系统的核心组成部分之一,它决定了处理器资源如何分配给多个竞争的进程。本文深入探讨了Linux内核中的进程调度策略和相关算法,包括其设计哲学、实现原理及对系统性能的影响。通过分析进程调度器的工作原理,我们能够理解操作系统如何平衡效率、公平性和响应性,进而优化系统表现和用户体验。
20 3
|
18天前
|
存储 缓存 安全
Linux IO:打开数据之窗的魔法
Linux I/O(输入/输出)是操作系统中一个至关重要的组成部分,它涉及到数据在内存🧠、存储设备💾、网络接口🌐等之间的传输过程。在Linux中,I/O操作不仅仅是文件读写那么简单,它包括了一系列复杂的机制和策略,旨在提高数据处理的效率,保证系统的稳定性和性能。📊
Linux IO:打开数据之窗的魔法