Linux 2.6 schedule() 切换进程时没有释放rq->lock却又为何不会导致死锁?

时间:2020-12-05 14:34:58

Linux 的 schedule()函数主要完成现场切换任务。下面的主要是与文章所讨论问题相关的代码段

// Linux 2.6.11.12
// file: sched.c
void schedule(void) {

    // ...

    spin_lock_irq(&rq->lock);

    // ...

    prepare_arch_switch(rq, next);
    context_switch(rq, prev, next);
    finish_task_switch(prev);

    // ...
}

这里锁住rq->lock后,并没有将其打开,而是在prev task下一次被调度后,才执行finish_task_switch(prev)解锁rq->lock

那么,现在假定有三个进程A,B 和 C。A首先执行 schedule(),A将rq->lock锁住后,调用context_switch()切换至B。再然后,B开始调用schedule(),B也想获取rq->lock,然而,如果照此逻辑,rq->lock已经被A锁住了,所以,死锁也就发生了。

但是,其实我们都知道,按照上面例子推断,那内核开始运行后,马上就会出现死锁,这似乎与直觉相违。于是,自然而然的想法便是,在调用switch_to()切换至B进程前,rq->lock已经被释放了。如果你同意上面A、B、C的例子,你也应该会同意我这么一个推断的,对吧?


于是,接下来我把焦点集中到了那个prepare_arch_switch()

/*
 * file: sched.c
 * Default context-switch locking:
 */
#ifndef prepare_arch_switch
# define prepare_arch_switch(rq, next) do { } while (0)
# define finish_arch_switch(rq, next) spin_unlock_irq(&(rq)->lock)
#endif


// file: include/asm-ia64/system.h
#define prepare_arch_switch(rq, next) \
do {                                        \
    spin_lock(&(next)->switch_lock); \ spin_unlock(&(rq)->lock); \ } while (0) #define finish_arch_switch(rq, prev) spin_unlock_irq(&(prev)->switch_lock)

事情貌似有了一点点转机,ia64里,prepare_arch_switch()确实是把rq->lock释放了,同时,锁上了next->switch_lock。振奋人心,我们似乎应该庆祝一下,我们的猜测是对的。

注: finish_task_switch(prev)使用finish_arch_switch()宏进行解锁

噢,等等,我们的老朋友i386并没有特别定义prepare_arch_switch(),他使用的是默认的实现。也就是说,在切换进程时,他没有释放rq->lock。好吧,天一下子塌了下来,我们最后的救命稻草就这样消失了。


无助的我,只能上*求助大神了。抱着满肚子的疑惑吃过了午饭,满怀期待地再次打开浏览器,结果,还是没有人回答……

好吧,我承认,我快疯了。无助的我,再次打开编辑器去看另一个版本的代码,希望能够搜寻到在switch_to()前释放rq->lock的一些蛛丝马迹。

现在,我可以确切告诉你,我没有找到在switch_to()前释放rq->lock的代码,而且,至少在Linux 2.6,应该是永远也不会找到的。rq->lock的的确确没有被释放就切换到另一个进程了!!

下面是 Linux 2.6.32.68 里 context_switch()中的一段注释

/*
 * Since the runqueue lock will be released by the next
 * task (which is an invalid locking op but in the case
 * of the scheduler it's an obvious special-case), so we
 * do an early lockdep release here:
 */

不知道你看到这段注释的时候是不是和我一样的激动,但这就是我们苦苦搜寻的答案了。虽然有悖常识,但他的确运行良好。下面分三种情况,以默认实现为例,我们仔细看看,这里面,内核程序员到底施了什么魔法。


1. B 是一个新进程

这里”新进程”的意思是,他刚刚被fork出来,他还没有运行什么代码。对于这个新进程,__switch_to()将返回到ret_from_fork()ret_from_fork()里最终将调用finish_task_switch()

如果你没有忘记,在最上面给出的schedule()代码里,在prev taskswitch_to()返回后(再次被调度),他将调用finish_task_switch()释放对应的锁。

现在,rq->lock被打开了。


2. B 是一个旧进程

同样的,这里的“旧”,指的是B曾经运行过。此时,B 将从 switch_to()函数返回。接着,他会接着运行schedule()剩下的代码。其中,就包括那finish_task_switch()rq->lock已经由prev task锁住了,所以,我们可以安全地对它进行解锁操作。

好了,让我们释放rq->lock吧!

注:A 再次运行时,与情况 2 相同



现在,虽然最初的问题已经解决了,但我觉得,还有另一个问题需要考虑:为什么我们要费尽心思,去写这么精巧且违反直觉的代码呢?为什么我们不能在切换至第二个进程前,直接释放锁呢?(好吧,看起来像是两问题)

答案是,不能。

  1. 不知道你有没有注意到,上面锁住rq->lock的同时,也禁止了本地中断。如果我们释放锁,就意味着我们可能会中断。而在进程B真正运行前,内核中进程的数据结构都将处于不一致的状态。此时,如果被中断,后果估计不会是我们所愿意看到的。

  2. 而在ia64里,虽然我们确确实实解开了rq->lock,但在解锁前,我们也锁住了next->switch_lock。由此我们也可以判断,在第1个理由里我所宣称的防止内核数据结构被破坏,具体的,就是next的task descriptor。至于这里使用next->switch_lock而不是rq->lock的原因,源码中其实已有说明,由于与这里我们要讨论的问题无关,便不再赘述。