
前两天帮朋友排查一个线上问题服务器负载飙到80多负载高但CPU空闲、内存也充足系统却卡得不行。用 ps 一看一百多个进程挂在 D 状态IO 等待队列塞满——这种进程控制层面的疑难杂症只会 top 和 kill 的人根本无从下手。我一直觉得Linux 进程控制属于那种“平时觉得简单、出事才发现地基不牢”的知识。今天这篇文章我就从这类真实问题出发把进程控制从头到尾拆一遍包括进程的创建与回收、状态切换、优先级、资源限额、进程间通信以及实际排障时最常用的命令组合。不管你是在运维岗位、做嵌入式开发还是在准备 Linux 面试这篇文章都值得认真看一遍。我会尽量用“为什么这么做”的视角去讲而不是简单罗列命令和参数因为真正让你在事故现场不慌的是理解机制本身。1. 从事故现场看进程控制状态、负载与排查起点1.1 同一个“卡死”五种完全不同的根因很多新手遇到系统卡顿第一反应就是“CPU 满了”。但 CPU 满、内存满、IO 堵死、锁竞争、进程饥饿这五类问题在 top 里的表现完全不同。那次线上事故里load average 接近 80但 top 里 %Cpu(s) 的 us 和 sy 加起来不到 10%。按理说负载高应该伴随高 CPU 使用率可这里没有反而是 IO 等待wa那一项占了将近 70%。再看进程列表大量进程处于 D 状态也就是不可中断睡眠通常意味着它们在内核里等着某个 IO 完成无法被信号打断。这时候如果你盲目 kill进程根本不会响应因为它在内核态里正“死等”硬件返回。反过来如果你看到 us 占满、多个 R 状态进程抢占 CPU那是计算密集型的表现处理思路完全不同。所以排查进程控制问题第一件事不是杀进程而是搞清楚进程到底处于什么状态等待什么资源。1.2 为什么进程状态比 CPU 使用率更能说明问题我之前带过不少人他们习惯只看 CPU 百分比极少看进程状态列。这两者其实不是一回事。CPU 使用率只能说明“进程占用处理器的情况”而进程状态则能告诉你“这个进程到底在哪一步卡住了”。Linux 进程的核心状态至少有五种R运行或可运行、S可中断睡眠、D不可中断睡眠、T停止、Z僵尸。还有一个 X 是死状态基本看不到。举个例子S 状态的进程通常是在等待某个条件比如等待用户输入、等待 socket 数据、等待锁唤醒D 状态则多和磁盘 IO、网络 IO 等内核驱动的等待相关。我那次排查就是通过ps -eo pid,ppid,stat,wchan:30,comm看到大量 D 状态再配合 wchan 列看到它们卡在wait_on_page_bit之类的内核函数上才确定是存储层 IO 延迟把整个业务拖垮了。所以我会说状态列是进程控制里最值得优先掌握的信息。2. 进程的诞生与死亡fork、exec、vfork 的底层语义2.1 fork 的两次返回与写时复制Linux 创建进程最核心的系统调用是 fork。它最反直觉的地方在于你调用一次却返回两次。父进程拿到的是子进程的 PID子进程拿到的是 0。如果返回 -1说明创建失败。为什么一个函数能返回两次因为 fork 本质上把当前进程的地址空间“复制”了一份然后让两个执行流各自继续跑。内核会为子进程创建新的 task_struct并把父进程的页表复制过去。如果每 fork 一次都完整拷贝所有内存开销会非常夸张所以现代 Linux 默认采用了写时复制Copy-on-WriteCOW机制。所谓写时复制就是 fork 之后父子进程先共享同一批物理内存页并且把这些页标记为只读。无论父子哪一方先写了这个页都会触发缺页异常内核才真正复制这一页并解除只读保护。这样一来大多数 fork 后马上 exec 的场景几乎不用复制任何数据代价只是一个页表的复制和几个引用计数的增减。这也是为什么很多人说“在 Linux 上 fork 很廉价”。但要注意廉价不等于免费如果父进程本身有几十 GB 的内存页fork 时要遍历页表开销依然存在。我在嵌入式设备上就踩过这种坑父进程吃掉了大部分内存频繁 fork 导致系统卡顿后来改用 posix_spawn 或者直接调整设计才缓解。看下面这段最简代码理解 fork 的返回特性#include stdio.h #include unistd.h int main() { pid_t pid fork(); if (pid 0) { printf(child: my pid is %d\n, getpid()); } else if (pid 0) { printf(parent: child pid is %d\n, pid); } else { perror(fork); } return 0; }它会打印两行执行顺序不定因为父子进程谁先获得 CPU 由调度器决定。这个“顺序不定”是很多新手写多进程程序时莫名出错的原因之一。2.2 exec 与 vfork创建进程的另外两条路fork 创建出来的子进程和父进程几乎一模一样。如果子进程想运行一个全新的程序就需要调用 exec 系列函数比如 execl、execv、execve。exec 的本质是替换当前进程的代码段、数据段、堆和栈但进程 PID 不变打开的文件描述符默认也不变。这就是“换壳不换魂”的过程。实际开发里最常见的组合就是 fork 之后立刻 exec 一个外部程序shell 执行命令时就是这个路径。创建进程的两步操作Linux 都提供了但绝不会隐式帮你做。再说 vfork。它很容易被误解为 fork 的一个高性能变体。早期内存昂贵、没有 COW 时vfork 用于优化性能它保证子进程先运行父进程挂起且子进程共享父进程的地址空间。这个设计很危险因为子进程里任何写操作都可能搞坏父进程的内存。现代 Linux 的 fork 已经有 COWvfork 的唯一优势就是省去页表复制而大多数场景下这个节省微乎其微。我的建议是新代码不要用 vfork老老实实用 fork exec。2.3 进程销毁的完整通道与 exit 清理进程退出也不是说没就没的。无论是 main 里 return还是调用 exit()或者收到致命信号进程最终都会进入 do_exit 流程。内核会释放它的内存、关闭文件描述符、释放各种资源但 task_struct 结构本身会保留一段时间因为我们还需要从里面读出退出码和资源使用统计。这个“保留了 task_struct 但没有完整存活”的进程就是僵尸状态 Z。等父进程调用 wait/waitpid 之后内核才会真正把 task_struct 回收。换句话说回收子进程是父进程的义务。如果父进程一直不 wait子进程就会一直以僵尸形式存在于进程表里。这个概念对理解后面的僵尸问题是基础。3. 僵尸进程与孤儿进程回收机制和工程防治3.1 僵尸进程是怎么产生的为什么杀不死我见过不少人在系统里看到一堆 Z 状态进程第一反应是 kill -9。结果发现怎么 kill 都杀不掉于是怀疑系统坏了。僵尸进程不是“没死透”而是“已经死了但没人收尸”。它不占 CPU、不占内存唯一占用的资源是内核进程表项也就是一个 PID 位置。Linux 的 PID 数量默认有上限如果僵尸进程疯狂累积最终会导致系统无法创建新进程这才是它的真正危害。产生僵尸的典型代码是这样的父进程 fork 了子进程子进程运行结束变成僵尸但父进程没有调用 wait 也没有注册 SIGCHLD 处理函数。最常见出现在长期运行的守护进程里如果程序里 fork 完就丢给子进程自己跑父进程不关心子进程的退出那子进程一结束就会变成僵尸。为什么 kill 杀不掉因为 kill -9 是针对“存活进程”的信号而僵尸进程已经结束了内核里只剩一个空壳结构等待父进程来 wait。信号对它没有意义。3.2 三种工程方案waitpid、SIGCHLD 与二次 fork治理僵尸的常规手段有三种按推荐程度排序父进程阻塞或非阻塞地调用 wait/waitpid。最简单粗暴阻塞 wait 会让父进程停在那里等子进程退出有些场景没问题但交互式程序一般不合适。更常用的是 WNOHANG 选项配合轮询。捕获 SIGCHLD 信号在信号处理函数里回收子进程。子进程退出时内核会向父进程发送 SIGCHLD父进程在该信号处理里调用 waitpid 回收。这是最正统的做法适合大多数服务程序。二次 fork 法父进程 fork 出一个子进程后自己先退出让这个中间子进程被 init 或 systemd 收养中间子进程再去 fork 真正的孙进程。这样一来孙进程退出时由 init 系统进程统一收尸父进程完全不用操心。缺点是逻辑绕但很有效早期很多守护进程就是这么干的。实际工程中我建议首选 SIGCHLD 信号处理因为它在事件驱动模型里体验最好。代码示意如下#include signal.h #include sys/wait.h void handle_sigchld(int sig) { int status; while (waitpid(-1, status, WNOHANG) 0) { // 循环回收避免有多个子进程同时退出而漏掉 } } int main() { struct sigaction sa {0}; sa.sa_handler handle_sigchld; sigemptyset(sa.sa_mask); sa.sa_flags SA_RESTART; sigaction(SIGCHLD, sa, NULL); // ... 业务逻辑 }这里有个小坑信号处理函数执行期间如果有多个子进程同时退出可能只收到一次 SIGCHLD。所以处理函数里要用 while WNOHANG 循环回收而不是只 waitpid 一次。3.3 孤儿进程的托管从 init 到 systemd跟僵尸相对的是孤儿进程。如果父进程先退出子进程会变成孤儿Linux 会对它做“过继”处理让 PID 为 1 的 init 进程接管。在老系统里是 init在主流发行版上就是 systemd。被收养的孤儿进程退出后systemd 会替父进程完成 wait 回收所以孤儿进程本身不容易变成僵尸。但要注意这种过继机制也带来一个经典问题如果程序把子进程简单丢出去不接管它的日志、状态和生命周期子进程就成了“野孩子”。这在容器环境里尤其危险容器内 PID 1 如果没实现好子进程回收逻辑容器退出时会积压一大堆僵尸进程。所以我一直建议任何写多进程程序的人都应该把“谁是父进程、谁负责回收、谁来兜底”这三个问题先想清楚再开始写代码。4. 优先级、调度与资源限额让进程按规矩跑4.1 nice 值的真相它只能让你“礼貌地排队”很多人以为 nice 值是“优先级”值越小越优先。这个说法对了一半更准确的说法是nice 值影响进程在 CPU 调度时的权重而不是绝对的优先级。它表示“这个进程愿意对别人多礼貌”nice 值越高进程越谦让让出的 CPU 时间越多。普通进程的 nice 范围是 -20 到 19默认是 0。普通用户只能把 nice 调高也就是让自己更谦让只有 root 才能调低让自己抢占更多 CPU。这就避免了一个普通用户把某个进程 nice 调到 -20 来饿死其他进程。使用上你可以在启动命令时用nice -n 10 ./myprog指定或者在进程运行期间renice 10 -p PID调整。不过说实话nice 只影响 CPU 调度权重管不了内存、IO、带宽这些资源。真正精细化控制资源得靠 cgroup。4.2 cgroup 限制 CPU 与内存cgroup 是 Linux 内核提供的资源隔离机制也是容器技术的地基。v2 版本的用法相对清晰所有控制都在 /sys/fs/cgroup 下面。我举两个最常用的例子限制 CPU 配额和限制内存占用。在 cgroup v2 里限制 CPU 主要看 cpu.max 文件。它的格式是quota period比如50000 100000表示每 100 毫秒周期内最多运行 50 毫秒也就是最多占用一个 CPU 核心的 50%。操作流程如下# 创建子控制组 mkdir -p /sys/fs/cgroup/myapp # 设置 CPU 配额100ms 周期内最多 50ms相当于 0.5 核 echo 50000 100000 /sys/fs/cgroup/myapp/cpu.max # 把进程 PID 写入控制组 echo 12345 /sys/fs/cgroup/myapp/cgroup.procs限制内存写 memory.max单位是字节echo 2147483648 /sys/fs/cgroup/myapp/memory.max echo 12345 /sys/fs/cgroup/myapp/cgroup.procs一旦进程组超过 memory.max通常会触发 OOM 或者回收具体由 memory.oom.group 等参数决定。在 cgroup v1 老路径上上述文件位置会变成 /sys/fs/cgroup/cpucpu.cfs_quota_us 和 cpu.cfs_period_us写法略有不同但思想一致。很多使用 Linux 的团队会把服务直接跑在 systemd 管理的 cgroup 下通过 service 文件里的 CPUQuota、MemoryMax 等参数配置而不是手工碰 /sys/fs/cgroup。这也是我推荐的方式因为系统重启后配置不会丢。4.3 按进程控制带宽tc cgroup 的组合玩法有段时间我在折腾一个离线下载服务它一跑起来就把出口带宽占满其他业务直接卡死。限制 CPU 没用瓶颈在网络带宽。这时候就需要按照进程来限流而带宽控制不属于 CPU 调度范畴得靠 tc 配合 cgroup 实现。最经典的做法是走 cgroup v1 的 net_cls 控制器。给进程组打一个 classid 标记比如 0x100001表示 1:1 这个类别然后用 tc 的 filter 去匹配这个标记对匹配到的流量执行限速策略。简化步骤如下创建 net_cls 控制组并设置标记mkdir -p /sys/fs/cgroup/net_cls/limited echo 0x100001 /sys/fs/cgroup/net_cls/limited/net_cls.classid用 tc 建立 HTB 队列把 1:1 子类限速到 1Mbpstc qdisc add dev eth0 root handle 1: htb default 999 tc class add dev eth0 parent 1: classid 1:1 htb rate 1mbit tc filter add dev eth0 parent 1: protocol ip prio 1 handle 1: cgroup把目标进程 PID 写入控制组echo 12345 /sys/fs/cgroup/net_cls/limited/tasks这种方案在容器和虚拟机环境中很常见。cgroup v2 移除了 net_cls目前在 v2 体系下做进程级带宽限制要么靠 eBPF要么依赖 systemd 的 BPF 相关配置复杂度高不少。如果你维护的还是 v1 老环境这套组合依然是最容易落地的方案。5. 进程间通信的五条通路与选型思路5.1 管道与 FIFO适合父子与同主机协作进程控制不只是创建和回收还涉及进程之间怎么协作。Linux 下最朴素的方式就是管道。cmd1 | cmd2这种 shell 管道本质就是让两个进程通过一个内核缓冲区传递数据一个写、一个读。管道是半双工的数据单向流动而且只能在有亲缘关系的进程间使用这个关系靠 fork 时继承文件描述符来建立。要让没有亲缘关系的进程通信可以用 FIFO也就是命名管道。它在文件系统里有一个路径任何进程只要知道路径就可以打开并读写。我早期写过一个简单的日志采集进程一个生产者进程往 FIFO 里写日志另一个消费者进程读出来做解析两台进程之间完全无关但协作得很干净。需要注意FIFO 的读写是阻塞式的如果写端没人打开读端 open 时会一直卡住这个行为容易吓到新手。不管是管道还是 FIFO数据量都不适合太大缓冲区一般在 64KB 级别写满会阻塞写端。它适合流式数据传递不适合大块随机访问。5.2 信号最轻量的异步通信信号可以说是 Linux 里最古老也最轻量的通信方式。它适合传递“事件通知”而不是搬运数据。比如子进程退出时内核发送 SIGCHLD用户按 CtrlC 发送 SIGINT进程非法访问内存产生 SIGSEGV这些都是信号的应用场景。进程控制里信号最常用到 kill 命令和 kill() 系统调用。这里有个误区kill 不只是用来杀进程的信号发送工具它可以发送任何信号。比如kill -USR1 PID是发送自定义的 SIGUSR1很多服务用它来触发日志重载或重新读取配置。写信号处理函数要小心只能调用异步信号安全函数比如 write、waitpid 这类绝对不能调用 printf、malloc 这类不安全函数。标准库在信号处理期间可能处于不一致状态调用非安全函数可能导致未定义行为这是经典面试题也是实际工程里容易踩的坑。5.3 共享内存、消息队列与 Socket 的取舍当进程间需要高频交换大量数据时管道和信号都不够用。共享内存是性能最高的一种方式多个进程直接映射同一块物理内存数据写入后对方立即可见不需要内核缓冲区参与拷贝。但共享内存有两个麻烦一是要自己处理同步问题否则两个进程同时写会数据错乱通常配合信号量semaphore使用二是共享内存对象是持久化的程序崩溃后如果没清理/dev/shm 下会残留一堆文件需要 ipcrm 手动删。我见过线上服务器 /dev/shm 被占满就是因为一个程序反复创建共享内存但从不释放。消息队列则是介于管道和共享内存之间的选择适合“短消息的可靠传递”但每条消息有大小上限并且同样需要管理生命周期。实际业务里它的使用率并不高多数场景已经被 Redis、Kafka 这类外部中间件替代。如果你要做的是跨主机进程通信Unix domain socket 和 TCP socket 才是正道。Unix domain socket 只在本机内有效但不需要走网络协议栈性能非常高很多数据库本地连接都走它。而 TCP socket 则能跨机器通信代价是要处理粘包、断线重连、并发连接管理这些问题。我做进程控制的选型建议就一句话能共享内存就不用管道能走消息中间件就不自己写通信协议但是作为 Linux 基本功这些底层 IPC 机制你都得知道它们的存在和适用边界。6. 排查进程问题的命令组合拳ps、top、strace6.1 ps 的正确打开方式字段筛选而不是 grep 流水账很多运维新手排查进程就是ps aux | grep java然后对着结果发呆。这个做法效率很低因为你看到的信息太多、过滤条件的语义又不明确。我习惯用 ps 的 -o 参数精确控制输出列再配合 awk 做二次过滤。最常用的一组ps -eo pid,ppid,stat,%cpu,%mem,wchan:30,comm --sort-%cpu | head -30wchan 列可以显示进程当前wait在内核的哪个函数上stat 列用于快速识别 D/T/Z 状态--sort-%cpu 让 CPU 占用最高的进程排在最前面。比如排查 D 状态进程一条命令就能筛全ps -eo pid,ppid,stat,wchan:30,comm | awk $3 ~ /D/能看到每个 D 状态进程卡在哪个内核等待点上。这比ps aux | grep高效太多了。6.2 top 和 pidstat 的调度视角top 是大家最熟的工具但大部分人的用法停留在“看前几行 CPU/memory 进程列表”。要深入进程控制至少要看这几项us用户态 CPU、sy内核态 CPU、waIO 等待占比load average三个值注意它和 CPU 使用率不是同一个概念进程的 S 列状态按P键按 CPU 排序按M键按内存排序。pidstat 则是更聚焦的工具按进程维度输出 CPU 使用率、上下文切换次数、内存占用等。我最常用的一条pidstat -w -p PID 1每秒输出一次指定 PID 的上下文切换数量。如果 cswch/s 和 nvcswch/s 飙升说明进程在频繁被动换入换出通常是锁竞争激烈或线程太多造成的这时候就该往锁优化方向排查了。6.3 strace看进程到底卡在哪个系统调用当进程状态异常比如一直 R 状态但 CPU 占比不高或者 S 状态却没有正常响应strace 是最直接的诊断工具。它可以跟踪进程发起的系统调用。strace -p PID执行之后会实时打印这个进程正在调用什么系统调用。如果看到它反复卡在 read、futex、poll 这些系统调用上你就能大概定位是 IO 等待还是锁等待。比如一个进程卡住不响应strace 输出停在futex(FUTEX_WAIT)那基本就是锁竞争问题停在read(3, ...)且 fd 3 指向某个磁盘文件那可能和存储性能有关。strace 也会带一些性能开销生产环境挂到高并发进程上要谨慎我一般先用 ps 的 wchan 粗略定位再用 strace 做短时间精确跟踪而不是长时间挂着。还可以用strace -f -e tracenetwork -p PID只看网络相关的系统调用。7. 把进程变成稳定服务daemon 与 systemd7.1 传统 daemon 化的步骤与语义早期写后台服务需要程序员手动做 daemon 化也就是把进程变成守护进程。一套标准操作包括 fork、setsid、再 fork、chdir、umask、关闭标准输入输出每个步骤都有原因。fork 一次是为了让子进程成为 session leader 的候选者父进程可以退出setsid 让进程脱离原会话和终端控制之后不再受 CtrlC 影响再 fork 一次是为了确保进程不会重新获得控制终端chdir 到 / 是为了不占用某个挂载点目录否则会影响卸载磁盘umask 设置文件权限掩码重定向标准输入输出到 /dev/null 或日志文件避免输出无处可去。这套操作现在看起来繁琐但它本身就是进程控制要素的集中体现会话、进程组、终端信号、孤儿进程全都涉及。如果你要维护老项目看到 daemon 相关代码时至少能知道它在干什么。7.2 systemd 接管后的进程控制方式现代主流 Linux 发行版上写服务基本用 systemd 的 unit 文件就够了。它把 daemon 化过程全部接管你只需要描述服务长什么样、怎么启停、怎么守护。一个简单的服务文件示例[Unit] DescriptionMy service [Service] ExecStart/usr/local/bin/myservice Restarton-failure RestartSec3 Nice-5 LimitNOFILE65536 MemoryMax2G CPUQuota50% [Install] WantedBymulti-user.target这里每个参数都对应一个进程控制点Restart 控制进程退出后的自动重启策略Nice 调整 CPU 调度权重LimitNOFILE 提高文件描述符上限MemoryMax 和 CPUQuota 直接映射到 cgroup 资源限额。配置文件改完之后systemctl daemon-reload再systemctl restart myservice生效。使用 systemd 管理进程的最大好处是日志统一到 journald进程状态统一查询资源限额统一声明。它比手写 daemon 逻辑可靠得多也更容易追踪这也是我建议团队新项目一律走 systemd 的原因。就我个人的实际经验来说处理进程控制问题最重要的是先看状态、再想机制、最后动手。很多人一上来就 kill结果把问题越搞越大。我常用的一个体检套餐是ps 看状态列、top 看全局负载、pidstat 看上下文切换、必要时 strace 跟踪系统调用这套步骤基本能覆盖 90% 的进程异常场景。把这套流程熟练之后你会发现在面试里聊进程控制也能聊得比别人更落地因为你不只是在背概念而是真的见过这些进程状态在系统里活生生地切换过。