先搞懂"操作系统为什么需要进程",再搞懂"进程是什么"
前言:操作系统为什么需要"进程"
答案是:程序不能并发执行。
如果没有进程,程序只能一个一个排队执行(单道程序时代),CPU大部分时间在等I/O,资源利用率惨不忍睹。进程的诞生,让多道程序得以并发 执行,让CPU、内存、I/O设备能够并行工作,从而把计算机的潜能榨干。
- 隔离------把不同程序装进独立"房间",一个崩溃不影响其他。
- 切换------记录每个程序的运行状态,让CPU快速切换,营造"同时运行"的假象。
- 限制------限制程序权限,防止普通应用随意破坏系统内核。
一、进程的概念
(一) 程序是什么?
程序是存储在磁盘上的一段指令序列,它包含:
- 代码段(Text) :可执行的机器指令
- 数据段(Data) :已初始化的全局变量
- 只读数据段(ROData) :常量字符串等
- BSS段:未初始化的全局变量(不占用磁盘空间,加载时清0)
程序是死的,是一份"菜谱"。
(二) 进程是什么?
进程是程序的一次执行过程 ,它是活的:
进程包含 程序段、数据段、进程控制块 PCB,其中PCB包含或指向:
- 拥有独立的地址空间(虚拟内存)
- 拥有执行上下文(寄存器、程序计数器、栈)
- 拥有资源清单(打开的文件、信号处理器、挂起的信号)
二、PCB(进程控制块):进程的"身份证"与"档案袋"
(一) 内核如何"记住"一个进程?
操作系统管理进程,不是靠进程的名字或路径,而是靠一个叫做PCB(Process Control Block) 的数据结构。
在Linux内核中,这个结构体叫 struct task_struct,定义在include/linux/sched.h中。它的体积极为庞大------在Linux 6.x内核中,task_struct已经超过了 10KB。
(二) PCB里存了什么?(五类核心信息)
以Linux为例,
① 进程标识信息
pid_t pid:进程ID(用户空间getpid()返回的是TGID)pid_t tgid:线程组ID(主线程的PID)struct task_struct *parent:父进程指针struct list_head children:子进程链表
② 进程状态信息
volatile long state:进程状态
-
TASK_RUNNING:就绪或运行(统一为可调度状态)TASK_INTERRUPTIBLE:可中断睡眠(等待事件,可被信号唤醒)TASK_UNINTERRUPTIBLE:不可中断睡眠(通常因I/O等待,D状态)TASK_ZOMBIE:僵尸状态(已终止,等待父进程wait()收尸)TASK_STOPPED:暂停状态(被SIGSTOP等信号挂起)
③ 调度信息
int prio, static_prio, normal_prio:优先级unsigned int policy:调度策略(SCHED_NORMAL、SCHED_FIFO、SCHED_RR等)u64 vruntime:虚拟运行时间(CFS调度器的核心度量指标)
④ 内存管理信息
struct mm_struct *mm:进程的地址空间描述符struct mm_struct *active_mm:内核线程借用的内存描述符
⑤ 文件与资源信息
struct files_struct *files:已打开的文件描述符表struct fs_struct *fs:当前工作目录、根目录信息struct signal_struct *signal:信号处理器和挂起信号
(三) PCB的"生死簿"属性
进程的创建、调度、消亡,本质都是内核在操作PCB。
- 创建 :分配一个
task_struct,填入初始值,挂入就绪队列 - 调度 :调度器从队列中选一个
task_struct,恢复其上下文 - 消亡 :释放大部分资源,但保留
task_struct(变成僵尸),等待父进程读取退出码后彻底销毁
三、进程三态模型:生命周期的"状态机"
(一) 三种基本状态
进程在其生命周期中,必然处于以下三种状态之一:
scss
┌─────────────┐
│ 就绪态 │
│ (Ready) │
└──────┬──────┘
│ 调度器分配CPU
▼
┌─────────────┐
时间片用完 ──▶│ 运行态 │──▶ 等待I/O/事件
│ (Running) │ (阻塞操作)
└──────┬──────┘
│ I/O完成/事件发生
▼
┌─────────────┐
│ 阻塞态 │
│ (Blocked) │
└─────────────┘
(二) 状态转换的触发条件
| 转换 | 触发条件 | 说明 |
|---|---|---|
| 就绪 → 运行 | 进程调度程序选中该进程 | 获得CPU时间片 |
| 运行 → 就绪 | 时间片用完 / 被更高优先级进程抢占 | 主动或被动让出CPU |
| 运行 → 阻塞 | 请求I/O、申请资源失败、等待信号 | 进程主动放弃CPU |
| 阻塞 → 就绪 | I/O完成、资源可用、信号到达 | 被唤醒,重新排队等待CPU |
四、上下文切换:不仅仅是"保存寄存器"
(一) 什么是上下文切换?
上下文就是:CPU 当前运行现场。
主要包括:
PC 程序计数器
SP 栈指针
通用寄存器
浮点寄存器
状态寄存器
上下文切换(Context Switch)是指CPU从一个进程切换到另一个进程的过程。
上下文切换时内核需要做三件事:
- 保存当前进程的上下文(CPU寄存器、程序计数器、栈指针等)到它的PCB中
- 恢复下一个进程的上下文(从它的PCB中读出)
- 切换页表 (将
cr3寄存器指向新进程的页目录)
(二) 开销的构成
① 直接开销(CPU周期消耗)
- 保存/恢复数十个通用寄存器和专用寄存器
- 刷新流水线(Pipeline Flush)
- 切换页表(cr3寄存器切换)------这是最大的开销
② 间接开销(微架构级)
- TLB(Translation Lookaside Buffer)失效:切换页表导致TLB中的所有缓存的虚拟地址↔物理地址映射全部失效,后续内存访问需要重新进行多级页表遍历(Page Walk),延迟从几纳秒暴增至几百纳秒
- Cache污染:新进程的数据会逐渐替换掉L1/L2/L3缓存中的旧数据,导致cache miss率陡增
这就是为什么:
- Nginx采用固定数量的Worker进程(避免频繁创建/销毁进程)
- Redis采用单线程+IO多路复用(完全避免上下文切换)
- Go使用Goroutine(用户态协程) ,在用户态完成调度,将上下文切换的开销从微秒级降到纳秒级
五、进程 vs 线程:两种并发模型的本质差异
(一) 为什么需要线程?
进程解决了"多道程序并发执行"的问题,但存在明显的局限性:
- 进程间通信成本高:进程拥有独立的地址空间,相互隔离,想要交换数据必须借助管道、消息队列、共享内存等IPC机制,涉及内核介入和数据拷贝,效率低下。
- 进程切换开销大:切换进程需要切换页表、刷新TLB,代价沉重。如果大量并发任务频繁切换,系统性能将严重劣化。
于是,线程被设计出来,作为"进程内部更轻量的执行单元",使得同一进程内的多个线程能够:
- 共享地址空间:可以直接读写同一块内存,通信效率极高(无需内核介入)。
- 轻量级切换:线程切换无需切换页表(TLB保留),代价远低于进程切换。
(二) 进程与线程的核心区别
线程其实就是轻量级的进程。
- 进程是系统进行资源分配和调度的基本单位,线程是 CPU 调度和分派的基本单位;
- 线程依赖于进程而存在,一个进程至少有一个线程;
- 进程有自己的独立地址空间,线程共享所属进程的地址空间;
- 进程可以独立拥有资源,线程并不独立拥有资源。
- 线程是轻量级的进程,它的创建和销毁所需要的时间比进程小很多
| 比较维度 | 进程 | 线程 |
|---|---|---|
| 资源拥有 | 拥有独立的地址空间、文件表、信号处理器等资源 | 共享所属进程的全部资源,自身几乎不拥有资源 |
| 通信方式 | 需要通过IPC机制(管道、共享内存、消息队列等),需经内核 | 直接读写进程内的共享内存,用户态即可完成 |
| 创建/销毁开销 | 大(需复制页表、建立内存映射、分配内核数据结构) | 小(仅需分配线程栈和少量线程局部存储) |
| 上下文切换 | 昂贵(需切换页表,TLB刷新) | 相对廉价(页表不变,TLB保持有效) |
| 系统调用影响 | 一个进程阻塞,不影响其他进程 | 一个线程阻塞(如read()),整个进程内的所有线程都无法继续执行 |
| 崩溃影响 | 进程崩溃,不影响其他进程 | 线程崩溃(如段错误),整个进程崩溃 |
(三) 关键差异深度剖析
① 通信效率的本质差异
两个进程之间要传递一个整数,数据路径是:进程A → 内核缓冲区 → 进程B,涉及两次数据拷贝和系统调用。两个线程之间传递一个整数,直接通过共享内存读写,只是两次内存访问操作------差了几个数量级。
② 为什么线程切换"轻量"?
线程切换时,地址空间(页表)保持不变,因此:
- TLB不需要刷新,虚拟地址到物理地址的映射缓存仍然有效
- Cache命中率更高,线程共享代码段和数据段,缓存中已有的热数据可以直接复用
而进程切换时,页表变了,TLB全部失效,cache被迫冷启动------这才是进程切换"重"的真正根源。
需要切换:
③ "一个线程阻塞会拖垮整个进程"为何成立?
当线程A执行read()系统调用时,内核将整个进程(包括线程B、C)置入阻塞态,CPU被让出。因为操作系统调度的是进程(或其内的所有线程),而非单个线程。 这是内核级线程的天然特性,而用户态协程(如Go的Goroutine)通过"不阻塞内核线程"的方式规避了这个问题------具体留待后续篇章详解。
六、Linux视角下的"轻量级进程"真相
(一) 教科书定义 vs Linux实现
教科书定义:
- 进程:资源分配的基本单位
- 线程:CPU调度的基本单位
Linux内核的实现(这才是真相):
在Linux内核源码中,你找不到 struct thread,只有struct task_struct。
内核并不区分进程和线程,它只调度"任务(Task)"。
(二) 什么是"轻量级进程(LWP)"?
所谓的线程,在Linux内核中就是一个与父进程共享了部分资源的特殊子进程。
创建进程和创建线程,底层都调用了同一个系统调用------clone(),区别在于传入的标志位(flags) 不同:
| 创建方式 | 底层调用 | 关键标志 | 资源关系 | |||||
|---|---|---|---|---|---|---|---|---|
fork() |
clone(SIGCHLD, 0) |
不共享任何资源 | 父子进程资源独立(写时复制) | |||||
vfork() |
`clone(CLONE_VFORK | CLONE_VM | SIGCHLD, ...)` | 共享内存,子进程先运行 | 主要用于exec()前的临时进程 |
|||
pthread_create() |
`clone(CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND | CLONE_THREAD | ...)` | 共享一切 | 线程间共享地址空间、文件表、信号处理器 |
(三) 线程到底"轻量"在哪里?
所谓"轻量",体现在资源不拷贝,只共享:
- 不拷贝页表 :所有线程共享同一个
mm_struct(内存描述符),切换线程时不需要刷新TLB - 不拷贝文件表 :所有线程共享
files_struct,打开一个文件所有线程都能用 - 不拷贝信号处理器 :共享
sighand_struct
但调度上并不"轻" :在Linux中,线程和进程的调度开销几乎相同------因为它们都是task_struct,都在同一个运行队列(runqueue)中竞争CPU。
(四) 核心结论
在Linux内核眼中:
- 进程 = 一个拥有独立地址空间的
task_struct - 线程 = 一个共享了父进程地址空间的
task_struct
它们本质上是同一种东西,只是共享资源的程度不同。
这就是为什么Linux程序员常说:"Linux没有真正的内核线程,只有轻量级进程。"
本文是「进程管理」系列的第二篇,欢迎关注,持续更新。如有任何疑问或指正,请在评论区留言讨论。
下篇预告:当10个进程同时处于就绪态时,CPU到底该选谁?
我们将深入Linux CFS(完全公平调度器)的实现,揭秘
vruntime是如何让每个进程"看起来公平地"获得CPU时间的。
