进程调度与管理:(一)进程的创建与销毁

文摘   2024-10-09 09:51   陕西  

作者介绍/Author

徐晗博,西安邮电大学研二在读,陈莉君教授学生。操作系统和Linux内核爱好者,热衷于探索Linux内核和eBPF技术。

本系列文章将对进程管理与调度进行知识梳理与源码分析,重点放在linux源码分析上,并结合eBPF程序对内核中进程调度机制进行数据实时拿取与分析。

在进行正式介绍之前,有必要对文章引用进行提前说明。本系列文章参考了大量的博客、文章以及书籍:

  • 《深入理解Linux内核》

  • 《Linux操作系统原理与应用》

  • 《奔跑吧Linux内核》

  • 《深入理解Linux进程与内存》

  • 《基于龙芯的Linux内核探索解析》

  • 进程调度 - 标签 - LoyenWang - 博客园 (cnblogs.com)

  • 专栏文章目录 - 知乎 (zhihu.com)

进程调度与管理:(一)进程的创建与销毁

在上一篇文章《进程调度与管理:(零)预备知识》中,我们简单介绍了一下关于进程调度的相关结构体,通过相关图片梳理了task_struct,sched_entity,rq,sched_avg之间的关系,为后面深入源码做了进出准备。本篇文章将继续围绕进程管理“周边”初步认识进程是如何从无到有的,又是如何“结束其辉煌一生”的,主要涉及到以下相关内容:

  • 进程的创建:fork,vfork,clone;
  • 进程的销毁:主动退出,被迫退出;

0.原理铺垫

有了相关结构体的铺垫,有利于我们阅读源码,但仅仅是阅读源码容易陷入源码细节中,所以需要再宏观上对进程的创建有一定的认识,本小节会对进程创建的原理与过程进行简要的介绍,主要涉及到:

  • 写时复制原理;
  • fork、vfork、clone流程与原理;

0.1.写时复制

在创建进程时,会复制父进程的task_struct结构体作为子进程的进程描述符,那么子进程是如何复制父进程的task_struct结构体的?如果复制父进程的全部内容,则会消耗大量的时间并占用大量的CPU及内存,显然是不合理的,写时复制技术可以解决这个问题。

写时复制技术就是父进程在创建子进程时不需要复制进程地址空间的内容到子进程,只需要复制父进程的进程地址空间的页表到子进程,这样父、子进程就共享了相同的物理内存。当父、子进程中有一方需要修改某个物理页面的内容时,触发写保护的缺页异常,然后才复制共享页面的内容,从而让父、子进程拥有各自的副本。也就是说,进程地址空间以只读的方式共享,当需要写入时才发生复制。写时复制是一种可以推迟甚至避免复制数据的技术,它在现代操作系统中有广泛的应用。

0.2.fork、vfork、clone

forkvforkclone均是用于创建进程的函数,他们通过系统调用在内核中实现,在Linux-6.5中,三者均是通过调用内核函数kernel_clone()实现,关于源码分析部分,会在下一章节详细剖析。下面将简略的介绍一下三者的区别:

**fork()**:

  • 子进程仅复制父进程的页目录;

  • 子进程和父进程有各自独立的进程地址空间;

  • 共享物理地址空间;

  • 父进程不会被阻塞,如果fork()返回值是正数,则表示运行在父进程中且返回值是子进程pid;如果返回值是0,则表示运行在子进程中;如果返回值是-1,则表示创建失败;

使用fork()函数来创建子进程时,子进程和父进程有各自独立的进程地址空间,但是共享物理内存资源,当它们开始执行各自程序时,它们的进程地址空间开始分道扬镰,这得益于写时复制技术。

**vfork()**:

  • 子进程不复制父进程的页目录和页表;
  • 父子进程共享进程地址空间;
  • 父进程会进入阻塞态等待子进程结束;

**clone()**:

  • 创建用户态线程;
  • 带有很多参数,可以精确控制复制进程的各种行为(可以用来实现和fork、vfork完全相同的功能,也可创建线程)

1.进程的创建

我们所开发的程序在系统中都是以进程的形式运行的,进程在系统中执行着开发人员所提前设计好的逻辑,并使用CPU、内存等资源。那么进程是如何被创建出来的?如何做到从无到有的?本小节将深入到内核去梳理进程创建的流程,并对Linux-6.5及Linux-5.0两个版本的内核源码进行分析解读。

在用户态程序中,我们通常会使用fork()、vfork()、以及clone()来创建一个新的进程或新的线程,这些接口通过系统调用最终在内核中实现进程/线程创建;

1.1 系统调用进入内核

在源码中我们可以看到fork()、vfork()、clone()最终都是通过系统调用陷入内核,在内核中实现的创建工作,以下分别是Linux-6.5及Linux-5.0两个版本的相关源码:

Linux-5.0: /kernel/fork.c

asmlinkage long sys_clone(unsigned longunsigned longint __user *,
        int __user *, unsigned long)
;
asmlinkage long sys_vfork(void);
asmlinkage long sys_fork(void);

Linux-5.0: /kernel/fork.c

/*fork系统调用入口*/
SYSCALL_DEFINE0(fork)
{
#ifdef CONFIG_MMU
 return _do_fork(SIGCHLD, 00NULLNULL0);
#else
 /* can not support in nommu mode */
 return -EINVAL;
#endif
}
/*vfork系统调用入口*/
SYSCALL_DEFINE0(vfork)
{
 return _do_fork(CLONE_VFORK | CLONE_VM | SIGCHLD, 0,
   0NULLNULL0);
}
/*clone系统调用入口*/
SYSCALL_DEFINE5(clone, unsigned long, clone_flags, unsigned long, newsp,
   int __user *, parent_tidptr,
   int __user *, child_tidptr,
   unsigned long, tls)
#endif
{
 return _do_fork(clone_flags, newsp, 0, parent_tidptr, child_tidptr, tls);
}

Linux-6.5: /include/linux/syscalls.h

asmlinkage long sys_clone(unsigned longunsigned longint __user *,
        int __user *, unsigned long)
;
asmlinkage long sys_vfork(void)
asmlinkage long sys_fork(void);

Linux-6.5: /kernel/fork.c

/*fork系统调用入口*/
SYSCALL_DEFINE0(fork)
{
#ifdef CONFIG_MMU
 struct kernel_clone_args args = {
  .exit_signal = SIGCHLD,
 };
 return kernel_clone(&args);
#else
 /* can not support in nommu mode */
 return -EINVAL;
#endif
}
/*vfork系统调用入口*/
SYSCALL_DEFINE0(vfork)
{
 struct kernel_clone_args args = {
  .flags  = CLONE_VFORK | CLONE_VM,
  .exit_signal = SIGCHLD,
 };
 return kernel_clone(&args);
}
/*clone系统调用入口*/
SYSCALL_DEFINE5(clone, unsigned long, clone_flags, unsigned long, newsp,
   int __user *, parent_tidptr,
   int __user *, child_tidptr,
   unsigned long, tls)
#endif
{
 struct kernel_clone_args args = {
  .flags  = (lower_32_bits(clone_flags) & ~CSIGNAL),
  .pidfd  = parent_tidptr,
  .child_tid = child_tidptr,
  .parent_tid = parent_tidptr,
  .exit_signal = (lower_32_bits(clone_flags) & CSIGNAL),
  .stack  = newsp,
  .tls  = tls,
 };
 return kernel_clone(&args);
}

对比一下我们可以看出,Linux6.5通过kernel_clone函数取代了_do_fork函数,并使用struct kernel_clone_args args结构体将相关参数封装到一个结构体中,最终通过kernel_clone实现具体的进程/线程创建工作。

1.2 kernel_clone()创建进程/线程

经过系统调用陷入内核后,fork()、vfork()、clone()都会通过kernel_clone()函数去执行真正的创建操作。在创建进程时,无论是Linux-5.0中的 _do_fork,还是Linux-6.5中的kernel_clone,均是先通过copy_process()创建一个新的task_struct,再通过wake_up_new_task()将新进程添加到调度队列中等待调度。

以下是两个版本的内核源码分析:

1.2.1 Linux-5.0-do_fork源码分析:

正如上面所说,在linux-5.0中,_do_fork()函数会先通过copy_process()函数复制父进程的task_struct,再通过wake_up_new_task()唤醒新进程并进入调度队列,如果是通过vfork创建的进程,则需要用过wait_for_vfork_done()函数扣留父进程(当前进程),等待子进程执行新程序(或退出)。

_do_fork()源码分析:

/*kernel/fork.c*/
long _do_fork(unsigned long clone_flags,
       unsigned long stack_start,
       unsigned long stack_size,
       int __user *parent_tidptr,
       int __user *child_tidptr,
       unsigned long tls)
{
 /*0.相关变量定义*/
 /*1.检查是否需要ptrace跟踪*/
 /*2.通过copy_process来复制父进程,生成子进程*/
 p = copy_process(clone_flags, stack_start, stack_size,
    child_tidptr, NULL, trace, tls, NUMA_NO_NODE);
 add_latent_entropy();
 /*3.获取子进程的ID*/
 pid = get_task_pid(p, PIDTYPE_PID);
 nr = pid_vnr(pid); 
    /*4.如果是vfork,初始化vfork_done完成量*/
 if (clone_flags & CLONE_VFORK) {
  p->vfork_done = &vfork;//使用vfork_done完成量来扣留父进程
  init_completion(&vfork);
  get_task_struct(p);
 }
    /*5.将子进程放入就绪队列等待被调度*/
    wake_up_new_task(p);
    /*6.如果是vfork,使用wait_for_vfork_done 让父进程等待子进程执行exec()或exit()*/
 if (clone_flags & CLONE_VFORK) {
  if (!wait_for_vfork_done(p, &vfork))
   ptrace_event_pid(PTRACE_EVENT_VFORK_DONE, pid);
 }
    /*7.返回子进程pid*/
 return nr;    
}

1.2.2 Linux-6.5-kernel_clone()源码分析

kernel_clone()沿用了Linux-5.0中的_do_fork()创建子进程的方法,通过copy_process()函数复制父进程的task_struct,再通过wake_up_new_task()唤醒新进程并进入调度队列,如果是通过vfork创建的进程,则需要用过wait_for_vfork_done()函数扣留父进程(当前进程),等待子进程执行新程序(或退出)。但不同的是,kernel_clone()在内存管理、接口设计等方面做了优化:

  • 通过传递一个 struct kernel_clone_args 结构体来传递所有参数,提供了更灵活和扩展性更强的接口;
  • 支持 CLONE_PIDFD,并且有专门的检查逻辑,确保 CLONE_PIDFDCLONE_PARENT_SETTID 互斥使用,以避免两个机制同时指向同一块内存;
  • 引入LRU优化内存管理,增加 CONFIG_LRU_GEN 支持,通过在进程创建时将子进程的内存管理结构体(mm_struct)添加到 LRU 生成系统中。

kernel_clone()源码分析:

/*kernel/fork.c*/
pid_t kernel_clone(struct kernel_clone_args *args)
{
 /*0.相关变量定义*/
 /*1.对CLONE_PIDFD进行错误处理*/
 /*2.检查是否需要ptrace跟踪*/ 
 /*3.通过copy_process来复制父进程,生成子进程*/
 p = copy_process(NULL, trace, NUMA_NO_NODE, args);
 add_latent_entropy();
 /*4.获取新创建进程的ID*/
 pid = get_task_pid(p, PIDTYPE_PID);
 nr = pid_vnr(pid);
 /*5.如果是vfork,则需要扣留父进程,直至子进程执行execve或exit*/
 if (clone_flags & CLONE_VFORK) {
  p->vfork_done = &vfork;//设置vfork_done完成量
  init_completion(&vfork);//初始化完成量
  get_task_struct(p);
 }
 /*6.如果启用了 LRU_GENERATION 并且没有设置 CLONE_VM,进行 LRU 管理操作*/
 if (IS_ENABLED(CONFIG_LRU_GEN) && !(clone_flags & CLONE_VM)) {
  /* lock the task to synchronize with memcg migration */
  task_lock(p);
  lru_gen_add_mm(p->mm);
  task_unlock(p);
 }
 /*7.将新创建的任务加入到就绪队列中*/
 wake_up_new_task(p); 
 /*8.对于vfork,使用wait_for_vfork_done 让父进程等待子进程执行exec()或exit()*/
 if (clone_flags & CLONE_VFORK) {
  if (!wait_for_vfork_done(p, &vfork))
   ptrace_event_pid(PTRACE_EVENT_VFORK_DONE, pid);
 }
 /*9.返回进程pid*/
 return nr;
}

1.3 copy_process 复制task_struct

在上一节中,我们知道了kernel_clone()函数和do_fork()函数都是通过copy_process()为新进程创建并复制task_struct,通过wake_up_new_task()将子进程唤醒并放入就绪队列;

那么copy_process()是如何复制父进程的task_struct的?需要复制task_struct中的哪些内容?wake_up_new_task()又是如何唤醒子进程的?在这一节中,我们将探究源码中copy_process()的具体实现细节,至于wake_up_new_task()的实现方法,我们将在下一节中详细讨论。

为了对copy_process()有一个宏观的认识,将展示copy_process()源码中的主要框架:

copy_process()函数做了以下工作:

  • 首先通过dup_task_struct()函数申请并复制task_struct结构体,这里仅仅是复制父进程task_struct结构体本身,复制后task_struct中的指针依然指向父进程中的内容(如mm_struct等成员只复制了指针,依然指向和父进程相同的对象);
  • 其次通过sched_fork()初试化与进程调度相关的数据结构,如sched_entity、优先级设置、调度策略、调度类等;
  • 通过copy_files共享或复制父进程的files;
  • 通过copy_fs共享或复制父进程的fs字段;
  • 通过copy_mm共享父进程的mmactive_mm字段 或 通过dup_mm复制父进程各地址空间的页表(仅复制页表,内存页面依旧是父子进程共享的,只不过设为了只读权限);
  • 通过copy_namespace共享父进程的命名空间,或通过create_new_namespaces()创建新的命名空间代理;
  • 通过alloc_pid为新进程申请pid;

由于copy_process函数源码较长, 精简后的源码如下:

kernel/fork.c
__latent_entropy struct task_struct *copy_process(
     struct pid *pid,
     int trace,
     int node,
     struct kernel_clone_args *args)

{
 /*1.标志位检查*/
 /*2.创建并复制父进程的task_struct*/
 p = dup_task_struct(current, node);
 if (!p)
  goto fork_out;
 /*3.复制父进程的证书*/
 retval = copy_creds(p, clone_flags);
 if (retval < 0)
  goto bad_fork_free;
 /*4.复制cgroup组信息*/
 cgroup_fork(p);
 /*5.复制调度相关信息*/
 retval = sched_fork(clone_flags, p);
 if (retval)
  goto bad_fork_cleanup_policy;
 /*6.复制信号量取消队列*/
 retval = copy_semundo(clone_flags, p);
 if (retval)
  goto bad_fork_cleanup_security;
 /*7.复制files字段*/
 retval = copy_files(clone_flags, p, args->no_files);
 if (retval)
  goto bad_fork_cleanup_semundo;
 /*8.复制fs字段*/
 retval = copy_fs(clone_flags, p);
 if (retval)
  goto bad_fork_cleanup_files;
    /*9.复制信号量处理函数信息*/
 retval = copy_sighand(clone_flags, p);
 if (retval)
  goto bad_fork_cleanup_fs;
    /*10.复制signal字段*/
 retval = copy_signal(clone_flags, p);
 if (retval)
  goto bad_fork_cleanup_sighand;
    /*11.复制mm字段*/
 retval = copy_mm(clone_flags, p);
 if (retval)
  goto bad_fork_cleanup_signal;
    /*12.复制命名空间*/
 retval = copy_namespaces(clone_flags, p);
 if (retval)
  goto bad_fork_cleanup_mm;
    /*13.复制io上下文*/
 retval = copy_io(clone_flags, p);
 if (retval)
  goto bad_fork_cleanup_namespaces;
    /*14.复制父进程中内核堆信息*/
 retval = copy_thread(p, args);
 if (retval)
  goto bad_fork_cleanup_io;
    /*15.申请pid号*/
 if (pid != &init_struct_pid) {
  pid = alloc_pid(p->nsproxy->pid_ns_for_children, args->set_tid,
    args->set_tid_size);
  if (IS_ERR(pid)) {
   retval = PTR_ERR(pid);
   goto bad_fork_cleanup_thread;
  }
 }
 /*16.其他:设置task_struct中的其他字段,并将新进程加入进程管理的流程里*/
}

将copy_process中的一些主要函数拿出来进行更进一步的源码分析,可以了解copy_process在复制task_struct中不同字段时的一些复制细节,再具体的复制过程中,需要根据clone_flags标志符来区分哪些字段需要复制,哪些需要共享。以下两张图分别表示了通过fork()进行进程创建以及通过clone进行内核线程创建的蓝图:

fork创建进程:

vfork 进程创建:

clone 线程复制:

1.3.1 dup_task_struct()

dup_task_struct()函数主要作用是为新进程分配复制task_struct,并分配内核栈空间。其中对父进程task_struct的复制是除栈stack外的全部复制,复制结果是子进程和父进程除stack外的所有字段均与父进程相同,指针指向的地址也相同;      下面是该函数的重要操作:

  • alloc_task_struct_node()函数为新进程分配一个task_struct
  • allocthread stack node()函数为新进程分配内核栈空间。
  • arch_duptask_struct()函数把父进程的task_struct内容直接复制到新进程的task_struct中。
  • 使新进程task_struct的 stack 字段指向新分配的内核栈。
  • set_task_stack_end_magic()函数在内核栈的最高地址处设置一个幻数STACKENDMAGIC,用于溢出检测。

值得重点关注的是dup_task_struct()通过alloc_task_struct_node()arch_duptask_struct()task_struct申请了空间并根据体系结构复制了父进程的task_struct,通过allocthread stack node()为新进程分配内核栈空间。

static struct task_struct *dup_task_struct(struct task_struct *orig, int node)
{
 /*0.获取分配task_struct的首选NUMA节点*/ 
 if (node == NUMA_NO_NODE)
  node = tsk_fork_get_node(orig);
 /*1.新进程分配一个task_struct*/
 tsk = alloc_task_struct_node(node);
 if (!tsk)
  return NULL;
 /*2.函数把父进程的task_struct内容直接复制到新进程的task_struct中*/
 err = arch_dup_task_struct(tsk, orig);
 if (err)
  goto free_tsk;
 /*3.为新进程分配内核栈空间*/
 err = alloc_thread_stack_node(tsk, node);
 if (err)
  goto free_tsk;
 /*4.内核栈的最高地址处设置一个幻数STACKENDMAGIC,用于溢出检测。*/
 set_task_stack_end_magic(tsk);
}

1.3.2 sched_fork()

sched_fork()函数主要是将task_struct结构体中和调度相关的字段进行初始化与复制;该函数主要通过__sched_fork()函数初始化sched_entity结构体、初始化实时调度rt相关字段等,并初始化其他与调度相关的字段(运行状态,优先级,调度策略,负载权重等)

  • __sched_fork()初始化与进程调度相关的数据结构。调度实体用sched_entity数据结构来抽象,每个进程或线程都是一个调度实体。
  • task_struct 数据结构中的state成员表示进程运行状态。
  • 新进程继承父进程的优先级。
  • 设置子进程的调度类。
  • set_task_cpu()函数为子进程设置CPU,子进程将来会运行在这个CPU上。
  • 初始化 thread info 数据结构中的 preempt count计数,它是为了支持内核抢占而引入的。

1.3.3 copy_files()

copy_files()函数主要是对新进程task_struct中的files字段(打开的文件描述符信息)进行初始化,该函数的clone_flags如果设置了CLONE_FILES,父进程和子进程共享父进程的files字段,如果未设置该标识,则需要通过dup_fd()复制父进程的files;

/*kernel/fork.c*/
static int copy_files(unsigned long clone_flags, struct task_struct *tsk,
        int no_files)

{
 /*1.如果设置了CLONE_FILES,则增加源文件描述符的引用次数,并结束复制*/
 if (clone_flags & CLONE_FILES) {
  atomic_inc(&oldf->count);
  goto out;
 }
 /*2.通过dup_fd复制files*/
 newf = dup_fd(oldf, NR_OPEN_MAX, &error);
 if (!newf)
  goto out;
 tsk->files = newf;
}

1.3.4 copy_fs()

copy_fs()函数主要是对新进程task_struct中的fs字段(包含根目录、当前目录等文件系统上下文信息)进行初始化,该函数的clone_flags如果设置了CLONE_FS,父进程和子进程共享父进程的fs字段,如果未设置该标识,则需要通过copy_fs_struct申请并对fs_struct赋值;

/*kernel/fork.c*/
static int copy_fs(unsigned long clone_flags, struct task_struct *tsk)
{
 struct fs_struct *fs = current->fs;
 /*1.如果设置了CLONE_FS,则增加fs的引用次数,并结束复制*/ 
 if (clone_flags & CLONE_FS) {
  fs->users++;
  return 0;
 }
 /*2.通过copy_fs_struct复制fs*/
 tsk->fs = copy_fs_struct(fs);
 return 0;
}

1.3.5 copy_mm()

copy_mm()主要工作是把父进程中mm字段进行共享或复制,主要操作如下:

  • 如果父进程的内存描述符mm为空,说明父进程是一个没有进程地址空间的内核线程,不需要为子进程做内存复制,可以直接退出。
  • 若调用 vfork()创建子进程,那么CLONE_VM 标志位会置位,因此直接将子进程的 mm指针指向父进程的内存描述符 mm 即可,这是最简单和最高效的做法,仅仅是一个指针操作。
  • 若CLONE_VM标志位没置位,那么调用 dup_mm()来复制父进程的进程地址空间。

值得一提的是,copy_mm()的实现涉及到了写时复制技术(0.原理铺垫时有介绍),该技术帮助子进程高效的复制父进程地址空间的内容。在执行 fork()期间,子进程仅仅复制父进程的进程地址空间对应的页表到子进程中,并且把父进程和子进程对应的页表项属性设置为只读,也就是子进程的这些映射区域的页表项指向父进程对应的物理页面,并将这些页面映射属性设置为只读。在fork()完成之后,当父进程或者子进程需要对这些内存页面进行修改时,内核会捕获到缺页异常。在缺页异常处理中对相应页表做适当调整。从这一刻之后,父、子进程就可以分别对各自的页表进行操作和修改,互不干扰。

/*kernel/fork.c*/
static int copy_mm(unsigned long clone_flags, struct task_struct *tsk)
{
 struct mm_struct *mm, *oldmm;
 /*1.初始化新进程的页面故障计数器*/
 tsk->min_flt = tsk->maj_flt = 0;
 /*2.初始化新进程的上下文切换计数器*/
 tsk->nvcsw = tsk->nivcsw = 0;
 /*3.初始化新进程的mm和active_mm*/
 tsk->mm = NULL;
 tsk->active_mm = NULL;
 /*4.判断父进程是否是一个内核线程,若是内核线程则直接返回*/
 oldmm = current->mm;
 if (!oldmm)
  return 0;
 /*5.如果clone_flags设置了CLONE_VM,子进程和父进程共享虚拟地址空间
  * 否则通过dup_mm复制父进程的页表
  */

 if (clone_flags & CLONE_VM) {
  mmget(oldmm);
  mm = oldmm;
 } else {
  mm = dup_mm(tsk, current->mm);
  if (!mm)
   return -ENOMEM;
 }

 tsk->mm = mm;
 tsk->active_mm = mm;
 sched_mm_cid_fork(tsk);
 return 0;
}
1.3.5.1 dup_mm

从上面源码中可以看出,实现复制父进程mm的核心函数是dup_mm()。dup_mm为子进程申请分配一个内存描述符mm,并将父进程mm结构体中的内容全部复制到子进程中(这里仅复制结构体中的内容,不复制内存中的内容),最终再将父进程的页表逐一复制。dup_mm核心功能的源码分析如下:

/*kernel/fork.c*/
static struct mm_struct *dup_mm(struct task_struct *tsk,
    struct mm_struct *oldmm)

{
 struct mm_struct *mm;
 int err;
 /*1.为子进程申请mm并分配空间*/
 mm = allocate_mm();
 if (!mm)
  goto fail_nomem;
 /*2.将父进程mm中的内容全部复制到子进程中,仅复制结构体中的内容,不复制内存*/
 memcpy(mm, oldmm, sizeof(*mm));
 /*3.初始化子进程内存描述符,并为子进程分配PGD*/
 if (!mm_init(mm, tsk, mm->user_ns))
  goto fail_nomem;
 /*3.复制父进程地址空间的页表到子进程中*/
 err = dup_mmap(mm, oldmm);
 if (err)
  goto free_pt;
 /*省略代码若干*/
}

主要操作:

  • allocate mm()函数为子进程分配一个内存描述符mm。
  • memcpy()把父进程的内存描述符的内容全部复制到子进程。注意,这里仅仅复制数据结构的内容,而不是复制内存。
  • mm_init()函数初始化子进程的内存描述符,并且调用mm_alloc_pgd()函数来为子进程分配 PGD。
  • dup_mmap()复制父进程的进程地址空间的页表到子进程。该函数的主要作用是遍历父进程中所有 VMA,然后复制父进程 VMA中对应的 PTE 到子进程相应 VMA 对应的 PTE中。注意,只复制PTE,并没有复制 VMA 对应页面的内容。

不难看出,dup_mm中具体实现页表复制的是dup_mmap函数,该函数遍历父进程的vma并将vma的页表复制到子进程的页表中,这里的页表复制工作是是通过copy_page_range()函数实现的,具体实现过程不做深究,只用知道该函数会沿着页表中的PGD、P4D、PUD、PMD、PTE的方向查询遍历页表并复制。

1.4 wake_up_new_task 唤醒子进程

kernel_clone()函数在通过copy_process()创建并复制了父进程的task_strcut之后,需要通过wake_up_new_task() 将新进程唤醒并放入运行队列中,等待cpu的调度。由于这部分内容涉及到进程调度及调度器相关内容,我们将在下一篇文章进程调度与管理:(二)进程的调度与进程调度器进行详细分析,此处仅进行简要的介绍。

这里wake_up_new_task ()函数主要操作为如下:

  • 将新进程的运行状态改为运行态;
  • __set_task_cpu为新进程选择指定的cpu;
  • 通过__task_rq_lock获取任务所属的运行队列;
  • activate_task将任务放到运行队列中;
  • check_preempt_curr检查新任务是否需要抢占当前正在运行的任务;
kerwnel/sched/core.c
void wake_up_new_task(struct task_struct *p)
{
 /*1.将任务的状态改为运行态*/
 WRITE_ONCE(p->__state, TASK_RUNNING);
#ifdef CONFIG_SMP
 p->recent_used_cpu = task_cpu(p);
 rseq_migrate(p);
 /*2.为进程指定运行队列*/
 __set_task_cpu(p, select_task_rq(p, task_cpu(p), WF_FORK));
#endif
 /*3.获取任务所属的运行队列*/
 rq = __task_rq_lock(p, &rf);
 update_rq_clock(rq);//更新运行队列时间
 post_init_entity_util_avg(p);//对新任务的负载统计信息进行初始化
 /*4.将任务放到运行队列中*/
 activate_task(rq, p, ENQUEUE_NOCLOCK);
 trace_sched_wakeup_new(p);
 /*5.检查新任务是否需要抢占当前正在运行的任务*/
 check_preempt_curr(rq, p, WF_FORK);
}

值得注意的是,在wake_up_new_task ()中将进程状态改为了TASK_RUNNING,那么在此之前,新进程是什么状态呢?新进程不可能一出生就是运行态的吧。这个问题在copy_process -> sched_fork中可以找到答案,在sched_fork时,将进程的状态初始化为TASK_NEW,这个状态可以保证新进程不被任何信号或外部事件唤醒,也不会被插入运行队列,直到运行到wake_up_new_task时,才会将运行状态改为运行态且加入运行队列;

int sched_fork(unsigned long clone_flags, struct task_struct *p)
/*1.初始化调度相关结构体*/
 __sched_fork(clone_flags, p);
 /*
  * 我们在这里将进程标记为NEW,这保证了没有人会真正运行它,
  * 而且信号或其他外部事件也不能唤醒它并将其插入到 runqueue 中。
  */

 /*2.将新进程的状态改为TASK_NEW*/
 p->__state = TASK_NEW;
}

wake_up_new_task函数通过activate_task将新进程加入运行队列,并等待调度器的调度,但如果新进程需要立即执行呢?则需要通过check_preempt_curr()检查新进程是否需要抢占正在运行的任务,check_preempt_curr()主要操作如下:

  • 相同调度类:如果新任务 p 和当前任务 rq->curr 属于同一个调度类,交由该调度类的 check_preempt_curr() 函数来判断是否需要抢占。
  • 更高优先级的调度类:如果新任务的调度类优于当前任务的调度类,则立即调用 resched_curr(),设置当前任务需要被抢占。
  • 如果新进程所属调度类优先级低于正在运行的任务,便只能在运行队列中等待;
/*check_preempt_curr()
 *1.检查新任务是否需要抢占当前正在运行的任务,
 *  以确保调度器能够正确响应高优先级任务的到来。
 *2.该函数基于任务的调度类和优先级来决定是否需要进行抢占,
 *  或设置相关标志以通知调度器在下次时钟中断时进行任务切换。
 */

void check_preempt_curr(struct rq *rq, struct task_struct *p, int flags)

 /*判断新进程和当前运行队列正在运行的任务的调度类是否相同*/
 if (p->sched_class == rq->curr->sched_class)
  /*1.若二者调度类相同,则调用该调度类的check_preempt_curr函数去检查是否需要抢占当前任务*/
  rq->curr->sched_class->check_preempt_curr(rq, p, flags);
 else if (sched_class_above(p->sched_class, rq->curr->sched_class))
  /*2.若新进程所属调度类优于当前运行队列调度类,则调用resched_curr强制重新调度*/
  resched_curr(rq);

 /*如果当前任务已经被标记为需要重新调度,
  *并且还在运行队列中,那么可以跳过不必要的时钟更新操作
  */

 if (task_on_rq_queued(rq->curr) && test_tsk_need_resched(rq->curr))
  rq_clock_skip_update(rq);
}

2.进程的销毁

进程被父进程创建出来后,经过漫长的生命周期,在执行了不同的工作任务(exec())之后,最终也会被终止和销毁。限于篇幅原因,本小节把重点放在了进程销毁原理部分的介绍,源码分析部分将在后续的文章中详细介绍。

进程终止有两种方式:自愿终止与被动终止。自愿终止是指单个进程主动执行exit()系统调用或从主函数中主动返回;被动终止则是指进程收到终止信号或异常终止。当一个进程终止时,Linux内核会释放该进程所占有的资源(除了进程描述符外),并将该消息告知父进程,以便父进程知道该子进程终止的原因。

值得一提的是,进程可能先于其父进程终止,那么该进程将成为僵尸进程(即其所占资源全部还给内核,唯有其“躯体”task_struct还未被消除),在等待父进程通过调用wait()获取已终结的子进程的信息后,内核才会释放子进程的task_struct结构体;进程也可能在父进程消亡后终止,这种情况下,该进程将会被托孤给init进程(1号进程),其父进程(p->parent)变为1号进程。


Linux内核之旅
Linux内核之旅
 最新文章