Shell 脚本中如何进行进程管理?如何启动、监控和终止进程?
Shell 进程管理是运维和后端面试的高频考点,核心考查你对进程生命周期、信号机制和作业控制的理解。
进程启动
Shell 中启动进程有前台、后台和脱离终端三种方式,区别在于进程与当前 shell 的绑定关系。
前台与后台执行
前台执行会阻塞当前 shell,后台执行在命令末尾加 &,shell 立即返回控制权:
bash# 前台执行(阻塞 shell) ./long_task.sh # 后台执行(shell 不阻塞) ./long_task.sh & echo "后台进程 PID: $!"
$! 保存最近一个后台进程的 PID,是进程管理的关键变量。
nohup 与 disown:脱离终端
用户退出终端时,shell 会向所有子进程发送 SIGHUP 信号,导致后台进程一并终止。nohup 和 disown 用于解决这个问题:
bash# nohup:启动时即忽略 SIGHUP,输出重定向到 nohup.out nohup ./long_task.sh > task.log 2>&1 & # disown:对已启动的后台进程脱离 shell 控制 ./long_task.sh & disown %1 # disown -h:标记为不接收 SIGHUP,但仍在 jobs 列表中 ./another_task.sh & disown -h %2
面试追问:nohup 和 disown 有什么区别?
nohup 在启动时就屏蔽 SIGHUP,是预防性的;disown 是事后将已有作业从 shell 的作业表移除,是补救性的。nohup 还会自动重定向输出,disown 不会。
子 shell 与进程替换
括号 () 创建子 shell,子 shell 中的变量和目录切换不影响父 shell:
bash# 子 shell 中修改变量不影响父进程 ( export MY_VAR="child"; echo "子: $MY_VAR" ) echo "父: $MY_VAR" # 空 # 用子 shell 实现并行执行 ( task_a.sh ) & ( task_b.sh ) & wait echo "两个任务都完成了"
进程监控
查看进程信息
ps 是最基础的进程查看命令,不同参数组合提供不同维度的信息:
bash# 查看所有进程的完整信息 ps aux # 查看进程树(显示父子关系) ps auxf # 自定义输出列 ps -eo pid,ppid,user,%cpu,%mem,cmd --sort=-%cpu | head # 查看指定进程的详细信息 ps -p $(pgrep -d, nginx) -o pid,ppid,state,%cpu,%mem,cmd
面试追问:ps aux 和 ps -ef 有什么区别?
两者都显示所有进程,但格式不同。
ps aux是 BSD 风格,aux不加横杠,输出包含 %CPU、%MEM 等资源占用列;ps -ef是 System V 风格,-ef加横杠,输出包含 PPID 列但无资源占用。实际工作中ps aux更常用。
进程查找
bash# pgrep:按名称模式查找,直接输出 PID pgrep -f "python.*app.py" # pidof:精确匹配进程名 pidof nginx # pgrep -P:查找子进程 pgrep -P $PARENT_PID
实时监控
top 和 htop 提供实时视图。脚本中更常用的是定时轮询:
bash# 检查进程是否存活 is_alive() { kill -0 "$1" 2>/dev/null } if is_alive 12345; then echo "进程 12345 正在运行" fi
kill -0 不发送任何信号,仅检测进程是否存在,是脚本中判断进程存活的标准做法。
进程终止与信号
信号机制
信号是进程间通信的基础机制,理解信号是进程管理的核心:
bash# 列出所有信号 kill -l # 常用信号 # SIGHUP (1) - 挂起,常用于通知守护进程重载配置 # SIGINT (2) - 中断,等同于 Ctrl+C # SIGTERM (15) - 优雅终止,进程可捕获并做清理 # SIGKILL (9) - 强制终止,进程无法捕获或忽略 # SIGSTOP (19) - 暂停,进程无法捕获 # SIGCONT (18) - 继续执行被暂停的进程
面试追问:SIGTERM 和 SIGKILL 有什么区别?为什么不推荐直接用 kill -9?
SIGTERM (15) 是优雅终止信号,进程可以捕获该信号并执行清理操作(关闭连接、保存状态、释放锁),然后主动退出。SIGKILL (9) 是强制终止,进程无法捕获或忽略,操作系统立即回收资源。直接使用 kill -9 会导致:1)进程来不及释放资源(锁文件、临时文件、socket 连接);2)子进程可能变成孤儿进程;3)数据库等应用可能损坏数据文件。正确做法是先 kill(默认 SIGTERM),等待超时后再 kill -9。
终止进程的多种方式
bash# 按 PID 终止 kill 12345 # SIGTERM kill -9 12345 # SIGKILL # 按名称终止 pkill -f "python.*app.py" # 匹配命令行模式 killall nginx # 精确匹配进程名 # 终止进程组 kill -- -$PGID # 向进程组发送信号 # 优雅重启 kill -HUP $(cat /var/run/nginx.pid)
wait 与退出码
wait 命令等待后台进程结束并获取其退出码:
bash./task_a.sh & PID_A=$! ./task_b.sh & PID_B=$! wait $PID_A; STATUS_A=$? wait $PID_B; STATUS_B=$? if [ $STATUS_A -eq 0 ] && [ $STATUS_B -eq 0 ]; then echo "全部成功" else echo "task_a 退出码: $STATUS_A, task_b 退出码: $STATUS_B" fi
僵尸进程与孤儿进程
这是面试中区分候选人水平的关键考点。
僵尸进程(Zombie)
子进程退出后,父进程必须调用 wait() 读取其退出状态,否则子进程的进程描述符会残留在系统中,成为僵尸进程(状态 Z):
bash# 查找僵尸进程 ps aux | awk "$8 ~ /Z/ {print}" # 或用更精确的方式 ps -eo pid,ppid,stat,cmd | awk "$3 ~ /Z/ {print}"
僵尸进程本身已死,不占 CPU 和内存,但占用 PID 资源和内核进程表项。大量僵尸进程会耗尽 PID,导致无法启动新进程。
解决方法:
- 修复父进程,让其正确调用
wait()/waitpid() - 杀死父进程,僵尸进程会被 init 进程(PID 1)接管并回收
- 在代码中设置
SIGCHLD信号处理函数自动回收
bash# Shell 中预防僵尸进程 trap "wait" CHLD
孤儿进程(Orphan)
父进程先于子进程退出,子进程被 init 进程(PID 1)收养,成为孤儿进程。孤儿进程不是问题进程,init 会负责回收。但在容器环境中,PID 1 可能不是 init,需要特别注意:
bash# 容器中用 init 系统确保孤儿进程被回收 # Dockerfile 中使用 cmd ["/sbin/tini", "--", "./app.sh"]
面试追问:僵尸进程和孤儿进程有什么区别?
僵尸进程是子进程已死但父进程未回收其状态,进程还在进程表中但已不运行;孤儿进程是父进程已死,子进程还在运行,被 init 接管。僵尸进程是问题需要处理,孤儿进程是正常现象。
进程优先级
Linux 用 nice 值控制进程优先级,范围 -20(最高优先级)到 19(最低优先级),默认 0:
bash# 以低优先级启动进程 nice -n 10 ./heavy_task.sh & # 修改运行中进程的优先级 renice -n 5 -p 12345 # 查看进程优先级 ps -eo pid,nice,cmd
普通用户只能降低优先级(增大 nice 值),只有 root 可以提高优先级(减小 nice 值)。
信号捕获与 trap
trap 命令捕获信号,实现优雅退出是面试常考点:
bash#!/bin/bash cleanup() { echo "清理临时文件..." rm -f /tmp/my_script_*.lock rm -f /tmp/my_script_*.tmp echo "清理完成" exit 0 } # 捕获 SIGINT、SIGTERM 和脚本退出 trap cleanup INT TERM EXIT # 创建锁文件防止重复运行 LOCK_FILE="/tmp/my_script.lock" if [ -f "$LOCK_FILE" ]; then OLD_PID=$(cat "$LOCK_FILE") if kill -0 "$OLD_PID" 2>/dev/null; then echo "脚本已在运行 (PID: $OLD_PID)" exit 1 fi fi echo $$ > "$LOCK_FILE" # 主逻辑 while true; do echo "工作中... $(date)" sleep 5 done
面试追问:trap 中捕获 EXIT 信号和捕获 TERM 有什么区别?
EXIT 是 shell 特殊信号,在脚本以任何方式退出时(正常结束、exit 命令、收到信号等)都会触发。TERM 只在收到 SIGTERM 时触发。捕获 EXIT 可以确保无论脚本如何退出,清理逻辑都能执行。
进程间通信
管道
匿名管道是最常用的 IPC 方式,命名管道(FIFO)支持无亲缘关系进程间通信:
bash# 匿名管道 cat access.log | awk "{print \$7}" | sort | uniq -c | sort -rn | head # 命名管道 mkfifo /tmp/task_pipe # 生产者 echo "task_data" > /tmp/task_pipe & # 消费者 while read line; do process "$line" done < /tmp/task_pipe
进程替换
进程替换是 Bash 的高级特性,将进程的输出映射为临时文件描述符:
bash# 比较两个命令的输出 diff <(sort file1.txt) <(sort file2.txt) # 合并多个进程的输出 paste <(command_a) <(command_b)
/proc 文件系统
/proc 提供内核和进程的实时信息,面试中常问如何不依赖工具获取进程信息:
bash# 查看进程的命令行参数 cat /proc/12345/cmdline | tr "\0" " " # 查看进程的环境变量 cat /proc/12345/environ | tr "\0" "\n" # 查看进程的文件描述符 ls -la /proc/12345/fd/ # 查看进程的内存映射 cat /proc/12345/maps # 查看进程状态 cat /proc/12345/status | head -20
实战场景
守护进程管理脚本
bash#!/bin/bash APP_NAME="my_app" APP_CMD="/usr/local/bin/my_app" PID_FILE="/var/run/${APP_NAME}.pid" LOG_FILE="/var/log/${APP_NAME}.log" start() { if [ -f "$PID_FILE" ] && kill -0 $(cat "$PID_FILE") 2>/dev/null; then echo "$APP_NAME 已在运行 (PID: $(cat $PID_FILE))" return 1 fi nohup $APP_CMD >> "$LOG_FILE" 2>&1 & echo $! > "$PID_FILE" echo "$APP_NAME 已启动 (PID: $!)" } stop() { if [ ! -f "$PID_FILE" ]; then echo "$APP_NAME 未运行" return 1 fi local pid=$(cat "$PID_FILE") kill "$pid" local timeout=10 while [ $timeout -gt 0 ] && kill -0 "$pid" 2>/dev/null; do sleep 1 ((timeout--)) done if kill -0 "$pid" 2>/dev/null; then kill -9 "$pid" echo "$APP_NAME 强制终止" else echo "$APP_NAME 已停止" fi rm -f "$PID_FILE" } restart() { stop sleep 1 start } case "$1" in start) start ;; stop) stop ;; restart) restart ;; status) if [ -f "$PID_FILE" ] && kill -0 $(cat "$PID_FILE") 2>/dev/null; then echo "$APP_NAME 运行中 (PID: $(cat $PID_FILE))" else echo "$APP_NAME 未运行" fi ;; *) echo "用法: $0 {start|stop|restart|status}" ;; esac
并发控制
bash#!/bin/bash MAX_PARALLEL=4 running=0 for task in tasks/*.sh; do bash "$task" & ((running++)) if [ $running -ge $MAX_PARALLEL ]; then wait -n # 等待任意一个后台进程完成 ((running--)) fi done wait # 等待所有完成 echo "全部任务完成"
wait -n 是 Bash 4.3+ 的特性,等待任意一个后台进程完成,是并发控制的关键。
进程超时控制
bash# 使用 timeout 命令 timeout 30s ./may_hang.sh # 手动实现超时 run_with_timeout() { local cmd="$1" local timeout=$2 $cmd & local pid=$! ( sleep $timeout kill -9 $pid 2>/dev/null ) & local watchdog=$! wait $pid 2>/dev/null local exit_code=$? kill $watchdog 2>/dev/null return $exit_code } run_with_timeout "./long_task.sh" 60