An Implementation Of Multiprocessor Linux 译

1 背景:Intel多处理器规范

Intel 奔腾处理器对支持多处理器有着很多内置的组建,包括硬件cache一致性、内置的处理器间中断处理和一套原子的test和set、exchange等类似操作。

2 单处理器内核中的互斥

内核要正确执行的话就得提供内部的锁来保护自己的各种表,以避免两个进程同时更新它们,比如分配同一个内存块。目前的Unix和类Unix内核有两种策略,传统早期的Unix系统采用粗粒度的锁,用少量的锁来保护整个内核。一些现代的系统使用细粒度的锁,因为细粒度锁有更多的开销其一般只用在多处理器内核和实时内核中。在实时内核中细粒度锁减少了锁被持有的事件,并且减少了严格的延迟时间。

Linux内核中有些保证必须被确保。除非自愿睡眠,否则在内核中执行的进程不会被另一个内核中执行的进程抢占。这样确保了内核代码块相对于进程而言是原子的,极大地简化了很多操作。其次中断可能会抢占一个正在执行的内核态进程,但是永远都会返回到该进程。一个在内核态中的进程有可能禁用处理器上的中断并保证不会发生这种中断。最后的保证是内核任务不会抢占中断,也就是中断只有可能被其它中断抢占。

SMP内核选择继续这些基本保证,以使得实现和配置更简单。所有处理器维持一个锁用来访问内核空间。这个锁确保内核中执行的进程不会被抢占并确保阻塞的中断正确执行。这样确保了只有拿到锁的进程能进入内核态,只有进入内核态的进程能禁用中断并且只有拿到锁的进程能处理中断。

这样的设计性能很差,以后有必要改为细粒度的并发,可以通过逐渐细粒度锁的覆盖范围来改进。当前内核在CPU密集型的任务性能很好,但是在IO密集型的任务则接近单处理器的性能。

2.1 对可移植内核组件的修改

内核更改分为通用SMP支持更改和针对Linux移植到的每种不同处理器类型所必需的体系结构特定更改。

初始化

多处理器内核首先遇到的问题就是启动其它处理器,Linux / SMP定义单个处理器进入正常的内核入口点start_kernel(),第一个处理器开始正常的Linux初始化序列,并设置分页,中断和陷阱处理程序。

/* The bootstrap kernel entry code has set these up. Save them for a given CPU */
void smp_store_cpu_info(int id)

/* 设置栈、页寄存器等控制信息,然后启动其它处理器,让其进入start_kernel() */
void smp_boot_cpus(void)

/* 设置ldt、tlb及TR等 */
void smp_callin(void)

void smp_commence(void)

调度

内核调度器实现简单而高效,基本架构在多处理器内核中也没有改变。每个进程控制块加入了一个多处理器字段,用来保存有多少个处理器执行该任务,或者是NO_PROC_ID的话表示该任务没有被分配给一个处理器。

每个处理器都独自执行调度器并将从所有可执行的进程并且没有被分配给其它处理器的任务中选择执行。选择任务的算法没有改变,目前来说这是不充分的因为将进程尽可能的保持在同样的CPU上执行是有利的,尤其是每个处理器有一个二级cache的时候。

内核中current被用来指向当前进程,在Linux/SMP中因为有多个处理器的存在,这会变成一个宏展开就是(0+current_set[smp_processor_id()])

修改fork系统调用以生成进程标识为零的多个进程,直到SMP内核正确启动。这是必要的,因为进程号1必须是init,并且希望所有系统线程都是进程0。

进程调度中造成问题的最后一个地方是单处理器内核硬编码使得空闲线程为任务0,而init进程为任务1。因为存在多个空闲线程,有必要替换这些为进程id为0。

2.2 针对Intel多处理器端口的特定架构代码

这部分代码主要包括初始化启动系统、消息处理、中断和内核系统调用入口函数以及针对多处理器的标准的内核组件

初始化

在Intel多处理器架构中,第一个处理器进入内核启动代码,加载并解压缩内核。在第一个处理器完成的初始化中,arch/i386/mm/init代码被修改为扫描低页面,首页和BIOS,用于intel MP签名块。这是必要的,因为在允许内核分配和销毁低内存顶部的页面之前,必须读取和处理MP签名块。建立了处理器数量后,它会为系统中的每个处理器保留一组页面以提供堆栈启动区域。这些必须在启动时分配,以确保它们低于1Mb边界。

通过编程APIC控制器寄存器并向处理器发送处理器间中断(IPI),可以在smp_boot_cpus()中启动更多处理器。此消息使目标处理器开始在最低1Mb,16位实模式下的任何内存页的开头执行代码。内核使用为每个处理器分配的单个页面作为堆栈使用。在引导给定CPU之前,来自trampoline.S和trampoline32.S的可重定位代码被复制到其堆栈页面的底部,并用作启动的目标。

trampoline代码从代码段计算所需的堆栈基数(因为启动时的代码段是堆栈的底部),进入32位模式并跳转到内核条目汇编器。如上所述,将其修改为仅执行每个处理器所需的部分,然后输入start_kernel()。在进入内核时,处理器在进入smp_callin()之前初始化其陷阱和中断处理程序,在此处它报告其状态并设置一个标志,该标志使引导处理器继续并寻找其他处理器。然后处理器旋转,直到调用smp_starts()。

启动每个处理器后,smp_begin()函数会翻转一个标志。在smp_callin()中旋转的每个处理器然后根据任务切换所需的任务状态段(TSS)加载任务寄存器。

消息处理支持代码

体系结构特定代码通过查询APIC逻辑标识寄存器来实现smp处理器id()函数。由于APIC在引导时未映射到内核地址空间,因此通过将APIC基指针设置为指向合适的常量来修复返回的初始值。一旦系统开始执行SMP设置(在smp_boot_cpus()中),APIC使用vremap()调用进行映射,并且适当调整apic指针。从那时起,读取真实的APIC逻辑身份寄存器。

使用中断13上的一对IPI(在SMP模式下由80486 FPU未使用)和中断16来完成消息传递。两个用于分离在接收器从可以是消息的消息获得内核自旋锁之前无法处理的消息。立即处理。实际上,IRQ 13是一个快速IRQ处理程序,它不能获得锁定,并且不能导致重新调度,而IRQ 16是一个慢速IRQ,必须获取内核自旋锁并可能导致重新调度。此中断用于将接收定时器中断的处理器的从定时器消息传递给其余处理器,以便它们可以重新安排正在运行的任务。

进入和退出代码

单个自旋锁保护整个内核。中断处理程序,系统调用条目代码和异常处理程序都在进入内核之前获取锁。当处理器试图获取自旋锁时,它会在禁用中断的情况下持续旋转锁定。这会导致特定的死锁问题。锁所有者可能需要向其余处理器发送无效请求,并等待这些请求完成后再继续。在锁上旋转的处理器将无法执行此操作。因此,自旋锁的循环测试并处理无效请求。如果设置了旋转CPU的无效位,则处理器使其TLB无效并以原子方式清除该位。当获得自旋锁时,处理器将采用IPI并在IPI中测试该位并在该位清零时跳过无效。

自旋锁的一个复杂性是在内核模式下运行的进程可以自愿睡眠并被抢占。从这样的过程切换到在用户空间中执行的过程可以减少锁定计数。为了跟踪这一点,内核使用系统调用计数和每进程锁定深度参数来跟踪内核锁定状态。在SMP模式下修改switch_to()功能以适当调整锁定。

最后一个问题是空闲线程。在单处理器内核中,空闲线程执行'hlt'指令。这样可以节省电力并降低处理器空闲时的运行温度。但是,这意味着该进程在内核模式中花费所有时间,因此将保留内核自旋锁。 SMP空闲线程不断重新安排新任务并返回用户模式。这远非理想,将被修改为使用'hlt'指令并尽快释放螺旋锁。使用'hlt'对多处理器系统更有利,因为它几乎完全占用了总线上的空闲处理器。

中断由i82489 APIC分发。该芯片设置为在机器启动时作为传统PC中断控制器的仿真(以便Intel MP机器启动一个CPU和PC兼容)。内核具有所有相关锁,但尚未对82489进行重新编程,以便向任意处理器提供中断。这需要进一步修改标准Linux中断处理代码,并且特别麻烦,因为一旦82489切换到SMP模式,中断处理程序行为就必须改变。

本文译自《An Implementation Of Multiprocessor Linux》, 文中内容适用于linux-2.0内核