Linux-13 进程与作业控制:ps 每一列、D 状态为什么杀不掉、nohup 与 setsid 的区别
进程是操作系统最核心的抽象。这一篇讲怎么看进程、怎么控制进程、以及那些「看起来杀不掉」的进程到底怎么了。
先看五个问题:
ps aux里的VSZ和RSS有什么区别?为什么VSZ常常大得离谱(几十 GB)?- 进程状态
D是什么意思?为什么连kill -9都杀不掉它? cmd &、nohup cmd &、setsid cmd、disown有什么区别?关掉终端后哪些能活下来?- 僵尸进程为什么杀不掉?它占资源吗?
load average是 8,但 CPU 使用率只有 20%,怎么解释?
1. 进程的身份与关系
1.1 四个 ID
每个进程有四个身份标识,理解它们是理解「后台运行」和「关终端进程为什么会死」的前提:
PID (Process ID) 进程自己的编号
PPID (Parent PID) 父进程的编号
PGID (Process Group ID) 进程组 ID —— 一条管道里的所有进程属于同一组
SID (Session ID) 会话 ID —— 一个终端登录对应一个会话
ps -o pid,ppid,pgid,sid,tty,comm -p $$
# PID PPID PGID SID TT COMMAND
# 1234 1200 1234 1234 pts/0 bash
# 一条管道里的三个进程:同一个 PGID,不同 PID
sleep 100 | sleep 200 | sleep 300 &
ps -o pid,ppid,pgid,sid,comm --ppid $$
# PID PPID PGID SID COMMAND
# 5001 1234 5001 1234 sleep
# 5002 1234 5001 1234 sleep <- PGID 都是 5001
# 5003 1234 5001 1234 sleep
层次关系:
会话 (Session) ← 一次登录(一个终端)
+--------------------------------------------------+
| 控制终端: pts/0 会话首进程: bash (SID=1234) |
| |
| +----------------------+ +------------------+ |
| | 前台进程组 PGID=5001 | | 后台进程组 5010 | |
| | sleep | sleep|sleep | | 长任务 & | |
| +----------------------+ +------------------+ |
| ^ 只有前台组能读终端输入、能收到 Ctrl-C |
+--------------------------------------------------+
关键规则:
- 一个会话最多有一个「控制终端」
- 一个会话里最多有一个「前台进程组」
- Ctrl-C / Ctrl-Z 只发给【前台进程组】
- 终端关闭时,内核给会话里的进程发 SIGHUP ← 这是第 6 章的核心
1.2 进程树
pstree # 树形显示所有进程
pstree -p # 带 PID
pstree -pa # 带 PID 和完整命令行参数
pstree -s 1234 # 显示某进程的【所有祖先】 ← 排查"谁启动了它"
pstree -u # 显示用户切换
pstree -T # 不显示线程(默认会把线程也显示出来)
pstree lhx # 只看某用户的
pstree -ps 1234
# systemd(1)───sshd(800)───sshd(1200)───bash(1234)───myapp(5000)
# ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 完整的祖先链
1.3 孤儿进程与 PID 1
# 父进程先死了,子进程会怎样?
bash -c 'sleep 300 & echo "子进程 PID: $!"; exit'
# 子进程 PID: 6001
ps -o pid,ppid,comm -p 6001
# PID PPID COMMAND
# 6001 1 sleep <- PPID 变成了 1!
# 内核的规则:父进程退出时,它的子进程被「托孤」给 PID 1(init/systemd)
# 目的:保证每个进程都有父进程来回收它(否则会永久变成僵尸)
容器里的重要推论:容器的 PID 1 是你的应用(而不是 systemd),如果它不处理子进程回收,容器里就会积累僵尸进程。这是为什么很多镜像会用 tini 或 --init 作为 PID 1(第 21 篇细讲)。
2. ps:读懂每一列
2.1 三套语法
ps 有个特殊之处:它同时支持三套互不兼容的参数风格,这是 BSD 和 System V 两大 Unix 分支融合的产物。
ps aux # BSD 风格(参数【不带】横杠)
ps -ef # UNIX/SysV 风格(参数带横杠)
ps --forest # GNU 长选项风格
# 所以这三个都是对的,输出的列不同:
ps aux | head -2
# USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
# root 1 0.0 0.1 168404 12996 ? Ss 08:00 0:03 /sbin/init
ps -ef | head -2
# UID PID PPID C STIME TTY TIME CMD
# root 1 0 0 08:00 ? 00:00:03 /sbin/init
# ^^^^ -ef 有 PPID,aux 没有
# ⚠️ 混用会有意外行为
ps -aux # 严格来说是错的(多了横杠),GNU ps 会警告但仍按 BSD 解释
记忆:ps aux 看资源占用(有 %CPU、%MEM、VSZ、RSS),ps -ef 看进程关系(有 PPID)。
2.2 ps aux 每列详解
USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
app 5000 12.3 4.5 2145678 412340 ? Ssl 08:00 12:34 /usr/local/bin/myapp
| 列 | 含义 | 要点 |
|---|---|---|
USER |
进程的有效用户 | 不是启动它的用户,是当前 euid 对应的用户 |
PID |
进程 ID | — |
%CPU |
CPU 使用率 | 是「进程生命周期内的平均值」,不是瞬时值!刚启动的进程可能显示很高 |
%MEM |
RSS 占物理内存总量的比例 | — |
VSZ |
虚拟内存大小(KB) | 进程「申请/映射」的地址空间总量,包括没真正用的 |
RSS |
常驻内存(KB) | 真正占用的物理内存,但共享库被重复计算 |
TTY |
控制终端 | ? 表示没有控制终端(守护进程) |
STAT |
进程状态 | 见 2.4,最有信息量的一列 |
START |
启动时间 | — |
TIME |
累计占用的 CPU 时间 | 不是运行时长!一个跑了 10 天但空闲的进程可能只有 0:01 |
COMMAND |
命令行 | [方括号] 表示内核线程(不是真进程) |
2.3 开篇第一问:VSZ vs RSS vs PSS
ps -o pid,vsz,rss,comm -p $(pgrep -f myapp)
# PID VSZ RSS COMMAND
# 5000 2145678 412340 myapp
# ^^^^^^^ 2.1 GB 虚拟 ^^^^^^ 412 MB 物理
为什么 VSZ 大得离谱?
VSZ 统计的是「虚拟地址空间」,包含:
✓ 真正在用的物理内存
✓ 申请了但还没写入的内存(内核用 overcommit 机制先记账,不真给页)
✓ 映射进来但没读过的文件(mmap 一个 10GB 文件,VSZ 就 +10GB)
✓ 所有链接的共享库的完整地址空间
✓ 线程栈的预留空间(每个线程默认预留 8MB!500 个线程 = 4GB VSZ)
✓ Go runtime 启动时会预留一大块虚拟地址空间(所以 Go 程序 VSZ 常有 1~2GB)
所以:VSZ 高【几乎没有意义】,不要用它判断内存问题。要看 RSS。
RSS 的问题:共享库被重复计算
# 10 个 nginx worker,每个 RSS 显示 50MB
ps aux | grep nginx | awk '{sum+=$6} END {print sum/1024 " MB"}'
# 500 MB <- 但实际物理内存占用远小于此,因为它们共享同一份代码和库
# ✅ PSS(Proportional Set Size)才是准确的:共享内存按份额分摊
sudo awk '/^Pss:/ {sum+=$2} END {print sum/1024 " MB"}' /proc/5000/smaps
# 或者用工具
sudo smem -k -P nginx # 需要安装 smem
# PID User Command Swap USS PSS RSS
# 5001 www nginx: wo 0K 8M 12M 50M
# ^^^ ^^^ ^^^
# 独占 分摊后 含共享的全部
# 单个进程的详细构成
sudo grep -E '^(Rss|Pss|Shared_Clean|Shared_Dirty|Private_Clean|Private_Dirty|Swap):' \
/proc/5000/smaps_rollup
# Rss: 412340 kB
# Pss: 398120 kB <- 按份额分摊后的真实占用
# Shared_Clean: 18432 kB <- 共享的干净页(如共享库代码)
# Private_Dirty: 391860 kB <- 【进程独占的脏页】= 杀掉它能释放的内存
# Swap: 1024 kB
# 按 PSS 排序找出真正的内存大户
sudo bash -c 'for p in /proc/[0-9]*; do
[[ -r $p/smaps_rollup ]] || continue
pss=$(awk "/^Pss:/{print \$2}" $p/smaps_rollup 2>/dev/null)
[[ -n $pss ]] && echo "$pss ${p##*/} $(cat $p/comm)"
done | sort -rn | head -10'
| 指标 | 含义 | 什么时候看 |
|---|---|---|
| VSZ | 虚拟地址空间 | 几乎不用看(除了排查地址空间耗尽/mmap 泄漏) |
| RSS | 常驻物理内存(共享部分重复计算) | 快速判断单个进程的量级 |
| PSS | 共享部分按份额分摊 | 多进程同源服务(nginx/php-fpm)的准确统计 |
| USS | 进程独占的部分 | 「杀掉它能释放多少内存」 |
2.4 STAT 状态码
这是 ps 输出里信息量最大的一列,由「主状态 + 修饰符」组成:
主状态(第一个字母):
| 码 | 名称 | 含义 |
|---|---|---|
R |
Running / Runnable | 正在 CPU 上跑,或在运行队列里等 CPU |
S |
Interruptible Sleep | 可中断睡眠:等 IO、等锁、等信号(绝大多数进程都是这个) |
D |
Uninterruptible Sleep | 不可中断睡眠:通常在等磁盘/网络 IO 完成,信号都不响应 |
Z |
Zombie | 僵尸:已退出但父进程还没回收它的退出状态 |
T |
Stopped | 被信号停止(Ctrl-Z / SIGSTOP) |
t |
Tracing stop | 被调试器(gdb/strace)暂停 |
X |
Dead | 即将销毁(几乎看不到) |
I |
Idle | 内核线程空闲(内核 4.14+,把大量空闲内核线程从 D/S 里分离出来) |
修饰符(后面的字符):
| 码 | 含义 |
|---|---|
s |
会话首进程(session leader) |
l |
多线程(multi-threaded)—— Go/Java 服务都会有这个 |
+ |
在前台进程组里 |
< |
高优先级(nice < 0) |
N |
低优先级(nice > 0) |
L |
有页被锁在内存里(mlock) |
ps aux | awk 'NR==1 || $8 ~ /^[DZ]/' # 找出所有 D 和 Z 状态的进程
ps -eo stat,pid,comm | sort | uniq -c -w4 | sort -rn # 统计各状态的进程数
常见组合的解读:
Ss 会话首进程 + 睡眠 典型的守护进程(sshd、systemd)
Ssl 会话首 + 睡眠 + 多线程 典型的 Go/Java 服务
S+ 睡眠 + 前台 你在终端里跑的普通命令
R+ 运行 + 前台 正在算的前台命令
Ds 不可中断睡眠 + 会话首 ⚠️ IO 卡住了
Z 僵尸 ⚠️ 父进程没回收
T 停止 被 Ctrl-Z 挂起的任务
2.5 开篇第二问:D 状态为什么杀不掉
ps aux | grep ' D'
# root 8123 0.0 0.0 4288 1024 ? D 10:23 0:00 dd if=/dev/nfs/x of=/dev/null
sudo kill -9 8123
ps -p 8123
# 还在! <- kill -9 也无效
原因:D = Uninterruptible Sleep(不可中断睡眠)。进程在内核态执行某个操作(通常是磁盘或网络 IO)时,会把自己设为 TASK_UNINTERRUPTIBLE 状态睡下去,等待硬件完成。
这个状态下内核不检查信号队列,所以任何信号(包括 SIGKILL)都只是被挂在待处理队列里,等进程醒来才会处理。而它醒来的唯一条件是那个 IO 操作完成或超时。
为什么要设计成不可中断? 因为这些操作正处于不能被打断的临界区——比如已经向磁盘控制器提交了 DMA 请求,内核数据结构处于中间状态。如果此时被信号打断跳出去,会导致内核数据结构不一致或内存损坏。
# 怎么排查 D 状态进程卡在哪
sudo cat /proc/8123/stack # ✅ 内核栈:能看到卡在哪个内核函数
# [<0>] nfs_wait_bit_killable+0x2e/0x40
# [<0>] nfs4_run_open_task+0x11a/0x1b0
# ^^^ 一眼看出是 NFS 卡住了
sudo cat /proc/8123/wchan # 等待的内核函数名(更简短)
ps -eo pid,stat,wchan:30,comm | awk '$2 ~ /D/'
sudo cat /proc/8123/syscall # 当前正在执行的系统调用号与参数
# 常见的 D 状态原因(排查顺序)
# ① NFS/CIFS 服务器失联 -> mount -o soft,timeo=X 或 umount -f -l
# ② 磁盘故障 / 坏道 -> dmesg | grep -iE 'I/O error|ata|scsi'
# ③ 磁盘满 / IO 队列积压 -> iostat -xz 1 看 %util 和 await
# ④ 内核 bug 或驱动问题 -> dmesg 看有没有 hung_task 警告
# ⑤ 大量脏页回写(fsync 阻塞) -> grep -E 'Dirty|Writeback' /proc/meminfo
# 内核会主动报告长时间 D 状态的进程
dmesg | grep -A20 'hung_task'
# INFO: task dd:8123 blocked for more than 120 seconds.
cat /proc/sys/kernel/hung_task_timeout_secs # 120(默认阈值)
唯一的处理办法:解决底层 IO 问题(恢复 NFS、换盘),或者重启机器。kill -9 不会有任何效果。
D 状态还有一个重要影响:它会算进 load average(第 4 章)。
2.6 ps 常用配方
# ── 按资源排序(最高频)──
ps aux --sort=-%cpu | head -11 # CPU 前 10(注意减号表示降序)
ps aux --sort=-rss | head -11 # 内存前 10
ps -eo pid,ppid,user,%cpu,%mem,rss,stat,start,cmd --sort=-rss | head
# ── 自定义列(-o)──
ps -eo pid,ppid,pgid,sid,tty,stat,nice,pri,psr,nlwp,rss,etime,comm
# ^^^ 绑在哪个 CPU 上
# ^^^^ 线程数(nlwp = number of light-weight processes)
# ^^^^^ 已运行时长
ps -eo pid,comm,etimes --sort=-etimes | head # 运行最久的进程(etimes = 秒数)
ps -eo pid,comm,nlwp --sort=-nlwp | head # 线程最多的进程 ← 排查线程泄漏
ps -o pid,rss,vsz,cmd -C nginx # 按命令名筛选
# ── 树形 ──
ps -ejH # 树形(缩进表示层级)
ps auxf # BSD 风格的树形
ps -eo pid,ppid,cmd --forest
# ── 线程 ──
ps -eLf # 显示所有线程(L = threads)
ps -Lf -p 5000 # 某进程的所有线程
ps -eLo pid,tid,pcpu,stat,comm --sort=-pcpu | head # 哪个线程在吃 CPU
# ^^^ TID 才能定位到具体线程
# ── 按条件筛选 ──
ps -u app # 某用户的进程
ps -p 1234,5678 # 指定 PID
ps --ppid 1234 # 某进程的直接子进程
ps -C myapp # 按命令名(精确匹配)
ps -e -o pid,cmd | grep '[m]yapp' # 用 [] 技巧避免 grep 自己出现在结果里
# ── 完整命令行(长命令被截断时)──
ps -eo pid,args --width 500 # 加宽输出
ps auxww # ww 表示不截断
cat /proc/5000/cmdline | tr '\0' ' ' # ✅ 最可靠的方式
3. 进程状态机与僵尸
3.1 状态转换
fork()
|
v
+----------------+
+------->| R Runnable |<-------+ 被调度器选中 -> 上 CPU 执行
| | (就绪/运行) | | 时间片用完 -> 回到就绪队列
| +----------------+ |
| | | |
| 等待事件 | | 等待 IO | IO 完成 / 事件到达
| (可被信号 | | (不可打断) |
| 唤醒) v v |
| +-----------+ +-----------+ |
+---| S 睡眠 | | D 睡眠 |---+
| (可中断) | | (不可中断) |
+-----------+ +-----------+
| ^
SIGSTOP | | 【信号在这里不起作用】
Ctrl-Z v
+-----------+
| T 停止 | <---- SIGCONT / fg / bg 恢复
+-----------+
|
| exit() 或被信号杀死
v
+-----------+
| Z 僵尸 | ---- 父进程调用 wait() ----> 彻底销毁
+-----------+
3.2 开篇第四问:僵尸进程
ps aux | awk '$8 ~ /Z/'
# app 7001 0.0 0.0 0 0 ? Z 10:00 0:00 [myworker] <defunct>
# ^ ^ ^^^^^^^^^
# VSZ 和 RSS 都是 0 标记
僵尸是什么:进程已经执行完毕、内存和文件描述符都已释放,但内核还保留着它的进程表项(PCB),因为里面存着退出状态码,等父进程来取。
为什么必须存在这个状态? 因为父进程有权知道子进程是怎么结束的(正常退出?退出码几?被哪个信号杀的?)。如果子进程一退出就彻底消失,这个信息就丢了。所以内核保留一个「尸体」,等父进程 wait() 领取后才清理。
# 亲手造一个僵尸
cat > /tmp/zombie.c <<'EOF'
#include <unistd.h>
int main() {
if (fork() == 0) return 42; // 子进程立即退出
sleep(300); // 父进程【不调用 wait】,睡着
return 0;
}
EOF
gcc -o /tmp/zombie /tmp/zombie.c && /tmp/zombie &
ps aux | grep defunct
# 能看到一个 Z 状态的进程
三个关键结论:
# ① 僵尸【杀不掉】,因为它已经死了
sudo kill -9 7001 # 无效,进程已经不存在了,只剩一条记录
# ② 僵尸【几乎不占资源】——只占一个进程表项(几 KB 内核内存)+ 一个 PID
# 所以偶尔出现几个僵尸【不是问题】,不需要处理
# ③ 真正的问题是【大量僵尸】:它耗尽 PID 号,导致无法创建新进程
cat /proc/sys/kernel/pid_max # 32768 或 4194304
ps aux | awk '$8 ~ /Z/' | wc -l # 数一下有多少
处理方式:
# ✅ 正确做法:处理【父进程】,不是僵尸自己
ps -o ppid= -p 7001 # 找出父进程
# 6999
sudo kill -HUP 6999 # 让父进程重新读配置/重启(可能会去 wait)
sudo kill 6999 # 或者杀掉父进程
# 父进程死后,僵尸会被托孤给 PID 1,而 systemd 会立即 wait 掉它们 ✅
# ❌ 无效做法
sudo kill -9 <僵尸PID> # 对已死的进程发信号毫无意义
# 根治:修代码
# C: 在 SIGCHLD 处理函数里调用 waitpid(),或 signal(SIGCHLD, SIG_IGN)
# Go: 用 cmd.Wait() 而不是只 cmd.Start()
// Go 里的正确姿势
cmd := exec.Command("worker")
if err := cmd.Start(); err != nil { ... }
// ❌ 只 Start 不 Wait -> 子进程退出后变僵尸
// ✅ 必须 Wait(它内部会调 wait4 回收)
go func() {
if err := cmd.Wait(); err != nil {
log.Printf("worker exited: %v", err)
}
}()
// 如果完全不关心子进程结果,也必须 Wait 来回收
// Go 的 os/exec 没有 "fire and forget" 模式,这是有意的设计
容器场景的特殊性:
# 容器里 PID 1 是你的应用,如果它不回收孤儿进程,僵尸会一直积累
docker run --init myimage # ✅ --init 注入 tini 作为 PID 1,负责回收
# 或者在 Dockerfile 里
# ENTRYPOINT ["/sbin/tini", "--", "/app/myapp"]
# K8s 里
# spec.shareProcessNamespace: true 时要特别注意
# 或者用支持 reaping 的 init(如 dumb-init、tini)
4. top 与 load average
4.1 top 的输出
top - 17:30:01 up 12 days, 3:42, 2 users, load average: 8.12, 6.45, 4.20
Tasks: 312 total, 3 running, 308 sleeping, 0 stopped, 1 zombie
%Cpu(s): 25.3 us, 4.1 sy, 0.0 ni, 60.2 id, 9.8 wa, 0.0 hi, 0.6 si, 0.0 st
MiB Mem : 15884.0 total, 231.5 free, 4321.2 used, 11331.3 buff/cache
MiB Swap: 2048.0 total, 2048.0 free, 0.0 used. 10234.5 avail Mem
PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND
5000 app 20 0 2145678 412340 18432 S 98.7 2.5 1:23.45 myapp
第三行 %Cpu(s) 是排查性能问题最重要的一行:
| 字段 | 全称 | 含义 | 高了说明什么 |
|---|---|---|---|
us |
user | 用户态 CPU | 应用自己在算(正常的忙) |
sy |
system | 内核态 CPU | 系统调用频繁:大量 IO、大量小包网络、频繁进程创建 |
ni |
nice | 低优先级进程的用户态 | — |
id |
idle | 空闲 | — |
wa |
iowait | 等待 IO 的时间 | 磁盘是瓶颈(配合 iostat -xz 1 确认) |
hi |
hardware irq | 硬中断 | 网卡/磁盘中断风暴 |
si |
software irq | 软中断 | 网络包处理(高 PPS 场景,看 /proc/softirqs) |
st |
steal | 被 hypervisor 抢走的时间 | 云主机上邻居在抢 CPU,或你超卖了 |
两个云上特有的排查点:
# ① st(steal)高:说明你的 vCPU 被宿主机分给别人了
# 表现:应用什么都没干但响应变慢,us 不高但吞吐下降
# 处理:换实例类型(独享型 CPU)、换可用区、向云厂商反馈
mpstat -P ALL 1 # 每个核的详细情况,看 %steal
# ② si(软中断)高:通常是网络包处理占用
cat /proc/softirqs | head -5 # 看 NET_RX 的增长
sar -n DEV 1 # 每秒收发包数
# 处理:开启 RPS/RFS 把软中断分散到多核、用 XDP/DPDK、升级网卡驱动
4.2 开篇第五问:load average 的真实含义
load average: 8.12, 6.45, 4.20
^^^^ ^^^^ ^^^^
1分钟 5分钟 15分钟的指数移动平均
最常见的误解是「load average 是 CPU 使用率」。它不是。
Linux 的 load average 统计的是**「运行队列长度 + 处于 D 状态(不可中断睡眠)的进程数」**的移动平均。
load = 正在 CPU 上跑的进程数
+ 在运行队列里等 CPU 的进程数
+ 【处于 D 状态等 IO 的进程数】 ← 关键!这是 Linux 特有的
所以「load 8 但 CPU 只用 20%」有一个标准答案:大量进程卡在 D 状态等 IO。
# 验证:数一下 R 和 D 状态的进程
ps -eo stat | grep -c '^R' # 运行中
ps -eo stat | grep -c '^D' # 等 IO 的 ← 如果这个数很大,就是 IO 瓶颈
# 交叉验证
top # 看 wa(iowait)是否很高
iostat -xz 1 # 看 %util 是否接近 100%、await 是否很大
vmstat 1 # 看 b 列(blocked 进程数)
# procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu-----
# r b swpd free buff cache si so bi bo in cs us sy id wa st
# 1 7 0 236892 12345 1234567 0 0 4096 512 800 1200 20 4 66 10 0
# ^ b=7 有 7 个进程在等 IO
怎么判断 load 高不高:
nproc # 8(逻辑 CPU 数)
# 经验规则:
# load < nproc -> 健康
# load ≈ nproc -> 饱和(刚好用满)
# load > nproc × 2 -> 明显过载,需要排查
# 但一定要结合 %wa 和 D 状态进程数判断是 CPU 瓶颈还是 IO 瓶颈
# 一行判断
awk -v c=$(nproc) '{printf "load1=%.2f cores=%d ratio=%.2f\n", $1, c, $1/c}' /proc/loadavg
# 三个数字的趋势含义
# 8.12, 6.45, 4.20 -> 递减顺序 = 负载【正在上升】 ← 要关注
# 4.20, 6.45, 8.12 -> 递增顺序 = 负载【正在下降】 ← 高峰已过
Linux 与其他 Unix 的差异:其他 Unix(如 Solaris)的 load 只算 CPU 运行队列,不含 D 状态。所以「Linux 的 load 会因为磁盘慢而飙高」是 Linux 特有的行为,这个设计当年也有争议(它把两种不同的瓶颈混在了一个指标里)。
4.3 top 的交互键
top
# 排序
# P 按 CPU 排序(默认)
# M 按内存排序
# T 按累计 CPU 时间排序
# N 按 PID 排序
# 显示
# 1 【展开每个 CPU 核心的单独统计】← 排查单核打满时必用
# H 显示线程而不是进程
# c 显示完整命令行
# V 树形显示
# m / t 切换内存/CPU 的显示样式(图形条)
# E / e 切换内存单位(KB/MB/GB)
# x / y 高亮排序列 / 高亮运行中的进程
# 过滤
# u 只看某个用户
# o 按条件过滤(如 %CPU>10)
# -p 启动时指定 PID:top -p 1234,5678
# 操作
# k kill 一个进程(会问 PID 和信号)
# r renice 改优先级
# d/s 改刷新间隔
# W 保存当前配置到 ~/.toprc
# q 退出
# 批处理模式(脚本/日志采集用)
top -bn1 | head -20 # b=batch n1=只刷新一次
top -bn1 -o %CPU | head -15 # 按 CPU 排序
top -bd 5 -n 12 > top.log # 每 5 秒采样一次,共 12 次,写日志
4.4 htop 与其他替代
sudo apt install htop
htop
# 优势:彩色、鼠标可点、树形(F5)、搜索(F3)、过滤(F4)、
# 直接看到每核使用率、可视化的内存条、更容易 kill(F9)
# 常用键:F5 树形 F6 排序 F9 kill F4 过滤 / 搜索 u 选用户 H 隐藏线程
# 其他现代工具
btop # 更漂亮,带图表
glances # 一屏看 CPU/内存/磁盘/网络/容器
atop # ✅ 会【记录历史】,能回看昨天某时刻的进程状态(事后排查神器)
sudo atop -r /var/log/atop/atop_20260812 -b 14:00
nmon # 交互式性能监控
pidstat 1 # 每秒输出每个进程的 CPU/IO/内存(sysstat 包)
pidstat -d 1 # 只看 IO
pidstat -t -p 5000 1 # 看线程级
5. 信号与 kill
信号的完整机制(为什么是异步的、不可靠信号的历史、信号与线程)留给第 29 篇,这里只讲操作。
5.1 常用信号
kill -l # 列出所有信号
| 号 | 名称 | 默认行为 | 用途 | 能否被捕获 |
|---|---|---|---|---|
| 1 | SIGHUP |
终止 | 终端挂断;守护进程约定用它「重载配置」 | ✅ |
| 2 | SIGINT |
终止 | Ctrl-C |
✅ |
| 3 | SIGQUIT |
终止 + core | Ctrl-\;Go 程序收到它会打印所有 goroutine 栈 |
✅ |
| 9 | SIGKILL |
强制终止 | 不可捕获、不可忽略,内核直接干掉 | ❌ |
| 11 | SIGSEGV |
终止 + core | 段错误(非法内存访问) | ✅ |
| 13 | SIGPIPE |
终止 | 往已关闭的管道写(09 篇 3.5) | ✅ |
| 15 | SIGTERM |
终止 | 默认的 kill,礼貌地请求退出 |
✅ |
| 17 | SIGCHLD |
忽略 | 子进程状态改变(回收僵尸的触发点) | ✅ |
| 18 | SIGCONT |
继续 | 恢复被停止的进程 | ✅ |
| 19 | SIGSTOP |
停止 | 不可捕获,强制暂停 | ❌ |
| 20 | SIGTSTP |
停止 | Ctrl-Z(可捕获,所以能自定义处理) |
✅ |
| 10 / 12 | SIGUSR1 / SIGUSR2 |
终止 | 留给应用自定义(nginx 用 USR1 重开日志) | ✅ |
5.2 kill 系列命令
# ── kill:按 PID ──
kill 1234 # 默认发 SIGTERM(15)
kill -15 1234 # 显式
kill -TERM 1234 # 用名字
kill -9 1234 # SIGKILL
kill -HUP 1234 # 重载配置
kill -0 1234 # 【不发信号】,只测试进程是否存在 + 有无权限
# 退出码 0 = 存在 1 = 不存在
kill -- -5001 # 【负数 = 整个进程组】(注意 -- 分隔)
kill %1 # 按作业号(见第 6 章)
# ── pkill / pgrep:按名字与属性 ──
pgrep myapp # 列出匹配的 PID
pgrep -l myapp # 带名字
pgrep -a myapp # 带完整命令行
pgrep -f 'myapp --config' # ✅ -f 匹配【完整命令行】而不只是进程名
pgrep -u app myapp # 限定用户
pgrep -n myapp # newest:最新启动的那个
pgrep -o myapp # oldest:最早启动的
pgrep -c myapp # 只输出数量
pgrep -P 1234 # 某进程的子进程
pkill myapp # 杀掉匹配的(默认 TERM)
pkill -9 -f 'myapp --config' # 强杀,按完整命令行匹配
pkill -u app # 杀掉某用户的所有进程 ⚠️ 危险
pkill -HUP nginx # 给 nginx 发 HUP
# ⚠️ pkill/pgrep 的模式是【正则】且默认只匹配进程名前 15 个字符
pgrep myverylongprocessname # 可能匹配不到!
pgrep -f myverylongprocessname # ✅ 用 -f
# ── killall:按精确的名字 ──
killall myapp # 精确匹配进程名(不是正则)
killall -i myapp # 交互确认
killall -u app # 某用户的
killall -e myverylongname # -e 要求完全匹配长名字
# ⚠️ 在 Solaris 上 killall 的含义是「杀掉所有进程」,跨平台脚本别用它
# ── 给进程组/会话发信号 ──
kill -TERM -$(ps -o pgid= -p 1234 | tr -d ' ') # 整个进程组
pkill -g 5001 # 按 PGID
pkill -s 1234 # 按会话 ID
5.3 SIGTERM vs SIGKILL:为什么不该随手 -9
kill -15 1234 # SIGTERM:进程【可以捕获】,执行清理后自行退出
kill -9 1234 # SIGKILL:内核直接销毁,进程【没有任何机会】做清理
kill -9 的后果:
✗ 不会 flush 缓冲区 -> 日志丢失、写入的数据丢失
✗ 不会关闭连接 -> 客户端看到 connection reset,数据库留下未提交事务
✗ 不会删除临时文件 -> /tmp 里留垃圾、pid 文件残留导致下次启动失败
✗ 不会释放锁 -> 分布式锁、文件锁残留,其他实例无法接管
✗ 子进程不会被通知 -> 留下孤儿进程继续跑("僵尸服务")
正确的关闭顺序:
# 手动
kill -TERM 1234
sleep 10 # 给它时间清理
kill -0 1234 2>/dev/null && kill -9 1234 # 还活着才强杀
# 一行版
timeout 10 sh -c 'kill -TERM 1234; while kill -0 1234 2>/dev/null; do sleep 0.5; done' \
|| kill -9 1234
# ✅ 用 systemd 管(它内置了这套逻辑)
# [Service]
# KillSignal=SIGTERM 先发这个
# TimeoutStopSec=30 等 30 秒
# KillMode=mixed 主进程发 TERM,剩余的发 KILL
# SendSIGKILL=yes 超时后发 KILL(默认 yes)
systemctl stop myapp # 自动走完整流程
Go 服务必须处理 SIGTERM(否则 K8s 滚动更新时会丢请求):
func main() {
srv := &http.Server{Addr: ":8080", Handler: router}
go func() {
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("listen: %v", err)
}
}()
// 捕获 SIGTERM 和 SIGINT
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGTERM, syscall.SIGINT)
<-quit
log.Println("收到退出信号,开始优雅关闭…")
// 给 30 秒处理完存量请求(要小于 K8s 的 terminationGracePeriodSeconds)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil { // 停止接受新连接,等存量请求完成
log.Printf("强制关闭: %v", err)
}
// 这里还要做:关闭数据库连接池、flush 日志、注销服务发现、等待后台 goroutine
db.Close()
logger.Sync()
log.Println("已退出")
}
配套要注意的两点:
# ① 容器 entrypoint 必须用 exec(09 篇 2.4)
# 否则 SIGTERM 发给了 shell,你的程序根本收不到
# ② K8s 的 preStop hook + terminationGracePeriodSeconds
# spec:
# terminationGracePeriodSeconds: 45 # 要大于你代码里的 30 秒
# containers:
# - lifecycle:
# preStop:
# exec:
# command: ["sleep", "5"] # 等 Service 的 endpoint 摘除生效
一个调试技巧:SIGQUIT 让 Go 程序打印所有 goroutine 的栈,排查死锁和 goroutine 泄漏极其有用:
kill -QUIT 1234 # Go 程序会把所有 goroutine 栈打到 stderr 然后退出
# 想不退出只 dump 栈,用 pprof:
curl localhost:6060/debug/pprof/goroutine?debug=2
6. 作业控制与后台运行
6.1 前台、后台、挂起
sleep 300 # 前台运行:占着终端
# Ctrl-Z # 挂起(发 SIGTSTP),进程变成 T 状态
# [1]+ Stopped sleep 300
jobs # 查看本 shell 的作业
# [1]+ Stopped sleep 300
jobs -l # 带 PID
# [1]+ 7001 Stopped sleep 300
jobs -p # 只输出 PID
jobs -r # 只看运行中的
jobs -s # 只看停止的
bg # 让它在【后台继续】(发 SIGCONT)
bg %1 # 指定作业号
fg # 拿回【前台】
fg %1
sleep 300 & # 直接放后台启动
# [2] 7002
echo $! # 最后一个后台进程的 PID
wait # 等所有后台作业结束
wait %1 # 等指定作业
wait $PID # 等指定 PID
kill %1 # 按作业号杀
# 作业号引用:%1 %2 / %+ 或 %% 当前作业 / %- 上一个作业 / %sleep 按名字前缀
6.2 开篇第三问:关掉终端为什么进程会死
机制:终端关闭(或 ssh 断开)时,内核给会话里的所有进程发送 SIGHUP(hang up,源自电话挂断)。SIGHUP 的默认行为是终止进程。
ssh 断开
|
v
内核给会话首进程(bash)发 SIGHUP
|
├─ bash 收到后,转发 SIGHUP 给它的所有【作业】
| |
| v
| 后台的 sleep 300 收到 SIGHUP -> 默认行为终止 -> 死了
|
└─ bash 自己也退出
四种让进程活下来的方式:
| 方式 | 原理 | 能扛住关终端 | 能扛住 ssh 断开 | 输出去哪 |
|---|---|---|---|---|
cmd & |
只是放后台,仍在同一会话 | ❌ | ❌ | 终端 |
nohup cmd & |
忽略 SIGHUP | ✅ | ✅ | nohup.out |
cmd & disown |
从 shell 的作业表里移除,bash 不再转发 SIGHUP | ✅ | ✅ | 终端(终端消失后写入失败) |
setsid cmd |
创建新会话,脱离原终端 | ✅ | ✅ | 终端(同上) |
tmux/screen |
进程在另一个持久会话里 | ✅ | ✅ | 可随时重连查看 |
| systemd 服务 | 由 PID 1 管理,与终端无关 | ✅ | ✅ | journald |
# ── nohup:忽略 SIGHUP ──
nohup ./myapp &
# nohup: ignoring input and appending output to 'nohup.out'
# ^^ 自动把 stdout/stderr 重定向到 nohup.out(因为终端可能消失)
nohup ./myapp > /var/log/myapp.log 2>&1 & # ✅ 显式指定日志位置
nohup ./myapp > /var/log/myapp.log 2>&1 < /dev/null &
# ^^^^^^^^^^^^^ 连 stdin 也断开,更彻底
# ── disown:从作业表移除 ──
./myapp &
disown # 移除最后一个作业
disown %1 # 移除指定作业
disown -a # 移除所有
disown -h %1 # 不移除,只标记「不发 SIGHUP」
# ⚠️ disown 是【事后补救】:进程已经启动了才想起来要保护它
# ⚠️ 它不重定向输出,终端关闭后进程往已关闭的 fd 写会报错甚至崩溃
# ── setsid:新建会话 ──
setsid ./myapp > /var/log/myapp.log 2>&1 &
ps -o pid,ppid,sid,tty,comm -p $(pgrep myapp)
# PID PPID SID TT COMMAND
# 8001 1 8001 ? myapp
# ^^^ ^^^^^^ 没有控制终端,PPID 直接是 1
# ✅ 这是最"干净"的方式:进程完全脱离原会话
# ── tmux(推荐用于交互式长任务)──
tmux new -s work # 创建会话
# ... 跑你的任务 ...
# Ctrl-B 然后 D # detach(分离,任务继续跑)
tmux ls # 列出会话
tmux attach -t work # 重新连上
tmux kill-session -t work
# ✅ 优势:能随时回来看输出、能开多窗格、断线自动保留
生产环境的正确答案是 systemd:
# nohup/setsid/tmux 都是「临时」手段,生产服务应该用 systemd(第 14 篇细讲)
sudo tee /etc/systemd/system/myapp.service > /dev/null <<'EOF'
[Unit]
Description=My App
After=network-online.target
[Service]
Type=simple
User=myapp
ExecStart=/usr/local/bin/myapp
Restart=always # ✅ 崩溃自动重启(nohup 做不到)
RestartSec=5
StandardOutput=journal # ✅ 日志进 journald(不会写爆某个文件)
StandardError=journal
[Install]
WantedBy=multi-user.target
EOF
sudo systemctl daemon-reload && sudo systemctl enable --now myapp
# systemd 相对 nohup 的优势
# ✓ 崩溃自动重启、启动失败告警
# ✓ 开机自启
# ✓ 日志统一管理与轮转
# ✓ 资源限制(CPU/内存配额)
# ✓ 依赖关系(等数据库就绪)
# ✓ 优雅停止(TERM -> 等待 -> KILL)
# ✓ 安全加固(沙箱选项,见 10 篇第 8 章)
6.3 huponexit 与 ssh 的行为
shopt | grep huponexit
# huponexit off <- Ubuntu/Debian 默认关闭
# 关闭时:bash 正常 exit 【不会】给后台作业发 SIGHUP
# 但 ssh 【异常断开】(网络掉线)时,内核仍会发 SIGHUP
shopt -s huponexit # 打开:连正常 exit 也发 SIGHUP
# 所以有个容易困惑的现象:
# - 你 exit 退出 ssh,后台任务还活着(因为 huponexit=off)
# - 但网络掉线导致 ssh 断开,后台任务死了(内核发的 SIGHUP)
# ✅ 结论:不要依赖 huponexit 的默认值,重要任务一律 nohup / tmux / systemd
7. /proc/<pid>:进程的一切
这是排查进程问题最强的工具,因为它是内核数据的直接视图。
PID=5000
ls /proc/$PID/
| 路径 | 内容 | 典型用途 |
|---|---|---|
cmdline |
完整命令行(\0 分隔) |
看真实启动参数(ps 会截断) |
comm |
进程名(可被程序修改) | — |
exe |
指向可执行文件的软链接 | 程序被删了也能看到((deleted) 标记) |
cwd |
当前工作目录 | 排查相对路径问题 |
root |
进程的根目录 | chroot / 容器场景 |
environ |
环境变量(\0 分隔) |
排查「配置没生效」 |
fd/ |
所有打开的文件描述符 | fd 泄漏、已删除文件、端口 |
fdinfo/ |
每个 fd 的详情(偏移量、标志) | 看读写位置 |
status |
状态汇总(人类可读) | 一站式查看:内存、线程数、uid、信号掩码 |
stat / statm |
同上但机器可读 | 脚本解析 |
limits |
资源限制的实际值 | 排查 too many open files |
stack |
内核栈 | D 状态卡在哪个内核函数 |
wchan |
等待的内核函数名 | 同上,更简短 |
syscall |
当前系统调用 | 卡在哪个 syscall |
maps / smaps |
内存映射区域 | 内存泄漏、看加载了哪些库 |
smaps_rollup |
内存汇总(含 PSS) | 准确的内存占用 |
task/ |
每个线程一个子目录 | 线程级排查 |
sched / schedstat |
调度统计 | 等待 CPU 的时间 |
io |
IO 统计(读写字节数) | 哪个进程在读写 |
net/ |
网络统计(是所在 netns 的) | 容器网络排查 |
oom_score / oom_score_adj |
OOM 评分与调整值 | 防止关键进程被 OOM 杀 |
mountinfo |
挂载表 | 容器的挂载视图 |
ns/ |
各命名空间的引用 | 判断进程在哪个 namespace |
# ── 实战配方 ──
# 完整启动命令(ps 截断时)
tr '\0' ' ' < /proc/$PID/cmdline; echo
# 环境变量(排查配置没生效)
sudo tr '\0' '\n' < /proc/$PID/environ | sort
sudo tr '\0' '\n' < /proc/$PID/environ | grep -i 'GOMEMLIMIT\|DATABASE'
# 程序真实路径(即使二进制被删了/被替换了)
sudo ls -l /proc/$PID/exe
# lrwxrwxrwx ... /proc/5000/exe -> /usr/local/bin/myapp (deleted)
# ^^^^^^^^^ 二进制已被删除,
# 说明部署时替换了文件但没重启服务
# fd 数量与内容(fd 泄漏排查)
sudo ls /proc/$PID/fd | wc -l # 当前打开的 fd 数
sudo cat /proc/$PID/limits | grep 'open files' # 上限
# Max open files 1024 4096
# ^^^^ soft ^^^^ hard
sudo ls -l /proc/$PID/fd | awk '{print $NF}' | sort | uniq -c | sort -rn | head
# ^^ 统计打开最多的是什么类型
sudo ls -l /proc/$PID/fd | grep -c socket # socket 数量
sudo ls -l /proc/$PID/fd | grep deleted # 已删除但仍占空间的文件
# 状态汇总(最有用的单个文件)
grep -E '^(Name|State|Pid|PPid|Threads|VmRSS|VmSize|VmSwap|voluntary_ctxt|nonvoluntary_ctxt)' \
/proc/$PID/status
# Name: myapp
# State: S (sleeping)
# Threads: 24 <- 线程数(排查线程泄漏)
# VmRSS: 412340 kB <- 常驻内存
# VmSwap: 1024 kB <- 被换出的部分
# nonvoluntary_ctxt_switches: 123456 <- 被抢占的次数(高说明 CPU 竞争激烈)
# 线程级排查
ls /proc/$PID/task/ # 每个 TID 一个目录
for t in /proc/$PID/task/*; do
echo "$(basename $t) $(cat $t/comm) $(cat $t/stat | awk '{print $3}')"
done | head -20 # TID、名字、状态
# IO 统计
cat /proc/$PID/io
# read_bytes: 1234567890 <- 从块设备真实读取的字节数
# write_bytes: 987654321 <- 真实写入的
# cancelled_write_bytes: 0
# 防止关键进程被 OOM 杀掉
echo -1000 | sudo tee /proc/$PID/oom_score_adj # -1000 = 几乎不会被选中
cat /proc/$PID/oom_score # 当前评分(0~1000)
# systemd 里配:OOMScoreAdjust=-500
8. 资源限制与优先级
8.1 ulimit
ulimit -a # 查看当前 shell 的所有限制
# core file size (blocks, -c) 0
# data seg size (kbytes, -d) unlimited
# file size (blocks, -f) unlimited
# pending signals (-i) 62841
# max locked memory (kbytes, -l) 8192
# open files (-n) 1024 <- 最常需要调的
# pipe size (512 bytes, -p) 8
# stack size (kbytes, -s) 8192
# cpu time (seconds, -t) unlimited
# max user processes (-u) 62841 <- 防 fork 炸弹
# virtual memory (kbytes, -v) unlimited
ulimit -n # 只看 open files 的 soft limit
ulimit -Hn # hard limit(上限,普通用户只能降不能升)
ulimit -Sn # soft limit(实际生效值)
ulimit -n 65535 # 临时提升(不能超过 hard limit)
soft 与 hard 的关系:soft 是实际生效值,hard 是 soft 的上限。普通用户只能降低 hard limit,只有 root 能提高。
# 持久化:/etc/security/limits.conf(对【登录会话】生效,走 PAM)
sudo tee -a /etc/security/limits.d/99-myapp.conf > /dev/null <<'EOF'
myapp soft nofile 65535
myapp hard nofile 65535
myapp soft nproc 4096
myapp hard nproc 8192
* soft core 0
EOF
# 字段:<域> <类型> <项目> <值>
# 域:用户名 / @组名 / * 通配 / uid 范围
# ⚠️ 必须【重新登录】才生效;且它【不影响 systemd 启动的服务】!
# ✅ systemd 服务要在 unit 里设(这是最常见的坑)
# [Service]
# LimitNOFILE=65535
# LimitNPROC=4096
# LimitCORE=infinity
systemctl show myapp -p LimitNOFILE # 验证实际生效值
cat /proc/$(pgrep -o myapp)/limits # ✅ 最可靠:看进程真实的限制
# 系统级的全局上限
cat /proc/sys/fs/file-max # 整个系统能打开的 fd 总数
sysctl fs.file-nr # 已分配 / 已用 / 上限
sudo sysctl -w fs.file-max=2097152
echo 'fs.file-max=2097152' | sudo tee /etc/sysctl.d/99-limits.conf
「too many open files」的完整排查链:
# ① 确认是哪个进程
sudo lsof | awk '{print $2}' | sort | uniq -c | sort -rn | head # 慢但全面
# 更快的方式
for p in /proc/[0-9]*; do echo "$(ls $p/fd 2>/dev/null | wc -l) ${p##*/}"; done \
| sort -rn | head
# ② 看它的限制与当前用量
PID=5000
sudo ls /proc/$PID/fd | wc -l # 当前
grep 'open files' /proc/$PID/limits # 上限
# ③ 看 fd 都是什么(判断是泄漏还是真的需要这么多)
sudo ls -l /proc/$PID/fd | awk '{print $NF}' | sed 's/[0-9]*$//' | sort | uniq -c | sort -rn
# 大量 socket -> 连接没关闭(HTTP client 没 Close Body、连接池配置错)
# 大量同一个文件 -> 文件句柄泄漏
# 大量 pipe -> 子进程管道没关
# ④ 提升限额(如果确实需要)+ 修代码(如果是泄漏)
// Go 里最常见的 fd 泄漏:HTTP 响应体没关
resp, err := http.Get(url)
if err != nil { return err }
defer resp.Body.Close() // ✅ 必须,否则连接不会归还池、fd 不释放
io.Copy(io.Discard, resp.Body) // ✅ 还要读完,否则连接无法复用
// 另一个:没设超时导致连接堆积
client := &http.Client{
Timeout: 10 * time.Second, // ✅ 整体超时
Transport: &http.Transport{
MaxIdleConns: 100,
MaxIdleConnsPerHost: 10,
IdleConnTimeout: 90 * time.Second,
},
}
8.2 nice / renice:CPU 优先级
nice -n 10 ./batch_job # 以低优先级启动(nice 值越大优先级越低)
nice -n -5 ./important # 高优先级(需要 root)
renice -n 10 -p 1234 # 改已运行进程的优先级
renice -n 5 -u app # 改某用户所有进程的
renice -n 10 -g 5001 # 改整个进程组的
ps -eo pid,ni,pri,comm | head
# PID NI PRI COMMAND
# 1234 0 19 bash
# ^^ nice 值范围 -20(最高优先级)到 19(最低)
# ^^^ 内核内部的优先级(与 nice 反向)
# ⚠️ nice 只影响【CPU 时间的分配比例】,不影响:
# - 内存分配
# - IO 优先级(那是 ionice)
# - 在 CPU 空闲时的执行(低优先级进程照样能跑满)
# 所以它只在【CPU 竞争】时起作用
8.3 ionice:IO 优先级
ionice -c 3 ./backup_job # class 3 = idle:只在磁盘空闲时才做 IO
ionice -c 2 -n 7 ./job # class 2 = best-effort,级别 0~7(7 最低)
ionice -c 1 -n 0 ./critical # class 1 = realtime(需要 root,谨慎)
ionice -p 1234 # 查看某进程的 IO 优先级
ionice -c 3 -p 1234 # 改已运行进程的
# 典型用法:让备份/压缩任务不影响线上服务
ionice -c 3 nice -n 19 tar -czf backup.tar.gz /data
# ^^ CPU 和 IO 都降到最低
# ⚠️ ionice 只对 CFQ/BFQ 调度器有效,对 none/mq-deadline(NVMe 常用)基本无效
cat /sys/block/sda/queue/scheduler
8.4 用 systemd 做资源控制(推荐)
# 比 ulimit/nice 强得多:底层是 cgroup(第 36 篇细讲)
# [Service]
# CPUQuota=50% # 最多用 0.5 核
# CPUWeight=100 # 相对权重(竞争时的份额)
# MemoryMax=2G # 内存硬限制(超了就 OOM)
# MemoryHigh=1.5G # 软限制(超了开始激进回收)
# TasksMax=4096 # 最大进程/线程数
# IOWeight=100 # IO 权重
# IOReadBandwidthMax=/dev/sda 50M # IO 带宽限制
# LimitNOFILE=65535 # fd 上限
# Nice=10 # CPU nice 值
# OOMScoreAdjust=-500 # 降低被 OOM 杀的概率
systemctl show myapp | grep -E 'CPUQuota|MemoryMax|TasksMax|LimitNOFILE'
systemd-cgtop # ✅ 按 cgroup 看资源占用(哪个服务在吃资源)
systemctl status myapp # 输出里有 CPU/Memory/Tasks 的当前用量
# 临时给一个命令加限制(不用写 unit)
systemd-run --scope -p MemoryMax=1G -p CPUQuota=50% ./myapp
9. 实战排查
9.1 CPU 打满
# ① 定位进程
top -bn1 -o %CPU | head -15
ps aux --sort=-%cpu | head -5
# ② 定位【线程】(%CPU 100% 但机器有 8 核 -> 可能是单线程死循环)
top -H -p $PID # H = 显示线程
ps -eLo pid,tid,pcpu,stat,comm --sort=-pcpu -p $PID | head
# ^^^ TID
# ③ 看它在干什么
sudo strace -c -p $PID -f # 统计系统调用(-c 汇总,-f 含线程)
# 按 Ctrl-C 结束后会给出各 syscall 的次数与耗时排名
sudo strace -p $PID -f -e trace=network # 只看网络相关
sudo perf top -p $PID # ✅ 采样看哪个函数在吃 CPU(最有用)
sudo perf record -F 99 -p $PID -g -- sleep 30 && sudo perf report
# ④ Go 服务:直接用 pprof(比 perf 更准确,有符号信息)
curl -o cpu.prof 'http://localhost:6060/debug/pprof/profile?seconds=30'
go tool pprof -http=:8080 cpu.prof
# 或者看当前所有 goroutine(排查死锁、goroutine 泄漏)
curl 'http://localhost:6060/debug/pprof/goroutine?debug=2' | head -50
curl 'http://localhost:6060/debug/pprof/goroutine?debug=1' | head -20 # 汇总视图
# ⑤ 区分是 us 还是 sy 高
top # 看 %Cpu(s) 那一行
# us 高 -> 应用在算(业务逻辑、GC、正则回溯、序列化)
# sy 高 -> 系统调用频繁(大量小 IO、频繁 fork、大量小包网络、锁竞争进内核)
sudo strace -c -p $PID -f # sy 高时用它找出是哪个 syscall
9.2 进程「卡死」
# ① 先看状态
ps -o pid,stat,wchan:30,comm -p $PID
# STAT=D -> IO 卡住(见 2.5)
# STAT=S -> 在等什么(正常等待,还是死锁?)
# STAT=T -> 被停止了(谁发的 SIGSTOP?)
# STAT=Z -> 已经死了(僵尸)
# STAT=R 但没进展 -> 死循环
# ② D 状态:看内核栈
sudo cat /proc/$PID/stack
dmesg | grep -i 'hung task'
# ③ S 状态:看它在等什么
sudo cat /proc/$PID/wchan; echo
sudo cat /proc/$PID/syscall # 当前的系统调用
sudo strace -p $PID # 挂上去看(如果完全没输出,说明真的卡在某个 syscall)
sudo lsof -p $PID | tail -20 # 打开了什么(等某个文件?等某个 socket?)
ss -tnp | grep $PID # 网络连接状态(等对端响应?)
# ④ 看线程级(多线程程序某个线程死锁)
ps -Lo pid,tid,stat,wchan:25,comm -p $PID
for t in /proc/$PID/task/*; do
echo "TID $(basename $t): $(cat $t/stat | awk '{print $3}') $(cat $t/wchan 2>/dev/null)"
done
# ⑤ Go 服务死锁:SIGQUIT 打印所有 goroutine 栈
kill -QUIT $PID # 会退出并打印栈
# 不想退出就用 pprof
curl 'http://localhost:6060/debug/pprof/goroutine?debug=2'
# 找 "chan receive"、"sync.Mutex.Lock"、"select" 卡了很久的 goroutine
# 输出里的 "goroutine 123 [chan receive, 25 minutes]" —— 括号里的时间是关键线索
9.3 进程数/线程数爆掉
# ① fork 失败:resource temporarily unavailable
ulimit -u # 用户最大进程数
ps -eo user | sort | uniq -c | sort -rn | head # 哪个用户开的进程最多
cat /proc/sys/kernel/pid_max # 系统 PID 上限
ps -eLf | wc -l # 系统总线程数
cat /proc/sys/kernel/threads-max # 线程数上限
# ② 线程泄漏
ps -eo pid,nlwp,comm --sort=-nlwp | head # 线程最多的进程
watch -n5 'ps -o nlwp= -p 5000' # 观察是否持续增长
cat /proc/5000/status | grep Threads
# Go 里的线程数异常通常源于:
# - 大量阻塞的 cgo 调用(每个阻塞的 cgo 调用会占一个 OS 线程)
# - 大量阻塞的系统调用
# - runtime.LockOSThread 使用不当
# 上限由 runtime.SetMaxThreads 控制(默认 10000,超了直接 panic)
# ③ goroutine 泄漏(不是 OS 线程,但会吃内存)
curl 'http://localhost:6060/debug/pprof/goroutine?debug=1' | head -5
# goroutine profile: total 152847
# ^^^^^^ 数量异常增长就是泄漏
# 常见原因:channel 无人接收、context 没传导致 goroutine 永久阻塞、
# time.Ticker 没 Stop、HTTP 请求没设超时
10. 知识点扩展
10.1 命令速查
| 命令 | 用途 | 关键参数/技巧 |
|---|---|---|
ps aux |
看资源占用 | --sort=-%cpu / --sort=-rss |
ps -ef |
看进程关系(有 PPID) | -ejH 树形 |
ps -eo |
自定义列 | pid,ppid,stat,nlwp,etimes,wchan,psr,comm |
ps -eLf |
显示线程 | -Lo pid,tid,pcpu |
pstree -ps PID |
看祖先链 | 排查「谁启动了它」 |
top |
实时监控 | 1 展开每核 / H 线程 / -bn1 批处理 |
htop |
交互式 top | F5 树形 F9 kill |
atop |
能回看历史 | atop -r /var/log/atop/atop_YYYYMMDD -b 14:00 |
pidstat 1 |
每进程的 CPU/IO | -d 只看 IO -t 线程级 |
vmstat 1 |
系统级概况 | 看 r(运行队列)b(阻塞数)wa |
mpstat -P ALL 1 |
每核 CPU | 看 %steal(云上被抢) |
iostat -xz 1 |
磁盘 IO | 看 %util、await |
pgrep -af PAT |
按命令行找进程 | -f 匹配完整命令行 |
pkill -f PAT |
按命令行杀 | 危险,先用 pgrep 确认 |
kill -0 PID |
测试进程是否存在 | 不发信号,看退出码 |
kill -QUIT PID |
Go 打印所有 goroutine 栈 | 调试死锁 |
lsof -p PID |
进程打开的一切 | +L1 已删除文件 -i 网络 |
nice/renice |
CPU 优先级 | -20(高) ~ 19(低) |
ionice -c 3 |
IO 降到 idle | 备份任务用 |
ulimit -a |
资源限制 | -n fd 上限 -u 进程数 |
taskset -c 0-3 cmd |
绑定 CPU 核 | -p PID 改运行中的 |
systemd-cgtop |
按服务看资源 | 比 top 更适合看「哪个服务在吃资源」 |
timeout 10 cmd |
超时自动杀 | -s KILL 指定信号 |
nohup/setsid/disown |
后台常驻 | 见 6.2 对比表 |
tmux/screen |
可重连的会话 | 交互式长任务 |
10.2 与 Go 服务相关的对照
| 现象 | 排查命令 | 常见原因 |
|---|---|---|
| CPU 100% | top -H -p PID + pprof CPU profile |
死循环、GC 压力、正则回溯 |
| 内存持续涨 | smaps_rollup 看 PSS/Private_Dirty + pprof heap |
堆内泄漏 / 堆外(cgo、mmap) |
| RSS 涨但 heap 不涨 | runtime.MemStats 的 Sys vs HeapAlloc |
cgo 内存、goroutine 栈、mmap |
| fd 耗尽 | ls /proc/PID/fd | wc -l + /proc/PID/limits |
resp.Body 没 Close、无超时 |
| 线程数暴涨 | ps -o nlwp -p PID |
阻塞的 cgo 调用 |
| goroutine 泄漏 | pprof/goroutine?debug=1 看 total |
channel 阻塞、无 context |
| 卡死不响应 | kill -QUIT 或 pprof/goroutine?debug=2 |
死锁、mutex 竞争 |
| 被 OOMKilled | dmesg | grep -i oom + 容器退出码 137 |
没设 GOMEMLIMIT |
| 容器 CPU 抢占 | mpstat 看 %steal + cgroup throttled |
GOMAXPROCS 未适配 cgroup |
# Go 服务应该暴露 pprof(生产上绑内网端口或加认证)
# import _ "net/http/pprof"
# go func() { log.Println(http.ListenAndServe("127.0.0.1:6060", nil)) }()
curl localhost:6060/debug/pprof/ # 索引页
curl -o heap.prof localhost:6060/debug/pprof/heap # 堆
curl -o cpu.prof 'localhost:6060/debug/pprof/profile?seconds=30'
curl 'localhost:6060/debug/pprof/goroutine?debug=2' # 所有 goroutine 栈
curl localhost:6060/debug/pprof/mutex # 锁竞争(需先 SetMutexProfileFraction)
curl localhost:6060/debug/pprof/block # 阻塞(需先 SetBlockProfileRate)
go tool pprof -http=:8080 heap.prof # 可视化
11. 面试题
Q:ps aux 里的 VSZ 和 RSS 有什么区别?为什么 VSZ 常常大得离谱?
VSZ 是虚拟地址空间大小,RSS 是实际占用的物理内存。
VSZ 高几乎没有意义,因为它包含了:申请了但还没写入的内存(overcommit 只记账不给页)、mmap 进来但没读过的文件、所有共享库的完整地址空间、每个线程预留的 8MB 栈空间(500 个线程就 4GB)、以及 Go runtime 启动时预留的一大块地址空间。所以 Go 程序 VSZ 有 1~2GB 是正常的。
RSS 的问题是共享部分被重复计算 —— 10 个 nginx worker 各显示 50MB,加起来 500MB,但它们共享同一份代码和库,实际远小于此。
准确的指标是:PSS(共享部分按份额分摊,多进程同源服务用它)和 USS(进程独占部分,等于「杀掉它能释放多少」)。从 /proc/PID/smaps_rollup 读,或用 smem。排查内存泄漏时最该看的是 Private_Dirty。
Q:进程状态 D 是什么?为什么 kill -9 也杀不掉?
D = 不可中断睡眠(TASK_UNINTERRUPTIBLE)。进程在内核态执行某个不能被打断的操作(通常是已提交给硬件的磁盘/网络 IO),此时内核不检查信号队列,所以 SIGKILL 只是被挂在待处理队列里,等进程醒来才处理 —— 而它醒来的唯一条件是那个 IO 完成或超时。
为什么要这样设计:这些操作正处于临界区(比如已经向控制器提交了 DMA 请求,内核数据结构处于中间状态),被信号打断跳出去会导致数据结构不一致或内存损坏。
排查用 cat /proc/PID/stack(内核栈,能直接看出卡在 NFS 还是块设备)和 /proc/PID/wchan。常见原因:NFS/CIFS 服务器失联、磁盘故障、IO 队列积压、大量脏页回写。内核还会主动报告:dmesg | grep hung_task(默认 120 秒阈值)。
唯一的办法是解决底层 IO 问题或重启机器。 另外 D 状态会算进 load average,这是下一题的答案。
Q:load average 是 8 但 CPU 只用了 20%,怎么解释?
因为 Linux 的 load average 不是 CPU 使用率,它统计的是「运行队列长度 + 处于 D 状态(不可中断睡眠)的进程数」的指数移动平均。
所以标准答案是:大量进程卡在 D 状态等 IO。验证方式是 ps -eo stat | grep -c '^D' 数一下、top 看 %wa(iowait)、vmstat 1 看 b(blocked)列、iostat -xz 1 看 %util 和 await。
判断 load 高不高要对比核数(nproc):load < nproc 健康,> nproc × 2 明显过载。三个数字的顺序也有信息:递减(8, 6, 4)表示负载正在上升,递增表示高峰已过。
补充一个知识点:其他 Unix(如 Solaris)的 load 只算 CPU 运行队列,不含 D 状态 —— 「磁盘慢导致 load 飙高」是 Linux 特有的行为,这个设计当年也有争议(把两种瓶颈混进了一个指标)。
Q:僵尸进程是什么?占资源吗?怎么清理?
僵尸是已经退出、内存和 fd 都已释放,但内核还保留着进程表项的进程。保留的原因是里面存着退出状态码,等父进程 wait() 来取 —— 父进程有权知道子进程是正常退出还是被信号杀的。
三个关键结论:
- 僵尸杀不掉,因为它已经死了,对它发任何信号都没意义
- 僵尸几乎不占资源 —— 只占一个进程表项(几 KB 内核内存)+ 一个 PID。所以偶尔几个僵尸不是问题
- 真正的问题是大量僵尸耗尽 PID 号,导致无法创建新进程
清理方式是处理父进程(不是僵尸自己):kill -HUP 让它重载、或直接杀掉它 —— 父进程死后僵尸被托孤给 PID 1,systemd 会立即 wait 掉。根治要修代码:Go 里必须 cmd.Wait() 而不是只 cmd.Start()。
容器场景要特别注意:容器的 PID 1 是你的应用,如果它不回收孤儿进程,僵尸会一直积累 —— 所以要用 docker run --init 或把 tini/dumb-init 作为 ENTRYPOINT。
Q:cmd &、nohup cmd &、setsid cmd、disown 有什么区别?
先讲机制:终端关闭或 ssh 断开时,内核给会话里的所有进程发 SIGHUP,默认行为是终止。
| 方式 | 原理 | 扛住关终端 | 输出去哪 |
|---|---|---|---|
cmd & |
只是放后台,仍在同一会话 | ❌ | 终端 |
nohup cmd & |
忽略 SIGHUP | ✅ | nohup.out |
cmd & disown |
从 shell 作业表移除,bash 不再转发 | ✅ | 终端(终端消失后写入失败) |
setsid cmd |
创建新会话,彻底脱离终端 | ✅ | 终端(同上) |
tmux/screen |
进程在另一个持久会话里 | ✅ | 可随时重连查看 |
nohup 会自动把输出重定向到 nohup.out(因为终端可能消失),推荐显式写 nohup cmd > /var/log/x.log 2>&1 < /dev/null &。disown 是事后补救(进程已启动才想起要保护)。setsid 最干净(PPID 直接变 1、无控制终端)。
但生产环境的正确答案是 systemd —— 它额外提供了崩溃自动重启、开机自启、日志轮转、资源限制、依赖管理、优雅停止(TERM→等待→KILL)、安全沙箱,这些 nohup 一个都做不到。
Q:为什么不该随手 kill -9?
SIGKILL 不可捕获,进程没有任何机会做清理,后果是:不 flush 缓冲区(日志和数据丢失)、不关闭连接(客户端看到 connection reset、数据库留下未提交事务)、不删临时文件(pid 文件残留导致下次启动失败)、不释放锁(其他实例无法接管)、子进程不被通知(留下孤儿进程继续跑)。
正确顺序是 先 SIGTERM、等一段时间、还活着才 SIGKILL:
kill -TERM $PID; sleep 10; kill -0 $PID 2>/dev/null && kill -9 $PID
systemd 内置了这套逻辑(KillSignal=SIGTERM + TimeoutStopSec=30 + SendSIGKILL=yes),所以用 systemctl stop 最省事。
Go 服务必须 signal.Notify(quit, syscall.SIGTERM) 然后 srv.Shutdown(ctx),否则 K8s 滚动更新时会丢正在处理的请求。配套注意两点:容器 entrypoint 要用 exec(否则信号发给了 shell)、terminationGracePeriodSeconds 要大于代码里的超时。
Q:too many open files 怎么排查?
四步:
- 找出是哪个进程:
for p in /proc/[0-9]*; do echo "$(ls $p/fd 2>/dev/null|wc -l) ${p##*/}"; done | sort -rn | head - 看限制与用量:
ls /proc/PID/fd | wc -l对比grep 'open files' /proc/PID/limits - 看 fd 都是什么(判断泄漏还是真需要):
ls -l /proc/PID/fd | awk '{print $NF}' | sort | uniq -c | sort -rn—— 大量 socket 说明连接没关,大量同一文件说明句柄泄漏 - 提限额 + 修代码
提限额有个大坑:/etc/security/limits.conf 走 PAM,只对登录会话生效,不影响 systemd 启动的服务。systemd 服务必须在 unit 里写 LimitNOFILE=65535。验证要看 cat /proc/PID/limits 而不是 ulimit -n。
Go 里最常见的泄漏是 resp.Body 没 Close()(而且还要读完,否则连接无法复用),以及 http.Client 没设 Timeout 导致连接堆积。
小结
- 四个 ID(PID/PPID/PGID/SID)与会话模型,是理解「Ctrl-C 发给谁」「关终端为什么进程会死」的基础
ps aux看资源、ps -ef看关系(BSD 与 SysV 两套语法的遗产)- VSZ 几乎没有意义(含预留、mmap、线程栈),看 RSS;准确要看 PSS/USS(
smaps_rollup) D状态kill -9无效 —— 内核在临界区不检查信号,只能解决底层 IO 或重启;/proc/PID/stack看卡在哪- load average 含 D 状态进程,所以「load 高但 CPU 空闲」= IO 瓶颈(Linux 特有行为)
- 僵尸不占资源也杀不掉,要处理父进程;容器里要用
tini/--init回收 nohup忽略 SIGHUP、setsid新建会话、disown是事后补救 —— 但生产用 systemd- 别随手
kill -9:不 flush、不关连接、不释放锁、留孤儿进程 /proc/<pid>/是排查金矿:cmdline/environ/fd/limits/stack/smaps_rollup/tasklimits.conf不影响 systemd 服务,要在 unit 里写LimitNOFILE
下一篇讲 systemd 与定时任务:unit 文件的每个字段在解决什么问题、Type=simple/forking/notify 该选哪个、systemctl 与 journalctl 的完整用法、以及 systemd timer 相比 cron 的优势(和 cron 那个「手动跑正常、放进 cron 就失败」的经典坑)。
xingliuhua