进程和线程区别

  • 进程(Process)

    • 操作系统资源分配的基本单位
    • 每个进程有自己独立的地址空间(代码段、数据段、堆、栈)
    • 程序一旦运行,就会至少有一个进程
    • 切换开销大,因为需要切换整个内存地址空间、文件描述符等上下文
  • 线程(Thread)

    • 程序执行的最小单位,是 CPU 调度的基本单位
    • 一个进程可以包含多个线程
    • 线程共享进程的地址空间,但有自己独立的栈和寄存器
    • 切换开销小,多个线程共享进程资源,只需切换少量上下文(寄存器、栈指针)

👉 直观类比:

  • 进程 = 公司(独立运作,有资源)
  • 线程 = 公司里的员工(共享公司资源,但干不同的活)

通信方面:

  • 进程间通信(IPC):麻烦,需要操作系统提供机制(管道、消息队列、共享内存、socket)
  • 线程间通信:方便,因为共享进程地址空间,直接读写共享变量即可,但需要同步机制(锁、条件变量)避免数据冲突

崩溃影响:

  • 进程:一个进程崩溃不会影响其他进程(只要没有依赖关系)
  • 线程:一个线程崩溃可能导致整个进程崩溃(因为共享地址空间)
对比点 进程 线程
定义 资源分配的基本单位 CPU 调度的基本单位
地址空间 独立 共享(代码、堆),但有独立栈
切换开销
通信 需要 IPC 直接共享内存,需同步
崩溃影响 不影响其他进程 可能拖垮整个进程
粒度
并发能力 多进程并发 多线程并发,更轻量

什么是线程同步,线程同步有哪些方法?

1. 什么是线程同步?

  • 线程同步 = 保证多个线程在访问共享资源时,按照一定的顺序执行,避免数据冲突。
  • 背景:多线程可以同时访问同一块内存,如果不加限制就可能出现“竞态条件”(race condition)。
  • 同步的目标:

    1. 保证数据一致性(不会读到脏数据)。

    2. 避免死锁和资源浪费。

    3. 提高并发效率。

👉 举例:
银行账户余额 = 1000 元。

  • 线程 A:取 800 元。
  • 线程 B:取 500 元。
    如果没有同步机制,可能两个线程都判断余额够 → 都取成功 → 最终余额变负数 ❌。
    同步机制可以保证操作的原子性:要么 A 成功 B 失败,要么 B 成功 A 失败。

2. 线程同步的方法

(1) 互斥锁(Mutex)

  • 最常见的方法。

  • 线程进入临界区前先加锁,用完资源后释放锁。

  • 保证同一时间只有一个线程能访问共享资源。

  • 缺点:可能导致阻塞,若使用不当会出现死锁。

C++ std::mutex,Java synchronized / ReentrantLock

(2) 读写锁(Read-Write Lock)

  • 区分 读锁写锁

    • 多个线程可以同时读(共享锁)。

    • 写操作必须独占(互斥锁)。

  • 适合读多写少的场景(如缓存、配置查询)。

Java ReentrantReadWriteLock,C++ std::shared_mutex

(3) 信号量(Semaphore)

  • 维护一个计数器,表示可用资源数量。

  • P 操作(等待):请求一个资源,计数器减一;如果不足就阻塞。

  • V 操作(释放):释放资源,计数器加一。

  • 常用于限流,比如线程池控制最大并发数。

Java Semaphore,Linux sem_t

(4) 条件变量(Condition Variable)

  • 与互斥锁配合使用。

  • 线程可以在条件变量上等待,直到某个条件为真再继续执行。

  • 常用于生产者-消费者模型

    • 消费者在队列空时等待条件变量;

    • 生产者放入数据后通知条件变量。

Java wait()/notify(),C++ std::condition_variable

(5) 自旋锁(Spinlock)

  • 当资源被占用时,线程不断循环检查(忙等),而不是挂起。

  • 适合锁占用时间很短的场景(避免线程上下文切换开销)。

  • 缺点:浪费 CPU 时间。

(6) 屏障(Barrier)

  • 一种同步点。

  • 多个线程必须都到达某个屏障位置,才能继续往下执行。

  • 常用于并行计算的阶段性同步。

Java CyclicBarrier,C++20 std::barrier


PCB —进程状态的标识

1
2
3
4
5
6
7
8
9
10
11
┌─────────────────┐
PCB (进程档案)
├─────────────────┤
PID = 1234 身份信息
状态 = 就绪
PC = 0x8048123 程序计数器
SP = 0x7fff1234 栈指针
优先级 = 5
打开文件表指针
页表指针
└─────────────────┘

PCB作用:

  1. 进程切换
    • 内核保存当前进程的 PCB(寄存器内容、状态)。
    • 切换到另一个进程时,从该进程的 PCB 中恢复 CPU 状态。
  2. 进程管理
    • 操作系统通过 PCB 维护所有进程的运行状态(创建、调度、挂起、终止)。
  3. 进程通信

    • 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)特点和作用

  • 特点

    1. 不占用 CPU(已经退出)
    2. 占用少量内存(PCB)
    3. 仍然有 PID(可以 ps 查看)
  • 作用

    • 保存退出状态,让父进程能获取子进程退出码(正常/异常)。

(4)解决方法

  1. 父进程主动回收

    • 使用 wait()waitpid() 读取子进程退出状态。
  2. 自动回收(避免僵尸)

    • 父进程捕获 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)特点和作用

  • 特点

    1. 父进程消失,子进程仍运行
    2. 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
2
3
4
5
6
7
8
wait(S) {
while (S <= 0) {
}
S -= 1;
}
signal(S) {
S = S + 1;
}

这里当S <= 0的时候会一直尝试,违反了“让权等待”

2. 记录型信号量

1
2
3
4
typedef struct{  
int value;
struct process *L;
} semaphore;
  • wait操作,S.value—,表示进程请求一个该类资源,当S.value<0时,表示该类资源已分配完毕,因此进程应调用block原语,进行自我阻塞,放弃处理机,并插入到该类资源的等待队列S.L中,可见该机制遵循了“让权等待”的准则。
1
2
3
4
5
6
7
void wait(semaphore S) //相当于申请资源  
S.value --;
if (S.value <0) {
add this process to S.L;
block(S.L);
}
}
  • signal操作,表示进程释放一个资源,使系统中可供分配的该类资源数增1,故S.value++。若加1后仍是S.value<=0,则表示在S.L中仍有等待该资源的进程被阻塞,故还应调用wakeup 原语,将S.L中的第一个等待进程唤醒。
    1
    2
    3
    4
    5
    6
    7
    void 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. 生产者 - 消费者问题
  • 互斥:在任一时间只能有一个线程操作缓冲区(缓冲区时临界资源)
  • 当缓冲区为空,消费者必须等待生产者(调度/同步约束)
  • 当缓冲区为满,生产者必须等待消费者(调度/同步约束)
  1. 读者-写者问题

读者写者问题是并发程序设计中的经典问题。问题描述为:对于同一个文件,读操作可以同时并行,读写操作互斥,写与写互斥。

管程

概念:信号量机制存在的问题:编写程序困难、易出错。于是,产生了一种新的进程同步工具——管程。

一般必有这三个:

组件 作用
共享数据 被保护的状态
互斥机制 保证同时只有一个线程进入
条件变量 线程等待 / 唤醒

教科书的:

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
26
monitor 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
14
type 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,把临界区换成 select

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
26
27
type 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
}
}
}