一、什么是程序
在学习进程之前,首先要区分:
程序(Program)
进程(Process)
1. 程序
程序可以理解为:
存放在外存中的一段代码和相关数据的集合。
例如我们编写:
#include <stdio.h>
int main(void)
{
printf("Hello Linux\n");
return 0;
}
编译:
gcc hello.c -o hello
生成:
hello
此时 hello 是一个程序。
它存放在磁盘上,本身是静态的。
二、什么是进程
进程可以理解为:
程序的一次动态执行过程。
例如:
./hello
执行以后:
磁盘上的 hello
↓
加载到内存
↓
创建进程
↓
CPU
↓
执行代码
课程资料中指出:
程序是存放在外存中的一段代码集合;进程是程序动态执行的过程,包括创建、调度和消亡。
三、程序和进程的区别
可以这样记:
| 程序 | 进程 |
|---|---|
| 静态的 | 动态的 |
| 存放在磁盘 | 运行在内存 |
| 是代码和数据的集合 | 是程序的一次运行 |
| 一个程序可以对应多个进程 | 每个进程有自己的进程信息 |
例如:
./a.out
./a.out
./a.out
虽然运行的是同一个:
a.out
但实际上可以产生三个不同的进程:
a.out ──→ PID 1001
a.out ──→ PID 1002
a.out ──→ PID 1003
四、Linux 中查看进程
Linux 提供了很多查看进程的命令。
1. ps
ps
查看当前终端中的进程。
2. ps -ef
ps -ef
查看系统中的进程信息。
常见输出:
UID PID PPID C STIME TTY TIME CMD
其中:
PID
↓
进程ID
PPID
↓
父进程ID
课程资料中也将 ps -ef、pstree、ps -aux 列为常用进程查看命令。
五、PID ------ 进程ID
PID:
Process ID
即:
进程的唯一标识符。
例如:
PID
1001
1002
1003
每一个运行中的进程都有自己的 PID。
可以使用:
ps -ef
查看。
课程资料中也明确指出,PID 用于区分进程。
六、PPID ------ 父进程ID
PPID:
Parent Process ID
表示:
当前进程的父进程 PID。
例如:
父进程
PID = 1000
│
├── 子进程 PID = 1001
│
└── 子进程 PID = 1002
那么:
1001 的 PPID = 1000
1002 的 PPID = 1000
所以:
PID
↓
我是谁
PPID
↓
谁创建了我
七、进程的父子关系
Linux 中进程可以形成一种树形结构:
init/systemd
│
├── 进程A
│ │
│ ├── 进程C
│ └── 进程D
│
└── 进程B
可以使用:
pstree
查看。
例如:
systemd
├─ sshd
│ └─ bash
│ └─ a.out
└─ ...
因此 Linux 中的进程之间具有明显的父子关系。
八、进程空间
进程运行时需要自己的虚拟地址空间。
课程资料按照 32 位系统的典型情况,将进程空间划分为:
0 ~ 4G
并进一步划分为文本段、数据段以及系统数据区域。
可以简单理解为:
高地址
┌──────────────────┐
│ 栈 Stack │
├──────────────────┤
│ │
│ ↓ │
│ │
│ 空闲空间 │
│ │
│ ↑ │
│ │
├──────────────────┤
│ 堆 Heap │
├──────────────────┤
│ BSS 数据区 │
├──────────────────┤
│ Data 数据区 │
├──────────────────┤
│ Text 代码区 │
└──────────────────┘
低地址
九、文本段 Text
文本段主要存放:
程序执行指令
例如:
printf("Hello");
编译以后会转换成机器指令,这些指令在进程运行时位于代码区域。
十、数据段 Data
数据区域主要存放程序中的数据。
例如:
int a = 10;
static int b = 20;
以及字符串常量等。
课程资料中将字符串常量、初始化/未初始化的全局变量和静态变量等列入数据相关区域。
十一、堆区 Heap
堆区主要用于:
malloc()
calloc()
realloc()
动态申请的内存。
例如:
int *p = malloc(sizeof(int) * 10);
申请的空间通常来自堆区。
十二、栈区 Stack
栈区主要用于:
局部变量
函数参数
函数调用信息
例如:
void fun(void)
{
int a = 10;
}
其中:
a
是局部变量,通常位于栈区。
十三、进程的特点
进程具有一个非常重要的特点:
进程之间的地址空间相互独立。
例如:
进程A
┌─────────────┐
│ 0~4G │
└─────────────┘
进程B
┌─────────────┐
│ 0~4G │
└─────────────┘
虽然每个进程看到的虚拟地址空间可以都是:
0 ~ 4G
但是它们实际上对应不同的物理内存映射。
课程资料也强调了进程空间独立以及分时复用的特点。
十四、进程调度
计算机中可能同时存在很多进程:
进程A
进程B
进程C
进程D
但 CPU 在某一个时间点实际上只能执行有限数量的任务。
所以操作系统需要:
进程调度。
十五、时间片
CPU 分配给某个进程的一小段执行时间叫:
时间片(Time Slice)
例如:
时间 →
────────────────────────>
A B C A B C
│ │ │ │ │ │
从宏观上看:
A、B、C
似乎同时运行。
实际上 CPU 在不断进行任务切换。
课程资料将时间片定义为 CPU 在进程任务中执行的一小段时间。
十六、常见进程调度算法
课程资料中列出的常见调度算法包括:
1. 先来先执行
2. 高优先级调度
3. 时间片轮转
4. 多级队列反馈
5. 负载均衡
其中学习阶段尤其需要理解:
时间片轮转
可以简单理解为:
A → B → C → A → B → C
十七、进程状态
进程并不是一直处于运行状态。
Linux 中常见的进程状态包括:
R
S
D
T
Z
X
课程资料也列出了这些状态。
1. R ------ Running / Runnable
R
表示:
运行或可运行状态。
也就是说进程正在运行,或者已经准备好运行、等待 CPU 调度。
2. S ------ Interruptible Sleep
S
表示:
可中断睡眠/等待状态。
例如:
sleep(10);
进程暂时等待。
收到合适的事件或信号后,可以继续运行。
3. D ------ Uninterruptible Sleep
D
表示:
不可中断睡眠状态。
通常与某些内核 I/O 等等待有关。
4. T ------ Stopped
T
表示:
进程被暂停。
例如:
kill -STOP PID
可以让进程进入停止状态。
5. Z ------ Zombie
Z
表示:
僵尸进程。
这是本节非常重要的内容,后面会重点讲。
6. X ------ Dead
表示进程已经结束,相关资源正在被系统回收。
十八、fork() ------ 创建进程
Linux 创建子进程最重要的函数:
fork()
函数原型:
#include <unistd.h>
pid_t fork(void);
作用:
创建一个新的子进程。
课程资料中明确给出了 fork() 的功能和返回值。
十九、fork() 最重要的特点
fork() 调用一次:
返回两次。
这是进程学习中最重要的一句话之一。
例如:
#include <stdio.h>
#include <unistd.h>
int main(void)
{
printf("before fork\n");
fork();
printf("after fork\n");
return 0;
}
可能输出:
before fork
after fork
after fork
为什么?
因为:
fork()之前
│
│
▼
一个进程
│
│ fork()
▼
┌───────────┐
│ │
父进程 子进程
│ │
└───────────┘
│
两边都会继续执行后面的代码
二十、fork() 的返回值
这是考试和写代码时必须记住的:
fork() < 0
↓
创建失败
fork() == 0
↓
当前是子进程
fork() > 0
↓
当前是父进程
在父进程中:
fork()
返回:
子进程 PID。
在子进程中返回:
0
课程资料明确给出了这一返回规则。
二十一、利用 fork() 区分父子进程
最经典的代码:
#include <stdio.h>
#include <unistd.h>
int main(void)
{
pid_t pid = 0;
pid = fork();
if(pid < 0)
{
perror("fork");
return -1;
}
else if(pid == 0)
{
printf("我是子进程\n");
}
else
{
printf("我是父进程\n");
}
return 0;
}
判断依据:
if(pid == 0)
就是:
当前代码正在子进程中执行。
而:
else
就是:
当前代码正在父进程中执行。
二十二、getpid() ------ 获取自己的 PID
函数:
pid_t getpid(void);
功能:
获取当前进程自己的 PID。
例如:
printf("PID = %d\n", getpid());
课程资料中也明确给出了 getpid() 的功能。
二十三、getppid() ------ 获取父进程 PID
函数:
pid_t getppid(void);
功能:
获取当前进程的父进程 PID。
例如:
printf("PID = %d\n", getpid());
printf("PPID = %d\n", getppid());
可以得到:
PID = 1234
PPID = 1200
说明:
当前进程 PID = 1234
父进程 PID = 1200
二十四、fork() 综合练习
创建一个子进程,并分别打印:
父进程 PID
父进程 PPID
子进程 PID
子进程 PPID
代码:
#include <stdio.h>
#include <unistd.h>
int main(void)
{
pid_t pid = 0;
pid = fork();
if(pid < 0)
{
perror("fork");
return -1;
}
if(pid == 0)
{
printf("子进程:PID = %d, PPID = %d\n",
getpid(), getppid());
}
else
{
printf("父进程:PID = %d, PPID = %d\n",
getpid(), getppid());
}
return 0;
}
二十五、fork() 后变量是否共享?
例如:
int num = 10;
fork();
num++;
很多初学者会误以为:
父进程 num = 11
子进程 num = 12
或者认为两个进程共同操作一个 num。
实际上:
fork() 后父子进程拥有各自独立的地址空间。
因此父子进程修改普通变量不会直接影响对方。
例如:
#include <stdio.h>
#include <unistd.h>
int main(void)
{
int num = 10;
pid_t pid = 0;
pid = fork();
if(pid == 0)
{
num++;
printf("child num = %d\n", num);
}
else
{
num++;
printf("parent num = %d\n", num);
}
return 0;
}
两边通常都会输出:
11
而不是一个变成 12。
二十六、fork() 后代码执行位置
这是理解 fork() 的关键:
printf("A\n");
fork();
printf("B\n");
执行流程:
printf A
│
▼
fork
/ \
/ \
父进程 子进程
│ │
printf B printf B
因此:
A
B
B
二十七、创建两个子进程
如果:
fork();
fork();
那么需要特别注意进程数量。
第一次:
P
│
fork
/ \
P C1
第二次 fork() 会被两个进程分别执行:
P
/ \
/ \
P1 C1
│ │
C2 C3
最终:
4个进程
因此:
fork() 一次 → 2个
fork() 两次 → 4个
fork() 三次 → 8个
理论上:
进程数 = 2^n
但实际还要考虑条件判断等因素。
二十八、exit() ------ 进程退出
函数:
#include <stdlib.h>
void exit(int status);
作用:
结束当前进程。
例如:
exit(0);
表示正常结束。
课程资料中也将 exit() 定义为进程结束函数。
二十九、return 和 exit 的区别
在 main() 中:
return 0;
和:
exit(0);
都可以结束程序。
例如:
int main(void)
{
return 0;
}
相当于正常结束进程。
但是在普通函数中:
void fun(void)
{
return;
}
只表示:
返回调用者。
而:
void fun(void)
{
exit(0);
}
表示:
直接结束整个进程。
课程资料也特别区分了 return 和 exit:主函数中的 return 与 exit 效果相近,而普通函数中的 return 只是返回调用者,exit 则结束整个进程。
三十、进程为什么会产生僵尸进程?
假设:
父进程
│
└── 子进程
子进程先结束:
父进程
│
└── 子进程结束
但父进程没有回收子进程的退出信息。
这时子进程可能进入:
Z
即:
僵尸进程(Zombie Process)
课程资料中指出,进程代码执行结束后,如果相关退出信息/资源没有被父进程及时回收,就可能形成僵尸进程。
三十一、为什么会有僵尸进程?
子进程结束以后:
代码执行完毕
↓
子进程退出
↓
父进程还没有回收
↓
保留退出状态等信息
↓
僵尸进程
可以理解为:
人已经死了,但是"死亡证明"还留在系统中等待父进程领取。
三十二、如何避免僵尸进程?
主要有两种思路:
方法一:父进程调用 wait()
wait(NULL);
让父进程主动回收子进程。
方法二:父进程先结束
父进程结束后,子进程成为:
孤儿进程
由系统中的特殊进程接管并最终负责回收。
课程资料中也给出了这两种思路。
三十三、wait() ------ 回收子进程
函数:
#include <sys/types.h>
#include <sys/wait.h>
pid_t wait(int *wstatus);
功能:
等待并回收子进程。
课程资料中明确说明 wait() 用于回收子进程空间,并且具有阻塞功能,可以实现多任务同步。
三十四、wait() 的阻塞
例如:
pid = fork();
if(pid == 0)
{
sleep(5);
printf("child\n");
}
else
{
wait(NULL);
printf("parent\n");
}
执行过程:
父进程
│
├── wait()
│ ↓
│ 阻塞等待
│
│
子进程
│
├── sleep(5)
│
├── 结束
│
└────────────→
│
↓
父进程继续
│
↓
printf
因此:
wait()不仅能够回收子进程,还可以实现父子进程之间的同步。
三十五、wait() 的返回值
成功:
子进程 PID
失败:
-1
课程资料也明确列出了这一返回值规则。
三十六、获取子进程退出状态
例如:
int status = 0;
wait(&status);
可以通过宏判断:
WIFEXITED(status)
判断:
子进程是否正常退出。
然后:
WEXITSTATUS(status)
获取:
子进程通过
exit()返回的退出值。
课程资料中还列出了:
WIFSIGNALED()
WTERMSIG()
用于判断子进程是否被信号终止以及获取终止信号编号。
三十七、完整的 wait() 示例
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
int main(void)
{
pid_t pid = 0;
int status = 0;
pid = fork();
if(pid < 0)
{
perror("fork");
return -1;
}
if(pid == 0)
{
printf("子进程运行\n");
exit(10);
}
else
{
wait(&status);
if(WIFEXITED(status))
{
printf("子进程正常退出\n");
printf("exit code = %d\n",
WEXITSTATUS(status));
}
}
return 0;
}
输出可能:
子进程运行
子进程正常退出
exit code = 10
三十八、waitpid() ------ 回收指定子进程
如果一个父进程有多个子进程:
父进程
├── 子进程A
├── 子进程B
└── 子进程C
那么:
wait(NULL);
只能等待某个已经结束的子进程。
如果希望:
指定回收某一个子进程
可以使用:
waitpid()
函数:
pid_t waitpid(pid_t pid, int *wstatus, int options);
课程资料中明确说明 waitpid() 可以回收指定 PID 的子进程,并支持阻塞和非阻塞方式。
三十九、waitpid() 的参数
waitpid(pid, &status, 0);
其中:
pid
↓
要等待的子进程 PID
&status
↓
保存退出状态
0
↓
阻塞等待
例如:
pid_t child_pid;
child_pid = fork();
if(child_pid == 0)
{
sleep(3);
exit(20);
}
else
{
waitpid(child_pid, NULL, 0);
printf("子进程已经结束\n");
}
四十、WNOHANG ------ 非阻塞等待
waitpid() 的第三个参数可以设置:
WNOHANG
例如:
waitpid(pid, &status, WNOHANG);
含义:
非阻塞等待。
如果子进程还没有结束:
返回 0
如果子进程已经结束:
返回子进程 PID
课程资料也明确说明了这一点。
四十一、wait 和 waitpid 对比
| 函数 | 作用 |
|---|---|
wait() |
等待任意一个子进程 |
waitpid() |
可以指定子进程 |
wait() |
阻塞 |
waitpid(..., 0) |
阻塞 |
waitpid(..., WNOHANG) |
非阻塞 |
记忆:
wait
↓
随便等一个
waitpid
↓
等指定 PID
四十二、孤儿进程
如果:
父进程先结束
而:
子进程还没有结束
此时子进程就成为:
孤儿进程(Orphan Process)
例如:
父进程
│
└── 子进程
│
│ 父进程退出
↓
孤儿进程
之后会被系统中的特殊父进程接管。
注意:
孤儿进程本身不是错误。
而僵尸进程通常是需要避免长期存在的。
四十三、僵尸进程和孤儿进程对比
| 僵尸进程 | 孤儿进程 |
|---|---|
| 子进程已经结束 | 子进程仍然运行 |
| 父进程没有及时回收 | 父进程提前结束 |
状态通常为 Z |
可以正常运行 |
| 需要及时回收 | 会被其他父进程接管 |
最容易记忆:
子死父不收
↓
僵尸
父死子还活
↓
孤儿
四十四、exec 函数族
进程创建之后,还有一个非常重要的问题:
如何让子进程执行另外一个程序?
这时就需要:
exec 函数族
常见函数:
execl()
execlp()
execle()
execv()
execvp()
execvpe()
课程资料中完整列出了这些函数。
四十五、exec 的作用
例如当前程序是:
parent
父进程创建子进程:
parent
│
fork
│
└── child
然后子进程调用:
execl(...)
就可以让这个子进程的进程空间加载并执行另外一个程序。
可以理解为:
fork()
↓
创建子进程
↓
exec()
↓
子进程执行另一个程序
四十六、exec 不会创建新进程
这是非常容易考的一个知识点。
fork()
负责:
创建进程。
而:
exec()
负责:
让当前进程执行另一份程序代码。
所以:
fork → 新进程
exec → 替换当前进程执行内容
课程资料中也将 exec 的功能描述为利用进程空间执行另一段代码。
四十七、exec 函数族命名规律
这个非常适合总结成表格:
| 字母 | 含义 |
|---|---|
l |
list,参数以列表形式传递 |
v |
vector,参数以指针数组形式传递 |
p |
在 PATH 中查找程序 |
e |
可以指定环境变量 |
课程资料也明确给出了 l/v/p/e 的含义。
四十八、execl()
例如执行:
ls -l
可以:
execl("/bin/ls",
"ls",
"-l",
NULL);
这里:
/bin/ls
↓
程序路径
"ls"
↓
argv[0]
"-l"
↓
argv[1]
NULL
↓
参数结束
四十九、execlp()
如果使用:
execlp("ls",
"ls",
"-l",
NULL);
就不需要写:
/bin/ls
因为:
p
↓
PATH
会根据环境变量 PATH 搜索程序。
五十、execv()
execv() 的参数使用:
指针数组
例如:
char *argv[] =
{
"ls",
"-l",
NULL
};
execv("/bin/ls", argv);
这里:
argv[0] = "ls"
argv[1] = "-l"
argv[2] = NULL
这就是你前面练习中经常出现的:
char *tmpbuff[]
这种形式。
五十一、execv 和 execl 的区别
这是学习 exec 时非常重要的区别。
execl
参数一个一个写:
execl("/bin/ls",
"ls",
"-l",
"-a",
NULL);
execv
使用数组:
char *argv[] =
{
"ls",
"-l",
"-a",
NULL
};
execv("/bin/ls", argv);
所以:
l → list
v → vector
五十二、fork + exec
Linux 编程中最经典的组合:
fork()
+
exec()
例如:
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int main(void)
{
pid_t pid = 0;
pid = fork();
if(pid < 0)
{
perror("fork");
return -1;
}
if(pid == 0)
{
execlp("ls", "ls", "-l", NULL);
perror("execlp");
exit(1);
}
printf("我是父进程\n");
return 0;
}
执行过程:
父进程
│
fork
/ \
/ \
父进程 子进程
│
exec
│
↓
ls -l
五十三、为什么经常使用 fork + exec?
因为:
fork()
负责:
创建一个新的执行实体。
而:
exec()
负责:
让新的子进程执行指定程序。
因此 Shell 执行命令时,本质上也涉及类似的机制:
Shell
│
├── fork()
│
└── 子进程
│
└── exec()
│
└── 执行 ls
这也是为什么前面的 Shell 学习和现在的进程学习能够连接起来。
五十四、exec 成功后为什么不执行后面的代码?
例如:
printf("A\n");
execl("/bin/ls", "ls", NULL);
printf("B\n");
如果 execl() 成功:
A
ls输出
通常不会看到:
B
因为成功执行 exec 后:
当前进程已经开始执行新的程序映像。
而如果:
execl(...) == -1
说明执行失败:
继续执行后面的代码
所以经常写:
execl(...);
perror("execl");
exit(1);
五十五、环境变量
进程运行过程中还会使用:
环境变量
例如:
echo $PATH
可能得到:
/usr/local/bin:/usr/bin:/bin
环境变量可以理解为:
进程运行过程中保存的一些环境信息。
课程资料也将环境变量定义为进程执行代码过程中临时存放的信息变量。
五十六、PATH 环境变量
例如:
ls
我们没有写:
/bin/ls
为什么系统知道在哪里?
因为:
PATH
例如:
/usr/local/bin
/usr/bin
/bin
Shell 会在这些目录中查找可执行文件。
因此:
execlp("ls", "ls", NULL);
中的:
p
就和 PATH 有关系。
五十七、查看环境变量
Shell 中:
env
可以查看环境变量。
例如:
echo $PATH
echo $HOME
echo $PWD
C 语言中也可以通过环境变量相关接口进行操作。
例如:
extern char **environ;
课程资料中也给出了:
extern char **environ;
这一环境变量接口。
五十八、进程常用命令总结
这一节需要掌握的 Linux 命令:
ps
ps -ef
ps -aux
pstree
top
kill
killall
jobs
fg
以及后台执行:
./a.out &
课程资料中也列出了后台执行、查看后台任务、前后台切换以及进程优先级相关命令。
五十九、kill ------ 发送信号
例如:
kill PID
可以向指定进程发送信号。
强制终止:
kill -9 PID
也可以:
killall -9 进程名
课程资料中也给出了:
kill -9 进程PID
killall -9 进程名
等操作。
六十、后台运行进程
例如:
./a.out &
最后的:
&
表示:
后台运行。
查看后台任务:
jobs
将后台任务放到前台:
fg 编号
六十一、进程完整生命周期
现在可以把进程的整个生命周期串起来:
程序
│
↓
创建进程
│
fork()
│
↓
就绪/运行
│
↓
CPU调度
│
┌────────┴────────┐
│ │
等待资源 继续运行
│ │
└────────┬────────┘
↓
exit()
│
↓
进程结束
│
父进程wait
│
↓
资源回收
│
↓
消亡
如果父进程没有及时回收:
exit()
↓
Z
↓
僵尸进程
总结
| 知识点 | 核心内容 |
|---|---|
| 程序 | 存储在磁盘上的代码和数据 |
| 进程 | 程序的一次动态执行 |
| PID | 当前进程的 ID |
| PPID | 父进程 ID |
fork() |
创建子进程 |
getpid() |
获取自己的 PID |
getppid() |
获取父进程 PID |
exit() |
结束进程 |
wait() |
等待并回收子进程 |
waitpid() |
等待并回收指定子进程 |
exec() |
执行另一份程序代码 |
| 僵尸进程 | 子进程结束但父进程尚未回收 |
| 孤儿进程 | 父进程结束但子进程仍在运行 |
ps |
查看进程 |
pstree |
查看进程关系 |
kill |
向进程发送信号 |