资讯详情

资讯详情

Linux进程全解析:从fork/exec到状态管理与僵尸进程排查

很多人在学Linux的时候第一次被“进程”这个概念卡住往往不是因为命令记不住而是因为脑子里没有一个清晰的模型。我刚开始接触Linux时总觉得“进程”就是“正在运行的程序”直到后来排查一个服务器问题才发现如果只停留在这种理解层面连两个最基础的概念都分不清为什么同一段程序开两个终端跑会有不同的PID为什么程序退出后进程还在这些问题背后其实藏着一整套操作系统的运行逻辑。这篇内容我把进程这个概念掰开揉碎从它在系统里的位置、状态流转、创建过程到实际管理命令一条线讲清楚。适合刚接触Linux、或者用过一段时间但是对进程理解还比较模糊的读者。我会尽量用实际场景和横向对比来讲而不是堆术语保证你读完能真正建立一套自己的认知框架。1. 进程不是程序先说清这个最基本的区别进程和程序的区别是所有后续概念的地基。很多人背过“程序是静态的进程是动态的”但到底怎么个动态法却说不出来。程序就是磁盘上的一个文件比如你用gcc编译出来的a.out躺在硬盘里什么也不干。它只有代码、数据、各种段比如代码段、数据段、BSS段的静态描述。你可以把程序想象成一本菜谱菜谱放在书架上不管放多久它都只是文字和图片不会自己变成一盘菜。进程则是程序被加载到内存并开始执行后的“活体”。操作系统把程序的代码段加载进内存分配栈、堆、进程控制块PCB、打开的文件描述符表、信号处理表等一套运行时资源然后让CPU去一条条执行指令。这时候它才是“正在运行”的实体。继续用菜谱类比进程是厨师照着菜谱开始炒菜的这个过程过程中锅碗瓢盆都动起来了火候、佐料、时间都在变。一个非常经典的验证方法是看 PID 的变化。你写一个简单的while(1);死循环程序编译后放到后台跑三次会得到三个不同的 PID。同一个a.out文件却产生了三个进程就是因为每次运行系统都从零开始给它搭了一套全新的运行时环境。还有一个细节往往被忽略进程不光是程序在跑它还包括程序运行过程中产生的一切“状态”。比如某个变量现在存的值、当前执行到哪一行也就是PC寄存器指向哪、函数调用的栈在哪里。这些内容合在一起才构成了一个完整的进程。1.1 进程的组成内核视角下的“三件套”从内核的角度看一个进程至少包含三个层面的东西进程控制块PCB / task_struct这是内核里最重要的数据结构里面记录着进程的PID、状态、优先级、上下文、打开的文件、内存地址空间映射等。你可以把它想象成进程的“档案袋”内核调度时看的就是这份档案。地址空间进程独享的虚拟内存空间包含代码段、数据段、堆、栈。虚拟内存的意义在于每个进程都觉得自己拥有一整块连续的内存实际上内核通过页表做了映射。这也是进程之间“隔离感”的来源——A进程的内存数据B进程正常情况下完全看不到。执行上下文寄存器的值、程序计数器、栈指针等。线程切换时切换的就是这一层而进程切换还要额外更换地址空间这也是为什么进程切换比线程切换重的原因。这三样缺一不可。每当一个程序被运行时内核就为它造一个task_struct分配地址空间初始化寄存器上下文。这一套动作一气呵成最终通过execve系统调用把磁盘上的程序文件“灌”进新建好的进程体里。1.2 为什么说“正在运行”是个模糊概念“正在运行”这个说法其实并不准确。在单核CPU上同一时刻只能有一个进程真正在使用CPU但系统里的进程可能有几百个。那其他进程在干嘛它们在排队在等内存在等I/O在等某个事件。这些进程并没有“死”只是处于不同的状态。所以你应该把“进程”理解成一个调度单元而不是一根“在跑”的电线。它的存在是为了让操作系统有能力在多个任务之间做时分复用让用户感觉所有程序都在同时工作。这个理解一旦建立后面看ps输出的STAT列就不会再觉得奇怪了。2. 进程的一生从创建到退出的状态流转进程不是凭空出现的也不是永远存在的。它有明确的出生、生存和死亡过程。搞清楚这个过程你就掌握了进程概念的骨架。2.1 创建fork与exec双人舞在Linux里创建一个新进程的经典路径是先调用fork()再调用exec()系列函数。这两个调用各司其职缺一不可。fork()干的事情是“复制当前进程”。父进程调用fork()后内核会把父进程PCB中大部分内容复制一份生成一个子进程。这个子进程拥有独立的PID但它的代码段和父进程共享只读数据段、堆、栈则通过写时复制COWCopy-On-Write技术做到“看起来独立、实际上共享直到某一方试图修改才真正复制”。这也是新手最容易误解的地方很多人以为 fork 之后父子进程是两个完全独立的程序。其实它们开始时刻执行的是同一段代码唯一的区别在fork()的返回值——父进程收到的返回值是子进程的PID子进程收到的返回值是 0。代码里通常通过判断这个返回值来分道扬镳pid_t pid fork(); if (pid 0) { // fork 失败 } else if (pid 0) { // 子进程到这里执行子进程的逻辑 } else { // 父进程到这里执行父进程的逻辑 }2.1.1 写时复制的设计动机为什么要用写时复制直接想一下就明白了fork()的本意是快速生成一个进程如果它要把整个父进程的地址空间完整复制一遍成本极高因为绝大多数场景下子进程紧接着就会调用exec去加载新的程序之前的复制全部白费。写时复制的基本思路是父子进程先共享同一份物理内存页并且把这些页标记为只读。只要双方都不写就一直共享一旦某一方要写某个页内核才真正复制一个副本给它并把原页恢复为可写。这个机制让fork()的速度极大提升也是Linux进程创建高效的关键之一。2.2 状态机运行、就绪、阻塞与僵尸理解了进程是调度单元后再看它的状态就顺理成章了。Linux进程的主要状态在ps命令里用一两个字母表达状态标识内核状态含义典型场景RTASK_RUNNING运行态或就绪态已在CPU队列中正在执行或等待被调度STASK_INTERRUPTIBLE可中断睡眠等待键盘输入、等待网络包DTASK_UNINTERRUPTIBLE不可中断睡眠等待磁盘I/O完成TTASK_STOPPED暂停收到 SIGSTOP 信号ZEXIT_ZOMBIE僵尸态进程已退出但未被父进程回收这几个状态串起来就是进程的一生新进程由 fork 创建后进入就绪队列等待调度被调度器选中后进入R态运行运行中如果等待某个资源进入睡眠状态资源就绪后重新变回R态程序跑完调用exit()系统调用退出。退出后它并不会立刻从系统里消失而是先变成一个僵尸直到父进程调用wait()收走它的退出码。2.3 僵尸进程一个经典的坑僵尸进程是每个Linux后端开发者都会遇到的家伙。前面说了进程退出后变成Z状态等待父进程回收。如果父进程写得很烂从不调用wait()僵尸进程就会一直留在进程表里。注意僵尸进程已经释放了绝大部分资源内存、文件描述符都关了但它还占着一个task_struct结构体也就是进程表里的一条记录。如果制造了大量僵尸进程而不清理最终会耗尽进程表条目导致系统无法创建新进程。这在长时间运行的服务里是个隐患。怎么清理僵尸有个看似玩笑但很实用的方法把僵尸进程的父进程杀掉。父进程死后僵尸进程会被init或现在的systemdPID1收养而 systemd 会自动对孤儿进程执行wait()从而把僵尸收走。当然更根本的解决办法是在父进程里正确使用wait()或waitpid()以及注册 SIGCHLD 信号的处理器。2.4 孤儿进程与守护进程与僵尸相对还有孤儿进程父进程先退出子进程还在运行。此时子进程并不会被直接杀死而是被过继给 PID 为 1 的进程systemd做“干儿子”由它来负责回收。这个机制保证了进程不会因为父进程的消失而“没人管”。很多服务器上的守护进程daemon就是利用了类似的机制启动后立即fork()然后让父进程退出子进程继续干活。这样服务进程就被 init 接管脱离了用户终端的控制即使终端关掉也不影响它运行。这也是nohup、setsid这些命令背后的逻辑基础。3. 进程树与父子关系系统结构的全局观前面提到了进程的父子关系其实这不仅仅是一个概念你完全可以在自己的系统里看到一棵清晰的进程树。理解了这棵树你就能明白系统从开机到现在的运行脉络。3.1 pstree一眼看穿系统结构在终端敲pstree你会看到一个从systemd发端的树形结构$ pstree -p systemd(1)──┬─NetworkManager(622)─┬─dhclient(720) ├─agetty(681) ├─crond(676) ├─sshd(695)───sshd(1234)───bash(1235)───pstree(1300) └─systemd-journald(497)这棵树的信息量非常大。首先所有进程的根是systemdPID 1它是操作系统启动后的第一个进程负责引导用户态环境和管理服务。其次每一层的父子关系其实对应的是“谁创建了谁”sshd 接收远程连接后会 fork 出一个新的 sshd 进程再 exec 成 bash 给用户用。bash 再 fork 出你敲下的每个命令。用pstree -p看到 PID会帮助你理解很多现象。比如当你 ps 看到一个进程的 PPID父进程PID异常时基本可以判断它是被哪个进程拉起来的这对排查恶意脚本、定位服务依赖非常有帮助。3.2 子进程继承了什么子进程从父进程那里继承的东西远比想象多环境变量、打开的文件描述符、当前工作目录、信号处理方式部分、umask、进程组ID和会话ID等。这意味着父进程对系统的影响会“遗传”给子进程。比如你用 root 身份启动了一个服务这个服务 fork 出来的子进程也全是 root 身份。这就是为什么不少 Web 服务的主进程以 root 启动、但 worker 进程会主动降权一旦子进程被利用攻击者拿到的权限也只是降权后的用户不至于直接攻陷整个系统。文件描述符的继承也值得注意。如果父进程监听了一个 socketfork 出来的子进程也持有同一个 socket 的副本。在某些多进程服务器模型如早期的 prefork里多个子进程共享同一个监听 socket由内核来分配连接这就是“惊群”问题的来源之一。理解了这个继承机制你才能明白为什么有些并发模型要加SO_REUSEPORT有些要主动关闭不需要的文件描述符。3.3 进程组与会话更深一层地组织单位进程树再往上还有进程组和会话这两个概念。进程组是一组相关联的进程集合通常由一个进程以及它后续 fork 出的所有子孙构成。会话则是一个或多个进程组的集合通常对应一次用户登录创建的终端环境。这两个概念的引入是为了解决“信号往哪里发”和“终端归属谁”的问题。你在终端按 CtrlC 时内核会向前台进程组的所有成员发送 SIGINT而不只是当前进程。这也是为什么多进程的 shell 脚本按 CtrlC 时全体子进程都能收到信号退出的原因。从设计的角度理解进程树、进程组、会话其实构成了三个不同颗粒度的“组织层”进程是最小单位进程组用于批量控制会话用于关联终端生命周期。这三层结构让操作系统能够精准地管理成百上千个进程而不是一盘散沙。4. 从进程角度看shell一条命令怎么变成进程很多人学进程时觉得抽象其实你用 shell 敲命令的过程就是最鲜活的进程教程。我们每天在终端里做的事情一直都在跟进程打交道。4.1 前台命令与后台命令的进程差异在 shell 里输入sleep 100shell 会 fork 出一个子进程然后 exec 成 sleep 程序。这个子进程在前台运行shell 进入等待状态直到 sleep 退出shell 才重新拿回控制权给你显示下一条提示符。如果你在命令后面加一个$ sleep 100 [1] 12345shell 会 fork 出子进程后立即返回不等待。这时候子进程在后台运行shell 继续接受你的下一条命令。打印出来的[1] 12345方括号里是任务编号后面的数字就是子进程PID。前台和后台的区别本质上是“谁来接收终端信号”和“shell是否等待”的区别。前台进程组的进程可以收到终端的键盘信号CtrlC 等后台进程组默认不行。你可以用jobs查看当前 shell 管理的后台任务用fg把它调回前台。4.2 exec进程的“换核不换壳”再细看 shell 执行命令这个动作你会发现一个关键点shell 并没有 fork 之后“跑两份代码”。fork 出来的子进程和 shell 一样都是同一个小程序比如 bash唯一不同的是返回值分流。为了执行sleep这条命令子进程必须调用execve把自己整个替换成sleep的代码。exec系列系统调用的特点是它不会创建一个新进程而是在当前进程的地址空间里加载一个新的程序文件替换掉当前的代码段、数据段、堆栈只保留PID和PCB。这就是“换核不换壳”。所以完整的流程是shell 调用fork()复制出一个几乎一样的子进程子进程调用execve()把自身替换成目标程序目标程序的main()开始执行这两个步骤合起来就是用最少的开销完成了一次新程序启动。很多初学者会有疑问为什么不直接设计一个“创建并执行新程序”的接口设计上当然可以但 fork/exec 的分离带来了极大的灵活性——fork 之后不一定马上 exec你可以在中间做很多事情重定向标准输入输出、修改环境变量、设置权限、建立管道连通性等准备好了再去 exec。4.3 管道背后的进程协作ls | grep txt这条命令底层就是两个进程通过管道协作。shell 先创建管道然后 fork 出两个子进程分别 exec 成ls和grep再把ls的标准输出重定向到管道写端把grep的标准输入重定向到管道读端。这里有一个隐藏知识点两个进程之间并没有共享内存它们只是通过内核提供的环形缓冲区交换数据。管道本身就是内核管理的一块缓冲区一端写入、一端读出。所以管道是“流式的”读走的数据就消失了无法回退。这就是为什么管道只能单向流动也是为什么ls | tee file | grep txt这种链条可以串联多个进程。理解了这一点你再看 shell 里许多“魔法”就会豁然开朗。比如cmd1 21 | cmd2是把标准错误重定向到标准输出再交给下一个进程cmd1 | cmd2在 bash 里是两者的简写。每一个重定向操作本质都是对文件描述符的调整而这些调整都发生在 exec 之前。5. 实操用进程概念解释线上排查场景理论讲了这么多如果不落地到实际场景很快就会忘。下面我用两个真实场景展示一下升级后的进程模型怎么帮你快速定位问题。5.1 场景一CPU 占用异常的排查链路线上某台服务器 CPU 持续 100%登录上去第一件事就是执行$ toptop输出了进程列表按 CPU 使用率排序会看到一个进程占用近 100%。但光知道 PID 等于什么都没做你需要进一步搞清楚# 查看这个进程的启动方式和完整命令 $ ps -fp PID # 查看它的进程树位置确认是否由某个服务拉起 $ pstree -p | grep PID # 查看它打开了哪些文件、网络连接 $ ls -l /proc/PID/fd $ cat /proc/PID/cmdline如果是业务本身的合理消耗那要优化代码或扩容如果进程身份陌生或者挂在某个没想到的父节点下那就要警惕是不是异常脚本甚至是入侵迹象。这个排查流程里每个操作都依赖进程概念区分进程身份要看 PID、PPID、进程树分析行为要看文件描述符和 cmdline。这些信息全部藏在 /proc 这个“运行时窗口”里。5.2 场景二杀不死的进程与僵尸清理有时候你kill -9 PID之后ps仍然能看到这个进程。这时就要分情况了。如果状态是Z说明它是僵尸真正执行代码的实体已经不在了只是等着被父进程收尸。你杀它也没用因为它根本没有在执行kill信号对它无效。正确姿势是找到父进程在父进程里调wait()回收或者直接重启父进程。如果状态是D正在等待不可中断的磁盘 I/O比如 NFS 挂载异常导致内核等待超时这时候kill -9也没用因为内核在等待期间不会处理信号。这种进程只能等 I/O 超时或者恢复存储没有更好的办法。遇到这种情况就要去检查存储系统健康状态了。如果状态是S理论上可以杀但kill -15发个 SIGTERM 可能就行不要一上来就用-9。SIGTERM 给了进程优雅处理的机会——清理临时文件、关闭连接、释放资源。kill -9是最后手段直接强制内核销毁进程连清理的机会都不给可能留下脏数据。5.3 /proc进程概念的实体化前面多次提到/proc/PID/这个目录是理解进程最好的教材。每个进程都有一个对应的目录里面按文件形式暴露了进程的所有状态文件/目录内容用途cmdline启动这个进程的完整命令行确认进程身份cwd符号链接指向进程当前工作目录判断进程在使用哪个目录fd/进程打开的所有文件描述符排查句柄泄漏status进程状态、内存、UID/GID等一键看全部关键信息environ进程的环境变量确认配置注入是否生效maps内存映射信息审查内存区域、排查注入很多运维脚本里的lsof、lsof -p本质也是去读这些目录的内容。理解了/proc的定位你就能在最短时间内掌握一个陌生进程的全貌这种能力在线上排查时非常关键。6. 打破几个常见误解学进程概念时总有一些流传很广的说法其实是错的或者不严谨的。我把自己踩过的、见过的几类误解整理一下希望你能绕开。6.1 “CPU多核就能同时跑多个进程”严格来说多核系统上确实可以同时有多个进程在跑但每个CPU核同一时刻还是只能执行一个进程不考虑超线程的SMT模拟。进程的并行度受限于物理核数。你现在打开系统监视器可能看到几百个线程但CPU核只有八个所以绝大多数进程是在“被唤醒—短暂运行—挂起”的循环中度过的。你观察top里的%Cpu(s)列就能感受到这种时分复用的节奏。6.2 “fork()是昂贵的”这是误解残留。前面提到写时复制之后fork()的开销远不像教科书几十年前描述的“复制整个地址空间”那么夸张。现在的fork只在复制进程控制块和页表等元数据物理内存页大多共享。所以现代服务里大量使用多进程模型比如Nginx worker进程、Gunicorn worker它们基于forkexec或者prefork性能并不差。代价真正大的地方在线程创建后的上下文切换需要切换地址空间而不是创建本身。6.3 “PID小的问题更严重”PID 小而被当成安全问题的说法其实要看上下文。PID 小只说明这个进程启动较早或者恰好系统分配的PID序号比较低。真正需要关心的是进程身份、权限位、资源占用和网络行为而不是 PID 数值。对于安全类问题要看进程的实际功能、监听端口和启动源头。我在排查中见过很多新手看到一个 HTTP 服务进程 PID 是 1234就吓得不行其实那就是个普通的业务服务。6.4 “kill -9 能杀死一切进程”前面场景二已经说过了kill -9并不是万能的。处于不可中断睡眠D的进程、以及内核线程内核线程根本不吃用户态的kill信号你都杀不掉。还有极少数的场景比如进程正在做不能被中断的文件系统操作kill -9信号也会排队等它返回后才会处理。理解这一点你才能避免在线上对着一个杀不掉的进程无限重发信号。7. 最后再说一点个人体会讲完这些我想起自己第一次完整读懂ps -ef输出时的感觉突然从“看到一串奇怪数字”变成了“看到一群有出生、有家庭、有状态的活体”。进程这个概念最妙的地方在于它把操作系统里最复杂的“并发管理”浓缩成了几个普通数据结构。如果想把进程彻底学透我建议你做两件小事。第一件在终端里反复看pstree和ps -ejH的输出对照这棵树去画自己理解的进程谱系。第二件用strace跟踪一个命令的clone、execve、exit系统调用你会亲眼看到进程从诞生到消亡的每一次系统调用痕迹。这两件事加起来比看十篇博客都管用。进程概念是Linux理解体系的枢纽——后面学线程会用到它线程就是轻量级进程学网络编程会用到它socket隔离与传递学容器和虚拟化也会用到它隔离的本质就是让进程看不见别的进程。等你真正理解了进程再回头看 Linux 的很多设计都会觉得顺理成章了。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →