操作系统小记:进程生命周期¶
老实验框架的问题¶
在老版本的 OS 实验中,task 实现为预制菜:在 task_init()
中预制好,然后通过时钟中断调度。本学期我们希望实现 task 的生命周期管理,即动态创建和销毁,从而能够模拟理论课上的场景以计算优先级调度的响应比。
借鉴 Linux 内核的实现,task 将在结束时进入 do_task_dead()
,将进程置为 TASK_DEAD
状态后调用 __schedule()
主动发起调度。
在老版本实验框架下,进程的上下文切换 __switch_to()
总是在中断上下文中进行的,因为只有时钟中断会触发调度。于是我们将进程的初始上下文设置为从中断返回,使进程上下文切换的所有情况一致。但 do_task_dead()
等情况引入了非中断上下文的进程上下文切换,让我们需要重新思考这一步骤。
考虑切换前后 Task 的状态,可以分类讨论如下:
prev Trap 中 |
next 首次调度 |
next Trap 中 |
处理 |
---|---|---|---|
是 | 是 | 不可能 | 由 __dummy 加载起始地址到 sepc 并 sret |
是 | 否 | 是 | 原路返回,由 _traps 退出时 sret 返回到中断发生处继续执行 |
是 | 否 | 否 | 无法处理:Trap 设置的 SPP、SPIE 未被清除,这意味着没有离开 Trap 就切换了上下文,造成混乱 |
否 | 是 | 不可能 | 无法处理:此时 SPIE 为 0,sret 后中断被禁用,造成混乱 |
否 | 否 | 是 | 无法处理:此时 SPP = 0,sret 将可能把内核进程带入进入用户态,造成混乱 |
否 | 否 | 否 | 原路返回,由 __switch_to() 返回到 schedule() 处继续执行 |
笔者思考了两种解决方案:
- 全部使用中断:令
schedule()
触发中断(可以是软件中断或 ecall),然后在中断处理中调用__schedule()
,这样所有调度都在中断上下文中进行 - 保存中断上下文信息:把
sstatus
保存到thread_struct
中,并在__switch_to()
中恢复。
这两种方法都能解决问题。让我们来看看 Linux 是怎么实现的。
进程创建¶
让我们从 Linux 启动过程,第一个用户态和内核态线程的创建开始。
-
_start()
Note
OpenSBI 进入内核时,
sstatus
为0x200000000
,也就是仅设置了UXL
为 2(64 位用户态),其他位均为 0。我们知道,在这种情况下sret
将因为SPP=0
而进入用户态。 -
_start_kernel()
:将预制的init_task
加载到tp
,令自己成为第一个 task(PID 0) -
start_kernel()
:各项资源初始化 -
rest_init()
:创建第一个用户态线程kernel_init
(PID 1)和内核态线程kthreadd
(PID 2)
flowchart TD
n1["user_mode_thread"]
n2["kernel_thread"]
n3["kernel_clone"]
n2 --- n3
n1 --- n3
n3 --- n4["copy_process"]
n4 --- n5["copy_thread"]
进程初始状态¶
copy_thread()
设置进程上下文的初始状态:
struct pt_regs *childregs = task_pt_regs(p);
/* p->thread holds context to be restored by __switch_to() */
if (unlikely(args->fn)) {
/* Kernel thread */
memset(childregs, 0, sizeof(struct pt_regs));
/* Supervisor/Machine, irqs on: */
childregs->status = SR_PP | SR_PIE;
p->thread.s[0] = (unsigned long)args->fn;
p->thread.s[1] = (unsigned long)args->fn_arg;
p->thread.ra = (unsigned long)ret_from_fork_kernel_asm;
} else {
*childregs = *(current_pt_regs());
/* Turn off status.VS */
riscv_v_vstate_off(childregs);
if (usp) /* User fork */
childregs->sp = usp;
if (clone_flags & CLONE_SETTLS)
childregs->tp = tls;
childregs->a0 = 0; /* Return value of fork() */
p->thread.ra = (unsigned long)ret_from_fork_user_asm;
}
-
task_pt_regs()
为我们揭示了 Linux 中内核栈的内存布局:arch/riscv/include/asm/processor.h#define task_pt_regs(tsk) \ ((struct pt_regs *)(task_stack_page(tsk) + THREAD_SIZE \ - ALIGN(sizeof(struct pt_regs), STACK_ALIGN)))
内存地址 (高地址 ↑) +-----------------------------+ ← task_stack_page(tsk) + THREAD_SIZE (栈页面结束) | pt_regs | ← task_pt_regs(tsk) 指向此处 | (寄存器快照结构体) | +-----------------------------+ ← 栈顶部 (sp 附近) | | | 栈数据 (局部变量、 | | 函数调用等,向下增长) | | | +-----------------------------+ ← task_stack_page(tsk) (栈页面起始,低地址) 内存地址 (低地址 ↓) task_struct (位于其他内存区域): +-----------------------------+ | task_struct 字段 | | (如 pid, state, stack 等) | | stack 字段 → 指向栈页面 | +-----------------------------+
-
kernel_thread()
和user_mode_thread()
都会将传入的函数作为args->fn
,因此都会走内核线程的分支,我们稍后会看到kernel_init()
如何将自己移动到用户态Note
- 注意,内核线程设置了
SR_PP | SR_PIE
,这意味着sret
将返回内核态且开启中断
- 那么其他情况下创建用户态进程一定是系统调用,位于 Trap 中,所以使用
current_pt_regs()
获取 Trap 时的寄存器快照
- 注意,内核线程设置了
进程首次调度¶
SYM_CODE_START(ret_from_fork_kernel_asm)
call schedule_tail
move a0, s1 /* fn_arg */
move a1, s0 /* fn */
move a2, sp /* pt_regs */
call ret_from_fork_kernel
j ret_from_exception
SYM_CODE_END(ret_from_fork_kernel_asm)
SYM_CODE_START(ret_from_fork_user_asm)
call schedule_tail
move a0, sp /* pt_regs */
call ret_from_fork_user
j ret_from_exception
SYM_CODE_END(ret_from_fork_user_asm)
schedule_tail()
本质上是在给前一个进程的上下文切换做清理工作,它会调用finish_task_switch(prev)
。-
内核线程会先完成内核线程的工作,然后再返回用户态。
arch/riscv/kernel/process.casmlinkage void ret_from_fork_kernel(void *fn_arg, int (*fn)(void *), struct pt_regs *regs) { fn(fn_arg); syscall_exit_to_user_mode(regs); } asmlinkage void ret_from_fork_user(struct pt_regs *regs) { syscall_exit_to_user_mode(regs); }
syscall_exit_to_user_mode()
和它们的名字并不一样,它们不会执行sret
等操作,只是为系统调用返回做一些准备。 -
ret_from_exception
将从pt_regs
恢复中断上下文并最终sret
。
移动到用户态¶
kernel_init()
在内核态继续做一些工作,最终通过下面的步骤进入用户态:
run_init_process()
kernel_execve()
bprm_execve()
sched_exec()
:将fn
放入某个 CPU 的 RunQueuestop_one_cpu()
-
exec_binprm()
从这里开始的内容与
exec
系统调用相同,可以看这篇文章:How does the Linux Kernel start a Processsearch_binary_handler()
fmt->load_binary()
:对于 ELF 格式,这就是load_elf_binary()
,它将解析 ELF 文件并负责进程加载,我们将在 Lab4 实现它START_THREAD()
:设置用户态进程的初始上下文
#ifndef START_THREAD
#define START_THREAD(elf_ex, regs, elf_entry, start_stack) \
start_thread(regs, elf_entry, start_stack)
#endif
void start_thread(struct pt_regs *regs, unsigned long pc,
unsigned long sp)
{
regs->status = SR_PIE;
if (has_fpu()) {
//...
}
regs->epc = pc;
regs->sp = sp;
#ifdef CONFIG_64BIT
//...
#endif
}
于是我们理解了关键的一点:用户态进程的初始状态
sstatus
- 仅
SPIE
置 1,使得返回时用户态进程启用中断 - 其余位置 0,也包括
SPP
,这意味着sret
将返回用户态
- 仅
sepc
设置为 ELF 文件的入口地址sp
设置为用户栈顶
从首次调度返回¶
上面介绍的所有过程都没有涉及 sret
离开 copy_thread()
设置好的初始上下文。实际上的返回将发生在 ret_from_exception()
中,恢复保存在 pt_regs
中的寄存器状态并执行 sret
。
- 对于 PID 2
kthreadd
来说,这永远不会发生,因为它运行着无限循环,无任务时主动调用schedule()
,这是不在中断上下文中主动触发的调度 - 对于 PID 1
kernel_init
来说,start_thread
完成pt_regs
的设置后将沿上述的路径逐层返回,一直返回到ret_from_fork_kernel()
,然后进入用户态
此后,用户态的程序一定是从系统调用或中断进入内核态的,因此 sret
一定是从 _traps
返回的。但是内核态的线程呢?
中断上下文与调度分离¶
读完上面的内容,我们能够确认 Linux 内核中会发生内核态线程和用户态线程的切换,并且这些切换并不总是发生在中断上下文中。
这会引发一个问题,中断上下文与非中断上下文的程序如何混合调度?__switch_to()
并不切换 sstatus
,也仅仅使用 ret
返回 ra
。
调度器入口¶
flowchart TD
n5["用户态进程"] --> n11["系统调用"]
n11 --> n7["__do_sys_exit_group"]
n11 --> n6["__do_sys_exit"]
n7 --> n8["do_sys_exit_group"]
n8 --> n4["do_exit"]
n6 --> n4
n4 --> n9["do_task_dead"]
n9 --> n10["__schedule()"]
n1["kthread"]
n2["内核线程"] --> n1
n1 --> n3["kthread_exit"]
n3 --> n4
style n11 fill:#FF3131
让我们从内核启动开始,设置这些断点:
启动:start_kernel,rest_init
调度:schedule
切换:__switch_to,context_switch
进程:kernel_clone
Init_task:进入 start_kernel 时会发现 tp 已经被设置好,它在 init/init_task.c 中定义,并在 head.S 中设置为 tp
Linux 的 task_struct 关联在好几个链表中:tasks、children、sibling、
第一个用户态线程:kernel_init,它会等待 kthreadd_done,然后去 run_init_process(调用 kernel_execve)
第一次发生 context_switch 发生在第一个内核态 kthreadd 的创建过程中,内存分配进行了条件调度
Ø Code that is known to be prone to long loops will, on occasion, call cond_resched()
to give the scheduler a chance to run a higher-priority process.
通过 preempt_schedule_common 进入 __schedule
这里 context_switch 会尝试从 PID0 即 init_task 进入 PID1,此时其 comm 为 swapper
这次 switch 成功了,进入了 kernel_init,而它 wait_for_completion 又再次从 schedule_timeout() 进入 schedule(),并切换回 init_task。检查其 thread.ra 发现是 __schedule() 中__switch_to 后返回的地方,可以看出是从 __schedule() 原路返回的
这里会发现返回路径上有一个 finish_task_switch(),它与上次切换时的 prepare_task_switch() 匹配。这里负责检查是否移动了 CPU 等情况。
来到 start_kernel 的最后,这里 schedule_preempt_disabled 进入 schedule,此后自己执行 cpu_startup_entry,它将循环调用 do_idle,成为 idle 线程,核心是 cpu_relax。在 RISC-V 架构下实现为:arch/riscv/include/asm/vdso/processor.h。
最后这一次调度进入 kthreadd(2),它很快进入了 trap handler。通过 sepc 回溯发现调用链如下:
Head.S _start ->_start_kernel :禁用所有中断,为 init_task 设置 tp 并 setup_vm、relocate_enable_mmu 最后 start_kernel
如果你 watch sepc,将获得龟速执行
首次调度:
更多关于调度和上下文的资料:
[The long road to lazy preemption LWN.net]