Linux 进程详解:从进程状态、调度到程序替换
在 Linux 中,进程是一个绕不开的核心概念。我们平时写的 C/C++ 程序,只有真正运行起来之后,才会变成进程。
比如磁盘上有一个可执行程序:
bash
./a.out
当它还躺在磁盘里时,它只是一个普通文件;当我们运行它之后,操作系统会为它创建进程描述信息,分配内存资源,并让它参与 CPU 调度。这个时候,它才算是一个真正的进程。
程序是静态的文件,进程是正在运行起来的程序。
这篇文章主要从几个方面来讲 Linux 进程:进程的基本概念、进程创建、进程状态、进程调度、进程等待与程序替换。
一、进程到底是什么?
从用户角度看,进程就是一个正在运行的程序。
但从操作系统角度看,进程并不只是"代码在运行"这么简单。一个进程背后包含了很多信息,比如:
- 程序代码;
- 数据段;
- 堆区;
- 栈区;
- 打开的文件;
- 当前工作目录;
- 环境变量;
- 进程 ID;
- 父进程 ID;
- 进程状态;
- 调度相关信息。
可以简单画成这样:

在 Linux 内核中,进程主要由一个叫做 task_struct 的结构体来描述。我们在操作系统中常说的 PCB,也就是进程控制块,在 Linux 中就可以大致理解为 task_struct。
操作系统管理进程,本质上不是直接管理代码,而是管理每个进程对应的进程描述信息。
1. PID 和 PPID
每个进程都有自己的编号,这个编号叫做 PID,也就是进程 ID。
查看当前 shell 的 PID:
bash
echo $$
查看系统中的进程:
bash
ps aux
查看进程之间的父子关系:
bash
ps -ef
示例:
text
UID PID PPID C STIME TTY TIME CMD
root 1 0 0 10:00 ? 00:00:01 systemd
user 2456 2430 0 10:12 pts/0 00:00:00 bash
user 2601 2456 0 10:13 pts/0 00:00:00 ./a.out
这里:
PID表示当前进程的 ID;PPID表示父进程的 ID;./a.out的父进程是bash。
也就是说,我们在 shell 中运行一个程序时,这个程序通常是由 shell 进程创建出来的。
二、进程的创建:fork
Linux 中创建子进程最常见的方式是使用 fork()。
fork() 的作用是创建一个子进程。子进程会继承父进程的大部分内容,比如代码、数据、打开的文件描述符等。
来看一段代码:
cpp
#include <iostream>
#include <unistd.h>
int main()
{
pid_t id = fork();
if (id < 0)
{
std::cerr << "fork failed" << std::endl;
return 1;
}
else if (id == 0)
{
std::cout << "我是子进程, PID = " << getpid()
<< ", PPID = " << getppid() << std::endl;
}
else
{
std::cout << "我是父进程, PID = " << getpid()
<< ", 子进程 PID = " << id << std::endl;
}
return 0;
}
运行结果可能类似这样:
text
我是父进程, PID = 3000, 子进程 PID = 3001
我是子进程, PID = 3001, PPID = 3000
这里有一个非常经典的问题:
fork()调用一次,却会返回两次。
原因是 fork() 执行之后,系统中已经有两个进程了:一个父进程,一个子进程。它们都会从 fork() 返回的位置继续往下执行。
区别在于:
- 在父进程中,
fork()返回子进程的 PID; - 在子进程中,
fork()返回 0; - 如果创建失败,返回负数。
所以我们经常通过 fork() 的返回值判断当前代码运行在父进程还是子进程中。
1. 写时拷贝
很多初学者会认为,fork() 会立刻把父进程的所有数据完整复制一份。
这个理解不完全准确。
Linux 为了提高效率,并不会在 fork() 的瞬间立刻复制所有物理内存,而是采用一种叫做 写时拷贝 的机制,也就是 Copy On Write,简称 COW。
fork 之后,父子进程暂时共享同一份物理内存。
只有当其中一个进程要修改数据时,操作系统才会真正拷贝对应的内存页。
可以这样理解:
text
fork 之后,尚未修改数据:
父进程 ─┐
├──> 同一份物理内存
子进程 ─┘
当子进程修改数据后:
父进程 ───> 原来的物理内存
子进程 ───> 新拷贝出来的物理内存
这样做的好处很明显:
- 节省内存;
- 提高
fork()的效率; - 如果子进程马上执行程序替换,就不需要浪费时间复制大量数据。
所以 fork() 看起来像是复制了一个进程,但底层并不是简单粗暴地把所有内存都复制一遍。
三、进程状态:进程并不是一直在运行
一个进程从创建到结束,不可能一直占用 CPU。它可能正在运行,也可能在等待资源,也可能被暂停,甚至已经退出但还没被父进程回收。
进程状态反映的不是程序"写成了什么样",而是它当前是否能被调度、是否正在等待资源、是否已经退出。
Linux 中常见的进程状态可以通过 ps aux 查看,输出中的 STAT 列就是进程状态。
常见状态如下:
| 状态 | 含义 |
|---|---|
R |
Running,运行状态或就绪状态 |
S |
Sleeping,可中断睡眠 |
D |
Disk sleep,不可中断睡眠 |
T |
Stopped,暂停状态 |
Z |
Zombie,僵尸状态 |
X |
Dead,死亡状态,基本看不到 |
1. R 状态:运行或就绪
R 表示进程正在运行,或者已经准备好运行,正在等待 CPU 调度。
注意,R 不一定代表它此时此刻真的正在 CPU 上执行,也可能只是处于就绪队列中。
R不一定表示进程正在 CPU 上运行,也可能只是已经准备好,正在等待调度器分配 CPU。
比如一个程序一直在做计算:
cpp
#include <iostream>
int main()
{
while (true)
{
// 持续占用 CPU
}
return 0;
}
运行后查看:
bash
ps aux | grep a.out
可能看到:
text
user 3001 99.0 0.1 4240 800 pts/0 R+ 10:20 0:10 ./a.out
这里的 R+ 表示这个进程处于运行或就绪状态。
2. S 和 D 状态:睡眠状态
进程很多时候并不需要一直占用 CPU。
比如程序在等待键盘输入、等待网络数据、等待磁盘读取,这时候它继续占着 CPU 也没意义,于是操作系统会让它进入睡眠状态。
S 是可中断睡眠,也是最常见的睡眠状态。
例如:
cpp
#include <unistd.h>
int main()
{
while (true)
{
sleep(1);
}
return 0;
}
这个程序大部分时间都在睡眠。查看状态时,很可能看到 S。
D 是不可中断睡眠,通常和底层 IO 有关,比如磁盘、NFS、块设备等。如果一个进程长时间处于 D 状态,往往说明系统 IO 可能有问题。
简单区分:
text
S 状态:普通等待,可以被信号打断
D 状态:底层 IO 等待,通常不能被普通信号立即打断
简单来说,
S更像是普通等待,D更像是底层 IO 卡住时的等待。
3. T 状态:暂停状态
T 表示进程被暂停了。
最常见的方式是在终端运行程序时按下:
bash
Ctrl + Z
这时候程序不会退出,而是被暂停。
可以使用:
bash
jobs
查看后台任务。
继续前台运行:
bash
fg
放到后台继续运行:
bash
bg
也可以用信号控制:
bash
kill -STOP 进程PID
kill -CONT 进程PID
STOP 用于暂停进程,CONT 用于让进程继续运行。
4. Z 状态:僵尸进程
Z 表示僵尸进程。
当子进程退出后,它的大部分资源已经被释放了,但是它的退出信息还需要保存下来,等待父进程读取。
这些退出信息包括:
- 子进程 PID;
- 退出码;
- 退出原因。
如果父进程一直不调用 wait() 或 waitpid() 回收子进程,那么这个子进程就会变成僵尸进程。
僵尸进程并不是还在运行的进程,而是已经退出、但退出信息还没有被父进程回收的进程。
示例代码:
cpp
#include <iostream>
#include <unistd.h>
int main()
{
pid_t id = fork();
if (id == 0)
{
std::cout << "子进程退出" << std::endl;
return 0;
}
else
{
while (true)
{
sleep(1);
}
}
return 0;
}
子进程很快退出,但父进程一直没有回收它。此时查看进程:
bash
ps aux | grep a.out
可能看到:
text
user 3301 0.0 0.0 0 0 pts/0 Z+ 10:35 0:00 [a.out] <defunct>
这里的 Z 就表示僵尸进程。
僵尸进程本身不会占用大量内存,但会占用 PID 等内核资源。如果系统中僵尸进程太多,就可能影响新进程的创建。
5. 进程状态转换图
进程状态之间并不是孤立的,它们会随着程序执行不断切换。
#mermaid-svg-47aUmc1vmfX2Jh8k{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-47aUmc1vmfX2Jh8k .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-47aUmc1vmfX2Jh8k .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-47aUmc1vmfX2Jh8k .error-icon{fill:#552222;}#mermaid-svg-47aUmc1vmfX2Jh8k .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-47aUmc1vmfX2Jh8k .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-47aUmc1vmfX2Jh8k .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-47aUmc1vmfX2Jh8k .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-47aUmc1vmfX2Jh8k .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-47aUmc1vmfX2Jh8k .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-47aUmc1vmfX2Jh8k .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-47aUmc1vmfX2Jh8k .marker{fill:#333333;stroke:#333333;}#mermaid-svg-47aUmc1vmfX2Jh8k .marker.cross{stroke:#333333;}#mermaid-svg-47aUmc1vmfX2Jh8k svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-47aUmc1vmfX2Jh8k p{margin:0;}#mermaid-svg-47aUmc1vmfX2Jh8k .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-47aUmc1vmfX2Jh8k .cluster-label text{fill:#333;}#mermaid-svg-47aUmc1vmfX2Jh8k .cluster-label span{color:#333;}#mermaid-svg-47aUmc1vmfX2Jh8k .cluster-label span p{background-color:transparent;}#mermaid-svg-47aUmc1vmfX2Jh8k .label text,#mermaid-svg-47aUmc1vmfX2Jh8k span{fill:#333;color:#333;}#mermaid-svg-47aUmc1vmfX2Jh8k .node rect,#mermaid-svg-47aUmc1vmfX2Jh8k .node circle,#mermaid-svg-47aUmc1vmfX2Jh8k .node ellipse,#mermaid-svg-47aUmc1vmfX2Jh8k .node polygon,#mermaid-svg-47aUmc1vmfX2Jh8k .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-47aUmc1vmfX2Jh8k .rough-node .label text,#mermaid-svg-47aUmc1vmfX2Jh8k .node .label text,#mermaid-svg-47aUmc1vmfX2Jh8k .image-shape .label,#mermaid-svg-47aUmc1vmfX2Jh8k .icon-shape .label{text-anchor:middle;}#mermaid-svg-47aUmc1vmfX2Jh8k .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-47aUmc1vmfX2Jh8k .rough-node .label,#mermaid-svg-47aUmc1vmfX2Jh8k .node .label,#mermaid-svg-47aUmc1vmfX2Jh8k .image-shape .label,#mermaid-svg-47aUmc1vmfX2Jh8k .icon-shape .label{text-align:center;}#mermaid-svg-47aUmc1vmfX2Jh8k .node.clickable{cursor:pointer;}#mermaid-svg-47aUmc1vmfX2Jh8k .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-47aUmc1vmfX2Jh8k .arrowheadPath{fill:#333333;}#mermaid-svg-47aUmc1vmfX2Jh8k .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-47aUmc1vmfX2Jh8k .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-47aUmc1vmfX2Jh8k .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-47aUmc1vmfX2Jh8k .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-47aUmc1vmfX2Jh8k .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-47aUmc1vmfX2Jh8k .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-47aUmc1vmfX2Jh8k .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-47aUmc1vmfX2Jh8k .cluster text{fill:#333;}#mermaid-svg-47aUmc1vmfX2Jh8k .cluster span{color:#333;}#mermaid-svg-47aUmc1vmfX2Jh8k div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-47aUmc1vmfX2Jh8k .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-47aUmc1vmfX2Jh8k rect.text{fill:none;stroke-width:0;}#mermaid-svg-47aUmc1vmfX2Jh8k .icon-shape,#mermaid-svg-47aUmc1vmfX2Jh8k .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-47aUmc1vmfX2Jh8k .icon-shape p,#mermaid-svg-47aUmc1vmfX2Jh8k .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-47aUmc1vmfX2Jh8k .icon-shape .label rect,#mermaid-svg-47aUmc1vmfX2Jh8k .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-47aUmc1vmfX2Jh8k .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-47aUmc1vmfX2Jh8k .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-47aUmc1vmfX2Jh8k :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 创建进程
就绪状态 R
运行状态
睡眠状态 S/D
暂停状态 T
进程退出
僵尸状态 Z
父进程回收
如果 Mermaid 图无法显示,也可以看下面这个简化版本:
text
创建
↓
就绪 R ←────────────┐
↓ │
运行 │
↓ │
等待资源 S/D ────────┘
↓
退出
↓
僵尸 Z
↓
父进程回收
这张图的重点是:进程不是创建之后就一直运行,而是在运行、等待、就绪、退出等状态之间不断变化。
四、进程等待与回收:wait / waitpid
子进程退出后,父进程需要回收它,否则就可能产生僵尸进程。
Linux 中常用 wait() 或 waitpid() 回收子进程。
示例:
cpp
#include <iostream>
#include <unistd.h>
#include <sys/wait.h>
int main()
{
pid_t id = fork();
if (id == 0)
{
std::cout << "子进程运行中..." << std::endl;
sleep(3);
std::cout << "子进程退出" << std::endl;
return 10;
}
else
{
int status = 0;
pid_t ret = waitpid(id, &status, 0);
if (ret > 0)
{
std::cout << "父进程回收子进程成功, pid = " << ret << std::endl;
if (WIFEXITED(status))
{
std::cout << "子进程正常退出, 退出码 = "
<< WEXITSTATUS(status) << std::endl;
}
}
}
return 0;
}
这里:
cpp
waitpid(id, &status, 0);
表示父进程阻塞等待指定子进程退出。
当子进程退出后,父进程会读取它的退出信息,并完成资源回收。
孤儿进程和僵尸进程的区别
这两个概念很容易混。
僵尸进程 是子进程已经退出,但父进程没有回收它。
孤儿进程 是父进程先退出了,而子进程还在运行。
孤儿进程一般不会没人管,它会被 1 号进程或者 systemd 接管,后续由它们负责回收。
可以这样理解:
text
僵尸进程:
子进程先退出,父进程不回收
孤儿进程:
父进程先退出,子进程还在运行
相比孤儿进程,僵尸进程更需要我们关注,因为大量僵尸进程可能会占用系统的 PID 资源。
五、进程调度:CPU 到底先运行谁?
我们平时使用电脑时,浏览器、终端、音乐播放器、编辑器好像都在同时运行。
但 CPU 核心数量是有限的。如果是单核 CPU,同一时刻真正运行的进程只有一个。我们感觉多个程序在同时运行,是因为操作系统在非常短的时间内不断切换进程。
这个过程就叫进程调度。
所谓"并发运行",本质上是操作系统在很短时间内快速切换不同进程,让用户感觉它们在同时执行。
进程调度要解决的问题就是:
操作系统下一次应该让哪个进程使用 CPU?
1. 就绪队列
所有可以运行、但暂时还没有拿到 CPU 的进程,会被放到就绪队列中。
text
就绪队列:
┌────────┐ ┌────────┐ ┌────────┐
│ 进程A │ │ 进程B │ │ 进程C │
└────────┘ └────────┘ └────────┘
↓
调度器选择
↓
CPU 运行
调度器会根据一定规则,从这些进程中选择一个放到 CPU 上运行。
2. 上下文切换
当 CPU 从一个进程切换到另一个进程时,需要保存当前进程的运行现场,再恢复另一个进程的运行现场。
这个过程叫做上下文切换。
上下文通常包括:
- 寄存器内容;
- 程序计数器;
- 栈指针;
- 内存地址空间;
- 进程状态;
- 调度信息。
上下文切换不是免费的,它本身也会消耗 CPU 时间。
所以并不是进程越多越好。如果系统中可运行进程太多,CPU 可能会花很多时间在切换进程上,而不是真正执行任务。
3. CFS 调度器和 vruntime
现代 Linux 普通进程主要使用 CFS 调度器,也就是 Completely Fair Scheduler,完全公平调度器。
CFS 的核心思想不是简单地给每个进程固定时间片,而是尽量让每个进程公平地获得 CPU 时间。
这里有一个重要概念叫 vruntime,也就是虚拟运行时间。
可以简单理解为:
哪个进程占用 CPU 越少,它的 vruntime 越小,调度器就越倾向于选择它运行。
举个例子:
text
进程 A:vruntime = 30
进程 B:vruntime = 10
进程 C:vruntime = 20
调度器更倾向于选择 vruntime 最小的进程 B
真实的 Linux 调度比这个复杂得多,还会考虑优先级、nice 值、多核负载均衡等因素。
但对于初学者来说,先记住一句话:
Linux 普通进程调度强调公平,尽量让每个可运行进程都能获得 CPU。
4. nice 值
Linux 中可以通过 nice 值影响普通进程的调度优先级。
nice 值范围通常是:
text
-20 到 19
注意:
- nice 值越小,优先级越高;
- nice 值越大,进程越"谦让",优先级越低。
查看进程 nice 值:
bash
ps -o pid,ni,cmd
启动程序时指定 nice 值:
bash
nice -n 10 ./a.out
修改已有进程的 nice 值:
bash
renice -n 5 -p 进程PID
nice 值只是影响调度倾向,不代表 nice 值高的进程一定马上运行,也不代表 nice 值低的进程一定不能运行。
六、程序替换:exec
前面说过,fork() 可以创建子进程。
但是 fork() 创建出来的子进程,默认执行的是和父进程一样的代码。很多时候我们并不是想让子进程继续执行父进程的代码,而是希望它去执行另一个程序。
比如 shell 中执行:
bash
ls -l
本质上可以理解为:
text
shell 先 fork 创建子进程
子进程再 exec 替换成 ls 程序
父进程等待子进程执行结束
所以,exec 的作用就是:
用一个新的程序,替换当前进程的代码和数据。
注意,这里说的是"替换",不是"创建"。
exec 不会创建新进程,它只是在当前进程内部,把原来的程序换成另一个程序。
也就是说:
text
调用 exec 前:
PID = 4000,运行的是 ./a.out
调用 exec 后:
PID = 4000,运行的是 /bin/ls
PID 没有变,但是进程内部运行的代码、数据、堆栈等内容已经被新程序替换了。
1. exec 调用成功后不会返回
这是学习 exec 时最容易忽略的一点。
如果
exec调用成功,当前进程的代码已经被新程序替换,所以exec后面的代码不会继续执行。
例如:
cpp
#include <iostream>
#include <unistd.h>
int main()
{
std::cout << "exec 之前" << std::endl;
execl("/bin/ls", "ls", "-l", nullptr);
std::cout << "exec 之后" << std::endl;
return 0;
}
如果 execl 调用成功,程序会执行 /bin/ls -l,但是不会输出:
text
exec 之后
因为原来的程序已经被 ls 替换掉了。
所以通常会这样写:
cpp
execl("/bin/ls", "ls", "-l", nullptr);
// 如果 exec 成功,下面的代码不会执行
perror("execl");
这意味着:如果 perror("execl") 被执行了,说明 execl 一定失败了。
2. exec 系列函数
Linux 中常见的 exec 系列函数有这些:
cpp
#include <unistd.h>
int execl(const char* path, const char* arg, ...);
int execlp(const char* file, const char* arg, ...);
int execle(const char* path, const char* arg, ..., char* const envp[]);
int execv(const char* path, char* const argv[]);
int execvp(const char* file, char* const argv[]);
int execve(const char* path, char* const argv[], char* const envp[]);
它们看起来比较多,但其实可以从三个角度理解:l 和 v 表示参数传递方式,p 表示是否从 PATH 中查找程序,e 表示是否自定义环境变量。
3. l 和 v:参数传递方式不同
函数名里带 l 的,表示 list,也就是参数一个一个列出来。
例如:
cpp
execl("/bin/ls", "ls", "-l", "-a", nullptr);
这里的参数是一个一个传进去的。
等价于执行:
bash
ls -l -a
函数名里带 v 的,表示 vector,也就是参数用数组传递。
例如:
cpp
char* const argv[] = {
(char*)"ls",
(char*)"-l",
(char*)"-a",
nullptr
};
execv("/bin/ls", argv);
它和上面的 execl 效果类似,也是执行:
bash
ls -l -a
可以简单记:
text
l:参数以列表形式传入
v:参数以数组形式传入
4. p:是否自动查找 PATH
函数名里带 p 的,表示会自动从环境变量 PATH 中查找程序。
例如:
cpp
execlp("ls", "ls", "-l", nullptr);
这里不用写完整路径 /bin/ls,因为 execlp 会去 PATH 中查找 ls 命令。
类似地:
cpp
char* const argv[] = {
(char*)"ls",
(char*)"-l",
nullptr
};
execvp("ls", argv);
也可以自动查找 ls。
如果函数名里不带 p,就需要指定程序的路径,例如:
cpp
execl("/bin/ls", "ls", "-l", nullptr);
execv("/bin/ls", argv);
可以简单记:
text
带 p:可以只写程序名,会从 PATH 中查找
不带 p:通常需要写程序路径
5. e:是否自定义环境变量
函数名里带 e 的,表示可以自己传入环境变量。
比如 execle 和 execve 都可以指定新的环境变量。
示例:
cpp
#include <unistd.h>
#include <stdio.h>
int main()
{
char* const envp[] = {
(char*)"MYENV=hello_linux",
(char*)"PATH=/usr/bin:/bin",
nullptr
};
execle("/usr/bin/env", "env", nullptr, envp);
perror("execle");
return 1;
}
这个程序会用我们自己传入的环境变量去执行 /usr/bin/env。
其中:
cpp
char* const envp[] = {
(char*)"MYENV=hello_linux",
(char*)"PATH=/usr/bin:/bin",
nullptr
};
表示新程序运行时使用的环境变量。
注意,环境变量数组最后也必须以 nullptr 结尾。
可以简单记:
text
带 e:可以自己指定环境变量
不带 e:默认继承当前进程的环境变量
6. exec 参数详细解释
以 execl 为例:
cpp
int execl(const char* path, const char* arg, ...);
调用示例:
cpp
execl("/bin/ls", "ls", "-l", "-a", nullptr);
参数含义如下:
text
path:要执行程序的路径,比如 /bin/ls
arg:传给新程序的第 0 个参数,通常写程序名本身
后面的 ...:继续传递命令行参数
nullptr:参数列表的结束标志,必须写
这里很多人会疑惑:为什么已经写了 /bin/ls,后面还要再写一个 "ls"?
因为:
cpp
execl("/bin/ls", "ls", "-l", "-a", nullptr);
第一个参数 /bin/ls 是告诉操作系统:我要执行哪个程序。
第二个参数 "ls" 是传给新程序的 argv[0]。
也就是说,新程序看到的参数大概是:
cpp
argv[0] = "ls";
argv[1] = "-l";
argv[2] = "-a";
argv[3] = nullptr;
第一个参数是"路径",后面的参数是"命令行参数"。
7. execv 参数详细解释
execv 的函数原型如下:
cpp
int execv(const char* path, char* const argv[]);
示例:
cpp
#include <unistd.h>
#include <stdio.h>
int main()
{
char* const argv[] = {
(char*)"ls",
(char*)"-l",
(char*)"-a",
nullptr
};
execv("/bin/ls", argv);
perror("execv");
return 1;
}
这里:
cpp
execv("/bin/ls", argv);
表示执行 /bin/ls 这个程序,并把 argv 数组作为命令行参数传过去。
其中 argv 的结构如下:
text
argv[0] = "ls"
argv[1] = "-l"
argv[2] = "-a"
argv[3] = nullptr
注意:
argv[0]通常放程序名,最后一个元素必须是nullptr。
8. execvp 参数详细解释
execvp 的函数原型如下:
cpp
int execvp(const char* file, char* const argv[]);
示例:
cpp
#include <unistd.h>
#include <stdio.h>
int main()
{
char* const argv[] = {
(char*)"ls",
(char*)"-l",
nullptr
};
execvp("ls", argv);
perror("execvp");
return 1;
}
这里和 execv 很像,只不过:
cpp
execv("/bin/ls", argv);
需要写完整路径。
而:
cpp
execvp("ls", argv);
可以只写 ls,它会自动从 PATH 环境变量中查找。
所以我们平时模拟 shell 执行命令时,execvp 用得非常多。
9. execve:最底层的系统调用
execve 的函数原型如下:
cpp
int execve(const char* path, char* const argv[], char* const envp[]);
它的三个参数分别是:
text
path:要执行的程序路径
argv:传给新程序的命令行参数
envp:传给新程序的环境变量
示例:
cpp
#include <unistd.h>
#include <stdio.h>
int main()
{
char* const argv[] = {
(char*)"ls",
(char*)"-l",
nullptr
};
char* const envp[] = {
(char*)"PATH=/usr/bin:/bin",
nullptr
};
execve("/bin/ls", argv, envp);
perror("execve");
return 1;
}
execve 是最底层的系统调用接口,其他 exec 函数本质上都是在它的基础上封装出来的。
可以这样理解:
text
execl / execlp / execle / execv / execvp
最终都会走到 execve
10. exec 系列函数总结
可以用下面这张表来快速记忆:
| 函数 | 参数形式 | 是否查找 PATH | 是否自定义环境变量 |
|---|---|---|---|
execl |
列表 | 否 | 否 |
execlp |
列表 | 是 | 否 |
execle |
列表 | 否 | 是 |
execv |
数组 | 否 | 否 |
execvp |
数组 | 是 | 否 |
execve |
数组 | 否 | 是 |
再简化一下:
text
l:list,参数一个一个传
v:vector,参数用数组传
p:PATH,自动查找环境变量 PATH
e:environment,可以自定义环境变量
11. fork + exec + wait 的完整例子
在实际使用中,exec 很少单独出现,更多是和 fork()、waitpid() 配合使用。
例如,我们让子进程执行 ls -l,父进程等待子进程结束:
cpp
#include <iostream>
#include <unistd.h>
#include <sys/wait.h>
#include <stdio.h>
int main()
{
pid_t id = fork();
if (id < 0)
{
perror("fork");
return 1;
}
else if (id == 0)
{
// 子进程进行程序替换
execlp("ls", "ls", "-l", nullptr);
// 如果 execlp 成功,下面代码不会执行
perror("execlp");
return 1;
}
else
{
// 父进程等待子进程退出
int status = 0;
pid_t ret = waitpid(id, &status, 0);
if (ret > 0)
{
if (WIFEXITED(status))
{
std::cout << "子进程正常退出,退出码是:"
<< WEXITSTATUS(status) << std::endl;
}
else
{
std::cout << "子进程异常退出" << std::endl;
}
}
}
return 0;
}
这段代码的执行流程是:
text
父进程 fork 出子进程
子进程调用 execlp,替换成 ls 程序
ls 程序执行完成后,子进程退出
父进程调用 waitpid,回收子进程退出信息
这就是 Linux 中非常经典的执行新程序的方式。
七、进程终止与常用命令
进程终止主要有几种情况:
main函数 return;- 调用
exit(); - 调用
_exit(); - 收到信号后被终止。
例如:
cpp
int main()
{
return 0;
}
这是最普通的正常退出方式。
也可以使用:
cpp
#include <cstdlib>
int main()
{
exit(0);
}
exit() 和 _exit() 有一个常见区别:
exit()会刷新缓冲区,并执行一些清理工作;_exit()更接近系统调用,会直接终止进程。
例如:
cpp
#include <iostream>
#include <unistd.h>
#include <cstdlib>
int main()
{
std::cout << "hello";
// exit(0);
_exit(0);
}
如果使用 _exit(0),由于缓冲区可能没有刷新,hello 不一定能输出。
1. 查看进程:ps / top
查看当前终端相关进程:
bash
ps
查看系统所有进程:
bash
ps aux
查看完整格式:
bash
ps -ef
自定义查看字段:
bash
ps -o pid,ppid,stat,ni,cmd
实时查看系统进程:
bash
top
如果系统安装了 htop,也可以使用:
bash
htop
htop 的交互体验更好,不过很多系统默认不自带,需要额外安装。
2. 查找和控制进程:pidof / kill
根据进程名查找 PID:
bash
pidof sshd
给进程发送信号:
bash
kill 进程PID
强制终止进程:
bash
kill -9 进程PID
暂停进程:
bash
kill -STOP 进程PID
继续运行进程:
bash
kill -CONT 进程PID
这里要注意,kill 这个命令的本质并不是"杀死进程",而是"给进程发送信号"。
kill的本质是发送信号,只不过默认信号通常会让进程终止,所以看起来像是在"杀进程"。
3. /proc 文件系统
Linux 中还有一个非常重要的虚拟文件系统:
bash
/proc
每个进程在 /proc 目录下都有一个以 PID 命名的目录。
查看当前 shell 对应的 /proc 目录:
bash
ls /proc/$$
常用查看方式:
bash
cat /proc/进程PID/status
可以查看进程状态、PID、PPID、内存等信息。
查看某个进程打开的文件描述符:
bash
ls -l /proc/进程PID/fd
比如:
bash
ls -l /proc/$$/fd
可以看到当前 shell 打开的标准输入、标准输出、标准错误等文件描述符。
八、一个小实验:观察进程状态变化
为了更直观地理解进程状态,可以做一个简单实验。
先写一个一直睡眠的程序:
cpp
#include <iostream>
#include <unistd.h>
int main()
{
std::cout << "PID = " << getpid() << std::endl;
while (true)
{
sleep(1);
}
return 0;
}
编译运行:
bash
g++ test.cpp -o test
./test
另开一个终端查看状态:
bash
ps -o pid,ppid,stat,cmd | grep test
你大概率会看到它处于 S 状态,因为它一直在 sleep。
然后把程序改成死循环:
cpp
#include <iostream>
#include <unistd.h>
int main()
{
std::cout << "PID = " << getpid() << std::endl;
while (true)
{
// 持续占用 CPU
}
return 0;
}
再运行并查看状态,可能会看到 R,并且 CPU 占用率明显升高。
这个实验可以帮助我们理解:
text
等待资源时,进程容易进入睡眠状态
持续计算时,进程更容易处于运行或就绪状态
进程状态不是固定的,而是会随着程序行为不断变化
九、总结
Linux 进程这一块内容虽然概念比较多,但可以用一条主线串起来:
创建进程 → 进入调度 → 状态切换 → 执行程序 → 退出回收
再具体一点:
- 程序是静态的,进程是运行起来的程序;
- Linux 使用
task_struct描述和管理进程; fork()用来创建子进程,并且底层使用写时拷贝提高效率;- 进程常见状态包括
R、S、D、T、Z; - 僵尸进程是子进程退出后,父进程没有及时回收导致的;
- 父进程可以通过
wait()或waitpid()回收子进程; - 进程调度就是操作系统决定下一个让谁使用 CPU;
- Linux 普通进程调度强调公平性,CFS 会尽量让进程公平获得 CPU;
exec不创建新进程,而是用新程序替换当前进程;fork + exec + wait是 Linux 中执行新程序的经典组合。
最后记住一句话:
程序是文件,进程是运行起来的程序;
操作系统管理进程,本质上是在管理进程的状态、资源和调度信息。
理解了进程,后面再学习进程间通信、信号、多线程、网络服务器和守护进程时,很多内容就会顺畅很多。