进程和线程
在多道程序环境下,允许多个程序并发执行,为此操作系统引入了 进程 (Process) 的概念,以便更好地描述和控制程序的并发执行,实现操作系统的并发性。
进程
进程是系统资源分配的基本单位。操作系统以进程为单位建立相对独立的资源环境,例如 虚拟地址空间、打开的文件、访问权限以及其他内核资源。
不同进程通常拥有独立的虚拟地址空间,因此一个进程不能直接访问另一个进程中的数据。同一进程中的多个线程则共享进程所拥有的虚拟地址空间和大部分系统资源。
可以将进程和线程的作用概括为:
- 进程 主要解决“资源归谁所有”的问题,是资源拥有和保护的基本单位。
- 线程 主要解决“由谁执行代码”的问题,是处理机调度和执行的基本单位。
进程和程序
程序是 静态的,进程是 动态的。进程可以理解为程序的一次执行过程,两者的具体区别如下:
- 程序
- 程序是一组指令和数据的静态集合,通常存储在磁盘等持久性存储介质中。
- 程序本身是被动的,不会自行执行。
- 同一个程序可以被多次运行,每次运行通常都会创建一个新的进程。
- 进程
- 进程是程序的一次执行过程。
- 当程序被加载并开始执行时,操作系统会为其创建进程及相应的管理信息。
- 进程是动态的,具有创建、运行、阻塞、终止等生命周期状态。
进程控制块
操作系统通过 进程控制块(PCB,Process Control Block)管理每一个进程。
可以把 PCB 看作操作系统为每个进程建立的一张 管理档案,其中记录了操作系统管理该进程所需的各种信息。只要进程存在,对应的 PCB 就始终保存在内存中,操作系统几乎所有与进程相关的操作都需要访问它。
PCB 主要包含以下几类信息:
| 信息类别 | 典型内容 |
|---|---|
| 进程标识信息 | 进程标识符(PID)、用户标识符(UID) |
| 进程控制信息 | 进程状态、进程优先级、调度信息 |
| 处理机状态信息 | 程序计数器(PC)、通用寄存器、程序状态字(PSW) |
| 资源信息 | 地址空间信息、打开的文件、内存管理信息等 |
各类信息的作用如下:
- 进程标识信息:用于唯一标识和区分不同进程。
- 进程控制信息:供操作系统进行调度和管理,例如记录进程当前处于就绪态、运行态还是阻塞态,以及进程的优先级等。
- 处理机状态信息:保存程序暂停执行时的 CPU 运行现场,例如程序计数器、寄存器和程序状态字等,使程序再次运行时能够从原来的位置继续执行。
- 资源信息:记录进程拥有或使用的系统资源,例如虚拟地址空间、打开的文件等,便于操作系统统一管理和回收。
在传统的 单线程进程模型 中,一个进程只有一个执行流,因此程序计数器、寄存器和程序状态字等处理机现场可以直接记录在 PCB 中。
引入线程后,一个进程中可能存在多个执行流,每个线程都需要分别保存自己的程序计数器、寄存器、栈和调度状态。这些线程级信息通常记录在线程控制块或相应的内核线程结构中,而 PCB 主要保存进程级的地址空间和资源信息。
PCB 在进程的整个生命周期中都会被操作系统使用,主要体现在以下几个方面:
- 进程创建:操作系统为新进程创建对应的 PCB,并初始化其中的各项信息。
- 进程管理:操作系统通过 PCB 记录和维护进程的状态、优先级、资源等信息。
- 进程切换:在传统单线程模型中,操作系统保存当前进程的运行现场,并恢复下一进程的运行现场。
- 进程终止:操作系统释放进程占用的资源,并删除对应的 PCB。
在线程模型中,处理机调度器实际选择的是某个可运行线程,并将其调度到某个逻辑核心上执行。
父子进程
在操作系统中,父子进程 是通过进程创建机制形成的一种层级关系:
- 父进程 :创建新进程的进程。
- 子进程 :由父进程创建的进程。
在 UNIX 和 Linux 系统中,子进程通常由父进程通过 fork 系统调用 创建。
fork 创建子进程后,子进程获得与父进程内容近似相同、但逻辑上独立的虚拟地址空间,包括代码段、数据段、堆和栈。父子进程拥有各自独立的执行状态,一方修改自己地址空间中的数据,通常不会直接影响另一方。
现代操作系统通常采用 写时复制(Copy On Write)机制实现 fork。在 fork 刚完成时,父子进程可以暂时共享相同的物理内存页;只有当父进程或子进程尝试修改某个内存页时,操作系统才真正复制该页。
子进程还会继承父进程打开的文件描述符等部分资源。父子进程也可以通过进程间通信(IPC)机制,例如管道、信号和共享内存,进行数据交换或同步。
fork 系统调用的声明为:
pid_t fork(void);
其返回值可以用于判断当前执行的是父进程还是子进程:
- 在 父进程 中,返回值为子进程的 PID,即返回值大于
0。 - 在 子进程 中,返回值为
0。 - 如果创建失败,则在父进程中返回
-1,不会创建子进程。
fork 调用成功后,父进程和子进程都会从 fork 返回处继续执行,但二者获得的返回值不同,并且可能按照不同的顺序被调度运行。
除了 fork 外,还需要理解 exec 系列调用。两者功能不同:
- fork:创建一个新的子进程。子进程获得父进程地址空间和部分资源的副本,父子进程继续执行原来的程序。
- exec:在当前进程中装入并执行一个新程序,用新程序的代码段、数据段、堆和栈替换原有的进程映像,但通常保留 PID 等进程身份信息。
exec 不创建新的进程,因此不会增加新的并发执行流。exec 调用成功后,原程序不会继续执行;只有调用失败时,exec 才会返回。
此外,父进程可以通过 wait 或 waitpid 系统调用等待子进程终止,并读取子进程的退出状态,从而完成对子进程的回收。
僵尸进程
(Zombie Process)是指一个已经终止,但其父进程尚未通过 wait 或 waitpid 读取其退出状态的子进程。
僵尸进程已经停止运行,其用户地址空间、打开的文件等大部分资源已经被释放,只在内核进程表中保留 PID、退出状态和少量统计信息,等待父进程回收。
孤儿进程 (Orphan Process)是指父进程在子进程终止前退出,使子进程失去了原来的父进程。
孤儿进程本身通常仍在正常运行。它一般会被 init、systemd 或其他子进程收割者接管,并在终止后由新的父进程负责回收。
下表给出了两者的对比:
| 项目 | 僵尸进程(Zombie Process) | 孤儿进程(Orphan Process) |
|---|---|---|
| 状态 | 已经终止 | 仍在运行 |
| 原父进程状态 | 仍然存在,但尚未回收子进程 | 已经终止 |
| 是否占用 CPU | 否 | 可能占用 |
| 主要保留资源 | PID、退出状态和少量进程表信息 | 正常运行所需的各种资源 |
| 处理方式 | 父进程调用 wait() 或 waitpid() 回收 | 通常由其他进程接管 |
线程
线程是处理机调度和执行的基本单位。
进程主要负责拥有虚拟地址空间、打开的文件等系统资源,线程则表示进程内部的一条执行流。操作系统调度时,实际选择的是某个可运行线程,并将其调度到某个 CPU 逻辑核心上执行。
多核处理器可以将不同线程调度到不同的 逻辑核心 上,因此多个线程可以在同一时刻并行运行。
每个进程启动时,至少包含一个线程,通常称为 主线程。主线程可以继续创建其他线程,从而形成多线程进程。
同一进程中的线程共享进程级资源,主要包括:
- 虚拟地址空间;
- 代码段;
- 全局变量和静态变量;
- 堆;
- 打开的文件;
- 其他进程级系统资源。
每个线程则拥有自己独立的执行现场,主要包括:
- 线程标识符;
- 程序计数器;
- 寄存器组;
- 栈;
- 线程状态;
- 调度优先级等调度信息。
线程拥有独立的栈,是指每个线程在进程虚拟地址空间中具有自己的栈区域。由于同一进程中的线程共享虚拟地址空间,一个线程原则上仍可能通过指针访问其他线程栈中的数据,因此线程之间并不存在像进程之间那样严格的地址空间隔离。
在一个进程中没有额外创建线程时,该进程为 单线程进程;如果一个进程中包含多个线程,则称为 多线程进程。
单线程进程
单线程进程只有一个执行线程,因此进程的虚拟地址空间和打开的文件等进程级资源只由该线程使用,不存在进程内部的线程同步问题。
单线程进程具有以下特点:
- 结构简单,程序设计和调试相对容易。
- 不需要处理多个线程之间的共享数据竞争。
- 当唯一线程因等待 I/O 或其他事件而阻塞时,进程中没有其他线程可以继续执行。
- 任一时刻最多只有一个线程在一个逻辑核心上运行,因此不能依靠进程内部的多个线程同时利用多个逻辑核心。
单线程虽然可能在不同时间被调度到不同逻辑核心上运行,但在任一时刻只能占用一个逻辑核心执行。
多线程进程
多线程进程包含多个线程,这些线程共享进程的虚拟地址空间和系统资源,但分别拥有自己的程序计数器、寄存器和栈。
多线程进程具有以下特点:
- 多个线程可以直接访问共享的全局变量、堆和其他进程级资源,通信效率较高。
- 多个线程可以被调度到不同逻辑核心上并行执行,提高处理器利用率。
- 当一个线程阻塞时,其他线程仍有机会继续运行。
- 多个线程同时访问共享数据时,可能发生竞态条件,需要使用互斥锁、信号量等同步机制。
- 同一进程中的线程隔离性较弱,一个线程发生严重错误可能导致整个进程终止。
完整的进程虚拟地址空间布局,以及线程视角下共享区域和私有区域的关系,可参考下图:
引入线程后,一个进程可能同时包含多个执行流,每个线程都拥有各自独立的程序计数器、寄存器和栈,因此这些与执行和调度相关的信息不能再统一保存在 PCB 中,而需要为每个线程分别维护一份管理信息,通常称为 TCB(Thread Control Block,线程控制块)。
因此,在现代操作系统中,可以将 PCB 和 TCB 的职责概括如下:
- PCB(进程控制块):管理整个进程拥有的资源,例如虚拟地址空间、页表、打开的文件、权限等进程级信息。
- TCB(线程控制块):管理单个线程的执行现场,例如程序计数器、寄存器、栈、线程状态以及调度信息等。
二者的关系如下图所示:
可以看出,一个 PCB 对应一个 进程,而一个 进程 可以包含多个 线程,因此一个 PCB 通常会关联多个 TCB。
操作系统在进行资源管理时,主要访问 PCB;而进行线程调度和上下文切换时,则主要访问对应线程的 TCB,保存和恢复该线程的程序计数器、寄存器等运行现场。
进程和线程对比
| 对比项目 | 进程 | 线程 |
|---|---|---|
| 基本作用 | 建立资源拥有和保护边界 | 建立执行流 |
| 基本单位 | 系统资源分配的基本单位 | 处理机调度和执行的基本单位 |
| 虚拟地址空间 | 不同进程通常相互独立 | 同一进程中的线程共享 |
| 打开的文件等资源 | 由进程拥有 | 同一进程中的线程共享 |
| 程序计数器和寄存器 | 单线程模型下只有一套 | 每个线程各自拥有一套 |
| 栈 | 进程中至少有一个线程栈 | 每个线程拥有独立的栈 |
| 通信方式 | 通常需要使用 IPC | 可以直接访问共享地址空间 |
| 切换开销 | 通常较大,可能涉及地址空间切换 | 同一进程内的线程切换通常较小 |
| 隔离性 | 较强 | 较弱 |
| 并行能力 | 依赖进程中可运行线程的数量 | 多个线程可在多个逻辑核心上并行执行 |
因此,可以将进程和线程之间的关系概括为:
进程是资源的拥有者和保护边界,线程是进程内部的执行流和处理机调度对象。
进程的状态
进程状态 是指在操作系统中,一个 进程 在执行过程中的不同阶段。它反映了 进程 当前正在做什么,以及是否可以被 CPU 执行。
状态种类
不同的操作系统对 进程状态 的划分可能不同,但常见的包括以下几种:
- 创建状态(New):当 进程 被创建但还未分配资源或执行时,它处于 创建状态。
- 就绪状态(Ready):在 就绪状态 中,进程 已准备好执行,但由于操作系统调度算法或其他原因,尚未获得 CPU 时间片。
- 运行状态(Running):在 运行状态 中,进程 正在执行指令并占用 CPU。
- 阻塞状态(Blocked):当 进程 在等待某些事件发生时,如等待 I/O 操作完成或等待其他资源时,它会进入 阻塞状态。在 阻塞状态 下,进程 暂停执行,直到等待的事件发生。
- 终止状态(Terminated):当 进程 执行完毕或被操作系统终止时,它进入 终止状态。
状态转化
1. 就绪态 → 运行态
| 触发条件 | 详细说明 |
|---|---|
| 调度器选择进程 | 调度器从就绪队列中选出进程,并分配 CPU。 |
| CPU 空闲 | CPU 空闲时,调度器立即选择就绪进程运行。 |
2. 运行态 → 就绪态
| 触发条件 | 详细说明 |
|---|---|
| 时间片耗尽 | 进程用完时间片,被中断并放回就绪队列。 |
| 抢占 | 更高优先级进程就绪,当前进程被抢占。 |
| 自愿放弃 CPU | 进程主动让出 CPU,重新回到就绪队列。 |
| 非阻塞系统调用返回 | 系统调用未阻塞,进程仍可运行,但可能被调度器切换出去。 |
3. 运行态 → 阻塞态
| 触发条件 | 详细说明 |
|---|---|
| I/O 请求 | 进程等待 I/O 完成,暂停运行。 |
| 等待资源 | 进程请求的锁、信号量等资源暂不可用。 |
| 等待事件 | 进程等待信号、消息或条件满足。 |
| 主动休眠 | 进程调用 sleep() 等接口,主动进入睡眠。 |
| 等待子进程 | 进程调用 wait() / waitpid() 等待子进程结束。 |
| 内存页缺失 | 访问未在内存中的页面,需等待页面换入。 |
4. 阻塞态 → 就绪态
| 触发条件 | 详细说明 |
|---|---|
| I/O 完成 | I/O 完成后,进程被唤醒并进入就绪队列。 |
| 资源获得 | 等待的资源可用,进程被唤醒。 |
| 事件发生 | 等待的信号、消息或条件已经出现。 |
| 定时器超时 | 睡眠或定时等待到期,进程恢复就绪。 |
| 资源释放 | 其他进程释放资源,等待进程被唤醒。 |
| 信号唤醒 | 信号中断阻塞状态,使进程重新就绪。 |
进程内存空间
- 用户空间(User Space):包含 进程 执行的用户程序代码和数据。在 用户空间 中,进程 可以执行各种任务,如运行应用程序、访问文件系统等。用户空间 对于应用程序是可见的,但对于操作系统中的核心功能是不可见的。
- 代码区(Text Segment):也称为“可执行代码区”,存储了 进程 的可执行代码,包括程序的指令和只读数据。这个区域通常是只读的,因为程序的指令在运行时不应被修改。
- 数据区(Data Segment),数据区分为两个子区域:
- 初始化数据区(Initialized Data Segment):存储全局和静态变量以及初始化的数据。这些变量在程序运行前就已经分配了内存并初始化。
- 未初始化数据区(Uninitialized Data Segment),也称为 BSS(Block Started by Symbol)段,存储全局和静态变量,但这些变量没有显式的初始化值。操作系统会在程序启动时自动将这个区域初始化为零。
- 堆区(Heap):堆区 是 动态分配内存 的地方,用于存储程序运行时需要的变量和数据结构。在 堆 中分配的内存需要手动释放,以避免内存泄漏。
- 栈区(Stack):栈区 用于存储函数调用和局部变量。每个函数调用都会在栈上创建一个栈帧,栈帧包含了函数的参数、局部变量以及函数返回地址。栈 是一种后进先出(LIFO)的数据结构,它的大小通常有限,由操作系统或编程语言定义。
- 内存映射区域(Memory Mapped Region):这是一些操作系统或运行时库的扩展,用于存储动态链接库(DLL)和共享库的信息以及其他系统数据结构。
- 内核空间(Kernel Space):内核空间 包含了操作系统的核心代码和数据结构,如页表、调度程序和系统调用接口等。内核空间 具有更高的特权级别,可以执行特权指令并且访问系统的各种资源,内核空间 对于用户程序是不可见的。
函数调用时内存结构
EBP (base pointer) 指向函数栈(栈帧)的底部(高地址),函数执行过程中在栈帧中分配局部变量,栈帧由高地址向低地址增长,ESP(stack pointer)一直指向栈顶。
每个函数的栈帧中包含如下内容:
- 上一个函数的 EBP
- 该函数的局部变量
- 如果函数内有
call指令的话,还需要保存额外信息:- 下一个函数的参数:依次存储从第 n 个到第 1 个,从高地址到低地址
- 返回地址:当前 PC 指向的位置,即
call指令的下一条指令的地址
在函数的调用过程中,调用函数叫做 caller,被调用函数叫做 callee。当我们从 caller 中调用 callee 时,callee 的 EBP 指向的物理地址的上下存储单元分别包含 caller 的返回地址以及 caller 所在栈帧的 EBP,当我们在 callee 中执行 ret 指令时,计算机可以跳转到 caller 中 call 指令的下一条并开始执行,同时 caller 的栈帧也会被恢复。
以下三个知识点可以放在一起学习:
进程间通信
进程间通信(Inter-Process Communication,IPC)是指在同一计算机系统中,两个或多个 进程 之间交换数据或信号的机制。常见的 进程间通信 方式主要包含 共享内存、管道、消息队列、信号、套接字 和 信号量。
下图汇总了常见 IPC 方式的数据流向、效率与同步需求差异:
管道
管道(Pipe) 是一种最基本的 进程间通信(IPC) 机制,本质上是由操作系统内核维护的一块 内存缓冲区。进程并不是直接把数据写给另一个进程,而是:
写进程 → 管道内核缓冲区 → 读进程
管道通常具有 固定大小 的缓冲区,数据只存在于内存中,不以普通磁盘文件的形式保存。管道中的数据按照 先进先出(FIFO) 的顺序被读取,因此先写入的数据会先被读出。
可以把管道理解成一个由内核维护的缓冲区:
- 读端:用于从管道中读取数据
- 读进程执行
read()时,操作系统从管道缓冲区中取出数据,并将数据复制到读进程的用户空间。
- 读进程执行
- 写端:用于向管道中写入数据
- 写进程执行
write()时,数据并不是直接进入读进程的地址空间,而是先被复制到 内核维护的管道缓冲区 中。
- 写进程执行
可以直接类比 生产者消费者问题:
- 管道 未满:写进程可以继续写;管道 已满 时,写进程会被阻塞,等待读进程取走数据。
- 管道 非空:读进程可以继续读;管道 为空 时,读进程会被阻塞,等待写进程写入数据。
因此,管道的读和写操作都可能发生阻塞。
管道中的数据以字节流形式传输,并按 FIFO(先进先出) 顺序读取。经典管道是单向通信(半双工),若要实现双向通信,通常需要建立两个管道。
管道只在内存中存在,容量受 内核缓冲区大小 限制,而不是磁盘容量限制。
管道可以分为:
- 无名管道(anonymous pipe)
- 有名管道(named pipe / FIFO)
无名管道 没有文件系统中的名字,通常用于具有亲缘关系的进程,例如父子进程、兄弟进程。
有名管道(FIFO) 在文件系统中具有一个名字,因此不要求通信进程之间具有亲缘关系。
共享内存
共享内存 是效率很高的一种 进程间通信(IPC) 方式。多个进程可以将同一组 物理内存页 映射到各自的 虚拟地址空间 中,从而直接读写这片共享区域。
例如:
注意,不同进程的虚拟页号(VPN)可以不同,但可以映射到同一个物理页号(PPN):
因此,A 和 B 虽然使用不同的虚拟地址访问,但最终访问的是同一块物理内存。
反过来,一个进程的一个虚拟页只能在某一时刻映射到一个物理页:
而不能:
因为同一个虚拟地址必须具有确定的地址转换关系,否则 CPU 无法确定访问哪个物理地址。
共享内存的核心思想可以概括为:
多个进程的不同虚拟地址 → 同一个物理内存区域。
由于数据直接在共享内存中读写,通信过程中不需要像管道那样反复在进程与内核之间复制数据,因此数据传输效率很高。但多个进程可能同时读写同一数据,所以仍然需要借助 信号量、互斥锁等同步机制 防止竞争条件。
消息队列
消息队列 是一种基于内核的数据结构,允许多个进程以消息为单位进行通信,具有良好的结构化和独立性。进程通过系统调用将消息发送到队列中或从中接收消息,可以实现同步与异步的通信模式。
相比 管道,消息队列 的通信更加灵活,支持优先级管理,适用于需要有序、分类传输数据的场景。但由于涉及内核操作,性能开销相对 共享内存 较大。
用户级线程和内核级线程
- 用户级线程
(ULT,User Level Thread)
- 用户级线程 是由应用程序通过线程库来实现的,操作系统内核并不直接感知到这些 线程 的存在。
- 线程的创建、调度和管理都由应用程序在 用户空间 完成。
- 内核级线程
(KLT,Kernel Level Thread)
- 内核级线程 是由操作系统内核直接支持的线程。
- 线程的创建、调度和管理都由 操作系统内核 完成。
线程模型
操作系统中的 线程实现方式 称为 线程模型,不同的 线程模型 在 用户级线程(ULT)和 内核级线程(KLT)的设计上各有不同。
系统中的 线程模型 可以分为如下三种:
- 纯用户态:所有的线程操作都在用户空间中进行,内核对线程的存在一无所知。
- 纯系统态:线程由操作系统内核直接支持和管理,内核负责线程的创建、调度和管理。
- 混合方案:应用程序可以在用户空间管理多个用户级线程,这些线程映射到较少数目的内核级线程。
混合方案中的映射关系
如果使用 混合方案 的 线程模型 的话,用户级线程 和 内核级线程 的映射方式也可以分为如下几种:
- 一对一:每一个用户级线程都对应一个内核级线程。
- 多对一:多个用户级线程映射到同一个内核级线程上。
- 多对多:多个用户级线程映射到多个内核级线程上。