🐧 第三章 进程管理
📄 一句话概括

进程结束留下的最后一句话是退出码(0 成功、非 0 失败)。死法有三种(自己退、被信号杀、崩溃),死完还要有人收尸——不 wait 的父进程会留下僵尸。

📝 本节和前面几节的关系

3.1 造、3.2 复制、3.3 换内容,这一节收尾:怎么退场,退完之后资源归谁。这是进程生命周期的最后一环,也是进程管理这一章唯一涉及【回收】的地方。

一、三种结束方式

一、自己体面地退出:exit() 或从 main 返回。退出码交给父进程,0 表示成功,非 0 表示失败(具体含义自定)。 二、被信号杀死:kill -9、Ctrl+C、内存越界(SIGSEGV)。进程没有机会说最后一句话,父进程拿到的是【信号编号】。 三、崩溃:严格说是第二种的特例,内核发现它干了不该干的事(段错误、除零、非法指令),于是给它发信号。可能留下核心转储。

exit() 与 _exit() 差一步:前者会先刷 stdio 缓冲、跑 atexit 回调、再进内核;后者直接进内核。fork 出来的子进程通常该用 _exit()——否则它会替父进程把缓冲区刷一遍,输出就重复了(3.2 那个坑)。

二、退出码:进程留下的最后一句话

约定只有一条,但极其重要:0 是成功,非 0 是失败。 1、2、42 之间没有标准含义——它们的意思只有写程序的人知道,所以正经程序会把退出码写进文档。 shell 靠它决定下一步:cmd && next(成功才继续)、cmd || fallback、$? 取上一个命令的退出码。 管道的退出码有个坑:a | b 的退出码默认取最后一个命令的,前面失败了也看不出来——所以 bash 有 set -o pipefail。

三、收尸:僵尸与孤儿

进程死了,但它的档案不能马上删:父进程可能还想读它的退出码。于是在【已终止但还没被读】这个状态上停一下——这就是僵尸(zombie)。 僵尸只占一条进程表记录,不占内存,所以一堆僵尸通常不致命;但进程号是有限的,攒多了就起不了新进程。 收尸的动作叫 wait:父进程调用它,读走退出码,内核才把记录删掉。 孤儿是另一回事:父进程先死了,子进程还活着——它被过继给 1 号,由 1 号负责收尸。所以孤儿不可怕,僵尸才要处理。 为什么守护进程要【双 fork】:第一次 fork 之后让父进程立刻退出,孙进程就成了孤儿被 1 号收养——这样它永远不会有僵尸父进程,也不会跟着终端一起死。

四、回收的账要算清

进程结束时内核要收回的东西:地址空间(所有页)、打开的文件(引用计数减一,归零才真关)、内存映射、信号处理、定时器、子进程关系、进程表记录。 这份清单解释了为什么【进程泄漏】比【内存泄漏】更严重:内存泄漏只吃内存,进程泄漏同时吃进程号、文件描述符、内存、CPU 时间片。 工程上的三条实践: 1. 一定要 wait(或用 SIGCHLD 设为 SIG_IGN 让内核自动收)。 2. 给子进程设超时——不设的话,一个卡住的子进程会让父进程永远等。 3. 退出码要检查——不检查等于放弃了子进程唯一的反馈渠道。

五、动手 Demo:结束进程——退出码是它留下的最后一句话

下面让子进程用三个不同的退出码结束,看父进程拿到什么。这就是 shell 里 $? 的来源。

for code in (0, 1, 42):
    pr = subprocess.Popen([sys.executable, "-c",
                          "import sys; sys.exit(%d)" % code])
    rc = pr.wait()          # 父进程收尸:读走退出码
    print(code, "->", rc)

实跑输出:

--------------------------------------------------------------------
结束进程——退出码是它留下的最后一句话
--------------------------------------------------------------------
① 进程怎么结束,最后留下什么

  程序里写的退出码     父进程看到的     含义
  -------------------------------------------------------
  0                    0                成功
  1                    1                失败(含义自定)
  42                   42               失败(含义自定)

      **约定只有一条:0 是成功,非 0 是失败。**
      具体 1 和 42 有什么差别,**只有写这个程序的人知道**——
      shell 的 `$?` 拿到的就是它,`&&` 与 `||` 靠它决定下一步。

② 结束的三种方式与身后事

  方式                谁发起               留下的东西
  ----------------------------------------------------------
  正常返回 / exit     自己                 退出码
  被信号杀死          别人或内核           信号编号
  崩溃                内核(收到异常)     核心转储(可能)

  **进程死了不等于清干净了**:内核要把它占的内存、打开的文件、
  信号处理、子进程关系全部收回去。**这一步做完,资源才真的回来了。**
  **父进程没去收(没 wait),就会留下一个僵尸**——只剩一条记录,等着被读退出码。

  📋 真 Linux 上观察:`./程序 &` 然后 `ps -o stat,comm -p $!` 看状态;
  僵尸的 STAT 是 `Z`。`cat /proc/<pid>/status` 里 `State:` 那行也会写。

三个退出码原样传回来了——exit(n) 里写什么,父进程就拿到什么。 约定只有一条:0 是成功。 1 与 42 的差别只有写程序的人知道,所以正经程序必须把退出码写进文档,否则调用方只能猜。 wait() 这一步就是收尸。不调它,子进程的记录会挂在那儿——Linux 上叫僵尸,ps 里状态显示 Z。

📋 在真 Linux 上看僵尸:写个小程序 fork 之后父进程 sleep(60) 不 wait,另开一个终端 ps -eo pid,ppid,stat,comm | grep Z。你会看到那条记录一直挂着,直到父进程退出或被 kill。

30 秒实验

  1. false; echo $? 与 true; echo $?——1 与 0,就是这两个命令的全部差别。
  2. ls /nope; echo $? 看失败时 shell 拿到什么;再 ls /nope || echo 兜底。
  3. (exit 42); echo $?——子 shell 的退出码会传出来,管道与括号都靠这个机制。

常见误区

  1. 【退出码只是一个数字,随便写】。它是进程唯一的结构化反馈。0 与非 0 的区别被 shell、CI、监控全都在用。
  2. 【僵尸会占内存】。只占一条进程表记录,不占内存。真正的风险是进程号耗尽(以及它说明父进程没在 wait)。
  3. 【父进程死了子进程会变成僵尸】。那是孤儿,会被 1 号收养并正常收尸。僵尸的成因是父进程活着却不 wait。
自测三问(点开对答案) 1. 进程的三种结束方式各留下什么? —— 自己退出→退出码;被信号杀→信号编号;崩溃→信号(可能还有核心转储)。 2. exit 与 _exit 的差别?fork 出来的子进程该用哪个? —— exit 先刷 stdio 缓冲、跑 atexit 回调;_exit 直接进内核。**子进程该用 _exit**,否则会替父进程刷一遍缓冲,造成输出重复。 3. 僵尸与孤儿的区别?为什么守护进程要双 fork? —— 僵尸=已终止但父进程没 wait;孤儿=父进程先死、被 1 号收养。**双 fork 让孙进程成为孤儿**,从而不会被僵尸父进程拖住,也不跟终端一起死。

相关笔记