你每天都在敲回车跑程序,但有没有想过:回车按下的那一瞬间,操作系统对着磁盘上那个文件到底干了什么?这一章,我们来看程序"活过来"之后的样子——进程(Process)。

本章回答什么问题

  • 程序(Program)和进程,难道不是同一个东西吗?
  • 同一个程序我跑了两遍,为什么两份数据互不相干?
  • 怎么看我的机器上正在跑哪些进程、谁在吃 CPU?
  • 想查某个进程的详细档案,去哪儿查?
  • 新进程是从天上掉下来的吗?它从哪儿来?

正文

程序是死的,进程是活的

先说结论:程序和进程不是一回事

你写好的程序,躺在磁盘上就是一个文件:一个 Python 脚本、一个编译好的可执行文件。它不占 CPU,一条指令都没跑,就是一堆安静的字节。

当你按下回车执行它,事情变了:操作系统把这个文件读进内存,分配资源,让 CPU 一条一条执行它的指令——这个正在运行的程序,就是进程。

用做饭来打比方:

  • 程序 = 菜谱。写在纸上的东西,可以复印无数份,但菜谱本身做不出菜;
  • 进程 = 照着菜谱做菜的整个过程。要占灶台(CPU)、用食材和碗碟(内存),还有进度——切完菜了、正在炒、快出锅了。

而且,同一份菜谱可以同时开好几份菜:同一个程序也能同时跑好几个进程。这一点马上用命令验证。

类比在这里失效了:做菜没人管你进行到哪一步,而进程的状态是操作系统强制管理的——它处于什么状态,操作系统说了算。

PID:每个进程的工号

操作系统同时管着成百上千个进程,怎么分清谁是谁?办法很朴素:给每个进程发一个编号,叫 PID(Process ID,进程号)。进程活着的时候,PID 是唯一的;进程结束后编号会被回收,过一会儿可能发给别人。

有个特殊编号要记住:PID 1。开机后操作系统启动的第一个进程就是它(Ubuntu 上一般是 systemd),后面所有进程都是它的子子孙孙。

光有个编号还不够。操作系统还给每个进程建了一份"任务档案",叫 PCB(Process Control Block,进程控制块):这个进程现在什么状态、用了多少内存、打开了哪些文件,都记在里面。说白了,PCB 就是操作系统的小本本,后面讲的调度、管理全靠它。这份档案由内核维护,你摸不到原件,但内核会把其中一部分内容放进一个叫 /proc 的目录供你随时查看——后面细说。

进程的几种状态:运行、睡眠、僵尸

任意时刻,一个进程大致处于这么几种状态:

  • 运行态(Running,ps 里显示 R):正在 CPU 上执行,或者在排队等 CPU。
  • 睡眠态(Sleeping,ps 里显示 S):在等一样东西——等磁盘读写、等键盘输入,或者像 sleep 命令那样单纯等时间过去。机器上绝大多数进程在绝大多数时候都在睡觉,看到一堆 S 不用慌,这是常态。
  • 僵尸态(Zombie,ps 里显示 Z):进程已经干完活退出了,但它的父进程还没来"收尸"。操作系统只能给它留一小块地方,记下"它是怎么死的、用了多少资源"这类遗言,等父进程来领。

僵尸进程听着吓人,其实它不吃 CPU 也不吃多少内存——它已经死了,只是还没下葬。真正的麻烦是僵尸太多:那说明某个父进程在偷懒,不收尸。

先留个观察点:top 输出的第二行 Tasks: 68 total, 1 running, 67 sleeping, 0 zombie,就是全机进程的状态分布,动手实验里会看到。

ps 拍合影,top 盯大屏

两个看进程的主力命令,性格完全不同。

ps(Process Status)拍的是快照:按下回车的瞬间,系统里有哪些进程,列出来给你看。

ps aux | head -n 5

下面是本机(WSL2 + Ubuntu)的实测输出,做了截取,你机器上的进程列表不会长这样:

USER         PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
root           1 18.1  0.0  24176 15424 ?        Ss   23:33   0:00 /sbin/init
root          45  6.3  0.1  42072 16624 ?        S<s  23:33   0:00 /usr/lib/systemd/systemd-journald
systemd+      62  3.6  0.0  22540 14672 ?        Ss   23:33   0:00 /usr/lib/systemd/systemd-resolved

先盯这几列:

  • USER:这进程是谁的;
  • PID:工号;
  • %CPU / %MEM:吃掉了多少 CPU 和内存;
  • STAT:状态。S 睡眠、R 运行、Z 僵尸,后面跟的 s+ 等是附加信息,初学不用纠结;
  • COMMAND:这个进程是跑什么命令起来的。

看第一行:PID 为 1 的 /sbin/init,正是前面说的开机第一个进程。

top 是实时监控大屏:每隔几秒刷新一次,按 q 退出。用批处理模式 top -bn1 可以只打一屏,方便贴笔记。下面是实测输出(进程列表截取了几行):

top - 23:30:38 up 0 min,  1 user,  load average: 0.00, 0.00, 0.00
Tasks:  68 total,   1 running,  67 sleeping,   0 stopped,   0 zombie
%Cpu(s):  0.0 us,  0.0 sy,  0.0 ni,100.0 id,  0.0 wa,  0.0 hi,  0.0 si,  0.0 st
MiB Mem :  15852.6 total,  14519.0 free,    746.7 used,    784.0 buff/cache
MiB Swap:   4096.0 total,   4096.0 free,      0.0 used.  15106.0 avail Mem

    PID USER      PR  NI    VIRT    RES    SHR S  %CPU  %MEM     TIME+ COMMAND
      1 root      20   0   24164  15468  11672 S   0.0   0.1   0:00.38 systemd
     45 root      19  -1   42068  16552  15320 S   0.0   0.1   0:00.09 systemd+
     87 root      20   0   35464  12472   9276 S   0.0   0.1   0:00.14 systemd+

上面几行是总览:Tasks 是进程总数和状态分布;%Cpu(s) 是 CPU 占用,id 那一项是空闲比例;Mem 是内存。下面的表格每行一个进程,S 列是状态,%CPU%MEM 最值得盯。top 默认按 CPU 占用排序——想知道"谁在拖慢机器",排在最前面的那位多半就是元凶。

/proc:进程的档案柜

pstop 给你的是列表。想翻某个进程的详细档案,去 /proc 目录。

/proc 很特别:每个活着的进程,在里面都有一个以自己 PID 命名的子目录,装着这个进程的各种档案。换句话说,/proc 就是操作系统给进程开的档案柜。它的特殊之处第 7 章讲文件系统时还会再遇,这里先用起来:

sleep 1000 &            # 后台起一个睡 1000 秒的进程,下一节细讲
head -n 10 /proc/390/status   # 把 390 换成你的 PID

下面是实测输出:

Name:	sleep
Umask:	0022
State:	S (sleeping)
Tgid:	390
Ngid:	0
Pid:	390
PPid:	320
TracerPid:	0
Uid:	1000	1000	1000	1000
Gid:	1000	1000	1000	1000

逐行看:Name 是进程名;State: S (sleeping) 说明它正在睡觉;Pid 是它自己的工号;PPid 是它父进程的工号——没错,进程是有爹的。这就引出下一节。

进程从哪儿来:fork 分身术

系统里这么多进程,除了开机那一个,其余都是从哪儿来的?答案是:全部由已有进程"复制"出来。执行复制的系统调用(System Call)叫 fork()

fork() 的行为很有意思:调用一次,返回两次。它把当前进程复制出一个几乎一模一样的子进程,父子俩从同一行代码继续往下跑。怎么知道自己是爹还是儿子?看 fork() 的返回值:

  • 父进程里,返回值是子进程的 PID
  • 子进程里,返回值是 0

写一段最小的 C 代码验证一下,保存为 fork_demo.c

#include <stdio.h>
#include <unistd.h>

int main(void) {
    printf("fork 之前:只有一个进程,PID = %d\n", getpid());

    pid_t pid = fork();

    if (pid < 0) {
        printf("fork 失败了\n");
        return 1;
    }

    if (pid == 0) {
        printf("子进程:我的 PID = %d,我的父进程 PPID = %d\n",
               getpid(), getppid());
    } else {
        printf("父进程:我的 PID = %d,fork 告诉我子进程的 PID = %d\n",
               getpid(), pid);
    }

    return 0;
}

编译并运行:

gcc fork_demo.c -o fork_demo
./fork_demo

(示意输出,实际以本机为准:编号会变,结构不变)

fork 之前:只有一个进程,PID = 1234
父进程:我的 PID = 1234,fork 告诉我子进程的 PID = 1235
子进程:我的 PID = 1235,我的父进程 PPID = 1234

拆开看这段输出:

  1. 第一行在 fork() 之前打印,那时世界上只有一个进程;
  2. fork() 之后,世界一分为二:走父进程分支的那位拿到 pid = 1235(儿子的工号),走子进程分支的那位拿到 pid = 0
  3. 子进程用 getpid() 报出的工号(1235),恰好是父进程从 fork() 拿到的那个数;子进程的 getppid()(1234)又恰好是父进程的 PID——父子互相把对方认了出来。

还有一个容易忽略的点:父子谁先打印,是不固定的fork() 之后两者平起平坐,谁先被 CPU 执行由调度器说了算(调度器怎么工作,第 4 章讲)。如果你的两行输出顺序和我不一样,完全正常。

给个类比:fork 就像大厨喊来一个学徒,学徒面前的厨房是大厨原样复制的。类比在这儿失效:现实中师徒共用一个厨房,而 fork() 之后,父子各有一份完全独立的内存——复制完成之后,各改各的,互不影响。

为什么要设计成"复制"这么怪的方式?因为在 Linux 上,跑一个新程序的标准流程就是:当前进程先 fork() 出一个子进程,再让子进程"变身"成新程序——你敲的每条 shell 命令都是这么跑起来的。"变身"的细节本章装不下,第 11 章讲系统调用时展开。

动手实验

环境:Ubuntu 22.04+ 的终端。下面的命令都可以直接敲,输出数字和你的会不一样,结构一样就对。

实验 1:用 ps 找到你的 shell

打开终端,第一个跑起来的进程就是你的 shell(默认是 bash)。它自己就是个进程:

ps aux | grep bash | grep -v grep

预期观察:能看到至少一行 bashUSER 是你自己的用户名。(示意输出,实际以本机为准)

yourname     388  0.0  0.0   6136  5200 pts/0    Ss   23:28   0:00 -bash

一句话解释:你敲命令的窗口本身就是个活着的进程;STAT 里的 S 说明它大多数时候在等你输入——睡觉是常态。

实验 2:用 top 看 CPU 和内存两列

top

预期观察:顶部是 Tasks%Cpu(s)Mem 总览;下面的列表每隔几秒刷新,绝大多数进程 S 列是 S%CPU 是 0.0。按 q 退出。

一句话解释:机器闲着的时候,进程几乎全在睡觉;睡觉不吃 CPU,所以 %CPU 全零很正常。

实验 3:造一个后台进程,翻它的档案

sleep 1000 &

预期观察:shell 打印类似 [1] 390 的一行,最后那个数字就是新进程的 PID。一句话解释:& 把命令丢到后台跑,shell 不等它,立刻把提示符还给你。

head -n 10 /proc/390/status     # 把 390 换成你刚拿到的 PID

预期观察:Name: sleepState: S (sleeping)PPid 正好是你 shell 的 PID(用实验 1 查到的那个)。一句话解释:它在等 1000 秒过去,所以是睡眠态;它是被你的 shell 生出来的,所以爸爸是 shell。

实验 4:看看"一份菜谱,两道菜"

sleep 1000 &
sleep 1000 &
ps aux | grep "sleep 1000" | grep -v grep

实测输出:

yurutu       421  0.0  0.0  16112  7684 pts/0    S+   23:16   0:00 sleep 1000
yurutu       422  0.0  0.0  16112  7856 pts/0    S+   23:16   0:00 sleep 1000

预期观察:两行,命令一模一样,PID 各不相同。一句话解释:同一个程序可以是两个独立进程——菜谱是死的,菜是各做各的。

收尾:干掉自己造的进程

kill 390 421 422      # 换成你实际的 PID
ps aux | grep "sleep 1000" | grep -v grep

预期观察:第二条命令没有输出——进程没了,/proc 下对应的目录也消失了。一句话解释:kill 给进程发一个终止信号,进程收到就退出;信号的完整机制第 4 章讲。

⚠️ 危险范围说明:kill 只作用于你指定的 PID。动手前确认 PID 是自己刚起的 sleep,别敲错数字;kill -9 这种"强制手段"先别用,为什么,第 4 章说。

常见误区

  1. 误解:进程就是程序的另一种说法。 → 真相:程序是磁盘上死着的文件,进程是运行起来的活体。同一个程序启动两次就是两个独立进程,各有各的 PID 和内存,互相看不见对方的数据。

  2. 误解:内存占得高,CPU 一定也占得高。 → 真相:top 里这两列没有必然联系。一个进程可以占着几个 G 内存安安静静睡觉(%CPU 为 0),也可以内存很小却把 CPU 烧满。两列分开看。

  3. 误解:僵尸进程是卡死的进程,得赶紧 kill 掉。 → 真相:僵尸已经死了,不吃 CPU,kill 一个死人也毫无作用。它只占一个 PID 和一小块记录遗言的地方;该处理的是那个不来收尸的父进程。

小结

  • 程序是躺在磁盘上的文件,进程是它跑起来之后的活体。
  • 一份菜谱能同时做几道菜:同一程序可以有多个进程,各跑各的。
  • 操作系统用 PID 区分进程,给每个进程建档案(PCB),PID 1 是万物之祖。
  • 进程状态看三种:运行 R、睡眠 S、僵尸 Z;绝大多数时候大家都在睡觉,僵尸只是没人收尸。
  • ps 拍快照,top 实时盯,%CPU%MEM 是最值得盯的两列。
  • /proc/PID/ 是进程的档案柜,status 里能看到状态和它的爹(PPid)。
  • 新进程都是老进程用 fork() 复制出来的;shell 跑命令靠的就是它,第 11 章展开。

思考题

  1. 浏览器开了几十个标签页,每个标签页往往就是一个进程——为什么你的 CPU 没有被立刻挤爆?(提示:想想大多数进程此刻处于什么状态。第 4 章会从调度器的角度验证你的猜想。)
  2. 执行 sleep 1000 & 之后,如果它的父进程(你的 shell)先退出了,这个子进程会变成什么样?再去看一眼 /proc/<PID>/status 里的 PPid,变成多少了?
  3. 在 fork 示例里,如果 fork() 之后子进程先结束,而父进程什么都不做、接着睡 30 秒——这 30 秒里子进程处于什么状态?用 topTasks 行验证一下。

一句话总结:程序只是躺在磁盘上的菜谱,进程是开火做饭的活现场——操作系统给每个进程发工号(PID)、建档案(PCB)、挂在 /proc 里供你查,而所有新进程,都是 fork() 出来的分身。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐