这篇想解决一个问题:进程到底是个什么东西,操作系统凭什么能让一堆程序"同时"跑起来。我会从硬件一层层往上推,推到进程这个抽象为止。
一、先搞清楚计算机在干嘛
聊进程之前,得先知道没有操作系统的计算机长什么样。
冯诺依曼结构
现代计算机基本都遵循冯诺依曼体系,核心就四个部件:CPU、存储器、输入、输出。这里不展开讲,只说一个后面会反复用到的点——存储层次的速度和容量是反着来的:
容量: 硬盘 > 内存 >> CPU 寄存器 速度: 硬盘 < 内存 << CPU 寄存器硬盘能装几 TB,但读一次要几毫秒;CPU 寄存器快到纳秒级,但几乎不存东西。中间的内存是个折中。后面会看到,操作系统花大量精力就是在"抹平"这个速度差。
CPU 怎么执行程序
CPU 其实只会做一件事:从内存里取一条指令,译码,执行,写回结果,然后取下一条。周而复始。
有个寄存器叫PC(Program Counter),它存着"下一条要执行的指令地址"。CPU 眼里没有"程序"这个概念,它就认 PC 指向的那条指令。所谓"程序运行起来",本质就是 CPU 不断更新 PC、不断取指令执行的过程。
这里有个伏笔:既然 CPU 只认 PC,那操作系统想让 CPU 去执行另一个程序,只要改 PC 就行——这就是后面进程切换的雏形。
二、操作系统在中间干了什么
裸机上直接跑程序会出问题:两个程序都想用内存、都想读写硬盘、都想占着 CPU 不放,互相踩来踩去。所以需要一个"中间人"来管资源——这就是操作系统。
它干两件事:
- 管硬件:不让某个程序把资源吃光,大家轮流用。
- 给接口:程序不用关心硬盘具体怎么转,调个
open()就能读文件。
具体到资源管理,操作系统把硬件抽象成几类对象:
| 资源 | 管理思路 | 对应模块 |
|---|---|---|
| CPU | 时间分片,轮流用 | 进程调度 |
| 内存 | 空间划分,各占一块 | 内存管理 |
| 设备 | 驱动屏蔽差异 | 设备管理 |
| 文件 | 统一成"文件"概念 | 文件系统 |
下面重点讲 CPU 这条线——它就是进程的来源。
三、进程:把"运行中的程序"抽象出来
为什么要有进程
设想你在单核电脑上同时开着浏览器、音乐播放器、IDE。CPU 只有一颗,同一时刻只能执行一条指令,那为什么这三个程序看起来都在跑?
因为操作系统在快速地切换它们:浏览器跑一会儿,切到音乐跑一会儿,再切到 IDE 跑一会儿。切换得足够快,人感觉不到,就以为是"同时"。
但这里有个问题:每个程序都有自己的代码、自己的变量、自己打开的文件、自己执行到哪一行了。如果操作系统要切换,就必须把这些"现场"信息记下来,不然切回来就接不上了。
进程,就是操作系统为了记住"一个运行中程序的全部现场"而搞出来的一个数据结构。
所以进程的定义其实很朴素:进程 = 程序的一次执行过程,外加操作系统为它记录的全部上下文信息。
程序和进程的区别也就清楚了:程序是硬盘上的文件,静态的;进程是程序被加载到内存跑起来后的动态实体。同一个程序可以同时跑出多个进程(比如开三个计算器,是三个进程,但只有一个 calc.exe 文件)。
进程控制块(PCB)
操作系统用一个结构体来记录进程的全部信息,这个结构体叫PCB(Process Control Block)。可以把它理解成操作系统账本上关于某个进程的一页记录。
PCB 里大概有这些字段:
PID
每个进程一个唯一编号。任务管理器里看到的那串数字就是它。
内存指针
进程的代码、数据都在内存里,PCB 要记住它们的地址。一个进程的内存布局大致是这样(从低地址到高地址):
┌──────────────┐ 高地址 │ 栈 Stack │ 局部变量、调用栈 ├──────────────┤ │ ↓ 空闲 ↓ │ │ ↑ 空闲 ↑ │ ├──────────────┤ │ 堆 Heap │ new/malloc 出来的 ├──────────────┤ │ 数据段 Data │ 全局变量、静态变量 ├──────────────┤ │ 代码段 Code │ 指令(只读) └──────────────┘ 低地址栈往下长,堆往上长,中间留空。这也是为什么数组下标访问是 O(1)——地址是连续算出来的,CPU 直接取。
文件描述符表
进程打开的每个文件,操作系统都给一个编号,叫文件描述符(fd)。PCB 里有一张表记录这个进程打开了哪些 fd。
默认每个进程一出生就有三个:
0标准输入(键盘)1标准输出(屏幕)2标准错误(屏幕)
你new FileInputStream("a.txt"),操作系统就分配3给它,记进表里。close()就是把表里这一行划掉。
进程状态
进程不是一直占着 CPU 的,它会在几种状态间转:
创建 → 就绪 ⇄ 运行 → 终止 ↑ ↓ ← 阻塞 ←- 就绪:万事俱备,排队等 CPU
- 运行:正在 CPU 上跑
- 阻塞:在等某个事件(比如磁盘读完了、网络数据到了),这时就算把 CPU 给它也没用,它干不了活
注意"就绪"和"阻塞"的区别:就绪是"我能跑,就差 CPU";阻塞是"我跑不了,在等别的东西"。这是个容易混的点。
优先级
多个进程抢 CPU 时,谁先来。音视频通话优先级高,后台下载优先级低,这样保证不卡。
上下文寄存器
这是 PCB 最关键的部分之一。进程被切下 CPU 时,CPU 寄存器里的值(包括 PC)要存到这里;进程被切上 CPU 时,再从这里恢复。没有这个,进程切换就接不上。
记账信息
CPU 用了多少时间、占了多少内存、做了多少次 I/O。用于统计和调度参考。
PCB 长什么样
用 Java 伪码大概是这样:
classPCB{intpid;longcodeStart,dataStart,heapPtr,stackPtr;FileDescriptor[]fdTable;intstate;// 就绪/运行/阻塞/...intpriority;longpc;// 程序计数器long[]registers;// 通用寄存器longcpuTimeUsed;longstartTime;longmemoryUsed;}操作系统把所有 PCB 用链表或树组织起来,增删查改。进程创建 = 新建一个 PCB 塞进链表;进程结束 = 从链表摘掉销毁。
四、进程调度:CPU 怎么分
为什么要调度
单核 CPU 同一时刻只能跑一个进程。但我们感觉一堆程序在同时跑,靠的就是调度:操作系统给每个进程分一小段时间片,轮流上 CPU,切得够快人就不察觉。
几种常见调度算法
这些算法不必全记,理解思路就行:
FCFS(先来先服务):谁先来谁先跑,跑完才换下一个。公平,但长任务会拖死短任务——你取 100 块钱,前面那人办 30 分钟贷款,你就得干等。
SJF(短作业优先):预计跑得短的先跑,平均等待时间最短。问题是一直有短任务来的话,长任务永远轮不上(饿死)。
时间片轮转(RR):每个进程分一个固定时间片,用完就排到队尾。这是分时系统的基本做法。时间片太大→响应慢;太小→光顾着切换了,CPU 干不了正事。现代系统一般取 10~100ms。
优先级调度:按优先级排队。可能饿死低优先级进程,通常配合"老化"机制(等太久就提优先级)。
多级反馈队列:上面几个的综合体,现代操作系统实际用的就是这种思路的变种。多个队列不同优先级,时间片用完降级,等太久升级,兼顾响应和吞吐。
上下文切换
调度落到实处就是"切换"——把 CPU 从进程 A 换给进程 B。这个过程叫上下文切换,步骤是:
- 保存 A 的寄存器(含 PC)到 A 的 PCB
- 把 A 状态改成"就绪"
- 从 B 的 PCB 读出寄存器,恢复到 CPU
- 把 B 状态改成"运行"
- CPU 从 B 的 PC 处继续执行
这个过程不便宜。直接开销是保存/恢复寄存器、切换页表、刷新 TLB;间接开销是切过来之后 CPU 缓存是冷的,要重新预热,这段预热期命中率低、跑得慢。
所以上下文切换不是"免费"的,频繁切换会让 CPU 把时间浪费在切换本身上。这也是为什么线程后来会出现——线程切换比进程轻得多。
完整的一次调度流程大致是:
时钟中断 → OS 介入 → 调度器选下一个进程 → 保存当前/恢复下一个 → 继续时钟中断就是操作系统的"心跳",每隔一段时间提醒它"该看看要不要换人了"。
五、进程间通信(IPC)
进程之间内存是隔离的,A 进程碰不到 B 进程的内存——这是安全设计,但也意味着它们要交换数据时没法直接读写对方的内存。操作系统得提供一些受控的通道,这就是 IPC。
常见的几种:
管道:一个进程写,一个进程读,单向。Linux 命令行的|就是管道,ps aux | grep java里ps写、grep读。
共享内存:操作系统划一块内存让两个进程都能访问。最快的 IPC,因为不用拷贝数据。但既然是共享,就涉及"同时写怎么办"的同步问题,一般配信号量用。
文件:A 写文件,B 读文件。简单,但慢,要落盘。
Socket:走网络协议栈,可以跨机器。最灵活,浏览器访问网站就是浏览器进程和服务器进程用 socket 通信。代价是要处理延迟、丢包。
信号量:不传数据,只用来做同步协调——告诉对方"我用完了,你可以用了"。
信号:一种通知机制,比如Ctrl+C给进程发 SIGINT,进程收到就退出。异步的,进程不知道什么时候会来。
实际开发中,同一台机器内首选共享内存(要快)或管道(要简单);跨机器只能 socket。
六、进程 vs 线程
进程是"资源分配的单位"——每个进程有自己的内存空间、文件描述符表等。但进程有个大问题:创建贵、切换贵。要是我只想让程序里的几段代码并行跑,没必要每次都开一个全新进程。
线程就是在这个背景下出来的:一个进程里可以有多条线程,它们共享进程的内存和资源,但各自有独立的 PC 和栈。
所以两者的分工是:
- 进程:拥有资源(内存、文件等)
- 线程:CPU 调度的实际单位
线程切换比进程快,因为不用换地址空间、不用刷 TLB。但代价是线程之间共享内存,一个线程写坏了全局数据,整个进程(包括其他线程)跟着崩。进程隔离,一个进程崩了不影响别的进程,但通信成本高。
实际工程里两者常常混用:进程做隔离边界,线程做进程内的并发。
七、Java 里的进程
Java 里创建子进程主要靠Runtime.exec或ProcessBuilder:
Processp=newProcessBuilder("notepad.exe").start();longpid=p.pid();// Java 9+ 可以直接拿 PIDintcode=p.waitFor();// 阻塞等子进程结束Process对象本质上是对操作系统进程的一个 Java 侧句柄,底层还是走操作系统的 fork/exec(Linux)或 CreateProcess(Windows)。
注意 Java 进程本身也是个进程,有自己的 PID。用jps -l能列出当前机器上所有 JVM 进程。
八、收尾
把整条线捋一遍:
- 硬件层面,CPU 只认 PC 指向的指令,本身没有"进程"概念
- 裸机上多程序会互相踩,所以引入操作系统管资源
- 操作系统为了让多个程序"看起来同时跑",要快速切换它们
- 切换就得记住每个程序的现场,于是抽象出"进程"+ PCB
- PCB 里存着 PID、内存布局、打开的文件、状态、寄存器现场等
- 调度算法决定谁先上 CPU,上下文切换负责实际换人
- 进程隔离带来安全,但也带来通信需求,于是有 IPC
- 进程太重,于是拆出线程共享进程资源
进程这个抽象之所以成功,在于它把"程序运行"这件事变成了一个可以被操作系统统一管理的对象——创建、调度、切换、通信、销毁,全都标准化了。理解了进程,后面再看线程、协程、容器,基本都是在这个抽象上做加减法。
几个值得想想的问题
单核 CPU 上,为什么感觉浏览器、音乐、微信在"同时"跑?如果时间片放大到 1 秒,你会看到什么现象?
"就绪"和"阻塞"都是没在 CPU 上跑,为什么要分两种状态?能不能合并?
上下文切换有开销,那是不是线程开得越多越好?为什么线程池要有上限?
共享内存是最快的 IPC,那为什么管道、socket 这些"慢"的方式还在用?什么场景下该用哪种?
同一个程序开三个进程,它们共享代码段吗?为什么这么设计?