进程、管道与信号控制

深入 Shell 进程模型,讲解子进程与 subshell 的边界、后台作业控制、管道与 FIFO 通信、exec 进程替换,以及 trap 信号处理实现优雅退出。

1. 进程模型与子进程

一句话总结: 每条外部命令都是当前 Shell fork 出的子进程;只有内建命令和变量赋值在本进程执行,理解这条边界才能解释脚本行为。

Shell 执行命令时,外部命令会 fork 出子进程去跑,内建命令(cd、echo、export 等)在本进程内直接执行。

# 查看父子关系:$$ 是当前 Shell 的 PID
echo "shell pid=$$"
sleep 1 &              # 后台跑一条外部命令
echo "child pid=$!"    # 子进程 PID

# subshell:() 会在子 Shell 里执行,变量改动不回传
var=old
(var=new; echo "子Shell: $var")   # new
echo "父Shell: $var"              # old,没变

1.1 内建 vs 外部命令

类别例子执行位置
内建命令cd export local shift当前 Shell 进程
外部命令grep awk sleepfork 出的子进程
关键字if for while case语法结构,不产生进程
管道线cmd1 | cmd2每个命令一个子进程

一句话总结: 想让"改变"生效于当前 Shell,必须用内建命令;外部命令的一切状态变化都留在子进程里,这是 Shell 进程模型的根基。

2. 后台任务与作业控制

一句话总结: & 把命令放后台,jobs/bg/fg 管理作业,wait 等全部结束,disown 脱离终端。

# 后台执行
long_task.sh &
echo "PID=$!"

# 查看作业列表
jobs -l        # 带 PID 的作业列表

# 前台/后台切换
# Ctrl+Z 暂停当前前台任务
# fg  %1   把作业 1 调回前台
# bg  %1   让作业 1 在后台继续
fg %1
bg %1

# 等待所有后台任务结束
wait

2.1 作业控制速查

操作含义
cmd &后台运行
Ctrl+Z暂停前台任务(SIGTSTP)
jobs列出作业
bg %n后台继续第 n 个作业
fg %n调到前台
wait [pid]等待指定/全部作业
disown %n让作业脱离 Shell 管理
# 并行下载后再统一处理
download.sh a.txt &
download.sh b.txt &
wait
echo "两个下载都完成了"

# 退出 Shell 时不想被挂起,disown 丢给 init
nohup server.sh > server.log 2>&1 &
disown

3. 管道与进程间通信

一句话总结: 管道是匿名 IPC,上游写入、下游读取,中间没有缓冲区之外的存储,是 Shell 组合命令的血管。

# 管道串联:上一个 stdout 接下一个 stdin
ps aux | grep nginx | grep -v grep | awk '{print $2}'

# 退出码只能看到最后一个命令
false | true
echo "管道退出码=$?"    # 0,是 true 的退出码

# 变量在管道两端是子 Shell,改动不外泄
echo "hi" | read x
echo "x=$x"          # 空!read 在子 Shell 里执行

3.1 管道数据流特性

特性说明
方向单向,stdout → stdin
缓冲内核缓冲区,约 64KB 阈值
阻塞读端未开,写端阻塞
退出码默认取最右命令
子 Shell两端都在子 Shell 中运行
# Bash 4.4+ 用 PIPESTATUS 拿全段退出码
false | true
echo "${PIPESTATUS[@]}"      # 1 0

# shopt -s lastpipe 让末段在当前 Shell 跑(仅脚本内生效)
set +m; shopt -s lastpipe
echo "hi" | read x
echo "x=$x"                  # 这次有值了

一句话总结: 管道好用但有三个坑:退出码只看最后一个、变量改动不外泄、两侧在子 Shell。需要保留状态时改用进程替换或临时文件。

4. exec 与进程替换

一句话总结: exec 用新程序替换当前 Shell 进程,不会 fork 新子进程;进程替换 <( ) 把命令输出变成"文件"喂给别的程序。

# exec:替换当前进程(PID 不变)
exec java -jar app.jar        # 脚本进程变成 Java,后续代码不执行

# 无命令的 exec:只重定向当前 Shell
exec 3> /tmp/out.log          # 打开文件描述符 3
echo "写入 fd3" >&3
exec 3>&-                    # 关闭 fd3

# 进程替换:<( ) 提供只读文件,>( ) 提供写入口
diff <(sort a.txt) <(sort b.txt)
grep error <(tail -f app.log)

4.1 exec 常见场景

场景写法
脚本转前台程序exec "$@"
重定向整段exec >log 2>&1 后所有输出进文件
打开命名 fdexec {fd}>file
关闭 fdexec {fd}>&-
# 整个脚本输出重定向
exec > /var/log/deploy.log 2>&1
echo "这条也进日志"           # 后续全部进日志

一句话总结: 用 exec "$@" 启动常驻程序,能省掉一个多余的父进程层级,PID 保持稳定,systemd/supervisor 看到的进程树更干净。

5. 信号基础

一句话总结: 信号是内核发给进程的通知,kill 按号码发信号,trap 在 Shell 里捕获处理,SIGTERM/SIGINT/SIGKILL 是最常打交道的一组。

# 查看全部信号
kill -l

# 按号码/名字发信号
kill -15 1234       # SIGTERM,请求终止,可被捕获
kill -9 1234        # SIGKILL,强制杀死,不可捕获
kill -INT 1234      # 等同 Ctrl+C

# 常用信号表
#  1 SIGHUP  终端挂断/重载配置
#  2 SIGINT  键盘中断 Ctrl+C
#  9 SIGKILL 强杀
# 15 SIGTERM 正常终止请求
# 17 SIGCHLD 子进程结束通知

5.1 trap 基本用法

# 捕获信号执行清理
trap 'echo "收到中断,清理退出"; exit 1' INT TERM

# 捕获 EXIT:脚本无论怎么退出都会执行(最实用)
trap 'rm -f "$tmpfile"; echo "清理完成"' EXIT

# 忽略信号
trap '' HUP
信号默认行为可否 trap
SIGINT终止可捕获
SIGTERM终止可捕获
SIGKILL终止不可捕获
SIGHUP终止可捕获
SIGCHLD忽略可捕获

6. 信号处理实战

一句话总结: 优雅退出的套路是:trap 捕获 TERM → 停止接收新任务 → 等正在处理的任务跑完 → 清理临时文件 → 退出。

#!/usr/bin/env bash
# 优雅退出的最小实现
running=1
cleanup() {
  echo "收到退出信号,清理中..."
  running=0
  rm -f /tmp/app.pid
}
trap cleanup TERM INT

echo $$ > /tmp/app.pid
while (( running )); do
  # 干活
  sleep 1
done
echo "已优雅退出"

6.1 常见陷阱与对策

场景问题对策
子进程收到信号主进程没等它wait 配合 trap
二次信号清理被中断第二次直接退出
Ctrl+C 后残留临时文件清理没跑EXIT trap 兜底
后台任务被信号打断状态未知用退出码判断重跑
# 二次信号直接强退的写法
trap 'echo "强退"; exit 130' INT
trap 'echo "清理中"; rm -f "$tmp"; trap - EXIT' EXIT

一句话总结: 只要是会创建临时文件、锁文件、后台任务的脚本,就该挂 EXIT trap 做收尾。这是脚本从"能跑"走向"可运维"的关键一步。

7. FIFO 与命名管道

一句话总结: FIFO 是文件系统里的管道,mkfifo 创建后两个进程可像读写文件一样通信,适合解耦生产者与消费者。

匿名管道只存在于命令之间,跨脚本、跨 Shell 会话的通信要用命名管道 FIFO。

# 创建 FIFO
mkfifo /tmp/my_fifo

# 终端 A:写入
echo "hello" > /tmp/my_fifo

# 终端 B:读取
cat /tmp/my_fifo          # hello

# 用后删除
rm -f /tmp/my_fifo

7.1 FIFO 特性与注意事项

特性说明
阻塞无读者时写者阻塞,无写者时读者阻塞
单向只支持单向数据流
持久存在于文件系统,进程退出后仍在
权限受文件权限控制
坑读写端必须同时打开,否则互相死等
# 生产消费示例:一个进程喂数据,一个进程消费
(
  for i in {1..5}; do echo "data$i" > /tmp/fifo; done
) &
while read -r line; do
  echo "消费: $line"
done < /tmp/fifo

一句话总结: FIFO 的价值在于解耦:生产者的写入频率不必匹配消费者的读取频率。但务必同时打开读写两端,否则阻塞会互相等待,这也是调试难题的常客。

8. 总结

环节要点
进程模型外部命令是子进程,内建命令在本进程
作业控制& 后台、jobs/bg/fg 管理、wait 汇总
管道单向流、末命令退出码、子 Shell 边界
exec替换当前进程,PID 不变
信号TERM 可捕获、KILL 不可捕获、trap 拦截
优雅退出trap 清理 + wait 收拢子进程 + 二次信号兜底
FIFO命名管道解耦生产消费,读写端须同时打开

进程与信号是脚本走向"服务化"的必修课。记住三点:管道两端在子 Shell、外部命令是子进程、EXIT trap 做收尾,就掌握了进程世界的主要地形。参数的解析与错误处理,则是脚本面对真实世界输入的第一道关口。

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「shell」更多文章

  1. 脚本性能优化实战
  2. 网络请求与诊断实战
  3. 定时任务调度实战