Skip to content

第 19 章 进程管理——程序卡了怎么办

Part 4 · 系统管理


引子

编译卡住了。光标不动了。风扇开始狂转,电脑热得像要起飞。

在 Windows 里,你会按 Ctrl+Alt+Delete,打开任务管理器,找到那个吃 CPU 的进程,点击「结束任务」。完事。

在 Linux 终端里呢?没有任务管理器窗口给你点。你得自己查是谁在吃资源,然后自己决定怎么处置它。而且「处置」的方式不止一种——kill 命令发送的是信号(signal),不是子弹。SIGTERM 是一句「请你自行退出」,进程收到后可以收拾东西、保存状态、体面地离开;SIGKILL 则是「你没有选择权」,内核直接把进程从内存里抹掉,连遗言都不让留。

这两者之间有一道分界线。理解这条线,就理解了 Linux 进程通信的基本原语。这一章我们就要找到它。


背景与动机

如果你之前用过 Windows,对「进程」这个词大概有个模糊的感觉——就是「正在运行的程序」。Linux 里也是这样,只不过你看到的不是图形界面的任务管理器,而是一堆命令和数字。

为什么要学这个?因为嵌入式开发里,进程管理是一个绕不开的技能:

  • 交叉编译内核可能跑几个小时,你需要确认它还活着、占了多少内存
  • 构建系统有时候会卡死,你需要找到卡住的进程并正确终止它
  • 开发板调试时,你可能需要杀掉一个占着串口的进程,或者重启一个挂掉的服务
  • 后面学 systemd 服务管理(下一章)时,你会发现服务的启动、停止本质上都是在给进程发信号

换句话说——进程是 Linux 里「正在发生的事」的基本单位。不知道怎么查看和管理进程,就像在一个公司里不知道谁在干什么、怎么联系他们。


概念层

进程是什么

一个程序躺在磁盘上的时候,它只是一堆字节。当你运行它,内核把这个程序加载到内存、分配资源、开始执行——这个运行中的实例就叫进程(process)。

每个进程有一个唯一的编号叫 PID(Process ID)。内核用 PID 来追踪和管理所有进程。进程还有一个 PPID(Parent Process ID),指向创建它的父进程。这就形成了一棵进程树:系统启动时内核创建的第一个进程是所有进程的祖先(在 Ubuntu 上是 systemd,PID 为 1),其他进程都是从它分叉出来的。

这个「树」结构很重要。不只是因为好看——当你杀掉一个父进程,它的子进程就变成了「孤儿」,需要被其他进程收养(通常是 systemd)。而我们后面会看到,如果子进程死了但父进程没有善后,就会产生「僵尸」。

信号:进程间通信的基本原语

这是本章真正要讲的核心。

Linux 里的进程不是孤岛——它们之间需要通信。信号(signal)就是最基本的一种通信方式。你可以把信号理解成公司内部的通知单

人力资源部给某个员工发了一封邮件。邮件有不同的类型:「请来会议室一趟」「你的工位已调整」「请在周五前完成交接」。员工收到邮件后,根据邮件类型做出不同的反应。

这就是信号机制的映射:内核(HR)向进程(员工)发送通知单(信号),进程根据信号类型做出响应。

每种信号都有一个编号和一个名字。你可以用 kill -l 查看系统支持的所有信号:

bash
$ kill -l
 1) SIGHUP       2) SIGINT       3) SIGQUIT      4) SIGILL       5) SIGTRAP
 6) SIGABRT      7) SIGBUS       8) SIGFPE       9) SIGKILL     10) SIGUSR1
11) SIGSEGV     12) SIGUSR2     13) SIGPIPE     14) SIGALRM     15) SIGTERM
16) SIGSTKFLT   17) SIGCHLD     18) SIGCONT     19) SIGSTOP     20) SIGTSTP
21) SIGTTIN     22) SIGTTOU     23) SIGURG      24) SIGXCPU     25) SIGXFSZ
26) SIGVTALRM   27) SIGPROF     28) SIGWINCH    29) SIGIO       30) SIGPWR
31) SIGSYS

这里面最需要记住的是这几个:

信号编号含义能否被捕获
SIGTERM15终止信号(请求退出)
SIGKILL9强制终止不能
SIGINT2中断(Ctrl+C)
SIGSTOP19暂停进程不能
SIGCONT18继续已暂停的进程
SIGHUP1挂起(终端关闭时发送)

我们重点关注 SIGTERMSIGKILL 的区别,因为这是日常操作中最常用的两个信号,也是理解信号机制的钥匙。

SIGTERM(15)——这是 kill 命令默认发送的信号。它是一个请求:「请你退出」。进程收到这个信号后,可以执行自己的清理逻辑——关闭打开的文件、释放资源、保存状态——然后自行终止。进程甚至可以选择忽略这个信号(虽然通常不会)。

SIGKILL(9)——这是一个命令:「立刻终止」。进程没有机会做任何清理,甚至根本看不到这个信号——内核直接把进程从运行队列中移除,释放它占用的资源。进程无法捕获、阻塞或忽略 SIGKILL。这是操作系统的最终手段。

但「通知单」这个比喻有一个地方是错的。在公司里,员工可以拒收邮件、假装没看到、甚至跟 HR 拍桌子。但在 Linux 里,SIGKILL 是内核直接介入的结果——进程甚至不知道发生了什么,它就已经不存在了。这不是「通知」,这是内核在行使最高权力。而且这个权力是有限制的:只有两个信号(SIGKILL 和 SIGSTOP)拥有这种特权,所有其他信号进程都可以选择处理方式。

回到那张「通知单」。现在你应该能看清了:SIGTERM 就是一封措辞礼貌的邮件——员工收到后可以收拾桌面、交接工作、关灯走人;而 SIGKILL 根本不是邮件,是保安直接把你从工位上拖走,桌上的咖啡还在冒热气。这就是引子里说的那道「分界线」——SIGTERM 背后是对进程自主权的尊重,SIGKILL 背后是内核的绝对权威。理解了这条线,你就理解了为什么 kill -9 应该是最后手段,而不是第一反应。

进程的状态

进程不是一直在跑的。一个进程可能处于以下几种状态:

状态字母标记含义
RunningR正在运行或在运行队列中等待
SleepingS等待某个事件(I/O、信号等)
Disk SleepD不可中断的睡眠(通常在等磁盘 I/O)
StoppedT被暂停(收到 SIGSTOP 或被调试器拦截)
ZombieZ已终止但父进程还没有读取它的退出状态

其中 Zombie(僵尸) 是一个值得单独说说的状态。子进程退出后,它的进程描述符还留在内核里,直到父进程调用 wait() 读取退出码。如果父进程一直不读,子进程就变成了僵尸——它已经死了,但还在进程表里占着一个位置。

僵尸进程不能被 kill -9 杀掉——因为它已经死了。你没法杀死一个已经死了的东西。正确的清理方式要么是让父进程正确调用 wait(),要么直接杀掉父进程,让 systemd(PID 1)接管并自动清理。

这听起来有点诡异,但在实际开发中你确实会偶尔在 ps 的输出里看到状态为 Z 的进程。到时候别慌——现在你知道它的原理了。


实践层

4.1 查看进程:谁在跑?

第一步永远是先看清局面。Linux 提供了好几种查看进程的方式,我们从最基础的开始。

ps——快照式查看

ps(process status)命令给出的是当前时刻的进程快照,就像拍了一张照片。最常用的两个组合:

bash
# BSD 风格——显示所有进程的详细信息
$ ps aux
# 预期输出(截取前几行)
USER         PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
root           1  0.0  0.1 169344 13296 ?        Ss   10:00   0:01 /sbin/init
root           2  0.0  0.0      0     0 ?        S    10:00   0:00 [kthreadd]
root           3  0.0  0.0      0     0 ?        I<   10:00   0:00 [rcu_gp]
...
charlie     2345  0.0  0.1  21468  5120 pts/0    Ss   10:05   0:00 -bash
charlie     2401  0.0  0.0  37368  3424 pts/0    R+   10:10   0:00 ps aux

各列含义:

含义
USER进程所有者(还记得 Ch15 的用户概念吗)
PID进程 ID
%CPUCPU 使用率
%MEM内存使用率
VSZ虚拟内存大小(KB)
RSS实际使用的物理内存(KB)
TTY终端(? 表示不需要终端)
STAT进程状态(R/S/D/T/Z)
COMMAND完整命令
bash
# System V 风格——功能类似,列名不同
$ ps -ef
# 预期输出(截取前几行)
UID          PID  PPID  C STIME TTY          TIME CMD
root           1     0  0 10:00 ?        00:00:01 /sbin/init
root           2     0  0 10:00 ?        00:00:00 [kthreadd]
...

ps auxps -ef 的区别主要是历史流派不同(BSD vs System V),功能上差不多。ps aux 的输出更直观一些,日常用得更多。ps -ef 多了一列 PPID(父进程 ID),当你需要追踪进程的父子关系时用这个更方便。

一个实用的技巧——结合 grep 过滤特定进程:

bash
# 查找所有和 ssh 相关的进程
$ ps aux | grep ssh
# 预期输出
root        1024  0.0  0.2  15420  7680 ?        Ss   10:00   0:00 sshd: /usr/sbin/sshd -D
charlie     2501  0.0  0.1  15420  5120 ?        S    10:15   0:00 sshd: charlie@pts/0
charlie     2600  0.0  0.0   6432  2048 pts/0    S+   10:20   0:00 grep --color=auto ssh

注意最后一行——grep 自己也会出现在结果里。如果觉得碍眼,可以用 grep -v grep 把它过滤掉:

bash
$ ps aux | grep ssh | grep -v grep

pstree——以树形结构查看

如果 ps aux 是一张扁平的列表,那 pstree 就是一棵树。它能直观地展示进程之间的父子关系:

bash
$ pstree
# 预期输出(简化)
systemd─┬─cron
        ├─dbus-daemon
        ├─sshd───sshd───bash───pstree
        ├─systemd─┬─(sd-pam)
         └─session-1.scope─┬─bash
                          └─vim
        └─ubuntu-advantage-timer

你可以清楚地看到:systemd(PID 1)是根节点,sshd 是它的子进程,bashsshd 的子进程,而我们正在运行的 pstree 又是 bash 的子进程。

-p 参数可以同时显示 PID:

bash
$ pstree -p | head -20
# 预期输出
systemd(1)─┬─cron(890)
           ├─dbus-daemon(920)
           ├─sshd(1024)───sshd(2501)───bash(2510)───pstree(2600)
           ...

4.2 实时监控:top 和 htop

ps 是快照——拍完就完了。如果你想持续观察进程的状态变化(比如监控一个编译任务占用了多少资源),需要实时监控工具。

top——系统自带的实时监控

bash
$ top
# 预期输出(简化)
top - 10:30:00 up 30 min,  1 user,  load average: 0.52, 0.28, 0.15
Tasks: 120 total,   1 running, 119 sleeping,   0 stopped,   0 zombie
%Cpu(s):  5.2 us,  1.1 sy,  0.0 ni, 93.5 id,  0.0 wa,  0.0 hi,  0.2 si
MiB Mem :   7823.2 total,    5120.0 free,    1500.0 used,    1203.2 buff/cache
MiB Swap:   2048.0 total,    2048.0 free,      0.0 used.    5823.2 avail Mem

    PID USER      PR  NI    VIRT    RES    SHR S  %CPU  %MEM     TIME+ COMMAND
   2501 charlie   20   0  154200  51200  10240 S   5.2   0.6   0:01.23 sshd
      1 root      20   0  169344  13296   8192 S   0.0   0.2   0:01.00 systemd
   ...

上半部分是系统概览:CPU 使用率、内存使用率、负载均值。下半部分是进程列表,默认按 CPU 使用率排序。

top 运行时有几个非常有用的交互快捷键:

快捷键功能
M按内存使用率排序
P按 CPU 使用率排序(默认)
k杀死一个进程(会提示你输入 PID)
q退出 top

这些快捷键是大小写敏感的——M 是 Shift+m,P 是 Shift+p。按 k 之后 top 会问你要杀哪个 PID、发什么信号,默认是 15(SIGTERM)。

htop——更好看的实时监控

top 功能够用但界面比较原始。htop 是一个增强版的替代品,支持鼠标操作、颜色高亮、横向滚动、树形视图,体验好很多。

htop 不是 Ubuntu 默认安装的。需要手动安装:

bash
$ sudo apt install htop
bash
$ htop
# 交互式界面,支持鼠标点击和 F1-F10 功能键
# F9  : 发送信号(包括 SIGTERM 和 SIGKILL)
# F5  : 切换树形视图
# F6  : 选择排序列
# F10 : 退出

日常开发中,htoptop 用起来舒服得多。但在某些最小化安装的服务器上可能没有 htop,所以 top 的基本操作也要会。

4.3 发送信号:kill、killall 和 pkill

现在到了关键部分——怎么给进程发信号。

kill——按 PID 发信号

kill 是最基本的命令。它接受一个 PID,向该进程发送指定的信号:

bash
# 默认发送 SIGTERM(15)——礼貌地请求退出
$ kill 2501

# 等价写法,显式指定信号编号
$ kill -15 2501

# 用信号名字也行
$ kill -TERM 2501

如果进程不响应 SIGTERM(比如它真的卡死了),那就上 SIGKILL:

bash
# 强制终止——没有商量余地
$ kill -9 2501

# 等价写法
$ kill -KILL 2501

⚠️ 踩坑预警kill -9 应该是最后手段,不是第一反应。SIGKILL 不给进程任何清理机会——如果进程正在写文件,数据可能丢失;如果进程持有锁,锁不会被释放。先试 kill(SIGTERM),等几秒,没有反应再上 kill -9

还有一个容易被忽略的细节:普通用户只能杀自己的进程。想杀别人的进程,得加 sudo

bash
# 杀自己的进程——不需要 sudo
$ kill 2501

# 杀 root 或其他用户的进程——需要 sudo
$ sudo kill 1024

这就是 Ch15、Ch16 讲的权限模型在进程管理中的体现——每个进程属于一个用户,你只能操作自己的东西,除非你有 root 权限。

killall——按名字发信号

kill 需要知道 PID,但你有时候不知道(或者懒得查)。killall 可以直接按进程名来发信号:

bash
# 杀掉所有名为 "nginx" 的进程
$ killall nginx

# 强制杀掉
$ killall -9 nginx

有一个容易踩的坑:在 Ubuntu 上,killall 默认是大小写敏感的。killall nginxkillall Nginx 不是一回事。如果你不确定进程名的确切大小写,可以加 -I--ignore-case)参数:

bash
# 忽略大小写
$ killall -I Nginx

⚠️ 踩坑预警killall 会杀掉所有匹配名字的进程。如果你在一个生产服务器上运行了 4 个 nginx worker,killall nginx 会把它们全部杀掉。不确定的话,先用 ps aux | grep nginx 看看有几个。

pkill——按模式匹配发信号

pkillkillall 的更灵活版本。它支持正则表达式匹配,而不是精确匹配:

bash
# 杀掉所有名字里包含 "ssh" 的进程
$ pkill ssh

# 只杀 charlie 用户的 ssh 相关进程
$ pkill -u charlie ssh

# 杀掉所有在 pts/0 终端上运行的进程
$ pkill -t pts/0

三个命令的对比:

命令匹配方式精确度适用场景
kill按 PID精确到一个进程知道确切的 PID
killall按进程名精确匹配同名全部命中想杀某个程序的所有实例
pkill按正则模式匹配模式匹配,更灵活按模式、用户、终端等条件过滤

补充一个你可能已经发现的命令——pgrep。它和 pkill 是一对,pgrep 只查找不杀,pkill 查到就杀:

bash
# 查找所有 ssh 相关进程的 PID(不发送信号)
$ pgrep -l ssh
# 预期输出
1024 sshd
2501 sshd

4.4 实战:程序卡了怎么办

学了一堆命令,现在来走一遍完整流程。假设你正在编译一个项目,终端卡住了:

第一步:确认谁在吃资源。

bash
# 另开一个终端(或在同一个终端按 Ctrl+Z 暂停当前任务后用 bg/fg 管理)
$ top
# 按 P 看 CPU 占用最高的进程,或按 M 看内存占用最高的

第二步:找到目标进程的 PID。

bash
# 退出 top 后,用 ps 精确查找
$ ps aux | grep make
# 预期输出
charlie     3001  99.0  2.5  51200  20480 pts/0    R+   10:30   2:15 make -j4

第三步:先礼后兵。

bash
# 先发 SIGTERM——礼貌地请求退出
$ kill 3001

等几秒钟。如果进程正常退出,你会看到终端恢复响应。

第四步:如果不响应,上 SIGKILL。

bash
$ kill -9 3001
# 预期输出
[1]    3001 killed     make -j4

进程消失了,终端恢复。但请注意——被 SIGKILL 终止的进程不会做任何清理。如果 make 正在写中间文件,那些文件可能是不完整的。下次重新编译前,最好跑一下 make clean

另外还有一个你可能已经知道但值得明确说的快捷键:Ctrl+C。它在终端里发送的是 SIGINT(信号 2),效果类似 SIGTERM——请求进程终止。大多数命令行程序都会响应 Ctrl+C,所以如果你的终端还能操作,先试这个。


练习题

走到这里,信号机制和进程管理的基本操作应该清楚了——或者你以为清楚了。下面几道题难度递进,建议先不看提示独立想,卡住了再翻。


练习 19.1 ⭐(理解)

kill 命令的名字叫「kill」,但它的默认行为并不是「杀掉」进程。它默认发送的是什么信号?这个信号和 SIGKILL 有什么本质区别?


练习 19.2 ⭐⭐(应用)

假设你发现系统上有一个名为 rogue_app 的进程占满了 CPU,你尝试了 kill 5000(PID 为 5000),等了 10 秒进程还在。接下来你应该怎么做?请写出完整的命令序列,并解释为什么不能直接跳到最后一步。


练习 19.3 ⭐⭐⭐(思考)

如果一个进程已经变成了僵尸(状态为 Z),kill -9 能杀掉它吗?为什么?正确的处理方式是什么?试从进程状态转换的角度解释。

提示:僵尸进程的「Z」代表什么?它还需要被「杀」吗?


参考答案

练习 19.1kill 默认发送 SIGTERM(信号 15)。SIGTERM 是一个请求,进程可以选择捕获它并执行清理逻辑后自行退出,甚至可以选择忽略。SIGKILL(信号 9)则不可捕获、不可忽略——内核直接终止进程,进程没有任何发言权。

练习 19.2:第一步 kill 5000 发送了 SIGTERM,进程可能正在做清理,再等一会。如果始终不退出,再发 SIGKILL:kill -9 5000。不能直接跳到 kill -9,因为 SIGKILL 不给进程任何清理机会——可能导致临时文件残留、锁未释放、数据丢失。

练习 19.3kill -9 不能杀掉僵尸进程。僵尸进程已经退出了(它的代码已经不再执行),它只是在进程表中保留了一个条目,等待父进程读取退出状态。你不能杀死一个已经死了的东西。正确做法:(1)让父进程调用 wait() 读取退出状态;(2)或者杀掉父进程,让 systemd(PID 1)接管并自动清理。


本章回响

这一章我们做的事情,表面上是学几个命令——ps 看进程、top 监控资源、kill 终止程序。但真正重要的是底下的那层认知:信号是进程间通信的基本原语,而 kill 只不过是「发送一个信号」的接口。

理解了信号,很多行为就不再令人困惑了。为什么 kill 默认不发 SIGKILL?因为操作系统设计者的哲学是:先给进程一个体面退出的机会。为什么 Ctrl+C 能终止前台进程?因为它发送了 SIGINT——也是一种信号。为什么僵尸进程杀不掉?因为它已经不在运行了,信号对它毫无意义。这些看似无关的现象,背后都是同一套机制。

还记得引子里那道分界线吗——SIGTERM 和 SIGKILL 之间那道「人性的分界线」?现在你应该看清楚了:SIGTERM 是对进程自主权的尊重,SIGKILL 是内核的最终裁决权。日常操作中,永远先试 SIGTERM。把 SIGKILL 留给那些真正已经失控、无法响应任何请求的进程。这不是形式主义——这是保护你自己(和你的数据)的习惯。

还有一个问题我们今天没有展开:进程从启动到退出,它的整个生命周期由谁管理?谁来保证一个服务挂了之后能自动重启?谁来决定系统启动时哪些进程应该先跑?这些问题的答案是 systemd——我们下一章的主题。今天学的信号机制会在那里再次出现:systemctl stop 的底层实现,就是给服务进程发送 SIGTERM。


← 上一章:磁盘管理下一章:服务管理——systemd →

Built with VitePress