:为什么父子进程的返回值不同?)
深入理解 fork()为什么父子进程的返回值不同引言在 Linux 系统编程中fork()是最基础、也最容易让初学者困惑的系统调用之一。它最著名的特征是调用一次返回两次。调用成功后父进程得到子进程的 PID子进程得到0调用失败时调用进程得到-1并且不会产生子进程。例如#includestdio.h#includestdlib.h#includesys/types.h#includesys/wait.h#includeunistd.hintmain(void){pid_tpidfork();if(pid0){perror(fork);returnEXIT_FAILURE;}if(pid0){printf(子进程PID%ld父进程PID%ldfork返回值%ld\n,(long)getpid(),(long)getppid(),(long)pid);}else{printf(父进程PID%ld子进程PID%ldfork返回值%ld\n,(long)getpid(),(long)pid,(long)pid);waitpid(pid,NULL,0);}returnEXIT_SUCCESS;}一次可能的输出是父进程PID1000子进程PID1001fork返回值1001 子进程PID1001父进程PID1000fork返回值0问题是父子进程明明从同一行代码继续执行为什么fork()的返回值不同真正的答案不在 C 语言语法中而在 Linux 内核为子进程构造的寄存器现场、内核栈和调度入口中。一、“调用一次返回两次”并不意味着同一个进程返回两次从用户程序角度看pid_tpidfork();似乎是同一次函数调用产生了两个返回结果。但从内核角度看更准确的过程是最初只有父进程父进程调用fork()进入内核内核创建一个新的task_struct内核为子进程准备一套执行上下文父进程沿正常系统调用路径返回子进程第一次被调度时沿专门的ret_from_fork路径进入用户态两个进程都从fork()后面的用户态代码继续执行。因此不是“同一个进程返回两次”而是内核创建了两个独立的执行流并让它们从同一个用户态代码位置继续运行。父进程和子进程的指令位置相同但寄存器现场不同所以看到的返回值也不同。二、结合源码看 fork() 的总体调用链根据给出的内核源码可以把调用链简化为用户态 fork() │ ▼ 系统调用入口 │ ▼ do_fork() │ ▼ _do_fork() │ ├── copy_process() │ ├── dup_task_struct() │ ├── copy_creds() │ ├── copy_files() │ ├── copy_fs() │ ├── copy_sighand() │ ├── copy_signal() │ ├── copy_mm() │ ├── copy_namespaces() │ ├── copy_io() │ ├── copy_thread_tls() │ └── alloc_pid() │ ├── pid_vnr() ├── wake_up_new_task() └── return nr这条路径中有两个最关键的位置returnnr;决定父进程看到子进程 PID。而架构相关代码中的childregs-r00;或者c_regs-r00;决定子进程看到0。三、do_fork()把旧式参数转换为统一参数结构源码首先定义了一个兼容入口longdo_fork(unsignedlongclone_flags,unsignedlongstack_start,unsignedlongstack_size,int__user*parent_tidptr,int__user*child_tidptr){structkernel_clone_argsargs{.flagslower_32_bits(clone_flags)~CSIGNAL,.pidfdparent_tidptr,.child_tidchild_tidptr,.parent_tidparent_tidptr,.exit_signallower_32_bits(clone_flags)CSIGNAL,.stackstack_start,.stack_sizestack_size,};if(!legacy_clone_args_valid(args))return-EINVAL;return_do_fork(args);}这个函数本身并不复制进程。它主要完成两件事把旧接口中的多个参数整理到kernel_clone_args调用真正的通用创建函数_do_fork()。因此可以把它看作参数适配层。这里需要注意源码版本不同函数名也可能不同。较新的内核中常见的是kernel_clone()而给出的代码使用_do_fork()。虽然函数组织方式会变化但核心原理基本一致。四、_do_fork()父进程返回值在这里产生核心代码如下pcopy_process(NULL,trace,NUMA_NO_NODE,args);if(IS_ERR(p))returnPTR_ERR(p);pidget_task_pid(p,PIDTYPE_PID);nrpid_vnr(pid);wake_up_new_task(p);put_pid(pid);returnnr;这一段几乎直接回答了“父进程为什么得到子进程 PID”。4.1 copy_process() 创建子进程pcopy_process(NULL,trace,NUMA_NO_NODE,args);如果创建成功p指向新子进程的task_struct。此时子进程的内核对象已经基本构造完成但它还没有被正式放入可运行队列。4.2 获取子进程 PIDpidget_task_pid(p,PIDTYPE_PID);nrpid_vnr(pid);get_task_pid()获取与子进程关联的 PID 对象。pid_vnr()将 PID 转换成当前 PID 命名空间中可见的数值。因此父进程得到的不一定是宿主机最外层 PID 命名空间中的全局 PID而是当前调用进程所在 PID 命名空间内可见的子进程 PID。这对容器环境尤其重要。例如同一个进程可能表现为容器内PID125 宿主机PID30481容器内的父进程调用fork()时返回的通常是125这一命名空间内可见的编号。4.3 唤醒子进程wake_up_new_task(p);这是一个重要分界点。在此之前子进程的task_struct已经创建PID 已经分配内存、文件、信号等资源已经处理子进程寄存器和内核栈已经准备好但子进程还不能真正被调度执行。调用wake_up_new_task()后子进程才进入调度器的可运行状态。此后调度器可能继续运行父进程立即切换到子进程在其他 CPU 核心上同时运行子进程。因此fork()不保证父进程先运行。4.4 父进程返回子进程 PIDreturnnr;这里的_do_fork()当前是在父进程的系统调用上下文中运行。所以这个返回值会沿系统调用返回路径回到父进程。最终体系结构相关的系统调用退出代码会把nr放入该架构规定的返回值寄存器中。因此父进程看到pid0这里不应简单写成“copy_process()把父进程的返回寄存器改成子 PID”。更准确的描述是_do_fork()返回子进程 PID系统调用退出路径再按照 CPU 架构 ABI把该值放入父进程的返回值寄存器。五、copy_process()创建子进程但暂时不启动它源码注释已经明确说明/* * This creates a new process as a copy of the old one, * but does not actually start it yet. */即创建一个旧进程的副本但暂时不真正启动它。copy_process()的主要任务不是“完整复制一块内存”而是根据clone_flags创建或共享各种进程资源。六、dup_task_struct()复制进程的基础描述结构源码中首先执行pdup_task_struct(current,node);其中current表示当前正在执行的父进程p表示新创建的子进程task_struct是 Linux 描述进程或线程的核心数据结构。dup_task_struct()会复制基础任务结构并为子进程准备内核栈等资源。但此时不能认为子进程已经可以运行。后面还必须完成凭据处理调度初始化文件表处理内存空间处理信号处理命名空间处理寄存器现场构造PID 分配父子关系建立进程链表注册。七、copy_process() 复制了哪些资源源码中的关键调用包括retvalcopy_creds(p,clone_flags);retvalcopy_files(clone_flags,p);retvalcopy_fs(clone_flags,p);retvalcopy_sighand(clone_flags,p);retvalcopy_signal(clone_flags,p);retvalcopy_mm(clone_flags,p);retvalcopy_namespaces(clone_flags,p);retvalcopy_io(clone_flags,p);retvalcopy_thread_tls(...);这些函数分别处理不同类型的进程资源。7.1 copy_creds()处理用户身份和权限信息例如UIDGIDcapabilities安全凭据。7.2 copy_files()处理文件描述符表。普通fork()中父子进程会拥有各自的文件描述符表但对应描述符通常仍指向相同的内核打开文件对象。所以父子进程可能共享文件偏移文件状态标志打开文件描述。这意味着intfdopen(data.txt,O_RDONLY);fork();之后父子进程对fd的读取可能共同改变同一个文件偏移。7.3 copy_fs()处理文件系统上下文例如当前工作目录根目录umask。7.4 copy_sighand() 和 copy_signal()处理信号处理函数信号相关状态线程组信号结构。7.5 copy_mm()处理用户态虚拟内存空间。这一步与写时复制密切相关。7.6 copy_namespaces()处理各种命名空间例如PID namespacemount namespaceuser namespacenetwork namespacetime namespace。7.7 copy_thread_tls()处理架构相关的用户寄存器现场内核栈布局TLS子进程首次调度入口fork()在子进程中的返回值。真正决定“子进程返回 0”的代码就在这里。八、写时复制copy_mm() 不会立即复制所有物理内存很多文章会说fork()完整复制了父进程的内存。这是一种简化说法但不够准确。普通fork()中父子进程具有独立的虚拟地址空间语义但 Linux 通常采用写时复制也就是 Copy-on-Write。初始阶段子进程获得自己的内存描述结构父子页表大体对应相同内容普通私有内存页可能仍映射到相同物理页这些页面会受到写保护。当父进程或子进程尝试写入某个共享页面时CPU 触发页面保护异常内核识别出这是 COW 页面为写入方分配新的物理页复制原页内容更新写入方页表重新执行写操作。因此intvalue10;pid_tpidfork();if(pid0)value999;子进程修改value后不会把父进程中的value也改成999。即使父子进程打印出的虚拟地址相同也不表示它们最终使用的是同一个私有物理页。九、copy_thread_tls()子进程返回 0 的真正来源在copy_process()中可以看到retvalcopy_thread_tls(clone_flags,args-stack,args-stack_size,p,args-tls);这是一个体系结构相关函数。不同 CPU 架构有不同的寄存器名称、栈布局和返回指令所以通用的kernel/fork.c不能直接写死某个寄存器。但所有架构都要完成相同的逻辑目标复制父进程的用户寄存器现场修改子进程的返回值寄存器构造子进程的内核栈设置子进程第一次被调度时的入口最终让子进程返回用户态。十、Alpha 架构childregs-r0 0给出的 Alpha 架构代码中structpt_regs*childregstask_pt_regs(p);structpt_regs*regscurrent_pt_regs();这里regs指向父进程当前保存的寄存器现场childregs指向子进程内核栈中的寄存器现场。随后*childregs*regs;这一步复制父进程的寄存器状态。因此子进程在初始状态下拥有与父进程相近的用户栈指针指令位置通用寄存器CPU 状态。接着执行childregs-r00;在该架构的调用约定中r0用于保存返回值。因此当子进程最终恢复这些寄存器并回到用户态时用户程序看到的就是fork()0源码还设置了childregs-r190;childregs-r201;这是 Alpha/OSF 兼容语义中的额外寄存器处理不应把它们机械套用到 x86、ARM 或 ARC 架构上。最关键的是childregs-r00;十一、ARC 架构c_regs-r0 0给出的另一段copy_thread_tls()属于 ARC 架构风格。首先获取子进程寄存器现场c_regstask_pt_regs(p);获取父进程寄存器现场structpt_regs*regscurrent_pt_regs();复制寄存器*c_regs*regs;如果调用者为子进程指定了新的用户栈还会修改if(usp)c_regs-spusp;最关键的一行是c_regs-r00;/* fork returns 0 in child */源码注释已经直接说明fork 在子进程中返回 0。因此无论是 Alpha 的childregs-r00;还是 ARC 的c_regs-r00;本质都一样修改子进程保存的用户态寄存器现场使子进程恢复到用户态时得到返回值 0。十二、不要把 copy_thread_tls() 的 return 0 理解成 fork 返回 0这是阅读源码时非常容易出现的误解。在copy_thread_tls()末尾还有return0;这个return 0并不是用户程序中子进程看到的fork()返回值。它只是表示copy_thread_tls()成功完成了子进程寄存器和线程上下文的构造。其调用者是copy_process()retvalcopy_thread_tls(...);if(retval)gotobad_fork_cleanup_io;所以这里的返回值属于内核内部函数调用。真正控制用户态子进程返回值的是c_regs-r00;或者childregs-r00;必须区分这两个完全不同的“0”代码含义c_regs-r0 0子进程回到用户态后fork()返回 0return 0内核函数copy_thread_tls()执行成功十三、ret_from_fork子进程并不从 _do_fork() 的 return nr 返回这是理解整个问题最关键的一点。父进程确实在执行returnnr;但子进程不会重新进入_do_fork()也不会执行这个return nr。在架构相关代码中可以看到Alphachildstack-r26(unsignedlong)ret_from_fork;ARCchildksp[1](unsignedlong)ret_from_fork;这些代码把子进程首次运行时的内核返回路径设置成ret_from_fork。因此父子进程返回用户态的路线不同。父进程路线父进程执行 _do_fork() │ ├── copy_process() ├── wake_up_new_task() └── return nr │ ▼ 系统调用退出路径 │ ▼ 返回用户态fork()得到子PID子进程路线copy_process()构造子进程 │ ├── 复制父进程pt_regs ├── 将子进程返回寄存器设置为0 └── 将首次调度入口设置为ret_from_fork │ ▼ wake_up_new_task() │ ▼ 调度器选中子进程 │ ▼ ret_from_fork │ ▼ 恢复子进程用户态寄存器 │ ▼ 返回用户态fork()得到0所以所谓“一次调用两次返回”不是两个任务都执行了同一个内核return nr。实际上父进程执行_do_fork()的正常返回子进程通过预先构造的ret_from_fork路径开始执行。十四、为什么子进程能从 fork() 后面继续执行在调用fork()进入内核时内核会保存父进程的用户态现场。其中包括用户态程序计数器用户栈指针通用寄存器标志寄存器系统调用上下文。copy_thread()或copy_thread_tls()执行*childregs*regs;或者*c_regs*regs;相当于把父进程当时的用户态现场复制给子进程。这个现场代表的是系统调用结束后应该回到哪个用户态位置。因此子进程第一次返回用户态时其程序计数器也会落在fork()调用之后。它不是重新执行fork();而是直接表现得像fork()刚刚返回只不过内核已经把它的返回值寄存器改成了0。十五、父进程返回值为何不是通过修改父进程 pt_regs 设置子进程的返回值必须提前写进它自己的保存现场因为子进程并没有执行_do_fork()的正常 C 返回路径。父进程则不同。父进程当前就在_do_fork()中执行所以只需要returnnr;返回值便会沿正常函数调用和系统调用退出路径传递。可以抽象为_do_fork()返回nr │ ▼ 系统调用入口代码取得返回值 │ ▼ 将返回值写入当前架构的系统调用返回寄存器 │ ▼ 父进程返回用户态在不同架构中这个寄存器不同x86-64 通常是RAXAArch64 通常是x0ARC 示例中是r0Alpha 示例中也使用r0表示主要返回值。因此文章不应把所有架构都描述成“修改RAX”。更通用、准确的说法是内核按照目标 CPU 架构的 ABI 设置系统调用返回值寄存器。十六、fork() 失败时发生了什么源码中pcopy_process(NULL,trace,NUMA_NO_NODE,args);if(IS_ERR(p))returnPTR_ERR(p);copy_process()失败时返回错误指针例如ERR_PTR(-ENOMEM)ERR_PTR(-EAGAIN)ERR_PTR(-EINVAL)_do_fork()使用PTR_ERR(p)重新取出负错误码并返回。需要注意内核内部通常返回的是-ENOMEM -EAGAIN -EINVAL而用户态 C 程序一般看到fork()-1同时errnoENOMEM或者errnoEAGAIN这是因为 libc 的系统调用包装层通常会把内核的负错误码转换成返回 -1并设置 errno失败时没有创建新的子进程执行上下文所以只有调用者返回一次。不存在“子进程也获得 -1”的情况。十七、源码中的错误清理路径说明了什么copy_process()尾部有大量标签bad_fork_cleanup_io:bad_fork_cleanup_namespaces:bad_fork_cleanup_mm:bad_fork_cleanup_signal:bad_fork_cleanup_sighand:bad_fork_cleanup_fs:bad_fork_cleanup_files:bad_fork_cleanup_security:bad_fork_free:fork_out:这是内核常见的分阶段资源回滚结构。例如假设已经成功执行复制文件表 复制文件系统状态 复制信号结构 复制内存空间但之后copy_thread_tls()失败就需要按照相反方向释放已经申请的资源。这种结构说明创建子进程不是一个单独的“复制动作”而是一系列可能失败的资源构造步骤。只有通过源码所说的/* past the last point of failure */之后新进程才跨过主要失败点并正式注册到系统中。十八、父子进程谁先执行执行wake_up_new_task(p);后子进程进入可运行状态。但_do_fork()还会继续执行put_pid(pid);returnnr;调度器此时可能有多种选择。情况一父进程先返回用户态父进程继续运行 │ ├── return nr └── 执行fork后的父分支 稍后调度子进程 │ └── 执行fork后的子分支情况二子进程先运行wake_up_new_task() │ ▼ 调度器切换到子进程 │ ▼ ret_from_fork │ ▼ 执行fork后的子分支 之后父进程再返回情况三多核并行父进程和子进程可能在不同 CPU 核心上几乎同时执行。因此以下两种输出都合法parent child或者child parent不能依赖默认调度顺序编写程序。十九、vfork 为什么还有 wait_for_vfork_done()源码中有特殊处理if(clone_flagsCLONE_VFORK){p-vfork_donevfork;init_completion(vfork);get_task_struct(p);}唤醒子进程后if(clone_flagsCLONE_VFORK){if(!wait_for_vfork_done(p,vfork))ptrace_event_pid(PTRACE_EVENT_VFORK_DONE,pid);}这属于vfork()的特殊语义。vfork()中父进程通常必须等待子进程调用execve()或调用_exit()。因为在这段时间里子进程可能暂时共享父进程地址空间。普通fork()不设置CLONE_VFORK因此父进程不会走这个等待分支。二十、ptrace 代码不负责决定 fork 返回值源码前面还有if(clone_flagsCLONE_VFORK)tracePTRACE_EVENT_VFORK;elseif(args-exit_signal!SIGCHLD)tracePTRACE_EVENT_CLONE;elsetracePTRACE_EVENT_FORK;以及ptrace_event_pid(trace,pid);这些代码用于调试器和跟踪器观察进程创建事件。它们决定的是是否向 ptrace 观察者报告报告FORK、VFORK还是CLONE何时触发调试事件。它们不是父子进程返回值不同的根本原因。根本原因仍然是returnnr;和childregs-r00;二十一、完整时序图把整个过程串联起来可以得到下面的时序。用户态父进程 │ │ fork() ▼ 进入内核 │ ▼ do_fork() │ │ 构造kernel_clone_args ▼ _do_fork() │ ▼ copy_process() │ ├── 检查clone_flags组合 ├── 检查进程数量限制 ├── dup_task_struct() ├── copy_creds() ├── copy_files() ├── copy_fs() ├── copy_sighand() ├── copy_signal() ├── copy_mm() ├── copy_namespaces() ├── copy_io() │ ├── copy_thread_tls() │ ├── 复制父进程pt_regs │ ├── 子进程返回寄存器设为0 │ └── 首次入口设为ret_from_fork │ ├── alloc_pid() ├── 设置p-pid和p-tgid ├── 建立父子关系 └── return p │ ▼ get_task_pid() │ ▼ pid_vnr() │ ▼ wake_up_new_task() │ ┌───┴──────────────────┐ │ │ ▼ ▼ 父进程继续执行 子进程被调度 │ │ ▼ ▼ return nr ret_from_fork │ │ ▼ ▼ 系统调用返回寄存器PID 恢复pt_regsr00 │ │ ▼ ▼ fork()返回子PID fork()返回0二十二、用一句话解释父子返回值差异父进程得到子进程 PID是因为nrpid_vnr(pid);returnnr;子进程得到0是因为架构相关代码复制父进程寄存器现场后主动修改了子进程的返回值寄存器*childregs*regs;childregs-r00;或者*c_regs*regs;c_regs-r00;同时子进程的内核栈被设置成从ret_from_fork开始恢复执行而不是重新执行_do_fork()。二十三、常见误区误区一父子进程都执行了 _do_fork() 的 return nr错误。只有父进程沿当前系统调用路径执行return nr。子进程通过ret_from_fork开始运行。误区二copy_thread_tls() 最后的 return 0 就是子进程返回 0错误。这个return 0只是表示内核函数执行成功。真正的用户态返回值由c_regs-r00;决定。误区三所有平台都修改 RAX错误。RAX只适用于 x86-64 的相关 ABI。给出的 Alpha 和 ARC 代码使用的是r0。准确说法应是修改目标架构的系统调用返回值寄存器。误区四fork() 重新执行了两次错误。只有父进程发起了一次系统调用。子进程由内核构造并从系统调用返回后的用户态位置开始执行。误区五copy_process() 返回后子进程已经运行不一定。copy_process()只完成创建真正使子进程可运行的是wake_up_new_task(p);误区六父子进程的物理内存从一开始就全部独立错误。普通私有内存通常先通过写时复制共享物理页面发生写操作后再分离。误区七fork() 失败时子进程也会获得 -1错误。失败时子进程根本没有创建成功只有调用进程返回错误。二十四、最终总结fork()“调用一次返回两次”的本质不是编译器魔法也不是同一个函数真正执行了两个普通的 C 语言return。Linux 内核做了两件不同的事。对父进程内核创建子进程后取得其 PIDnrpid_vnr(pid);然后从当前系统调用路径返回returnnr;所以父进程得到子进程 PID。对子进程内核复制父进程保存的寄存器现场*c_regs*regs;然后把子进程的返回值寄存器改成c_regs-r00;并把子进程首次调度入口设置为ret_from_fork所以子进程第一次回到用户态时就像fork()刚刚返回一样并且返回值是0。最终可以概括为执行流返回路径返回值来源父进程_do_fork()正常系统调用返回路径return nr子进程调度器 →ret_from_fork→ 用户态子进程pt_regs中的r0 0失败原调用进程正常错误返回内核负错误码经 libc 转换为-1和errno最准确的一句话是父子进程并不是从同一个内核返回语句得到不同结果父进程通过系统调用正常返回而子进程通过预先构造的寄存器现场和ret_from_fork路径进入用户态。