第二章 进程
导读
进程(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 的一些关键字段:
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。
#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和某些其他属性。
#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() 获取子进程的退出状态并回收其资源:
#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 命令查看进程树:
pstree2.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 查看进程信息
# 查看所有进程
ps aux
# 以树形结构查看进程
pstree -p
# 实时监控进程
top
# 查看进程的详细信息
cat /proc/[pid]/status2.10.2 进程控制实践
#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 理解进程地址空间
#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调度。