咱们搞 Linux 的人,迟早得跟“进程”这个概念正面交锋。不管是排查服务器负载过高,还是写个脚本管理后台任务,又或者是面试的时候被问到“程序和进程什么区别”,归根结底都是在跟进程打交道。不少人刚接触 Linux 的时候,命令敲得飞起,但一旦问到进程底层是怎么回事、状态怎么切换、为什么会有僵尸进程,就开始犯迷糊了。这篇东西就是把这些概念掰开揉碎了讲清楚,结合我实际运维和开发中踩过的坑,配合常用命令和排查思路,希望看完你能对进程有个立体的认识。
这篇文章更适合这几类人看:刚入门 Linux 想系统打基础的同学、准备 Linux 运维或后端开发面试的求职者,以及平时写脚本但总感觉对系统底层把握不准的开发者。文中不会堆砌大段源码,而是用类比加实操的方式,把进程的来龙去脉讲透。
1. 进程到底是什么:从程序和进程的区别说起
很多教程一上来就甩出“进程是正在运行的程序”这种定义,字面上没毛病,但对理解背后的机制帮助不大。咱们换个角度,从操作系统管理资源的角度去看这个问题。
1.1 程序是静态的,进程是动态的
程序是什么?就是你磁盘上那个可执行文件,比如/usr/bin/nginx,它躺在硬盘里,有固定的文件大小,占用的磁盘空间是确定的,不会自己跑起来,也不会消耗 CPU 内存。你可以把它理解成一本菜谱,放在书架上不管放多久,内容都不会变。
进程是什么?是把这个菜谱拿进厨房,按照步骤真正开始切菜、炒菜、装盘的过程。菜谱还是那本菜谱,但“炒菜”这个活动占用着灶台(CPU)、占用着碗碟(内存)、有自己的操作进度(程序计数器),这套运行起来的活动就是进程。同一个菜谱,你可以同时开好几个灶台炒出好几份同样的菜,对应到 Linux 上就是同一个程序可以启动多个进程,每个进程独立运行,互不干扰。
我第一次真正理解这个区别,是排查一个 Nginx 启动后出现多个 worker 进程的情况。明明我执行了一次启动命令,ps -ef里却看到好几个 nginx 进程,当时还以为是重复启动了。后来才明白,master 进程 fork 出来的 worker 进程走的还是同一份可执行文件,但它们是完全不同的进程实例,各自处理各自的连接请求。
1.2 进程在系统里的“身份证”:PCB 与 PID
操作系统要管理这么多进程,光靠进程本身是不行的,得给每个进程建一个档案。这个档案在内核里是一个叫task_struct的结构体(不同版本内核可能有差异,但功能类似),我们一般把它叫做进程控制块,也就是 PCB。
PCB 里记着的东西非常多,但我习惯把它想成三块核心信息。第一块是身份信息,包括进程 ID(PID)、父进程 ID(PPID)、用户 ID(UID)这些,就像身份证号、户口本上的父母信息。第二块是状态信息,比如进程当前是运行中、睡眠中还是停止状态,以及寄存器里的值、程序计数器指向哪里,这些是进程被切换出去以后,下次切换回来要接着用的现场数据。第三块是资源信息,比如打开的文件描述符列表、内存映射情况、CPU 占用统计等。
当你在 shell 里敲ps命令的时候,看到的每一行,本质上就是系统从这一堆 PCB 里提取出来的关键字段展示给你看。PID就是进程的身份证号,系统里每个进程都有唯一的 PID。PPID则是你爸爸的 PID,比如你在 bash 里启动了一个sleep 100命令,那么 sleep 进程的 PPID 就是 bash 进程的 PID。
这里有一个我在面试中经常问候选人的问题:PID 是唯一的,但 PID 会不会被复用?会的。当某个进程退出后,它的 PID 可能会被系统分配给新创建的进程。所以如果你写脚本要长时间记录某个进程的信息,光存 PID 是不靠谱的,最好连同进程启动时间一起记录下来,防止 PID 被复用导致误判。
1.3 第一个进程和进程树
Linux 系统启动后,内核会创建第一个进程,它的 PID 永远是 1,叫做systemd(在较新的 CentOS、Ubuntu 系统上都是它,老一点的 SysV 时代是init)。这个进程是整个用户态进程的祖先,所有其他用户进程,要么是它直接拉起来的,要么是它的子孙后代。
可以用pstree命令很直观地看到这棵进程树。我在排查诡异问题的时候,经常先用pstree -p看一整个进程体系长什么样,能帮助快速定位某个进程是谁拉起来的。比如你发现服务器上有个奇怪的进程占着高 CPU,通过 pstree 找到它的父进程,顺藤摸瓜就能找到启动它的源头,这个思路在处理挖矿木马和异常进程时非常有效。
2. 进程状态与状态切换:running、sleep、zombie 实战解读
有了进程,进程在生命周期里会经历各种状态。top命令里S列那一堆字母,ps命令里STAT列的字符,都是进程状态的缩写。这块必须得弄清楚,因为排查系统卡顿、进程异常时,状态就是你诊断的第一手证据。
2.1 运行态(R)和睡眠态(S/D)
R(Running 或 Runnable)不代表进程此刻真的在 CPU 上跑,而是说它处于“只要 CPU 有空位就能立刻上去跑”的就绪状态。你看到一堆 R 状态的进程,说明系统里有大量任务在争抢 CPU,这时候结合负载均值可以判断是不是 CPU 瓶颈。
S(Sleeping)是可中断睡眠,这是进程最常见的状态。比如你在终端里执行sleep 300,这个 sleep 进程就是 S 状态,它在等待时间到或者等待某个事件发生。这个状态可以被信号打断,比如你用kill命令发一个 TERM 信号,进程就会收到并处理。
D(Uninterruptible Sleep)是不可中断睡眠,这个状态新手见了容易慌。D 状态通常是进程在等待 I/O 完成,比如磁盘读写、网络响应,而这个过程不允许被信号打断。如果系统里 D 状态进程特别多,基本可以判断是 I/O 有问题,可能磁盘坏了、NFS 挂了、或者存储系统响应极慢。D 状态的进程用kill -9都杀不掉,因为它压根没收信号,这时候只能等 I/O 恢复,或者重启系统。我早年有一台机器挂载了一个不稳定的 NFS 共享,一断连就出现一堆 D 状态进程,机器负载飙到几十,最后排查定位到是网络存储的问题。
2.2 僵尸进程(Z):回收不了的“遗骸”
僵尸进程是很多新手理解的难点。当一个进程结束运行后,它并不会立刻从系统里消失。它需要向父进程报告“我退出了”,并且把自己退出时的状态码交给父进程。如果父进程没有及时调用wait()系统调用来读取子进程的退出状态,那么这个子进程就变成了僵尸进程,进程描述符还留在内核里,但已经停止了任何执行。
用ps查看,僵尸进程的状态是 Z(Zombie),而且你发现 COMMAND 列经常是[python] <defunct>或[sleep] <defunct>这样带<defunct>标记的。僵尸进程不占 CPU,也不占内存,但它占着一个 PID,如果大量堆积,系统的 PID 数量会被耗尽,后面想创建新进程都创建不了。
处理僵尸进程的正解是先处理它的父进程。如果父进程还活着,可以尝试给父进程发送 SIGCHLD 信号或者直接重启父进程,父进程退出后,僵尸进程会被 PID 1 的 systemd 收养并清理。如果父进程本身就是 PID 1,那就比较麻烦了,通常只能重启系统。我在排查 CI 构建机的时候遇到过一次,Python 脚本 fork 出一堆子进程,但父进程逻辑写得不好,没有好好回收子进程,结果跑了几天以后系统无法创建新进程,最后定位到代码里没有正确调用 waitpid,属于典型的程序 bug。
2.3 停止态(T)和进程的暂停与后台运行
T 状态是进程被暂停了。你可以在终端里按Ctrl+Z暂停一个前台进程,此时进程就是 T 状态。kill -STOP信号也能达到同样效果。想要让暂停的进程继续运行,用kill -CONT信号。
这个机制配合 shell 的作业控制非常实用。比如你跑一个大任务,想临时腾出终端干别的,可以Ctrl+Z挂起,然后bg让它到后台继续跑。也可以用jobs查看当前终端的作业列表。这里有个常见误区:Ctrl+Z暂停的进程和&启动的后台进程不是一回事。&启动的进程是直接放在后台运行,状态可以是 R 或 S;Ctrl+Z是让进程先暂停,你手动bg之后才会真正在后台跑起来。
3. 进程的一生:fork、exec、exit 和 wait
进程不是凭空冒出来的,除了 PID 1 是内核创建的,其他进程都是通过一套固定的机制诞生的。理解这套机制,对理解 Linux 的进程模型至关重要。
3.1 fork():复制一份自己
在 Linux 里,一个进程创建另一个进程,最核心的调用是fork()。fork()做的事情简单说就是“复制当前进程”,内核会创建一个新的 PCB,新进程几乎拥有和父进程一模一样的内存内容、文件描述符、环境变量等,唯一区别是 PID 不同,且fork()的返回值不同。
我特别喜欢用“细胞分裂”来类比 fork。一个细胞(父进程)分裂成两个细胞,两个细胞继承了相同的细胞质和细胞器(内存内容和文件描述符),但他们是两个独立的生命体。在 C 语言里,fork()调用一次,却返回两次:在父进程里返回子进程的 PID,在子进程里返回 0。所以程序员通常用返回值判断当前代码是在父进程还是子进程里执行,据此走不同的分支。
一个经典问题是:fork 之后,父进程和子进程是共享内存还是各自独立的内存?答案是共享物理内存,但标记为写时复制(Copy-On-Write)。也就是说,在 fork 出来的那一瞬间,父子进程指向同一块物理内存,谁都不改数据,那就相安无事共享着。一旦某一方要写入数据,内核就另外分配一块物理内存,把数据拷贝过去再修改。这种机制大大降低了 fork 的开销,避免了无谓的数据复制。
3.2 exec():换一套程序来跑
fork 复制了父进程的躯壳,但很多时候我们不想跑和父进程相同的代码,而是想跑一个全新的程序。比如你在 shell 里敲ls,shell 先 fork 出一个子进程,这个子进程立刻调用exec系列函数,把自己当前运行的程序替换成/bin/ls这个可执行文件。exec 会加载新的程序到当前进程的内存空间,替换掉原来的代码段、数据段、堆栈,但 PID 不变,进程还是那个进程,但干的活完全变了。
所以创建新进程的标准组合拳是:fork() + exec()。先 fork 复制一个和自己一样的进程,然后在子进程里 exec 加载新程序。shell 执行命令、Nginx 启动 worker、大多数服务进程拉起子进程,底层都是这套组合拳。
3.3 exit() 与 wait():好死不如赖活着,走了也要留个交代
进程退出时,会调用exit()系统调用。内核会释放进程占用的内存、关闭打开的文件描述符等资源,但进程的 PCB 不会立刻删除,里面还保留着退出状态码等着父进程来收。这个状态码就是echo $?能看到的值,0 表示正常退出,非 0 表示有异常。
父进程必须调用wait()或waitpid()来读取子进程的退出状态。读取完成之后,内核才会真正把子进程的 PCB 删除,子进程才算彻底消失。如果父进程一直不调用 wait,子进程就一直是僵尸状态。
手工写 C 程序的人应该深有体会:如果不小心忘了写 wait 调用,跑完 fork 之后一查,一堆僵尸进程。写 Shell 脚本的人可能不太关心这些,因为 shell 作为父进程会自动回收子进程。但如果你自己写服务端程序,必须深刻理解 wait 的作用,否则生产环境很容易出现僵尸进程堆积。
4. 进程优先级与调度:为什么你的程序“卡”了
Linux 是多任务操作系统,CPU 资源要在众多进程之间分配,怎么分配、谁先跑、谁后跑,这就是调度器做的事。进程优先级是调度器做决策的关键依据。
4.1 nice 值与优先级的关系
Linux 里每个进程有两个关键优先级参数:一个是nice值,范围是 -20 到 19,默认是 0。nice 值越小,优先级越高,越容易被调度器选中运行。另一个是实时优先级,范围 0 到 99,实时进程的优先级永远高于普通进程。
nice这个名字挺有意思,直译“友好”,你 nice 值越高,就是越“友好”地把 CPU 让给别人,自己的优先级就越低。用top看进程时,NI列就是 nice 值,PR列是内核实际使用的优先级数值。普通进程的PR一般等于20 + NI,所以 nice 为 0 的进程 PR 是 20。
如果你想启动一个对 CPU 占用不高、慢慢跑的任务,可以用nice -n 10 ./slow_task,让它别跟核心服务抢 CPU。反之,如果某个任务特别重要,想要它优先跑,可以用sudo nice -n -5 ./important_task,把它 nice 值调成负数。不过设置负 nice 值需要 root 权限。
4.2 进程调度器的工作原理(不深入代码也够用)
现代 Linux 默认的调度器是 CFS(完全公平调度器)。CFS 的思路不是给每个进程分配固定的时间片,而是维护一个虚拟运行时间。每次调度时,CFS 选择虚拟运行时间最小的进程来运行,这样所有进程都能公平地推进虚拟运行时间。
用生活经验来类比,CFS 就像几个人排队打饭,每个人按一定速率积累“等待时间”,等待时间最长的优先打饭。nice 值影响的是“积累速度”,nice 为 -5 的进程积累虚拟运行时间的速率更慢,所以它总能保持较小的虚拟运行时间,于是更频繁地被选中运行。
实际观察top时会发现,同一时刻 CPU 核数就那么多,R 状态进程再多,能真正同时运行的也就等于 CPU 核数。如果 R 状态进程数超过 CPU 核数很多,系统负载就高了,你会感觉到明显卡顿。这时候优先要看的不是进程数量,而是每个进程的%CPU使用率和整体的load average。
4.3 调整进程优先级:renice 实战
一个进程跑起来了,发现它太占 CPU,影响了线上服务,不用杀掉重启,可以用renice动态调整优先级。比如把 PID 为 12345 的进程 nice 值调整为 10:
renice 10 -p 12345执行后用top查看,NI 列应该显示 10 了。要特别注意,普通用户只能调高自己的进程的 nice 值(变“友好”),不能调低;要调低必须用 root。生产环境对数据库、Web 服务这类核心进程,我一般不建议调 nice 值,保持默认就好,调来调去反而容易把系统搞乱。
5. 守护进程与作业控制:nohup、setsid 和 systemd
说完成生命历程和调度,得讲讲进程怎么在后台长期运行。开发者和运维打交道最多的场景之一,就是怎么把一个程序放到后台跑,而且关了终端也不能死。
5.1 为什么关了终端程序就死了
很多人写过python app.py启动服务,然后一关终端,服务就没了。原因是这个程序成了终端的会话成员,终端关闭时会向会话中的所有进程发送 SIGHUP 信号,默认处理是终止进程。这跟进程本身写得怎么样无关,是终端会话的机制决定的。
解决思路有两个方向:一是忽略 SIGHUP 信号,二是让进程脱离会话,成为一个新的会话首领。前者对应nohup命令,后者对应setsid命令,或者用 systemd 来托管。
5.2 nohup 和 setsid 的区别
nohup command &是最常用的后台运行方式。nohup 会让进程忽略 SIGHUP 信号,所以终端关了它也不受影响。但要注意,它仍然属于当前会话,虽然 SIGHUP 被忽略了,但关闭终端时进程的 stdin/stdout 可能已经指向了关闭的终端设备,所以一般要配合重定向:
nohup python app.py > app.log 2>&1 &2>&1是把标准错误重定向到标准输出,和日志一起进 app.log。这个&是丢到后台的意思,两兄弟经常配合使用。不重定向的话,nohup 默认会把输出写到当前目录的nohup.out,也算一个可用选项,但我不喜欢让日志写到默认文件里,太不显眼。
setsid的思路更彻底,它让新进程完全脱离当前会话,成为一个新会话的领头进程。用setsid启动的进程不仅不怕 SIGHUP,而且不再关联当前终端。不过现在 systemd 大行其道的时代,真正正经跑服务的场景,我优先推荐用 systemd service 来管理,配好 Restart、StandardOutput 这些参数,用systemctl start/stop/status管理,比 nohup 规范得多。
5.3 孤儿进程会被谁收养
如果一个进程的父进程退出了,这个进程就成了孤儿进程。孤儿进程不会被晾着,内核会把它们收养给 PID 为 1 的 systemd。所以孤儿进程的 PPID 最终会变成 1。
这里要注意僵尸进程和孤儿进程的区别:孤儿进程是父进程死了但自己还活着,它还在正常运行;僵尸进程是自己死了但父进程没来收尸,它的 PCB 残留。孤儿进程如果一直活着,不会有问题,反而是僵尸进程会占着资源。不过要是孤儿进程本身不正常,跑到最后变成僵尸,它的新父进程是 systemd,systemd 通常处理得很好,不会让僵尸堆积。
6. 排查进程问题的常用命令与实战技巧
概念讲了这么多,最终还是要落在排查问题上。这里整理我工作中最高频用到的一些查看进程的命令和排查思路,不打算把所有参数都列一遍,只挑真实场景最有用的。
6.1 ps、top、htop 的姿势
ps -ef是最常用的进程快照查询方式,每一行显示进程的 UID、PID、PPID、CPU 占用、启动时间、执行命令。ps aux也是类似功能,但输出格式略有不同。当你想找某个特定进程时,配合 grep 用:
ps -ef | grep nginx但 grep 会匹配到 grep 本身那条,所以常用grep -v grep或者直接pgrep -af nginx。pgrep的好处是不会匹配自己,-a显示完整命令行,-f是匹配完整命令参数。
top适合实时监控进程资源占用。但说实话,我更多用htop,因为交互体验更好,可以 F5 看进程树,F6 排序,鼠标操作也挺流畅。在排查 CPU 爆高时,top里按P键按 CPU 排序,按M键按内存排序,这个操作非常高频。
6.2 查看进程打开的文件:lsof
lsof是个神器,它列出进程打开的所有文件。前面提到 PCB 里有文件描述符表,lsof就是把这个表展示出来。排查很多问题都会用到它。
比如你想看某个进程打开了哪些文件:
lsof -p 12345或者你想知道某个文件被哪个进程占用:
lsof /var/log/nginx/access.log或者某个端口被哪个进程监听:
lsof -i :8080端口被占用的报错,绝对是新手遇到频率最高的问题之一。lsof -i :端口号一秒定位,省得去翻一堆日志。
6.3 查看进程资源占用:pidstat 和 /proc
如果你要连续观察某个进程的 CPU 和内存变化,pidstat比 top 更适合脚本化采集:
pidstat -p 12345 1 5这条命令每秒采样一次,共采样 5 次,输出 PID 12345 的 CPU、内存、线程状态等数据。
/proc目录是一个透明的内核视图目录树,每个运行中的进程都有对应的/proc/PID目录。比如/proc/12345/status里能看到进程的状态、内存、线程数,/proc/12345/cmdline里是完整的命令行参数(原本以 null 分隔,需要处理一下)。排查性能问题时,/proc/PID/io能看进程的 I/O 读写统计,/proc/PID/limits能看进程的资源限制。
6.4 进程起不来的常见排查路径
遇到“进程起不来”的问题,我有一套固定的排查顺序。第一步dmesg | tail看系统日志,很多时候是 OOM(内存不足)杀进程,或者段错误,dmesg 都会有记录。第二步看应用自己的日志,比如 Nginx 的 error.log,Java 应用的 catalina.out。第三步用ulimit -a看当前 shell 的资源限制,特别是 open files 数量,很多高并发服务启动时报 “too many open files” 就是这个限制太低了。第四步看磁盘空间,df -h,因为进程启动时如果没法写日志,也可能静默失败。
顺便提一句,线上服务器排查进程问题时,我强烈建议先用uptime看 load average,再top看整体负载分布,然后再深入具体进程。很多人一上来就盯着某个进程的 CPU 猛查,结果发现是系统整体负载过高导致的连锁反应,白白浪费时间。
7. 高频面试题里的进程概念
因为 Linux 进程这块是面试常客,我把这些年面试别人或者被别人问过的高频问题整理一下,当作知识检验清单。
7.1 进程和线程的区别是什么
这是最经典的问题。进程是资源分配的基本单位,线程是 CPU 调度的基本单位。同一个进程下的多个线程共享该进程的地址空间、打开的文件、全局变量等,而不同进程之间拥有独立的地址空间,互不干扰。所以线程之间通信简单(直接共享内存),但同步复杂;进程之间隔离性强,但通信要走 IPC 机制。
用生活化比喻,进程像一家公司,线程像公司里的员工。每个公司有独立的办公场地和资产(进程有独立的地址空间),员工在同一个办公场地里共享打印机、会议室(线程共享进程资源),但员工之间抢会议室就需要协调(线程同步)。
7.2 fork 之后子进程会复制父进程的哪些内容
子进程会复制父进程的地址空间(代码、数据、堆栈)、文件描述符表、环境变量、信号处理方式等。注意区分哪些是复制哪些是共享:内存是写时复制,文件描述符是共享同一个文件偏移量(也就是说,父子进程写同一个文件时,偏移量是共享的,可能导致交错写入)。这也是一种常见的 fork 之后踩坑的地方,尤其是在日志文件写入场景。
7.3 僵尸进程和孤儿进程的区别,如何处理
一句话区别:僵尸死了没人收尸,孤儿活着没人管教。处理僵尸进程的关键是处理父进程,父进程退出了僵尸进程就会被收养清理。写代码时要记住父进程要调用 wait/waitpid,或者给 SIGCHLD 信号注册处理函数,避免子进程退出后成为僵尸。
7.4 nohup 和 & 有什么区别
&只是把进程放到后台运行,进程仍然是当前终端的子进程,终端关闭后仍可能收到 SIGHUP 被杀掉。nohup是让进程忽略 SIGHUP 信号,但进程仍然属于当前会话,如果想要完全脱离,建议同时使用nohup command > log 2>&1 &,或者用setsid。实际生产环境中用 systemd 更规范。
7.5 如何知道一个进程是否发生内存泄漏
这个不是简单一条命令能回答的。通常的手段包括:长期观察进程内存占用趋势,用top -p PID定期采样 RSS 大小;或者用/usr/bin/time -v查看内存峰值;也可以通过/proc/PID/status里的VmRSS字段做监控报警。当然最靠谱的还是用专门的工具,比如 valgrind、gdb 查看堆内存分配记录,或者用 Python 的话 tracemalloc 模块。但在静态看内存是否涨这个问题上,ps和/proc足够作为第一道筛查手段了。
8. 从进程看 Linux 设计哲学:一切皆文件,进程是核心
聊了这么多,我最后想跟你分享一点对 Linux 设计的理解。Linux 最核心的设计哲学是“一切皆文件”,但真正让系统活起来的,是进程。文件是静态的,设备是静态的,网络连接是静态的描述,真正去读写文件、收发数据、执行计算的是进程。可以说,进程把整个系统的资源串联了起来。
理解进程,不光是懂几个命令、几个状态的问题,而是理解了操作系统怎么协调资源、怎么隔离任务、怎么保证一个程序挂了不影响整个系统。很多人觉得这些概念离日常开发很远,其实不然。写多线程程序要理解进程和线程的关系,排查线上 CPU 飙高要看进程状态,设计后台任务要考虑进程的生命周期和退出机制,部署服务要明白守护进程是怎么实现的。
进程概念是 Linux 知识体系里承上启下的一环:向上连接着用户命令和 shell,向下连接着系统调用和内核调度,横向延伸出线程、IPC、信号、资源限制等一大片内容。这一环打通了,后面学网络编程、学容器、学性能调优,都会顺畅很多。
最后分享一个我自己的小习惯:每学到一个新的系统概念,我就强迫自己用“如果我要用文字给别人讲明白,该怎么讲”来复盘一遍。写这篇东西也算是我自己重新梳理了一遍进程知识体系的过程。如果看完你也能用自己的话把 fork、僵尸进程、优先级这些讲给旁边的人听,那说明你是真的懂了。