分类目录归档:读核笔记

Linux kernel spinlock使用不当的后果

spinlock(自旋锁)是内核中最常见的锁,它的特点是:等待锁的过程中不休眠,而是占着CPU空转,优点是避免了上下文切换的开销,缺点是该CPU空转属于浪费,spinlock适合用来保护快进快出的临界区。

spinlock有很多限制条件,其中最重要的是,持有spinlock的CPU不能被抢占,持有spinlock的代码不能休眠。如果违反,会发生死锁,后果很严重。持有spinlock的代码不能休眠,这一条是开发者编写内核程序使用spinlock的时候要人工保证的。而持有spinlock的CPU不能被抢占是由spinlock的API本身提供保证,出于效率的考虑,spinlock的API提供了多种选择,对抢占的防止程度也不一样,开发者在选用的时候需要谨慎,下文对此详细展开。

Linux内核提供了多种spinlock的API,其中最常用的是:
spin_lock/spin_unlock — 禁止内核抢占
spin_lock_irq/spin_unlock_irq — 禁止内核抢占并屏蔽中断
spin_lock_irqsave/spin_unlock_irqrestore — 禁止内核抢占并屏蔽中断,事先保存中断屏蔽位并事后恢复原状

spin_lock()禁止了内核抢占,但是没有屏蔽中断,意味着持有该spinlock的CPU有可能被中断抢占。如果你的某段内核代码选用了spin_lock(),就必须保证这段代码不会被任何中断处理程序调用,否则就会发生死锁(参见后文的一个实际发生的案例)。如果某段内核代码有可能被中断处理程序调用,那就只能选择spin_lock_irq或spin_lock_irqsave。

下面是一个刚发生的实际案例,SLES11 SP4的系统失去响应,kdump生成了vmcore,分析过程中发现以下backtraces揭示了原因:

我来解释一下,上面的backtraces意思是:CPU 2上正在运行的进程是”kswapd0″(kswapd0是负责swapping的内核线程),它正在压缩dcache以便腾出一些空闲内存,当它执行到__shrink_dcache_sb()的时候被一个中断抢占了CPU,(注意被中断抢占的进程不会离开当前CPU,不会有机会到其它CPU上运行,只能等中断处理结束之后把CPU交还给它),中断处理程序是bnx2x驱动模块(注意看[],表示的是内核模块),它发现内存不够,于是自动清理内存,最终也走到了压缩dcache这一步,也去调用__shrink_dcache_sb(),但是__shrink_dcache_sb()的临界区受到spinlock保护,见下面源代码第0823行,这个名为dcache_lru_lock的spinlock刚才已经被”kswapd0″进程持有了,所以中断处理程序不可能抢到,问题是持有dcache_lru_lock的”kswapd0″进程又被中断抢占了CPU,不可能继续运行,也就没机会释放掉dcache_lru_lock,这就陷入了死锁状态。

根本原因在于,既然__shrink_dcache_sb()选用了spin_lock(),就意味着设计者认为它不会被中断处理程序调用,因为spin_lock()不屏蔽中断,是不能防止中断抢占的,只要中断处理程序不调用__shrink_dcache_sb(),死锁就不会发生;如果要让__shrink_dcache_sb()可以被中断处理程序调用,那就不能选用spin_lock(),而应该用spin_lock_irq或spin_lock_irqsave。这个案例中的问题出在bnx2x驱动程序中,它在bnx2x_alloc_rx_sge() 中调用alloc_pages()时不恰当地使用了GFP_KERNEL标志,实际上应该使用GFP_ATOMIC标志,这样alloc_pages()就不会试图去主动回收内存、也就不会最终调用__shrink_dcache_sb()了。此bug记载在SUSE的bsc#975358中,在kernel 3.0.101-77中得以修复。

内核引导参数iommu与intel_iommu有何不同?

前文介绍过IOMMU是提供DMA Remapping功能的硬件模块,可以把DMA地址从虚拟地址翻译成物理地址。Linux kernel有两个引导参数(boot parameter)与iommu有关:iommu=[on/off] 和 intel_iommu=[on/off],它们有什么区别呢?答案是:参数iommu控制的是GART iommu功能,参数intel_iommu控制的是基于Intel VT-d的iommu功能。

下面的代码表明:CONFIG_IOMMU控制的是GART iommu,CONFIG_DMAR控制的是intel_iommu。(CONFIG_IOMMU对应的是引导参数iommu,CONFIG_DMAR对应的是引导参数intel_iommu,注意:CONFIG_DMAR名称中没有Intel字样,这里比较容易误导,但你看它下面对应的函数intel_iommu_init就很明显了):

 

引导参数iommu控制的是GART iommu前文介绍过GART (Graphics Address Remapping Table),最初是为了方便图形芯片直接读取内存而设计的:使用地址转译功能将收集到内存中的数据映射到一个图形芯片可以“看”到的地址。这个地址转译功能自然也可以充当IOMMU,于是GART被Linux kernel用来帮助传统的32位PCI设备访问可寻址范围之外的内存区域。GART iommu有局限性(比如仅限于显存范围内),不具备Intel IOMMU的完整功能。

“iommu”参数默认是打开的,以2.6.18 kernel为例,

注:GART iommu功能是按需激活的,并有前提条件,比如系统内存必须在3GB以上、而且只对有限的设备,参见:

引导参数intel_iommu控制的是基于Intel VT-d的iommu,该参数默认是关闭的,在config文件中对应的配置如下(注意:名称中用的是DMAR而不是iommu,不留意的话容易错过):

从以下的代码中我们看到:[默认情况]与[显式设置intel_iommu=off]的效果是一样的,结果都是”dmar_disabled=1″,所以 intel_iommu=off 设不设置其实都一样:

注:启动参数intel_iommu有点复杂的是:存在两个与DMAR有关的配置,除了上面看到的CONFIG_DMAR_DEFAULT_ON之外,另外还有一个是CONFIG_DMAR,默认是打开的:

CONFIG_DMAR告诉内核在编译时要准备好支持DMA Remapping设备(见注一);
而CONFIG_DMAR_DEFAULT_ON 是告诉内核在引导时是否激活DMAR设备。
也就是说,默认情况下(CONFIG_DMAR=y)内核已经具备了支持DMAR的功能,设置内核引导参数intel_iommu=off(等效于缺省情况:即CONFIG_DMAR_DEFAULT_ON未设置)并不是关闭内核的DMAR功能,仅仅是告诉内核在引导过程中不要把DMAR设备激活而已。
这两个配置参数的详细解释参见以下文件。

(注一)事实上更准确地说,内核不仅仅是在编译时具备了支持DMAR设备的功能,而且在引导过程中始终会根据ACPI table把DMAR设备有关的数据结构都初始化好–无论是否加了引导参数intel_iommu=off。

内核怎么知道哪些设备需要DMA Remapping呢?是通过ACPI table知道的,因为需要DMA Remapping的设备必须在firmware/BIOS中登记。以下是内核初始化DMAR的代码,可以看到无论是否dmar_disabled,都会调用dmar_table_init和dmar_dev_scope_init:

这就是为什么只要BIOS中打开了Intel VT-d,我们就总会在kernel messages中看到类似下面的初始化DMAR table的信息,无论intel_iommu参数是on还是off。

如果你想让kernel中与Intel VT-d有关的软件模块完全关闭,仅仅使用启动参数intel_iommu=off是不够的,而必须重新编译内核–在config中配置CONFIG_DMAR=n,或者用另一种方法:在BIOS中关闭Intel VT-d。

 

NMI Watchdog是怎样检测 lockup的?

所谓lockup,是指某段内核代码占着CPU不放。Lockup严重的情况下会导致整个系统失去响应。Linux kernel设计了一个检测lockup的机制,称为NMI Watchdog,是利用NMI中断实现的,用NMI是因为lockup有可能发生在中断被屏蔽的状态下,这时唯一能把CPU抢下来的方法就是通过NMI,因为NMI中断是不可屏蔽的。

Lockup有几个特点:首先只有内核代码才能引起lockup,因为用户代码是可以被抢占的,不可能形成lockup;其次内核代码必须处于禁止内核抢占的状态(preemption disabled),因为Linux是可抢占式的内核,只在某些特定的代码区才禁止抢占,在这些代码区才有可能形成lockup。

Lockup分为两种:soft lockup 和 hard lockup,它们的唯一区别是 hard lockup 发生在CPU屏蔽中断的情况下。NMI Watchdog 中包含 soft lockup detector 和 hard lockup detector,两者的工作原理稍有区别,2.6之后的内核中的实现方法如下:

NMI Watchdog 的触发机制包括两部分:

  1. 一个高精度计时器(hrtimer),对应的中断处理例程是kernel/watchdog.c: watchdog_timer_fn(),在该例程中:
    • 要递增计数器hrtimer_interrupts,这个计数器供hard lockup detector用于判断CPU是否响应中断;
    • 还要唤醒[watchdog/x]内核线程,该线程的任务是更新一个时间戳;
    • 例程中的soft lock detector代码检查时间戳,如果超过soft lockup threshold一直未更新,说明[watchdog/x]未得到运行机会,意味着CPU被霸占,也就是说发生了soft lockup。
  2. 基于PMU的NMI perf event,当PMU的计数器溢出时会触发NMI中断,对应的中断处理例程是 kernel/watchdog.c: watchdog_overflow_callback(),hard lockup detector就在其中,它会检查上述hrtimer的中断次数(hrtimer_interrupts)是否在保持递增,如果停滞则表明hrtimer中断未得到响应,也就是发生了hard lockup。

hrtimer的周期是:softlockup_thresh/5,(在2.6内核中softlockup_thresh的值等于内核参数kernel.watchdog_thresh,默认60秒;而到3.10内核中,内核参数名称未变,还是kernel.watchdog_thresh,但含义变成了hard lockup threshold,默认10秒,soft lockup threshold则等于2*kernel.watchdog_thresh,即默认20秒)。

NMI perf event是基于PMU的,触发周期在2.6内核里是固定的60秒,不可手工调整,在3.10内核里可以手工调整,因为直接对应着内核参数kernel.watchdog_thresh,默认值10秒。这也是hard lockup threshold的值。

检测到 lockup 之后怎么办?可以自动panic,也可输出条信息就算完了,这是可以通过内核参数来定义的:
kernel.softlockup_panic: 决定了检测到softlock时是否自动panic,缺省值是0;
kernel.nmi_watchdog: 定义是否开启nmi watch、以及hardlock是否导致panic,该内核参数的格式是”=[panic,][nopanic,][num]”.(注:好像最新的kernel引入了新的内核参数kernel.hardlockup_panic,请自测。)

参考 kernel/watchdog.c:
设置PMU NMI perf event的代码 wachdog_nmi_enable()
响应NMI perf overflow中断的代码 watchdog_overflow_callback()
[watchdog/x]内核线程 watchdog()
响应hrtimer中断的代码 watchdog_timer_fn()

dmar 与 IOMMU

在分析Linux kernel dump的时候经常会看到一个叫做dmar的东西,查看中断信息的时候也时常见到一个名称为dmar0的设备,到底什么是dmar呢?

大家知道,I/O设备可以直接存取内存,称为DMA(Direct Memory Access);DMA要存取的内存地址称为DMA地址(也可称为BUS address)。在DMA技术刚出现的时候,DMA地址都是物理内存地址,简单直接,但缺点是不灵活,比如要求物理内存必须是连续的一整块而且不能是高位地址等等,也不能充分满足虚拟机的需要。后来dmar就出现了。 dmar意为DMA remapping,是Intel为支持虚拟机而设计的I/O虚拟化技术,I/O设备访问的DMA地址不再是物理内存地址,而要通过DMA remapping硬件进行转译,DMA remapping硬件会把DMA地址翻译成物理内存地址,并检查访问权限等等。负责DMA remapping操作的硬件称为IOMMU。做个类比:大家都知道MMU是支持内存地址虚拟化的硬件,MMU是为CPU服务的;而IOMMU是为I/O设备服务的,是将DMA地址进行虚拟化的硬件。   IOMMUIOMMA不仅将DMA地址虚拟化,还起到隔离、保护等作用,如下图所示意,详细请参阅Intel Virtualization Technology for Directed I/O DMA remapping现在我们知道了dmar的概念,那么Linux中断信息中出现的dmar0又是什么呢? 还是用MMU作类比吧,便于理解:当CPU访问一个在地址翻译表中不存在的地址时,就会触发一个fault,Linux kernel的fault处理例程会判断这是合法地址还是非法地址,如果是合法地址,就分配相应的物理内存页面并建立从物理地址到虚拟地址的翻译表项,如果是非法地址,就给进程发个signal,产生core dump。IOMMU也类似,当I/O设备进行DMA访问也可能触发fault,有些fault是recoverable的,有些是non-recoverable的,这些fault都需要Linux kernel进行处理,所以IOMMU就利用中断(interrupt)的方式呼唤内核,这就是我们在/proc/interrupts中看到的dmar0那一行的意思。 我们看到的中断号48,据此还可以进一步发掘更多的信息:

上面最有意思的信息是ACTION的handler,表示IOMMU发生fault之后的中断处理例程,我们看到的例程名是dmar_fault,源代码如下:

请注意行号1343,dmar_fault_do_one()会报告fault的具体信息,包括对应设备的物理位置。由于一个dmar对应着很多个I/O设备,这条信息可以帮助定位到具体哪一个设备。源代码如下:

 

dmar的初始化是kernel根据ACPI中的dmar table进行的,每一个表项对应一个dmar设备,名称从dmar0开始依次递增,涉及取名的代码如下:

上面也揭示了boot过程留在dmesg中信息的来历:

附注: 过去的AMD64芯片也提供一个功能有限的地址转译模块——GART (Graphics Address Remapping Table),有时候它也可以充当IOMMU,这导致了人们对GART和新的IOMMU的混淆。最初设计GART是为了方便图形芯片直接读取内存:使用地址转译功能将收集到内存中的数据映射到一个图形芯片可以“看”到的地址。后来GART被Linux kernel用来帮助传统的32位PCI设备访问可寻址范围之外的内存区域。这件事新的IOMMU当然也可以做到,而且没有GART的局限性(它仅限于显存的范围之内),IOMMU可以将I/O设备的任何DMA地址转换为物理内存地址。


参考资料:
Linux Kernel的Intel-IOMMU.txt
Intel’s Virtualization for Directed I/O (a.k.a IOMMU)
Wikipedia IOMMU
理解IOMMU、北桥、MMIO和ioremap
Intel Virtualization Technology for Directed I/O

PREEMPT_ACTIVE标志在内核抢占中的作用

Linux从2.6开始支持内核抢占,意味着即使进程运行在内核态也可以被抢占。为了支持内核抢占,代码中为每个进程的 thread_info 引入了 preempt_count 计数器,数值为0的时候表示可以内核抢占,每当进程持有内核锁的时候把 preempt_count 计数器加1,表示禁止内核抢占。此外还有一些其它禁止内核抢占的场景,也通过 preempt_counter 字段反应出来:preempt_counter 字段是32位的,除了抢占计数器之外还包括其他标志位,只要 preempt_counter 整体不为0,就不能进行内核抢占,这个设计一下子简化了对众多不能抢占的情况的检测:

  • Bit  0- 7: 就是上文中所讲的抢占计数器,表示显式禁用内核抢占的次数;
  • Bit  8-15: 软中断计数器,记录可延迟函数被禁用的次数;
  • Bit 16-27: 硬中断计数器,表示中断处理程序的嵌套数,irq_enter()递增它,irq_exit()递减它;
  • Bit    28: PREEMPT_ACTIVE 标志。

这篇笔记要讲的是 PREEMPT_ACTIVE 标志。PREEMPT_ACTIVE标志的本意是表明正在进行内核抢占,设置了之后preempt_counter就不再为0,从而达到禁止内核抢占的效果,使得执行抢占工作的代码不会被再抢占。它的一个重要用途是防止非Running状态的进程被抢占过程错误地从Run Queue中移除。这句话令人十分费解,已经不处于Running状态的进程本来就不应该留在Run Queue中,为什么要防止它被移除?我用了很长时间才琢磨出来,要回答这个问题,首先要理解为什么非Running状态的进程会被抢占?所谓抢占,就是从一个正在运行的进程手上把CPU抢过来,可是既然进程已经不是Running状态了,怎么会还在CPU上,还被抢占?
这是因为进程从Running变成非Running要经过几个步骤:在把自己放进Wait Queue、状态置成非Running之后,最后调用schedule()把自己从Run Queue中移除、并把CPU交给其他进程。设想一下,一个进程恰好在调用schedule()之前就被抢占了,此时它仍然还在CPU上运行。这就是为什么非Running状态的进程也会被抢占的原因。对这样的进程,抢占流程不能擅自将之从Run Queue中移除,因为它的切换过程没有完成,应该让它有机会自己回头接着做完。比如以下的代码,是一个典型的休眠过程:

如果在第2行被抢占,刚把进程状态设置为TASK_UNINTERRUPTIBLE,本来马上就要测试条件是否满足了,这时被抢占,而抢占过程必定包含调用schedule()的步骤,导致该进程被移出运行队列,失去了运行机会,随后的条件判断语句就无法执行了,假如此时condition条件是满足的,它本来会跳出for循环、而不会去调用schedule()进入休眠,然而却被抢占过程错误地调用schedule()导致它休眠了,也因此错过了那个条件判断语句,也许就永远没有被唤醒的机会了。正确的做法是:进程被抢占后还留在Run Queue中,下次还有机会继续运行,恢复运行后继续判断condition,如果条件不满足,在随后主动调用的schedule()中会被移出运行队列,这是不能由抢占代劳的。

下面我们详细看看PREEMPT_ACTIVE是如何帮助实现上述的正确做法的。在内核里,进程从运行态进入休眠态的最后一步是呼叫调度器schedule()——把自己从Run Queue中移除,把CPU交给其他进程,这在不支持内核抢占的时代没有问题,因为整个过程不会被打断,然而内核抢占的出现使情况变复杂了,现在从运行态进入休眠态的过程可能会被抢占所打断,而抢占过程中会调用schedule(),导致schedule()的调用提前发生,有可能形成race condition。为了避免这种情况,内核抢占过程中不能直接呼叫schedule()调度器,而是呼叫preempt_schedule(),再通过它来调用schedule(),preempt_schedule()会在调用schedule()之前设置PREEMPT_ACTIVE标志,调用之后再清除这个标志。而schedule()会检查这个标志,如果设置了PREEMPT_ACTIVE标志,意味着这是从抢占过程中进入schedule(),对于不是TASK_RUNNING(state != 0)的进程,就不会调用deactivate_task()把进程从Run Queue移除。源码如下:

这段代码的逻辑含义是这样的:
如果设置了PREEMPT_ACTIVE,说明该进程是由于内核抢占而被调离CPU的,这时不把它从Run Queue里移除;如果PREEMPT_ACTIVE没被设置(进程不是由于内核抢占而被调离),还要看一下它有没有未处理的信号,如果有的话,也不把它从Run Queue移除。
总之,只要不是主动呼叫schedule(),而是因被抢占而调离CPU的,进程就还在运行队列中,还有机会运行。