分析Linux内核fork子进程的过程

江纯杰 原创作品转载请注明出处 
《Linux内核分析》MOOC课程

我们编写程序的时候会用到fork 、vfork 、clone等函数来创建一个子进程,但是,这些函数是怎么做到创建一个子进程的呢,底层是怎样实现的呢?我们通过分析内核代码来了解这些。

进程创建

查看 相关的内核代码 ,可以看到如下程序段

SYSCALL_DEFINE0(fork) 
{ 
  return do_fork(SIGCHLD, 0, 0, NULL, NULL);
}
 ....

SYSCALL_DEFINE0(vfork)
{ 
  return do_fork(CLONE_VFORK | CLONE_VM | SIGCHLD, 0, 0, NULL, NULL);
}
 ...

SYSCALL_DEFINE5(clone, unsigned long, clone_flags,
         unsigned long, newsp, int __user *, parent_tidptr, 
         int __user *, child_tidptr, int, tls_val)
{
  return do_fork(clone_flags, newsp, 0, parent_tidptr,
                child_tidptr);
}
 ...

这里只提取出关键信息,可以看到,三个函数都调用了do_fork()来完成。那么既然实际上都是调用一个过程,为什么要有fork,vfork,clone这么多不同的函数呢?实际上这是为了不同的场景需要而设计的,文章结尾部分是查阅资料总结这三个函数的区别,现在的重点是看内核是怎么创建新进程的,所以我们先来看do_fork代码,代码中关键地方做了注释。

long do_fork(unsigned long clone_flags, unsigned long stack_start,
        unsigned long stack_size, int __user *parent_tidptr,
        int __user *child_tidptr)
{
  struct task_struct *p;
  int trace = 0;
  long nr;

  //复制进程环境,返回创建的 task_struct 的指针
  p = copy_process(clone_flags, stack_start, stack_size,
       child_tidptr, NULL, trace);

  if (!IS_ERR(p)) {
    struct completion vfork;
    struct pid *pid;

    trace_sched_process_fork(current, p);

    //获取 task 结构 pid
    pid = get_task_pid(p, PIDTYPE_PID);
    nr = pid_vnr(pid);

    if (clone_flags & CLONE_PARENT_SETTID)
      put_user(nr, parent_tidptr);

    if (clone_flags & CLONE_VFORK) {
      p->vfork_done = &vfork;
      init_completion(&vfork);
      get_task_struct(p);
    }

    //将子进程添加到调度器的队列,等待调度运行
    wake_up_new_task(p);

    //如果调用的是vfork()则将父进程插入等待队列,让子进程先运行
    if (clone_flags & CLONE_VFORK) {
      if (!wait_for_vfork_done(p, &vfork))
        ptrace_event_pid(PTRACE_EVENT_VFORK_DONE, pid);
    }

    put_pid(pid);
  } else {
    nr = PTR_ERR(p);
  }
  return nr;
}

很明显,我们需要分析很重要的copy_process()

static struct task_struct *copy_process(unsigned long clone_flags,
          unsigned long stack_start,
          unsigned long stack_size,
          int __user *child_tidptr,
          struct pid *pid,
          int trace)
{
  int retval;
  struct task_struct *p;

  //分配一个新的 task_struct,内容从调用进程复制过来,仅仅是 stack 地址不同
  p = dup_task_struct(current);

  if (atomic_read(&p->real_cred->user->processes) >=
      task_rlimit(p, RLIMIT_NPROC)) {
    //检查该用户是否具有相关权限
    if (p->real_cred->user != INIT_USER &&
        !capable(CAP_SYS_RESOURCE) && !capable(CAP_SYS_ADMIN))
      goto bad_fork_free;
  }

  retval = -EAGAIN;
  //检查进程数量是否超过 max_threads
  if (nr_threads >= max_threads)
    goto bad_fork_cleanup_count;

  //把新进程的状态设置为TASK_RUNNING
  retval = sched_fork(clone_flags, p);

  //初始化子进程的内核栈
  retval = copy_thread(clone_flags, stack_start, stack_size, p);
  if (retval)
    goto bad_fork_cleanup_io;

  if (pid != &init_struct_pid) {
    retval = -ENOMEM;
    //为子进程分配新的 pid 号
    pid = alloc_pid(p->nsproxy->pid_ns_for_children);
    if (!pid)
      goto bad_fork_cleanup_io;
  }

  //设置子进程的 pid
  p->pid = pid_nr(pid);
  //如果是创建线程
  if (clone_flags & CLONE_THREAD) {
    p->exit_signal = -1;
    p->group_leader = current->group_leader;
    //tgid 是当前线程组的 id
    p->tgid = current->tgid;
  } else {
    if (clone_flags & CLONE_PARENT)
      p->exit_signal = current->group_leader->exit_signal;
    else
      p->exit_signal = (clone_flags & CSIGNAL);
    p->group_leader = p;
    p->tgid = p->pid;
  }

  if (clone_flags & (CLONE_PARENT|CLONE_THREAD)) {
    //如果是创建线程,那么同一线程组内的所有线程共享进程空间
    p->real_parent = current->real_parent;
    p->parent_exec_id = current->parent_exec_id;
  } else {
    //如果是创建进程,当前进程就是子进程的父进程
    p->real_parent = current;
    p->parent_exec_id = current->self_exec_id;
  }

  attach_pid(p, PIDTYPE_PID);
  nr_threads++;

  //返回被创建的 task 结构体指针
  return p;
}

copy_process函数所做的工作比较复杂,这里只选取了部分做了简单的注释。总结一下,copy_process 函数为了创建子进程,主要做了:

  • 调用 dup_task_struct() 复制当前进程的 task_struct;
  • 调用 sched_fork 初始化进程数据结构,设置新进程状态为 TASK_RUNNING;
  • 复制父进程的进程环境;
  • 调用 copy_thread 初始化子进程内核栈;
  • 为子进程设置新的 pid;

我们知道,fork()函数在子进程返回0,是怎么做的呢。答案在copy_thread 里,可以看到有 childregs->ax = 0 这句,把子进程的 eax 赋值为 0。还有,创建的子进程从何处执行呢?
代码里有p->thread.ip = (unsigned long) ret_from_fork, 将子进程的 ip 设置为 ret_form_fork 的首地址,因此子进程是从 ret_from_fork 开始执行的。

实验

在实验楼环境中gdb跟踪新进程的创建过程,在重要位置设置断点。

分析Linux内核fork子进程的过程_第1张图片

fork,vfork,clone的区别

Linux的用户进程不能直接被创建出来,因为不存在这样的API。它只能调用fork、vfork、clone这样的API从某个进程中复制出来,再通过exec这样的API来切换到实际想要运行的程序。

进程的构成:

  1. 执行代码,该代码是可以被多个进程共享的。

  2. 进程专用的系统堆栈空间。

  3. 进程控制块,在linux中具体实现是task_struct

  4. 有独立的存储空间。

fork

  • fork创建一个进程时,子进程只是完全复制父进程的资源,复制出来的子进程有自己的task_struct结构和pid,但却复制父进程其它所有的资源。例如,要是父进程打开了五个文件,那么子进程也有五个打开的文件,而且这些文件的当前读写指针也停在相同的地方。所以,这一步所做的是复制。

  • 这样看来,fork是一个开销十分大的系统调用,这些开销并不是所有的情况下都是必须的,比如某进程fork出一个子进程后,其子进程仅仅是为了调用exec执行另一个可执行程序,那么在fork过程中对于虚存空间的复制将是一个多余的过程。

  • 现在Linux中采取了 copy-on-write(COW写时复制) 技术,为了降低开销,fork最初并不会真的产生两个不同的拷贝,子进程先共享父进程的空间,若后来进程要修改数据,那意味着parent和child的数据不一致了,于是产生复制动作,每个进程拿到属于自己的那一份,这样就可以降低系统调用的开销。所以有了写时复制后呢,vfork其实现意义就不大了。

vfork

  • vfork系统调用不同于fork,它并不将父进程的地址空间完全复制到子进程中,因为子进程会立即调用exec或exit。
  • 用vfork创建的子进程与父进程共享地址空间,也就是说子进程完全运行在父进程的地址空间上,如果这时子进程修改了某个变量,这将影响到父进程。

  • 子进程在vfork()返回后直接运行在父进程的栈空间,并使用父进程的内存和数据。这意味着子进程可能破坏父进程的数据结构或栈,造成失败。为了避免这些问题,需要确保一旦调用vfork(),子进程就不从当前的栈框架中返回,并且如果子进程改变了父进程的数据结构就不能调用exit函数。子进程还必须避免改变全局数据结构或全局变量中的任何信息,因为这些改变都有可能使父进程不能继续。

  • 但此处有一点要注意的是用vfork()创建的子进程必须显示调用exit()来结束,否则子进程将不能结束,而fork()则不存在这个情况。

  • 用 vfork创建子进程后,父进程会被阻塞直到子进程调用exec或exit。

vfork的好处是在子进程被创建后往往仅仅是为了调用exec执行另一个程序,因为它就不会对父进程的地址空间有任何引用,所以对地址空间的复制是多余的 ,因此通过vfork共享内存可以减少不必要的开销。

clone

  • 系统调用fork()和vfork()是无参数的,而clone()则带有参数。fork()是全部复制,vfork()是共享内存,而clone()是则可以将父进程资源有选择地复制给子进程,而没有复制的数据结构则通过指针的复制让子进程共享,具体要复制哪些资源给子进程,由参数列表中的clone_flags决决定。

fork不对父子进程的执行次序进行任何限制,fork返回后,子进程和父进程都从调用fork函数的下一条语句开始行,但父子进程运行顺序是不定的,它取决于内核的调度算法;而在vfork调用中,子进程先运行,父进程挂起,直到子进程调用了exec或exit之后,父子进程的执行次序才不再有限制;clone中由标志CLONE_VFORK来决定子进程在执行时父进程是阻塞还是运行,若没有设置该标志,则父子进程同时运行,设置了该标志,则父进程挂起,直到子进程结束为止。

你可能感兴趣的:(linux,函数,api,kernel,内核)