1、进程和线程

厨子大约 74 分钟操作系统进程和线程原创面试题技术解析程序员多线程编程

进程管理篇

进程与线程

🌟什么是进程?

**标准定义:**进程是一个具有一定独立功能的程序在一个数据集合上依次动态执行的过程。进程是一个正在执行程序的实例,包括程序计数器、寄存器和程序变量的当前值。

简单来说进程就是一个程序的执行流程,内部保存程序运行所需的资源

在操作系统中可以有多个进程在运行,可对于 CPU 来说,同一时刻,一个 CPU 只能运行一个进程,但在某一时间段内,CPU 将这一时间段拆分成更短的时间片,CPU 不停的在各个进程间游走,这就给人一种并行的错觉,像 CPU 可以同时运行多个进程一样,这就是伪并行。

对比维度
进程 (Process)
程序 (Program)
本质
程序的 动态执行实例
存储在磁盘上的 静态可执行文件
生命周期
有创建、运行、阻塞、终止等状态变化
无生命周期概念(除非被加载为进程)
资源占用
独立分配内存、文件描述符、CPU时间片等系统资源
不占用实际资源(仅占用磁盘空间)
并发性
多个进程可并发执行(伪并行/真并行)
无法并发(需被加载为进程才能运行)
组成结构
包含代码段、数据段、堆、栈、PCB(进程控制块)
仅包含代码段、数据段等静态内容

主要特点

  • 动态性:进程是程序的一次执行过程,它是动态地产生、变化和消亡的。一个程序可以多次被执行,每次执行都会创建一个新的进程。
  • 独立性:每个进程都有自己独立的地址空间,不同进程之间的内存空间是相互隔离的。这意味着一个进程中的错误不会影响到其他进程。
  • 并发性:在多道程序设计系统中,多个进程可以同时存在并执行。操作系统通过时间片轮转等调度算法,在不同进程之间切换执行,从而实现并发执行。
  • 制约性:因访问共享资源或进程间同步而产生制约。

组成部分

  • 程序代码:进程执行的指令序列,是进程的静态部分。
  • 数据:进程在执行过程中所使用的变量、常量、数组等数据,也是进程的静态部分。
  • 进程控制块(PCB):是操作系统为了管理进程而设置的数据结构,包含了进程的标识信息、状态信息、资源信息等。PCB 是进程存在的唯一标志,操作系统通过 PCB 来对进程进行管理和调度。

重要作用

  • 资源分配:操作系统通过为进程分配资源,如内存、CPU 时间、文件等,来实现对计算机系统资源的有效管理和利用。
  • 提高系统效率:通过并发执行多个进程,可以充分利用计算机的硬件资源,提高系统的效率和吞吐量。
  • 实现多任务处理:在现代操作系统中,用户可以同时运行多个应用程序,每个应用程序都是一个进程。操作系统通过对进程的管理和调度,实现了多任务处理,提高了用户的工作效率。

进程和程序有什么联系?

一个进程是某种类型的一个活动,它有程序、输入、输出以及状态。单个处理器可以被若干进程共享,它使用某种调度算法决定何时停止一个进程的工作,并转而为另一个进程提供服务。

  • 程序是产生进程的基础
  • 程序的每次运行产生不同的进程
  • 进程是程序功能的体现
  • 通过多次执行,一个程序可对应多个进程;通过调用关系,一个进程可包括多个程序

🌟进程和程序的区别?

  • 进程是动态的,程序是静态的:程序是有序代码的集合,进程是程序的执行。
  • 进程是暂时的,程序是永久的:进程是一个状态变化的过程,程序可长久保存。
  • 进程和程序的组成不同:进程的组成包括程序、数据和进程控制块(进程状态信息)。
对比维度
进程 (Process)
程序 (Program)
本质
程序的 动态执行实例
静态的 代码和数据集合
存在形式
运行时存在于内存中
永久存储在磁盘上
生命周期
临时(创建→运行→终止)
永久(除非被删除)
组成结构
程序代码 + 数据 + 堆栈 + PCB(进程控制块)
仅包含代码段、数据段等静态内容
资源占用
分配CPU、内存、文件描述符等运行时资源
仅占用磁盘存储空间
状态变化
有运行、就绪、阻塞等状态
无状态概念
独立性
拥有独立的内存地址空间
必须被加载为进程才能运行
并发性
多个进程可并发执行
无法直接并发
创建方式
通过`fork()`/`exec()`或`CreateProcess()`生成
由开发者编写并编译/解释保存
是否直接操作硬件
通过系统调用间接操作
不涉及硬件操作

如何创建进程?

有什么事件会触发进程的创建呢?

  • 系统初始化:当启动操作系统时,通常会创建很多进程,有些是同用户交互并替他们完成工作的前台进程,其它的都是后台进程,后台进程和特定用户没有关系,但也提供某些专门的功能,例如接收邮件等,这种功能的进程也称为守护进程。计划任务是个典型的守护进程,它每分钟运行一次来检查是否有工作需要它完成。如果有工作要做,它就会完成此工作,然后进入休眠状态,直到下一次检查时刻的到来。
  • 正在运行的程序执行了创建进程的系统调用:在一个进程中又创建了一个新的进程,这种情况很常见。
  • 用户请求创建一个新进程:这种情况相信每个人都见过,用电脑时双击某个应用图标,就会有至少一个进程被创建。
  • 一个批处理作业的初始化:这种情形不常见,仅在大型机的批处理系统中应用,用户在这种系统中提交批处理作业,在操作系统认为有资源可运行另一个作业时,它创建一个新的进程,并运行其输入队列中的下一个作业。

归根到底:在 UNIX 系统中,只有 fork 系统调用才可以创建新进程,使用方式如下:

#include <stdio.h>
#include <unistd.h>
int main() {
    pid_t id = fork();
    if (id < 0) {
        perror("fork\n");
    } else if (id == 0) {  // 子进程
        printf("子进程\n");
    } else {  // 父进程
       printf("父进程\n");
   }
   return 0;
}

进程创建之后,父子进程都有各自不同的地址空间,其中一个进程在其地址空间的修改对另一个进程不可见。子进程的初始化空间是父进程的一个副本,这里涉及两个不同地址空间,不可写的内存区是共享的,某些 UNIX 的实现使程序正文在两者间共享,因为它是不可修改的。

进程为何终止?

有什么事件会触发进程的终止呢?

  • 正常退出(自愿):进程完成了工作正常终止,UNIX 中退出进程的系统调用是 exit。
  • 出错退出(自愿):进程发现了错误而退出。可以看如下代码:
#include <stdio.h>
#include <stdlib.h>
void Func() {
    if (error) { // 有错误就退出程序
        exit(1);
    }
}

int main() {
    Func();
}
  • 严重错误(非自愿):进程发生了严重的错误而不得不退出,通常是程序的错误导致,例如执行了一条非法指令,引用不存在的内存,或者除数是 0 等,出现这些错误时进程默认会退出。而有些时候如果用户想自行处理某种类型的错误,发生不同类型错误时进程会收到不同类型的信号,用户注册处理不同信号的函数即可。
  • 被其它进程杀死(非自愿):其它进程执行 kill 系统调用通知操作系统杀死某个进程。

🌟操作系统如何进行进程管理?

这里就不得不提到一个数据结构:进程控制块(PCB),操作系统为每个进程都维护一个 PCB,用来保存与该进程有关的各种状态信息。进程可以抽象理解为就是一个 PCB,PCB 是进程存在的唯一标志,操作系统用 PCB 来描述进程的基本情况以及运行变化的过程,进程的任何状态变化都会通过 PCB 来体现。

PCB 包含进程状态的重要信息,包括程序计数器、堆栈指针、内存分配状况、所打开文件的状态、账号和调度信息,以及其它在进程由运行态转换到就绪态或阻塞态时必须保存的信息,从而保证该进程随后能再次启动,就像从未中断过一样。

提到进程管理,有一个概念我们必须要知道,就是中断向量,中断向量是指中断服务程序的入口地址。一个进程在执行过程中可能会被中断无数次,但是每次中断后,被中断的进程都要返回到与中断发生前完全相同的状态。

中断发生后操作系统底层做了什么?

  1. 硬件压入堆栈程序计数器等;
  2. 硬件从中断向量装入新的程序计数器;
  3. 汇编语言过程保存寄存器值;
  4. 汇编语言过程设置新的堆栈;
  5. C 中断服务例程运行(典型的读和缓冲输入);
  6. 调度程序决定下一个将运行的进程;
  7. C 过程返回到汇编代码;
  8. 汇编语言过程开始运行新的当前进程。

📚 关键补充说明

// Linux 中 PCB 的简化结构(task_struct 部分字段)
struct task_struct {
    volatile long state;          // 进程状态(-1不可运行, 0可运行, >0已停止)
    struct mm_struct *mm;         // 内存管理信息
    pid_t pid;                    // 进程标识符
    struct files_struct *files;   // 打开的文件表
    struct thread_struct thread;  // CPU 寄存器状态
    // ... 其他 100+ 字段
};

为什么说 PCB(进程控制块)是进程存在的唯一标识?

PCB(进程控制块)是操作系统中用来管理和控制进程的数据结构,其中包含了进程的各种信息,如进程状态、程序计数器、寄存器、内存分配情况、打开文件列表等。由于 PCB 包含了进程的所有关键信息,因此可以说 PCB 是进程存在的唯一标识。

主要存储这些信息:

  • 进程标识信息:如本进程的标识,本进程的父进程标识,用户标识等。

  • 处理机状态信息保护区:用于保存进程的运行现场信息:

    • 用户可见寄存器:用户程序可以使用的数据,地址等寄存器
    • 控制和状态寄存器:程序计数器,程序状态字
    • 栈指针:过程调用、系统调用、中断处理和返回时需要用到它
  • 进程控制信息

    • 调度和状态信息:用于操作系统调度进程使用
    • 进程间通信信息:为支持进程间与通信相关的各种标识、信号、信件等,这些信息存在接收方的进程控制块中
    • 存储管理信息:包含有指向本进程映像存储空间的数据结构
    • 进程所用资源:说明由进程打开使用的系统资源,如打开的文件等
    • 有关数据结构连接信息:进程可以连接到一个进程队列中,或连接到相关的其他进程的 PCB

每个进程都有一个独特的 PCB,通过 PCB 可以唯一地标识和管理每个进程。当操作系统需要对一个进程进行调度、切换或终止时,就需要通过该进程对应的 PCB 来获取和修改进程的相关信息。

因此,可以说 PCB 是进程存在的唯一标识,因为它包含了进程的所有信息,并且用于操作系统对进程进行管理和控制。

📌 进程控制块 (PCB) 核心结构

PCB 组成部分
作用描述
Linux 对应实现
进程标识符 (PID)
唯一标识进程的数字
`task_struct->pid`
程序计数器 (PC)
存储下一条要执行的指令地址
`task_struct->thread.ip`
寄存器状态
保存 CPU 寄存器值(RAX/RSP等)
`task_struct->thread.regs`
进程状态
标记运行态(TASK_RUNNING)、就绪态等
`task_struct->state`
内存管理信息
页表指针、堆/栈边界
`task_struct->mm`
文件描述符表
记录打开的文件(fd[0]~fd[n])
`task_struct->files`
调度信息
优先级(`prio`)、时间片剩余量
`task_struct->sched_class`
父进程指针
指向创建该进程的父进程 PCB
`task_struct->parent`

🌟进程的生命周期

进程创建

创建进程有三个主要事件:

  • 系统初始化
  • 用户请求创建一个新进程
  • 一个正在运行的进程执行创建进程的系统调用

进程运行

内核选择一个就绪的进程,让它占用处理机并运行,这里就涉及到了进程的调度策略,选择哪个进程调度?为什么选择调度这个进程呢?

进程等待

在以下情况下进程会等待(阻塞):

  • 请求并等待系统服务,无法马上完成
  • 启动某种操作,无法马上完成
  • 需要的数据没有到达。

进程唤醒

进程只能被别的进程或操作系统唤醒,唤醒进程的原因有:

  • 被阻塞进程需要的资源可被满足
  • 被阻塞进程等待的事件到达
  • 将该进程的 PCB 插入到就绪队列

进程结束

在以下四种情况下进程会结束:

  • 自愿型正常退出
  • 自愿型错误退出
  • 强制型致命错误退出
  • 强制型被其它进程杀死退出

进程的状态与转换

不同系统设置的进程状态是不同的,多数系统中的进程在生命结束前有三种基本状态,进程只会处于三种基本状态之一:

  • 运行状态:进程正在处理机上运行时就处在运行状态,该时刻进程时钟占用着 CPU;
  • 就绪状态:万事俱备,只欠东风,进程已经获得了除处理机之外的一切所需资源,一旦得到处理机就可以运行;就绪态中的进程其实可以运行,但因为其它进程正在占用着 CPU 而暂时停止运行;
  • 等待状态(阻塞状态):进程正在等待某一事件而暂停运行,等待某个资源或者等待输入输出完成。除非某种外部事件发生,否则阻塞态的进程不能运行;

进程状态变化图如下:

在操作系统发现进程不能继续运行下去时,进程因为等待输入而被阻塞,进程从运行态转换到阻塞态!

调度程序选择了另一个进程执行时,当前程序就会从运行态转换到就绪态!

被调度程序选择的程序会从就绪态转换到运行态!

当阻塞态的进程等待的一个外部事件发生时,就会从阻塞态转换到就绪态,此时如果没有其他进程运行时,则立刻从就绪态转换到运行态!

有些与进程管理相关的系统调用有必要了解一下:

pid=fork(); // 创建一个与父进程一样的子进程
pid=waitpid(); // 等待子进程终止
s=execve(); // 替换进程的核心映像
exit(); // 终止进程运行并返回状态值
s=sigaction(); // 定义信号处理的动作
s=sigprocmask(); // 检查或更换信号掩码
s=sigpending(); // 获得阻塞信号集合
s=sigsuspend(); // 替换信号掩码或挂起进程
alarm(); // 设置定时器
pause(); // 挂起调用程序直到下一个信号出现

⚙️ 进程状态转换对比

状态转换
触发条件
相关系统调用/事件
创建 → 就绪
`fork()` 成功创建子进程
`clone()` / `do_fork()`
就绪 → 运行
被调度器选中(调用 `__schedule()`)
`schedule()`
运行 → 阻塞
等待 I/O(如 `read()` 阻塞)或信号量
`wait_event()` / `sleep()`
阻塞 → 就绪
I/O 完成(如磁盘中断触发)或信号量释放
`wake_up_process()`
运行 → 终止
调用 `exit()` 或收到致命信号(`SIGKILL`)
`do_exit()`
运行 → 就绪
时间片用完或被更高优先级进程抢占
`timer_interrupt()`

什么是进程挂起?为什么会出现进程挂起?

进程挂起就是为了合理且充分的利用系统资源,把一个进程从内存转到外存。进程在挂起状态时,意味着进程没有占用内存空间,处在挂起状态的进程映射在磁盘上。进程挂起通常有两种状态:

  • 阻塞挂起状态:进程在外存并等待某事件的出现;
  • 就绪挂起状态:进程在外存,但只要进入内存即可运行。

有什么与进程挂起相关的状态转换?

进程挂起可能有以下几种情况:

  • 阻塞到阻塞挂起:没有进程处于就绪状态或就绪进程要求更多内存资源时,会进行这种转换,以提交新进程或运行就绪进程;
  • 就绪到就绪挂起:当有高优先级阻塞进程或低优先级就绪进程时,系统会选择挂起低优先级就绪进程;
  • 运行到就绪挂起:对于抢占式分时系统,当有高优先级阻塞挂起进程因事件出现而进入就绪挂起时,系统可能会把运行进程转到就绪挂起状态;
  • 阻塞挂起到就绪挂起:当有阻塞挂起进程有相关事件出现时,系统会把阻塞挂起进程转换为就绪挂起进程。

有进程挂起那就有进程解挂:指一个进程从外存转到内存,相关状态有

  • 就绪挂起到就绪:没有就绪进程或就绪挂起进程优先级高于就绪进程时,就会进行这种转换;
  • 阻塞挂起到阻塞:当一个进程释放足够内存时,系统会把一个高优先级阻塞挂起进程转换为阻塞进程。

🔄 中断处理流程(以 Linux 为例)

步骤
硬件/操作系统行为
关键数据结构
1. 硬件保存现场
压入 PC/PSW 到内核栈
`pt_regs` 结构体
2. 加载中断向量
根据中断号跳转到 `entry_64.S` 中的中断入口
IDT(中断描述符表)
3. 保存寄存器
汇编代码保存所有通用寄存器到 `pt_regs`
`push %rax` 等指令
4. 切换堆栈
从用户栈切换到内核栈(`esp0`)
`task_struct->thread.sp0`
5. 执行 ISR
调用对应的中断处理函数(如键盘中断调用 `keyboard_interrupt()`)
`irq_desc->action->handler`
6. 进程调度检查
判断是否需要调用 `schedule()` 切换进程
`need_resched` 标志
7. 恢复寄存器
从 `pt_regs` 恢复寄存器状态
`pop %rax` 等指令
8. 返回用户态
通过 `iretq` 指令恢复用户程序执行
`swapgs` 指令(x86_64)

🌟什么是线程?

线程是进程当中的一条执行流程,这几乎就是进程的定义,一个进程内可以有多个子执行流程,即线程。可以从两个方面重新理解进程:

  • 从资源组合的角度:进程把一组相关的资源组合起来,构成一个资源平台环境,包括地址空间(代码段、数据段),打开的文件等各种资源
  • 从运行的角度:代码在这个资源平台上的执行流程,然而线程貌似也是这样,但是进程比线程多了资源内容列表样式:那就有一个公式:进程 = 线程 + 共享资源

为什么使用线程?

因为要并发编程,在许多情形中同时发生着许多活动,而某些活动有时候会被阻塞,通过将这些活动分解成可以准并行运行的多个顺序流程是必须的,而如果使用多进程方式进行并发编程,进程间的通信也很复杂,并且维护进程的系统开销较大:创建进程时分配资源建立 PCB,撤销进程时回收资源撤销 PCB,进程切换时保存当前进程的状态信息。所以为了使并发编程的开销尽量小,所以引入多线程编程,可以并发执行也可以共享相同的地址空间。并行实体拥有共享同一地址空间和所有可用数据的能力,这是多进程模型所不具备的能力。

使用线程有如下优点:

  • 可以多个线程存在于同一个进程中
  • 各个线程之间可以并发的执行
  • 各个线程之间可以共享地址空间和文件等资源
  • 线程比进程更轻量级,创建线程撤销线程比创建撤销进程要快的多,在许多系统中,创建一个线程速度是创建一个进程速度的 10-100 倍。
  • 如果多个线程是 CPU 密集型的,并不能很好的获得更好的性能,但如果多个线程是 IO 密集型的,线程存在着大量的计算和大量的 IO 处理,有多个线程允许这些活动彼此重叠进行,从而会加快整体程序的执行速度。

但也有缺点:

  • 一旦一个线程崩溃,会导致其所属进程的所有线程崩溃。
  • 由于各个线程共享相同的地址空间,那么读写数据可能会导致竞争关系,因此对同一块数据的读写需要采取某些同步机制来避免线程不安全问题。

什么时候用进程?什么时候用线程?

  1. 进程是资源分配单位,线程是 CPU 调度单位;

  2. 进程拥有一个完整的资源平台,而线程只独享必不可少的资源,如寄存器和栈;

  3. 线程同样具有就绪阻塞和执行三种基本状态,同样具有状态之间的转换关系;

  4. 线程能减少并发执行的时间和空间开销:

    • 线程的创建时间比进程短
    • 线程的终止时间比进程短
    • 同一进程内的线程切换时间比进程短
    • 由于同一进程的各线程间共享内存和文件资源,可直接进行不通过内核的通信

**结论:**可以在强调性能时候使用线程,如果追求更好的容错性可以考虑使用多进程,google 浏览器据说就是用的多进程编程。在多 CPU 系统中,多线程是有益的,在这样的系统中,通常情况下可以做到真正的并行。

指标
多进程模型
多线程模型
创建速度
100ms
1ms
内存占用
高(独立地址空间)
低(共享地址空间)
通信延迟
高(需 IPC)
低(共享内存)
崩溃影响范围
单个进程
整个进程

进程和线程的比较

我们主要从调度,拥有资源 ,并发性,系统开销等方面对其进行比较:

  • 调度:在传统的操作系统中,拥有资源和独立调度的单位都是进程,引入线程之后,调度的最小单位就变成了线程,但是拥有资源的最小单位还是进程,另外在同一个进程中,线程的切换不会影响进程,但是不同的进程中的切换则会引起进程切换。
  • 拥有资源:不论是传统的操作系统,还是设有线程的操作系统,进程都是资源分配的最小单位。
  • 并发性:在引入线程的操作系统中,不仅进程之间可以并发,线程之间也可以并发,这使得操作系统具有更好的并发性。
  • 系统开销:创建进程或撤销进程的同时,系统都要为之分配或回收资源,在进行进程切换时,涉及当前进程 CPU 环境的保存和新环境的设置,线程切换时,只需保存和设置少量寄存器内容,因此开销很小。总的来说就是线程间切换的开销更小,同步和通信很容易实现。

线程是如何实现的?

线程的实现可分为用户线程和内核线程:

用户线程

在用户空间实现的线程机制,它不依赖于操作系统的内核,由一组用户级的线程库函数来完成线程的管理,包括进程的创建终止同步和调度等。

用户线程有如下优点:

  • 由于用户线程的维护由相应进程来完成(通过线程库函数),不需要操作系统内核了解内核了解用户线程的存在,可用于不支持线程技术的多进程操作系统。
  • 每个进程都需要它自己私有的线程控制块列表,用来跟踪记录它的各个线程的状态信息(PC,栈指针,寄存器),TCB 由线程库函数来维护;
  • 用户线程的切换也是由线程库函数来完成,无需用户态/核心态切换,所以速度特别快;
  • 允许每个进程拥有自定义的线程调度算法;

但用户线程也有缺点:

  • 阻塞性的系统调用如何实现?如果一个线程发起系统调用而阻塞,则整个进程在等待。
  • 当一个线程开始运行后,除非它主动交出 CPU 的使用权,否则它所在进程当中的其它线程将无法运行;
  • 由于时间片分配给进程,与其它进程比,在多线程执行时,每个线程得到的时间片较少,执行会较慢

内核线程

是指在操作系统的内核中实现的一种线程机制,由操作系统的内核来完成线程的创建终止和管理。

特点

  • 在支持内核线程的操作系统中,由内核来维护进程和线程的上下文信息(PCB TCB);
  • 线程的创建终止和切换都是通过系统调用内核函数的方式来进行,由内核来完成,因此系统开销较大;
  • 在一个进程当中,如果某个内核线程发起系统调用而被阻塞,并不会影响其它内核线程的运行;
  • 时间片分配给线程,多线程的进程获得更多 CPU 时间;

注意:

由于在内核中创建或撤销线程的代价比较大,某些系统采取复用的方式回收线程,当某个线程被撤销时,就把它标记不可运行,但是内核数据结构没有受到任何影响,如果后续又需要创建一个新线程时,就重新启动被标记为不可运行的旧线程,从而节省一些开销。

尽管使用内核线程可以解决很多问题,但还有些问题,例如:当一个多线程的进程创建一个新的进程时会发生什么?新进程是拥有与原进程相同数量的线程还是只有一个线程?在很多情况下,最好的选择取决于进程计划下一步做什么?如果它要调用 exec 启动一个新程序,或许一个线程正合适,但如果它继续运行,那么最好复制所有的线程。

轻量级进程

它是内核支持的用户线程模型,一个进程可以有多个轻量级进程,每个轻量级进程由一个单独的内核线程来支持。

在 Linux 下是没有真正的线程的,它所谓的线程其实就是使用进程来实现的,就是所谓的轻量级进程,其实就是进程,都是通过 clone 接口调用创建的,只不过两者传递的参数不同,通过参数决定子进程和父进程共享的资源种类和数量,进而有了普通进程和轻量级进程的区别。

🧵 线程实现方式对比

对比维度
用户线程
内核线程
轻量级进程 (LWP)
实现层级
用户空间(线程库实现)
内核空间(操作系统直接支持)
用户空间 + 内核支持(混合模型)
管理主体
用户级线程库(如 `pthread_create()`)
操作系统内核(如 `clone()`)
用户线程通过内核线程映射(1:1 或 M:N)
TCB/PCB 存储
用户空间(线程库维护)
内核空间(内核维护)
内核维护 LWP 结构
创建/销毁开销
极低(无需内核介入)
高(需系统调用)
中(需内核参与但复用资源)
阻塞影响范围
整个进程阻塞(单线程阻塞则全部阻塞)
仅阻塞当前线程
仅阻塞当前 LWP
调度单位
进程(所有用户线程共享时间片)
线程(内核直接调度线程)
线程(内核调度 LWP)
并发性能
差(无法利用多核)
好(真正并行)
好(支持多核并行)
典型系统
早期 Java "绿色线程"
Windows / Linux 原生线程
Solaris / 现代 Java 虚拟线程

什么是僵尸进程

僵尸进程是已完成且处于终止状态,但在进程表中却仍然存在的进程。僵尸进程通常发生在父子关系的进程中,由于父进程仍需要读取其子进程的退出状态所造成的。

进程切换

进程切换的速度是远小于线程切换的。举个不太恰当的例子,我们如果把进程比作房子,线程比作卧室的话,线程切换则是在每个卧室之间,来回穿梭,不用更换衣物,鞋子,但是我们如果要去邻居家(另一个进程)那么则需要更换衣服鞋子,这就是线程切换和进程切换。

进程切换为什么慢?

这就需要说到虚拟地址的地方了,因为每个进程都有自己的虚拟地址空间,然后线程共用当前进程的虚拟空间,所以进程切换需要虚拟地址空间的切换。这个过程是比较慢的。

另外进程通过查找页表,将虚拟空间映射到物理地址空间。这个过程是比较慢的,所以通常使用 Cache 来保存那些经常被查找的地址映射,然后进程切换的话,也会导致页表的切换,进而导致 Cache 失效,命中率降低,进而就会导致程序运行变慢。

协程

协程又叫微线程。

在有大量 IO 操作业务的情况下,考虑采用协程替换线程,可以到达很好的效果,一是降低了系统内存,二是减少了系统切换开销,因此系统的性能也会提升。

协程多用于异步 I/O,这样性能最好,阻塞 I/O 不能完全发挥出优势。

我们可以把协程理解成用户态的线程,操作系统不知道协程的存在,所以协程切换时不需要由操作系统控制,可以由自己控制,这样就节省了操作系统资源。

🌟什么是上下文切换?

上下文切换指的是操作系统停止当前运行进程(从运行状态改变成其它状态)并且调度其它进程(就绪态转变成运行状态)。操作系统必须在切换之前存储许多部分的进程上下文,必须能够在之后恢复他们,所以进程不能显示它曾经被暂停过,同时切换上下文这个过程必须快速,因为上下文切换操作是非常频繁的。那上下文指的是什么呢?指的是任务所有共享资源的工作现场,每一个共享资源都有一个工作现场,包括用于处理函数调用、局部变量分配以及工作现场保护的栈顶指针,和用于指令执行等功能的各种寄存器。

注意:这里所说的进程切换导致上下文切换其实不太准确,准确的说应该是任务的切换导致上下文切换,这里的任务可以是进程也可以是线程,准确的说线程才是 CPU 调度的基本单位,但是因为各个资料都这么解释上下文切换,所以上面也暂时这么介绍,只要心里有这个概念就好。

上下文切换的过程

  1. 挂起一个进程,将这个进程在 CPU 中的状态(上下文信息)存储于内存的 PCB 中。
  2. 在 PCB 中检索下一个进程的上下文并将其在 CPU 的寄存器中恢复。
  3. 跳转到程序计数器open in new window所指向的位置(即跳转到进程被中断时的代码行)并恢复该进程

📌 核心概念对比

对比项
进程上下文切换
线程上下文切换
切换对象
不同进程间的切换
同一进程内不同线程间的切换
资源开销
高(需切换地址空间、刷新TLB、更新PCB)
低(仅切换栈和寄存器,共享地址空间)
触发原因
时间片耗尽、系统调用阻塞、更高优先级进程抢占
时间片耗尽、线程主动让出CPU(如`pthread_yield()`)
保存内容
寄存器状态、内存映射表、文件描述符表、信号处理等
寄存器状态、栈指针(TCB)
速度
慢(约1000-5000 CPU周期)
快(约100-1000 CPU周期)
硬件影响
导致CPU缓存和TLB大量失效
仅部分缓存失效(共享内存数据可能保留)

💡 上下文切换的详细步骤

步骤
进程切换操作
线程切换操作
1. 保存现场
保存所有通用寄存器、浮点寄存器、程序计数器到PCB
仅保存必要寄存器(PC/SP等)到TCB
2. 更新调度队列
将当前进程移入就绪/阻塞队列
将当前线程移入线程就绪队列
3. 切换地址空间
加载新进程的页表指针(CR3寄存器),刷新TLB
无此步骤(共享地址空间)
4. 恢复现场
从新进程的PCB恢复寄存器状态
从新线程的TCB恢复寄存器
5. 切换堆栈
切换内核栈(`esp0`)和用户栈
仅切换用户栈指针

为什么进程上下文切换比线程上下文切换代价高?

原文:https://zwmst.com/1322.htmlopen in new window

进程切换分两步:

  1. 切换页目录以使用新的地址空间
  2. 切换内核栈和硬件上下文

对于 linux 来说,线程和进程的最大区别就在于地址空间,对于线程切换,第 1 步是不需要做 的,第 2 是进程和线程切换都要做的

切换的性能消耗:

线程上下文切换和进程上下文切换一个最主要的区别是线程的切换虚拟内存空间依然是相同 的,但是进程切换是不同的。这两种上下文切换的处理都是通过操作系统内核来完成的。内核 的这种切换过程伴随的最显著的性能损耗是将寄存器中的内容切换出

另外一个隐藏的损耗是上下文的切换会扰乱处理器的缓存机制。简单的说,一旦去切换上下 文,处理器中所有已经缓存的内存地址一瞬间都作废了。还有一个显著的区别是当你改变虚拟 内存空间的时候,处理的页表缓冲(processor’s Translation Lookaside Buffer (TLB))或 者相当的神马东西会被全部刷新,这将导致内存的访问在一段时间内相当的低效。但是在线程 的切换中,不会出现这个问题。

🖥️ 进程 vs 线程上下文切换代价对比

对比项
进程上下文切换
线程上下文切换
关键差异说明
地址空间切换
✅ 需要切换页目录(CR3寄存器)
❌ 无需切换
进程切换会导致TLB全部刷新,线程切换复用原地址空间
TLB失效
✅ 全部失效
❌ 保持有效
TLB重建耗时约100-300周期(x86架构)
CPU缓存失效
✅ L1/L2缓存大部分失效
⚠️ 仅部分失效(共享数据保留)
进程切换后缓存命中率下降明显
寄存器保存/恢复
✅ 保存全部寄存器+浮点寄存器
✅ 仅保存必要寄存器
线程切换需保存的数据量更少
内核栈切换
✅ 需切换内核栈和用户栈
✅ 仅切换用户栈
进程需要独立的内核栈空间
平均耗时
⏳ 1000-5000 CPU周期
⏳ 100-1000 CPU周期
实测线程切换速度可达进程的5-10倍
硬件优化支持
⚠️ PCID/ASID可部分优化TLB刷新
✅ 天然优化(共享资源)
Intel的PCID技术可降低进程切换开销

守护、僵尸、孤儿进程的概念

  • 守护进程:运行在后台的一种特殊进程,独立于控制终端并周期性地执行某些任务。
  • 僵尸进程:一个进程 fork 子进程,子进程退出,而父进程没有 wait/waitpid 子进程,那么子 进程的进程描述符仍保存在系统中,这样的进程称为僵尸进程。
  • 孤儿进程:一个父进程退出,而它的一个或多个子进程还在运行,这些子进程称为孤儿进程。 (孤儿进程将由 init 进程收养并对它们完成状态收集工作)

🌟进程通信与线程通信

进程通信

进程通信,是指进程之间的信息交换(信息量少则一个状态或数值,多者则是成千上万个字 节)。因此,对于用信号量进行的进程间的互斥和同步,由于其所交换的信息量少而被归结为低级通信。

高级进程通信指:用户可以利用操作系统所提供的一组通信命令传送大量数据的一种通信 方式。操作系统隐藏了进程通信的实现细节。或者说,通信过程对用户是透明的

高级通信机制可归结为三大类:

**共享存储器系统(存储器中划分的共享存储区);**实际操作中对应的是“剪贴板”(剪贴板实际 上是系统维护管理的一块内存区域)的通信方式,比如举例如下:word 进程按下 ctrl+c,在 ppt 进程按下 ctrl+v,即完成了 word 进程和 ppt 进程之间的通信,复制时将数据放入到剪贴 板,粘贴时从剪贴板中取出数据,然后显示在 ppt 窗口上

消息传递系统(进程间的数据交换以消息(message)为单位,当今最流行的微内核操作系统 中,微内核与服务器之间的通信,无一例外地都采用了消息传递机制。应用举例:邮槽(MailSlot)是基于广播通信体系设计出来的,它采用无连接的不可靠的数据传输。邮槽是一 种单向通信机制,创建邮槽的服务器进程读取数据,打开邮槽的客户机进程写入数据

管道通信系统(管道即:连接读写进程以实现他们之间通信的共享文件(pipe 文件,类似先进 先出的队列,由一个进程写,另一进程读))。实际操作中,管道分为:匿名管道、命名管道。匿名管道是一个未命名的、单向管道,通过父进程和一个子进程之间传输数据。匿名管道 只能实现本地机器上两个进程之间的通信,而不能实现跨网络的通信。命名管道不仅可以在本 机上实现两个进程间的通信,还可以跨网络实现两个进程间的通信。

  • 管道:管道是单向的、先进先出的、无结构的、固定大小的字节流,它把一个进程的标准输出 和另一个进程的标准输入连接在一起。写进程在管道的尾端写入数据,读进程在管道的道端读 出数据。数据读出后将从管道中移走,其它读进程都不能再读到这些数据。管道提供了简单的 流控制机制。进程试图读空管道时,在有数据写入管道前,进程将一直阻塞。同样地,管道已 经满时,进程再试图写管道,在其它进程从管道中移走数据之前,写进程将一直阻塞。
  • 信号量:信号量是一个计数器,可以用来控制多个进程对共享资源的访问。它常作为一种锁机 制,防止某进程正在访问共享资源时,其它进程也访问该资源。因此,主要作为进程间以及同 一进程内不同线程之间的同步手段
  • 消息队列:是一个在系统内核中用来保存消 息的队列,它在系统内核中是以消息链表的形式出 现的。消息队列克服了信号传递信息少、管道只能承载无格式字节流以及缓冲区大小受限等缺 点
  • 共享内存:共享内存允许两个或多个进程访问同一个逻辑内存。这一段内存可以被两个或两个 以上的进程映射至自身的地址空间中,一个进程写入共享内存的信息,可以被其他使用这个共 享内存的进程,通过一个简单的内存读取读出,从而实现了进程间的通信。如果某个进程向共 享内存写入数据,所做的改动将立即影响到可以访问同一段共享内存的任何其他进程。共享内 存是最快的 IPC 方式,它是针对其它进程间通信方式运行效率低而专门设计的。它往往与其它 通信机制(如信号量)配合使用,来实现进程间的同步和通信
  • 套接字:套接字也是一种进程间通信机制,与其它通信机制不同的是,它可用于不同机器间的进程通信。

由于各个进程不共享相同的地址空间,任何一个进程的全局变量在另一个进程中都不可见,所以如果想要在进程之间传递数据就需要通过内核,在内核中开辟出一块区域,该区域对多个进程都可见,即可用于进程间通信。有读者可能有疑问了,文件方式也是进程间通信啊,也要在内核开辟区域吗?这里说的内核区域其实是一段缓冲区,文件方式传输数据也有内核缓冲区的参与(零拷贝除外)。

进程通信就是指进程间的信息交换,有多种进程间通信的方式:

  • 管道
  • 消息队列
  • 共享内存
  • 信号量
  • 信号
  • socket

🔄 进程通信 vs 线程通信机制对比

📌 通信方式分类

通信类型
进程通信 (IPC)
线程通信
共享内存
✅ 需显式创建(如`shmget()`),配合信号量同步
✅ 直接访问进程全局变量(需加锁)
消息传递
✅ 消息队列(`msgget`)、管道(`pipe`)、邮槽
❌ 通常无需(可直接读写内存)
文件映射
✅ `mmap` 文件映射
✅ 同进程通信
信号量
✅ `semget` 系统调用
✅ 同进程通信(`pthread_mutex`)
信号
✅ `kill()` 发送信号
⚠️ 有限支持(信号处理影响整个进程)
套接字
✅ 跨网络通信(`socket`)
❌ 不适用

📊 进程通信方式详解

机制
原理
特点
典型API
匿名管道
单向字节流,内存中的固定大小队列
仅限父子进程,单向通信
`pipe()`
命名管道
文件系统中的特殊文件(FIFO)
支持非亲缘进程,跨网络需SMB/NFS
`mkfifo()`
消息队列
内核维护的消息链表
支持消息类型标记,克服管道无结构限制
`msgget()`/`msgsnd()`
共享内存
多个进程映射同一物理内存区域
最快IPC方式,需配合同步机制
`shmget()`/`shmat()`
信号量
内核计数器控制资源访问
主要用于同步,不传递数据
`sem_init()`/`sem_wait()`
套接字
网络协议栈接口
跨主机通信,支持TCP/UDP
`socket()`/`bind()`

⚙️ 线程同步机制

**机制**
**作用**
**特点**
**POSIX API**
**互斥锁**
保护临界区
简单高效,可能死锁
`pthread_mutex_lock()`
**条件变量**
线程间状态通知
需配合互斥锁使用
`pthread_cond_wait()`
**读写锁**
区分读写操作
读多写少场景性能优
`pthread_rwlock_rdlock()`
**自旋锁**
忙等待锁
短等待时比互斥锁高效
`pthread_spin_lock()`
**屏障**
多线程同步点
等待所有线程到达指定点
`pthread_barrier_wait()`

管道

ps auxf | grep mysql

中间的 | 就是管道,管道分为 匿名管道命名管道

匿名管道

匿名管道就是 pipe,pipe 只能在父子进程间通信,而且数据只能单向流动(半双工通信)。

使用方式:

  1. 父进程创建管道,会得到两个文件描述符,分别指向管道的两端;
  2. 父进程创建子进程,从而子进程也有两个文件描述符指向同一管道;
  3. 父进程可写数据到管道,子进程就可从管道中读出数据,从而实现进程间通信,下面的示例代码中通过 pipe 实现了每秒钟父进程向子进程都发送消息的功能。
#include <stdio.h>
#include <string.h>
#include <unistd.h>
int main() {
    int _pipe[2];
    int ret = pipe(_pipe);
    if (ret < 0) {
        perror("pipe\n");
    }
    pid_t id = fork();
    if (id < 0) {
        perror("fork\n");
    } else if (id == 0) {  // 子进程
        close(_pipe[1]);
        int j = 0;
        char _mesg[100];
        while (j < 100) {
            memset(_mesg, '\0', sizeof(_mesg));
            read(_pipe[0], _mesg, sizeof(_mesg));
            printf("%s\n", _mesg);
            j++;
        }
    } else {  // 父进程
        close(_pipe[0]);
        int i = 0;
        char *mesg = NULL;
        while (i < 100) {
            mesg = "父进程来写消息了";
            write(_pipe[1], mesg, strlen(mesg) + 1);
            sleep(1);
            ++i;
        }
    }
    return 0;

}

我们平时也经常使用关于管道的命令行:

ls | less
  1. 创建管道
  2. 为 ls 创建一个进程,设置 stdout 为管理写端
  3. 为 less 创建一个进程,设置 stdin 为管道读端
高级管道

通过 popen 将另一个程序当作一个新的进程在当前进程中启动,它算作当前进程的子进程,高级管道只能用在有亲缘关系的进程间通信,这种亲缘关系通常指父子进程,下面的 GetCmdResult 函数可以获取某个 Linux 命令执行的结果,实现方式就是通过 popen。

std::string GetCmdResult(const std::string &cmd, int max_size = 10240) {
    char *data = (char *)malloc(max_size);
    if (data == NULL) {
        return std::string("malloc fail");
    }
    memset(data, 0, max_size);
    const int max_buffer = 256;
    char buffer[max_buffer];
    // 将标准错误重定向到标准输出
    FILE *fdp = popen((cmd + " 2>&1").c_str(), "r");
    int data_len = 0;

    if (fdp) {
        while (!feof(fdp)) {
            if (fgets(buffer, max_buffer, fdp)) {
                int len = strlen(buffer);
                if (data_len + len > max_size) {
                    cout << "data size larger than " << max_size;
                    break;
                }
                memcpy(data + data_len, buffer, len);
                data_len += len;
            }
        }
        pclose(fdp);
    }
    std::string ret(data, data_len);
    free(data);
    return ret;
}
命名管道

有名字,可以双向传输,可以在非亲缘关系的进程间传输。

匿名管道有个缺点就是通信的进程一定要有亲缘关系,而命名管道就不需要这种限制。

命名管道其实就是一种特殊类型的文件,所谓的命名其实就是文件名,文件对各个进程都可见,通过命名管道创建好特殊文件后,就可以实现进程间通信。

可以通过 mkfifo 创建一个特殊的类型的文件,参数读者看名字应该就了解,一个是文件名,一个是文件的读写权限:

int mkfifo(const char* filename, mode_t mode);

当返回值为 0 时,表示该命名管道创建成功,至于如何通信,其实就是个读写文件的问题!

管道的优点:方便,可以传输大量数据。

管道的缺点:效率相对于共享内存来说较低。

消息队列

队列想必大家都知道,像 FIFO 一样,这里可以有多个进程写入数据,也可以有多个进程从队列里读出数据,但消息队列有一点比 FIFO 还更高级,它读消息不一定要使用先进先出的顺序,每个消息可以赋予类型,可以按消息的类型读取,不是指定类型的数据还存在队列中。本质上 MessageQueue 是存放在内核中的消息链表,每个消息队列链表会由消息队列标识符表示,这个消息队列存于内核中,只有主动的删除该消息队列或者内核重启时,消息队列才会被删除。

在 Linux 中消息队列相关的函数调用如下:

// 创建和访问一个消息队列
int msgget(key_t, key, int msgflg);
// 用来把消息添加到消息队列中
int msgsend(int msgid, const void *msg_ptr, size_t msg_sz, int msgflg);
// msg_ptr是结构体数据的指针,结构第一个字段要有个类型:struct Msg {
    long int message_type;
    // 想要传输的数据
};
// 从消息队列中获取消息
int msgrcv(int msgid, void *msg_ptr, size_t msg_st, long int msgtype, int msgflg);
// 用来控制消息队列,不同的command参数有不同的控制方式
int msgctl(int msgid, int command, struct msgid_ds *buf);

示例代码如下:

#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/msg.h>

#include <chrono>
#include <iostream>
#include <thread>

using namespace std;

#define BUFFER_SIZ 20

typedef struct {
    long int msg_type;
    char text[BUFFER_SIZ];
} MsgWrapper;

void Receive() {
    MsgWrapper data;
    long int msgtype = 2;
    int msgid = msgget((key_t)1024, 0666 | IPC_CREAT);
    if (msgid == -1) {
        cout << "msgget error \n";
        return;
    }
    while (true) {
        if (msgrcv(msgid, (void *)&data, BUFFER_SIZ, msgtype, 0) == -1) {
            cout << "error " << errno << endl;
        }
        cout << "read data " << data.text << endl;
        if (strlen(data.text) > 6) {  // 发送超过6个字符的数据,结束
            break;
        }
    }
    if (msgctl(msgid, IPC_RMID, 0) == -1) {
        cout << "msgctl error \n";
    }
    cout << "Receive ok \n";
}

void Send() {
    MsgWrapper data;
    long int msgtype = 2;
    int msgid = msgget((key_t)1024, 0666 | IPC_CREAT);
    if (msgid == -1) {
        cout << "msgget error \n";
        return;
    }
    data.msg_type = msgtype;
    for (int i = 0; i < 10; ++i) {
        memset(data.text, 0, BUFFER_SIZ);
        char a = 'a' + i;
        memset(data.text, a, 1);
        if (msgsnd(msgid, (void *)&data, BUFFER_SIZ, 0) == -1) {
            cout << "msgsnd error \n";
            return;
        }
        std::this_thread::sleep_for(std::chrono::seconds(1));
    }
    memcpy(data.text, "1234567", 7);
    if (msgsnd(msgid, (void *)&data, BUFFER_SIZ, 0) == -1) {
        cout << "msgsnd error \n";
        return;
    }
}

int main() {
    std::thread r(Receive);
    r.detach();
    std::thread s(Send);
    s.detach();
    std::this_thread::sleep_for(std::chrono::seconds(20));
    return 0;
}

输出:root@iZuf64idor3ej648ciairaZ:~# ./a.out
read data a
read data b
read data c
read data d
read data e
read data f
read data g
read data h
read data i
read data j
read data 1234567
Receive ok

代码中为了演示方便使用消息队列进行的线程间通信,该代码同样用于进程间通信,消息队列的实现依赖于内核的支持,上述代码可能在某些系统(WSL)上不能运行,在正常的 Ubuntu 上可以正常运行。

消息队列 vs 管道

消息队列 > 命名管道

  1. 消息队列收发消息自动保证了同步,不需要由进程自己来提供同步方法,而命名管道需要自行处理同步问题;
  2. 消息队列接收数据可以根据消息类型有选择的接收特定类型的数据,不需要像命名管道一样默认接收数据。

消息队列 < 命名管道

消息队列有一个缺点就是发送和接收的每个数据都有最大长度的限制。

共享内存

如图:

多个进程共同使用一块内存,自然可以通信。

可开辟中一块内存,用于各个进程间共享,使得各个进程可以直接读写同一块内存空间,就像线程共享同一块地址空间一样,该方式基本上是最快的进程间通信方式,因为没有系统调用干预,也没有数据的拷贝操作,但由于共享同一块地址空间,数据竞争的问题就会出现,需要自己引入同步机制解决数据竞争问题。

共享内存只是一种方式,它的实现方式有很多种,主要的有 mmap 系统调用、Posix 共享内存以及 System V 共享内存等。通过这三种“工具”共享地址空间后,通信的目的自然就会达到。

优点:效率高。

缺点:对开发者编程能力要求很高,很容易写出问题,特别是并发环境下。

信号量

信号量就是一个特殊的变量,程序对其访问都是原子操作,每个信号量开始都有个初始值。最简单最常见的信号量是只能取 0 和 1 的变量,也叫二值信号量。

信号量有两个操作,P 和 V:

  • P:如果信号量变量值大于 0,则变量值减 1,如果值为 0,则阻塞进程;
  • V:如果有进程阻塞在该信号量上,则唤醒阻塞的进程,如果没有进程阻塞,则变量值加 1

信号

信号也是进程间通信的一种方式,信号可以在任何时候发送给某一个进程,如果进程当前并未处于执行状态,内核将信号保存,直到进程恢复到执行态再发送给进程,进程可以对信号设置预处理方式,如果对信号设置了阻塞处理,则信号的传递会被延迟直到阻塞被取消,如果进程结束,那信号就被丢弃。我们常用的 CTRL+C 和 kill 等就是信号的一种,也达到了进程间通信的目的,进程也可以对信号设置 signal 捕获函数自定义处理逻辑。

这种方式有很大的缺点:只有通知的作用,通知了一下消息的类型,但不能传输要交换的任何数据。

Linux 系统中常见的信号有:

  • SIGHUP:该信号在用户终端结束时发出,通常在中断的控制进程结束时,所有进程组都将收到该信号,该信号的默认操作是终止进程;
  • SIGINT:程序终止信号,通常的 CTRL+C 产生该信号来通知终止进程;
  • SIGQUIT:类似于程序错误信号,通常的 CTRL+\产生该信号通知进程退出时产生 core 文件;
  • SIGILL:执行了非法指令,通常数据段或者堆栈溢出可能产生该信号;
  • SIGTRAP:供调试器使用,由断电指令或其它陷阱指令产生;
  • SIGABRT:使程序非正常结束,调用 abort 函数会产生该信号;
  • SIGBUS:非法地址,通常是地址对齐问题导致,比如访问一个 4 字节长的整数,但其地址不是 4 的倍数;
  • SIGSEGV:合理地址的非法访问,访问了未分配的内存或者没有权限的内存区域;
  • SIGPIPE:管道破裂信号,socket 通信时经常会遇到,进程写入了一个无读者的管道;
  • SIGALRM:时钟定时信号,由 alarm 函数设置的时间终止时产生;
  • SIGFPE:出现浮点错误(比如除 0 操作);
  • SIGKILL:杀死进程(不能被捕捉和忽略);

文件

显而易见,多个进程可以操作同一个文件,所以也可以通过文件来进行进程间通信。

socket

很类似于网络通信,分为客户端和服务端:

线程通信

所有线程共用所在进程的内存,所以肯定可以通信。但要注意线程安全问题,需要通过锁机制或者原子机制避免多线程环境下的数据竞争问题。

🌟进程调度

当系统中有多个进程同时竞争 CPU,如果只有一个 CPU 可用,那同一时刻只会有一个进程处于运行状态,操作系统必须要选择下一个要运行的是哪个进程,在操作系统中,完成选择工作的这部分称为调度程序,该程序使用的算法称作调度算法

什么时候进行调度?

  1. 系统调用创建一个新进程后,需要决定是运行父进程还是运行子进程
  2. 一个进程退出时需要做出调度决策,需要决定下一个运行的是哪个进程
  3. 当一个进程阻塞在 I/O 和信号量或者由于其它原因阻塞时,必须选择另一个进程运行
  4. 当一个 I/O 中断发生时,如果中断来自 IO 设备,而该设备现在完成了工作,某些被阻塞的等待该 IO 的进程就成为可运行的就绪进程了,是否让新就绪的进程运行,或者让中断发生时运行的进程继续运行,或者让某个其它进程运行,这就取决于调度程序的抉择了。

调度的准则

  • CPU利用率:如何调度可以使 CPU 的利用率达到最大
  • 系统吞吐率:系统吞吐量表示单位时间内 CPU 完成作业的数量
  • 响应时间:调度策略要尽量保证响应时间在用户接受的范围内
  • 周转时间:周转时间是作业从开始到完成所需的时间,尽量使这个时间较小。

调度的策略

不同系统环境下有不同的调度策略算法。调度算法也是有 KPI 的,对调度算法首先提的需求就是:

  • 公平:调度算法需要给每个进程公平的 CPU 份额,相似的进程应该得到相似的服务,对一个进程给予较其它等价的进程更多的 CPU 时间是不公平的,被普通水平的应届生工资倒挂也是不公平的!
  • 执行力:每一个策略必须强制执行,需要保证规定的策略一定要被执行。
  • 平衡:需要保证系统的所有部分尽可能都忙碌

进程调度算法 ⭐️⭐️⭐️

进程调度:多个进程都在等待处理器调度执行,处理器如何选择?

进程调度分为 抢占式非抢占式

非抢占式调度算法:挑选一个进程,然后让该进程运行直至被阻塞,或者直到该进程自动释放 CPU,即使该进程运行了若干个小时,它也不会被强迫挂起。这样做的结果是,在时钟中断发生时不会进行调度,在处理完时钟中断后,如果没有更高优先级的进程等待,则被中断的进程会继续执行。简单来说,调度程序必须等待事件结束。

非抢占方式引起进程调度的条件:

  • 进程执行结束,或发生某个事件而不能继续执行
  • 正在运行的进程因有 I/O 请求而暂停执行
  • 进程通信或同步过程中执行了某些原语操作(wait、block 等)

抢占式调度算法:挑选一个进程,并且让该进程运行某个固定时段的最大值。如果在该时段结束时,该进程仍在运行,它就被挂起,而调度程序挑选另一个进程运行,进行抢占式调度处理,需要在时间间隔的末端发生时钟中断,以便 CPU 控制返回给调度程序,如果没有可用的时钟,那么非抢占式调度就是唯一的选择。简单来说,就是当前运行的进程在事件没结束时就可以被换出,防止单一进程长时间独占 CPU 资源。下面会介绍很多抢占式调度算法:优先级算法、短作业优先算法、轮转算法等。

常见的进程调度算法:

因为不同的应用有不同的目标,不同的系统中,调度程序的优化也是不同的,大体可以分为三种环境:

批处理系统

批处理系统的管理者为了掌握系统的工作状态,主要关注三个指标:

  • 吞吐量:是系统每小时完成的作业数量
  • 周转时间:指从一个作业提交到完成的平均时间
  • CPU 利用率:尽可能让 CPU 忙碌,但又不能过量

具体调度算法:

  • 先来先服务:先来后到,就像平时去商店买东西需要排队一样,使用该算法,进程按照它们请求 CPU 的顺序来使用 CPU,该算法最大的优点就是简单易于实现,太容易的不一定是好的,该算法也有很大的缺点:平均等待时间波动较大,时间短的任务可能排队排在了时间长的任务后面。举个生活中的例子,排着队去取快递,如果每个人都很快取出来快递还好,如果前面有几个人磨磨唧唧到快递柜前才拿出手机打开 app,再找半分钟它的取件码,就会严重拖慢后面的人取快递的速度,同理排着队的进程如果每个进程都很快就运行完还好,如果其中有一个得到了 CPU 的进程运行时候磨磨唧唧很长时间都运行不完,那后面的进程基本上就没有机会运行了!
  • 短作业优先调度:该调度算法是非抢占式的算法,每个进程执行期间不会被打断,每次都选择执行时间最短的进程来调度,但问题来了,操作系统怎么可能知道进程具体的执行时间呢,所以该算法注定是基于预测性质的理想化算法,而且有违公平性,而且可能导致运行时间长的任务得不到调度。
  • 最短剩余时间优先:该调度算法是抢占式的算法,是最短作业优先的抢占版本,在进程运行期间,如果来了个更短时间的进程,那就转而去把 CPU 时间调度给这个更短时间的进程,它的缺点和最短作业优先算法类似。

交互式系統

对于交互系统最重要的指标就是响应时间和均衡性:

  • 响应时间:一个请求被提交到产生第一次响应所花费的时间。你给别人发微信别人看后不回复你或者几个小时后才回复你,你是什么感受,这还是交互式吗?
  • 均衡性:减少平均响应时间的波动。需要符合固有期望和预期,你给别人发微信,他有时候秒回复,有时候几个小时后才回复。在交互式系统中,可预测性比高差异低平均更重要。

具体调度算法:

  • 轮转调度:每个进程被分配一个时间段,称为时间片,即 CPU 做到雨露均沾,轮流翻各个进程的牌子,这段时间宠幸进程 A,下一段时间宠幸进程 B,再下一段时间宠幸进程 C,确保每个进程都可以获得 CPU 时间,如果 CPU 时间特别短的话,在外部看来像是同时宠幸了所有进程一样。那么问题来了,这个时间片究竟多长时间好呢?如果时间片设的太短会导致过多的进程切换,频繁的上下文切换会降低 CPU 效率,而如果时间片设的太长又可能对短的交互请求的响应时间变长,通常将时间片设为 20-50ms 是个比较合理的折中,大佬们的经验规则时维持上下文切换的开销处于 1% 以内。
  • 优先级调度:上面的轮转调度算法是默认每个进程都同等重要,都有相同优先级,然而有时候进程需要设置优先级,例如某些播放视频的前台进程可以优先于某些收发邮件的后台守护进程被调度,在优先级调度算法中,每个优先级都有相应的队列,队列里面装着对应优先级的进程,首先在高优先级队列中进行轮转调度,当高优先级队列为空时,转而去低优先级队列中进行轮转调度,如果高优先级队列始终不为空,那么低优先级的进程很可能就会饥饿到很久不能被调度。
  • 多级队列:多级队列算法与优先级调度算法不同,优先级算法中每个进程分配的是相同的时间片,而在多级队列算法中,不同队列中的进程分配给不同的时间片,当一个进程用完分配的时间片后就移动到下一个队列中,这样可以更好的避免上下文频繁切换。举例:有一个进程需要 100 个时间片,如果每次调度都给分配一个时间片,则需要 100 次上下文切换,这样 CPU 运行效率较低,通过多级队列算法,可以考虑最开始给这个进程分配 1 个时间片,然后被换出,下次分给它 2 个时间片,再换出,之后分给它 4、8、16、64 个时间片,这样分配的话,该进程只需要 7 次交换就可以运行完成,相比 100 次上下文切换运行效率高了不少,但顾此就会失彼,那些需要交互的进程得到响应的速度就会下降。
  • 最短进程优先:交互式系统中应用最短进程优先算法其实是非常适合的,每次都选择执行时间最短的进程进行调度,这样可以使任务的响应时间最短,但这里有个任务,还没有运行呢,我怎么知道进程的运行时间呢?根本没办法非常准确的再当前可运行进程中找出最短的那个进程。有一种办法就是根据进程过去的行为进行预测,但这能证明是个好办法吗?
  • 保证调度:这种调度算法就是向用户做出明确的可行的性能保证,然后去实现它。一种很实际的可实现的保证就是确保 N 个用户中每个用户都获得 CPU 处理能力的 1/N,类似的,保证 N 个进程中每个进程都获得 1/N 的 CPU 时间。
  • 彩票调度:彩票调度算法基本思想是为进程提供各种资源(CPU 时间)的彩票,一旦需要做出调度决策时,就随机抽出一张彩票,拥有该彩票的进程获得该资源,很明显,拥有彩票越多的进程,获得资源的可能性越大。该算法在程序喵看来可以理解为股票算法,将 CPU 的使用权分成若干股,假设共 100 股分给了 3 个进程,给这些进程分别分配 20、30、50 股,那么它们大体上会按照股权比例(20:30:50)划分 CPU 的使用。
  • 公平分享调度:假设有系统两个用户,用户 1 启动了 1 个进程,用户 2 启动了 9 个进程,如果使用轮转调度算法,那么用户 1 将获得 10% 的 CPU 时间,用户 2 将获得 90% 的 CPU 时间,这对用户来说公平吗?如果给每个用户分配 50% 的 CPU 时间,那么用户 2 中的进程获得的 CPU 时间明显比用户 1 中的进程短,这对进程来说公平吗?这就取决于怎么定义公平啦?

实时系统

实时系统顾名思义,最关键的指标当然是实时:

  • 满足截止时间:需要在规定 deadline 前完成作业;
  • 可预测性:可预测性是指在系统运行的任何时刻,在任何情况下,实时系统的资源调配策略都能为争夺资源的任务合理的分配资源,使每个实时任务都能得到满足。

调度算法分类:

  • 硬实时:必须在 deadline 之前完成工作,如果 delay,可能会发生灾难性或发生严重的后果;
  • 软实时:必须在 deadline 之前完成工作,但如果偶尔 delay 了,也可以容忍。

具体调度算法:

  • 单调速率调度:采用抢占式、静态优先级的策略,调度周期性任务。每个任务最开始都被配置好了优先级,当较低优先级的进程正在运行并且有较高优先级的进程可以运行时,较高优先级的进程将会抢占低优先级的进程。在进入系统时,每个周期性任务都会分配一个优先级,周期越短,优先级越高。这种策略的理由是:更频繁的需要 CPU 的任务应该被分配更高的优先级。
  • 最早截止时间调度:根据截止时间动态分配优先级,截止时间越早的进程优先级越高。该算法中,当一个进程可以运行时,它应该向操作系统通知截止时间,根据截止时间的早晚,系统会为该进程调整优先级,以便满足可运行进程的截止时间要求。它与单调速率调度算法的区别就是一个是静态优先级,一个是动态优先级。

⏱️ 进程调度算法对比

📌 调度算法分类

调度类型
特点
适用场景
优缺点
非抢占式
进程持续运行直到阻塞或结束
批处理系统
✅ 简单高效
❌ 长任务可能阻塞短任务
抢占式
通过时间片强制切换进程
交互式/实时系统

✅ 公平性高
❌ 上下文切换开销大

🔄 常见调度算法对比

算法名称
调度策略
系统类型
关键特点
先来先服务 (FCFS)
按请求顺序执行
批处理
❌ 平均等待时间波动大
✅ 实现简单
短作业优先 (SJF)
选择预估执行时间最短的进程
批处理
✅ 最小化平均等待时间
❌ 可能导致长任务饥饿
轮转调度 (RR)
固定时间片轮流执行
交互式
⚖️ 时间片设置敏感(推荐20-50ms)
✅ 公平性高
优先级调度
按优先级分配CPU,同优先级轮转
交互式/实时
❌ 低优先级可能饥饿
✅ 支持关键任务优先
多级队列
不同队列分配不同时间片,进程随时间片用完降级
交互式
✅ 减少短作业切换开销
❌ 长作业响应延迟
单调速率 (RM)
周期越短优先级越高(静态)
实时系统
✅ 可预测性强
❌ 需已知任务周期
最早截止时间 (EDF)
截止时间越早优先级越高(动态)
实时系统
✅ 动态适应性强
❌ 计算复杂度较高

📊 性能指标对比

指标
批处理系统关注
交互式系统关注
实时系统关注
核心目标
吞吐量、周转时间
响应时间、均衡性
截止时间、可预测性
优化方向
CPU利用率最大化
用户交互体验
任务时限保证
典型算法
FCFS/SJF
RR/优先级
RM/EDF

如何配置调度策略

调度算法有很多种,各有优缺点,操作系统自己很少能做出最优的选择,那么可以把选择权交给用户,由用户根据实际情况来选择适合的调度算法,这就叫策略与机制分离,调度机制位于内核,调度策略由用户进程决定,将调度算法以某种形式参数化,由用户进程来选择参数从而决定内核使用哪种调度算法。

操作系统如何完成的进程调度?

进程的每次变化都会有相应的状态,而操作系统维护了一组状态队列,表示系统中所有进程的当前状态;不同的状态有不同的队列,有就绪队列阻塞队列等,每个进程的 PCB 都根据它的状态加入到相应的队列中,当一个进程的状态发生变化时,它的 PCB 会从一个状态队列中脱离出来加入到另一个状态队列。

注意图中同一种状态为什么有多个队列呢?因为进程有优先级概念,相同状态的不同队列的优先级不同。

🌟同步与互斥

进程同步的方法

操作系统中,进程是具有不同的地址空间的,两个进程是不能感知到对方的存在的。有时候,需要多个进程来协同完成一些任务。

当多个进程需要对同一个内核资源进行操作时,这些进程便是竞争的关系,操作系统必须协调各个进程对资源的占用,进程的互斥是解决进程间竞争关系的方法。 进程互斥指若干个进程要使用同一共享资源时,任何时刻最多允许一个进程去使用,其他要使用该资源的进程必须等待,直到占有资源的进程释放该资源。

当多个进程协同完成一些任务时,不同进程的执行进度不一致,这便产生了进程的同步问题。需要操作系统干预,在特定的同步点对所有进程进行同步,这种协作进程之间相互等待对方消息或信号的协调关系称为进程同步。进程互斥本质上也是一种进程同步。

进程的同步方法:

  1. 互斥锁
  2. 读写锁
  3. 条件变量
  4. 记录锁(record locking)
  5. 信号量
  6. 屏障(barrier)
序号
同步方法
描述
1
互斥锁
确保任意时刻最多只有一个进程访问共享资源,用于实现对共享资源的互斥访问。
2
读写锁
多个读操作可以同时进行,但在写操作时不允许其他读或写操作,适用于读多写少的场景。
3
条件变量
允许线程在某个条件满足之前阻塞自己,常与互斥锁结合使用来协调不同线程间的执行顺序。
4
记录锁(record locking)
进程可以锁定文件的部分或全部内容,以避免与其他进程对该部分内容的并发访问冲突。
5
信号量
可以用来控制对共享资源的访问,通过计数器机制允许一定数量的线程同时访问资源。
6
屏障(barrier)
设定一个同步点,所有参与的进程/线程必须到达该点后才能继续执行,确保所有进程同步进行。

线程同步的方法

操作系统中,属于同一进程的线程之间具有相同的地址空间,线程之间共享数据变得简单高效。遇到竞争的线程同时修改同一数据或是协作的线程设置同步点的问题时,需要使用一些线程同步的方法来解决这些问题。

线程同步的方法:

  1. 互斥锁
  2. 读写锁
  3. 条件变量
  4. 信号量
  5. 自旋锁
  6. 屏障(barrier)
序号
同步方法
描述
1
互斥锁
确保任意时刻最多只有一个线程访问共享资源,用于实现对共享资源的互斥访问。
2
读写锁
多个读操作可以同时进行,但在写操作时不允许其他读或写操作,适用于读多写少的场景。
3
条件变量
允许线程在某个条件满足之前阻塞自己,常与互斥锁结合使用来协调不同线程间的执行顺序。
4
信号量
可以用来控制对共享资源的访问,通过计数器机制允许一定数量的线程同时访问资源。
5
自旋锁
线程在等待获取锁的时候不会进入睡眠状态,而是循环等待,适合于锁被持有的时间非常短的情况。
6
屏障(barrier)
设定一个同步点,所有参与的线程必须到达该点后才能继续执行,确保所有线程同步进行。

进程同步与线程同步有什么区别

进程之间地址空间不同,不能感知对方的存在,同步时需要将锁放在多进程共享的空间。而线程之间共享同一地址空间,同步时把锁放在所属的同一进程空间即可。

临界区和临界资源

临界资源:同时只能允许一个进程访问的资源。

临界区:进程中用于访问临界资源的代码。

如何防止多个进程同时进入临界区?

  • 闲时让进:空闲状态你可以进入
  • 忙则等待:有人正在用,你等一等
  • 有限等待:不会让你一直等的放心好啦
  • 让权等待:你出问题了,就别占着位置啦,让别人用吧。

同步和互斥的经典示例

生产者-消费者问题

一组生产者向一组消费者提供产品,他们共享一个缓冲区,生产者会向里面投放物品,消费者从里面取走产品。 但是这个里面存在一些问题:

  • 缓冲区内没有物品,消费者还不断取
  • 缓冲区已经满了,生产者还在不断放入
  • 多个消费者同时取

读写锁

多线程环境下对共享资源的读多写少场景,使用读写锁正适合,它通过区分读操作和写操作,可以显著提高并发性能。

  • 读锁:多个线程可同时持有读锁(共享访问)。
  • 写锁:仅一个线程能持有写锁,且期间不允许任何读锁或其他写锁(独占访问)。

优势:

  • 读操作不互斥,适合读多写少的场景(如缓存、数据库)。
  • 写操作独占资源,避免数据竞争。

读饥饿是什么

读饥饿是读写锁中的一种现象,指写线程因读线程持续占用锁而长时间无法获取写锁,导致写操作被“饿死”(无法执行)。这种现象在高并发读场景下很常见。

原因:

  • 多个读线程频繁获取读锁(共享访问)。
  • 写线程尝试获取写锁时,必须等待所有读锁释放。
  • 若读锁的获取是连续的(无间隙),写线程将无限等待。

危害:

  • 数据更新延迟:写操作无法执行,导致数据长时间不更新。
  • 系统响应下降:写线程阻塞可能引发超时或死锁。
  • 公平性破坏:违背“先来先服务”原则。

解决方案:

  • 公平读写锁:通过队列或优先级机制,保证写锁请求不会被后续读锁插队。比如一旦有写锁请求,后续读锁必须等待写锁完成。
  • 限制读锁持有时间:设置读锁的最大持有时间,超时后自动释放。
  • 避免长期持有读锁:将长耗时读操作拆分为多个短操作,间歇释放锁。