前言
进程休眠与唤醒也是内核管理的重要一部分。本是进程调度相关内容,笔者在此单拎出来进行梳理。同样的,主要对比 linux0.12 与 linux2.6 之间的差异。
流程梳理
自然地,让一个进程休眠,我们只需要将其状态更改为TASK_INTERRUPTIBLE 或者TASK_UNINTERRUPTIBLE ,接着再执行调度程序 schedule() 即可。由于调度程序只会调度状态为TASK_RUNNING 的进程,因此被修改的进程不会被调度,看上去就像"休眠"了。而唤醒则更简单,只需要将待唤醒进程状态改为 TASK_RUNNING 即可,等待调度就能够被“唤醒”。
然而,由于休眠可能是为了等待某一类资源的使用或者某一条件的满足,当多个进程同时请求某类资源而都进入休眠状态时,需要使用一个等待队列进行管理。因此,我们可以简单地认为,实现进程的休眠和唤醒需要:
- 设计一个等待队列,用于关联正在休眠的进程
- 修改进程状态并执行调度
Linux0.12
该版本通过sleep_on() 与wake_up() 进行进程的休眠与唤醒。代码中没有显式地使用一个等待队列关联休眠进程,而是通过指针直接串联task_struct结构体来实现。其原理在《Linux内核完全注释》中有详细说明,如下:
当刚进入该函数时,队列头指针*p 指向已经在等待队列中等待的任务结构(进程描述符)。当然,在系统刚开始执行时,等待队列上无等待任务。因此上图中原等待任务在刚开始时不存在,此时*p 指向NULL。通过指针操作,在调用调度程序之前,队列头指针指向了当前任务结构,而函数中的临时指针 tmp 指向了原等待任务。在执行调度程序并在本任务被唤醒重新返回执行之前,当前任务指针被指向新的当前任务,并且 CPU 切换到该新的任务中执行。这样本次 sleep_on()函数的执行使得 tmp 指针指向队列中队列头指针指向的原等待任务,而队列头指针则指向此次新加入的等待任务,即调用本函数的任务。 从而通过堆栈上该临时指针 tmp 的链接作用,在几个进程为等待同一资源而多次调用该函数时,内核程序就隐式地构筑出一个等待队列,参见图 8-7 中的等待队列示意图。图中示出了当向队列头部插入第三个任务时的情况。从图中我们可以更容易理解 sleep_on()函数的等待队列形成过程。
linux-0.12\kernel\sched.c
void sleep_on(struct task_struct **p)
{
__sleep_on(p,TASK_UNINTERRUPTIBLE);
}
static inline void __sleep_on(struct task_struct **p, int state)
{
struct task_struct *tmp;
if (!p)
return;
if (current == &(init_task.task))
panic("task[0] trying to sleep");
tmp = *p;
*p = current;
current->state = state;
repeat: schedule();
if (*p && *p != current) {
(**p).state = 0;
current->state = TASK_UNINTERRUPTIBLE;
goto repeat;
}
if (!*p)
printk("Warning: *P = NULL\n\r");
if (*p = tmp)
tmp->state=0;
}
通过wake_up()唤醒时,只需要修改对应进程状态
void wake_up(struct task_struct **p)
{
if (p && *p) {
if ((**p).state == TASK_STOPPED)
printk("wake_up: TASK_STOPPED");
if ((**p).state == TASK_ZOMBIE)
printk("wake_up: TASK_ZOMBIE");
(**p).state=0;
}
}
如上所示,该版本对于进程休眠与唤醒实现十分简单,也没有考虑条件是否满足等因素。所谓麻雀虽小五脏俱全,在此基础上我们能够理解内核运行的本质也是极好的。
Linux2.6
该版本在实现上,除了显式的设计了等待队列,还进一步完善了整个休眠和唤醒的逻辑。并且由于目前内核处于多处理器且进程可抢占的环境下,需要考虑上锁,条件判断等问题。
在该版本中,休眠的入口变成了wait_event() ,并且是个宏定义。由下述代码可知,休眠当前进程需要执行几个步骤:
- 通过 DEFINE_WAIT 为当前进程构建一个等待队列项 __wait
- 进入 for 循环中,通过 prepare_to_wait() 将 __wait 插入 wq 等待队列中,并将当前进程状态变为TASK_UNINTERRUPTIBLE
- 执行调度前,判断condition是否已经满足,如果满足则无需调度直接结束循环。否则进行调度
- 结束循环后,通过 finish_wait() 从 __wait 中获取当前进程,并将其状态置为 TASK_RUNNING
\include\linux\wait.h
#define wait_event(wq, condition) \
do { \
if (condition) \
break; \
__wait_event(wq, condition); \
} while (0)
#define __wait_event_timeout(wq, condition, ret) \
do { \
DEFINE_WAIT(__wait); \
\
for (;;) { \
prepare_to_wait(&wq, &__wait, TASK_UNINTERRUPTIBLE); \
if (condition) \
break; \
ret = schedule_timeout(ret); \
if (!ret) \
break; \
} \
finish_wait(&wq, &__wait); \
} while (0)
上述过程涉及了内核对于队列的使用,可以发现,Linux内核并不会将进程描述符 task_struct 直接插入链表中,而是为其构建了一个队列项,在这里是等待队列项 wait_queue_t ,通过 list_head 进行链表串联。这么做的好处是,无需为 task_struct 添加多种队列相关的 list_head
struct __wait_queue {
unsigned int flags;
#define WQ_FLAG_EXCLUSIVE 0x01
void *private;
wait_queue_func_t func;
struct list_head task_list;
};
#define DEFINE_WAIT_FUNC(name, function) \
wait_queue_t name = { \
.private = current, \
.func = function, \
.task_list = LIST_HEAD_INIT((name).task_list), \
}
#define DEFINE_WAIT(name) DEFINE_WAIT_FUNC(name, autoremove_wake_function)
具体地,prepare_to_wait() 函数涉及同步操作,利用锁,完成链表的插入。后续 schedule() 的逻辑就不重复了,前篇文章有介绍
void
prepare_to_wait(wait_queue_head_t *q, wait_queue_t *wait, int state)
{
unsigned long flags;
wait->flags &= ~WQ_FLAG_EXCLUSIVE;
spin_lock_irqsave(&q->lock, flags);
if (list_empty(&wait->task_list))
__add_wait_queue(q, wait);
set_current_state(state);
spin_unlock_irqrestore(&q->lock, flags);
}
wake_up() 用于根据队列头,将某个等待队列上所有进程唤醒。实际上就是上锁,修改链表上进程的状态,并将包含进程的队列项从链表队列种删除 kernel\sched.c
#define wake_up(x) __wake_up(x, TASK_NORMAL, 1, NULL)
void __wake_up(wait_queue_head_t *q, unsigned int mode,
int nr_exclusive, void *key)
{
unsigned long flags;
spin_lock_irqsave(&q->lock, flags);
__wake_up_common(q, mode, nr_exclusive, 0, key);
spin_unlock_irqrestore(&q->lock, flags);
}
__wake_up_common() 为主唤醒函数,通过宏定义遍历整个链表,并执行队列项的 func。这里有点像回调的实现。而 func 在上述休眠过程中,构建队列项时设置,即DEFINE_WAIT 将autoremove_wake_function() 作为默认的唤醒方法。
static void __wake_up_common(wait_queue_head_t *q, unsigned int mode,
int nr_exclusive, int wake_flags, void *key)
{
wait_queue_t *curr, *next;
list_for_each_entry_safe(curr, next, &q->task_list, task_list) {
unsigned flags = curr->flags;
if (curr->func(curr, mode, wake_flags, key) &&
(flags & WQ_FLAG_EXCLUSIVE) && !--nr_exclusive)
break;
}
}
autoremove_wake_function() 进一步调用了 default_wake_function() 进行处理,该函数封装了try_to_wake_up()
int autoremove_wake_function(wait_queue_t *wait, unsigned mode, int sync, void *key)
{
int ret = default_wake_function(wait, mode, sync, key);
if (ret)
list_del_init(&wait->task_list);
return ret;
}
try_to_wake_up() 该函数比较长(省略了smp相关代码)。此时的输入已经是从队列项中取出的 task_struct 进程描述符。可以直接对其进行状态的修改。调用 activate_task() 将当前进程放入调度器的就绪队列中。最后将状态改为 TASK_RUNNING
static int try_to_wake_up(struct task_struct *p, unsigned int state,
int wake_flags)
{
int cpu, orig_cpu, this_cpu, success = 0;
unsigned long flags;
struct rq *rq;
if (!sched_feat(SYNC_WAKEUPS))
wake_flags &= ~WF_SYNC;
this_cpu = get_cpu();
smp_wmb();
rq = task_rq_lock(p, &flags);
update_rq_clock(rq);
if (!(p->state & state))
goto out;
if (p->se.on_rq)
goto out_running;
cpu = task_cpu(p);
orig_cpu = cpu;
schedstat_inc(p, se.nr_wakeups);
if (wake_flags & WF_SYNC)
schedstat_inc(p, se.nr_wakeups_sync);
if (orig_cpu != cpu)
schedstat_inc(p, se.nr_wakeups_migrate);
if (cpu == this_cpu)
schedstat_inc(p, se.nr_wakeups_local);
else
schedstat_inc(p, se.nr_wakeups_remote);
activate_task(rq, p, 1);
success = 1;
out_running:
trace_sched_wakeup(rq, p, success);
check_preempt_curr(rq, p, wake_flags);
p->state = TASK_RUNNING;
out:
task_rq_unlock(rq, &flags);
put_cpu();
return success;
}
activate_task() 通过 enqueue_task() 将进程放入就绪队列中,对于CFS调度器而言,就绪队列为红黑树
static void activate_task(struct rq *rq, struct task_struct *p, int wakeup)
{
if (task_contributes_to_load(p))
rq->nr_uninterruptible--;
enqueue_task(rq, p, wakeup, false);
inc_nr_running(rq);
}
梳理到此,突然有问题,即唤醒需要放回就绪队列,休眠时不需要从就绪队列拿出吗?以及condition如何被修改呢?不同进程能够同时访问condition吗?不然休眠进程如何才能被其他进程唤醒呢
思考记录
Q: 进程休眠时需从就绪队列中拿出吗? A: 需要的。之前分析schedule()时没注意,该函数会判断当前进程状态是否非运行态,若是则通过 deactivate_task() 将其从就绪队列中摘出
asmlinkage void __sched schedule(void)
{
struct task_struct *prev, *next;
unsigned long *switch_count;
struct rq *rq;
int cpu;
need_resched:
preempt_disable();
cpu = smp_processor_id();
rq = cpu_rq(cpu);
rcu_sched_qs(cpu);
prev = rq->curr;
if (prev->state && !(preempt_count() & PREEMPT_ACTIVE)) {
if (unlikely(signal_pending_state(prev->state, prev)))
prev->state = TASK_RUNNING;
else
deactivate_task(rq, prev, 1);
switch_count = &prev->nvcsw;
}
}
Q:condition可以被多进程共享吗? A:如果 condition 在内核空间,则能够被进程共享。不管是 Linux0.12 还是 Linux2.6 ,尽管两个对于用户空间与内核空间分布上的设计不相同,但内核地址空间指向的物理地址是相同的。Linux0.12在内核学习系列中以及对此进行了分析,而 Linux2.6 中,目前个人水平较低,只能根据资料书进行判断(但本质应该都是差不多的,无外乎内核页表映射),如:
无论当前哪个用户进程处于活动状态,虚拟地址空间内核部分的内容总是同样的。取决于具体的硬件,这可能是通过操作各用户进程的页表,使得虚拟地址空间的上半部看上去总是相同的。也可能是指示处理器为内核提供一个独立的地址空间,映射在各个用户地址空间之上。 ------《深入Linux内核架构》4.2节内容
|