目录

Linux-13 进程与作业控制:ps 每一列、D 状态为什么杀不掉、nohup 与 setsid 的区别

进程是操作系统最核心的抽象。这一篇讲怎么看进程、怎么控制进程、以及那些「看起来杀不掉」的进程到底怎么了。

先看五个问题:

  1. ps aux 里的 VSZRSS 有什么区别?为什么 VSZ 常常大得离谱(几十 GB)?
  2. 进程状态 D 是什么意思?为什么连 kill -9 都杀不掉它?
  3. cmd &nohup cmd &setsid cmddisown 有什么区别?关掉终端后哪些能活下来?
  4. 僵尸进程为什么杀不掉?它占资源吗?
  5. 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 %utilawait
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.MemStatsSys 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 -QUITpprof/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 1b(blocked)列、iostat -xz 1%utilawait

判断 load 高不高要对比核数(nproc):load < nproc 健康,> nproc × 2 明显过载。三个数字的顺序也有信息:递减(8, 6, 4)表示负载正在上升,递增表示高峰已过。

补充一个知识点:其他 Unix(如 Solaris)的 load 只算 CPU 运行队列,不含 D 状态 —— 「磁盘慢导致 load 飙高」是 Linux 特有的行为,这个设计当年也有争议(把两种瓶颈混进了一个指标)。

Q:僵尸进程是什么?占资源吗?怎么清理?

僵尸是已经退出、内存和 fd 都已释放,但内核还保留着进程表项的进程。保留的原因是里面存着退出状态码,等父进程 wait() 来取 —— 父进程有权知道子进程是正常退出还是被信号杀的。

三个关键结论:

  1. 僵尸杀不掉,因为它已经死了,对它发任何信号都没意义
  2. 僵尸几乎不占资源 —— 只占一个进程表项(几 KB 内核内存)+ 一个 PID。所以偶尔几个僵尸不是问题
  3. 真正的问题是大量僵尸耗尽 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 cmddisown 有什么区别?

先讲机制:终端关闭或 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 怎么排查?

四步:

  1. 找出是哪个进程for p in /proc/[0-9]*; do echo "$(ls $p/fd 2>/dev/null|wc -l) ${p##*/}"; done | sort -rn | head
  2. 看限制与用量ls /proc/PID/fd | wc -l 对比 grep 'open files' /proc/PID/limits
  3. 看 fd 都是什么(判断泄漏还是真需要):ls -l /proc/PID/fd | awk '{print $NF}' | sort | uniq -c | sort -rn —— 大量 socket 说明连接没关,大量同一文件说明句柄泄漏
  4. 提限额 + 修代码

提限额有个大坑:/etc/security/limits.conf 走 PAM,只对登录会话生效,不影响 systemd 启动的服务。systemd 服务必须在 unit 里写 LimitNOFILE=65535。验证要看 cat /proc/PID/limits 而不是 ulimit -n

Go 里最常见的泄漏是 resp.BodyClose()(而且还要读完,否则连接无法复用),以及 http.Client 没设 Timeout 导致连接堆积。


小结

  • 四个 ID(PID/PPID/PGID/SID)与会话模型,是理解「Ctrl-C 发给谁」「关终端为什么进程会死」的基础
  • ps aux 看资源、ps -ef 看关系(BSD 与 SysV 两套语法的遗产)
  • VSZ 几乎没有意义(含预留、mmap、线程栈),看 RSS;准确要看 PSS/USSsmaps_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/task
  • limits.conf 不影响 systemd 服务,要在 unit 里写 LimitNOFILE

下一篇讲 systemd 与定时任务:unit 文件的每个字段在解决什么问题、Type=simple/forking/notify 该选哪个、systemctljournalctl 的完整用法、以及 systemd timer 相比 cron 的优势(和 cron 那个「手动跑正常、放进 cron 就失败」的经典坑)。