ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

进程机制深度拆解:从PCB到守护进程的Linux实战手册

进程机制深度拆解:从PCB到守护进程的Linux实战手册 很多刚接触操作系统的人第一次真正破防都是在遇到进程这个概念的时候。你打开任务管理器看到几十个陌生的英文名字你在 Linux 服务器上敲ps -ef列出来的东西看懂了每一列但拼不出一个完整的画面。更常见的是你写了一个程序运行起来之后它占着终端不撒手你想让它去后台想让它别盯着屏幕想让它和别的程序协同工作——这些操作背后全是进程机制在托底。这篇东西就是围绕进程展开的深度拆解从它到底是什么、长什么样、怎么从一个程序变成一个活体到进程之间怎么通信、怎么被杀死、怎么变成后台的隐形守护者再落到 Linux 系统里那些你一定会用到的实操命令和排查手段。不绕弯子不堆理论适合正在学操作系统这门课的学生也适合在真实服务器上被进程问题折腾过的开发、运维朋友。1. 先回答那个最基础的问题进程到底是什么1.1 程序正在运行这句话错在哪网上的教材十有八九会让你背一句话进程是程序的一次执行过程。这句话是严谨的但也是容易误导人的。因为它很容易被理解成进程就是活过来的程序仿佛程序里本来就有生命力一样。实际上程序和进程是两个几乎完全不同的东西。打个比方程序是放在抽屉里的菜谱进程是灶台上正在进行的那场烹饪。菜谱是静态的它不会因为时间流逝而变化烹饪是一次动态的、有状态的、会结束的活动。你照着同一份菜谱做十次饭做出来的菜可能有咸有淡火候有大有小——这就是同一份程序产生多个不同进程的原因。程序只是一堆躺在磁盘上的指令二进制文件它自己不占 CPU、不占内存、不持有文件句柄。直到操作系统把它加载进内存为它分配资源、创建身份档案它才变成一个进程开始消耗真实世界的资源。所以当你双击一个 exe操作系统做的不是把程序拿进来跑而是做了一系列工程把可执行文件的代码段映射到内存把数据段、堆、栈初始化好创建一个进程控制块PCB把程序计数器指向入口函数——这一切完成之后你的程序才活了。这个从静态到动态的过程就是进程的诞生。还有一个经常被忽略的点一个程序可以被同一个用户同时启动多次产生多个进程它们各自独立互不干扰。你用 Chrome 打开十个标签页看到的往往是同一个 chrome 进程路径下的一堆不同 PID 的进程它们用的是同一份磁盘上的二进制但内存空间、状态、句柄完全不同。这就是程序与进程最直观的差异——程序是复制的蓝本进程是每一次独立的实况转播。1.2 一段代码从程序变成进程操作系统都干了什么我习惯把进程的诞生拆成四个步骤来理解这四个步骤也是面试里常问的exec 系调用与 fork 的关系的底层逻辑分配 PID 和 PCB操作系统维护着一张进程表每创建一个进程就在表中新增一项分配一个全局唯一的进程标识符PID。这个 PID 就是操作系统的身份证号之后所有操作——发信号、看资源占用、强制终止——都靠它来索引。加载程序映像内核把可执行文件中的代码段、数据段读入内存现代操作系统是映射用到了虚拟内存和请求分页机制不是一次性读入建立虚拟地址空间。这一步本质上是把菜谱翻到食材清单和步骤页。初始化运行时环境创建用户栈和内核栈初始化寄存器上下文设置程序计数器PC指向程序的入口通常是_start。这一步在 fork 出来的子进程里表现得更清楚子进程从 fork 调用返回处开始执行而不是从 main 开始。就绪入队把进程状态设置为就绪态放入就绪队列等待调度器选中。到这里进程已经具备随时上 CPU 执行的全部条件只差一个被允许执行的机会。理解了这个过程你就能解释很多平时觉得神奇的现象。比如用strace ./my_program调试程序你能看到内核先做了execve调用紧接着就是一系列内存映射和加载动态链接库的系统调用。这些都是进程诞生过程中看得见的证据。进程和程序的这个区别往深了说还会引出一个重要推论所有我们说的高并发多任务本质上是操作系统在多个进程之间快速切换而不是让一个程序同时做很多事。CPU 同时只能执行一条指令所谓的同时运行若干个程序其实是时间片轮转带的错觉。这个错觉背后就是调度器的工作。2. 进程的心电图状态机与生命周期2.1 五态模型进程活着的时候只有这几种状态进程不是永远在运行的。操作系统教科书上经典的三态模型是就绪态、运行态、阻塞态再多加两个就变成五态模型——新建态和终止态。这个状态机是你理解一切进程行为的基础我挨个说清楚新建态New进程正在被创建PCB 刚分配还没进入就绪队列。这个状态持续时间极短短到大多数时候你用ps根本抓不到它但它是真实存在的。就绪态Ready进程已经具备一切运行条件只等 CPU。可以想象成一群运动员在起跑线上热身发令枪一响调度器选中你就能冲出去。运行态Running进程占用 CPU正在执行指令。在单核 CPU 上任何时刻只有一个是运行态多核场景下则最多有与 CPU 核数相同的运行态进程。阻塞态Blocked/Wait进程因为等待某个事件通常是 I/O 完成、等待锁、读取管道数据而暂时无法执行。注意它自己是主动放弃 CPU 的调度器会把它从运行态踢出把 CPU 让给别人。这就是等待的本质。终止态Terminated进程执行完或收到终止信号操作系统回收资源但 PCB 可能还留存在进程表里供父进程查询退出状态——这就是僵尸进程的由来后面再展开。这五个状态之间不是乱切换的能走的路径完全由触发条件决定。我把关键的转换关系整理成一张表转换触发条件谁做的决定新建 → 就绪创建完成进入就绪队列内核创建例程就绪 → 运行调度器选中该进程分配 CPU调度算法时间片轮转、优先级等运行 → 就绪时间片用完或高优先级进程抢占时钟中断/抢占机制运行 → 阻塞进程主动发起 I/O、等待事件、睡眠进程自身调用阻塞 → 就绪等待的事件发生如 I/O 完成中断/内核唤醒运行 → 终止正常退出、致命错误、被杀进程自身或外部信号这里我想特别点一下运行 → 就绪和运行 → 阻塞的区别。前者是被迫让位——你没干完但时间片到了先下去排队回来接着干后者是主动让位——你遇到了一个自己当下无法解决的问题比如等磁盘数据只能让出 CPU等数据到了再回来。很多刚学的人把阻塞和就绪混为一谈其实关键区别就是就绪态不缺少 CPU 以外的任何东西阻塞态缺少的是某个尚未发生的事件。2.2 从 fork 到 exitLinux 进程的复制式诞生方式讲到进程的生命周期就绕不开 Linux 创建进程的两个原语fork()和exec()。这是无数 Linux 后端工程师又爱又恨的两个东西也是很多诡异 bug 的源头。fork()的特殊之处在于它被调用一次却返回两次。调用它的程序父进程会得到子进程的 PID而新创建的子进程会得到一个返回值 0。程序代码用一个if判断返回值就能区分我现在是爹还是儿子从而走不同的逻辑分支。这个机制看起来简单第一次跑的时候很容易忽略一个事实——fork 之后的代码父进程和子进程都会继续往下执行。于是经常出现这种情况你在 fork 前后各放了一个printf结果屏幕上出现了四行输出两份输出各被执行了两次。这不是 bug这是进程语义的正常表现。fork 创建子进程的底层实现早年是彻底复制父进程的地址空间代价高昂。现代 Linux 用了写时复制技术fork 出的子进程先和父进程共享同一片物理内存只有当其中一方尝试写入时内核才真正复制那一页互不干扰。这个优化让 fork 变得非常轻量也是你看到服务器上动不动几百上千个进程还能撑住的原因之一。进程结束的路径也有讲究进程主动调用exit()或者从 main 函数 return最终都会走到内核的 do_exit 逻辑。但如果子进程先退出、父进程还没来得及处理这个进程就会进入僵尸态——PCB 还保留着内存和文件描述符都回收了但进程表里有一个幽灵项用来记录退出状态码。父进程需要调用wait()/waitpid()来给这个幽灵收尸。如果你写的父进程没做这个处理僵尸进程就会积攒起来这是一个非常常见的坑在后面的排查章节我会演示一个真实的场景。2.3 实操观测怎么在 Linux 里看见进程的状态光看书上的状态机没感觉真到了服务器上你可以用命令实时看见这些状态。最核心的观测工具是ps我给你列一下最常看的字段ps -ef显示全格式列表。UID用户、PID进程号、PPID父进程号、CCPU 占用率百分比、STIME启动时间、TTY关联终端?表示无终端——守护进程多半是它、TIME累计 CPU 时间、CMD完整命令行。ps auxBSD 风格。最重要的列是 STAT它直接反映进程状态。STAT 字段里那些字母的含义是S可中断睡眠对应阻塞态、R运行态或就绪态、D不可中断睡眠一般是等待磁盘 I/O、Z僵尸态、T停止、在前台进程组。top/htop动态刷新。top 里的S列显示的也是状态D 状态是个值得留意的信号——下面会讲。实操中一个特别值得警惕的组合是STAT 为 Z 的进程 PPID 为 1 的进程。前者说明你的程序有僵尸子进程后者说明子进程的父进程已经退出了被 init 收养。如果你发现一个进程的父进程 PID 是 1通常是 systemd说明它的原生父进程已经死掉它自己也成了孤儿由系统帮忙接管。看到这种进程别急着杀它——先查它的资源占用再判断它是坏掉的野子还是本该如此的系统服务。我自己的习惯是排查进程问题先pstree -ap PID看整个进程家族树理清父子关系再用ps -o pid,ppid,stat,wchan -p PID看某个具体进程的等待通道知道它卡在哪个内核函数上。wchan是一个很冷门但非常强大的字段它能告诉你这个进程是不是正堵在某次 read 系统调用上。3. PCB那个给每个进程发身份证的档案袋3.1 PCB 里到底装了什么为什么说它是进程存在的唯一标志如果把进程比作一个正在工作的人那进程控制块PCBProcess Control Block就是这个人的人事档案袋。操作系统对进程的一切管理——分配 CPU、调度、记账、回收——全部是通过这个档案袋完成的。学习的时候你甚至可以认为PCB 存在进程就存在PCB 被销毁进程就彻底消失。这是进程存在的唯一标志这句话的准确含义。一个典型的 PCBLinux 里对应task_struct结构体包含的信息大致有以下几组进程标识信息PID、PPID、进程组 IDPGID、会话 IDSID。这是操作系统索引一个进程的钥匙。进程状态信息当前状态运行/就绪/阻塞等、状态转换需要的标志位。CPU 上下文通用寄存器、程序计数器、程序状态字PSW、栈指针。这些是切换进程时现场保留的内容——进程被调度下去时它的寄存器值被原样存在 PCB 里下次调度回来从断点继续执行好像从未被打断过。调度信息优先级、调度策略、已使用的 CPU 时间、等待时间。这是调度器做决策的数据来源。内存管理信息指向页表/段表的指针、代码段/数据段/堆栈的边界。每个进程的虚拟地址空间之所以能隔离不串扰全凭这组信息。I/O 状态进程打开的文件描述符表、当前使用的设备、挂起的 I/O 请求。记账信息CPU 使用时间、系统时间、用户时间、退出码等供统计和审计使用。你在 Linux 里做ls -l /proc/PID/看到的一大堆文件其实大部分就是 PCB 内容的对外暴露。比如/proc/PID/status直接就能看到进程状态、PPID、内存峰值、信号屏蔽字/proc/PID/stat则是给ps提供原始数据的文件。所以如果你想深入了解一个进程/proc目录就是你透视 PCB 的窗口。3.2 一切皆文件背后的文件描述符表和它的泄漏问题PCB 里最常在实战中拖你后腿的一项就是文件描述符表。Linux 的设计哲学是一切皆文件socket、管道、磁盘文件、设备统统用文件描述符fd来引用。每个进程内部维护着一张 fd 表记录着它当前打开的每一个文件。一个服务端程序长时间运行后表现越来越差、CPU 不高但内存一路狂涨八成有两个嫌疑内存泄漏和文件描述符泄漏。后者尤其隐蔽因为你用ps看完全正常内存也没涨太多但它就是吃不进新连接了。排查手法是ls /proc/PID/fd | wc -l数一下这个进程打开的 fd 总数然后cat /proc/PID/limits看它允许的软/硬上限。如果数字逼近上限基本可以断定代码里某个分支忘了 close。我踩过最深的一个坑是某个内部工具每处理一条消息就 new 一个 socket正常路径会关闭但异常路径直接 return 了导致每个失败消息都留下一个 TIME_WAIT 的 socket fd 没被进程层释放。当时线上表现为服务每隔三天就拒绝新请求重启即恢复。后来就是靠 /proc/PID/fd 列表 dump 出来的 fd 指向全部是某个固定 IP 的 TCP 连接才定位到问题。信任这条经验当你的进程病了但看不出毛病先数它的文件描述符。如果你想在代码层面防住这个问题命令行下先把ulimit -n调大是一个临时的续命手段。但根治还是要靠代码审查和测试配合 fd 数量的监控曲线来实现。4. 进程之间怎么说话IPC 的几种主流姿势4.1 管道它可能是你每天都在用的进程通信方式进程通信IPCInter-Process Communication这个概念听起来很高端但你说不定每天都在用而不自知。你在 shell 里敲ps aux | grep nginx那个竖线|就是一个匿名管道。它的本质是左边进程的标准输出接到右边进程的标准输入两边通过内核里的一块环形缓冲区交换数据。管道通信有两个特点值得记住。第一它是半双工的——数据只能从写端流向读端不能双向同时进行。第二它的容量是有限制的Linux 下pipe缓冲区默认通常是 64KB一旦缓冲区满写进程会被阻塞反过来缓冲区空读进程会阻塞。这个机制保证了生产者-消费者模型不会爆缓冲但也意味着如果读写双方配合不好可能出现互相等待的死锁。我以前写过一个多进程数据处理的小工具父进程给子进程下发任务、子进程回传结果用的就是两个管道但因为回传管道满了子进程阻塞而父进程在等子进程的第一条回传才去读下发管道——经典的双向等待死锁排查了一个下午。教训是管道数量多时注意读写顺序和阻塞时机。比匿名管道更正式的是命名管道FIFO。它通过mkfifo创建一个特殊的文件类型两个进程可以像打开普通文件一样一个以只读方式打开一个以只写方式打开实现通信即使它们不是父子关系。这在一些临时性的进程间数据传递场景很好用但实际生产中用得不算多原因是被下面要说的方式替代了。4.2 消息队列与共享内存速度与结构的取舍管道传递的是字节流没有边界概念。如果你的进程间通信需要一条一条消息这种结构化数据消息队列更合适。消息队列是内核空间维护的一个消息链表每个消息有类型和长度接收方可以按类型选择性读取。在 System V 的接口里它是msgget、msgsnd、msgrcv三个函数的组合。不用自己处理粘包问题这是它相对管道最大的便利。但消息队列有一个性能瓶颈数据需要从用户态拷贝到内核态再从内核态拷贝到另一个用户态两次拷贝。当你追求极致吞吐时这个开销会被放大。这时候就轮到共享内存出场了。共享内存是让两个进程的虚拟地址空间映射到同一块物理内存页数据直接读写在物理内存上进出都无须内核中转这是目前速度最快的 IPC 方式。代价是什么呢一来共享内存需要双方互相约定好数据的布局和读写顺序否则就是裸奔二来它自带同步问题——两边同时写同一片内存数据会互相踩踏。所以经典的组合是共享内存 信号量。信号量负责锁门写方先 P 操作等待资源再写写完 V 操作释放资源读方同理。你可以用ipcs -m查看当前系统里的共享内存段用ipcrm -m shmid手工清理残留的共享内存——这是那些案发后还留在现场的共享内存段最常见的宿命。我个人的建议是中小项目的进程间通信能用 Unix domain socket 就用 socket别折腾 System V 的这套接口因为后者资源的生命周期管理真的很容易出问题程序退出没清理下次跑就报id 已存在之类莫名其妙的问题。共享内存留给那些确实测过、瓶颈在 IO 拷贝上的高性能场景。4.3 信号进程的一条紧急短信与 kill 的正确姿势信号是最古老的 IPC 手段之一本质上是发给进程的一个异步通知——进程无需为它准备任何代码内核会打断进程当前的执行流跳到信号处理函数去执行。CtrlC 就是给前台进程组发了一个 SIGINTkill -9 PID是发 SIGKILL。信号的数量有限可携带的信息量很小所以它适合做事件通知不适合传数据。这里有个必须纠正的普遍误解很多人觉得进程杀不掉就上 kill -9这其实是下策。kill -9发送的是 SIGKILL它不能被捕获、不能被阻塞内核直接强制终止进程。但这个强制也意味着进程没机会收拾现场的——给它擦屁股的反而还是内核来回收资源但子进程没被妥善处理、临时文件没清理、共享内存没释放都是隐患。正确的处理路径是先用kill PID默认编号 15SIGTERM让进程收到请你终止的礼貌通知有配置优雅退出的程序会在此时保存状态、释放资源。等几秒还活蹦乱跳再考虑 SIGKILL。另外还有一种情况进程连 SIGKILL 都杀不动——没错那种进程确实存在处于D 状态不可中断睡眠的进程比如正在等进行磁盘 I/O最典型的就是读取 NFS 挂载目录卡死内核在完成该次 I/O 前不接受任何信号连 SIGKILL 都排在队尾。这时候杀进程无从谈起真正该做的是修复底层 I/O 问题比如重启 NFS 服务或者重启机器。替换杀进程三板斧的第一板斧是kill的信号选择第二板斧是pkill -f 进程名按名字匹配省得每次ps查 PID第三板斧才是kill -9。此外nohup命令的本质是让进程忽略挂断信号 SIGHUP——在终端退出后还能存活但要注意它不等于真正脱离会话这在下一节会有更深刻的说法。5. 线程、守护进程与改名大法那些概念和实操混在一起的地方5.1 进程与线程说的不是一个维度的事很多教材用进程是资源分配的基本单位线程是 CPU 调度的基本单位这句话来区分二者。这个定义是对的但初看不太容易有体感。我换一个角度说进程是隔离的容器线程是容器里的执行流。同一进程内的多个线程共享该进程的地址空间、文件描述符、堆、静态数据每个线程只保留自己的栈、寄存器和线程局部存储。这意味着什么呢线程之间的数据交换根本不需要 IPC——它们直接读写同一块内存就行成本低得离谱。但共享也意味着彼此干扰。一个线程里写坏了一块堆内存整个进程都跟着崩所有线程一起陪葬一个线程段错误你ps看到的是整个进程退出。进程则有天然的隔离性一个进程挂了另一个进程毫无感觉。用一句概括进程让你活得更久线程让你跑得更快。切换开销也是对比的核心。切换进程要换页表、刷新 TLB、切内核栈代价不小切换同一进程内的线程只需换寄存器上下文和栈指针轻量很多。但注意线程切换更轻是有前提的——它们必须在同一个进程内部。跨进程的线程切换比如 Go 的 M:N 调度里开销并不会好到哪里。工程上的启发是如果你的程序主要是并发处理 I/O网络请求、读写文件用线程池就够了没必要拆成多进程但如果你担心一个模块出问题牵连整个服务或者想充分利用多核且不共享数据多进程是更稳健的选择。我做过一个数据采集器一开始用多线程写省事结果某个第三方库偶尔崩溃直接带走全部采集任务。后来改成多进程模型每个采集任务一个独立进程崩溃了由 supervisor 拉起就好整个系统从此安稳了不少。5.2 守护进程与会话像隐形人一样在后台活着想看一个进程是不是守护进程daemon最直观的办法是看ps -ef里的 TTY 列是不是?并且它的进程号和父进程号往往是特殊的值。守护进程的生存哲学是脱离控制终端不再依赖任何登录会话即使你退出登录它依然在运行。nginx、mysqld、sshd全是这种角色。一个普通进程如果想让自己的后代变成守护进程教科书上的标准步骤是 fork setsid chdir umask 二次 fork。setsid 是其中最核心的一步它让进程成为新会话的首进程同时摆脱原来的进程组和会话从此不再有控制终端。这是区分我用 nohup 跑了程序和真正的守护进程的分水岭。但这里有个坑想提前帮你填上nohup cmd 虽然能在你退出终端后继续运行但它并没有调用 setsid所以它依然属于刚才那个会话。如果你用的是某些对会话敏感的终端环境或者进程后续需要真正独立运行时nohup很可能不够用——我就见过一个用 nohup 跑的服务在 SSH 超时导致会话断开时程序虽然没有被杀但输出了报错最终陷入半死不活的状态。更规范的替代是systemd的 service 单元或者setsid命令直接包裹启动命令。在手动写守护进程的 C 代码时也记得先umask(0)和chdir(/)避免继承当前目录和创建文件的权限过宽。5.3 一个很实用的技巧Linux 下修改进程名最后分享一个很多人不知道、但在线上运维时非常救命的小技巧——修改进程名。默认情况下Linux 进程的命令行名字就是你启动它的命令这在排查问题时往往不够直观。尤其当你有一个脚本用 Python 起了多个工作进程时ps aux里全部显示python3 worker.py根本分不清谁是谁。把进程名改一改运维幸福感直线上升。Linux 下有两条路可以改进程名。一条路是修改/proc/self/comm或者/proc/PID/comm它保存的是进程的通信名最多 15 个字符超过会被截断ps的 COMMAND 列通常就显示这个。写一个测试脚本运行后看到的名字就变成了my_custom_nameecho my_custom_name /proc/self/comm注意/proc/self/comm 的修改只对当前进程和 ps 显示效果生效它不会改/proc/self/cmdline——那条记录的是原始启动参数。另一条更底层、也更强大的路是使用prctl()系统调用配合PR_SET_NAME参数可以在 C/C/Python通过 ctypes中动态修改子线程的名字。这也是很多服务框架内部把线程池命名成thread-pool-1之类的方式。比如在 Python 里用 ctypes 调 libc 的 prctlimport ctypes ctypes.CDLL(None).prctl(15, bmy_thread_name, 0, 0, 0)数字 15 就是PR_SET_NAME的枚举值。坑在于它同样有 15 个字符的限制——你想改一个更长的进程名比如 20 个字符会从尾部被截断。要绕开这个限制只能去改 argv[0] 指向的内容把原来的命令行参数覆盖掉这需要 C 语言层面精巧地操作日常运维中用不太到。从运维视角看建议把进程名当成代码的一部分来设计在脚本启动后第一行就把/proc/self/comm写上可读性强、包含业务含义的名字代价几乎为零收益却很大——ps和top的展示立刻变得一目了然报警系统按进程名聚合时也不会再混淆。关于进程最后再唠叨一点。我见过太多人学操作系统把精力全放在背概念上结果一到真实环境不知道 D 状态是什么不知道僵尸进程怎么收尸不知道 nohup 和 daemon 的区别写出的多进程代码一跑就出怪事。其实进程机制是那种概念和实操高度咬合的知识——你每多理解一层 PCB、每多排查一次进程异常底层操作系统都在帮你补课。我有一个小习惯每次在服务器上遇到一个运行异常的进程都会刻意花五分钟查它的完整状态、父进程、打开的文件和等待的通道再想想它对应的是书里的哪个知识点。这个习惯坚持一年比刷十遍教材都管用。
返回列表