fork()、vfork()、exec()和clone()之间的区别

252

我在谷歌上查找了这四个系统调用的区别,本以为会有很多相关信息,但实际上并没有一个可靠的比较结果。

于是我试图编写一个基本的、快速了解这些系统调用之间差异的内容,以下是我整理的结果。其中是否有错误或遗漏了重要信息?

Fork:fork 调用可以创建与当前进程几乎完全相同的另一个进程(并不是所有信息都被复制,例如在某些实现中的资源限制),新进程(子进程)会拥有独立的进程ID (PID),而其父进程的PID则成为它的父进程PID(PPID)。由于两个进程现在运行着完全相同的代码,因此它们可以通过 fork 的返回值来区分彼此——子进程得到 0,父进程得到子进程的 PID。当然,这基于 fork 调用有效——如果不是,则不会创建子进程,父进程会收到一个错误码。

Vfork:使用 vfork() 创建新进程时,父进程暂时挂起,子进程可能借用父进程的地址空间。除非子进程退出或调用execve(),否则这种奇怪的状态将继续存在,此时父进程会继续运行。

这意味着 vfork() 的子进程必须小心地避免意外修改父进程的变量。特别是,子进程不能从包含vfork()调用的函数返回,也不能调用 exit()(如果需要退出,则应使用_exit();实际上,这对于普通的fork()的子进程也是正确的)。

Exec: exec 调用可以将整个当前进程替换为一个新程序,它将程序加载到当前进程空间并从入口点运行。 exec() 将当前进程替换为函数指向的可执行文件。除非发生 exec() 错误,否则控制不会返回到原始程序。

Clone:与 fork() 不同,clone() 允许子进程与调用进程共享其执行上下文的某些部分,例如内存空间、文件描述符表和信号处理程序表。

当使用clone()创建子进程时,它执行函数应用程序fn(arg)(这与fork()不同,在fork()调用点之后,子进程会继续执行)。 fn参数是指向在子进程开始执行时由子进程调用的函数的指针。 arg参数传递给fn函数。

fn(arg)函数应用程序返回时,子进程终止。由fn返回的整数是子进程的退出代码。 子进程也可以通过调用exit(2)或在接收到致命信号后终止。

信息来自:

感谢您抽出时间阅读! :)


2
为什么vfork不能调用exit()?或者不返回?难道exit()不只是使用_exit()吗?我也在努力理解 :) - LazerSharks
2
@Gnuey:因为它有可能(如果它的实现方式与fork()不同,Linux和大多数BSD都是这样)借用其父进程的地址空间。除了调用execve()_exit()之外,它所做的任何事情都有很大的潜力会破坏父进程。特别是,exit()调用atexit()处理程序和其他“终结器”,例如:它刷新stdio流。从vfork()子进程返回可能(与前面相同的警告)会破坏父进程的堆栈。 - ninjalj
我在想父进程的线程会发生什么;它们全部被克隆还是只有调用fork系统调用的线程被克隆? - Mohammad Jafar Mashhadi
@LazerSharks vfork 产生一个类似线程的进程,其中内存是共享的,没有写时复制保护,因此进行堆栈操作可能会破坏父进程。 - Jasen
5个回答

197
  • vfork()是一种过时的优化。 在良好的内存管理之前,fork()会完全复制父进程的内存,因此非常昂贵。 由于在许多情况下,fork()后面跟着exec(),它会丢弃当前内存映射并创建一个新的内存映射,所以这是不必要的开销。 现在,fork()不再复制内存; 它只是设置为“写时复制”,因此fork()+exec()vfork()+exec()一样有效。

  • clone()fork()使用的系统调用。 使用某些参数,它可以创建一个新进程,使用另一些参数,它可以创建一个线程。 它们之间的区别仅在于共享哪些数据结构(内存空间、处理器状态、堆栈、PID、打开文件等)。


33
vfork 避免了临时分配更多的内存以便执行 exec 的需要,即使效率不如 fork 高,但仍然比较高效。因此,可以避免因为一个庞大的程序需要生成子进程而过度承诺内存。因此,这不仅可以提高性能,还可能使其成为可能。 - Deduplicator
8
实际上,我亲眼目睹了当你的 RSS 很大时 fork() 并不便宜。我猜测这是因为内核仍然需要复制所有页表。 - Martina Ferrari
5
它必须复制所有页面表,在两个进程中将所有可写内存设置为写时复制,刷新TLB,然后在exec上恢复父进程的所有更改(并再次刷新TLB)。 - zwol
3
在 cygwin(运行在微软的 Windows 上的内核模拟 DLL)中,vfork 仍然很有用。由于底层操作系统缺乏有效的 fork 实现,因此 cygwin 不能实现高效的 fork。 - ctrl-alt-delor
显示剩余6条评论

96
  • execve()函数用一个可执行文件中的另一个映像替换当前进程的可执行映像。
  • fork()函数创建一个子进程。
  • vfork()函数是fork()函数的历史优化版本,旨在在直接在fork()函数后调用execve()函数时使用。它被证明在非-MMU系统(其中fork()函数无法高效工作)和当需要为具有巨大内存占用量的进程运行一些小程序时(比如Java的Runtime.exec()),能够很好地工作。POSIX标准化了posix_spawn()函数以取代这两种更现代的vfork()函数的使用方法。
  • posix_spawn()函数相当于fork()/execve()函数,还允许在之间进行一些fd(文件描述符)操作。它主要用于非-MMU平台,旨在取代fork()/execve()函数。
  • pthread_create()函数创建一个新线程。
  • clone()函数是Linux特定的调用,可用于实现从fork()pthread_create()的任何功能。它提供了很多控制。灵感来自于rfork()函数。
  • rfork()函数是Plan-9特定的调用。它被认为是一个通用调用,允许在完整进程和线程之间共享多个级别。

2
感谢您提供了比实际需要的更多信息,这帮助我节省了时间。 - Neeraj
6
Plan 9 真是一个挑逗人的家伙。 - J.J
1
对于那些不记得 MMU 是什么的人: "内存管理单元" - 更多阅读请参考 维基百科 - mgarey

64
  1. fork() - 创建一个新的子进程,该进程是父进程的完全副本。子进程和父进程使用不同的虚拟地址空间,最初由相同的内存页填充。然后,当这两个进程都被执行时,虚拟地址空间开始有越来越多的差异,因为操作系统执行正在被两个进程中任何一个写入的内存页的惰性复制,并为每个进程分配修改后的内存页面的独立副本。这种技术称为写时复制(COW)。
  2. vfork() - 创建一个新的子进程,该进程是父进程的“快速”副本。与系统调用fork()相反,子进程和父进程共享同一虚拟地址空间。注意!使用相同的虚拟地址空间,父进程和子进程使用相同的堆栈、堆栈指针和指令指针,就像经典的fork()一样!为了防止父进程和子进程之间的不必要干扰,它们使用相同的堆栈,在子进程调用exec()(创建一个新的虚拟地址空间和过渡到不同的堆栈)或_exit()(终止进程执行)之前,父进程的执行被冻结。vfork()是“fork-and-exec”模型的优化fork()。它可以比fork()快4-5倍,因为与fork()不同(即使考虑到COW),vfork()系统调用的实现不包括创建新地址空间(分配和设置新页面目录)。
  3. clone() - 创建一个新的子进程。这个系统调用的各种参数指定了哪些父进程部分必须被复制到子进程中,哪些部分将在它们之间共享。因此,这个系统调用可用于创建各种执行实体,从线程开始,到完全独立的进程结束。事实上,clone()系统调用是用于实现pthread_create()和所有fork()系统调用系列的基础。
  • exec() - 重置进程的所有内存,加载和解析指定的可执行二进制文件,设置新的堆栈并将控制权传递给加载的可执行文件的入口点。这个系统调用永远不会将控制权返回给调用者,并用于将一个新程序加载到已经存在的进程中。这个系统调用与fork()系统调用一起形成了经典的UNIX进程管理模型,称为“fork-and-exec”。

  • 2
    请注意,vfork 的 BSD 和 POSIX 要求非常弱,以至于将 vfork 作为 fork 的同义词是合法的(而 POSIX.1-2008 将 vfork 完全从规范中删除)。如果您在一个将它们视为同义词的系统上测试代码(例如大多数 4.4 之后的 BSD,除了 NetBSD、2.2.0-pre6 之前的 Linux 内核等),即使违反了 vfork 的约定,它也可能正常工作,但在其他地方运行时会崩溃。一些使用 fork 模拟它的系统(例如 OpenBSD)仍然保证父进程在子进程 exec_exit 之前不会恢复运行。这太不可移植了。 - ShadowRanger
    2
    关于您第三点的最后一句话:我注意到在Linux上使用strace时,虽然glibc对于fork()的包装器调用了clone系统调用,但是对于vfork()的包装器却调用了vfork系统调用。 - ilstam
    Linux的vfork可以通过简单的汇编语言包装器来更容易地使用,这些包装器会导致exitexecve在父进程中返回,而不是vfork返回两次。 - Timothy Baldwin

    13

    fork()函数,vfork()函数和clone()函数都调用do_fork()函数来执行实际工作,但使用的参数不同。

    asmlinkage int sys_fork(struct pt_regs regs)
    {
        return do_fork(SIGCHLD, regs.esp, &regs, 0);
    }
    
    asmlinkage int sys_clone(struct pt_regs regs)
    {
        unsigned long clone_flags;
        unsigned long newsp;
    
        clone_flags = regs.ebx;
        newsp = regs.ecx;
        if (!newsp)
            newsp = regs.esp;
        return do_fork(clone_flags, newsp, &regs, 0);
    }
    asmlinkage int sys_vfork(struct pt_regs regs)
    {
        return do_fork(CLONE_VFORK | CLONE_VM | SIGCHLD, regs.esp, &regs, 0);
    }
    #define CLONE_VFORK 0x00004000  /* set if the parent wants the child to wake it up on mm_release */
    #define CLONE_VM    0x00000100  /* set if VM shared between processes */
    
    SIGCHLD means the child should send this signal to its father when exit.
    

    对于fork,子进程和父进程都有独立的虚拟内存页表。但考虑到效率,fork并不会真正复制任何页面,它只是将所有可写页面设置为只读状态供子进程使用。因此,当子进程想要在该页面上写入内容时,会发生一个页面异常,并且内核会分配一个新的页面,该页面克隆自旧页面并带有写权限。这就是所谓的“写时复制”。

    对于vfork,子进程和父进程共享虚拟内存,正因为如此,父进程和子进程不能同时运行,因为它们会互相影响。因此,在“do_fork()”的结尾,父进程会休眠,并在子进程调用exit()或execve()时唤醒,因为此时父进程将拥有新的页表。以下是导致父进程休眠的代码(在do_fork()中)。

    if ((clone_flags & CLONE_VFORK) && (retval > 0))
    down(&sem);
    return retval;
    

    这里是代码(在mm_release()中被exit()和execve()调用),它会唤醒父进程。

    up(tsk->p_opptr->vfork_sem);
    

    对于sys_clone()而言,它更加灵活,因为您可以向其中输入任何clone_flags。因此,pthread_create()使用许多clone_flags来调用此系统调用:

    int clone_flags = (CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGNAL | CLONE_SETTLS | CLONE_PARENT_SETTID | CLONE_CHILD_CLEARTID | CLONE_SYSVSEM);

    总结:fork()、vfork()和clone()将创建具有不同共享资源数量的子进程,并且我们也可以说vfork()和clone()可以创建线程(实际上它们是进程,因为它们具有独立的task_struct),因为它们与父进程共享VM页表。


    -5

    在fork()中,子进程或父进程将根据CPU选择执行。但在vfork()中,子进程肯定会先执行。在子进程终止后,父进程将执行。


    3
    错误。vfork() 可以直接实现为 fork() - ninjalj
    在调用AnyFork()之后,未定义是谁先运行父进程/子进程。 - AjayKumarBasuthkar
    5
    如果你认为分叉后存在隐含的串行顺序,那么你有一些概念上的误解。分叉创建了一个进程,然后将控制返回给两个进程(每个进程返回不同的pid)-如果这样做有意义(例如多个处理器),操作系统可以调度新进程并行运行。如果由于某种原因需要这些进程按特定的串行顺序执行,则需要额外的同步,分叉无法提供;坦白地说,你可能根本不希望进行分叉。 - Andon M. Coleman
    实际上,@AjayKumarBasuthkar和@ninjalj,你们两个都错了。使用vfork(),子进程先运行。这在手册中有说明;父进程的执行被暂停,直到子进程死亡或执行exec。而且,ninjalj可以查看内核源代码。无法将vfork()实现为fork(),因为它们在内核中向do_fork()传递不同的参数。但是,您可以使用clone系统调用来实现vfork - Zac Wimer
    @ZacWimer:请参考ShadowRanger在另一个答案中的评论 https://dev59.com/L2445IYBdhLWcg3wcZ8N#lvPqnYgBc1ULPQZFTol0 旧版Linux将它们视为同义词,因为显然除了NetBSD(倾向于被移植到许多非MMU系统之外)之外的其他BSD也是如此。来自Linux手册的说明:在4.4BSD中,它被视为fork(2)的同义词,但NetBSD再次引入了它;请参见⟨http://www.netbsd.org/Documentation/kernel/vfork.html⟩。在Linux中,它一直等同于fork(2),直到2.2.0-pre6左右。 - ninjalj

    网页内容由stack overflow 提供, 点击上面的
    可以查看英文原文,
    原文链接