02

进程

运行中的程序

阅读量:5 · 预计 15 分钟读完

进程地址空间上下文切换
关联层级:L5 虚拟机代码
阅读进度4%

第二章 进程

导读

进程(Process)是操作系统中最核心的概念之一。当我们说"运行一个程序"时,实际上是在说"创建一个进程"。进程不仅包含程序的代码,还包含程序运行所需的全部状态信息——CPU寄存器的值、内存的分配、打开的文件、信号的处理器等。可以说,进程是程序在某个时刻的"快照",是操作系统进行资源分配和调度的基本单位。

本章将深入探讨进程的概念、结构、生命周期和管理机制。我们将从进程的地址空间开始,逐步了解进程控制块(PCB)、进程状态转换、进程创建与终止、上下文切换等核心内容。理解进程的工作原理,是掌握CPU调度、内存管理、并发编程等后续章节的基础。


2.1 进程的概念

2.1.1 从程序到进程

程序(Program)是一组存储在磁盘上的指令和数据的集合,它是静态的、被动的。而进程(Process)是程序的一次执行实例,它是动态的、主动的。同一个程序可以被多次执行,产生多个进程;每个进程都有自己独立的执行状态和资源。

举个例子:文本编辑器 vim 是一个程序,存储在 /usr/bin/vim 文件中。当你打开终端输入 vim 命令时,操作系统会创建一个进程来执行这个程序。如果你同时打开两个终端窗口分别运行 vim,就会有两个独立的 vim 进程,它们各自有独立的内存空间和执行状态。

2.1.2 进程的定义

一个进程可以定义为:一个正在执行的程序的实例,加上与之相关的所有状态信息。这些状态信息包括:

  • CPU寄存器的内容:程序计数器(PC)、栈指针(SP)、通用寄存器等。
  • 内存的内容:代码段、数据段、堆、栈的内容。
  • I/O状态:打开的文件描述符列表、等待的I/O操作等。
  • 进程的状态:运行、就绪、阻塞等。
  • 调度信息:优先级、CPU使用时间等。
  • 记账信息:进程使用的CPU时间总量、进程号(PID)等。

2.1.3 进程与程序的区别

特性程序进程
存在形式静态文件(存储在磁盘上)动态实体(在内存中执行)
生命周期长期存在有限(创建→运行→终止)
资源占用仅占用磁盘空间占用CPU、内存、I/O等资源
对应关系一个程序可对应多个进程一个进程对应一个程序(或程序的一部分)
状态变化无状态有状态(运行、就绪、阻塞等)

2.2 进程的地址空间

2.2.1 地址空间的概念

地址空间(Address Space)是进程可以访问的所有内存地址的集合。操作系统为每个进程创建独立的地址空间,使得每个进程都认为自己独占了整个内存。这种抽象是操作系统虚拟化的重要体现。

在32位系统中,地址空间的大小为 2^32 = 4GB。在64位系统中,理论上的地址空间大小为 2^64 = 16EB(Exabyte),但实际实现中通常只使用了其中的一部分(如Linux在x86-64上使用48位地址,即256TB)。

2.2.2 地址空间的布局

一个典型的进程地址空间包含以下区域:

代码段(Text Segment / Code Segment)

  • 存放程序的可执行机器指令
  • 通常是只读的,防止程序意外修改自己的代码
  • 位置固定在地址空间的低地址区域

数据段(Data Segment)

  • 存放已初始化的全局变量和静态变量
  • 可读写
  • 紧跟在代码段之后

BSS段(Block Started by Symbol)

  • 存放未初始化的全局变量和静态变量
  • 程序加载时自动初始化为零
  • 不占用文件的实际空间(只在内存中分配)

堆(Heap)

  • 用于动态内存分配(malloc()new 等)
  • 从低地址向高地址增长
  • 大小不固定,可以根据需要扩展或收缩

栈(Stack)

  • 用于函数调用,保存局部变量、参数和返回地址
  • 从高地址向低地址增长
  • 每个线程有自己的栈
  • 大小通常有限制(Linux默认8MB)

内核空间

  • 地址空间的高地址部分保留给操作系统内核
  • 用户程序不能直接访问
  • 包含内核代码、数据和内核栈

2.2.3 地址空间的隔离

操作系统通过硬件机制(如内存管理单元MMU)确保每个进程的地址空间是隔离的。进程A不能访问进程B的内存,也不能访问内核的内存(除非通过系统调用)。这种隔离保证了系统的安全性和稳定性——一个进程的bug不会影响到其他进程或操作系统本身。


2.3 进程控制块(PCB)

2.3.1 PCB 的概念

进程控制块(Process Control Block, PCB)是操作系统为每个进程维护的数据结构,它包含了操作系统管理进程所需的全部信息。PCB 是进程存在的唯一标志——当一个进程被创建时,操作系统为它分配一个PCB;当进程终止时,操作系统回收它的PCB。

2.3.2 PCB 的内容

一个典型的PCB包含以下信息:

进程标识信息

  • PID(Process ID):进程的唯一标识符,是一个正整数。
  • PPID(Parent Process ID):父进程的PID。
  • UID(User ID):创建进程的用户标识。

处理器状态信息

  • 通用寄存器:如 x86 的 EAX、EBX、ECX、EDX 等。
  • 程序计数器(PC):下一条要执行的指令的地址。
  • 状态寄存器(PSW):包含条件码、中断使能位等。
  • 栈指针(SP):指向进程栈的顶部。

进程调度信息

  • 进程状态:当前进程的状态(运行、就绪、阻塞等)。
  • 优先级:进程的调度优先级。
  • 调度队列指针:指向进程在调度队列中的下一个进程。
  • CPU使用时间:进程已使用的CPU时间总量。

内存管理信息

  • 基址寄存器:进程地址空间的起始物理地址。
  • 页表:虚拟地址到物理地址的映射表。
  • 内存限制:进程可以使用的最大内存量。

I/O状态信息

  • 打开文件列表:进程打开的所有文件描述符。
  • I/O设备列表:分配给进程的设备。
  • 等待的I/O操作:进程正在等待的I/O事件。

2.3.3 PCB 在 Linux 中的实现

在 Linux 内核中,进程控制块由 task_struct 结构体表示。它定义在 <linux/sched.h> 中,包含上百个字段,涵盖了进程管理的所有方面。以下是 task_struct 的一些关键字段:

c
struct task_struct {
    volatile long state;        // 进程状态
    long priority;              // 优先级
    struct mm_struct *mm;       // 内存描述符
    pid_t pid;                  // 进程ID
    pid_t tgid;                 // 线程组ID
    struct task_struct *parent; // 父进程
    struct list_head children;  // 子进程链表
    struct files_struct *files; // 打开的文件
    ...
};

2.4 进程的状态与转换

2.4.1 进程的五状态模型

进程在其生命周期中会经历不同的状态。经典的五状态模型包括:

创建(New):进程正在被创建,操作系统正在为其分配资源、初始化PCB。

就绪(Ready):进程已准备好运行,等待CPU分配。就绪进程被放在就绪队列中。

运行(Running):进程正在CPU上执行指令。

阻塞(Blocked/Waiting):进程因等待某个事件(如I/O完成、等待信号量、等待子进程)而暂停执行。

终止(Terminated):进程执行完毕或被强制终止,操作系统正在回收其资源。

2.4.2 状态转换

进程在不同状态之间的转换由操作系统控制:

  • 创建 → 就绪:进程创建完成,所有资源分配就绪,进入就绪队列。
  • 就绪 → 运行:调度器选择一个就绪进程,分配CPU给它。
  • 运行 → 就绪:时间片用完、被更高优先级进程抢占、或主动让出CPU(yield())。
  • 运行 → 阻塞:进程发起I/O请求、等待某个事件、或调用 sleep() 等。
  • 阻塞 → 就绪:等待的事件发生(如I/O完成),进程被唤醒进入就绪队列。
  • 运行 → 终止:进程执行完毕(正常退出)或发生错误(异常终止)。

2.4.3 挂起状态

在某些系统中,还引入了挂起(Suspended) 状态。当系统内存紧张时,操作系统可以将某些就绪或阻塞进程换出到磁盘(Swap Space),此时进程处于挂起状态。挂起状态分为:

  • 就绪挂起(Suspended Ready):进程原本就绪,被换出到磁盘。
  • 阻塞挂起(Suspended Blocked):进程原本阻塞,被换出到磁盘。

当内存有空闲时,挂起的进程可以被换回内存,恢复为就绪或阻塞状态。


2.5 进程的创建

2.5.1 进程创建的触发

进程的创建可以由以下事件触发:

系统初始化:操作系统启动时,会创建一系列系统进程(如init进程、守护进程等)。

用户请求:用户在Shell中输入命令,Shell会创建新进程来执行该命令。

程序请求:正在运行的程序调用 fork()exec() 等系统调用来创建新进程。

2.5.2 fork() 系统调用

在 UNIX/Linux 系统中,新进程通过 fork() 系统调用创建。fork() 的特殊之处在于:它被调用一次,但返回两次——在父进程中返回子进程的PID,在子进程中返回0。

c
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>

int main() {
    pid_t pid = fork();
    if (pid < 0) {
        // fork 失败
        perror("fork failed");
        return 1;
    } else if (pid == 0) {
        // 子进程
        printf("我是子进程,我的PID是 %d\n", getpid());
    } else {
        // 父进程
        printf("我是父进程,我的PID是 %d,子进程PID是 %d\n", getpid(), pid);
    }
    return 0;
}

2.5.3 写时复制(Copy-on-Write)

早期的 fork() 实现会完整复制父进程的整个地址空间给子进程,这在进程很大时非常低效。现代操作系统普遍采用写时复制(Copy-on-Write, COW) 技术:fork() 后,父子进程共享相同的物理内存页,只有当某一方尝试修改某个页面时,操作系统才会为该方创建一个独立的副本。

写时复制极大地减少了 fork() 的开销,特别是在 fork() 之后立即调用 exec() 的场景(如Shell执行命令)——子进程根本不需要复制父进程的内存,因为 exec() 会立即替换子进程的地址空间。

2.5.4 exec() 系列函数

fork() 创建的子进程是父进程的副本。如果要在子进程中运行不同的程序,需要调用 exec() 系列函数。exec() 会用新的程序替换当前进程的地址空间(代码段、数据段、堆、栈),但保留进程ID和某些其他属性。

c
#include <unistd.h>

// exec 的几种形式
int execl(const char *path, const char *arg, ...);
int execv(const char *path, char *const argv[]);
int execlp(const char *file, const char *arg, ...);
int execvp(const char *file, char *const argv[]);

fork() + exec() 的组合是 UNIX 创建新进程的标准模式:fork() 创建子进程,exec() 让子进程执行新的程序。


2.6 进程的终止

2.6.1 正常终止

进程可以通过以下方式正常终止:

main() 函数返回main() 函数的返回值作为进程的退出状态码。

调用 `exit()`:显式终止进程,exit() 会执行清理操作(关闭文件、刷新缓冲区等)。

调用 `_exit()` 或 `_Exit()`:立即终止进程,不执行清理操作。

2.6.2 异常终止

进程也可以因为以下原因异常终止:

调用 `abort()`:向自身发送 SIGABRT 信号。

接收到信号:如 SIGSEGV(段错误)、SIGKILL(强制终止)、SIGFPE(浮点异常)等。

未处理的异常:如除以零、访问非法内存地址等。

2.6.3 僵尸进程与孤儿进程

僵尸进程(Zombie Process):当子进程终止但父进程尚未调用 wait()waitpid() 获取其退出状态时,子进程进入僵尸状态。僵尸进程不占用内存和CPU,但仍占用一个进程ID和进程表条目。如果父进程长期不回收僵尸进程,可能导致进程ID耗尽。

孤儿进程(Orphan Process):当父进程终止而子进程仍在运行时,子进程成为孤儿进程。操作系统会将孤儿进程交给 init 进程(PID=1)作为其新的父进程,由 init 负责回收其退出状态。

2.6.4 wait() 和 waitpid()

父进程通过 wait()waitpid() 获取子进程的退出状态并回收其资源:

c
#include <sys/wait.h>

pid_t wait(int *status);
pid_t waitpid(pid_t pid, int *status, int options);

wait() 会阻塞调用进程,直到任意一个子进程终止。waitpid() 可以指定等待特定的子进程,并支持非阻塞模式。


2.7 上下文切换

2.7.1 什么是上下文切换

上下文切换(Context Switch)是操作系统将一个正在运行的进程切换到另一个进程的过程。在上下文切换期间,操作系统需要:

保存当前进程的上下文:将CPU寄存器(包括程序计数器)的值保存到当前进程的PCB中。

加载下一个进程的上下文:从下一个进程的PCB中恢复CPU寄存器的值。

更新页表:切换内存映射(如果使用不同的地址空间)。

刷新TLB:清除地址转换缓存(如果硬件不自动处理)。

2.7.2 上下文切换的开销

上下文切换是有代价的,主要包括:

  • 直接开销:保存和恢复寄存器的时间(几十到几百个CPU周期)。
  • 间接开销:TLB刷新导致的地址转换开销增加、缓存失效(Cache Miss)增加。
  • 调度开销:运行调度算法选择下一个进程的时间。

在现代处理器上,一次上下文切换的直接开销大约在几微秒到几十微秒之间。但由于缓存和TLB的间接影响,实际性能损失可能更大。

2.7.3 减少上下文切换的策略

为了减少上下文切换的开销,操作系统采用了多种策略:

增大时间片:减少切换频率,但会增加响应延迟。

亲和性调度(Affinity Scheduling):尽量让进程在同一个CPU核心上运行,减少TLB和缓存的失效。

用户级线程:在用户空间实现线程切换,避免陷入内核态。

协程(Coroutine):由程序自己控制切换时机,避免不必要的切换。


2.8 进程的组织结构

2.8.1 进程树

在 UNIX/Linux 系统中,所有进程组织为一棵进程树。除了最初的 init 进程(PID=1),所有进程都是由其他进程通过 fork() 创建的。init 进程是进程树的根节点。

可以使用 pstree 命令查看进程树:

bash
pstree

2.8.2 进程组与会话

进程组(Process Group):一组相关进程的集合,通常用于信号的分发。每个进程组有一个组长进程(其PID等于进程组ID)。Shell中的管道命令(如 ls | grep foo)中的所有进程属于同一个进程组。

会话(Session):一组相关进程组的集合。一个典型的会话对应一个终端登录会话。会话中的第一个进程称为会话首(Session Leader)。


2.9 常见误区

误区一:进程就是程序

进程和程序是两个不同的概念。程序是静态的代码文件,进程是动态的执行实例。同一个程序可以同时运行多个进程,每个进程有独立的地址空间和状态。

误区二:fork() 会完整复制父进程的所有内存

现代操作系统使用写时复制(COW)技术,fork() 后父子进程共享物理内存页,只有在写入时才复制。如果 fork() 后立即 exec(),则完全不需要复制内存。

误区三:进程终止后资源立即被回收

子进程终止后进入僵尸状态,其进程表条目仍然保留,直到父进程调用 wait() 回收。如果父进程不回收,就会形成僵尸进程。

误区四:上下文切换的开销可以忽略

虽然单次上下文切换的直接开销不大(几微秒),但在高并发场景下,频繁的上下文切换会累积显著的性能损失。特别是在缓存和TLB方面,间接开销可能远超直接开销。

误区五:一个进程只能有一个执行流

一个进程可以包含多个线程,每个线程有独立的栈和寄存器状态,但共享地址空间和文件描述符。多线程是现代应用程序的常见模式。


2.10 实践应用

2.10.1 查看进程信息

bash
# 查看所有进程
ps aux

# 以树形结构查看进程
pstree -p

# 实时监控进程
top

# 查看进程的详细信息
cat /proc/[pid]/status

2.10.2 进程控制实践

c
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>

int main() {
    pid_t pid = fork();
    
    if (pid == 0) {
        // 子进程:执行 ls 命令
        execlp("ls", "ls", "-l", NULL);
        perror("execlp failed");
        exit(1);
    } else if (pid > 0) {
        // 父进程:等待子进程完成
        int status;
        waitpid(pid, &status, 0);
        if (WIFEXITED(status)) {
            printf("子进程正常退出,退出码:%d\n", WEXITSTATUS(status));
        }
    } else {
        perror("fork failed");
        exit(1);
    }
    return 0;
}

2.10.3 理解进程地址空间

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

int global_var = 100;  // 数据段

int main() {
    int local_var = 200;  // 栈
    int *heap_var = malloc(sizeof(int));  // 堆
    *heap_var = 300;
    
    printf("代码段地址: %p\n", main);
    printf("数据段地址: %p\n", &global_var);
    printf("堆地址: %p\n", heap_var);
    printf("栈地址: %p\n", &local_var);
    
    free(heap_var);
    return 0;
}

运行这个程序,可以看到不同内存区域的地址分布。


2.11 本章小结

本章深入探讨了进程的概念和管理机制,主要内容包括:

进程的定义:进程是程序的一次执行实例,包含代码和所有运行状态。进程是操作系统进行资源分配和调度的基本单位。

进程地址空间:每个进程拥有独立的地址空间,包含代码段、数据段、堆、栈等区域。地址空间的隔离保证了进程间的安全。

进程控制块(PCB):操作系统通过PCB管理进程,PCB包含进程标识、处理器状态、调度信息、内存信息和I/O状态。

进程状态:进程经历创建、就绪、运行、阻塞、终止等状态,状态转换由操作系统控制。

进程创建:UNIX/Linux 通过 fork() + exec() 创建新进程。写时复制技术优化了 fork() 的性能。

进程终止:进程可以正常终止或异常终止。僵尸进程和孤儿进程是需要特别注意的特殊情况。

上下文切换:上下文切换是进程调度的基础操作,涉及保存和恢复CPU状态,具有一定的性能开销。

进程是理解操作系统工作原理的基石。在下一章中,我们将探讨操作系统如何在多个进程之间分配CPU时间——即CPU调度。