操作系统之线程和进程

本文档用于梳理操作系统中线程和进程的核心概念,查缺补漏,巩固基础知识。

进程与线程及应用场景

本质区别:

  • 进程是资源分配的基本单位,相当于一个独立运行的程序,有独立的内存空间。
  • 线程是CPU调度的基本单位,是进程内的执行单元,共享进程的内存空间。

主要区别:

  1. 资源开销:进程开销大(需要独立内存),线程开销小(共享内存)
  2. 通信方式:进程间通信复杂(需要IPC机制),线程间通信简单(直接访问共享变量)
  3. 稳定性:进程间互不影响,一个线程崩溃可能导致整个进程崩溃。
  4. 创建销毁:进程慢,线程快

进程适用场景:

  • 需要高稳定性的场景
  • 需要安全隔离的场景
  • 独立应用之间的协作

线程适用场景:

  • 需要频繁数据交互的场景
  • 计算密集型任务的并行处理
  • IO密集型任务
  • 需要快速响应的场景

线程的实现方式(用户级 vs 内核级)

用户级线程(User-Level Thread,ULT)

实现原理:

  • 线程管理由用户空间的线程库完成
  • 内核不知道线程的存在,只看到进程
  • 线程切换在用户态完成,不需要系统调用

优点:

  • 切换快,无需陷入内核,不用上下文切换特权级
  • 开销小,创建、销毁、调度都在用户空间
  • 灵活,可以自定义调度算法
  • 跨平台,不依赖内核支持

缺点:

  • 无法利用多核,内核只调度进程,多个线程只能在一个CPU上跑
  • 阻塞问题,一个线程阻塞,整个进程都阻塞
  • 无时间片,内核不感知线程,无法给每个线程分配CPU时间

内核级线程(Kernel-Level Thread,KLT)

实现原理:

  • 线程管理由操作系统内核完成
  • 内核为每个线程维护TCB(线程控制块)
  • 线程切换需要系统调度,涉及内核态切换

优点:

  • 支持多核,内核可以把不同的线程调度到不同的CPU
  • 不会阻塞整个进程,一个线程阻塞,其他线程继续运行
  • 公平调度,内核给每个线程分配时间片

缺点:

  • 切换慢。需要用户态和内核态切换,开销大
  • 资源消耗大,每个线程都需要内核资源
  • 数量限制,内核线程数量受系统限制

进程的状态转换图及各状态转换触发条件

进程三种基本状态

  • 就绪态(Ready):进程已获得除CPU外的所有资源,等待CPU调度
  • 运行态(Running):进程正在CPU上执行
  • 阻塞态(Blocked/Waiting):进程等待某个事件(如IO完成),暂时无法运行

进程转换图

进程创建
资源分配完成
获得CPU(调度)
时间片用完
或被高优先级抢占
等待IO/资源
(主动请求)
执行完成
或异常终止
IO完成
或事件发生
资源回收
新建态
就绪态
运行态
阻塞态
终止态
已获得除CPU外
的所有资源
正在CPU上执行
等待事件,
不能直接到运行态

状态转换的触发条件

  1. 创建 → 就绪

    进程创建完成,分配了PCB和必要资源。比如:fork()系统调用后,子进程进入就绪队列

  2. 就绪 → 运行

    进程调度器选中该进程,分配CPU。比如:时间片轮询算法选中该进程

  3. 运行 → 就绪

    遇到时间片用完(时间片轮转)或者更高优先级的进程抢占(抢占式调度)。比如:进程运行10ms后时间片到,被放回就绪队列

  4. 运行 → 阻塞

    等待IO操作(读取文件,网络请求)或申请资源失败(信号量P操作失败)或等待事件(wait等待子进程,sleep()睡眠)。比如:进程执行read()读文件,等待磁盘IO完成。

    注意:这是主动行为,进程自己请求阻塞。

  5. 阻塞 → 就绪

    IO操作完成(磁盘中断通知)或等待的事件发送(子进程结束、锁释放)或睡眠时间到。比如:文件读取完成,磁盘控制器发出中断,进程被唤醒。

    注意:不能直接到运行态,需要先进入就绪队列排队

  6. 运行 → 终止

    正常结束(exit()系统调用)或异常终止(段错误,除零错误)或被杀死(kill信号)。比如:程序执行完return 0,进程结束。

五态模型(扩展)

新增了两个状态

新建态(New)

  • 进程刚创建,还未进入就绪队列
  • 正在申请资源

终止态(Terminated)

  • 进程结束,等待系统收回资源
  • 也叫僵尸态(Zombie)

七态模型(带挂起)

如果考虑内存交换,还有:

就绪挂起(Ready Suspended):进程在外存中等待,等待被调入内存

阻塞挂起(Blocked Suspended):进程在外存中等待事件

触发条件:

  • 内存不足时,系统将低优先级进程换出到磁盘(挂起)
  • 内存充足或优先级提高,换回内存(激活)

注意

  1. 阻塞态不可能直接到运行态,必须先到就绪态
  2. 就绪态不可能直接到阻塞态,只有运行的进程才能请求IO或资源
  3. 运行态只有一个CPU就只有一个进程,多核CPU可以多个进程同时运行
  4. 状态转换是原子操作,由操作系统内核完成,不会被打断。

实际例子

  1. 新建 → 就绪(下载线程创建)
  2. 就绪 → 运行(获得CPU)
  3. 运行 → 阻塞(发起网络IO,等待数据)
  4. 阻塞 → 就绪(网络数据到达,网卡中断)
  5. 就绪 → 运行(再次获得CPU)
  6. 运行 → 就绪(时间片到,让出CPU)
  7. …循环直到下载完成
  8. 运行 → 终止(下载完成,线程结束)

上下文切换的具体开销来源

直接开销

保存和恢复CPU寄存器

  • 保存当前进程的通用寄存器,程序计数器,栈指针,状态寄存器
  • 恢复下一个进程的寄存器

切换内核栈

  • 每个进程有独立的内核栈
  • 需要切换栈指针

切换页表(虚拟内存映射)

  • 更新CR3寄存器(指向页表基址)
  • 不同进程有不同的虚拟地址空间

内核态和用户态切换

  • 进程切换需要陷入内核
  • 涉及特权级切换

间接开销

TLB失效(Translation Lookaside Buffer)

  • TLB是页表缓存,加速地址转换
  • 切换进程后,TLB内容失效(除非支持ASID/PCID)
  • 新进程运行时需要重新填充TLB

CPU缓存失效(Cache Miss)

  • L1/L2/L3缓存中旧进程的数据失效
  • 新进程需要从内存加载数据到缓存
  • Cache冷启动问题

流水线清空

  • CPU指令流水线需要重新填充
  • 分支预测失控

调度器开销

  • 选择下一个运行的进程
  • 更新调度队列,统计信息

上下文切换开销优化策略

减少切换次数

  • 增加时间片
  • 使用亲和性

使用线程代替进程

  • 同一进程的线程共享地址空间
  • 切换不需要切换页表
  • TLB和缓存可以部分保留

使用协程/纤程(Coroutine/Fiber)

  • 用户态调度,不陷入内核
  • 只保留栈指针和程序计数器
  • 不涉及页表、TLB切换

减少线程/进程数量

  • 使用IO多路复用,一个线程处理多个连接,减少上下文切换
  • 线程池,复用线程,避免频繁创建和销毁

硬件层面优化

  • 使用PCID,现代计算机支持,TLB条目自带进程标识,切换时不完全失效
  • 使用ASID,ARM架构的类似技术,减少TLB刷新

算法层面优化

  • 批处理
  • 工作窃取
  • 用户态调度器

操作系统配置

  • 调整调度策略
  • 禁用不必要的后台任务

实际案例

  1. Nginx(事件驱动):使用epoll + 少量worker进程,避免大量进程切换,单worker处理数千连接
  2. Redis(单线程):单线程 + IO多路复用,完全避免上下文切换,性能极高
  3. 数据库连接池:复用连接,减少进程/线程创建,降低切换频率

多线程与多进程的选择依据

维度 多线程 多进程
内存 共享内存空间 独立内存空间
通信 直接访问共享变量 需要IPC(管道/消息队列等)
开销 小(创建/切换快) 大(创建/切换慢)
稳定性 一个线程崩溃全崩 进程间互不影响
多核利用 支持 支持
编程难度 复杂(需要同步) 相对简单(天然隔离)

数据共享需求

选择多线程:需要频繁共享数据,数据量大,拷贝成本高

选择多进程:数据相对独立,不需要频繁通信

稳定性和容错要求

选择多线程:可靠性要求不高,追求性能

选择多进程:可靠性要求高,一个任务失败不能影响其他任务

资源消耗考虑

选择多线程:资源有限,需要大量并发单元

选择多进程:资源充足,并发数量较少

开发和调式难度

选择多线程:团队有并发编程经验,能够处理竞态条件,死锁等问题

选择多进程:团队经验不足,追求简单可靠,不想处理复杂的锁和同步

任务特性

选择多线程:大量IO等待,线程可以等待时让出CPU

选择多进程:计算密集任务,避免GIL限制,充分利用多核

安全性和权限隔离

选择多线程:信任所有代码,同一用户/权限

选择多进程:需要权限隔离,安全敏感场景

进程间通信(IPC)的机制

共享内存(Shared Memory)

实现原理:操作系统分配一块物理内存,将这块内存映射到多个进程的虚拟地址空间,进程直接读写这块内存。

特点:需要配合信号量/互斥锁解决同步问题,无数据拷贝,效率最快,编程复杂度高

管道(Pipe)

实现原理:操作系统在内核中创建一个缓存区,一个进程写入,另一个进程读取,单向传输。半双工通信,数据先进先出

特点:只能用于亲缘关系的进程(父子进程),匿名管道随进程结束而消失,命名管道可无亲缘关系进程通信

消息队列(Message Queue)

实现原理:内核维护一个消息链表,进程可以按消息类型读取,不必按FIFO顺序,每条消息有类型标识和数据内容

特点:支持随机读取(可按类型选择),消息的固定格式和大小限制,消息持久化,进程结束后消息仍然存在

套接字(Socket)

实现原理:通过网络协议栈通信,可以跨主机通信(TCP/UDP),本地Socket(Unix Domain Socket)性能更高

特点:支持跨网络通信,双向通信,使用灵活但开销大

信号量(Semaphore)

实现原理:一个计数器,用于控制资源访问,P操作(Wait)计数减1,为0则阻塞。V操作(signal)计数加1,唤醒等待进程

特点:主要用于同步,不直接传递数据,解决资源共享的互斥访问问题

信号(Signal)

实现原理:操作系统向进程发送软件中断,进程收到信号后执行预定义的处理函数

特点:传递信号量少,只是通知,用于异步通知

死锁的四个必要条件

同时满足会发生死锁

  1. 互斥条件(Mutual Exclusion):资源不能被多个进程同时使用
  2. 持有并等待(Hold and Wait):进程已经持有至少一个资源,还等待获取其他资源
  3. 不可剥夺(No Preemption):资源不能被强制抢占,只能由持有者主动释放
  4. 循环等待(Circular Wait):存在进程链:P1等待P2的资源,P2等待P3的资源…Pn等待P1的资源

预防死锁策略

破坏任意必要条件

  1. 破坏互斥:让资源可以共享
  2. 破坏持有并等待:进程启动时申请所有需要的资源
  3. 破坏不可剥夺:申请新资源失败时,主动释放已持有的资源
  4. 破坏循环等待:给资源编号,进程必须按递增顺序申请(最常用)

避免死锁策略

银行家算法(Banker’s Algorithm)

核心思想:在分配资源前,先判断分配后系统是否处于安全状态

实现步骤:

  1. 进程申请资源时,先试探性分配
  2. 检查是否存在一个安全序列(所有进程都能顺序执行完)
  3. 如果安全,真正分配,不安全,拒绝并让进程等待

死锁检测与恢复

  1. 检查,定期运行死锁检查算法
  2. 恢复:终止一个或多个死锁进程,强制剥夺某些进程资源

孤儿进程和僵尸进程

孤儿进程:父进程先于子进程结束,子进程变成孤儿进程。(几乎没有危害)

僵尸进程:子进程已经结束,但父进程没有调用wait()、waitpid()回收,进程已死,但PCB(进程控制块)还在

特性 孤儿进程 僵尸进程
定义 父进程先死 子进程先死,父进程不回收
父进程 已退出 还活着
是否运行 可能还在运行 已经结束
危害 无害 占用进程表
处理 系统自动处理 需要父进程回收

僵尸进程预防

  1. 父进程主动回收
  2. 信号处理(推荐)
  3. 两次fork(守护进程)

僵尸进程清理

  1. 杀死父进程(推荐)
  2. 重启系统

协程和线程的本质区别

调度方式

线程:抢占式调度

协程:协作式调度

切换开销

线程:切换比较重

协程:切换比较轻

并发模型

线程:真并行

协程:伪并发

内存占用

线程:每个线程由独立的栈比较大

协程:协程栈很小

维度 协程(Coroutine) 线程(Thread)
调度者 用户态程序自己调度 内核调度
切换位置 用户态,不陷入内核 内核态,需要系统调用
切换时机 程序主动让出CPU 被动(时间片到/阻塞)
并发性 单线程内并发(伪并行) 真正的多核并行
可以很小(几KB) 较大(MB级别)
开销 极小(纳秒级) 较大(微秒级)

fork()的工作原理

fork()是Unix/Linux创建新进程的系统调用,调用一次,返回两次(父进程和子进程各返回一次),子进程是父进程的副本。

fork()的返回值含义

pid_t pid = fork();

if (pid < 0) {
    // 返回值 < 0:fork失败
    perror("fork failed");
    
} else if (pid == 0) {
    // 返回值 = 0:当前是子进程
    printf("我是子进程,PID=%d\n", getpid());
    
} else {
    // 返回值 > 0:当前是父进程,pid是子进程的PID
    printf("我是父进程,PID=%d,子进程PID=%d\n", getpid(), pid);
}

为什么这样做?

  • 子进程返回0:子进程没有子进程,返回0作为标识
  • 父进程返回子进程PID:父进程需要知道子进程的PID来管理它
  • 失败返回-1:遵循Unix错误处理惯例

进程调度算法比较

算法 抢占 优点 缺点 适用场景
FCFS 简单公平 护航效应 批处理
SJF 否/是 等待时间最短 饥饿、难预测 已知长度
RR 响应快、公平 切换开销 交互式
优先级 否/是 灵活 饥饿 实时系统
多级反馈 综合最优 复杂 通用OS
Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐