我们平时编写的 C/C++ 程序,本质上只是磁盘上的一个可执行文件。

例如:

./mytest

当我们运行这个程序后,它才真正被加载到内存中执行。

那么:

程序运行起来以后,操作系统是如何管理它的?
为什么一个程序可以同时运行很多份?
操作系统又如何区分这些正在运行的程序?

这就涉及 Linux 中一个非常重要的概念——进程。

1.进程概念

进程是一个程序执行的实例,正在执行的程序
程序和进程不是一回事
我们首先需要区分两个很容易混淆的概念:程序和进程
程序本质上是存放在磁盘中的一份可执行文件。
例如我们编译得到:

test

此时 test 只是磁盘上的一个文件,其中保存着程序的代码和相关数据,它本身并没有真正执行。
当我们输入:

./test

操作系统会将程序运行所需要的代码和数据加载到内存,并开始执行。
这时,系统中就产生了一个进程(Process)。

因此可以简单理解:

  • 程序:存放在磁盘上的静态文件

  • 进程:程序运行起来以后形成的动态执行实体

为什么不能直接把进程理解成“运行中的程序”?

假设我们打开两个终端,都运行:

./test

此时运行的明明是同一个 test 程序,但是操作系统中会存在两个不同的进程。

磁盘中的 test 可执行程序

第一次运行 ./test

第二次运行 ./test

进程 A
PID=1234

进程 B
PID=1235

虽然两个进程执行的是同一份程序代码,但是它们是两个独立的执行实体
比如:

  • 它们有不同的 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。

test 可执行程序

第一次运行 ./test

第二次运行 ./test

进程 A
PID = 4268

进程 B
PID = 4271

因此,我们可以得到一个结论:

同一个程序可以产生多个进程,而不同进程拥有不同的 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

Bash 进程
PID = 3157

test 进程
PID = 4268
PPID = 3157

对于 test 进程来说:

PID  = 4268
PPID = 3157

也就是说:

  • 4268test 自己的进程 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,系统调用。

简单来说:

系统调用就是用户程序向操作系统内核请求服务的一种方式。

可以简单理解为:

发起系统调用

返回结果

用户程序

Linux 内核

进程 / 文件 / 内存 / 设备

例如以后经常会接触到:

接口 作用
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() 之后:

父进程
+
子进程

都会继续执行 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

也就是说:

fork

父进程

子进程

返回子进程 PID

返回 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 创建子进程

父子进程暂时共享部分物理页面

是否发生写操作

继续共享

为写入方建立独立页面

修改自己的数据

因此需要区分两个概念:

父子进程的虚拟地址空间是独立的。

但这并不意味着:

fork 的瞬间就把所有物理内存完整复制了一遍。

Linux 可以通过写时拷贝减少大量不必要的内存复制,提高 fork() 的效率。


Logo

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

更多推荐