Series · Linux · Chapter 7

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 + 路径
进程程序的一次运行实例:自己的地址空间、文件描述符、PIDPID、父进程 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()

fork/exec 模型

Linux 创建新进程只有一种机制:forkfork()(以及它的现代亲戚 clone())会把调用方进程复制出来一份。调用返回后,你有两个几乎一模一样的进程从同一行代码继续往下走,唯一的差别是返回值——子进程拿到 0,父进程拿到子进程的 PID。它们共享打开的文件描述符,代码、堆数据完全一致,连物理内存页一开始都是共享的(写时复制)。

这本身只是把同一个程序复制了一遍。真正的把戏是后半段——exec。在刚 fork 出来的子进程里调用 execve("/bin/ls", argv, envp),是在告诉内核:把当前进程的程序映像替换成另一个二进制,保留同一个 PID、同一个 PPID,默认也保留打开的文件描述符。所以你在 shell 里敲一个 ls,背后真正发生的事是:

  1. shell 调用 fork(),现在有两个 shell。
  2. 子进程调用 execve("/bin/ls", ...),它的程序映像变成了 ls
  3. 父进程(原来那个 shell)调用 waitpid() 阻塞,等子进程退出,把退出码塞进 $?

把"创建进程"和"装载程序"拆成两步,是 shell 管道、重定向、环境变量修改、setuid 式降权能成立的根本原因。所有这些动作都发生在 fork 之后、exec 之前的子进程里,新程序还没开跑。

这种设计有两个后果会反复在生产里出现:

  • 文件描述符会被继承。 子进程会继承父进程所有未标 O_CLOEXEC 的 FD。这就是为什么 cmd > log.txt 能工作:shell 在子进程里、exec 之前先把那个文件以 FD 1 ��开。这也是为什么一个泄漏的守护进程能"钉住"已经被删除的文件(待会就讲)。
  • PID 1 是特殊的。 进程退出时如果还有子进程,这些孤儿会被重新挂到 PID 1 名下,由它负责 wait()。容器里的 PID 1 也承担同样职责——忘了处理就会得到那个出名的"Docker 容器里堆满僵尸"问题。

进程的状态机

pstop 会给每个进程打印一个一字母的 STAT 列,对应的就是内核当前把它放在哪个状态:

编码状态含义
RRunning正在某个 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 是你的第一个体检表——stoppedzombie 不是 0,就值得追一下。

四个资源轴

在淹没在工具里之前,先把心智模型立起来。生产里的问题,几乎都能映射到这四个轴之一:

  1. CPU——每秒能完成多少计算。
  2. 内存——进程的工作集能放下多少而不去碰磁盘。
  3. 磁盘 I/O——字节进出持久化存储的速度。
  4. 网络——字节进出这台机器的速度。

任何一个出现瓶颈,依赖它的东西都会跟着慢。难点在于判断是哪一个——监控工具基本上就是为这件事服务的。

CPU

通常你想知道两件事:

  • 这台机器到底有几个核?nproclscpu 都答得了。
  • 现在压力多大?uptime 给出 1、5、15 分钟三个负载值。

负载平均值不是 CPU 使用率。它统计的是处于 running 或 uninterruptibleR + D)状态的进程数。一台 4 核机器,load avg 4.0、CPU 95% idle,几乎一定是磁盘问题:任务都堆在 D 上等 I/O,不是等 CPU。把负载值跟核心数比着读:< 核数 是闲,≈ 核数 是满载,>> 核数 是过载。

1
2
3
4
uptime
# 06:56:12 up 12 days,  3:45,  3 users,  load average: 0.22, 0.45, 0.56
nproc
# 4

内存

free -h 是规范视图。陷阱在于不能用 Windows 任务管理器的脑子去读它:

1
2
3
              total        used        free      shared  buff/cache   available
Mem:           15Gi       2.5Gi       1.0Gi       100Mi       11.5Gi        12Gi
Swap:         2.0Gi          0B       2.0Gi

新手一看 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 1iotop 里;最重要的两个指标是 %util(设备多忙)和 await(请求在队列里待多久)。%util 100await 几十毫秒的设备,是真瓶颈。

网络方面:ss -tulnp 看谁在监听,ip -s link 看接口计数和丢包,iftop(或 nload)看实时带宽。“80 端口被谁占了"的最快答案是 lsof -i :80ss -tulnp | grep :80

监控工具链

top——首先要跑的那个

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,别信 freeSwap used 持续上涨,才是真正的内存压力。
  • 进程表:可以用快捷键排序。P%CPUM%MEMT 按时间。1 切换到按核展开——某一个核被钉死、其他核闲着的时候特别有用。k 输入 PID 发信号。q 退出。

htop——带界面的 top

htop 就是带颜色、能用鼠标、有树状图(F5)、能直接选进程发信号(F9)的 top。任何一台你打算待超过一分钟的机器,都装上:

1
2
sudo apt install htop          # Debian/Ubuntu
sudo dnf install htop          # RHEL/Rocky/Fedora

ps——静态快照

top 在刷新;ps 是把某个瞬间冻结下来,写脚本和 grep 时往往要的就是这个。

1
2
3
ps -ef                  # System V 风格:所有进程,完整字段
ps aux                  # BSD 风格:所有进程,包括没有 TTY 的
ps -eo pid,ppid,user,stat,%cpu,%mem,cmd --sort=-%cpu | head -10

ps aux 里值得记住的列:

  • VSZ——虚拟内存大小(进程申请的;单看意义不大)。
  • RSS——常驻集,实际占用的物理页。这是诚实的内存数字。
  • STAT——上面表里的状态码,有时还带后缀(< 高优先级、N 低优先级、s 会话首领、+ 前台进程组)。
  • TIME——累计 CPU 时间,不是墙上时间。

pstree——谁是谁的父亲

1
2
pstree -ap            # 命令行 + PID
pstree -ap <PID>      # 某个 PID 下的子树

某个进程死了又活、活了又死时,往上查——总有谁在拉它起来。

lsof——一切打开着的"文件”

lsof 列出打开的"文件",这里的文件是 Unix 意义上"一切皆文件"的文件——常规文件、socket、管道、设备节点,乃至内核侧的句柄。

1
2
3
4
5
6
7
lsof -p <PID>          # 这个 PID 打开了什么
lsof -c nginx          # 任何 nginx 进程打开了什么
lsof -u alice          # alice 拥有的进程打开了什么
lsof -i :80            # 谁绑在 80 端口
lsof -i tcp            # 所有 TCP socket
lsof +D /var/log       # /var/log 下被打开的所有文件
lsof +L1               # 链接数 < 1 的文件——已删除但还被钉着

FD 列本身就是一套小语法:

  • cwd——进程的当前工作目录
  • txt——可执行程序自身
  • mem——内存映射的文件(动态库)
  • 0r / 1w / 2w——stdin / stdout / stderr
  • Nuu / r / w)——一个普通 FD,带打开模式

ss——socket 现代版的 netstat

1
2
3
ss -tulnp            # tcp + udp + 监听 + 数字 + PID
ss -s                # 各种 socket 状态的总览
ss -tan state established '( dport = :443 )'

iostatiotop——磁盘轴

1
2
iostat -x 1          # 每个设备的扩展统计,每秒刷新
sudo iotop -o        # 只显示当前在做 I/O 的进程

%utilr/sw/sawait。设备 %util 100await 个位数,是忙但健康%util 100 同时 await 几百毫秒,那是堆队列了,难受

控制进程

信号

信号速查

kill 这名字起错了。它不"杀"任何东西;它发送信号——只不过大多数信号的默认行为恰好是"终止进程"。日常真正要用到的就这几个:

信号编号作用
SIGTERM15礼貌请求进程终止;进程能做清理。永远先用这个。
SIGINT2Ctrl+C 发的;语义上是"用户中断了你"
SIGHUP1本意是"终端挂断了";按惯例,守护进程把它当作重新加载配置(nginx、sshd、syslog)
SIGKILL9核武器。内核直接干掉进程,不做清理,不可捕获。最后手段。
SIGSTOP19暂停。不可捕获。SIGCONT 恢复。
SIGTSTP20stop 的可捕获版本——Ctrl+Z 发的就是这个
SIGCONT18让停止的进程继续
SIGUSR1 / SIGUSR210 / 12应用自定义。很多守护进程用 SIGUSR1 触发日志切割。
SIGCHLD17子进程状态变化时发给父进程。父进程应该 wait()
SIGPIPE13你往一个没读端的管道写(`yes

正确的升级顺序永远是:先 SIGTERM,给进程几秒钟,无效了再上 SIGKILLkill -9 会跳过析构、留着没释放的锁、丢下临时文件、把正在写的数据库写坏。这是最后的手段。

1
2
3
4
5
6
kill <PID>             # SIGTERM(默认)
kill -HUP <PID>        # 重载配置
kill -9 <PID>          # SIGKILL——最后手段
kill -l                # 列出本机内核认识的所有信号名和编号
pkill -HUP nginx       # 按名字 / 模式
killall -USR1 nginx    # 按精确程序名

后台任务和脱离会话

要让一个任务活过 SSH 断线,按"越来越靠谱"的顺序有三种选择:

1
2
3
4
./long_task.sh &                                  # 仅当前 shell 后台;
                                                  # SSH 断了会收到 SIGHUP 而死
nohup ./long_task.sh >/dev/null 2>&1 &           # 忽略 SIGHUP;输出丢弃
tmux new -s work     # 在里面跑,Ctrl-B d 分离,回头再 attach

任何要交互或者长跑的事情,正确答案都是 tmux(或老一点的 screen)。但如果是服务,这三种都不合适——写一个 systemd unit,让 init 系统去监管它(这部分会在系统服务那篇讲)。

同一个 shell 内的前台/后台切换:

1
2
3
4
5
6
./task.sh         # 前台
^Z                # SIGTSTP——暂停,回到 shell
bg %1             # 在后台继续
fg %1             # 拉回前台
jobs              # 列出当前 shell 的任务
disown %1         # 与 shell 解绑,登出后存活

优先级:nicerenice

调度器选谁先跑,部分依据是进程的 nice 值——一个从 -20(最高优先级,调度器偏爱它)到 19(最低,对别人很客气)的整数,默认是 0。把 nice 调到 0 以下需要 root。

1
2
3
4
nice -n 19  ./backup.sh                # 用低优先级跑后台备份
nice -n -10 ./important.sh             # 提高优先级(root)
renice -n 10 -p <PID>                  # 改一个正在跑的进程的 nice
renice -n 5 -u alice                   # alice 的所有进程

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 名下、立刻被回收。重启是真没招了的最后手段。

便宜地列出当前的僵尸:

1
ps -eo pid,ppid,stat,cmd | awk '$3 ~ /^Z/'

cgroups + namespaces——容器底下的那两块砖

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 列表,加上一组配置资源上限的文件:

1
2
3
4
5
# 建一个组,限到半核 CPU、512 MiB 内存,然后把当前 shell 塞进去
sudo mkdir /sys/fs/cgroup/demo
echo "50000 100000" | sudo tee /sys/fs/cgroup/demo/cpu.max     # 每 100ms 用 50ms
echo $((512*1024*1024)) | sudo tee /sys/fs/cgroup/demo/memory.max
echo $$ | sudo tee /sys/fs/cgroup/demo/cgroup.procs            # 当前 shell 加入

值得记住的几个 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 这种容器引擎,本质上就是一个小程序,做了这三件事:

  1. 创建一组新的 namespace(带正确标志的 unshare(2)clone(2));
  2. pivot_root 到一个从镜像里解出来的新根文件系统;
  3. 把得到的进程树丢进一个有你要求的限制的 cgroup(docker run --cpus 0.5 --memory 512m)。

看明白这一层之后,调容器就不那么神秘了。nsenter -t <PID> -a 进入一个运行中容器的 namespace;cat /proc/<PID>/cgroup 告诉你它在哪个组里;systemd-cgtop 是 cgroup 版的 top

演练:“机器变慢了,怎么排”

可复用的"变慢"分诊顺序:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# 1. 有没有什么明显在着火?
uptime                          # 负载值对比 nproc
dmesg -T | tail -50             # OOM、硬件错误、segfault
df -h                           # 哪个文件系统快满了?

# 2. 哪个轴饱和了?
top                             # 按 1 看每核;盯 us/sy/wa/st
free -h                         # available 还有多少?swap 在涨吗?
iostat -x 1 5                   # 每个设备的 %util 和 await
ss -s                           # socket 计数;TIME-WAIT 巨多吗?

# 3. 是谁干的?
ps aux --sort=-%cpu | head      # CPU 大户
ps aux --sort=-%mem | head      # RSS 大户
sudo iotop -o                   # 谁在读写
sudo lsof -i -nP | head         # 谁在开 socket

# 4. 钻进某个 PID
cat /proc/<PID>/status          # 状态、线程数、内存合计
ls -l /proc/<PID>/fd/           # 打开了哪些文件
cat /proc/<PID>/limits          # 这个进程实际生效的 ulimit

# 5. 决定怎么动手
renice -n 10 -p <PID>           # 把失控的批处理调低优先级
kill <PID>                      # 先客气地问一下
kill -9 <PID>                   # 只在 SIGTERM 没用之后再上

千万别跳过第 2 步。“服务器慢"的工单大半被误诊,就是因为有人直接打开 top,看到一个进程 100% CPU,kill -9 了事——而真正的问题是磁盘饱和,那个进程只是傻傻地在 D 里等。

实战:恢复被误删的日志文件

经典事故:有人在 nginx 还在写日志的时候跑了 rm -rf /var/log/nginx/access.logrm 之后:

  • ls /var/log/nginx/ 看不到这个文件了(目录项没了)。
  • df -h 报告的占用没变(inode 和数据块还分配着)。
  • nginx 继续往同一个 FD 上写,跟没事一样——因为它真的没事。

这是 Unix 语义里的标准行为:文件名从目录里取消链接了,但只要还有进程持有它的打开 FD,inode 就还活着。内核会把那个 FD 暴露在 /proc/<pid>/fd/<fd> 下,意思是你可以原样把文件复制回来

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
PID=$(pidof nginx | awk '{print $1}')

# 1. 确认这个被删的文件确实还开着
sudo lsof -p "$PID" | grep access.log
# nginx 1234 root  6w  REG  8,1  123456789  4711  /var/log/nginx/access.log (deleted)

# 2. 从 /proc 里复制出来
sudo cp /proc/"$PID"/fd/6 /var/log/nginx/access.log

# 3. 让 nginx 重新打开日志文件
sudo nginx -s reload    # 或:sudo kill -USR1 "$PID"

同样的招数对任何"被删但还开着"的文件都成立。但只要最后一个 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“和"在运维一个系统"之间的差别。

Liked this piece?

Follow on GitHub for the next one — usually one a week.

GitHub