
Linux(七):进程与资源管理 —— 从 top 到 cgroups
进程是怎么诞生和死亡的:fork/exec 模型、ps 和 top 在 STAT 列里打印的状态机、真正约束一台服务器的四个资源轴(CPU/内存/磁盘 I/O/网络)、那几个一定要记住的信号,以及把这一切组合成容器的 cgroup 与 namespace 原语。
Linux 运维的核心能力在于快速定位问题的根源,而不是记住更多命令。例如,当遇到网站变慢、接口超时或服务器无响应等模糊现象时,关键是迅速找到资源瓶颈:CPU 是否被占满?内存是被缓存占用(正常现象)还是被某个失控的进程消耗(需重点关注)?磁盘队列是否严重积压?某个 socket 是否出现泄漏?一旦定位到问题根源,选用合适的工具就顺理成章。

本文将按照这个思路,带你完整梳理一遍 Linux 系统中进程与资源管理的全貌。首先,我们会从头讲起:一个进程是如何诞生的(fork() + exec()),内核如何驱动它在不同状态间切换,以及限制它行为的四种核心资源维度。接着,我们会把常用的工具链——top / htop / ps / pstree / lsof / ss / iostat——重新梳理,避免简单罗列命令,而是以一种分层的方式,帮助你从不同角度观察同一个系统。最后,我们会探讨你能对进程做哪些操作:发送信号、放到后台运行、调整优先级(nice/renice),以及支撑现代容器技术的底层内核机制——cgroup 和 namespace。这些内容不仅实用,还能让你更深刻地理解 Linux 系统的本质。
进程、程序与线程——为什么区分它们很重要#

在日常对话中,这三个术语经常被混用,但在内核世界里,它们有着截然不同的含义。
| 术语 | 含义 | 标识符 |
|---|---|---|
| 程序 | 磁盘上的静态文件(例如 /usr/bin/vim),包含指令和数据布局 | inode + 路径 |
| 进程 | 程序的一次运行实例,拥有独立的地址空间、文件描述符和 PID | PID 和父进程 PPID |
| 线程 | 进程内部的一个执行流,与其他线程共享地址空间 | TID (内核中的 task_struct) |
进程是资源分配的基本单位——内存页、文件描述符、打开的套接字等都归属于进程。而线程则是调度的基本单位,它与同一进程内的其他线程共享这些资源。在 Linux 中,这种区别非常微妙:无论是进程还是线程,在内核看来都是 task_struct,唯一的不同在于创建时共享了哪些资源(由 clone() 的标志位决定)。这就是为什么 ps -eLf 会显示相同 PID 但不同 LWP 的线程列表,也是为什么一个多线程的 JVM 在系统中表现为一个进程,背后却有数百个 task_struct。
以下是几个关键点:
- 每个进程都有一个父进程。 PPID 为 0 是内核保留的特殊值, PPID 为 1 则是
systemd(或init),这是内核启动的第一个用户态进程。无论从任一进程向上追溯父进程链,最终都会抵达 PPID 为 1 的进程。 - 进程之间默认是隔离的:它们的地址空间互不重叠,进程 A 打开文件不会影响进程 B。若需共享数据,必须通过显式的 IPC 机制(如管道、套接字、共享内存或信号)。
- 进程是动态变化的:它们不断被创建、调度、阻塞、唤醒和销毁。一台健康的服务器每分钟可能会产生数千个短生命周期的进程,这完全正常。
进程怎么诞生: fork() 和 exec()#

在 Linux 中,创建新进程只有一种方式:fork。fork()(或者它的现代版本 clone())会复制调用进程,生成一个几乎完全相同的副本。调用完成后,父进程和子进程会从同一行代码继续执行,唯一的区别在于返回值——子进程返回 0,而父进程返回子进程的 PID。它们共享打开的文件描述符、相同的代码、堆内容,甚至最初连内存页都是共享的,这得益于写时复制(Copy-on-Write)机制。
不过,仅仅靠 fork() 只能复制出一个一模一样的程序。真正的关键在于接下来的一步:exec。在刚刚 fork 出来的子进程中,调用 execve("/bin/ls", argv, envp) 会让内核用另一个二进制程序替换当前进程的映像,同时保留原来的 PID、 PPID,以及默认情况下打开的文件描述符。所以,当你在终端输入 ls 时,背后实际发生的事情是这样的:
- shell 调用
fork(),这时系统中出现了两个 shell。 - 子进程调用
execve("/bin/ls", ...),将自身的程序映像替换为ls。 - 父进程(也就是原来的 shell)调用
waitpid()阻塞自己,等待子进程退出,并将退出状态码存入$?。
这种“先创建进程,再加载程序”的分离设计,正是 shell 管道、重定向、环境变量修改以及 setuid 权限降级等功能得以实现的基础。所有这些操作都发生在 fork 之后、 exec 之前的子进程中,此时新程序尚未启动。
这种设计带来了两个常见的后果,经常会在实际生产环境中遇到:
- 文件描述符的继承问题: 子进程会继承父进程中所有未标记为
O_CLOEXEC的文件描述符。这就是为什么cmd > log.txt能正常工作: shell 在子进程中、 exec 之前就已经将目标文件以文件描述符 1 打开。这也是为什么某些泄漏的守护进程会导致已被删除的文件仍然被占用(稍后会详细讨论)。 - PID 1 的特殊性: 如果一个进程退出时仍有子进程存在,这些子进程会被重新挂载到 PID 1 下,由它负责调用
wait()回收资源。容器中的 PID 1 也承担同样的职责——如果忘记处理,就会出现那个经典的“Docker 容器里僵尸进程堆积”的问题。
进程的状态机#
ps 和 top 命令会为每个进程显示一个单字母的 STAT 列,这个字母表示内核当前将任务置于的状态:
| 编码 | 状态 | 含义 |
|---|---|---|
R | 运行中 | 当前正在某个 CPU 上执行,或者在运行队列中等待 CPU 资源 |
S | 可中断睡眠 | 等待某个事件(如从 socket 读取数据或定时器触发),可以被信号唤醒 |
D | 不可中断睡眠 | 等待内核处理某些无法中断的操作,通常是磁盘 I/O——即使 kill -9 也无能为力 |
T | 暂停 | 被 SIGSTOP 或 SIGTSTP 信号暂停(比如你按下了 Ctrl+Z) |
Z | 僵尸进程 | 进程已退出,但父进程尚未调用 wait() 回收其资源 |
X | 死亡 | 进程销毁过程中的短暂状态,几乎不会看到 |
这些状态不仅仅是冷知识。如果一台服务器上有 200 个进程处于 D 状态,那问题不是“CPU 被打满了”,而是它们卡在了存储 I/O 上——无论你怎么优化 CPU 都无济于事。如果 Z 状态的进程数量不断增加,说明父进程存在 bug,忘记调用 wait() 来回收子进程。而如果 T 状态的进程一直存在,则可能是某个脚本或用户发送了 SIGSTOP 信号后,没有及时发送 SIGCONT 来恢复进程。
top 的顶部信息,例如 Tasks: 150 total, 2 running, 148 sleeping, 0 stopped, 0 zombie,是你的第一道健康检查线。如果 stopped 或 zombie 的值不是 0,那就需要进一步排查问题所在。
四个核心资源维度#
在陷入各种工具之前,先理清思路,建立正确的心智模型。生产环境中的问题,几乎都可以归结到以下四个关键维度之一:
- CPU——每秒能够完成多少计算任务;
- 内存——进程能容纳多大的工作集而不触发磁盘交换;
- 磁盘 I/O——数据在持久化存储中读写的快慢;
- 网络——数据进出服务器的速度。
任何一个维度出现瓶颈,都会拖累依赖它的所有环节。关键在于找出具体是哪个维度出了问题——而这正是监控工具的核心价值所在。
CPU#
通常你会关心两个问题:
- 这台机器到底有多少核?
nproc和lscpu可以告诉你答案。 - 当前负载如何?
uptime会显示三个负载值: 1 分钟、 5 分钟和 15 分钟的平均负载。
需要注意的是,负载平均值并不等于 CPU 使用率。它统计的是处于 运行中(running)或不可中断状态(uninterruptible) 的进程数(R + D)。例如,一台 4 核机器,如果负载平均值是 4.0,但 CPU 仍然有 95% 的空闲时间,那大概率是磁盘 I/O 出了问题:任务卡在 D 状态等待 I/O,而不是争抢 CPU 资源。通过对比负载值与核心数可以快速判断:
< 核心数表示轻松;≈ 核心数表示满载;>> 核心数则意味着过载。
| |
内存#
查看内存使用情况的标准命令是 free -h。但千万别用 Windows 任务管理器的思维去解读它:
| |
新手看到 free: 1.0Gi 往往会紧张。别急: Linux 会主动利用闲置内存作为 buffer/cache 来加速 I/O 操作——这里的 11.5 GiB 是随时可回收的。真正值得关注的是 available:它反映了内核估算的可用内存,即当进程需要时,系统能够腾出多少。
缓存分为两种类型:
- Buffer 是写操作的暂存区。当你写入文件时,数据会先进入页缓存,随后批量刷入磁盘——这就是为什么小规模写入速度很快。
- Cache 是读操作的缓存区:从磁盘读取的页会被内核保留下来,以便下次再读时直接命中——而这种情况非常常见。
只有当以下三种情况同时发生时,才能判定内存真的不足:
available接近于零;swap used持续增长,而不仅仅是非零;- 内核开始记录 OOM (Out of Memory)杀进程的日志(
dmesg | grep -i 'killed process')。
磁盘与网络#
磁盘容量可以通过 df -h 查看(按文件系统)或者 du -sh *(按目录)。实时 I/O 监控则依赖 iostat -x 1 和 iotop;其中最关键的两个指标是:
%util:设备的繁忙程度;await:请求在队列中等待的时间。
如果某个设备的 %util 达到 100%,且 await 高达几十毫秒,那它就是真正的性能瓶颈。
至于网络:
- 使用
ss -tulnp查看谁在监听端口; - 使用
ip -s link查看接口的流量统计和丢包情况; - 使用
iftop或nload实时监控带宽。
如果想知道“谁占用了 80 端口”,最快的方法是运行 lsof -i :80 或者 ss -tulnp | grep :80。
监控工具链#
top——首先要用的工具#

top 是系统资源的全景视图,每个区域都有特定的信息:
- 顶部信息:运行时间、登录用户数、平均负载。记得把负载和
nproc对比来看。 - 任务统计:总任务数 / 运行中 / 睡眠中 / 停止 / 僵尸进程。最后两项应该为 0。
%Cpu(s):分为用户态(us)、系统态(sy)、低优先级(ni)、空闲(id)、 I/O 等待(wa)、硬中断/软中断(hi/si)以及被偷走的时间(st)。如果wa高,说明磁盘性能可能有问题;在虚拟机中,st高意味着宿主机把你的 CPU 时间分配给了其他租户——这种情况在云环境中很常见,尤其是邻居噪音问题。- 内存与交换分区:关注
avail Mem,而不是free。如果Swap used持续增长,说明系统正面临真正的内存压力。 - 进程列表:支持快捷键排序。
P按 CPU 使用率排序,M按内存使用率排序,T按累计运行时间排序。按1可以切换到按 CPU 核心显示——当某个核心被占满而其他核心闲置时特别有用。按k输入 PID 发送信号,按q退出。
htop——更友好的 top#
htop 是 top 的增强版,支持彩色显示、鼠标操作、树状视图(F5)以及内置的信号发送功能(F9)。如果你需要在一个机器上停留超过一分钟,建议安装它:
| |
ps——静态快照工具#
top 是动态刷新的,而 ps 则是某一时刻的静态快照,非常适合用于脚本编写和筛选。
| |
ps aux 中值得关注的字段:
VSZ——虚拟内存大小(进程申请的内存,单独看意义不大)。RSS——常驻内存大小,实际占用的物理内存。这是衡量内存使用的真实指标。STAT——状态码,可能带额外的后缀(<表示高优先级,N表示低优先级,s表示会话首领,+表示前台进程组)。TIME——累计的 CPU 时间,不是墙上时间。
pstree——进程父子关系#
| |
如果某个进程反复重启,顺着它的父进程往上查,总会找到谁在背后拉它起来。
lsof——打开的“文件”一览#
lsof 列出所有打开的“文件”,这里的文件是 Unix 意义上的广义文件——包括普通文件、 socket、管道、设备节点,甚至内核句柄。
| |
FD 列是一套小型语法:
cwd——当前工作目录txt——可执行程序本身mem——内存映射文件(如共享库)0r/1w/2w——标准输入 / 标准输出 / 标准错误Nu(u/r/w)——普通文件描述符及其模式
ss——现代版的 netstat#
| |
iostat 和 iotop——磁盘监控利器#
| |
重点关注 %util、r/s、w/s 和 await。如果设备的 %util 达到 100%,但 await 是个位数,说明设备忙但健康;如果 %util 达到 100%,同时 await 高达几百毫秒,则说明设备排队严重,性能堪忧。
控制进程#
信号#

kill 这个命令的名字其实有点误导人。它并不是直接“杀死”进程,而是发送一个信号,而大多数信号的默认行为恰好是终止进程。日常工作中真正需要关注的信号如下:
| 信号 | 编号 | 作用 |
|---|---|---|
SIGTERM | 15 | 礼貌地请求进程终止,进程有机会进行清理工作。永远优先尝试这个信号。 |
SIGINT | 2 | 按下 Ctrl+C 时发送的信号,语义上表示“用户中断了你的操作”。 |
SIGHUP | 1 | 原本表示“终端挂断”,但现在通常被守护进程(如 nginx、 sshd、 syslog)用来重新加载配置。 |
SIGKILL | 9 | 强制终止进程的终极手段,内核直接干掉进程,无法被捕获或忽略,不留任何清理机会。 |
SIGSTOP | 19 | 暂停进程,无法被捕获或忽略,需要用 SIGCONT 恢复运行。 |
SIGTSTP | 20 | 可被捕获的暂停信号,按下 Ctrl+Z 时发送的就是这个信号。 |
SIGCONT | 18 | 恢复被暂停的进程继续运行。 |
SIGUSR1 / SIGUSR2 | 10 / 12 | 用户自定义信号,很多守护进程用 SIGUSR1 来触发日志轮转。 |
SIGCHLD | 17 | 子进程状态发生变化时发送给父进程,父进程应该调用 wait() 收集子进程的状态。 |
SIGPIPE | 13 | 当你向一个没有读端的管道写入数据时触发(例如 `yes |
正确的处理顺序应该是:先发送 SIGTERM,给进程几秒钟时间自行退出;如果无效,再考虑使用 SIGKILL。kill -9 是最后的手段,因为它会跳过析构函数、不释放锁、留下临时文件,甚至可能导致数据库损坏。
| |
后台任务与会话分离#
如果你需要让某个任务在 SSH 会话结束后继续运行,可以按以下方法选择,从简单到更可靠逐步升级:
| |
对于交互式任务或长时间运行的任务,tmux(或者更老的 screen)是最优解。但如果是服务类任务,这些方法都不够专业——应该编写一个 systemd 配置单元,交由 init 系统管理(具体见服务管理篇)。
在单个 shell 内切换前台和后台任务:
| |
优先级:nice 和 renice#
调度器在决定下一个运行哪个进程时,会参考进程的 nice 值。这是一个从 -20(最高优先级,调度器偏爱)到 19(最低优先级,对其他进程友好)的整数,默认值为 0。将 nice 值调低到 0 以下需要 root 权限。
| |
nice 只是一个软性提示,它会影响调度权重,但在系统空闲时,低优先级进程仍然可以获得 CPU 时间。如果需要设置硬性限制(例如“这个批处理任务最多只能使用 2 个核心和 4 GiB 内存”),应该使用 cgroup,而不是 nice。
孤儿进程与僵尸进程#
两种容易让新手困惑的状态,都与父子进程的关系有关:
- 孤儿进程(Orphan):父进程比子进程先退出时,子进程会被内核重新挂到 PID 1 (通常是
systemd或init)名下,由其接管wait()的职责。正常情况下无害。 - 僵尸进程(Zombie,标记为
Z):子进程已经退出,但父进程尚未调用wait()收集其退出状态。此时,进程表中仍保留该进程的task_struct,占用一个 PID 和退出码。僵尸进程不消耗 CPU 或内存,但会占用进程表槽位——积累过多会导致fork()失败。
解决僵尸进程的关键在于修复父进程。如果父进程在你的控制范围内,确保它调用 wait()(或者注册 SIGCHLD 处理函数,或者将 SIGCHLD 设置为 SIG_IGN,让内核自动回收子进程)。如果父进程是第三方程序且无法修复,可以尝试杀死父进程,僵尸进程会被重新挂到 systemd 名下并立即回收。重启系统是最后的无奈之举。
快速列出当前的僵尸进程:
| |
cgroups + namespaces——支撑容器的两大基石#

搞清楚进程的概念后,内核中的两个特性就能帮你彻底理解容器的运行原理。即使你从来不碰 Docker,这两个特性依然非常有用。
Namespaces 的作用是隔离进程的视野,让每个进程只能看到自己的一片“小天地”。每种 namespace 都负责虚拟化一类系统资源:
pid——独立的进程 ID 空间;容器内的 PID 1 和外部的 PID 1 没有任何关系。net——独立的网络设备、路由表和 socket。mnt——独立的挂载点视图;容器里的根文件系统可以和宿主机完全不同。uts——独立的主机名和域名。ipc——独立的 SysV/POSIX 进程间通信对象。user——独立的用户和组 ID 范围;容器内的 root 用户映射到宿主机上是一个普通用户。cgroup——独立的 cgroup 层级结构视图。
cgroups (控制组, v2) 的职责是限制进程的资源使用量。一个 cgroup 是 /sys/fs/cgroup/ 下的一个目录,里面存放了属于该组的进程 ID 列表,以及一组用来配置资源限制的文件:
| |
需要记住的几个关键控制器:
cpu.max——CPU 使用带宽上限(quota / period)。cpu.weight——在资源竞争时分配的相对权重。memory.max——内存硬限制;超出限制会触发该 cgroup 内部的 OOM killer,而不会影响整个宿主机。io.max——按设备设置的 IOPS 和 BPS 上限。pids.max——防止 fork 炸弹,限制组内进程数量。cpuset.cpus——绑定到特定的 CPU 或 NUMA 节点。
像 Docker、 Podman 或 containerd 这样的容器引擎,本质上就是一个小程序,完成了以下三件事:
- 创建一组新的 namespace (通过带正确标志的
unshare(2)或clone(2)系统调用)。 - 切换到从镜像中解压出来的新根文件系统(
pivot_root)。 - 把生成的进程树放入一个带有指定资源限制的 cgroup (比如
docker run --cpus 0.5 --memory 512m)。
明白了这些底层机制后,调试容器问题就不再那么神秘了。用 nsenter -t <PID> -a 可以进入一个正在运行的容器的 namespace;查看 /proc/<PID>/cgroup 能知道它属于哪个 cgroup;而 systemd-cgtop 则是专为 cgroup 设计的 top 工具。
演练:机器变慢了,接下来怎么办?#
当遇到“机器变慢”的问题时,可以按照以下步骤一步步排查。这种方法不仅系统化,还能帮助你避免误判。
| |
千万不要跳过第 2 步!很多“服务器变慢”的问题之所以被误诊,就是因为有人直接打开 top,看到某个进程占用了 100% 的 CPU,就急着用 kill -9 干掉它。殊不知,真正的问题可能是磁盘 I/O 饱和,而那个进程只是因为等待 I/O 而卡在 D 状态(不可中断的睡眠状态)。
实战:恢复被误删的日志文件#
经典场景:有人在 nginx 正常写日志的时候,手一抖执行了 rm -rf /var/log/nginx/access.log。等反应过来时,文件已经被删了,但事情还没完:
- 用
ls /var/log/nginx/查看目录,发现文件已经不见了(目录项被移除了)。 - 再用
df -h查看磁盘使用情况,发现空间占用居然没变(inode 和数据块依然存在)。 - 更神奇的是, nginx 依然在往原来的文件描述符(FD)写入数据,仿佛什么都没发生——实际上确实如此。
这是 Unix 文件系统语义的经典体现:虽然文件名从目录中被删除了,但只要还有进程持有该文件的打开 FD, inode 就不会被释放,数据依然存在。内核会通过 /proc/<pid>/fd/<fd> 暴露这个 FD,这意味着我们有机会把文件内容抢救回来:
| |
这种方法适用于任何“被删除但仍有进程打开”的文件。不过需要注意的是,一旦最后一个 FD 被关闭,内核就会彻底回收 inode 和数据块——所以务必立即行动,赶在相关进程重启之前完成恢复操作。
一页带走#
- 进程是正在运行的程序,拥有独立的地址空间;而线程共享这个地址空间。在调度器眼里,只有
task_struct才是真实存在的。 - 新进程总是通过
fork()+exec()创建,从来不会凭空生成。 PID 1 (也就是systemd或init)是所有进程树的起点。 - 状态机(
R/S/D/T/Z)就是ps或top命令输出的状态列,每种状态背后都对应着不同的排查思路。 - 资源瓶颈通常出现在四个维度——CPU、内存、磁盘 I/O 和网络——市面上大多数监控工具不过是这些维度的不同视角罢了。
- Linux 的内存管理看起来比实际复杂:真正值得关注的是
available,而不是free。 kill命令用来发送信号;先尝试SIGTERM,实在不行再用SIGKILL。守护进程收到SIGHUP时会重新加载配置。nice和renice是软性的调度建议;而cgroups 则是硬性限制,它和 namespaces 共同构成了容器技术的核心基础。
推荐阅读#
- Brendan Gregg,《Linux 性能优化》——http://www.brendangregg.com/linuxperf.html
man proc(5)——/proc文件系统的详尽参考man 7 signal—— 每个信号的默认行为和捕获规则man 7 cgroups—— cgroup v2 的设计与控制器列表man 7 namespaces—— 每种命名空间虚拟化的具体内容
本系列下一篇预告#
- Linux 磁盘管理 —— 分区、文件系统、 LVM 和挂载栈
- Linux 用户管理 —— 用户、组、 sudo、 PAM 和最小权限原则
到目前为止,你应该已经具备了快速定位问题的能力:面对一台性能不佳的机器,能在一分钟内指出哪个资源维度出现了瓶颈,并且有目的地选择合适的工具,而不是盲目地运行 top。这才是从“跑命令”到“运维系统”的关键转变。