操作系统 --进程与线程篇
进程和线程区别
进程(Process)
- 操作系统资源分配的基本单位
- 每个进程有自己独立的地址空间(代码段、数据段、堆、栈)
- 程序一旦运行,就会至少有一个进程
- 切换开销大,因为需要切换整个内存地址空间、文件描述符等上下文
线程(Thread)
- 程序执行的最小单位,是 CPU 调度的基本单位
- 一个进程可以包含多个线程
- 线程共享进程的地址空间,但有自己独立的栈和寄存器
- 切换开销小,多个线程共享进程资源,只需切换少量上下文(寄存器、栈指针)
👉 直观类比:
- 进程 = 公司(独立运作,有资源)
- 线程 = 公司里的员工(共享公司资源,但干不同的活)
通信方面:
- 进程间通信(IPC):麻烦,需要操作系统提供机制(管道、消息队列、共享内存、socket)
- 线程间通信:方便,因为共享进程地址空间,直接读写共享变量即可,但需要同步机制(锁、条件变量)避免数据冲突
崩溃影响:
- 进程:一个进程崩溃不会影响其他进程(只要没有依赖关系)
- 线程:一个线程崩溃可能导致整个进程崩溃(因为共享地址空间)
| 对比点 | 进程 | 线程 |
|---|---|---|
| 定义 | 资源分配的基本单位 | CPU 调度的基本单位 |
| 地址空间 | 独立 | 共享(代码、堆),但有独立栈 |
| 切换开销 | 大 | 小 |
| 通信 | 需要 IPC | 直接共享内存,需同步 |
| 崩溃影响 | 不影响其他进程 | 可能拖垮整个进程 |
| 粒度 | 粗 | 细 |
| 并发能力 | 多进程并发 | 多线程并发,更轻量 |
什么是线程同步,线程同步有哪些方法?
1. 什么是线程同步?
- 线程同步 = 保证多个线程在访问共享资源时,按照一定的顺序执行,避免数据冲突。
- 背景:多线程可以同时访问同一块内存,如果不加限制就可能出现“竞态条件”(race condition)。
同步的目标:
保证数据一致性(不会读到脏数据)。
避免死锁和资源浪费。
提高并发效率。
👉 举例:
银行账户余额 = 1000 元。
- 线程 A:取 800 元。
- 线程 B:取 500 元。
如果没有同步机制,可能两个线程都判断余额够 → 都取成功 → 最终余额变负数 ❌。
同步机制可以保证操作的原子性:要么 A 成功 B 失败,要么 B 成功 A 失败。
2. 线程同步的方法
(1) 互斥锁(Mutex)
最常见的方法。
线程进入临界区前先加锁,用完资源后释放锁。
保证同一时间只有一个线程能访问共享资源。
缺点:可能导致阻塞,若使用不当会出现死锁。
C++
std::mutex,Javasynchronized/ReentrantLock。
(2) 读写锁(Read-Write Lock)
区分 读锁 和 写锁:
多个线程可以同时读(共享锁)。
写操作必须独占(互斥锁)。
适合读多写少的场景(如缓存、配置查询)。
Java
ReentrantReadWriteLock,C++std::shared_mutex。
(3) 信号量(Semaphore)
维护一个计数器,表示可用资源数量。
P操作(等待):请求一个资源,计数器减一;如果不足就阻塞。V操作(释放):释放资源,计数器加一。常用于限流,比如线程池控制最大并发数。
Java
Semaphore,Linuxsem_t。
(4) 条件变量(Condition Variable)
与互斥锁配合使用。
线程可以在条件变量上等待,直到某个条件为真再继续执行。
常用于生产者-消费者模型:
消费者在队列空时等待条件变量;
生产者放入数据后通知条件变量。
Java
wait()/notify(),C++std::condition_variable。
(5) 自旋锁(Spinlock)
当资源被占用时,线程不断循环检查(忙等),而不是挂起。
适合锁占用时间很短的场景(避免线程上下文切换开销)。
缺点:浪费 CPU 时间。
(6) 屏障(Barrier)
一种同步点。
多个线程必须都到达某个屏障位置,才能继续往下执行。
常用于并行计算的阶段性同步。
Java
CyclicBarrier,C++20std::barrier。
PCB —进程状态的标识
1 | ┌─────────────────┐ |
PCB作用:
- 进程切换
- 内核保存当前进程的 PCB(寄存器内容、状态)。
- 切换到另一个进程时,从该进程的 PCB 中恢复 CPU 状态。
- 进程管理
- 操作系统通过 PCB 维护所有进程的运行状态(创建、调度、挂起、终止)。
进程通信
- PCB 中保存通信所需的信息(管道、消息队列等)。
直观类比
- 进程 = 一个在跑的应用。
- PCB = 操作系统里的“档案袋”,里面记录了这个进程的身份证号(PID)、运行现场(寄存器)、调度优先级、用的资源等。
- 当操作系统切换进程时,就像“把档案袋收起来,换另一个档案袋”。
进程的调度算法
![[Pasted image 20251002113159.png]]
(1) 先来先服务(FCFS, First-Come-First-Served)
算法思想:主要从“公平”的角度考虑(类似于我们生活中排队买东西的例子), 先请求 CPU 的进程首先分配到 CPU。当一个进程进入就绪队列时,它的 PCB 会被链接到队列尾部。当 CPU 空闲时,它会分配给位于队列头部的进程,并且这个运行进程从队列中移去。也就是按照作业/进程到达的先后顺序进行服务
- 类型:非抢占式
- 规则:按照进程到达就绪队列的顺序分配 CPU。
- 优点:简单、公平
- 缺点:平均等待时间可能大,容易发生 短作业被长作业阻塞(Convoy Effect),但是不会饥饿
(2) 短作业优先(SJF, Shortest Job First)
算法思想:追求最少的平均等待时间,最少的平均周转时间、最少的平均平均带权周转时间
- 类型:非抢占式 / 可抢占式(称 SRTF, Shortest Remaining Time First)
- 规则:优先执行预计运行时间最短的进程
- 优点:平均等待时间最小(理论上最优)
- 缺点:难以预测进程执行时间;可能导致长作业饥饿
(3) 时间片轮转(RR, Round Robin)
- 类型:抢占式
- 规则:每个进程按顺序分配固定时间片(quantum),时间片用完被剥夺 CPU → 排队等待下一轮
- 优点:响应时间好,适合 时间共享系统
缺点:时间片选择不合理会导致频繁切换(开销大)或响应慢
(4) 优先级调度(Priority Scheduling)
类型:可抢占或非抢占
- 规则:给每个进程分配一个优先级,CPU 优先给优先级高的进程
- 优点:重要任务优先处理
- 缺点:低优先级可能 饥饿(Starvation)
改进:老化(Aging):随着等待时间增加,提高低优先级进程优先级
(5) 多级队列调度(Multilevel Queue)
类型:抢占或非抢占
- 规则:将进程按类型(如交互型、批处理型)划分多个队列,每个队列有自己的调度策略
- 优点:可针对不同进程类型优化调度
缺点:队列划分固定,灵活性低
(6) 多级反馈队列调度(Multilevel Feedback Queue)
类型:抢占式
规则:多个队列,进程可根据执行情况在队列间移动
- 刚开始进程优先级高(时间片短)
- 占用 CPU 时间长 → 移到低优先级队列
优点:兼顾短作业响应快和长作业公平性
- 缺点:实现复杂
僵尸进程和孤儿进程
1. 僵尸进程(Zombie Process)
(1)定义
- 僵尸进程是已经 执行完毕(终止) 的进程,但其 PCB(进程控制块)仍然保留在内核中。
- 保留的原因是 父进程还没有读取子进程的退出状态(通过
wait()系列系统调用)。
(2)出现场景
父进程没有及时回收子进程的状态信息:
pid_t pid = fork(); if (pid == 0) { exit(0); // 子进程退出 } else { sleep(1000); // 父进程没有 wait }此时子进程已经结束,但 PCB 还在内核中 → 僵尸进程。
(3)特点和作用
特点:
- 不占用 CPU(已经退出)
- 占用少量内存(PCB)
- 仍然有 PID(可以
ps查看)
作用:
- 保存退出状态,让父进程能获取子进程退出码(正常/异常)。
(4)解决方法
父进程主动回收:
- 使用
wait()或waitpid()读取子进程退出状态。
- 使用
自动回收(避免僵尸):
- 父进程捕获
SIGCHLD信号,处理时调用wait()。 在 Linux 可以设置:
signal(SIGCHLD, SIG_IGN);系统会自动回收子进程 PCB。
- 父进程捕获
(5)排查
ps -el | grep Z→ 查看状态为Z的进程(僵尸)top→ STAT 列显示Z
2. 孤儿进程(Orphan Process)
(1)定义
- 孤儿进程是 父进程先于子进程结束 的进程。
- 系统会把孤儿进程的 父进程改为 init 进程(PID=1),由 init 负责回收。
(2)出现场景
pid_t pid = fork(); if (pid > 0) { exit(0); // 父进程先结束 } else { sleep(100); // 子进程还在运行 }
- 子进程就成为孤儿进程,被
init收养。
(3)特点和作用
特点:
- 父进程消失,子进程仍运行
- 由
init进程接管
作用:
- 保证操作系统可以最终回收子进程资源,不会出现永远占用内存的僵尸。
(4)解决方法
- 孤儿进程不需要手动处理,由 init 自动回收。
(5)排查
ps -ef→ PPID 列为 1 的进程通常是孤儿进程。
| 类型 | 定义 | 状态 | 占用 CPU | 如何回收 |
|---|---|---|---|---|
| 僵尸进程 | 已退出,但 PCB 未被回收 | Z |
否 | 父进程调用 wait(),或由 SIGCHLD 处理自动回收 |
| 孤儿进程 | 父进程先退出的活着子进程 | 正常运行 | 是 | 被 init 收养,运行完毕由 init 回收 |
- 僵尸过多 → PCB 占用过多 → 进程表满 → 新进程无法创建
- 孤儿进程本身无害,由 init 管理
同步与互斥概念
Ⅰ、同步亦称直接制约关系,它是指为完成某种任务而建立的两个或多个进程,这些进程因为需要在某些位置上协调它们的工作次序而等待、传递信息所产生的制约关系。进程间的直接制约关系就是源于它们之间的相互合作。
例如,输入进程A通过单缓冲向进程B提供数据。当该缓冲区空时,进程B不能获得所需数据而阻塞,一旦进程A将数据送入缓冲区,进程B被唤醒。反之,当缓冲区满时,进程A被阻塞,仅当进程B取走缓冲数据时,才唤醒进程A。
Ⅱ、 互斥亦称间接制约关系。当一个进程进入临界区使用临界资源时,另一个进程必须等待, 当占用临界资源的进程退出临界区后,另一进程才允许去访问此临界资源。
例如,在仅有一台打印机的系统中,有两个进程A和进程B,如果进程A需要打印时, 系统已将打印机分配给进程B,则进程A必须阻塞。一旦进程B将打印机释放,系统便将进程A唤醒,并将其由阻塞状态变为就绪状态。
两个进程同时进入临界区,同步机制应遵循以下准则:
信号量机构是一种功能较强的机制,可用来解决互斥与同步的问题,它只能被两个标准的原语wait(S)和signal(S)来访问,也可以记为“P操作”(通过)和“V操作(释放)”。原语是指完成某种功能且不被分割不被中断执行的操作序列,通常可由硬件来实现完成不被分割执行特性的功能。
1. 整形信号量
1 | wait(S) { |
这里当S <= 0的时候会一直尝试,违反了“让权等待”
2. 记录型信号量
1 | typedef struct{ |
- wait操作,S.value—,表示进程请求一个该类资源,当S.value<0时,表示该类资源已分配完毕,因此进程应调用block原语,进行自我阻塞,放弃处理机,并插入到该类资源的等待队列S.L中,可见该机制遵循了“让权等待”的准则。
1 | void wait(semaphore S) //相当于申请资源 |
- signal操作,表示进程释放一个资源,使系统中可供分配的该类资源数增1,故S.value++。若加1后仍是S.value<=0,则表示在S.L中仍有等待该资源的进程被阻塞,故还应调用wakeup 原语,将S.L中的第一个等待进程唤醒。
1
2
3
4
5
6
7void signal(semaphore S) //相当于释放资源
S.value ++;
if (S.value <=0) {
remove a process P from S.L;
wakeup(P);
}
}
申请资源时,当value小于0时,就会执行block,让其进入阻塞队列中。当一个进程执行完,若value还是小于等于0,则会执行wakeup操作。
经典的同步问题
- 生产者 - 消费者问题
- 互斥:在任一时间只能有一个线程操作缓冲区(缓冲区时临界资源)
- 当缓冲区为空,消费者必须等待生产者(调度/同步约束)
- 当缓冲区为满,生产者必须等待消费者(调度/同步约束)
- 读者-写者问题
读者写者问题是并发程序设计中的经典问题。问题描述为:对于同一个文件,读操作可以同时并行,读写操作互斥,写与写互斥。
管程
概念:信号量机制存在的问题:编写程序困难、易出错。于是,产生了一种新的进程同步工具——管程。
一般必有这三个:
| 组件 | 作用 |
|---|---|
| 共享数据 | 被保护的状态 |
| 互斥机制 | 保证同时只有一个线程进入 |
| 条件变量 | 线程等待 / 唤醒 |
教科书的:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26monitor BoundedBuffer {
item buffer[N];
int count = 0;
condition notFull;
condition notEmpty;
procedure put(item x) {
if count == N {
wait(notFull);
}
buffer.push(x);
count++;
signal(notEmpty);
}
procedure get() {
if count == 0 {
wait(notEmpty);
}
x = buffer.pop();
count--;
signal(notFull);
return x;
}
}
Go也可以封装管程: 简单实现1
2
3
4
5
6
7
8
9
10
11
12
13
14type Monitor struct {
ch chan int
}
func NewMonitor() *Monitor {
return &Monitor{ch: make(chan int, 10)}
}
func (m *Monitor) Put(x int) {
m.ch <- x
}
func (m *Monitor) Get() int {
return <-m.ch
}
可以主动形管程,把锁换成一个 goroutine,把临界区换成 select1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27type Monitor struct {
put chan int
get chan chan int
}
func NewMonitor() *Monitor {
m := &Monitor{
put: make(chan int),
get: make(chan chan int),
}
go m.run()
return m
}
func (m *Monitor) run() {
buf := []int{}
for {
select {
case x := <-m.put:
buf = append(buf, x)
case ch := <-m.get:
x := buf[0]
buf = buf[1:]
ch <- x
}
}
}
