kernel 中的 lock 问题

前言

本篇算是对 从零开始写 OS 内核 - 锁与多线程同步 的一个补充修正,那篇文章里讨论了 lock 的基本原理和几种不同类型的 lock 以及它们的适用场景,但都只是原理性的,而且其实更偏向于 lock 在 user 态下的使用情形;而对于 kernel 中的 lock,尤其是 spinlock,以及 lock 在单核/多核 CPU 上的特性,都还没有详细地展开。本文将会从源头出发,重新讨论 kernel 下各种 lock 的实现和使用方式。

race 的根源

lock 是用来解决 data race 的,那么我们首先要问自己一个问题,data race 的根源是什么?

一个通常的回答是:多个 threads 在多个 CPU 上的同时运行,或者在同一 CPU 上的切换并发运行,如果访问/修改了同一数据,那么就会导致 race。

这个回答对 user 态下也许是对的;但是对于 kernel,它还并不完整,甚至从某种意义上说还有点本末倒置。

这里我们先抛开多核CPU,先讨论单核的情形,这也是我们的这个 scroll 项目所使用的。我们来纠正上面的这个回答里的几个不足之处和认知偏差。

代码交织(interleave)

对于 kernel 而言,除了 threads 之间的切换,还有一个很重要的角色是 interrupt (注意这里的术语使用,interrupt 是指外部中断,即硬中断),它也能打断一个正在执行的代码,进而可能导致 data race。

因此严格来说,引起 race 的本质原因是代码执行流的 交织interleave)。在 kernel 中,它既可能是由 threads 切换导致,也可能是由于 interrupt 导致。对于 interrupt,你必须要认识到以下几点:

  • 它随时可能发生,不受控制,无法预测;
  • interrupt 的处理过程,即 中断上下文interrupt context),不属于任何 thread,而是一个独立的执行流,它只是暂时“打断”一下当前的执行流而已;既然它不属于任何 thread,那么它就不可能成为被 scheduler 调度的对象,也就是说它不可以 yieldsleep 等,这也意味着在 interrupt 里不可以使用 yieldlock 或者阻塞锁;

preempt 与 interrupt

在 user 空间下,我们通常认为引起 data race 的主要原因是 threads 的切换,导致可能对同一数据的并发访问/修改。然而到了 kernel 中,这种观点其实需要重新审视一下。固然线程在 kernel 态下也会发生相互切换,但我们需要认识到,这种切换行为的根源其实就来自于 kernel 自己:是 kernel 的 scheduler 控制着 threads 的切换调度,所以这里有点因果关系倒置的意思。我们不妨思考:如果 kernel 暂停了 threads 切换调度,那么由 threads 切换引起的 data race 问题是不是就不复存在了?

从专门术语上讲,threads 之间的切换执行,叫做 抢占preempt),也就是说在 threads-A 运行过程中,kernel 将它从 CPU 上换下,并换上 thread-B 执行。注意这里的切换,可能是 thread-A 主动放弃了 CPU(例如等待某个资源而 sleep),也可能是被 kernel 强制的(例如时间片用完了)。因此后面我们就用 preempt 这个术语来指代 threads 切换。

这里要分清 preemptinterrupt 之间的区别,它们虽然都会暂停当前执行流而换到另一个,但是它们之间是有着本质区别的:

  • preempt 是基于 thread 的,而 interrupt 是独立的个体,它虽然会打断 thread,但并不和这个 thread 有任何牵连关系;
  • preempt 行为是同步的,完全受 kernel 逻辑控制的;interrupt 则是异步发生的,无法预知的;

对于 Linux 内核,发生 preempt 决策的几个最重要的时间点是:

  • interrupt 处理返回时;
  • syscall 返回时;
  • 代码主动调用 scheduler 时,例如线程 yield,sleep,exit 等行为;

对应到我们 scroll 项目的实现上,最常见的切换点在 timer interrupt 处理中,这里我们会检查当前运行的 thread 时间片是否已经用完,如果用完了则调用 scheduler,触发 threads 切换(即 preempt)。所以我们通常说多 threads 的轮转切换是由 timer interrupt 驱动的,这种说法固然没有错,但并不严格。更通常的做法是,将调用 scheduler 的动作移出 timer interrupt handler,而延后到 interrupt 的通用退出过程中,这对于 timer interrupt 来说,最终的效果其实没有什么本质区别;当然有一个改变就是,所有的 interrupt 在退出都会去调用 scheduler,不仅仅是 timer。

我们来看修改后的 timer interrupt 处理函数,它不再调用 scheduler,而是只将当前(interrupt 前)运行的 thread 的运行时间 ticks 增加,如果达到了时间片上限,那么设置这个 thread 的 need_reschedule = true,标记该 thread 需要切换:

static void timer_callback(isr_params_t regs) {
  // ...
  tcb_t* crt_thread = get_crt_thread();
  crt_thread->ticks++;
  if (crt_thread->ticks >= crt_thread->time_silice) {
    crt_thread->need_reschedule = true;
  }
}

interrupt 退出时,会调用 scheduler

[EXTERN schedule]

interrupt_exit:
  ; call scheduler
  call schedule
  
  ;...

schedule 函数里会检查 need_reschedule,如果为 true,则发生切换(preempt)。

关闭 preempt

正因为 preempt 的行为完全是由 kernel 自己控制的,所以当我们需要排除 preempt 导致的 data race 问题时,可以从根源上解决它,即禁止 preempt:既然 threads 不切换,又何来 data race 呢?

当然关闭 preempt 是有代价的,它会让当前 thread 独占当前 CPU,对 threads 调度的公平性有所损害,所以应该使关闭 preempt 时间尽量短。

关闭 interrupt

关闭 preempt 只能解决 threads 切换导致的 race 问题,但是如果我们需要保护一个被 thread 和 interrupt 都访问的数据,那么仅仅是关闭 preempt 是不够的。例如 thread-A 在运行,尽管 preempt 禁用了,不可能有其它 thread 干涉了,但是 interrupt 可能随时会打断 thread-A,假如此时 thread-A 持有了一个 lock,同时 interrupt 处理过程中也需要获取这个 lock,那么 interrupt 就会卡住,这是绝对不允许的,因为 interrupt 不可以 sleep 或者 yield,但是此时 thread-A 又被打断了无法继续运行并释放这个 lock,所以这里发生了 deadlock。以上讨论仍然仅限于在一个 CPU 上发生的情况。

总而言之,interrupt 一旦开始处理,必须处理完(而且要尽可能地快)。当在 interrupt 里需要获取 lock 时,必须保证:

  • lock 不可以被本 CPU 上的其它人持有;
  • lock 如果在其它 CPU 上被持有了,那么本 interrupt 只能原地 spin 等待,不可以被剥夺 CPU,也就是说 yield,sleep 等都是禁止的;你不可以推迟 interrupt 的处理;

然而 interrupt 是不可预知的,所以为了满足上述要求的第一点,只能反其道而行之:确保在 lock 被持有之后,不可以再有 interrupt 横插进来。也就是说如果 lock 可能会被 interrupt 使用到,上锁时需要关闭 interrupt,这样就可以完全杜绝 interrupt 引发的问题。

最典型的应用场景就是各种输入设备的驱动,例如键盘,每次有新的按键输入,就会触发键盘 interrupt,那么 interrupt 处理函数就会从硬件端口上读取输入,并放置到 kernel 里的一个缓冲区;而用户线程可能正在等待读取这个缓冲区;这是典型的 producer-consumer 模型,这个缓冲区肯定需要 lock 来保护,它既要防止多个用户 threads 之间对该缓冲区的并发 race 问题,也要防止 thread 和键盘 interrupt 处理函数之间的 race 问题,这把锁就需要关闭中 interrupt。

关闭 interrupt 同样是有代价的,它会降低系统对 interrupt 的响应速度,这对于对实时性要求高的系统是不可容忍的,例如控制系统,人机交互的系统等。所以关闭 interrupt 的时间同样需要控制得非常短。

同时,关闭了 interrupt 后,实际上 preempt 的大多数触发点也就被关闭了,因为没了 interrupt,也就不会有 interrupt 退出时的 scheduler 调用点。但是这并意味着完全安全了,因为 preempt 仍然有可能发生,例如 thread 主动调用 scheduler(这种情况应该不多见,我并不确定,欢迎指正)。但不管怎样,最保险的做法是,关闭 interrupt 同时也关闭 preempt,它保证了在当前代码执行流对本 CPU 的完全独占(mutual exclusive),也就彻底避免了 data race。

再看 spinlock

我们现在来重新考察 kernel 中的 spinlock,它和 user 态下的 spinlock 完全不同,远非看上去的那么简单。我们现在开始要考虑有无 interrupt,以及在单核/多核 CPU 上的不同情形。

没有 interrupt

首先考虑没有 interrupt 的情况:这里是指 lock 不会被 interrupt handler 使用到,也就是说只有正常的 threads 之间的竞争。现在开始我们需要对单个/多个 CPU 分开讨论。

如果是一个 CPU(注意这里的 “一个 CPU”,可以是指单核处理器,也可以指多核处理器上的某一个 CPU,并且两个竞争的 threads 都运行在这个核上,它实际上就等效于单核处理器了),在以前的讨论中我们提到过,单纯的 spin 等待在一个 CPU 上是没有任何意义的,因为持有 lock 的 thread 无法得到运行并释放 lock,所以当前 thread 在它的运行周期内 spin 空转完全是浪费时间。所以 spinlock 要做的第一件事情就是,关闭 preempt,这样别的 thread 也就不可能在这个 CPU 上 spin 空转了。

你可能说这样还算什么 spinlock,完全是让一个 thread 霸占了这个 CPU。这么说当然也没错,但是这仅仅是对于一个 CPU 而言,而且你会发现对于单个 CPU,这其实是最合理的做法了。

如果进一步考虑多个 CPU 的情况,那么 spin 的语义实际上还是有意义的。多个 CPU 上的 threads 可能同时竞争这个 lock,那么仅仅关闭本 CPU 上的 preempt 是不够的,其它 CPU 还是可能加入竞争,所以这里还需要真正的 atomic 竞争机制,也就是前面文章里讲过的 CAS 原子操作:

void spinlock_lock(spinlock_t *splock) {
  // Disable preempt on local cpu.
  disable_preempt();

  // CAS competition for multi-processor.
  #ifndef SINGLE_PROCESSOR
  while (atomic_exchange(&splock->hold , LOCKED_YES) != LOCKED_NO) {}
  #endif
}

再强调一下上面的 lock 不可以被 interrupt 使用,我们的讨论暂时还没有将 interrupt 考虑进来,仅仅解决 threads 之间的竞争问题。

另外前面的文章里也提到过,spinlock 通常用于保护比较小的 critical section,这在多核 CPU 上可以避免 spin 空转浪费太多 CPU 时间;对于单核的情形,小的 critical section 保证了当前持有 lock 的 thread 不要霸占 CPU (即关闭 preempt) 太久,影响调度公平性。

你可能会问 disable_preempt() 是怎么实现的。仿照 Linux 的做法,在 thread 的结构体里定义一个整数 preempt_count,每次调用 disable_preempt() 就将它加 1,enable_preempt 就减 1。只有当 preempt_count 为 0 时才可以 preempt,否则 scheduler 就认为当前 preempt 在该 thread 所在的 CPU 上是关闭的:

struct task_struct {
  // ...
  uint32 preempt_count;
};

void disable_preempt() {
  get_crt_thread()->preempt_count += 1;
}
void enable_preempt() {
  get_crt_thread()->preempt_count -= 1;
}

所以可以发现,所谓的关闭 preempt,只能关闭当前 thread 所在的 CPU 上的 preempt,使当前 thread 独占该 CPU;但是你无法影响到多核处理器上的其它 CPU,这也是为什么在多核的情况下,需要上面的 CAS 竞争机制保证安全。

引入 interrupt

接下来考虑 interrupt 和 thread 之间竞争的情况。前面已经分析过,interrupt 不依托于任何 thread,所以它不可以 sleep 或者 yield,所以它只能原地 spin 等待。如果某个 lock 可能会被 interrupt 使用,那么其它使用者在获取这个 lock 时,必须先关闭 interrupt,这样才能彻底杜绝 interrupt 的干涉。

因此在 kernel 的 spinlock 的实现上,除了正常的 lock 接口,它还需要提供一个关闭 interrupt 的接口,例如 spinlock_lock_irqsave

void spinlock_lock_irqsave(spinlock_t *splock) {
  // First disable preempt.
  disable_preempt();

  // Now disable local interrupt and save interrupt flag bit.
  uint32 eflags = get_eflags();
  disable_interrupt();
  splock->interrupt_mask = (eflags & (1 << 9));

  // For multi-processor, competing for CAS is still needed.
  #ifndef SINGLE_PROCESSOR
  while (atomic_exchange(&splock->hold , LOCKED_YES) != LOCKED_NO) {}
  #endif
}

这个接口不仅关闭 preempt,同时也关闭了 interrupt,确保该 lock 可以被 interrupt 使用;在 Linux 内核里也有类似的 API 用法。

之所以叫 spinlock_lock_irqsave,是因为它会将之前的 interrupt 状态保存下来,在 unlock 时恢复之,而不是简单的打开 interrupt,因为有可能在 lock 前 interrupt 本来就是关闭的,那么你不可以在 unlock 时擅自打开,只能恢复原样。

同样,即使是同时关闭 preempt 和 interrupt,也只能保证本 CPU 安全;而对于多核的情况,仍然需要和其它 CPU 继续 CAS 竞争 lock,确保多核之间的安全性。

spin 还是 yield

在 scroll 项目里我们还实现过一种 yieldlock,即获取不到 lock 时主动放弃 CPU,但是不睡眠,只是 yield,过一会儿再来重试。

yieldlock 显然不可以被 interrupt 使用,原因还是之前说过的,interrupt 不属于任何 thread,所以它不可以 yield,否则可能会导致死锁。例子还是上面那个,一个 thread-A 持有一个 lock,如果被 interrupt 打断了,interrupt 也想获取这个 lock,但是它已经被 thread-A 持有,interrupt 此时没有任何办法,它不可以 yield,因为它不是一个 thread。不妨设想一下,就算你此时真的想 yield,那 yield 谁呢,thread-A 吗?但此刻我们想要的是 thread-A 继续运行并释放 lock,那这显然是矛盾的。

对于没有 interrupt 介入的场景(即只有 threads 之间竞争),使用 spinlock(这里是指非 interrupt 版本的接口函数) 和 yieldlock 都是可以的,这里比较一下它们的原理:

  • spinlock 会直接关闭 preempt 保证本 CPU 的独占;如果是多核 CPU,还需要 CAS 竞争,失败时原地 spin 等待;
  • yieldlock 直接采取 CAS 竞争的方式,不管是本 CPU 还是多核的情况;如果竞争失败,则暂时让出 CPU,等下一次有机会再重试;

前面也提到过,在单核情况下,spinlock 稍微有点名不副实,它甚至不需要 CAS 竞争,也不会出现 spin 等待,而是直接关闭 preempt 来杜绝threads 竞争。不过这也没关系,只要达到 lock 的目的就可以了,而且这也可能是最合理的做法了。

再来看一下它们各自的缺点:

  • spinlock 由于关闭了 preempt 而暂时霸占 CPU,可能会稍稍影响公平性;
  • yieldlock 本质上是一种延迟的重试,有可能重试好几次都还是失败,这会浪费一些 CPU 时间;

但是还是要强调,这两种 lock 都是用于保护非常小的 critical section,所以上面所说的问题都会被控制在尽可能小的范围之内,减小竞争带来的损耗。

总结

本文重新整理讨论了 kernel 中的 lock 问题,尤其是 spinlock,作为一种在 kernel 中非常常用的锁,它充分体现了 kernel 下锁的复杂性和多样性。这里面最重要的概念就是 preemptinterrupt,以及它们的区别。kernel 中锁的原理和使用场景,几乎都是直接和这两个概念相关的,这也是 kernel 和 user 态下锁的不同之处。

参考资料

  1. Linux 中断、抢占、锁之间的关系
  2. Linux 内核同步机制之 spin lock
  3. Why linux disables kernel preemption after the kernel code holds a spinlock?
  4. Why disabling interrupts disables kernel preemption and how spin lock disables preemption
  5. Why are spin locks good choices in Linux Kernel Design instead of something more common in userland code, such as semaphore or mutex?
  6. Understanding link between CONFIG_SMP, Spinlocks and CONFIG_PREEMPT in Linux kernel

你可能感兴趣的:(操作系统多线程中断内核锁)