【Linux】进程基础
我们平时编写的 C/C++ 程序,本质上只是磁盘上的一个可执行文件。
例如:
./mytest
当我们运行这个程序后,它才真正被加载到内存中执行。
那么:
程序运行起来以后,操作系统是如何管理它的?
为什么一个程序可以同时运行很多份?
操作系统又如何区分这些正在运行的程序?
这就涉及 Linux 中一个非常重要的概念——进程。
1.进程概念
进程是一个程序执行的实例,正在执行的程序
程序和进程不是一回事
我们首先需要区分两个很容易混淆的概念:程序和进程。
程序本质上是存放在磁盘中的一份可执行文件。
例如我们编译得到:
test
此时 test 只是磁盘上的一个文件,其中保存着程序的代码和相关数据,它本身并没有真正执行。
当我们输入:
./test
操作系统会将程序运行所需要的代码和数据加载到内存,并开始执行。
这时,系统中就产生了一个进程(Process)。
因此可以简单理解:
-
程序:存放在磁盘上的静态文件
-
进程:程序运行起来以后形成的动态执行实体
为什么不能直接把进程理解成“运行中的程序”?
假设我们打开两个终端,都运行:
./test
此时运行的明明是同一个 test 程序,但是操作系统中会存在两个不同的进程。
虽然两个进程执行的是同一份程序代码,但是它们是两个独立的执行实体。
比如:
- 它们有不同的 PID;
- 当前执行到的位置可能不同;
- 使用的内存不同;
- 打开的文件可能不同;
- 当前的运行状态可能不同。
所以:
一个程序可以对应多个进程。
此时运行的明明是同一个 test 程序,但是操作系统中会存在两个不同的进程。
2. PCB:操作系统如何描述一个进程
前面我们知道,程序运行起来以后就会形成进程。
但是对于操作系统来说,仅仅知道“这个程序正在运行”显然还不够。
Linux 中可能同时存在成百上千个进程,那么操作系统至少需要知道:
- 这个进程是谁?
- 它的父进程是谁?
- 当前处于什么状态?
- 程序执行到哪里了?
- 它占用了哪些内存?
- 打开了哪些文件?
- 调度优先级是多少?
也就是说,操作系统必须保存大量与进程相关的信息。
那么这些信息应该存放在哪里呢?
这就要引出一个非常重要的概念:PCB。
2.1 什么是 PCB
PCB 的全称是:
Process Control Block,进程控制块。
PCB 可以理解为操作系统为了描述和管理一个进程,而保存的一组管理信息。
例如,一个 PCB 中通常需要记录:
PID 进程ID
PPID 父进程ID
进程状态 运行、睡眠、停止等
CPU上下文 程序当前执行位置、寄存器信息等
调度信息 优先级等
内存信息 进程使用的内存空间
文件信息 进程打开的文件
信号信息 与信号处理有关的数据
......
因此,可以简单理解为:
PCB 就像一个进程的“档案”。
程序本身负责告诉 CPU “要执行什么”,而 PCB 则记录操作系统管理这个进程时所需要的信息。
例如:
进程
│
↓
┌────────────────┐
│ PCB │
├────────────────┤
│ PID │
│ PPID │
│ 进程状态 │
│ CPU上下文 │
│ 调度信息 │
│ 内存信息 │
│ 文件信息 │
│ ...... │
└────────────────┘
│
↓
操作系统管理
这样,当操作系统需要调度、暂停、恢复或者终止某个进程时,就可以通过这些信息完成对应的管理工作。
2.2 Linux 中的 PCB
需要注意的是,PCB 是操作系统中的通用概念。
Linux 内核中并没有简单地定义一个名为 PCB 的结构体。
在 Linux 中,描述一个进程的核心数据结构叫做:
struct task_struct
我们在学习阶段,可以把 task_struct 理解为 Linux 中承担 PCB 核心作用的数据结构。
为了方便理解,可以先把它简化想象成下面这样:
struct task_struct
{
pid_t pid; // 进程ID
long state; // 进程状态
int priority; // 调度优先级
// 内存相关信息
// 文件相关信息
// 信号相关信息
// 父子进程关系
// CPU上下文
// ......
};
需要说明的是,上面的代码只是为了帮助理解而写的简化示意,并不是 Linux 内核中 task_struct 的完整定义。
真实的 task_struct 要复杂得多,其中包含了大量用于进程管理的数据。
2.3 操作系统如何管理大量进程
操作系统管理进程时采用了一个非常重要的思想:
先描述,再组织。
首先,通过 PCB 描述每一个进程。
例如系统中存在三个进程:
进程A ───→ PCB_A
进程B ───→ PCB_B
进程C ───→ PCB_C
每个 PCB 中都保存着对应进程的信息。
然后,操作系统再将这些描述进程的数据结构组织起来。
这样,当操作系统需要:
- 创建进程
- 调度进程
- 切换进程
- 阻塞进程
- 唤醒进程
- 终止进程
就可以通过操作这些描述进程的数据结构完成。
因此,我们经常会说:
操作系统对进程的管理,本质上就是对描述进程的数据结构进行管理。
在 Linux 中,这个核心数据结构就是 task_struct。
到了这里,我们已经知道操作系统能够通过 task_struct 描述一个进程。
那么问题又来了:
系统中存在这么多进程,Linux 又是如何区分不同进程的呢?
这就要涉及进程的一个重要标识:PID(进程 ID)。
3. 进程标识符:PID 与 PPID
前面我们提到,Linux 会使用 task_struct 来描述一个进程,并在其中保存进程的各种信息。
但是,系统中可能同时存在成百上千个进程,那么 Linux 是如何区分这些进程的呢?
很简单,给每个进程一个编号。
这个编号就是 PID。
3.1 PID:进程的唯一标识
PID 的全称是:
Process ID,进程 ID。
在同一个 PID 命名空间中,每一个正在运行的进程都会拥有自己的 PID,操作系统可以通过 PID 来区分不同的进程。
例如,我们连续运行两次同一个程序:
./test
虽然两次运行执行的都是同一个 test 程序,但是对于操作系统来说,它们是两个独立的进程,因此会拥有不同的 PID。
因此,我们可以得到一个结论:
同一个程序可以产生多个进程,而不同进程拥有不同的 PID。
需要注意的是,PID 并不是永久绑定在某个进程上的。
当一个进程结束后,它原来使用的 PID 在之后可能会被新的进程重新使用。
因此,更准确地说:
PID 用于唯一标识当前系统中存在的某个进程。
3.2 获取当前进程的 PID
在 Linux 中,可以通过 getpid() 函数获取当前进程的 PID。
使用该函数需要包含头文件:
#include <unistd.h>
我们来看一个简单的程序:
#include <stdio.h>
#include <unistd.h>
int main()
{
printf("PID = %d\n", getpid());
while(1)
{
sleep(1);
}
return 0;
}
编译并运行:
gcc test.c -o test
./test
程序可能输出:
PID = 4268
其中:
getpid()
返回的就是当前进程的 PID。
如果我们重新打开一个终端,再运行一次:
./test
可能得到:
PID = 4273
可以看到,即使运行的是同一份程序,两次运行产生的进程 PID 也不同。
3.3 PPID:父进程的 PID
除了 PID 之外,进程还有另外一个比较重要的信息:
PPID(Parent Process ID),父进程 ID。
Linux 中的进程之间通常存在父子关系。
例如,我们在 Bash 中执行:
./test
可以简单理解为:
对于 test 进程来说:
PID = 4268
PPID = 3157
也就是说:
4268是test自己的进程 ID;3157是它的父进程 ID。
因此:
PID 表示“我是谁”,PPID 表示“我的父进程是谁”。
至于父进程究竟是如何创建子进程的,我们后面学习 fork() 时还会继续讨论。
3.4 获取父进程的 PID
Linux 提供了:
getppid()
用于获取当前进程的父进程 PID。
我们可以将前面的程序稍作修改:
#include <stdio.h>
#include <unistd.h>
int main()
{
while(1)
{
printf("PID = %d, PPID = %d\n",
getpid(), getppid());
sleep(1);
}
return 0;
}
编译并运行:
gcc test.c -o test
./test
程序可能输出:
PID = 4268, PPID = 3157
PID = 4268, PPID = 3157
PID = 4268, PPID = 3157
其中:
getpid()
用于获取当前进程的 PID。
而:
getppid()
用于获取当前进程的父进程 PID。
3.5 使用 ps 查看进程 PID
除了通过程序获取 PID,我们还可以直接使用 Linux 命令查看系统中的进程。
例如:
ps -ef | grep test
可能得到:
user 4268 3157 0 14:32 pts/0 00:00:00 ./test
user 4310 4250 0 14:33 pts/1 00:00:00 grep test
其中,对于 test 进程:
PID = 4268
PPID = 3157
这和我们通过:
getpid()
getppid()
获取到的结果是对应的。
由此也可以看出,PID 不只是程序内部使用的数据,我们在 Linux 日常进程管理中也会频繁使用它。
例如后面学习:
kill
终止进程时,就需要通过 PID 指定要操作哪个进程。
4. 系统调用与 fork 创建进程
前面我们已经知道,每一个进程都有自己的 PID,Linux 也会通过 task_struct 等数据结构来描述和管理进程。
那么新的问题来了:
进程是怎么被创建出来的?
例如我们在终端中执行:
./test
一个新的进程就运行起来了。
但是普通程序并没有权限直接操作 Linux 内核中的进程管理结构,因此创建进程这样的操作,必须由操作系统内核完成。
这就涉及一个新的概念:系统调用。
4.1 什么是系统调用
我们平时编写的程序,并不能随意访问操作系统中的所有资源。
例如:
- 创建进程
- 打开文件
- 读取文件
- 操作内存
- 访问设备
- 网络通信
这些操作都涉及操作系统管理的资源。
因此,Linux 提供了一系列接口,让用户程序能够向操作系统请求服务,这种机制就叫做:
System Call,系统调用。
简单来说:
系统调用就是用户程序向操作系统内核请求服务的一种方式。
可以简单理解为:
例如以后经常会接触到:
| 接口 | 作用 |
|---|---|
fork() |
创建子进程 |
wait() |
等待子进程 |
exec() |
程序替换 |
open() |
打开文件 |
read() |
读取数据 |
write() |
写入数据 |
在这一部分,我们首先来看与进程创建有关的 fork()。
4.2 fork 创建子进程
Linux 中可以使用 fork() 创建一个新的进程。
使用 fork() 需要包含头文件:
#include <unistd.h>
函数原型:
pid_t fork(void);
调用 fork() 的进程称为:
父进程(Parent Process)
通过 fork() 创建出来的新进程称为:
子进程(Child Process)
先来看一个最简单的程序:
#include <stdio.h>
#include <unistd.h>
int main()
{
printf("before fork\n");
fork();
printf("after fork\n");
return 0;
}
编译并运行:
gcc test.c -o test
./test
可能看到:
before fork
after fork
after fork
这里就出现了一个比较有意思的现象:
明明程序中只有一句:
printf("after fork\n");
为什么却打印了两次?
原因就在:
fork();
执行成功以后,系统中已经存在两个进程:
fork() 之前只有一个进程。
而 fork() 之后:
父进程
+
子进程
都会继续执行 fork() 后面的代码。
所以:
printf("after fork\n");
会分别被父进程和子进程执行一次。
这也是为什么最终会打印两次。
4.3 fork 的返回值
fork() 有一个非常特殊的地方:
一次调用,在父进程和子进程中会得到不同的返回值。
返回值情况如下:
| 返回值 | 含义 |
|---|---|
> 0 |
当前是父进程,返回值为子进程 PID |
= 0 |
当前是子进程 |
< 0 |
创建子进程失败 |
因此,我们可以利用 fork() 的返回值区分父进程和子进程。
例如:
#include <stdio.h>
#include <unistd.h>
int main()
{
pid_t ret = fork();
if(ret < 0)
{
printf("fork failed\n");
}
else if(ret == 0)
{
printf("我是子进程,PID = %d,PPID = %d\n",
getpid(), getppid());
}
else
{
printf("我是父进程,PID = %d,子进程PID = %d\n",
getpid(), ret);
}
return 0;
}
运行结果可能类似:
我是父进程,PID = 4268,子进程PID = 4269
我是子进程,PID = 4269,PPID = 4268
可以看到:
父进程 PID = 4268
子进程 PID = 4269
子进程 PPID = 4268
这里正好和前面学习的 PID、PPID 对应起来。
对于子进程来说:
PPID
↓
父进程的 PID
因此,前面学习的父子进程关系,在这里就真正体现出来了。
4.4 为什么 fork 一次会有两个返回值
第一次接触 fork() 时,一个很容易让人困惑的问题就是:
pid_t ret = fork();
明明只调用了一次函数,为什么会出现两个返回值?
实际上并不是 fork() 在同一个进程中返回了两次。
而是执行 fork() 之后,系统中出现了:
父进程
子进程
两个进程。
两个进程都会从 fork() 返回,并继续执行后面的代码。
因此:
父进程中的 fork()
↓
返回子进程 PID
子进程中的 fork()
↓
返回 0
也就是说:
通过这个返回值,我们就可以控制父子进程执行不同的代码:
pid_t ret = fork();
if(ret == 0)
{
// 子进程执行的代码
}
else if(ret > 0)
{
// 父进程执行的代码
}
这是 Linux 多进程程序中非常常见的一种写法。
4.5 fork 后谁先运行
现在还有一个问题:
fork() 创建子进程以后,到底是父进程先运行,还是子进程先运行?
例如:
#include <stdio.h>
#include <unistd.h>
int main()
{
pid_t ret = fork();
if(ret == 0)
{
while(1)
{
printf("child\n");
sleep(1);
}
}
else
{
while(1)
{
printf("parent\n");
sleep(1);
}
}
return 0;
}
我们可能看到:
parent
child
parent
child
...
也可能在某些情况下看到:
child
parent
child
parent
...
因此:
fork 之后,父进程和子进程谁先运行是不确定的。
因为父进程和子进程都是独立的进程,都需要由操作系统的调度器决定什么时候获得 CPU。
所以不能根据某一次程序的运行结果,就认为:
父进程一定先执行
或者:
子进程一定先执行
它们具体的执行顺序取决于操作系统的调度。
4.6 fork 后父子进程的数据
fork() 创建子进程以后,子进程会继承父进程当时的大量运行环境。
例如:
#include <stdio.h>
#include <unistd.h>
int main()
{
int num = 10;
pid_t ret = fork();
if(ret == 0)
{
num = 20;
printf("child: num = %d, address = %p\n",
num, (void*)&num);
}
else
{
num = 30;
printf("parent: num = %d, address = %p\n",
num, (void*)&num);
}
return 0;
}
最终我们会发现:
父进程修改 num
不会直接修改子进程中的 num
子进程修改 num
也不会直接修改父进程中的 num
这是因为:
父进程和子进程拥有各自独立的虚拟地址空间。
从进程的角度来看:
父进程
└── num
子进程
└── num
两者互不直接影响。
但是这里还有一个有意思的问题。
如果父进程本身占用了大量内存,那么执行一次 fork(),Linux 难道真的会立刻把父进程所有数据完整复制一遍吗?
实际上并不会这么简单粗暴。
这就涉及 Linux 中一个非常重要的机制:
写时拷贝(Copy-On-Write,COW)。
4.7 写时拷贝
如果每次 fork() 都立即完整复制父进程的所有物理内存,开销会非常大。
例如,一个进程已经使用了数百 MB 内存,如果创建子进程时全部复制一次,会造成大量不必要的资源消耗。
因此 Linux 会使用:
Copy-On-Write,写时拷贝。
简单理解就是:
刚执行 fork() 时,父子进程可以暂时共享部分底层物理页面。
父进程 ──┐
├──→ 暂时共享物理页面
子进程 ──┘
如果父子进程都只是读取数据,就没有必要立即复制。
只有当某一方尝试修改数据时,系统才会为需要写入的一方准备独立的物理页面。
因此需要区分两个概念:
父子进程的虚拟地址空间是独立的。
但这并不意味着:
fork 的瞬间就把所有物理内存完整复制了一遍。
Linux 可以通过写时拷贝减少大量不必要的内存复制,提高 fork() 的效率。
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐


所有评论(0)