Linux-06 进程管理与作业控制
从这一篇开始进入原理部分。进程是操作系统最核心的抽象,也是排查线上问题时绕不开的对象——「服务没了」「CPU 打满」「进程杀不掉」「一堆僵尸进程」,这些现象背后都是同一套模型。
本篇讲进程是什么、怎么产生的、有哪些状态、怎么观察和控制。调度和上下文切换在第 7 篇,信号和 IPC 在第 8 篇。
1. 进程与线程:内核眼里没有区别
一句话:Linux 内核里只有一种调度实体,叫 task_struct。所谓「进程」和「线程」,区别只在于创建时共享了哪些资源。
这和 Windows 或教科书里「进程包含线程」的模型很不一样,但它解释了 Linux 上很多现象。
// 简化的 task_struct(内核源码 include/linux/sched.h)
struct task_struct {
pid_t pid; // 内核唯一标识(其实是线程 ID)
pid_t tgid; // thread group id <- 用户看到的「PID」
struct task_struct *parent; // 父进程
volatile long state; // 运行状态 R/S/D/...
struct mm_struct *mm; // 地址空间 <- 线程之间共享这个
struct files_struct *files; // 打开的文件表 <- 线程之间也共享
struct signal_struct *signal; // 信号处理
/* ... */
};
创建时用同一个系统调用 clone(),通过 flag 决定共享什么:
fork() = clone(SIGCHLD) 什么都不共享 -> 「进程」
pthread_create() = clone(CLONE_VM | CLONE_FS | CLONE_FILES |
CLONE_SIGHAND | CLONE_THREAD | ...)
共享地址空间等 -> 「线程」
pid 与 tgid 的区别是这个模型最直接的体现:
# 一个有 8 个线程的 Java 进程
ps -eLf | grep myapp | head -4
# UID PID PPID LWP NLWP CMD
# app 1234 1 1234 8 java -jar myapp.jar <- LWP == PID,主线程
# app 1234 1 1235 8 java -jar myapp.jar <- LWP 不同,是线程
# app 1234 1 1236 8 java -jar myapp.jar
# ^^^^ 所有线程的 PID 相同(= tgid)
# ^^^^ LWP 才是内核真正的 pid
ps 默认显示的 PID 其实是内核的 tgid,LWP(Light Weight Process)才是内核的 pid。排查「线程数暴涨」时必须用 ps -eLf 或 -L,否则你只看得到进程数。
# 看某个进程有多少线程(三种方法)
ps -o nlwp= -p 1234 # 8
cat /proc/1234/status | grep Threads # Threads: 8
ls /proc/1234/task | wc -l # 8
2. 进程是怎么产生的:fork / exec / wait
Linux 创建新程序需要两个系统调用配合,这个设计和 Windows 的 CreateProcess(一步到位)很不同:
fork() 复制当前进程,产生一个几乎一模一样的子进程
exec() 用一个新程序【替换】当前进程的内存映像(PID 不变)
wait() 父进程等待子进程结束,回收它的退出状态
Shell 执行 ls 时发生的完整过程:
bash (pid 1000)
|
+- fork() ---------> bash 副本 (pid 1001)
| |
| +- execve("/usr/bin/ls", ...)
| | 内存映像被 ls 替换,pid 仍是 1001
| |
+- wait(1001) 阻塞 ... | 运行,exit(0)
| ^ |
| +---------------+ 内核通知父进程,wait 返回
v
继续执行
2.1 为什么要拆成两步
因为 fork 和 exec 之间的那个窗口非常有用——子进程此时已经是独立进程,但还没换成新程序,可以在这里做各种设置:
pid_t pid = fork();
if (pid == 0) {
// 子进程:在 exec 之前做设置
close(STDOUT_FILENO);
open("out.txt", O_WRONLY|O_CREAT, 0644); // 重定向标准输出
setuid(1000); // 降权
chdir("/tmp"); // 改工作目录
execve("/usr/bin/myapp", argv, envp); // 现在才换程序
}
Shell 的重定向、管道、sudo 的降权、容器运行时设置 namespace,全都发生在这个窗口里。如果是 CreateProcess 那样一步到位,这些都得靠一大堆参数来表达。
2.2 fork 的写时复制
fork 要复制父进程的整个地址空间——一个占用 8GB 内存的进程 fork 一下要复制 8GB?不会。内核用写时复制(Copy-On-Write):
fork 之后:
父进程页表 -+
+--> 同一批物理页(标记为只读)
子进程页表 -+
任一方尝试写入某页:
-> 触发缺页异常
-> 内核复制【那一页】,改成可写
-> 只有被修改的页才真正复制
所以 fork 本身很快,代价推迟到了实际写入时。这也解释了一个常见现象:
坑:Redis 做 RDB 持久化时会
fork一个子进程,此时内存看起来「翻倍」的风险是存在的——如果父进程在子进程存盘期间大量写入,COW 会不断复制页面。所以 Redis 机器的内存要留出余量,vm.overcommit_memory=1也是为此配置的。
3. 进程状态:D 和 Z 是排查重点
ps aux | head -3
# USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
# root 1 0.0 0.1 168944 11876 ? Ss Jul01 3:24 /sbin/init
# ^^ 这一列
| 状态 | 名称 | 含义 | 要不要紧 |
|---|---|---|---|
R |
Running/Runnable | 正在 CPU 上跑,或在运行队列里等 CPU | 正常 |
S |
Interruptible Sleep | 可中断睡眠,等待事件(IO、锁、信号) | 正常,绝大多数进程都是 S |
D |
Uninterruptible Sleep | 不可中断睡眠,通常在等磁盘 IO | 异常信号 |
Z |
Zombie | 已退出但父进程没回收 | 异常信号 |
T |
Stopped | 被 SIGSTOP 暂停,或被调试器挂起 |
看情况 |
I |
Idle | 空闲的内核线程(4.14+ 新增,从 D 里拆出来的) | 正常 |
附加标志:
s 会话首进程(session leader)
l 多线程
+ 在前台进程组
< 高优先级(nice < 0)
N 低优先级(nice > 0)
3.1 D 状态:不可中断睡眠
D 状态的进程连 kill -9 都杀不掉。这是它最反直觉也最麻烦的特性。
为什么不可中断:进程发起了一个磁盘 IO 请求,内核已经把 DMA 地址交给了硬件。如果此时允许信号打断它并让进程退出,硬件仍会往那块内存写数据——写到一块已经被释放、可能已分配给别的进程的内存上。所以内核规定这个阶段不响应任何信号,必须等 IO 完成。
# 找出所有 D 状态进程
ps -eo pid,stat,wchan:30,comm | awk '$2 ~ /^D/'
# PID STAT WCHAN COMMAND
# 8821 D io_schedule mysqld
# 8822 D nfs_wait_bit_killable myapp <- NFS 卡住了
# ^^^^^^^^^ wchan 告诉你卡在内核的哪个函数
wchan 是定位 D 状态原因的关键,它显示进程阻塞在哪个内核函数上:
| wchan | 含义 |
|---|---|
io_schedule / wait_on_page_bit |
等本地磁盘 IO |
nfs_* |
等 NFS,网络存储不通 |
rpc_wait_bit_killable |
等 RPC(NFS/CIFS) |
# 更详细:看这个进程卡在哪个系统调用
cat /proc/8821/stack # 需要 root,显示内核栈
cat /proc/8821/wchan
D 状态的两个后果:
- 算进平均负载。Linux 的 load average 统计的是
R + D的进程数,不像其他 Unix 只算R。所以「load 高但 CPU 使用率很低」的经典场景就是一堆 D 状态进程在等 IO——第 7 篇会展开。 - 杀不掉。唯一的办法是让 IO 完成或失败:修好磁盘、恢复 NFS 连接、或者
umount -f -l强制卸载。实在不行只能重启。
注意:偶尔出现几个 D 状态是正常的(任何磁盘读写的瞬间都是 D)。持续存在、数量增长的 D 状态才是问题。判断方法是连续采样几次
ps -eo stat | grep -c D。
3.2 Z 状态:僵尸进程
僵尸不是「没死的进程」,恰恰相反——它已经死了。
子进程退出时,内核释放它的内存、文件描述符等全部资源,只保留一小块 task_struct,里面记着退出码、CPU 用时等信息,等父进程用 wait() 来取。取走之后这个结构才被释放。
子进程 exit(0)
-> 内核释放内存、fd、地址空间
-> 保留 task_struct,状态标记为 Z(僵尸)
-> 给父进程发 SIGCHLD
-> 父进程调用 wait()/waitpid() 取走退出码
-> 内核释放 task_struct,僵尸消失
僵尸产生的唯一原因:父进程没有调用 wait()。
# 找僵尸
ps -eo pid,ppid,stat,comm | awk '$3 ~ /^Z/'
# PID PPID STAT COMMAND
# 12346 12345 Z myworker
# ^^^^^ 关键:找它的【父进程】
# 或者
ps aux | grep 'Z.*<defunct>'
僵尸的危害不是占内存(它几乎不占),而是占 PID。系统的 PID 是有上限的:
cat /proc/sys/kernel/pid_max
# 4194304 (老系统常见是 32768)
僵尸持续堆积会耗尽 PID,之后系统无法创建任何新进程——fork: Cannot allocate memory,连 ssh 都登不进去。
怎么处理:
# ❌ 杀僵尸本身没用,它已经死了
kill -9 12346 # 无效果
# ✅ 让父进程去 wait —— 给父进程发 SIGCHLD 提醒它
kill -CHLD 12345 # 如果父进程只是漏了处理,可能有用
# ✅ 真正的解法:重启父进程
kill 12345
# 父进程死后,僵尸被 init(pid 1) 收养,init 会立即 wait 掉它们
根治是修代码。父进程必须回收子进程:
// 方式一:显式 wait
while (waitpid(-1, NULL, WNOHANG) > 0);
// 方式二:明确忽略 SIGCHLD,让内核自动回收(最省事)
signal(SIGCHLD, SIG_IGN);
// Go 里用 os/exec 时,必须调用 Wait()
cmd := exec.Command("ls")
cmd.Start()
defer cmd.Wait() // <- 漏了这行就会产生僵尸
// 或者直接用 Run(),它内部会 Wait
cmd.Run()
坑:容器里的僵尸问题特别常见。容器的 PID 1 是你的应用进程,而应用通常不实现「收养孤儿并 wait」的 init 职责。所以容器里
exec出来的子进程一旦变孤儿,就永远是僵尸。解法是用docker run --init(注入 tini 作为 PID 1)或在 Dockerfile 里用tini/dumb-init做 entrypoint。
3.3 孤儿进程:不是问题
父进程先于子进程退出,子进程就成了「孤儿」。内核会立即把它过继给 PID 1(init/systemd),由 PID 1 负责 wait 它。
ps -eo pid,ppid,comm | awk '$2 == 1' | head
# PID PPID COMMAND
# 823 1 sshd
# 1234 1 myapp <- PPID 变成 1 = 曾经是孤儿,或者是 daemon
孤儿进程完全正常,这正是守护进程(daemon)的经典实现方式——fork 之后父进程立即退出,让子进程被 init 收养,从而脱离终端。
孤儿和僵尸的关系:孤儿被 init 收养后,init 会正确地 wait 它们,所以孤儿不会变成僵尸。危险的是反过来——父进程活着但不 wait,那才产生僵尸。
4. ps:两套语法与常用组合
ps 有历史遗留的两套语法,混用会出错:
ps aux # BSD 风格:选项【不加】横杠
ps -ef # UNIX 风格:选项【加】横杠
ps -eo pid,comm # UNIX 风格 + 自定义列
两者输出的列不同,aux 有 %CPU/%MEM,-ef 有 PPID。记住 aux 看资源、-ef 看父子关系就够了。
4.1 自定义输出列(最实用)
ps -eo 让你只看关心的列,配合 --sort 排序:
# 按内存占用排序
ps -eo pid,ppid,user,rss,vsz,stat,etime,comm --sort=-rss | head -10
# PID PPID USER RSS VSZ STAT ELAPSED COMMAND
# 1234 1 app 8123456 12345678 Sl 42-03:11 java
# ^^^^^^^ RSS 单位是 KB
# 按 CPU 排序
ps -eo pid,pcpu,pmem,comm --sort=-pcpu | head -10
# 看线程
ps -eLo pid,lwp,pcpu,comm --sort=-pcpu | head -10
# ^^^ LWP = 线程 ID
常用的列名:
| 列 | 含义 |
|---|---|
rss |
物理内存占用(KB)← 看这个 |
vsz |
虚拟内存(含未真正分配的),通常远大于实际,参考价值低 |
pcpu / pmem |
CPU / 内存百分比 |
etime / etimes |
运行时长(后者是秒数,便于比较) |
stat |
状态 |
nlwp |
线程数 |
wchan |
内核等待点 |
args |
完整命令行(comm 只有进程名,会被截断) |
坑:
ps aux的COMMAND列会被终端宽度截断,看不到完整参数。用ps -eo args或ps auxww(两个 w 表示不截断)。要看某个进程的完整命令,最可靠的是cat /proc/PID/cmdline | tr '\0' ' '。
4.2 pgrep / pkill:按名字操作
比 ps aux | grep xxx 好用得多,而且不会匹配到 grep 自己:
pgrep nginx # 列出 pid
pgrep -l nginx # 带进程名
pgrep -a nginx # 带完整命令行
pgrep -u app # 指定用户
pgrep -f "java.*myapp" # 匹配【完整命令行】而不只是进程名 <- 常用
pgrep -P 1234 # 找 1234 的子进程
pkill -f "java.*myapp" # 按同样规则杀
pkill -u app # 杀某用户的所有进程
pkill -9 -f myapp # 强制杀
-f 几乎总是需要的:Java、Python 服务的进程名分别是 java、python,只有完整命令行才能区分是哪个应用。
# ❌ 会误杀所有 java 进程
pkill java
# ✅ 精确匹配
pkill -f "java -jar myapp.jar"
# ✅ 杀之前先确认匹配到了什么(重要!)
pgrep -af "java -jar myapp.jar"
规律:pkill 之前一定先用同样的参数跑一遍 pgrep -a 确认。
4.3 pstree:看进程树
pstree -p 1234
# myapp(1234)-+-{myapp}(1235)
# +-{myapp}(1236)
# +-sh(1300)---python(1301)
# ^^ 花括号里的是线程
pstree -aps 1234 # -a 显示参数,-s 显示到 root 的完整路径
排查「这个进程是谁拉起来的」时,pstree -s PID 比反复 ps -o ppid 快得多。
5. 进程组与会话
这是理解 Ctrl+C 为什么能同时结束一个管道里的所有命令、以及 nohup 到底在干什么的基础。
会话(Session)
+-- 一次登录 / 一个终端,有一个 session leader(通常是 shell)
|
+-- 前台进程组(Foreground Process Group)—— 只能有一个
| +-- cat f | grep x | sort 这三个进程属于【同一个进程组】
|
+-- 后台进程组(Background)—— 可以有多个
+-- sleep 100 &
三层 ID:
ps -eo pid,ppid,pgid,sid,tty,stat,comm | head -5
# PID PPID PGID SID TT STAT COMMAND
# 1000 999 1000 1000 pts/0 Ss bash <- sid == pid,会话首进程
# 1050 1000 1050 1000 pts/0 S+ cat
# 1051 1000 1050 1000 pts/0 S+ grep <- pgid 相同 = 同一进程组
# 1052 1000 1050 1000 pts/0 S+ sort
# ^^^^ sid 相同 = 同一会话
为什么 Ctrl+C 能终止整个管道:终端驱动把 SIGINT 发给前台进程组的所有进程(kill(-pgid, SIGINT)),而管道里的三个命令属于同一个进程组,所以一起收到。
信号发给进程组用负号:
kill 1234 # 发给单个进程
kill -- -1234 # 发给【进程组】1234 的所有成员 <- 注意负号
这在杀掉「一个脚本和它拉起的所有子进程」时很有用——只 kill 脚本本身,子进程会变孤儿继续跑。
5.1 终端断开时发生什么
SSH 断开、终端关闭时,内核给会话里的所有进程发 SIGHUP(Hang Up,源自电话挂断)。默认动作是终止进程。
这就是「ssh 断了任务就没了」的原因。三种解决方式,原理各不相同:
| 方式 | 原理 | 特点 |
|---|---|---|
nohup cmd & |
忽略 SIGHUP 信号 | 简单,但仍在原会话里 |
setsid cmd |
创建新会话,脱离原终端 | 彻底,收不到原终端的信号 |
disown -h %1 |
从 shell 的作业表里移除 | 对已经在跑的任务补救 |
tmux / screen |
任务跑在独立会话里,可重新连上 | 最推荐,还能回去看输出 |
# nohup:输出默认重定向到 nohup.out
nohup ./long_task.sh > task.log 2>&1 &
# setsid:更彻底,PPID 直接变成 1
setsid ./long_task.sh > task.log 2>&1 < /dev/null
ps -o pid,ppid,sid,tty,comm -p $!
# PID PPID SID TT COMMAND
# 2001 1 2001 ? long_task.sh
# ^^^ 已被 init 收养 ^^ 无终端
# disown:任务已经在跑了才想起来要保护它
./long_task.sh &
disown -h %1 # -h 只是标记不发 HUP;不加 -h 是直接从作业表移除
生产建议:交互式跑长任务一律用 tmux。nohup 的问题是你看不到实时输出,也无法再交互;tmux 可以随时 tmux attach 回去查看。真正的长期服务应该用 systemd(第 14 篇)而不是 nohup。
6. 作业控制
这是终端里的日常操作,但很多人只会 Ctrl+C:
cmd & # 直接后台运行
Ctrl+Z # 把【前台】任务挂起(发 SIGTSTP,进入 T 状态)
jobs # 列出当前 shell 的作业
jobs -l # 带 pid
fg %1 # 把作业 1 调到前台
bg %1 # 让【挂起的】作业在后台继续运行
kill %1 # 按作业号杀
wait # 等待所有后台作业结束
wait %1 # 等待指定作业
一个典型场景——跑到一半发现忘了加 &:
./long_task.sh # 忘了加 &,终端被占住
Ctrl+Z # 挂起
# [1]+ Stopped ./long_task.sh
bg # 让它在后台继续跑
# [1]+ ./long_task.sh &
disown -h %1 # 顺便保护它不受 SIGHUP 影响
$! 和 wait 在脚本里做并发控制很有用:
# 并发跑 3 个任务,等全部完成
./task1.sh & pid1=$!
./task2.sh & pid2=$!
./task3.sh & pid3=$!
wait $pid1 $pid2 $pid3
echo "全部完成"
# 或者收集各自的退出码
for pid in $pid1 $pid2 $pid3; do
wait "$pid" || echo "进程 $pid 失败"
done
注意:
Ctrl+Z发的是SIGTSTP,进程进入T(Stopped)状态。被 stop 的进程不消耗 CPU,但仍占着内存和所有 fd。如果误把一个持有数据库连接的进程 stop 了,那些连接会一直挂着直到超时。用kill -CONT PID恢复。
7. 实战:定位一个「CPU 打满」的进程
把本篇的工具串起来。场景:监控告警某台机器 CPU 使用率 100%。
① 确认是哪个进程
ps -eo pid,pcpu,pmem,nlwp,stat,etime,args --sort=-pcpu | head -5
# PID %CPU %MEM NLWP STAT ELAPSED COMMAND
# 9812 640.0 18.7 96 Sl 7-11:03 java -jar report-service.jar
# ^^^^^ 780% = 占了 7.8 个核 ^^^ 128 个线程
%CPU 超过 100% 是正常的——它是所有核的总和。8 核机器上 780% 意味着基本打满。
② 定位到具体线程
# 找出这个进程里最耗 CPU 的线程
ps -Lo pid,lwp,pcpu,comm -p 9812 --sort=-pcpu | head -6
# PID LWP %CPU COMMAND
# 9812 4021 99.1 java
# 9812 4022 98.7 java
# 9812 4023 98.2 java
# ^^^^ 这几个线程各占满一个核
③ 把线程 ID 换算成应用层的线程名
# Java:jstack 里的 nid 是【十六进制】的 LWP
printf "%x\n" 4021
# fb5
jstack 9812 | grep -A 20 'nid=0xfb5'
# "GC task thread#0" os_prio=0 tid=0x... nid=0xfb5 runnable
# <- 是 GC 线程在打满 CPU,说明是内存问题不是业务逻辑问题
# Go:直接用 pprof,不需要这一步
curl -o cpu.prof http://localhost:6060/debug/pprof/profile?seconds=30
go tool pprof -top cpu.prof
④ 确认不是系统层面的问题
# 看是用户态还是内核态占的
top -H -p 9812
# %Cpu(s): 12.3 us, 85.2 sy, 0.0 ni, 2.5 id
# ^^^^^^^ sy 占 85% -> 系统调用/内核在忙,不是业务计算
# 这种情况要用 strace 看是什么系统调用(第 9 篇 §4)
规律:CPU 高先分 us 和 sy——us 高查业务代码和 GC,sy 高查系统调用、上下文切换、中断。
这里演示的是
ps/jstack的用法串联。一个完整的 CPU 打满案例(含 perf 交叉验证、根因是正则灾难性回溯、以及修复方案)见 Linux-20 §1。
8. 面试题
Q:Linux 里进程和线程有什么区别?
在内核层面没有本质区别——都是 task_struct,都由同一个调度器调度,创建都走 clone() 系统调用,区别只在于传了哪些 CLONE_* 标志决定共享什么资源。线程共享地址空间(CLONE_VM)、文件描述符表(CLONE_FILES)、信号处理(CLONE_SIGHAND),进程则都不共享。用户看到的「PID」其实是内核的 tgid(线程组 ID),同一进程的所有线程 tgid 相同,而各自的 pid(ps 里显示为 LWP)不同。所以查线程数要用 ps -eLf 或 /proc/<pid>/task。
Q:fork 之后为什么还需要 exec?为什么不像 Windows 那样一步创建进程?
因为 fork 和 exec 之间的窗口极其有用。此时子进程已经是独立进程,但还没被新程序替换,可以在这里做各种设置:重定向标准输入输出(Shell 的 > 和管道)、降低权限(setuid)、改工作目录、设置 namespace(容器)、修改资源限制。如果像 CreateProcess 那样一步到位,这些都得靠越来越庞大的参数列表来表达。代价是 fork 要复制地址空间——用**写时复制(COW)**解决,父子共享同一批物理页并标记只读,只有实际写入的页才真正复制。
Q:什么是僵尸进程?危害是什么?怎么处理?
子进程退出后,内核已经释放了它的内存和 fd,但保留一小块 task_struct 记录退出码等信息,等父进程调用 wait() 取走——这个阶段的进程状态是 Z。所以僵尸不是「没死」,恰恰是「已经死了但没被收尸」。产生的唯一原因是父进程没有调用 wait。危害不是占内存(几乎不占),而是占用 PID,大量堆积会耗尽 pid_max 导致系统无法 fork 任何新进程。处理:kill 僵尸本身无效,要杀掉或重启父进程——父进程死后僵尸被 init 收养,init 会立即回收。根治要改代码:调用 waitpid,或 signal(SIGCHLD, SIG_IGN) 让内核自动回收。
Q:僵尸进程和孤儿进程有什么区别?孤儿进程有害吗?
孤儿是父进程先退出的子进程,内核会立即把它过继给 PID 1,由 init/systemd 负责 wait 它。孤儿完全无害,守护进程正是靠「fork 后父进程立即退出」来主动制造孤儿,从而脱离终端。僵尸则相反——父进程还活着但不 wait,所以没人来回收,才会堆积。注意:容器里 PID 1 是应用进程而非真正的 init,通常不实现收养和 wait 的职责,所以容器内的孤儿会变成永久僵尸。解法是 docker run --init 或用 tini/dumb-init 作为 entrypoint。
Q:进程处于 D 状态,kill -9 为什么杀不掉?
D 是不可中断睡眠,通常在等磁盘或网络存储的 IO。内核之所以规定这个阶段不响应任何信号(包括 SIGKILL),是因为进程已经把内存地址交给了硬件做 DMA——如果此时允许进程退出并释放那块内存,硬件仍会往里写数据,会破坏已经分配给其他进程的内存。所以必须等 IO 完成或失败。排查用 ps -eo pid,stat,wchan,wchan 会显示卡在哪个内核函数:io_schedule 是本地磁盘,nfs_* 是 NFS 不通。解决只能是修复底层存储、umount -f -l 强制卸载,或重启。注意:D 状态会计入平均负载,这是「load 很高但 CPU 空闲」的典型原因。
Q:nohup、setsid、disown 有什么区别?
三者都是为了让进程在终端断开后存活,但机制不同。终端断开时内核给会话内所有进程发 SIGHUP,默认动作是终止。nohup 让进程忽略 SIGHUP,但进程仍在原会话里;setsid 创建一个新会话,进程彻底脱离原终端(PPID 变成 1,TTY 变成 ?),因此根本收不到原终端的信号;disown 是事后补救——对已经在运行的作业,把它从 shell 的作业表里移除(-h 只标记不发 HUP),这样 shell 退出时不会去通知它。生产建议:交互式长任务用 tmux(能重新连回去看输出),常驻服务用 systemd(有自动重启、日志、依赖管理),nohup 只适合临时用。
Q:Ctrl+C 为什么能一次结束 cat f | grep x | sort 这三个进程?
因为管道中的所有命令被 Shell 放进了同一个进程组,而这个进程组是终端的前台进程组。按下 Ctrl+C 时,终端驱动产生 SIGINT 并发送给前台进程组的全部成员(相当于 kill(-pgid, SIGINT)),所以三个进程同时收到。这也解释了为什么后台任务(cmd &,属于后台进程组)不受 Ctrl+C 影响。在命令行里给整个进程组发信号要用负号:kill -- -1234。
Q:ps aux 里的 VSZ 和 RSS 有什么区别?该看哪个?
VSZ 是虚拟内存大小,包含了已映射但从未真正分配物理页的部分——比如 malloc 了但没写入的内存、映射进来的共享库、Go runtime 预留的巨大地址空间。所以 Go 程序 VSZ 经常显示几百 GB,这完全正常,参考价值很低。RSS 是常驻物理内存,即真正占用的物理页,排查内存问题看这个。但 RSS 也有局限:多个进程共享的内存页(如共享库、fork 后未写的 COW 页)会在每个进程的 RSS 里重复计算,所以把所有进程 RSS 加起来会超过实际内存用量。要精确统计单进程独占的内存,看 /proc/<pid>/smaps_rollup 里的 Pss(按共享比例分摊)。
上一篇:Linux-05 Shell 编程与脚本工程化 | 下一篇:Linux-07 进程调度与上下文切换
xingliuhua