Linux进程控制实战:从优先级、信号到僵尸进程

📅 发布时间:2026/10/19 16:52:21
Linux进程控制实战:从优先级、信号到僵尸进程
上一篇我们把进程从“是什么”讲到了“怎么看”ps、top、进程状态这些基本操作应该都摸过一遍了。但一旦你真去维护一台服务器或者写脚本拉起一堆任务就会发现进程真正麻烦的地方其实在于“控制”为什么关掉终端程序就跟着没了为什么有个进程明明卡死了kill也杀不掉为什么进程列表里躺着一堆defunct看着吓人却又不占内存这一篇我打算把几个绕不开的问题一次讲透围绕调度优先级、信号机制、前后台作业与守护进程、僵尸进程这几个核心主题展开最后再补一段资源限制和排查经验。内容偏实战适合已经了解基本命令、想进一步理解进程运行机制的Linux进阶用户也适合刚接触服务器运维的同学——读完再去碰线上问题至少不会看到僵尸进程就手足无措。1. 进程调度与优先级为什么你的程序“不听话”1.1 时间片与调度策略CPU先跑谁内核说了算先厘清一个基础概念单核CPU在同一个瞬间只能执行一个进程。但你打开电脑浏览器、编辑器、音乐播放器好像都在“同时”干活靠的是内核调度器在极短的时间里反复切换进程制造出“并行”的假象。每次切换谁上场、上场多久就是进程调度要解决的事。Linux默认使用的调度器是CFS完全公平调度。它不会给每个进程分一个固定的时间片而是按权重来分配CPU时间比例。你可以把它理解成一个公共厨房锅里只有一个灶眼多个进程轮流用灶。有人要做三口菜有人只热一杯牛奶调度器不会傻乎乎地每人分固定的30秒而是尽量保证所有人总体的“等待比例”公平。CPU密集的进程如果一直在占用它的权重会被逐渐压低把机会让给别人这就是“完全公平”的基本思想。对大多数普通进程来说决定权重高低的关键参数就是nice值。nice的取值在-20到19之间默认是0。注意这个名字很形象nice值越高进程越“谦让”获得CPU的资源就越少nice值越低进程越“霸道”更愿意抢CPU。普通用户跑任务基本只会把nice调高也就是主动让出资源想把nice调低到负数让进程抢更多CPU通常需要root权限。这里还有一个常见误区top里的PRpriority列和NInice列很多人分不清。NI就是你设置的nice值而PR是内核实际使用的优先级在普通调度策略下大致等于20 NI你看到PR是20、NI是0的进程就属于默认优先级。1.2 调整进程优先级nice和renice的实操笔记实际工作中最常见的场景是一台机器上跑着编译任务同时又有人在上面做交互操作结果整台机器卡得不行。这时候与其直接杀进程不如先降低编译任务的优先级。具体做法分两种情况。进程还没启动用nice指定初始值# 以 nice10 启动一个 CPU 密集任务 nice -n 10 while :; do :; done echo $!进程已经在跑了用renice动态调整# 假设上面命令输出的 PID 是 12345 renice -n 10 -p 12345调整完用ps -o pid,nice,cmd -p 12345或者直接在top里看NI列就能看到效果。我实际测试过把while :; do :; done这种无限空循环跑满一个核心renice到 10 之后它在top里的CPU占用确实会明显回落交互操作的卡顿感大大缓解。这里要说个容易踩的坑top里的%CPU是一个采样时间段内的平均值不是瞬时值。你调整完 nice 值后%CPU不会立刻跳变需要等几秒刷新才稳定。如果你在脚本里用renice调整完马上取 CPU 数据做判断很可能会误判。1.3 低优先级进程会被“饿死”吗聊聊调度公平性另一个很多人担心的问题把进程的nice值调得很高比如19它是不是就完全跑不动了答案是不会完全饿死。CFS有一个调度周期概念可以理解成每隔一段时间几毫秒到几十毫秒取决于系统负载所有进程都会轮流被调度一次。低优先级的进程虽然每次分到的CPU时间极少但依然能获得运行机会只是慢很多。所以遇到CPU密集型的批量任务你把nice调到19它依然会慢慢跑完完全可以在白天跑不影响其他交互操作如果发现某个低优先级进程真的一点都不动了那更需要怀疑是不是有其他问题比如进程状态卡在了不可中断睡眠D state。还有一个使用建议不要轻易把核心服务进程的nice调成负数尤其是数据库一类的进程。极端优先级可能导致它长期霸占CPU让操作系统本身都变得迟缓甚至触发看门狗机制。我的习惯是除非明确知道业务特性否则生产环境里一般只往下调不往上调。2. 信号机制与进程通信给进程发“指令”2.1 常见信号速查为什么kill -9不是万能钥匙进程控制离不开信号。信号是一种异步通知机制内核或者别的进程可以通过信号告诉目标进程“发生了什么事”比如“该停了”“你被暂停了”“你的子进程退出了”。kill这个命令名字特别有误导性它的本意其实不是“杀死”而是“发送信号”。比如kill -TERM PID是发送SIGTERM信号kill -9 PID是发送SIGKILL信号。下面是Linux上最常见的几个信号建议背下来信号编号x86_64默认动作典型场景SIGHUP1终止进程关闭终端时通知会话进程退出SIGINT2终止进程按 CtrlC 触发SIGQUIT3终止并产生core按 Ctrl\ 触发SIGKILL9强制终止不可捕获、不可阻塞SIGTERM15终止进程kill 默认信号可捕获并清理SIGCHLD17忽略子进程退出时通知父进程SIGSTOP19暂停进程不可捕获、不可阻塞SIGCONT18继续运行恢复被暂停的进程为什么我一直强调不要随手kill -9因为SIGKILL直接由内核强制回收进程进程没有任何机会做清理工作比如关闭文件、删临时文件、把内存里的数据落盘。尤其对数据库、消息队列这类有持久化状态的应用kill -9很容易造成数据损坏或启动后恢复失败。正确处理方式是优先发SIGTERM也就是直接kill PID让程序自行退出。如果过几秒还没退再考虑用SIGKILL兜底。2.2 用trap和kill控制进程从发信号到收信号光知道信号名字还不够还得会“接信号”。很多脚本在退出前需要清理临时文件但你直接CtrlC把脚本断了清理逻辑压根没机会执行。这时候就要在脚本里用trap自定义信号处理。举个实际例子。写一个备份脚本运行中如果被CtrlC中断应该删掉已经写入一半的临时文件#!/bin/bash cleanup() { echo receive SIGINT, clean and exit... rm -f /tmp/backup.tmp exit 1 } trap cleanup SIGINT echo backup start, pid is $$ # 模拟写临时文件 while true; do echo working... /tmp/backup.tmp sleep 1 done执行这个脚本后按CtrlC你会看到先打印清理信息再退出而不是被系统粗暴杀掉。trap只对可捕获的信号有效SIGKILL和SIGSTOP就没辙了内核压根不给你拦截的机会这也是有时候排查线上问题发现某些进程连清理日志都没写就被干掉的原因——很可能就是收到了kill -9。信号机制在守护进程里也常用很多服务进程收到SIGHUP后会重新读取配置文件而不是退出。不过这点放到下一章详细讲因为和“关掉终端程序就没了”这个世纪难题直接相关。2.3 进程间通信管道、共享内存和消息队列怎么选进程之间默认是互相隔离的谁也碰不到谁的数据。要想协作就得走内核提供的通信通道。常见的进程间通信方式有管道、消息队列、共享内存、信号量、套接字。管道是最朴素的一种分两种。匿名管道就是你天天在用的那个竖线|比如ps aux | grep nginx它把左边命令的标准输出接到右边命令的标准输入两边各自作为一个进程在跑数据在内核里中转。命名管道用mkfifo创建它以文件形式存在两个不相干的进程可以通过这个文件来传递数据。我有时做临时调试会用命名管道模拟简单的生产者消费者# 终端A创建并读取管道 mkfifo /tmp/msg_fifo cat /tmp/msg_fifo # 终端B往管道写数据 echo hello from terminal B /tmp/msg_fifo管道适合流式传递、数据量不大、单向通信的场景。如果要大量数据共享管道效率就低了因为要反复拷贝到内核再拷贝回来。共享内存是直接划一块内存让多个进程都能映射访问读写效率最高但需要自己处理并发同步问题复杂度明显上去了。消息队列适合“发送一条结构化消息需要排队处理”的场景。你不需要现在就搞懂每一种的API细节但脑子里要有这张地图数据量小、单向、简单协作用管道数据量大、追求性能用共享内存跨主机通信用套接字。后面学IPC编程的时候再往细里填。3. 前台后台与守护进程让程序真正跑起来3.1 前台、后台与作业控制CtrlZ、bg、fg一套流程日常操作里进程还分前台和后台。前台进程占着你的终端你干不了别的事后台进程在终端里不显示输出你还可以继续敲命令。试着跑一个sleep 1000它会一直卡住。此时按CtrlZ可以把这个进程挂起暂停然后终端会返回提示符。用jobs -l可以看到被挂起的作业编号和PID。接着输入bg %1把它放到后台继续运行。如果又不想让它待在后台了用fg %1拉回前台。sleep 1000 # CtrlZ [1] Stopped sleep 1000 jobs -l # [1] 12345 Stopped sleep 1000 bg %1 fg %1这套流程在处理交互脚本、临时切换任务时很实用。但你有没有发现一个问题如果在SSH会话里跑一个some_app 然后直接退出登录再重新登录进去这个进程往往不见了。很多人以为加了就是后台运行结果发现一关终端进程还是没了。这里面的关键不是而是下一节要讲的SIGHUP。3.2 关掉终端程序就没了SIGHUP与nohup的真相先讲根因。当你退出登录、关闭SSH会话时内核会向这个终端会话里的进程发送SIGHUP信号。SIGHUP全称是 hang up本意是“电话挂断”的意思——终端连接都断了依附它的进程自然也要终止。进程收到SIGHUP的默认行为就是退出。普通只是把进程放到后台并没有让进程脱离终端会话所以关终端时SIGHUP照样发过来进程照样死。想让它不随终端消失思路有这么几个第一个是nohup意思是 no hang up它会让进程忽略SIGHUP信号。最经典的用法是nohup java -jar myapp.jar app.log 21 这样关终端后进程还能继续跑。注意nohup不负责接收输出所以重定向是必须的否则它会把输出写到nohup.out里时间久了容易把磁盘填满。第二个是setsid它让进程开启一个新的会话彻底脱离当前控制终端。前面我说的SIGHUP是发给“会话首进程”的如果你的进程压根不在这个会话里自然也就收不到这个信号。对比来说nohup是“忽略信号”setsid是“脱离环境”两者效果相似但setsid更彻底。第三个是后面要说的systemd服务托管这已经是现代服务器上最正规的做法。这里插一句经验如果你要写一个部署脚本别把nohup ... 和setsid ... 混着用容易让日志输出变得不可控。选一种固定风格日志统一重定向到固定路径排查时能省很多事。3.3 从临时后台到常驻服务systemd unit文件怎么写nohup适合临时任务但真正要常驻的服务还是交给systemd管理更靠谱。好处很多开机自启、崩溃自动重启、统一日志、资源隔离。现在主流Linux发行版都默认init系统就是systemd。一个最基本的服务文件长这样[Unit] DescriptionMy Demo App Afternetwork.target [Service] Typesimple Usernobody WorkingDirectory/opt/myapp ExecStart/opt/myapp/myapp --config /etc/myapp.conf Restartalways RestartSec3 [Install] WantedBymulti-user.target把文件放到/etc/systemd/system/myapp.service然后执行systemctl daemon-reload systemctl enable --now myapp systemctl status myapp journalctl -u myapp -fRestartalways是线上服务的核心参数进程意外退出后systemd会自动拉起加上RestartSec3防止频繁崩溃导致重启风暴。日志用journalctl -u myapp -f看不用再自己写日志轮转。对比一下临时任务、快速验证用nohup就行长期服务、需要开机自启和崩溃恢复的直接上systemd。别再用nohup xxx 跑生产服务了进程死了都没人知道十年前的坑到现在还有人在踩。4. 僵尸进程与孤儿进程不得不防的“死而不僵”4.1 僵尸进程是怎么炼成的从wait到进程表进程列表里偶尔能看到状态是Zzombie的进程也叫defunct。很多人一看“僵尸”两个字就慌了是不是机器被入侵了其实僵尸进程是Linux进程生命周期里的一个正常阶段大多数时候不用紧张但要是积累多了就该警惕了。故事要从父子进程的约定说起。子进程退出时并不会立刻从进程表中彻底消失它要保留一个最小记录包括PID、退出状态、资源使用统计等等着父进程来“收尸”。父进程用wait或waitpid系统调用读取子进程的退出信息读完这个记录才被清除。如果父进程一直不调wait子进程就会一直保持在僵尸状态。用我常打的比方孩子已经退房了但酒店前台还留着他的登记信息等着家长来签个字家长一直不来这个房间号就被占着。僵尸进程有什么危害它不占CPU、不占内存但占着一个PID。PID资源不是无限的如果大量进程变成僵尸新的进程可能因为分配不到PID而启动失败。另一个隐患是父进程这种“生了孩子不抚养”的行为本身往往说明它写代码时就没处理子进程回收这类程序通常还潜藏着其他资源泄露问题。4.2 一行Python复现僵尸进程直观看defunct想亲眼看到僵尸进程用Python很容易复现。os.fork()创建一个子进程子进程直接退出父进程故意不调waitimport os import time pid os.fork() if pid 0: # 子进程打印PID后立刻退出 print(fchild pid: {os.getpid()}) os._exit(0) else: # 父进程睡眠60秒不回收子进程 print(fparent pid: {os.getpid()}, child pid: {pid}) time.sleep(60)保存为make_zombie.py运行然后另开一个终端ps -eo pid,ppid,stat,cmd | grep Z会看到类似输出12346 12345 Z [python] defunctSTAT列是Z说明子进程已经进入僵尸状态PPID就是父进程的PID。这里有一个小细节子进程退出用os._exit(0)而不是sys.exit()。因为sys.exit()会触发Python解释器的清理流程而os._exit()是系统调用级的立即退出更贴近进程本身。想验证父进程退出后僵尸会被清理那就等60秒父进程sleep结束自动退出再看进程列表僵尸进程会跟着消失。因为父进程退出后它的子进程会被1号进程收养1号进程处理完退出信息后僵尸状态就被回收了。4.3 僵尸进程到底怎么清处理思路与代码补丁既然是父进程不回收那“杀掉僵尸进程”这个思路就是错的。僵尸进程已经死了你再用kill -9 PID对着僵尸进程操作完全没用因为它根本不在可运行状态。正确方向只有一个让父进程去回收。分成几种情况。如果父进程随时可以重启那最简单重启父进程僵尸状态随之消失。如果父进程是常驻服务不能随便重启就得看代码了。比如用Python的话父进程需要注册SIGCHLD信号处理或者轮询os.waitpid(-1, os.WNOHANG)import signal import os import time def reap_child(signum, frame): while True: try: pid, status os.waitpid(-1, os.WNOHANG) if pid 0: break print(freaped child: {pid}) except ChildProcessError: break signal.signal(signal.SIGCHLD, reap_child)配置了Restartalways的systemd服务有时能“掩盖”问题systemd本身注册了subreaper机制可以在一定程度上接管孤儿进程但它也接不住父进程一直健在、不断产生僵尸子进程的场景。所以看到僵尸进程还是得定位到具体父进程看看程序逻辑为什么没有回收。快速排查父子关系用pstree -p PID很直观能一眼看出哪个进程下面挂着一堆defunct子进程。实际排查时我会用一条命令把僵尸进程和它们的父进程同时列出来ps -eo pid,ppid,stat,cmd | awk $3 ~ /^Z/ {print $1, $2, $4}拿到PPID再去看对应服务八成就能定位到代码问题。5. 资源限制与进程监控小问题拖垮整台机器5.1 先用ulimit给进程拴上缰绳进程不是无限吃资源的Linux允许你给进程加上资源限制主要是防止单个失控进程拖垮整台机器。常用的限制用ulimit命令来设置。ulimit -a重点看几个参数含义典型问题open files (-n)单个进程最多打开文件数文件句柄耗尽应用报 Too many open filesmax user processes (-u)单个用户最大进程/线程数进程数超限报 Cannot allocate memorycore file size (-c)core dump文件大小失控程序产生超大core文件刷爆磁盘virtual memory (-v)进程虚拟内存大小限制不当时进程OOM临时修改当前shell及其子进程的限制ulimit -n 65535但注意这个只在当前终端会话生效。想持久化要写到/etc/security/limits.conf* soft nofile 65535 * hard nofile 65535 * soft nproc 65535 * hard nproc 65535我遇到过好多次这类问题服务明明没挂就是响应很慢一查日志全是Too many open files调整文件描述符上限后立刻恢复。这个参数在服务托管文档里最容易被忽略建议所有网关、数据库、消息队列组件上线时都提前把ulimit -n调大别等线上故障了再手忙脚乱。5.2 top、pidstat、lsof组合拳揪出吃资源进程定位进程资源问题的基本功是把一口气堆出工具都用熟。top进入交互界面后按P按CPU排序按M按内存排序按1看每个核心的使用率。要盯某一个进程直接top -p PID -d 1-d 1表示一秒刷新一次。但top是全局视角想专门看某个进程的历史统计pidstat更顺手pidstat -p PID 1每行会输出该进程的CPU使用率、内存占用、线程数等适合做连续采样。还有个高频排查场景明明一个进程CPU不高磁盘却不正常。这时候用lsof -p PID看它打开了哪些文件统计一下数量lsof -p PID | wc -l如果数量持续上涨多半是文件句柄泄漏程序在处理请求时没关闭文件或连接。这种泄漏短时间看不出来跑几天后才会触发Too many open files线上排查时一定要看趋势而不是单次快照。另一种情况是进程CPU跑到接近100%但看不清它在干嘛。用strace跟踪系统调用可以看出来strace -p PID -c跑几秒后CtrlC退出它会打印这段时间内该进程最密集的系统调用统计。有个经典结论如果在疯狂调用read某一个文件那就是业务代码死循环读文件如果大量futex或nanosleep那多半是线程锁竞争或空转问题焦点就转移到并发设计上了。日常生产环境strace要慎用它对性能影响很大一般先在预发或者低峰期做。5.3 进程数过多也不容忽视Limit与pid_max最后提醒一个容易被忽略的点进程和线程的总数限制。很多人遇到fork: Cannot allocate memory第一反应以为是内存不够其实可能是进程数或者线程数达到上限。Linux里每个PID不能无限增长查看系统最大PIDcat /proc/sys/kernel/pid_max在64位系统上默认一般是4194304听着很大但单个用户可以创建的进程/线程数量还会受/etc/security/limits.conf里的nproc限制。统计当前系统里到底有多少线程用ps -eLf | wc -l注意线程也算进程表的一项一个JVM进程动辄几百个线程如果按“进程数”排查很容易漏掉线程耗尽的问题。容器场景下还会用cgroup的pids.max单独限制容器内的进程总数超过一样会报fork失败。这块我自己的排查顺序是先ulimit -u看用户进程数限制再cat /proc/sys/kernel/pid_max看系统上限然后ps -eLf | wc -l看当前实际用量三步走下来基本能判断是配置不够还是程序泄漏。聊到这儿进程(二)这条线差不多就收尾了。我个人最想强调的还是信号和会话的关系很多部署问题比如关终端服务就断、nohup看着用了却还是失效、僵尸进程清不掉本质上都是没搞懂SIGHUP、SIGCHLD和进程生命周期。把这几个机制串起来再去看systemd和容器里的进程管理都会轻松很多。最后分享一个自己的小习惯拿到一台新服务器先看一眼ulimit -a再跑一遍ps -eo pid,ppid,stat,cmd | grep -E Z|D花不了一分钟但能让你对这台机器的健康状态心里有个底。