Linux 进程与资源管理:从 top 到 cgroups
进程是怎么诞生和死亡的:fork/exec 模型、ps 和 top 在 STAT 列里打印的状态机、真正约束一台服务器的四个资源轴(CPU/内存/磁盘 I/O/网络)、那几个一定要记住的信号,以及把这一切组合成容器的 cgroup 与 namespace 原语。
运维这份工作,本事从来不是"会背命令",而是把一个模糊的现象——网站慢了、接口超时了、机器没响应了——快速映射到正确的资源轴:是 CPU 跑满了,是内存被 cache 占满了(这没事)还是被某个失控进程吃光了(这有事),是磁盘队列堆住了,还是某个 socket 在泄漏?只要这一步定下来,工具的选择基本是机械的。
这篇文章按这个顺序把全景走一遍。先讲一个进程到底是怎么被造出来的(fork() + exec())、内核会让它跑过哪些状态、它能用的四种资源边界是什么。然后我们把工具链——top / htop / ps / pstree / lsof / ss / iostat——按"对同一个系统的不同观察角度"组织起来,而不是简单罗列。最后讲你能对一个进程做的事情:发送信号、放到后台、用 nice/renice 调优先级,以及背后默默撑起每一个容器的 cgroup 与 namespace。
进程、程序、线程——分清楚为什么重要

口语里这三个词经常换着用,但内核眼里它们是三种不同的东西:
| 术语 | 是什么 | 标识 |
|---|---|---|
| 程序 | 磁盘上的一个静态文件(/usr/bin/vim)——指令和数据排布的描述 | inode + 路径 |
| 进程 | 程序的一次运行实例:自己的地址空间、文件描述符、PID | PID、父进程 PPID |
| 线程 | 进程内部的执行流,与同进程里的其他线程共享地址空间 | TID(内核 task_struct) |
资源是按进程算账的——内存页、文件描述符、打开的 socket,都挂在进程上。线程只是另一个可被调度的执行单元,恰好和兄弟们共用那套状态。Linux 上这条线尤其细:进程和线程在内核眼里都是 task_struct,唯一的区别是创建时共享了什么(clone() 的标志位决定)。这就是为什么 ps -eLf 会列出 PID 相同、LWP 不同的若干行,也是为什么一个多线程 JVM 在系统里看起来是一个进程下面挂着几百个 task_struct。
几个值得记住的不变式:
- 每个进程都有父进程。PPID 0 是内核保留;PPID 1 是
systemd(或init),是内核唯一亲手启动的用户态进程。沿着 PPID 一路往上爬,最终都会回到 1。 - 进程默认彼此隔离:地址空间不重叠,进程 A 打开一个文件不会影响进程 B。要共享必须显式走 IPC(管道、socket、共享内存、信号)。
- 进程是动态的:不停被创建、调度、阻塞、唤醒、销毁。一台健康的服务器每分钟都会产生几千个短命进程,这是正常的。
进程怎么诞生:fork() 和 exec()

Linux 创建新进程只有一种机制:fork。fork()(以及它的现代亲戚 clone())会把调用方进程复制出来一份。调用返回后,你有两个几乎一模一样的进程从同一行代码继续往下走,唯一的差别是返回值——子进程拿到 0,父进程拿到子进程的 PID。它们共享打开的文件描述符,代码、堆数据完全一致,连物理内存页一开始都是共享的(写时复制)。
这本身只是把同一个程序复制了一遍。真正的把戏是后半段——exec。在刚 fork 出来的子进程里调用 execve("/bin/ls", argv, envp),是在告诉内核:把当前进程的程序映像替换成另一个二进制,保留同一个 PID、同一个 PPID,默认也保留打开的文件描述符。所以你在 shell 里敲一个 ls,背后真正发生的事是:
- shell 调用
fork(),现在有两个 shell。 - 子进程调用
execve("/bin/ls", ...),它的程序映像变成了ls。 - 父进程(原来那个 shell)调用
waitpid()阻塞,等子进程退出,把退出码塞进$?。
把"创建进程"和"装载程序"拆成两步,是 shell 管道、重定向、环境变量修改、setuid 式降权能成立的根本原因。所有这些动作都发生在 fork 之后、exec 之前的子进程里,新程序还没开跑。
这种设计有两个后果会反复在生产里出现:
- 文件描述符会被继承。 子进程会继承父进程所有未标
O_CLOEXEC的 FD。这就是为什么cmd > log.txt能工作:shell 在子进程里、exec 之前先把那个文件以 FD 1 ��开。这也是为什么一个泄漏的守护进程能"钉住"已经被删除的文件(待会就讲)。 - PID 1 是特殊的。 进程退出时如果还有子进程,这些孤儿会被重新挂到 PID 1 名下,由它负责
wait()。容器里的 PID 1 也承担同样职责——忘了处理就会得到那个出名的"Docker 容器里堆满僵尸"问题。
进程的状态机
ps 和 top 会给每个进程打印一个一字母的 STAT 列,对应的就是内核当前把它放在哪个状态:
| 编码 | 状态 | 含义 |
|---|---|---|
R | Running | 正在某个 CPU 上跑,或者在运行队列里等 CPU |
S | 可中断睡眠 | 等某个事件(读 socket、定时器),可被信号唤醒 |
D | 不可中断睡眠 | 卡在内核里某件不能放手的事上,通常是磁盘 I/O——连 kill -9 也搬不动 |
T | 停止 | 被 SIGSTOP/SIGTSTP 暂停(比如你按了 Ctrl+Z) |
Z | 僵尸 | 已经退出,但父进程还没 wait() 回收 |
X | 死亡 | 销毁过程中的瞬时状态,几乎看不到 |
这些状态不是冷知识。一台机器有 200 个进程在 D,不是“CPU 被打满了”,而是它们被存储卡住了——你怎么调 CPU 都没用。Z 越堆越多,是父进程忘了 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 核机器,load avg 4.0、CPU 95% idle,几乎一定是磁盘问题:任务都堆在 D 上等 I/O,不是等 CPU。把负载值跟核心数比着读:< 核数 是闲,≈ 核数 是满载,>> 核数 是过载。
| |
内存
free -h 是规范视图。陷阱在于不能用 Windows 任务管理器的脑子去读它:
| |
新手一看 free: 1.0Gi 就慌。别慌。 Linux 故意把空闲内存当 buffer/cache 用来加速 I/O——那 11.5 GiB 是按需可回收的。真正要看的是 available:内核估算一下,如果有进程伸手要内存,它能腾出来多少。
cache 的两种口味:
- Buffer 是写入侧的暂存。写文件时字节先进页缓存,再批量回刷到磁盘——零碎写之所以快就是这个原因。
- Cache 是读取侧:从磁盘读上来的页内核留着,等下次再读时直接命中——而它们确实经常被再读到。
只有当这三件事同时发生时,内存才是真的不够:
available接近 0;swap used在持续上涨,而不只是非零;- 内核开始记录 OOM kill(
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 是驾驶舱视角。每一块区域都讲了具体的事:
- 顶部一行:uptime、登录用户、负载均值。把负载和
nproc比着看。 - Tasks:总数 / 运行 / 睡眠 / 停止 / 僵尸。后两个应该是 0。
%Cpu(s):拆成 user(us)、system(sy)、nice(ni)、idle(id)、I/O wait(wa)、硬中断/软中断(hi/si)、stolen(st)。wa高是磁盘慢;st在虚拟机里高,意味着宿主机把你的 CPU 时间分给了别的租户——云上的"邻居打架"很常见。- Mem / Swap:信
avail Mem,别信free。Swap used持续上涨,才是真正的内存压力。 - 进程表:可以用快捷键排序。
P按%CPU,M按%MEM,T按时间。1切换到按核展开——某一个核被钉死、其他核闲着的时候特别有用。k输入 PID 发信号。q退出。
htop——带界面的 top
htop 就是带颜色、能用鼠标、有树状图(F5)、能直接选进程发信号(F9)的 top。任何一台你打算待超过一分钟的机器,都装上:
| |
ps——静态快照
top 在刷新;ps 是把某个瞬间冻结下来,写脚本和 grep 时往往要的就是这个。
| |
ps aux 里值得记住的列:
VSZ——虚拟内存大小(进程申请的;单看意义不大)。RSS——常驻集,实际占用的物理页。这是诚实的内存数字。STAT——上面表里的状态码,有时还带后缀(<高优先级、N低优先级、s会话首领、+前台进程组)。TIME——累计 CPU 时间,不是墙上时间。
pstree——谁是谁的父亲
| |
某个进程死了又活、活了又死时,往上查——总有谁在拉它起来。
lsof——一切打开着的"文件”
lsof 列出打开的"文件",这里的文件是 Unix 意义上"一切皆文件"的文件——常规文件、socket、管道、设备节点,乃至内核侧的句柄。
| |
FD 列本身就是一套小语法:
cwd——进程的当前工作目录txt——可执行程序自身mem——内存映射的文件(动态库)0r/1w/2w——stdin / stdout / stderrNu(u/r/w)——一个普通 FD,带打开模式
ss——socket 现代版的 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 | stop 的可捕获版本——Ctrl+Z 发的就是这个 |
SIGCONT | 18 | 让停止的进程继续 |
SIGUSR1 / SIGUSR2 | 10 / 12 | 应用自定义。很多守护进程用 SIGUSR1 触发日志切割。 |
SIGCHLD | 17 | 子进程状态变化时发给父进程。父进程应该 wait()。 |
SIGPIPE | 13 | 你往一个没读端的管道写(`yes |
正确的升级顺序永远是:先 SIGTERM,给进程几秒钟,无效了再上 SIGKILL。kill -9 会跳过析构、留着没释放的锁、丢下临时文件、把正在写的数据库写坏。这是最后的手段。
| |
后台任务和脱离会话
要让一个任务活过 SSH 断线,按"越来越靠谱"的顺序有三种选择:
| |
任何要交互或者长跑的事情,正确答案都是 tmux(或老一点的 screen)。但如果是服务,这三种都不合适——写一个 systemd unit,让 init 系统去监管它(这部分会在系统服务那篇讲)。
同一个 shell 内的前台/后台切换:
| |
优先级:nice 与 renice
调度器选谁先跑,部分依据是进程的 nice 值——一个从 -20(最高优先级,调度器偏爱它)到 19(最低,对别人很客气)的整数,默认是 0。把 nice 调到 0 以下需要 root。
| |
nice 是个软提示——它影响调度权重,但低优先级进程在没人争 CPU 时仍然能跑。要做硬限制(比如"这个批处理任务最多用 2 个核、4 GiB 内存"),用 cgroup,不要用 nice。
孤儿和僵尸
两种新手容易搞混的状态,都源于父子进程之间的契约:
- 孤儿(Orphan):父进程比子进程先退出。内核会把孤儿重新挂到 PID 1 名下,由它继承
wait()的责任。正常情况下没害。 - 僵尸(Zombie,
Z):子进程已经退出,但父进程没调wait()把退出状态收回去。task_struct还赖在进程表里,只占着一个 PID 和退出码。僵尸不吃 CPU 也不吃内存,但它占进程表槽位——堆够多了,连fork()本身都会失败。
僵尸的修法是修父进程。如果父进程在你手里,让它去 wait()(或者注册 SIGCHLD 处理函数,或者把 SIGCHLD 设成 SIG_IGN 让内核自动回收)。如果父进程是别人写的、坏的,那就杀掉父进程,僵尸会被重新挂到 systemd 名下、立刻被回收。重启是真没招了的最后手段。
便宜地列出当前的僵尸:
| |
cgroups + namespaces——容器底下的那两块砖

理解了进程之后,还有两个内核特性能解释容器到底是怎么工作的——而且就算你永远不碰 Docker,它们也很有用。
Namespaces 隔离的是一个进程能看见什么。每种 namespace 虚拟化一类系统资源:
pid——独立的 PID 空间;里面的 PID 1 不是外面的 PID 1。net——独立的网卡、路由表、socket。mnt——独立的挂载表;根文件系统都可以完全不同。uts——独立的 hostname 和 domain name。ipc——独立的 SysV/POSIX IPC 对象。user——独立的 UID/GID 范围;里面的 root 在外面映射成一个非特权 UID。cgroup——独立的 cgroup 层级视图。
cgroups(control groups,v2) 限制的是一个进程能用多少。一个 cgroup 就是 /sys/fs/cgroup/ 下的一个目录,里面装着属于它的 PID 列表,加上一组配置资源上限的文件:
| |
值得记住的几个 controller:
cpu.max——带宽上限(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;cat /proc/<PID>/cgroup 告诉你它在哪个组里;systemd-cgtop 是 cgroup 版的 top。
演练:“机器变慢了,怎么排”
可复用的"变慢"分诊顺序:
| |
千万别跳过第 2 步。“服务器慢"的工单大半被误诊,就是因为有人直接打开 top,看到一个进程 100% CPU,kill -9 了事——而真正的问题是磁盘饱和,那个进程只是傻傻地在 D 里等。
实战:恢复被误删的日志文件
经典事故:有人在 nginx 还在写日志的时候跑了 rm -rf /var/log/nginx/access.log。rm 之后:
ls /var/log/nginx/看不到这个文件了(目录项没了)。df -h报告的占用没变(inode 和数据块还分配着)。- nginx 继续往同一个 FD 上写,跟没事一样——因为它真的没事。
这是 Unix 语义里的标准行为:文件名从目录里取消链接了,但只要还有进程持有它的打开 FD,inode 就还活着。内核会把那个 FD 暴露在 /proc/<pid>/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 Performance——http://www.brendangregg.com/linuxperf.html
man proc(5)——/proc的完整参考man 7 signal——每个信号、默认动作、是否可捕获man 7 cgroups——cgroup v2 的设计和 controller 列表man 7 namespaces——每种 namespace 虚拟化什么
下一篇
- 《Linux 磁盘管理》——分区、文件系统、LVM 与挂载栈
- 《Linux 用户管理》——用户、组、sudo、PAM 与最小权限原则
到这里,你应该能走进一台慢机器,一分钟之内说出哪个轴饱和了,然后带着意图去选工具,而不是凭反射敲命令。这就是"会跑 top“和"在运维一个系统"之间的差别。