ARTICLE DETAIL

资讯详情

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

Linux进程管理实战:从task_struct到生产环境排查

Linux进程管理实战:从task_struct到生产环境排查 说实话day1 的笔记发出去之后有不少人私信问我同一个问题学了一堆进程概念回到工位上该怎么用这问题问得好。因为“进程”这个东西光知道“它是运行中的程序”根本不够面试不会只考这一句话服务器卡死的时候也不会因为这句话少报一个错。所以 day2 我想换个思路——不再从“进程是什么”讲起而是把进程当成一个“活的人”来看它有户口本、有生命周期、有朋友圈、有沟通方式还要被调度中心安排着排队干活。这篇笔记我会尽量少讲空理论多讲“内核里到底存了啥”“你在终端敲哪条命令能看到它”“出了故障怎么顺着线索查下来”。函数、命令、代码我都会贴你可以在自己的 Linux 机器上跟着敲一遍。适合已经在看进程基础、但总觉得“懂概念不会用”的朋友也适合准备系统方向面试、想理清进程和线程 / IPC / 调度之间关系的开发者。1. 进程的“户口本”task_struct 与内核眼中的进程1.1 进程控制块不是一张表而是一个结构体操作系统课上讲“PCB进程控制块”很多人以为内核里维护了一张大表一行一个进程。真实 Linux 里不是这样的。每个进程在内核中都对应一个struct task_struct结构体它才是进程的“户口本”。这个结构体非常大几百个字段主要记录几类信息身份信息PID、PPID、TGID线程组 ID、用户和组信息、命名空间等状态信息当前进程状态运行、睡眠、停止、僵尸以及状态切换需要的数据调度信息调度策略、优先级、vruntime虚拟运行时间、CPU 亲和性掩码内存信息指向mm_struct的指针描述进程地址空间文件信息fd文件描述符表指向打开的文件、socket、管道信号信息信号掩码、挂起信号队列、信号处理函数指针亲子关系parent、children、sibling指针把进程串成树。类比一下户口本上写着你是谁、家住哪、家里几口人、名下有没有房产。task_struct 差不多就是这个意思只是“房产”换成了内存地址空间“几口人”换成了子进程和线程关系。所有 task_struct 通过链表串起来内核遍历进程时就是沿着这个链表走。你敲ps aux时内核并不是“去某个目录数了一遍”而是遍历进程链表把每个 task_struct 里的字段格式化输出。1.2 用 /proc 直接读进程的户口本/proc是虚拟文件系统内核把进程的运行时信息暴露在这里。每个正在跑的进程都有一个/proc/pid/目录。随便挑一个进程看看# 查看当前 shell 自己的进程信息 cat /proc/self/status你会看到类似这样的内容Name: bash Umask: 0022 State: S (sleeping) Tgid: 12345 Ngid: 0 Pid: 12345 PPid: 1234 VmPeak: 123456 kB VmSize: 345678 kB Threads: 1这里面每行都对应 task_struct 的某个字段。State: S (sleeping)就是当前状态Tgid是线程组 IDThreads是这个进程里有多少个线程。更实用的是这几个文件/proc/pid/maps地址空间的内存映射能看到堆、栈、动态库分别映射到哪个地址范围/proc/pid/fd/这个进程打开了哪些文件描述符每个都是一个软链接/proc/pid/exe指向可执行文件的真实路径排查进程可疑身份时最有用。有一次线上排查我怀疑某个进程是挖矿程序直接ls -l /proc/3112/exe发现它指向/tmp/.x基本就实锤了。这个以后排查“进程伪装”也常用咱们在第 6 节细说。1.3 内存布局进程的“家产清单”进程的地址空间是虚拟的但结构上分得很清楚。下图是 64 位 Linux 下一个进程的典型布局从低地址到高地址区域内容特点代码段text编译后的机器指令只读可共享数据段data已初始化的全局变量、静态变量可读写BSS 段未初始化的全局变量、静态变量不占用磁盘运行时清零堆heapmalloc/new动态分配的内存向高地址增长内存映射区mmap动态库、mmap文件映射、共享内存用mmap时出现栈stack局部变量、函数调用帧向低地址增长cat /proc/self/maps可以直接看到当前 shell 的内存映射cat /proc/self/maps | head -20输出里每一行就是地址范围、权限、偏移、设备、inode、路径。你会发现动态库被映射进来栈的范围也在里面。这就是“进程的虚拟地址空间长什么样”的真实答案。为什么进程之间互不干扰因为每个进程都有自己的页表虚拟地址虽然相同但映射到的物理内存不同。这也引出了第 4 节要讲的问题——进程之间想共享数据必须要走 IPC。2. 从 fork 到 wait进程的完整生命周期2.1 fork 不是“复制”而是“写时复制”Linux 创建进程用fork()。很多人以为 fork 是把父进程的内存整个复制一份给子进程早期 sysV 时代的 Unix 确实这么干过但现在早不是了。现代 Linux 用的是 COWCopy-On-Write写时复制。COW 的意思fork 之后父子进程共享同一份物理内存页并且这些页被标记为只读。谁先写入谁才触发缺页异常内核再拷贝一份给它。换句话说fork 本身几乎不拷贝内存数据只拷贝页表和 task_struct。我把这个类比成“租同一套房”父子俩共用一套家具平时不搬不换没开销谁动家具了谁自己买一套新的搬走。用 Python 验证一下import os pid os.fork() if pid 0: print(f子进程 runningPID{os.getpid()}父进程 PID{os.getppid()}) os._exit(0) else: print(f父进程 runningPID{os.getpid()}刚创建的子进程 PID{pid})注意os.fork()在 Windows 上不可用请在你的 Linux 或 macOS 机器上跑。fork 之后有两个进程同时在执行这段代码区别只在pid这个返回值上。父进程拿到子进程的 PID子进程拿到 0这个返回值是它们“分道扬镳”的依据。2.2 状态转换从 R 到 S再到 Z进程不是永远在跑。用ps看进程状态时常见的字母含义要记牢状态码含义说明Rrunning / runnable正在 CPU 上跑或者在就绪队列等着被调Ssleeping可中断睡眠常见于等待 IODuninterruptible sleep不可中断睡眠多半在等磁盘 IOTstopped被停止通常是被SIGSTOP挂起Zzombie僵尸进程子进程已退出但父进程还没回收Iidle内核线程专用空闲状态状态切换的核心逻辑进程在 CPU 上跑着时间片用完就回到就绪队列变回 R调用了阻塞式 IO读文件、等网络就进入 S如果收到停止信号变成 T如果调用了exit()先变成 Z等父进程wait()之后才算真正消失。2.3 僵尸进程孩子死了爹不埋僵尸进程是最容易被忽略的坑。子进程退出时内核不会马上清掉它的 task_struct而是保留着等父进程调用wait()/waitpid()来收尸。如果父进程一直不调用子进程就僵在 Z 状态。造一个僵尸很容易import os import time pid os.fork() if pid 0: # 子进程干完活直接结束 print(子进程退出了) os._exit(0) else: # 父进程睡 20 秒不调用 wait time.sleep(20)跑起来之后另开一个终端找到子进程 PIDps -o pid,ppid,stat,cmd -p 子进程PIDSTAT那一列会显示Z。这就是僵尸。僵尸的危害它虽然没有占用太多内存但 PID 号、task_struct 和一些资源没被释放。如果父进程一直不回收僵尸会越积越多直到系统 PID 耗尽新进程 fork 不出来。生产环境遇到“无法创建新进程”第一反应就去数一数僵尸进程。解决办法写进代码里父进程调用waitpid最好是非阻塞地轮询import os import time pid os.fork() if pid 0: os._exit(3) else: for _ in range(10): done, status os.waitpid(-1, os.WNOHANG) if done ! 0: print(f回收了子进程 {done}退出码 {os.waitstatus_to_exitcode(status)}) break # 没退出就先忙别的避免阻塞等待 time.sleep(1) else: done, status os.waitpid(-1, 0) print(f最终回收了 {done}退出码 {os.waitstatus_to_exitcode(status)})WNOHANG是“不挂起等待子进程没结束就返回 0”。这样父进程可以一边干活一边收尸不阻塞。还有一个经典技巧叫“两次 fork”父进程 fork 出一个子进程子进程再 fork 出孙子进程后立刻退出。这样孙子进程被孤儿化过继给 init/systemdPID 1由系统帮忙wait回收。这种写法在一些守护进程框架里很常见。2.4 孤儿进程被 PID 1 收养反过来如果父进程先退出子进程就成了孤儿进程。内核不会让它没人管而是把它“过继”给 PID 1systemd 或 init由 PID 1 负责在它退出后回收。这解释了为啥很多服务进程的 PPID 是 1它们的父进程早就挂了。你不能直接通过 PPID 推断它的“真实爸爸”只能推测它曾经被某个进程创建过后来父进程退出了。3. 进程与线程一口锅里的不同分工3.1 为什么要有线程进程的隔离是优点也是缺点切换开销大、通信麻烦、创建销毁重。如果你只是想要“同一份数据多个执行流并行处理”用多个进程会很不舒服。线程就是在进程内部再分出的执行流。同一个进程的所有线程共享虚拟地址空间、文件描述符表、信号处理函数、工作目录。但每个线程有自己独立的栈、寄存器上下文、线程局部存储TLS、调度优先级。简单说进程是资源分配的基本单位线程是 CPU 调度的基本单位。回到内核视角Linux 管线程叫 LWPLightweight Process。每个线程也有自己的 task_struct但通过 TGID线程组 ID把同一进程内的线程串起来。你ps看到的“进程”其实是主线程ps -T才能看到全部线程。验证一下# 看某个进程的线程 ps -T -p PID # 或者用 top 的 -H 参数 top -Hp PID你找一个 Java 进程试一次会看到主线程和 GC 线程、业务线程一起列出来。Threads字段在/proc/pid/status里也有。3.2 多线程 vs 多进程怎么选这是面试和工程里都会被问烂的问题。做个对照维度多进程多线程数据共享难需要 IPC管道/共享内存等容易直接读写同一块内存隔离性好一个崩了不影响其他进程差一个线程*(int*)0崩溃整个进程都挂切换成本高需要切换地址空间低同进程内切换很快稳定性高适合长时间运行的服务低需要小心处理崩溃编程复杂度低fork简单但通信麻烦高锁、条件变量、死锁典型场景Chrome / nginx / 微信多开Web 服务器连接处理、计算密集型任务为什么微信在 Windows 上动不动就是一堆进程因为多进程能提供崩溃隔离——一个进程挂了不会拖垮全部还能在任务管理器里单独干掉卡死的模块。Electron 应用同理主进程负责窗口和菜单渲染进程负责页面渲染两者崩了互不影响之间靠 IPC 通信。现在看热搜词里的“微信运行好多进程”“Electron 主渲染进程 IPC 通信”本质就是对进程模型的一次实例化不是玄学。3.3 协程不归内核管的“超轻量线程”聊到线程就绕不开协程。协程是用户态的它不占内核 task_struct由应用自己实现调度。Python 的asyncio、Go 的 goroutine、Rust 的 async内核根本不知道它们存在。协程的好处是极低的切换成本只切换上下文寄存器不陷入内核态。所以单线程里可以跑几千、几万个协程。缺点是如果一个协程里写了耗时同步操作会卡住整个事件循环。Go 会在特定时机自动让出 CPUPython 需要你await让出机制不同但都得小心。4. 进程间通信隔离世界里的“快递系统”4.1 为什么需要 IPC前面讲了进程拥有独立地址空间这是安全边界。但现实需求是A 进程算完的数据要传给 B或者多个进程要协作完成同一件事。于是有了 IPCInter-Process Communication。我把 IPC 比作快递系统进程们住在不同的隔离房里虚拟地址空间不能互相伸手拿东西只能通过快递IPC传。4.2 管道最朴素的一条线管道是 Unix 最早的 IPC实现简单内核里维护一个环形缓冲区一头写、一头读单向字节流。最常见的就是 shell 里的竖线ps aux | grep nginxps aux的输出接到grep nginx的输入中间就是管道。管道分两种匿名管道只能用于父子进程或兄弟进程之间因为子进程会继承父进程的文件描述符命名管道FIFO有路径名任意进程都能打开mkfifo就能创建。管道的优点简单、安全适合小数据量天然是流式处理。缺点半双工没法随机访问数据量大了容易阻塞写端。所以命令行工具之间的协作大多用管道但复杂系统里它不太够用。4.3 共享内存最快的快递但要自己管锁共享内存把所有 IPC 方式里性能极限拉满两块虚拟地址映射到同一块物理内存进程直接读写省掉任何拷贝。代价是什么同步问题。两个进程同时写同一个变量就会有竞争。这时候必须配合信号量或者锁来用。Linux 下用shmget/shmat那套 SysV 接口Python 里更简单的做法是用multiprocessing它底层就是 fork 共享内存 信号量的封装from multiprocessing import Process, Value counter Value(i, 0) def worker(): for _ in range(1000): with counter.get_lock(): counter.value 1 processes [Process(targetworker) for _ in range(4)] for p in processes: p.start() for p in processes: p.join() print(counter.value)你多次跑这段代码如果去掉锁counter.value很可能小于 4000加锁之后就稳定等于 4000。这就是共享内存的同步坑。共享内存适合大数据量高性能场景比如消息队列的底层存储、视频帧处理、数据库缓冲池但不适合刚入门就裸用因为锁一旦没控制好线上排查能让你怀疑人生。4.4 信号最直接的“指令快递”信号是个特立独行的小弟。它传的不是数据是“事件通知”。kill -9本质不是“杀”而是向目标进程发送SIGKILL信号让内核强制终止它。信号是异步的你发完信号进程可能正在满世界跑业务逻辑但它总会在某个时机去处理信号。有的信号默认动作是终止进程SIGKILL、SIGTERM有的是忽略SIGCHLD、SIGURG有的可以自定义处理函数SIGINT、SIGUSR1。SIGCHLD对父进程很重要子进程退出时内核会向父进程发SIGCHLD如果父进程注册了 handler就能在 handler 里调用waitpid收尸避免僵尸。信号带不了大数据只能传递“发生了什么”这种消息。它适合做控制类操作让进程重载配置、优雅退出、检查状态而不是传业务数据。4.5 Socket跨机器的通路也是 Electron IPC 的底层如果两个进程不在同一台机器上管道、共享内存都不适用这时候就要走 Socket。同一台机器内的进程也可以走 Socket走的是localhost回环性能不算差胜在统一——本地跨进程、远程跨机器用同一套 API。Electron 的主进程和渲染进程就是典型的两个进程它们之间不能直接读写内存通信本质就是 IPC。Electron 封装了一套ipcMain和ipcRenderer底层走的是 Chromium 的 Mojo 机制但抽象语义就是“主进程和渲染进程之间收发消息”。学完进程间通信之后再看它会发现那层神秘感少了很多。4.6 怎么选型一张表讲透IPC 方式数据形态是否跨机器速度适用场景管道字节流否中命令行协作、父子进程传消息共享内存原始内存否极快大数据量、低延迟交换消息队列结构化消息否中应用间解耦、短消息传递信号事件信号否快控制进程行为、通知事件Socket字节流/消息是中分布式系统、跨主机通信信号量计数器同步用否快控制并发访问共享资源选型判断逻辑很简单跨机器选 Socket不跨机器但量大性能敏感选共享内存就是不多的短消息管道或消息队列随手选只是想让对方“停一下”“重新加载”发个信号。5. 调度器CPU 面前的“排队系统”5.1 排队规则的演进进程只知道“我要运行”CPU 只有一个单核场景下。谁先跑、跑多久由调度器决定。调度策略的演进就是排队规则的演进。上古时代是 FCFS先来先服务简单但会出现“一个又长又慢的进程堵住后面所有短任务”的问题产生“护航效应”。然后是 SJF短作业优先吞吐量上去了但长任务可能饿死。再然后是时间片轮转每人一个时间片轮流跑交互性好了但时间片怎么定又是个讲究。现代操作系统不可能只用一种单算法一般是多级反馈队列的思想进程初始优先级高、时间片短跑一段时间没完成就被降级到下一级队列时间片变长。IO 密集的进程经常主动让出 CPU优先级会维持得高CPU 密集的进程会被不断降级避免独占 CPU。5.2 Linux 的 CFS让每个人都觉得自己独占 CPULinux 的默认调度器是 CFSCompletely Fair Scheduler完全公平调度用一句话概括它不按时间片均分而按虚拟运行时间vruntime排队。每个进程有一个vruntime它消耗 CPU 越多值越大。调度器每次选vruntime最小的进程跑。这样跑得多的人会自动靠后跑得少的人会被优先照顾看起来公平到不合理。nice值影响权重nice越小进程权重越高消耗同样物理 CPU 时间时vruntime涨得越慢于是被选中调度的机会就越多。实操看当前进程优先级# 查看当前 shell 的优先级 ps -l # 启动一个低优先级进程 nice -n 10 sleep 300 # 调整已经运行的进程优先级 renice -n 5 -p PIDps -l输出里的PRI是实际优先级NI是 nice 值。普通进程的 nice 范围是 -20 到 19但一般用户只能往大了调降低优先级往小了调变小提高优先级需要 root。5.3 实时调度策略响应时间的硬保障普通进程不需要绝对的“必须在 X 毫秒内被调度”但某些场景音视频、工业控制要求严格。Linux 提供实时调度策略SCHED_FIFO先来先服务无时间片一直跑到主动让出或被更高优先级抢占SCHED_RR时间片轮转同级之间轮流跑。查看进程调度策略用chrtchrt -p PID优先级范围是 1–99数值越高越优先。内核里Kthreadd这些关键内核线程很多就是实时策略这也是为什么你不该随便给它们调优先级的原因。5.4 为什么生产服务器的进程总在 S 状态拿top一看大量进程状态是 SsleepingR 状态的很少。这不是服务器闲着而是绝大多数进程都在等等网络包、等磁盘、等锁、等定时器。只有 CPU 密集的进程比如视频编码、科学计算才会长时间停在 R 状态。这就引出一个判断维度一个进程的优化方向取决于它在等什么。如果大量时间在 S 且等待 IO说明磁盘或网络是瓶颈你加 CPU 没用得换 SSD、扩带宽如果大量时间在 R说明计算密集你加核、优化算法才有用如果进程频繁进入 D 状态多半在等磁盘 IO而且不可被信号打断kill不掉也正常等它 IO 完成自然恢复。排查这个问题光top不够pidstat能看每个进程更细的 CPU 和 IO 情况后面实战章节里我会列出整套命令。6. 生产环境进程排查别等服务器 OOM 才慌了6.1 三件套快速定位热点进程搜热词里频繁出现“服务器总是 oom”“top 找占用最大进程换线程”。我把自己平时排查的方法整理成三个命令的配合足够覆盖大部分场景。第一件列出 CPU 和内存占用最高的进程。# 按 CPU 占用排序 ps aux --sort-%cpu | head -20 # 按内存占用排序 ps aux --sort-%mem | head -20第二件动态看状态变化。top -c按P按 CPU 排序按M按内存排序按H切换到线程视图。如果你的进程 CPU 占用忽高忽低在这个界面里按1看每个核的使用率能发现是不是有个核被打满。第三件看每个进程具体的历史统计。pidstat -p PID 1 10每秒钟打印一次持续 10 秒能看出这个进程的 CPU 使用率波动结合%CPU和%iowait判断是计算密集还是 IO 密集。6.2 一条 dmesg 救回一次 OOM 危机服务器 OOM 是 Linux 运维的经典噩梦。表现某个进程突然消失可能是被 OOM Killer 杀了。先别急着重启服务第一步是查看内核日志dmesg | grep -i out of memory | tail -20或者journalctl -k | grep -i oom | tail -20日志里会有类似Out of memory: Killed process 3112 (java) total-vm:123456kB, anon-rss:102400kB, ...这行直接告诉你谁被杀了、当时占了多少内存、为什么触发。常见的应对策略分几个层次释放缓存sync echo 3 /proc/sys/vm/drop_caches可以清掉 page cache但只是治标调整 vm.overcommit_memory默认 0 是启发式允许过度分配内存改成 2 会限制过度分配但可能导致某些程序申请内存失败增加 swap 或 zram给系统一个缓冲垫但也别指望 swap 能救重度 OOM用 cgroup / systemd 限制服务内存给不稳定的服务单独套上限别让它拖垮整个系统。我最推荐的也是最直接的用 systemd 或容器限制住可疑服务的内存上限比如给某个 Java 服务设MemoryMax4GOOM 时只杀它自己不牵连别的进程。6.3 误杀进程导致“电脑黑屏”的教训热词里那条“误删了一个进程任务电脑黑屏怎屏怎么办”一看就是手误 kill 掉关键系统进程了。Windows 下杀 explorer 或 dwm 会黑屏、任务栏消失Linux 下杀图形会话的 GNOME Shell / Xorg 也会黑屏。更别说一个极其容易踩的雷把 PID 1systemd杀了系统会立刻 panic或者进入一个没有服务管理器的残废状态。我的红线规则不确定这个进程是什么绝对不kill -9先用kill -TERM让它优雅退出观察有没有反应kill 之前先ps -o pid,ppid,stat,cmd -p PID看一眼 PPID 和完整命令PPID 是 1 的进程通常有被系统收养的背景杀掉影响面可能很大/bin/kill比 shell 内置的kill更可控生产服务器严禁对关键服务无脑pkill -9。如果已经黑屏了Windows 下可以用 CtrlShiftEsc 唤起任务管理器前提是系统还活着或者远程桌面连接进去重启 explorerLinux 下切到 TTYCtrlAltF2登录systemctl restart gdm重启桌面会话。还有一次我看到 VSCode 的终端报错“终端进程启动失败: 启动期间发生本机异常(无法启动 conpty)”其实也是终端进程层面的问题——Windows 终端依赖 conpty 伪终端进程如果那个进程没起来或和 shell 之间通信失败终端就炸了。这跟我们的主题其实是一件事终端里跑的命令本身就是一个进程它的启动依赖一个伪终端设备。重启终端往往比折腾半天“修复”更快。6.4 进程伪装与识破你能改进程名我也能找到你操作系统允许进程修改自己的名字这是合法能力但也被恶意程序拿来当掩体。真正的排查不只看“名字”而要看“身份”。Linux 下改进程名主要有两种方式prctl(PR_SET_NAME)修改/proc/pid/comm限制 16 字符以内直接修改 argv[0]可以改ps显示的命令行长度比 comm 宽松。写个 C 示例让你感受一下#include stdio.h #include sys/prctl.h #include unistd.h int main() { prctl(PR_SET_NAME, systemd-resolve, 0, 0, 0); sleep(120); return 0; }编译运行后ps aux看到的是一个叫systemd-resolve的进程长得和系统服务几乎一样。但伪装改不了实质# 查看真实可执行文件路径 ls -l /proc/PID/exe # 查看工作目录 ls -l /proc/PID/cwd # 查看累计 CPU 时间伪装进程通常跑得异常久 ps -o pid,etime,time,cmd -p PID/proc/PID/exe是指向真实二进制文件的符号链接这个改不了。如果名字叫systemd-resolve但 exe 指向/tmp/.hidden基本就是伪装。这个思路在做安全分析、确认可疑进程时已经很够用了。最后再分享一个我平常最爱用的排进程问题小习惯pstree -p PID能瞬间看清进程的“家族谱系”。有一次线上有个怪进程用ps看 PPID 是 1以为是孤儿进程pstree -asp PID一下发现它其实挂在某个业务服务下面是那个服务的子进程失联后被 systemd 临时收容了顺着 pstree 往上定位才真正找到它的上一级是谁。排查进程问题永远先看族谱再看症状最后动手这个顺序能少踩很多坑。
返回列表