ARTICLE DETAIL

资讯详情

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

Linux进程全解析:从冯诺伊曼结构到进程管理实战

Linux进程全解析:从冯诺伊曼结构到进程管理实战 学Linux尤其是刚准备跨过门槛的阶段最常碰到的两个“听着很熟、其实没搞透”的词一个是系统另一个就是进程。跑一条ps aux屏幕上一大片进程名你刚想弄清楚谁是谁却发现光“进程”一个词就能扯出操作系统、CPU调度、内存管理一大堆前置知识。事实也确实如此。进程不是一个凭空冒出来的抽象概念它是建立在冯诺伊曼结构这套硬件模型之上、由操作系统负责承载和调度的具体实体。这篇文章我就把这条链路从头捋一遍内容作为Linux进程系列的第一篇适合刚接触Linux、以及想系统补基础的人。1. 为什么讲进程前必须先聊冯诺伊曼结构1.1 冯诺伊曼结构现代电脑的通用骨架1945年冯诺伊曼在《First Draft of a Report on the EDVAC》中提出了一种计算机设计思想把指令和数据一起放在存储器里让计算机按地址自动取指并执行。今天你手里的笔记本、手机、云服务器几乎都沿用了这个框架。结构上分成五大部件部件作用生活化理解输入设备向计算机提供外部信息收银员收菜单输出设备把结果呈现出来厨师出餐窗口存储器存指令和数据后厨操作台运算器做算术和逻辑运算切菜、处理和调味控制器从内存取指令并指挥其他部件大厨照着菜谱下达指令这套结构最关键的思想是“存储程序”和“程序控制”。你写好的 C 代码经过编译变成一条条机器指令和它要用到的数据一起躺在内存里。CPU 里的控制器根据一个叫程序计数器PC的寄存器从内存中取出当前要执行的指令再用运算器去执行。整个过程周而复始。为什么这条老掉牙的硬件结构值得在讲进程前反复提因为“进程”这个概念背后最重要的假设就是“指令必须从内存取出数据也要放到内存CPU 只能一步一步地执行”。如果没有这个前提操作系统想在同一台机器上运行多个程序连切换的切入点都没有。换句话说冯诺伊曼结构定义了“一台机器同一时刻只能执行一条指令”的规矩操作系统和进程机制本质上都是在和这个“一条一条来”的规矩打交道。1.2 取指-译码-执行CPU 的工作循环CPU 干活的基本节奏是取指Fetch、译码Decode、执行Execute。具体来说控制器把 PC 寄存器指向的地址发给内存内存把这条指令送回来控制器识别这条指令到底要干什么然后指挥运算器或者其他部件完成操作。做完一件事PC 指向下一条指令继续循环。拿一段伪代码来说int a 1; // 内存地址 1000: 把常量 1 写入寄存器 R1 int b 2; // 内存地址 1001: 把常量 2 写入寄存器 R2 int c a b; // 内存地址 1002: R1 R2 - R3CPU 并不会“看着变量名”去理解你的代码它只知道从内存地址 1000 开始先取第一条、执行完毕、再将 PC 移动到 1001。这里有一个关键现象PC 一旦被修改成另一个地址CPU 的下一条指令就来自那个地址执行的“程序”就变了。这正是操作系统实现进程切换的硬件基础。一个进程在 CPU 上跑了一段时间内核可以把它的 PC、寄存器、内存页表等信息保存下来然后把另一个进程的 PC 和相关状态恢复到 CPU 中。从 CPU 的角度看它只是忠实地执行“当前 PC 指向的那段指令”至于这段指令属于哪个进程CPU 自己并不关心。由此引出一个结论进程切换不是 CPU 自动完成的而是操作系统主动介入、利用这套取指循环实现的“打断-保存-恢复”。提示很多人第一次听到“进程上下文”这个词总以为是很高深的东西。其实它就是 CPU 寄存器集合、程序计数器、内存映射等一箩筐“现场信息”的总称。进程被切走时这些信息要保存被切回来时要原样装回去。2. 操作系统进程栖身的“大管家”2.1 从裸机程序到操作系统假设没有操作系统只有 CPU 和内存一个程序要怎么跑要么你用烧录器把程序固化到 ROM要么把程序加载到固定内存地址然后跳转过去执行。这种裸机方式有个致命问题程序一旦跑起来就独占整台机器它可以直接读写任何内存、操作任何硬件。一个程序把内存写坏了系统立刻崩掉想同时跑两个程序你还要手动规划内存分块一个程序越界就把另一个程序踩死。于是操作系统诞生地位相当于一栋公寓楼的物业。它的第一件事就是“制定规则、分配资源”让每个程序都住进独立的房间谁也别想直接闯进别人的房子。进程就是操作系统为“住在公寓里的租户”起的一个正式名字。CS 领域有个经典类比程序是菜谱进程是照着菜谱做菜的过程。菜谱放在磁盘上不会占锅占灶一旦把它加载进内存、开始执行它就成了一个会占用 CPU、内存、文件资源的动态实体。操作系统这层“物业”不是可有可无的锦上添花而是资源稀缺逼出来的必然产物。CPU 只有一个早期确实如此内存有限磁盘读写速度又慢多个程序抢同一批资源如果没有一个权威角色来仲裁谁也别想舒服地跑。操作系统做的事情本质上就是三件事分配什么资源、什么时间给谁用、用完后怎么回收。进程管理只是其中一环但这一环最直观。2.2 操作系统的四大核心功能操作系统要管的事很多但核心可以归成四大块进程与线程管理负责创建、销毁、调度进程决定谁先拿到 CPU。内存管理给每个进程划分虚拟地址空间用页表映射到物理内存并实现隔离和保护。文件系统管理把磁盘上的数据抽象成文件与目录屏蔽底层扇区、柱面等硬件细节。设备管理通过驱动程序统一管理鼠标、键盘、网卡、显卡等外设。很多初学者喜欢把知识点一个一个列出来背但不自觉把它们割裂开。实际上四大功能是咬合在一起的进程需要内存来存放代码和数据需要文件系统和设备来读写数据CPU 调度又直接影响进程的执行效率。所以真正理解进程必须把它放到操作系统整盘棋里看。Linux 更特殊它有一个经典哲学“一切皆文件”连进程的很多运行指标都放在 /proc 目录下通过读文件就能查看后面实操部分会踩到这个点。就好比你要理解一个住客的生活不能只看他待在哪个房间还要看公寓的水电怎么供、垃圾怎么倒、电梯怎么调度。公寓里的每一个规定最后都会体现在住客的一天里。进程也是一样操作系统对内存、文件、设备的每一项管理策略最后都会反映到进程的运行表现上。这也是为什么我强调不要脱离操作系统去理解进程。2.3 用户态与内核态的“收费关卡”操作系统不能让每个进程都随心所欲地碰硬件。如果任何一个程序都能直接改写磁盘、占用全部内存整个系统的安全与稳定无从谈起。为此 CPU 设计了特权级别用户态与内核态。普通进程运行在用户态只能访问自己地址空间内的数据一旦它想创建进程、打开文件、分配内存或读写网络就必须通过系统调用syscall陷入内核态让内核替它去操作资源。用生活语言解释用户态是“乘客”你可以自由聊天但不能擅自打开驾驶舱门内核态是“驾驶员”掌握方向盘和仪表盘。系统调用就是乘客按铃通知驾驶员的通道。在 Linux 里最常见的系统调用有 fork()、execve()、open()、read()、write() 等。你写 C 代码时调用 printf()底层最终也会走到 write() 系统调用由内核把数据写到标准输出文件描述符上。理解用户态和内核态对排查问题很有帮助。比如一个进程动不动就把 CPU 打满你用 perf 看热点如果热点集中在内核态的 syscall 上多半是读写文件或网络太频繁如果集中在用户态的函数上那就是业务计算逻辑太重。这种区分正是从“冯诺伊曼结构”迈向实操时必须要补的一课。这个“收费关卡”还有一个很有意思的副作用就是系统调用有开销。每次用户态切到内核态都要保存寄存器、切换栈、执行内核代码再切回来这本身要耗费几百个时钟周期。频繁的小数据读写比如每次只 read 几个字节反而比一次大批量读更慢。所以很多高性能服务会自己做缓冲目的就是减少系统调用的次数。3. 从程序到进程Linux 进程机制初探3.1 PCB进程的“身份证”在 Linux 内核中每个进程都有一个名为 task_struct 的结构体这就是我们常说的进程控制块PCB。它是内核感知“进程存在”的唯一凭据。把这个结构体理解为进程“身份证”非常合适。身份证里有姓名、身份证号、户籍地址而 PCB 里记录了一个进程的 PID、PPID、当前状态、优先级、打开的文件描述符表、内存地址空间、信号处理函数表、CPU 现场的寄存器快照等等。进程创建时内核分配一个 task_struct进程退出时内核回收它。如果 task_struct 迟迟不被回收就会出现后续要聊的“僵尸进程”问题。关于 PCB还要注意一点task_struct 本身放在内核内存区用户态的进程是不能直接访问的。这既是安全边界也意味着进程的一切运行痕迹只有内核能统一管起来。从操作系统的角度看“进程”其实看的不是“代码”而是这个 PCB。CPU 调度器编排队列时排的也是 task_struct进程间通信要传递数据也要先通过 task_struct 找到这个进程的内存信息。可以说学进程管理的第一步就是理解“内核里躺着这么一张不断变化的表”。这张表不是固定大小的。进程开文件越多文件描述符表就越长映射的内存区域越多内存管理相关的数据结构就越复杂。你如果看过 task_struct 的完整定义会发现里面有几十个字段。但没必要全背抓住几个关键就够用进程标识符PID/PPID、进程状态、调度信息、内存描述符、文件描述符表、信号处理、以及用于保存上下文切换现场的那组寄存器副本。3.2 Linux 进程的五大状态Linux 的进程状态在 ps 命令里用 STAT 列显示。常见的字母组合不少但核心状态可以归纳如下状态标识内核宏含义典型场景RTASK_RUNNING运行态或就绪态正在 CPU 上跑或在可运行队列里等待STASK_INTERRUPTIBLE可中断睡眠等待输入能被信号唤醒DTASK_UNINTERRUPTIBLE不可中断睡眠等待磁盘等 I/O不可被信号打断TTASK_STOPPED停止被 SIGSTOP 或调试器暂停ZTASK_ZOMBIE僵尸进程已退出但父进程没回收 PCB每次我看到有人把 R 理解成“只可能正在 CPU 上跑”都会提醒一句R 既包括正在执行的进程也包括已经准备好、只等调度器给 CPU 的进程。这两者界限很重要。而 D 状态是个“硬骨头”它通常出现在等待磁盘 I/O 时。这种进程连 kill -9 都不太容易干掉因为内核认为它正在做一件不能被打断的操作。你如果看到一堆 D 状态先别急着杀进程往往要查存储设备是不是有故障或过载。还有一个容易被忽略的细节是 S 状态和 D 状态的区别。S 状态可以被信号唤醒所以 kill 能正常生效D 状态则不然它挂在不可中断的内核态 I/O 等待上信号要等它回到用户态才有机会处理。理解这个区别就能解释“为什么 kill -9 有时候杀不死一个进程”这种经典面试题。3.3 fork/exec/wait进程从诞生到落幕Linux 创建一个新进程经典方式是组合使用 fork() 和 exec()。fork() 会把当前进程完整复制一份出来准确说是复制 PCB、页表等核心数据结构新进程成为当前进程的子进程子进程从 fork() 返回后继续执行。exec() 系列则负责把当前进程的内存映像替换成另一个可执行文件PID 不变但代码段、数据段被清空重载。这两兄弟必须合体才能解释你平时在终端敲命令的过程。shell 收到一条命令第一步就是 fork 一个子进程给子进程的“身体”换上命令对应的可执行文件再让子进程去跑。父进程则调用 wait() 在那里等。wait() 不仅让父进程等到子进程结束还有一个重要功能回收子进程退出后的 task_struct免得它变成僵尸。用一段简单的 C 代码演示#include stdio.h #include unistd.h #include sys/wait.h int main() { pid_t pid fork(); if (pid 0) { perror(fork); return 1; } if (pid 0) { // 子进程 printf(子进程PID%d\n, getpid()); return 0; } else { // 父进程等待子进程退出 wait(NULL); printf(父进程子进程已结束继续执行\n); } return 0; }编译并运行gcc demo_fork.c -o demo_fork ./demo_fork我第一次写这段代码时困惑很久为什么 fork() 之后if 和 else 两个分支好像都会执行后来才明白fork() 返回两次。父进程收到的是子进程的 PID子进程收到的是 0。程序就靠这个返回值区分自己当前的身份。写 fork 代码有一个原则一定要先判断返回值的三种情况小于 0 表示失败、等于 0 是子进程、大于 0 是父进程不要想当然。此外exec 家族里有 execl、execlp、execv、execvp 等多个变体本质都是调用 execve() 系统调用区别只是参数怎么传。写脚本或工具时大概率用不到直接调用 exec但理解这个机制你就知道为什么 shell 在执行外部命令时要经历“先 fork 一个子进程再 exec 替换”的流程而不是直接在当前进程里执行。直接 exec 的话shell 自己就被替换掉了用户还怎么继续输入下一条命令4. 进程管理的常用命令与实战场景4.1 用 ps 和 top 看懂系统里都在跑什么查看进程最常用的命令是 ps。组合参数不同输出格式也不同。我平时最常用的是 ps -ef 和 ps aux。ps -ef 偏标准格式ps aux 多了 CPU、内存占用百分比更适合排查性能问题。两者字段大同小异但 STAT 列里会有细微差别不要对不上。一条典型输出UID PID PPID C STIME TTY TIME CMD root 1 0 0 10:02 ? 00:00:03 /sbin/init splash zhang 1302 1298 0 10:02 tty2 00:00:00 bash zhang 1568 1302 0 10:25 pts/0 00:00:00 ps -efUID进程归属用户PID进程号PPID父进程号CCPU 使用率百分比STIME启动时间TTY关联的终端? 表示无终端如守护进程TIME累计占用 CPU 时间CMD命令本身top 适合看动态变化。进入 top 后按 M 按内存排序按 P 按 CPU 排序。htop 是 top 的加强版支持鼠标点选、树形视图视觉上友好很多。没有装的话Debian/Ubuntu 系就sudo apt install htopRedHat 系就sudo yum install htop。好多新人对 ps -ef 和 ps aux 的细微差别容易懵。其实两个都能用只是字段风格不同。我个人的习惯是排查 CPU、内存问题时用 aux因为它直接把 %CPU 和 %MEM 显示在列表里一眼就能看到谁在吃资源看完整命令行或者进程父子关系时用 -ef尤其配合--forest参数看进程树特别直观。4.2 kill、nohup、jobs控制进程的运行进程控制命令里出镜率最高的是 kill。kill 的准确含义是“给进程发信号”它的默认信号是 SIGTERM15让进程优雅退出。如果进程不听话可以 kill -9 发 SIGKILL内核直接强制终止。从设计上说SIGTERM 是“请你自己收尾”SIGKILL 是“我替你收尸”。还有一个分开使用的配对kill -STOP 和 kill -CONT。前者让进程暂停状态变 T后者让暂停的进程继续跑。写调试脚本或手动控制任务时特别有用。比如在终端里sleep 1000 # 后台启动 sleep输出任务号 jobs -l # 查看后台任务 kill -STOP %1 # 暂停 1 号任务 kill -CONT %1 # 恢复 1 号任务 kill -TERM %1 # 正常退出后台运行还有一对注意事项用 放到后台进程还是会受终端信号影响终端关闭时后台进程可能收到 SIGHUP 被干掉。想让它脱离终端继续跑用 nohup 命令nohup ./myserver server.log 21 。这里的重定向是把标准输出和错误输出都写进日志别漏掉 21否则你把 stdout 导走了stderr 还会在终端里乱蹦。注意不要一上来就 kill -9。很多程序收到 SIGTERM 会清理临时文件、释放锁、优雅关闭连接直接 SIGKILL 可能留下脏状态。尤其是数据库、消息队列这类对一致性敏感的服务SIGKILL 是万不得已的手段。4.3 用 /proc 观察进程细节Linux 把进程运行时的大部分“内部”信息放在 /proc 下。比如 /proc/PID/status 里有进程状态、内存、PPID/proc/PID/fd 里能看到进程打开的文件描述符/proc/PID/cmdline 是启动参数。看进程到底在干嘛有时候比 top 还直接。举个例子cat /proc/1/status | head -20你会发现 systemd 几乎每个进程都开了大量 fd如果用 lsof 连不上的时候cat /proc/PID/fd 也能凑合看。这套目录不是真正意义上的磁盘文件是内核虚拟出来的“进程信息窗口”。这正应了 Linux 那句“一切皆文件”。我曾经排查过一个诡异问题某个服务启动后一直“假死”日志里没有任何报错。后来我反复查看 /proc/PID/status发现进程总是停在 S 状态等待输入再配合 /proc/PID/fd 看到它居然在等一个终端的输入。原来启动脚本没有把 stdin 重定向到别处它一直在等一个谁也看不到的终端。这种问题单看业务日志根本发现不了必须借助进程层面的观察。5. 进程间通信与三大特殊进程5.1 为什么进程之间需要通信进程之间默认是隔离的。每个进程有自己的地址空间A 进程的变量在 B 进程里根本不存在。但现实业务经常需要多个进程协作一个进程负责采集数据一个进程负责运算一个进程负责写结果。这时候就需要进程间通信IPCInter-Process Communication。常见 IPC 方式对比方式特点典型场景管道 pipe单向、字节流写完就没了shell 命令串联如 ps aux命名管道 FIFO有路径名可跨进程打开两个独立进程间传输数据信号 signal通知事件不带数据kill 命令就是发信号共享内存多个进程映射同一块物理内存速度最快大数据量交换消息队列按消息类型读写简单的请求/应答Socket可用于本机和网络分布式系统、HTTP 服务使用频率最高的反而是看起来最不起眼的管道。你在命令行里敲 history | grep vim那个竖线就是匿名管道。内核创建一块缓冲区左边进程往管道写右边进程从管道读数据顺序和内容由内核搬运进程自己不用操心锁的问题。共享内存虽然快但要自己处理互斥稍不留神就出并发 bug。管道之所以口口相传“最常用”是因为它把两个命令的协作变成了一种极其自然的语法。ps aux | grep nginx可以说是每一个 Linux 用户最早接触的管道范例。理解了匿名管道的原理你再看云原生里大量使用的 sidecar 模式、日志采集代理之类的设计会发现它们本质上也都是进程间协作的思路。5.2 僵尸进程、孤儿进程与守护进程这三个名词几乎每次面试都会出现我用“家长和孩子”来记忆僵尸进程Zombie子进程退出后父进程既没调用 wait()也没有处理 SIGCHLD 信号子进程的 task_struct 残留在内核里。状态是 Z。它已经不占用 CPU、内存也释放了但 PID 和内核栈没回收。PID 是有限资源僵尸多了会导致无法创建新进程。孤儿进程Orphan父进程先挂了子进程被系统收养。Linux 下 init/systemdPID 为 1会成为孤儿的养父负责在子进程离开时调用 wait() 回收资源。所以孤儿的结局通常是安全的。守护进程Daemon常驻后台、脱离终端控制的服务进程。典型的如 nginx、sshd。它们一般没有 TTY把工作目录切到 /、把标准输入输出都重定向到日志文件专门处理外部请求。看到 Z 状态不要慌。先ps -o ppid -p 僵尸PID找到它的父进程再判断父进程该不该保。如果是个长期运行的服务可以考虑重启它让父进程重新接管如果父进程本身也没有意义就处理父进程僵尸也会被系统清理。这里还想多说一句僵尸进程杀是杀不掉的因为严格意义上它已经不是“活着的进程”了。你唯一能做的是让它对应的父进程把它“收尸”。如果你写代码时创建子进程一定要在父进程里处理 SIGCHLD 信号或者调用 wait()这是防止僵尸进程的根本办法。5.3 典型排查如何顺着进程树定位问题我调试过一台 Linux 机器负载很高但 top 里找不到明显高 CPU 的进程。后来看进程树才发现有个进程 D 状态卡在磁盘 I/O 上一直在等。这时直接 kill 没效果因为 D 状态进程的终止也要等它从系统调用返回。真正的解法是检查磁盘是不是有坏道、RAID 阵列是不是在重建、NFS 挂载是不是超时把底层 I/O 恢复后进程才自己恢复。这提醒我排查进程问题不要只盯着“杀掉”本身。先用ps -ejH看清晰的进程树再加上 lsof、iostat、vmstat 一起看很多悬案会变成“原来问题在别处”。定向给读者一个小工具清单pstree 看继承关系、pgrep 按名字找 PID、pidof 找命令对应 PID。这三样配合日常排查就够用了。6. 面试常见问题与排查经验6.1 进程、线程和“程序”的区别在哪这是 Linux 进程系列最常见的口头追问。程序是静态的、躺在磁盘上的可执行文件进程是动态的、运行中的程序实例。线程有时被称为轻量级进程同一个进程内的多个线程共享地址空间和文件描述符但各自拥有独立的栈、寄存器上下文。如果把进程比作一家公司线程就是公司里的员工。公司有办公室、账本等公共资源员工们共享但每个员工有自己的工位和手头活忙完自己那份还要互相配合。Linux 里线程本质也是用 clone() 创建的和进程底层共享同一套调度机制。6.2 如何找到一个进程里卡死的子线程排查 Java 进程或 Python 进程时光看 ps 不一定够。线程信息在 /proc/PID/task 下。用top -H -p PID可以按线程维度看 CPU 占用再用 jstackJava或 py-spyPython把线程栈打出来就能定位到具体是哪个业务逻辑在死循环还是等待锁。遇到属实卡死的线程可以记下线程 ID再用 gdb attach 查看它的调用栈不过生产环境别轻易 attach最好先停服务。这里有个忠告不要害怕动进程但要动之前先留证据。比如做 pstack 采集调用栈、ss -tlnp看端口、cat /proc/PID/io 看 I/O 统计。否则你一通杀进程事后想复盘数据全没了。6.3 端口被占用、U 盘无法弹出这类日常问题端口被占用一句命令定位lsof -i:8080 # 或 ss -tlnp | grep 8080查到 PID 之后再决定结束谁。U 盘无法弹出通常是还有进程在上面读写文件。用lsof /media/usb列出占用者kill 掉对应进程再重新弹出就正常了。这些都离不开对“进程和文件描述符”的理解。记住文件被打开后内核里会有一张文件表进程持有指向它的 fd。谁持有 fd谁就拽着文件不放。6.4 学习路径建议如果看完这篇你打算继续深入 Linux 进程我给一条参考路径先把 task_struct 读一遍不要怕重要的内容其实就几块再看 kernel/sched/core.c 里调度器的主流程然后动手写几个 fork 小程序、模拟一个简陋的 shell。最后回去读《Linux 内核设计与实现》中进程管理和调度两章。实践和原理交替进行效果比单纯刷题好得多。我自己学进程这部分时起初总觉得一上来讲冯诺伊曼结构是在绕远路。直到后来我在解释一个诡异的“CPU 占用高但看不到进程在忙”的问题时发现同事顺着“CPU 取指-执行”的模型一步步推断出是系统调用频繁切换、上下文切换开销过大这才意识到一切底层机制都绕不开最开始那台“只能一步一步执行指令”的机器。这套底层模型是整个系列的地基。下一篇我会接着写进程调度与上下文切换的具体实现。调度器怎么决定谁先上 CPU、怎么计算优先级和 vruntime、什么时候会发生抢占都会摊开讲。如果有兴趣可以先自己执行 top 看一眼 TIME 和 STAT 列的变化对照这篇文章建立感觉后面再见面时就不会一头雾水了。
返回列表