上一篇我们把进程从“是什么”讲到了“怎么看”,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里的PR(priority)列和NI(nice)列很多人分不清。NI就是你设置的nice值,而PR是内核实际使用的优先级,在普通调度策略下大致等于20 + NI,你看到PR是20、NI是0的进程,就属于默认优先级。
1.2 调整进程优先级:nice和renice的实操笔记
实际工作中,最常见的场景是:一台机器上跑着编译任务,同时又有人在上面做交互操作,结果整台机器卡得不行。这时候与其直接杀进程,不如先降低编译任务的优先级。
具体做法分两种情况。进程还没启动,用nice指定初始值:
# 以 nice=10 启动一个 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) | 默认动作 | 典型场景 |
|---|---|---|---|
| SIGHUP | 1 | 终止进程 | 关闭终端时通知会话进程退出 |
| SIGINT | 2 | 终止进程 | 按 Ctrl+C 触发 |
| SIGQUIT | 3 | 终止并产生core | 按 Ctrl+\ 触发 |
| SIGKILL | 9 | 强制终止 | 不可捕获、不可阻塞 |
| SIGTERM | 15 | 终止进程 | kill 默认信号,可捕获并清理 |
| SIGCHLD | 17 | 忽略 | 子进程退出时通知父进程 |
| SIGSTOP | 19 | 暂停进程 | 不可捕获、不可阻塞 |
| SIGCONT | 18 | 继续运行 | 恢复被暂停的进程 |
为什么我一直强调不要随手kill -9?因为SIGKILL直接由内核强制回收进程,进程没有任何机会做清理工作,比如关闭文件、删临时文件、把内存里的数据落盘。尤其对数据库、消息队列这类有持久化状态的应用,kill -9很容易造成数据损坏或启动后恢复失败。正确处理方式是优先发SIGTERM,也就是直接kill PID,让程序自行退出。如果过几秒还没退,再考虑用SIGKILL兜底。
2.2 用trap和kill控制进程:从发信号到收信号
光知道信号名字还不够,还得会“接信号”。很多脚本在退出前需要清理临时文件,但你直接Ctrl+C把脚本断了,清理逻辑压根没机会执行。这时候就要在脚本里用trap自定义信号处理。
举个实际例子。写一个备份脚本,运行中如果被Ctrl+C中断,应该删掉已经写入一半的临时文件:
#!/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执行这个脚本后按Ctrl+C,你会看到先打印清理信息,再退出,而不是被系统粗暴杀掉。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 前台、后台与作业控制:Ctrl+Z、bg、fg一套流程
日常操作里,进程还分前台和后台。前台进程占着你的终端,你干不了别的事;后台进程在终端里不显示输出,你还可以继续敲命令。
试着跑一个sleep 1000,它会一直卡住。此时按Ctrl+Z可以把这个进程挂起(暂停),然后终端会返回提示符。用jobs -l可以看到被挂起的作业编号和PID。接着输入bg %1,把它放到后台继续运行。如果又不想让它待在后台了,用fg %1拉回前台。
sleep 1000 # Ctrl+Z [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 2>&1 &这样关终端后进程还能继续跑。注意nohup不负责接收输出,所以重定向是必须的,否则它会把输出写到nohup.out里,时间久了容易把磁盘填满。
第二个是setsid,它让进程开启一个新的会话,彻底脱离当前控制终端。前面我说的SIGHUP是发给“会话首进程”的,如果你的进程压根不在这个会话里,自然也就收不到这个信号。对比来说,nohup是“忽略信号”,setsid是“脱离环境”,两者效果相似,但setsid更彻底。
第三个是后面要说的systemd服务托管,这已经是现代服务器上最正规的做法。
这里插一句经验:如果你要写一个部署脚本,别把nohup ... &和setsid ... &混着用,容易让日志输出变得不可控。选一种固定风格,日志统一重定向到固定路径,排查时能省很多事。
3.3 从临时后台到常驻服务:systemd unit文件怎么写
nohup适合临时任务,但真正要常驻的服务,还是交给systemd管理更靠谱。好处很多:开机自启、崩溃自动重启、统一日志、资源隔离。现在主流Linux发行版都默认init系统就是systemd。
一个最基本的服务文件长这样:
[Unit] Description=My Demo App After=network.target [Service] Type=simple User=nobody WorkingDirectory=/opt/myapp ExecStart=/opt/myapp/myapp --config /etc/myapp.conf Restart=always RestartSec=3 [Install] WantedBy=multi-user.target把文件放到/etc/systemd/system/myapp.service,然后执行:
systemctl daemon-reload systemctl enable --now myapp systemctl status myapp journalctl -u myapp -fRestart=always是线上服务的核心参数,进程意外退出后systemd会自动拉起,加上RestartSec=3防止频繁崩溃导致重启风暴。日志用journalctl -u myapp -f看,不用再自己写日志轮转。
对比一下:临时任务、快速验证用nohup就行;长期服务、需要开机自启和崩溃恢复的,直接上systemd。别再用nohup xxx &跑生产服务了,进程死了都没人知道,十年前的坑到现在还有人在踩。
4. 僵尸进程与孤儿进程:不得不防的“死而不僵”
4.1 僵尸进程是怎么炼成的:从wait到进程表
进程列表里偶尔能看到状态是Z(zombie)的进程,也叫<defunct>。很多人一看“僵尸”两个字就慌了:是不是机器被入侵了?其实僵尸进程是Linux进程生命周期里的一个正常阶段,大多数时候不用紧张,但要是积累多了,就该警惕了。
故事要从父子进程的约定说起。子进程退出时,并不会立刻从进程表中彻底消失,它要保留一个最小记录,包括PID、退出状态、资源使用统计等,等着父进程来“收尸”。父进程用wait或waitpid系统调用读取子进程的退出信息,读完这个记录才被清除。
如果父进程一直不调wait,子进程就会一直保持在僵尸状态。用我常打的比方:孩子已经退房了,但酒店前台还留着他的登记信息,等着家长来签个字;家长一直不来,这个房间号就被占着。
僵尸进程有什么危害?它不占CPU、不占内存,但占着一个PID。PID资源不是无限的,如果大量进程变成僵尸,新的进程可能因为分配不到PID而启动失败。另一个隐患是:父进程这种“生了孩子不抚养”的行为本身,往往说明它写代码时就没处理子进程回收,这类程序通常还潜藏着其他资源泄露问题。
4.2 一行Python复现僵尸进程,直观看defunct
想亲眼看到僵尸进程,用Python很容易复现。os.fork()创建一个子进程,子进程直接退出,父进程故意不调wait:
import os import time pid = os.fork() if pid == 0: # 子进程:打印PID后立刻退出 print(f"child pid: {os.getpid()}") os._exit(0) else: # 父进程:睡眠60秒,不回收子进程 print(f"parent pid: {os.getpid()}, child pid: {pid}") time.sleep(60)保存为make_zombie.py运行,然后另开一个终端:
ps -eo pid,ppid,stat,cmd | grep Z会看到类似输出:
12346 12345 Z [python] <defunct>STAT列是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(f"reaped child: {pid}") except ChildProcessError: break signal.signal(signal.SIGCHLD, reap_child)配置了Restart=always的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 files |
| max user processes (-u) | 单个用户最大进程/线程数 | 进程数超限,报 Cannot allocate memory |
| core 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跑几秒后Ctrl+C退出,它会打印这段时间内该进程最密集的系统调用统计。有个经典结论:如果在疯狂调用read某一个文件,那就是业务代码死循环读文件;如果大量futex或nanosleep,那多半是线程锁竞争或空转,问题焦点就转移到并发设计上了。日常生产环境strace要慎用,它对性能影响很大,一般先在预发或者低峰期做。
5.3 进程数过多也不容忽视:Limit与pid_max
最后提醒一个容易被忽略的点:进程和线程的总数限制。
很多人遇到fork: Cannot allocate memory,第一反应以为是内存不够,其实可能是进程数或者线程数达到上限。Linux里每个PID不能无限增长,查看系统最大PID:
cat /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',花不了一分钟,但能让你对这台机器的健康状态心里有个底。