前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >Linux内核37-内核数据的同步访问

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

作者头像
Tupelo
发布2022-08-15 16:09:56
9160
发布2022-08-15 16:09:56
举报
文章被收录于专栏:嵌入式ARM和Linux

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

1 内核数据的同步访问

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

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

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

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

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

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

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

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

代码语言:javascript
复制
new->next = list_element->next;
list_element->next = new;

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

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

代码语言:javascript
复制
    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 中断、异常和可延时函数同时访问的数据结构

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

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2020-04-22,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 嵌入式ARM和Linux 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 1 内核数据的同步访问
  • 2 如何选择自旋锁、信号量和禁止中断
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档