Linux内核设计与实现总结(3) —— 进程管理与进程调度

进程与线程

Linux内核是不区分线程与进程的,线程只是一种特殊的进程,被视为与其他进程共享地址空间的进程。与windows这种在内核专门提供线程支持的操作系统不一样。

task_struct与thread_info

图片来自biscuitos.github.io

task_struct(进程描述符)描述了一个进程的所有信息,是一个体系无关的结构,位于include/linux/sched.h。它包含进程打开的文件、挂起的信号、进程的状态、进程的pid等。保存在tasks双向循环链表中(见上一节的链表结构体总结),因此可以从一个进程结构体出发,索引到任何其他进程结构体。目前内核中,进程描述符是用slab分配器动态生成的,task_struct有一个void *stack成员变量指向进程的内核栈。

thread_info是体系相关的,位于arch/xxx/include/asm/thread_info.h,它存放了线程上下文,包括是否可以抢占、当前进程是属于哪一种规范的可执行程序等。

早期内核配置中,在X86体系并且CONFIG_THREAD_INFO_IN_TASK没有被时,进程的thread_info存放在进程的内核栈。如果是向下增长的栈,则thread_info在栈顶低地址;向上增长的栈,thread_info还是在低地址,只不过在栈底了。因此,可以通过内核栈的地址快速获得thread_info的地址。

在thread_info中有一个struct task_struct *task变量指向task_struct。后来内核把X86的thread_info结构体中的这个task指针给去掉了(Move thread_info into task_struct),并且将thread_info放到了task_struct中。这个task指针,是为了寄存器在不够多的体系上,可以通过内核栈地址加偏移,找到thread_info,然后快速找到task_struct的。但是X86一直使用的是per_cpu的变量来保存正在使用cpu的进程的进程描述符:

1
DECLARE_PER_CPU(struct task_struct *, current_task);

所以thread_info中的task变量是可有可无的。

在进程调度时,会更新这个变量:

1
this_cpu_write(current_task, next_p);

同样,内核栈的地址也是存放在per_cpu变量的:

1
DECLARE_PER_CPU(unsigned long, cpu_current_top_of_stack);

对于ARM和ARM64这种寄存器多的体系,目前内核也没有在thread_info里面继续保存进程描述符的指针了,但是mips还有。

用户栈到内核栈的切换

比如通过int 0x80软中断或者syscall进行系统调用时,cpu被软中断打断并进入内核态,系统调用的入口entry_SYSCALL_64将用户栈的地址,也就是rsp寄存器的内容存放到per_cpu的变量cpu_tss_rw,然后从per_cpu的cpu_current_top_of_stack拿到进程内核栈的地址,就完成了到内核栈的切换。

1
2
3
4
5
6
7
8
9
SYM_CODE_START(entry_SYSCALL_64)
UNWIND_HINT_ENTRY
ENDBR

swapgs
/* tss.sp2 is scratch space. */
movq %rsp, PER_CPU_VAR(cpu_tss_rw + TSS_sp2)
SWITCH_TO_KERNEL_CR3 scratch_reg=%rsp
movq PER_CPU_VAR(cpu_current_top_of_stack), %rsp

随后,将用户态的寄存器(保存了系统调用的参数)压栈,也就是pt_regs结构体中的成员,然后call对应的系统调用实现函数,实现函数是用C写的,接受pt_regs指针(也就是栈顶)作为参数,这样通过C可以操作汇编压栈的结构了,妙。这里之所以使用汇编编写syscall入口,是因为直接修改了rsp寄存器。

等系统调用结束后,恢复压栈的寄存器并切换到用户态。

进程状态与进程调度

进程状态定义在include/linux/sched.h中:

  1. TASK_RUNNING,进程中用户空间唯一可能的状态。正在运行或者在运行队列中等待运行。
  2. TASK_INTERRUPTIBLE,进程正在睡眠,等待某些条件的达成,可被中断。
  3. TASK_UNINTERRUPTIBLE,进程正在睡眠,但接收到信号不会被唤醒,也不会准备投入运行。
  4. __TASK_TRACED,进程正在被跟踪。
  5. __TASK_STOP,进程停止,接收到SIGSTOP、SIGTSTP、SIGTTIN、SIGTTOU等信号会让进程进入这种状态。

对于Linux这种从一开始就采用了抢占式多任务的操作系统来说,调度程序决定了一个进程什么时候停止,什么时候投入运行,进程在被抢占前运行的时间被称作时间片。调度器的入口在schedule()函数,通常和调度器相关联,调用调度器的pick_next_task(),调度器返回下一个被调度进程的进程描述符。

休眠(被阻塞)状态的进程处于不可执行的状态。进程休眠的原因有多种多样,但通常来说都是等待某一事件的发生,例如等待I/O, 等待设备输入等等。
内核对于休眠和唤醒的操作如下:

  • 休眠:进程首先把自己标记为休眠状态(TASK_INTERRUPTIBLE),然后从可执行红黑树中移除该进程,并将进程放入等待队列
  • 唤醒:进程被置为可执行状态(TASK_RUNNING),进程从等待队列移入可执行红黑树中

休眠或者阻塞状态有两种:可中断休眠(TASK_INTERRUPTIBLE),不可中断休眠(TASK_UNINTERRUPTIBLE)。

通常进程的休眠,为可中断休眠,即进程进入休眠,等待某一事件发生。一旦事件发生,或者满足条件,内核将会把进程状态置为运行,并将进程从等待队列中移除。

上下文切换,处理器从即为从一个可执行的进程切换到另一个可执行的进程,其中包含了两个关键的函数:

  • switch_mm:把虚拟内存从上一个进程映射切换到新进程中
  • switch_to:负责将上一个处理器状态信息切换到新进程的处理器状态。包括保存,恢复栈信息和寄存器信息。

内核必须知道在什么时候需要调用schedule()来执行一次调度,而不是靠用户去执行schedule()函数,为此内核提供了一个need_resched标志位,表明是否需要重新进行一次调度。need_resched标志位为1时会触发内核进行一次调度,有几个情况:

用户态抢占(重新调度):

  1. 从系统调用返回用户空间时,read,write,syscall
  2. 从中断处理程序返回用户空间时,硬件中断,时钟中断(类似于时间片的概念),等等

内核态抢占(重新调度)

  1. 中断处理程序正在执行,且返回内核空间之前
  2. 内核代码再一次具有可抢占性的时候
  3. 内核任务显式的调用schedule()
  4. 内核任务阻塞

fork与vfork

父进程可以使用fork系统调用创建一个子进程,在copy-on-write(写时拷贝)还没出来时,fork系统调用会将父进程的所有资源拷贝到刚创建的子进程,主要是虚拟内存空间里面的大部分内容,数据段、堆栈、代码段,这个拷贝操作非常的耗时。而且,如果子进程随后立即调用exec系统调用,那么前面所做的所有拷贝全部是无用的。于是,vfork则是用来解决这个问题的,vfork出来的子进程不复制父进程的页表,而是直接使用父进程的页表,父进程被阻塞,直到子进程调用_exit()或者调用exec函数簇,调用任何其它函数(包括exit())、修改任何数据(除了保存vfork()返回值的那个变量)、执行任何其它语句(包括return)都是不应该的。vfork作为一个优化,它假设子进程起来后立即会exec,避免了父进程的资源拷贝。

但在fork引入copy-on-write之后,vfork除了不复制页表外没有其他作用了。在fork时,会给子进程复制一份父进程的页表,同时将父子进程的所有内存页标记为只读。当子进程(父进程也行)尝试写入某一个内存页时会触发异常并陷入内核态,内核会为这个页面创建一个副本,同时修改这个页面可写,写操作即可正常执行。同时,内核记录了每个页面的引用,每次复制页面则减少1,当引用为1时,即可直接修改,所以在fork-fork-fork-...这样的场景中,写时拷贝依旧可用。写时拷贝的整个过程是透明的,进程感知不到。而且,内核会让子进程先运行(避免了父进程先修改了页面,导致页面的复制,但是子进程随后却调用了exec,复制的页面无用的问题)。

Linux内核设计与实现总结(3) —— 进程管理与进程调度

https://lyq.blogd.club/2022/08/12/LKD-conclusion-3/

Author

lyq1996

Posted on

2022-08-12

Updated on

2022-09-09

Licensed under

Comments