:进程的创建与退出——fork、写时拷贝与退出码)
前言前面我们已经学习了进程的基本概念知道了进程不仅仅是一段正在运行的程序还需要操作系统为它维护 PCB、虚拟地址空间、页表等信息。那么接下来就有两个很自然的问题一个进程是怎么创建出另一个进程的一个进程执行完毕之后操作系统又是怎么知道它的执行结果的在 Linux 中这两个过程分别涉及进程创建和进程退出。本篇我们就从fork()开始看看一个进程是如何创建子进程的再通过写时拷贝理解父子进程之间的内存关系最后看看进程退出时返回值又是如何传递给父进程的。一、进程创建1.1 fork 函数初识在 Linux 中如果我们想创建一个新的进程就需要使用fork()函数。先来看一下它的函数原型#includeunistd.hpid_tfork(void);fork()是一个系统调用它的作用就是以当前进程为基础创建一个新的进程。原来的进程叫作父进程新创建出来的进程叫作子进程。比如现在有一个进程正在执行intmain(){printf(hello Linux\n);return0;}当它执行到fork()时操作系统就会创建一个子进程。这里要注意子进程不是从main()函数的开头重新执行而是从fork()调用之后的位置开始执行。也就是说父子进程都会继续执行fork()后面的代码。那么父子进程既然都执行同一份代码程序怎么知道当前到底是父进程还是子进程呢这就涉及fork()最有意思的地方它会返回两次。#includestdio.h#includeunistd.hintmain(){pid_t idfork();if(id0){printf(我是子进程pid: %d\n,getpid());}elseif(id0){printf(我是父进程pid: %d\n,getpid());}else{printf(fork failed\n);}return0;}运行结果我是父进程pid: 1234 我是子进程pid: 1235这里的 PID 只是示意实际运行时会有所不同。我们发现明明只有一个fork()调用却出现了两次返回结果。这是因为父进程调用fork()返回的是新创建的子进程 PID。子进程调用fork()对应的执行流返回的是 0。如果创建失败则返回 -1并且不会产生子进程。为什么要设计成两个不同的返回值其实很好理解。父进程需要知道自己创建出来的子进程是谁所以返回子进程的 PID。子进程只需要知道自己已经成功创建出来了因此返回 0 就可以了。这样父子进程就可以通过fork()的返回值分别执行不同的代码。1.2 fork 创建子进程的底层过程那么fork()到底做了什么我们之前说过一个进程不仅仅是代码还包含 PCB、地址空间、页表以及各种资源。所以创建子进程并不是简单地复制一份代码就结束了。大致可以理解为以下几个步骤操作系统为子进程创建新的进程控制块PCB也就是 Linux 内核中的task_struct。为子进程建立相应的虚拟地址空间和内存管理信息。建立父子进程之间的资源关系并处理需要继承的文件描述符等信息。建立子进程的内存映射关系。将子进程加入进程调度体系等待 CPU 调度执行。6.这里有一个非常重要的问题父进程已经有自己的代码、数据和各种变量了子进程创建出来之后难道还要把这些内容全部重新复制一份吗如果每次创建子进程都要完整复制父进程的所有内存不仅浪费时间也会占用大量物理内存。Linux 为了解决这个问题就使用了写时拷贝技术。1.3 写时拷贝Copy On Write写时拷贝英文叫作 Copy On Write简称 COW。它的核心思想是先共享等到真正需要修改数据的时候再进行拷贝。我们先从一个简单的例子理解。假设父进程中有一个变量inta10;当父进程调用fork()创建子进程时父子进程都需要拥有这个变量。如果直接复制那么内存中就会有两份父进程a 10 子进程a 10但问题是子进程刚创建出来时可能根本不会修改这个变量。如果它一直只是读取数据那么提前复制一份就没有必要。所以 Linux 的做法是刚创建子进程时父子进程暂时共享同一份物理内存。这样父子进程虽然各自有独立的虚拟地址空间和页表但在没有发生写入操作时可以共同使用同一份物理内存。不过这里还有一个问题如果父进程把a改成了 20那么子进程看到的也会变成 20 吗当然不行。父子进程是两个独立的执行实体它们应该拥有各自独立的数据不能因为一方修改变量就影响另一方。因此操作系统还需要做一件事防止父子进程直接修改这份共享数据。1.3.1 写时拷贝是如何实现的当fork()创建子进程后操作系统会对父子进程共享的相关内存页进行权限设置。对于需要写时拷贝保护的页面父子进程对应的页表项都会被设置为只读。注意这里设置的是页表中的访问权限并不是把原本可以修改的变量永久变成了只读变量。它只是暂时不允许父子进程直接修改共享的物理页面。接下来我们看看具体会发生什么。假设inta10;intmain(){pid_t idfork();if(id0){a20;}elseif(id0){a30;}return0;}父子进程刚创建出来时内存关系大致如下父进程虚拟地址 子进程虚拟地址 | | v v 父进程页表 子进程页表 | | ----------------------------- | v 物理内存 a 10 只读保护现在子进程执行a20;它想修改a但是页表告诉 CPU这块内存目前只允许读取不允许写入。于是 CPU 就会产生一个异常通知操作系统处理。这里产生的异常并不意味着程序一定出错了。因为操作系统知道这个页面原本是可以写入的只不过为了实现写时拷贝暂时设置成了只读。所以是在错误的驱使下有了写时拷贝的行为内核会结合进程的内存区域信息vm_area_struct和页表权限判断这是不是一次合法的写时拷贝。如果确认属于 COW操作系统就会执行下面的操作申请一块新的物理内存。将原来共享页面中的数据复制到新页面。修改子进程的页表让它指向新的物理页面。将新页面的访问权限恢复为可读可写。子进程重新执行刚才的写操作。最终父进程页表 子进程页表 | | v v 原来的物理页面 新的物理页面 a 10 a 20这样父进程和子进程就拥有了各自独立的数据。如果父进程之后也尝试修改a同样会根据当前的共享情况触发相应的写时拷贝处理。这就是写时拷贝的基本实现过程。1.3.2 为什么操作系统能够分辨出谁是数据段我们刚才提到父子进程共享的内存页面被设置成只读之后如果发生写入CPU 就会产生异常。但操作系统怎么知道这次异常是写时拷贝引起的而不是真正的非法内存访问呢这就要说到vm_area_struct。我们之前学习虚拟地址空间时知道进程的虚拟地址空间中有不同的区域比如代码段、数据段、堆区、栈区等。Linux 内核通过vm_area_struct结构体来描述这些虚拟内存区域。其中保存着该区域的起始地址、结束地址以及访问权限等信息。比如某块虚拟内存区域 起始地址0x1000 结束地址0x2000 权限可读、可写如果这块区域原本就允许写入只是因为写时拷贝暂时被设置成只读那么操作系统就有机会识别出这是一次合法的写入请求。而如果访问本身就不合法比如访问了不属于当前进程的地址那么就不能简单地当成写时拷贝处理。所以操作系统不是只看页表中的只读标记还需要结合虚拟内存区域的信息进行判断。1.3.3 查询页表时可能出现的几种情况我们可以把页表理解成一张地址转换表。程序访问虚拟地址时CPU 和 MMU 会根据页表完成地址转换同时检查访问权限。大致有以下几种情况页表项存在权限也符合要求正常完成地址转换访问物理内存。页表项不存在可能需要申请物理内存并建立映射也可能是非法访问。页表项存在但权限不符合要求触发异常交给操作系统进一步判断。对于写时拷贝来说重点就是第三种情况。父子进程尝试写入共享的只读页面时CPU 会触发异常操作系统确认属于 COW 后再完成物理页面的复制和页表修改。1.3.4 为什么要使用写时拷贝理解了实现过程再回头看它的好处就很简单了。第一提高进程创建效率。如果每次fork()都需要完整复制父进程的物理内存那么内存越大创建子进程所需要的时间就越长。而写时拷贝只在真正发生写入时才复制页面避免了不必要的操作。第二节省物理内存。如果父子进程一直没有修改某些共享页面那么它们就可以一直使用同一份物理内存。只有需要修改时才为对应的页面分配新的物理内存。因此写时拷贝并不是让父子进程永远使用同一份数据而是让它们在不需要修改时共享在需要修改时分离。1.3.5 写时拷贝之后父进程的权限会恢复吗这里再补充一个容易混淆的地方。当子进程发生写时拷贝后它会获得一块新的物理页面并且对应的页表权限可以恢复为可读可写。但是父进程原来的页面是否恢复可写不能简单地说一定要等所有子进程都完成拷贝。操作系统会根据物理页面的共享引用情况和其他映射关系进行判断。当原来的页面不再需要受到共享写保护时内核才会恢复相应的写权限。所以我们记住核心即可写时拷贝的目的是保证父子进程修改数据时互不影响而不是简单地给某个进程恢复权限。1.4 fork 的常见用法理解了fork()我们再看看实际开发中为什么需要创建子进程。常见的使用方式主要有两种。第一种父进程复制自己让父子进程执行不同的任务。例如一个服务器进程需要同时处理多个客户端请求。如果只有一个进程那么它可能需要不断等待和处理请求。而通过fork()父进程可以创建子进程让子进程负责处理客户端请求自己继续等待新的请求。这样就可以把不同任务交给不同进程执行。第二种创建子进程后让子进程执行一个全新的程序。这就涉及我们之前接触过的exec函数族。例如#includestdio.h#includeunistd.hintmain(){pid_t idfork();if(id0){execlp(ls,ls,-l,NULL);}elseif(id0){printf(我是父进程\n);}return0;}这里的过程是父进程调用fork()创建子进程。子进程执行execlp()。execlp()加载新的程序替换子进程原来的用户空间程序映像。子进程开始执行ls程序。注意exec并不是创建一个新进程而是替换当前进程所执行的程序。所以子进程的 PID 不会因为调用exec而改变。这也是为什么 Linux 中经常使用fork() exec()来实现新程序的启动。1.5 fork 调用失败的原因fork()也不是每次都能成功。如果系统无法为新进程准备好所需资源就可能创建失败。常见原因有系统中的进程数量已经达到限制。当前用户能够创建的进程数量超过了限制。系统内存或内核资源不足无法完成进程创建所需的分配。所以在代码中调用fork()时一定要检查返回值pid_t idfork();if(id0){printf(fork failed\n);}elseif(id0){printf(child process\n);}else{printf(parent process\n);}二、进程退出上面我们讲了进程是怎么创建出来的。那么子进程创建出来之后总有执行完毕的一天。当一个进程执行结束时操作系统需要知道它是否正常完成任务以及最终的执行结果是什么。这就涉及进程退出。2.1 进程退出场景2.1.1 进程退出的三种场景一个进程退出大致可以分为以下三种情况。第一种代码运行完毕结果正确。例如intmain(){printf(hello Linux\n);return0;}程序正常执行完毕返回 0。通常我们用 0 表示程序执行成功。第二种代码运行完毕但结果不正确。例如intmain(){// 执行某些操作return1;}程序虽然正常执行到了结束位置但是由于某些操作没有达到预期所以返回了非 0 值。这时程序本身并没有崩溃只是通过退出码告诉外部这次执行存在问题。第三种程序异常终止。例如程序发生非法内存访问或者收到了某些终止信号。这种情况下程序可能还没有执行到main()的结束位置就已经被操作系统终止了。例如在终端运行程序时按下CtrlC通常就会向前台进程发送SIGINT信号。因此我们可以这样理解正常退出程序按照正常流程执行结束。错误退出程序执行结束但通过退出码表示执行失败。异常退出程序因为信号或其他致命错误提前终止。2.1.2 退出码有哪些退出码就是进程结束时留下的一个状态信息。一般情况下0表示执行成功。非0表示执行过程中存在问题。Linux 中还存在一些比较常见的退出码退出码含义0执行成功1通用错误2命令或参数使用错误126找到了命令但无法执行127没有找到命令128n通常表示被编号为 n 的信号终止130常见于 CtrlC 终止143常见于 SIGTERM 终止这些只是常见约定不是所有程序都必须严格按照这个表使用退出码。我们可以先记住一个最重要的结论退出码为 0通常表示成功非 0通常表示失败或异常情况。2.2 进程常见退出方法与核心机制在 C/C 程序中我们经常会看到三种退出方式returnexit()_exit()它们都能让进程结束但底层执行过程并不完全相同。2.2.1 return 机制与返回值我们先来看最熟悉的return。intAdd(intx,inty){returnxy;}intmain(){intretAdd(10,20);return0;}这里Add()执行完毕后通过return将计算结果返回给调用它的函数。那么返回值是怎么传递的呢从底层来看函数调用时会按照调用约定通过寄存器等方式传递参数和返回值。例如在常见的 x86-64 System V ABI 中整数类型的返回值通常通过RAX寄存器传递。可以简单理解为main() 调用 Add() | v Add() 执行计算 | v 将结果放入返回值寄存器 | v 返回 main() | v main() 接收返回值那么main()中的return 0又有什么不同呢普通函数的return是把结果交还给调用者。而main()是程序的入口函数当main()返回时意味着整个程序已经执行完毕。在正常的 C/C 运行环境中main()返回的状态会交给运行时库处理最终完成进程退出。所以return0;表示程序正常结束并将 0 作为退出状态。这里要注意不是所有函数的 return 都会导致进程退出只有 main 函数正常返回时才会结束整个程序。2.2.2 exit() 与 _exit() 的底层区别除了return我们还可以主动调用exit()或_exit()来结束进程。先看一下#includestdlib.hintmain(){printf(hello Linux\n);exit(0);return0;}这里调用exit(0)后进程就会结束后面的return 0不会再执行。那么exit()和_exit()又有什么区别我们先看一下它们的关系用户程序 | v exit(0) | v 执行注册的清理函数 | v 刷新标准 I/O 缓冲区 | v _exit(0) | v 进程终止而如果直接调用_exit()用户程序 | v _exit(0) | v 进程终止它不会替我们刷新 C 标准 I/O 库的缓冲区。所以两者最大的区别就是exit()先进行标准 I/O 清理、刷新缓冲区等操作再结束进程。_exit()直接请求内核终止进程不执行上述用户态标准 I/O 清理操作。我们通过一个例子来看看。#includestdio.h#includestdlib.h#includeunistd.hintmain(){printf(hello);exit(0);}运行结果hello再看#includestdio.h#includeunistd.hintmain(){printf(hello);_exit(0);}运行结果没有输出 hello为什么会这样这里就涉及我们之前学习过的缓冲区。2.2.3 深入理解我们谈论的缓冲区在哪里之前我们学习文件 I/O 时会接触到输入输出缓冲区。这里先提前理解一个重要的知识点C 标准 I/O 库的缓冲区不等于操作系统内核中的缓冲区。例如printf(hello);当我们调用printf()时数据不一定会立即显示在屏幕上。对于终端这种通常采用行缓冲的标准输出输出内容可能暂时保存在 C 标准 I/O 库维护的用户态缓冲区中。因为我们没有输出换行符所以数据可能还没有真正通过底层输出操作交给内核。这时候exit(0);会先刷新标准 I/O 缓冲区再结束进程。所以我们能够看到hello。但是_exit(0);不会执行 C 标准 I/O 库的缓冲区刷新操作。进程直接终止尚未刷新的用户态数据就可能丢失。这也说明了一个问题如果缓冲区中的数据属于 C 标准 I/O 库那么内核直接终止进程时并不会自动替我们调用printf()对应的刷新逻辑。这里要区分两个概念用户态缓冲区由 C 标准 I/O 库维护例如printf()使用的FILE流缓冲区。内核缓冲区操作系统内核在处理 I/O 时使用的缓冲机制。它们不是同一个东西。另外exit()除了刷新标准 I/O 缓冲区还会执行通过atexit()注册的清理函数等操作最后再完成进程终止。2.3 退出码的回收与访问上面我们已经知道进程退出时可以带着一个退出码。那么进程退出之后这个退出码会交给谁答案就是父进程。2.3.1 子进程退出码的保存我们之前学习过父进程可以通过fork()创建子进程。那么子进程执行完毕之后父进程怎么知道它的执行结果呢操作系统会在子进程退出时保存它的退出状态供父进程后续获取。这里还涉及一个重要概念僵尸进程Zombie。当一个进程退出时并不意味着它的所有进程信息都会立即从系统中消失。为了让父进程能够获取子进程的退出状态内核会保留必要的退出信息。此时子进程就可能进入僵尸状态。简单理解僵尸进程就是已经退出但是还没有被父进程回收退出状态的进程。它已经不再执行任务也不再占用正常运行进程所需的用户态内存但内核仍然保留着必要的进程信息。等父进程调用wait()或waitpid()获取子进程的退出状态后内核就可以完成对子进程的回收。所以进程退出和进程被彻底回收并不是完全相同的概念。2.3.2 退出码的访问那么我们平时在 Linux 终端中运行程序之后怎么查看它的退出码呢例如#includestdio.hintmain(){printf(hello Linux\n);return10;}运行./proc然后输入echo$?就可以看到10这里的$?是 Shell 中的一个特殊变量表示上一条前台命令的退出状态。为什么 Bash 能拿到这个值因为我们在终端中执行./proc实际上是 Bash 创建并启动了一个子进程来执行这个程序。程序执行完毕之后Bash 会通过等待机制获取它的退出状态并将对应的状态码保存到$?中。所以整个过程可以理解为Bash父进程 | | fork() v 子进程 proc | | 执行程序 v return 10 | v 进程退出 | v Bash 获取退出状态 | v 保存到 $? | v echo $? | v 10这就解释了为什么我们可以通过echo $?查看程序的执行结果。2.3.3 退出码可以转化为错误信息除了直接查看退出码我们还可以通过错误码获取对应的错误描述。例如C 语言提供了#includestring.hchar*strerror(interrnum);它的作用就是将错误码转换成对应的错误描述字符串。例如#includestdio.h#includestring.hintmain(){for(inti0;i10;i){printf(%d - %s\n,i,strerror(i));}return0;}运行之后就可以看到不同错误码对应的描述信息。这里需要区分退出码表示程序最终的执行状态。errno通常用于保存某些库函数或系统调用失败时对应的错误编号。strerror()负责将错误编号转换成便于阅读的错误描述。例如#includestdio.h#includeerrno.h#includestring.hintmain(){FILE*fpfopen(test.txt,r);if(fpNULL){printf(打开文件失败%s\n,strerror(errno));return1;}fclose(fp);return0;}这里如果文件打开失败fopen()会返回NULL并设置相应的errno。我们就可以通过strerror(errno)将错误编号转换成具体的错误信息。这样程序不仅能够通过退出码告诉外部自己是否执行成功还可以通过错误信息帮助我们定位问题。三、本篇总结到这里我们就把 Linux 中进程的创建与退出串起来了。创建进程时我们使用fork()让操作系统创建一个新的子进程。为了避免直接复制大量内存造成浪费Linux 使用写时拷贝技术让父子进程先共享物理页面等到真正需要修改数据时再进行复制。而进程退出时可以通过return、exit()或_exit()等方式结束执行并留下相应的退出状态。最后父进程通过等待机制获取子进程的退出状态完成对退出信息的回收。我们可以用一张图把整个过程串起来父进程 | v fork() | 创建子进程 | v 父子进程共享内存页面 | 是否修改数据 / \ / \ 否 是 | | | 写时拷贝 | | | 分离物理页面 | | ---------- | v 进程继续执行 | v 进程退出 | v 保存退出状态 | v 父进程 wait() | v 获取退出状态 | v 回收子进程这篇内容中有几个概念需要重点记住fork()创建子进程父进程返回子进程 PID子进程返回 0。写时拷贝的核心是先共享、后分离真正修改数据时才复制物理页面。exec()不创建新进程而是替换当前进程的程序映像。return、exit()和_exit()都可以参与进程退出但处理用户态缓冲区的方式不同。子进程退出后父进程通过wait()或waitpid()获取退出状态并完成回收。至此进程从创建到退出的基本过程就介绍完了。下一篇我们继续学习进程等待看看父进程究竟是如何回收子进程的以及僵尸进程和孤儿进程又是怎么产生的。