评论

收藏

[Linux] 详解Linux系统中的内核抢占机制

服务系统 服务系统 发布于:2021-11-27 10:42 | 阅读数:500 | 评论:0

这篇文章主要介绍了Linux系统中的内核抢占机制,包括内核抢占的时机和如何避免内核抢占等方面的问题,需要的朋友可以参考下
1、内核抢占概述
2.6新的可抢占式内核是指内核抢占,即当进程位于内核空间时,有一个更高优先级的任务出现时,如果当前内核允许抢占,则可以将当前任务挂起,执行优先级更高的进程。
在2.5.4版本之前,Linux内核是不可抢占的,高优先级的进程不能中止正在内核中运行的低优先级的进程而抢占CPU运行。进程一旦处于核心态(例如用户进程执行系统调用),则除非进程自愿放弃CPU,否则该进程将一直运行下去,直至完成或退出内核。与此相反,一个可抢占的Linux内核可以让Linux内核如同用户空间一样允许被抢占。当一个高优先级的进程到达时,不管当前进程处于用户态还是核心态,如果当前允许抢占,可抢占内核的Linux都会调度高优先级的进程运行。
2、用户抢占
内核即将返回用户空间的时候,如果need resched标志被设置,会导致schedule()被调用,此时就会发生用户抢占。在内核返回用户空间的时候,它知道自己是安全的。所以,内核无论是在从中断处理程序还是在系统调用后返回,都会检查need resched标志。如果它被设置了,那么,内核会选择一个其他(更合适的)进程投入运行。
简而言之,用户抢占在以下情况时产生:
从系统调返回用户空间。
从中断处理程序返回用户空间。
3、不可抢占内核的特点
在不支持内核抢占的内核中,内核代码可以一直执行,到它完成为止。也就是说,调度程序没有办法在一个内核级的任务正在执行的时候重新调度—内核中的各任务是协作方式调度的,不具备抢占性。当然,运行于内核态 的进程可以主动放弃CPU,比如,在系统调用服务例程中,由于内核代码由于等待资源而放弃CPU,这种情况叫做计划性进程切换(planned process switch)。内核代码一直要执行到完成(返回用户空间)或明显的阻塞为止,
在单CPU情况下,这样的设定大大简化了内核的同步和保护机制。可以分两步对此加以分析:
首先,不考虑进程在内核中自愿放弃CPU的情况(也即在内核中不发生进程的切换)。一个进程一旦进入内核就将一直运行下去,直到完成或退出内核。在其没有完成或退出内核之前,不会有另外一个进程进入内核,即进程在内核中的执行是串行的,不可能有多个进程同时在内核中运行,这样内核代码设计时就不用考虑多个进程同时执行所带来的并发问题。Linux的内核开发人员就不用考虑复杂的进程并发执行互斥访问临界资源的问题。当进程在访问、修改内核的数据结构时就不需要加锁来防止多个进程同时进入临界区。这时只需再考虑一下中断的情况,若有中断处理例程也有可能访问进程正在访问的数据结构,那么进程只要在进入临界区前先进行关中断操作,退出临界区时进行开中断操作就可以了。
再考虑一下进程自愿放弃CPU的情况。因为对CPU的放弃是自愿的、主动的,也就意味着进程在内核中的切换是预先知道的,不会出现在不知道的情况下发生进程的切换。这样就只需在发生进程切换的地方考虑一下多个进程同时执行所可能带来的并发问题,而不必在整个内核范围内都要考虑进程并发执行问题。
4、为什么需要内核抢占?
实现内核的可抢占对Linux具有重要意义。首先,这是将Linux应用于实时系统所必需的。实时系统对响应时间有严格的限定,当一个实时进程被实时设备的硬件中断唤醒后,它应在限定的时间内被调度执行。而Linux不能满足这一要求,因为Linux的内核是不可抢占的,不能确定系统在内核中的停留时间。事实上当内核执行长的系统调用时,实时进程要等到内核中运行的进程退出内核才能被调度,由此产生的响应延迟,在如今的硬件条件下,会长达100ms级。
这对于那些要求高实时响应的系统是不能接受的。而可抢占的内核不仅对Linux的实时应用至关重要,而且能解决Linux对多媒体(video, audio)等要求低延迟的应用支持不够好的缺陷。
由于可抢占内核的重要性,在Linux2.5.4版本发布时,可抢占被并入内核,同SMP一样作为内核的一项标准可选配置。
5、什么情况不允许内核抢占
有几种情况Linux内核不应该被抢占,除此之外Linux内核在任意一点都可被抢占。这几种情况是:
内核正进行中断处理。在Linux内核中进程不能抢占中断(中断只能被其他中断中止、抢占,进程不能中止、抢占中断),在中断例程中不允许进行进程调度。进程调度函数schedule()会对此作出判断,如果是在中断中调用,会打印出错信息。
内核正在进行中断上下文的Bottom Half(中断的底半部)处理。硬件中断返回前会执行软中断,此时仍然处于中断上下文中。
内核的代码段正持有spinlock自旋锁、writelock/readlock读写锁等锁,处干这些锁的保护状态中。内核中的这些锁是为了在SMP系统中短时间内保证不同CPU上运行的进程并发执行的正确性。当持有这些锁时,内核不应该被抢占,否则由于抢占将导致其他CPU长期不能获得锁而死等。
内核正在执行调度程序Scheduler。抢占的原因就是为了进行新的调度,没有理由将调度程序抢占掉再运行调度程序。
内核正在对每个CPU“私有”的数据结构操作(Per-CPU date structures)。在SMP中,对于per-CPU数据结构未用spinlocks保护,因为这些数据结构隐含地被保护了(不同的CPU有不一样的per-CPU数据,其他CPU上运行的进程不会用到另一个CPU的per-CPU数据)。但是如果允许抢占,但一个进程被抢占后重新调度,有可能调度到其他的CPU上去,这时定义的Per-CPU变量就会有问题,这时应禁抢占。
为保证Linux内核在以上情况下不会被抢占,抢占式内核使用了一个变量preempt_ count,称为内核抢占锁。这一变量被设置在进程的PCB结构task_struct中。每当内核要进入以上几种状态时,变量preempt_ count就加1,指示内核不允许抢占。每当内核从以上几种状态退出时,变量preempt_ count就减1,同时进行可抢占的判断与调度。
从中断返回内核空间的时候,内核会检查need_resched和preempt_count的值。如果need_ resched被设置,并且preempt count为0的话,这说明可能有一个更为重要的任务需要执行并且可以安全地抢占,此时,调度程序就会被调用。如果preempt-count不为0,则说明内核现在处干不可抢占状态,不能进行重新调度。这时,就会像通常那样直接从中断返回当前执行进程。如果当前进程持有的所有的锁都被释放了,那么preempt_ count就会重新为0。此时,释放锁的代码会检查need_ resched是否被设置。如果是的话,就会调用调度程序。
6、内核抢占时机
在2.6版的内核中,内核引入了抢占能力;现在,只要重新调度是安全的,那么内核就可以在任何时间抢占正在执行的任务。
那么,什么时候重新调度才是安全的呢?只要premptcount为0,内核就可以进行抢占。通常锁和中断是非抢占区域的标志。由于内核是支持SMP的,所以,如果没有持有锁,那么正在执行的代码就是可重新导人的,也就是可以抢占的。
如果内核中的进程被阻塞了,或它显式地调用了schedule(),内核抢占也会显式地发生。这种形式的内核抢占从来都是受支持的(实际上是主动让出CPU),因为根本无需额外的逻辑来保证内核可以安全地被抢占。如果代码显式的调用了schedule(),那么它应该清楚自己是可以安全地被抢占的。
内核抢占可能发生在:
当从中断处理程序正在执行,且返回内核空间之前。
当内核代码再一次具有可抢占性的时候,如解锁及使能软中断等。
如果内核中的任务显式的调用schedule()
如果内核中的任务阻塞(这同样也会导致调用schedule())
7、如何支持抢占内核
抢占式Linux内核的修改主要有两点:一是对中断的入口代码和返回代码进行修改。在中断的入口内核抢占锁preempt_count加1,以禁止内核抢占;在中断的返回处,内核抢占锁preempt_count减1,使内核有可能被抢占。
我们说可抢占Linux内核在内核的任一点可被抢占,主要就是因为在任意一点中断都有可能发生,每当中断发生,Linux可抢占内核在处理完中断返回时都会进行内核的可抢占判断。若内核当前所处状态允许被抢占,内核都会重新进行调度选取高优先级的进程运行。这一点是与非可抢占的内核不一样的。在非可抢占的Linux内核中,从硬件中断返回时,只有当前被中断进程是用户态进程时才会重新调度,若当前被中断进程是核心态进程,则不进行调度,而是恢复被中断的进程继续运行。
另一基本修改是重新定义了自旋锁、读、写锁,在锁操作时增加了对preempt count变量的操作。在对这些锁进行加锁操作时preemptcount变量加1,以禁止内核抢占;在释放锁时preemptcount变量减1,并在内核的抢占条件满足且需要重新调度时进行抢占调度。
另外一种可抢占内核实现方案是在内核代码段中插入抢占点(preemption point)的方案。在这一方案中,首先要找出内核中产生长延迟的代码段,然后在这一内核代码段的适当位置插入抢占点,使得系统不必等到这段代码执行完就可重新调度。这样对于需要快速响应的事件,系统就可以尽快地将服务进程调度到CPU运行。抢占点实际上是对进程调度函数的调用,代码如下:

代码如下:
if(current->need_resched)schedule();</p> <p>if(current->need_resched)schedule();
通常这样的代码段是一个循环体,插入抢占点的方案就是在这一循环体中不断检测need_ resched的值,在必要的时候调用schedule()令当前进程强行放弃CPU
8、何时需要重新调度
内核必须知道在什么时候调用schedule()。如果仅靠用户程序代码显式地调用schedule(),它们可能就会永远地执行下去。相反,内核提供了一个need_resched标志来表明是否需要重新执行一次调度。当某个进程耗尽它的时间片时,scheduler tick()就会设置这个标志;当一个优先级高的进程进入可执行状态的时候,try_to_wake_up也会设置这个标志。
set_ tsk_need_resched:设置指定进程中的need_ resched标志
clear tsk need_resched:清除指定进程中的need_ resched标志
need_resched():检查need_ resched标志的值;如果被设置就返回真,否则返回假
信号量、等到队列、completion等机制唤醒时都是基于waitqueue的,而waitqueue的唤醒函数为default_wake_function,其调用try_to_wake_up将进程更改为可运行状态并置待调度标志。
在返回用户空间以及从中断返回的时候,内核也会检查need_resched标志。如果已被设置,内核会在继续执行之前调用调度程序。
每个进程都包含一个need_resched标志,这是因为访问进程描述符内的数值要比访问一个全局变量快(因为current宏速度很快并且描述符通常都在高速缓存中)。在2.2以前的内核版本中,该标志曾经是一个全局变量。2.2到2.4版内核中它在task_struct中。而在2.6版中,它被移到thread_info结构体里,用一个特别的标志变量中的一位来表示。可见,内核开发者总是在不断改进。
9、避免内核抢占
进程一旦调用了schedule,如果再次被调度运行,那么有下面几种可能:1.状态为TASK_RUNNING,处于运行队列,那么它肯定有机会再运行;2.处于睡眠队列,那么一旦条件满足被唤醒,那么它就会运行。那么如果一个进程被抢占的话,而且它不在运行队列,那么怎么再让它运行呢?答案是它不能运行了。为了避免这种情况,就必须避免处于非TASK_RUNNING的进程被抢占的进程不被赶出运行队列,也就是下面的代码,schedule的代码:

代码如下:
if (prev->state && !(preempt_count() & PREEMPT_ACTIVE)) {</p> <p>switch_count = &prev->nvcsw;</p> <p>if (unlikely((prev->state & TASK_INTERRUPTIBLE) && unlikely(signal_pending(prev))))</p> <p>prev->state = TASK_RUNNING;</p> <p>else {</p> <p>if (prev->state == TASK_UNINTERRUPTIBLE)</p> <p>rq->nr_uninterruptible++;</p> <p>deactivate_task(prev, rq);</p> <p>}
也许有人会问,怎么会有不是TASK_RUNNING的进程而且被抢占的,这个问题实在难以回答,可是记住,进程状态和其所在的队列没有关系,设置进程状态和抢占总是有可能有间隙的。我们看看下面的代码:

代码如下:
for (;;) { \</p> <p>1: prepare_to_wait(&wq, &__wait, TASK_UNINTERRUPTIBLE); \</p> <p>2: if (condition) \</p> <p>3: break; \</p> <p>4: schedule(); \</p> <p>}
如果在1中被抢占,恰恰在设置完进程为TASK_UNINTERRUPTIBLE的时候被抢占,本来马上就要测试条件是否满足了,结果又被加入睡眠队列去睡眠了,如果没有PREEMPT_ACTIVE,那么在schedule中就会被移出运行队列,如果只有这一次唤醒机会,那么就永远唤不醒这个进程了,如果本次从schedule回来条件不满足,那么在下面的schedue中就会被移出运行队列,这不是抢占的职责,如果非要怎么做就会出错,在dequeue_task中由array->queue已经为空了,在第二次真正出队的时候就会由于空指针引用而出错(这其实不会发生,因为只要从schedue回来,进程的状态肯定是TASK_RUNNING,仅仅是一个例子)。因此必须保证在将进程从运行队列移除的时候,它必须在运行队列,否则移个鸟啊!实际上PREEMPT_ACTIVE的作用就是防止将处于非TASK_RUNNING状态的进程并且没有在任何睡眠队列的进程移出运行队列,总之必须保证进程在一个队列中或者可以被唤醒,被抢占的进程是不能被唤醒的,如果它还不在运行队列中,那么它将永远不能再运行了。那么PREEMPT_ACTIVE是怎么保证被抢占的进程不会被移除运行队列呢?就是在preempt_schedule实现的:

代码如下:
asmlinkage void __sched preempt_schedule(void)</p> <p>{</p> <p>struct thread_info *ti = current_thread_info();</p> <p>if (likely(ti->preempt_count || irqs_disabled()))</p> <p>return;</p> <p>do {</p> <p>add_preempt_count(PREEMPT_ACTIVE); //设置PREEMPT_ACTIVE位,一直到下面的sub_preempt_count(PREEMPT_ACTIVE),这期间不能再抢占这个进程,不过再抢占也没有意义,如果非要抢占,出了下面的sub_preempt_count(PREEMPT_ACTIVE)也不迟</p> <p>schedule();</p> <p>sub_preempt_count(PREEMPT_ACTIVE); //抢占完毕后清除之</p> <p>barrier();</p> <p>} while (unlikely(test_thread_flag(TIF_NEED_RESCHED)));</p> <p>}
除了这里之外,在早一些的内核中从中断返回内核空间时如果要抢占,在entery.S中也会加上这个这个PREEMPT_ACTIVE。现在还有一个问题,就是为何wait_event要用那种实现方式呢?为何需要一个循环呢?我的回答就是:这种情况下进程之所以能被唤醒就是因为它加入了一个睡眠队列,如果如你所说在schedule之后直接判断condition的话是不安全的,因为唤醒不一定是因为条件满足了,万一两个进程同时被唤醒那很可能有一个进程条件不能满足,如果正好此时进程被抢占,那么这个进程就没有机会加入睡眠队列了,也就没有机会被唤醒了,虽然PREEMPT_ACTIVE保证了这个进程不出运行队列,但是却失去了程序的本意,程序的本意是通过唤醒运行队列来使进程运行,而此时却成了完全依据优先级了,即使条件满足因为这个进程不在睡眠队列也不会被唤醒,系统就乱掉了。
其实很简单,必须在将进程加入到睡眠队列以后再判断条件,因为这样可以不漏掉唤醒通知,如果反过来的话,就是先判断再加入睡眠队列,如果在加入之前其它进程唤醒了这个睡眠队列,那么这个进程就会漏掉这次唤醒,之所以会有一个循环是因为可能不止一个进程被唤醒,那么就会出现竞争,这个循环就是为了竞争而设置的,这个循环保证了每个出了这个循环的进程都能安全带着结果为真的条件。
另外,说到TASK_RUNNING这个状态,又有人问了,为何在缺页中要把进程状态设置为TASK_RUNNING,难道缺页前不是TASK_RUNNING吗?大部分情况下应该是,可是linux内核不敢保证,之所以在handle_mm_fault中将进程状态设置为TASK_RUNNING是为了保证在缺页处理中如果睡眠,那么进程可以被唤醒,举个例子,在select中,当进程被设置为非TASK_RUNNING之后还会copy_from_user,而这却可能引起缺页。如果不把进程状态设置为TASK_RUNNING,那么万一在page fault中schedule了,那么这个进程就会被赶出运行队列,就再也回不来了,为了预防之,措施是:在任何调用schedule的地方分辨状态,然后设置进程状态,比如前面说的用PREEMPT_ACTIVE来预防,另外就是像handle_mm_fault中做到的一样,尽量使进程在TASK_RUNNABE状态下进入schedule。不过我想是不是现在这个应该去掉了,即使在缺页中不把进程设置为运行态,如果非要调度,也在之前设为运行台了。
ACTIVE_PREEMPT的作用:防止已经处于非运行态的进程还没有加入睡眠队列的时候就被抢占然后剔除出运行队列。这样就永远也回不来了,虽然这种情况很少见,一般都是先将进程放到睡眠队列再设置状态。

关注下面的标签,发现更多相似文章