操作系统 并发

本文将介绍操作系统对并发的支持。

一、进程与线程

作者:pansz
链接:https://www.zhihu.com/question/19901763/answer/13299543

  1. 单进程单线程:一个人在一个桌子上吃菜
  2. 单进程多线程:多个人在同一个桌子上一起吃菜
  3. 多进程单线程:多个人每个人在自己的桌子上吃菜

1. 进程

在操作系统中,一个运行中的任务通常对应一个进程。

进程通常包含以下三个特征:

  • 独立性:进程是系统中的独立单位,具有独立的资源、私有的空间

  • 动态性:进程具有自己的生命周期

  • 并发性:多个进程可以并发执行而不会相互影响

    并发与并行并不相同

    • 并发:同一时刻只能有一条指令执行,当多个进程的指令快速轮换执行,从而达到宏观上同时执行的效果
    • 并行:同一时刻多条指令在多个处理器上同时执行

2. 线程

  • 线程是比进程更小的执行单位
  • 线程被包含在进程中,是进程中的实际执行单位
  • 系统在切换线程时,与切换进程相比,负担更小,因此线程也被称为轻量级进程

3. 多进程

多进程就是计算机同时执行多个进程(软件),现代操作系统均支持多进程,用户可以随意开启多个软件,并让它们同时运行在计算机之上。

对于 CPU 而言,它在同一时间只能执行一个程序。因此 CPU 会在多个进程之间轮换执行,从而达到 “同时运行” 的效果。

4. 多线程

(1) 什么是多线程?

多线程扩展了多进程的概念,使得一个进程可以同时并发处理多个任务。

(2) 特点

  • 线程是进程的组成部分
  • 进程中必须有一个主线程
  • 进程中可以有很多个线程
  • 当进程被创建,主线程也就被创建了
  • 线程的执行是抢占式的
  • 与进程相比,线程之间的分隔程度更小,它们共享进程的状态

(3) 线程与进程的相同点

线程之于进程,就像进程之于操作系统。

  • 线程在进程中是独立的

    进程在操作系统中也是独立的

  • 线程具有生命周期

    进程具有生命周期

  • 线程可以并发执行

    进程可以并发执行

(4) 多线程的优势

  • 线程可以共用进程的状态,因此具有更好的性能
  • 线程会共享进程的状态,因此更容易实现相互通信

二、为什么在操作系统中学习并发?

因为 “历史”。

操作系统是第一个并发程序,它需要借助并发技术保证系统的正确运行。

三、并发带来的问题

具体请看:

并发编程 可见性、原子性、有序性

四、锁

1. 什么是锁?

操作系统提供锁这一概念。

操作系统允许进程通过 lock() 方法获取锁,并通过 unlock() 方法释放锁,有且仅有获取到锁的线程才能访问到临界区。

锁为程序员提供了最小程度的调度控制,通过锁,原本由操作系统调度的混乱状态变得可控。

2. 锁的基本要求

  • 互斥:最基本要求;确保锁有效
  • 公平:保证尝试获取锁的线程都有公平的机会获取到锁
  • 性能:避免使用锁后增加性能开销

3. 互斥的解决方案

(1) 控制中断

“控制中断” 是最早的一个互斥的解决方案,即在进入临界区之前关闭中断,保证临界区中的代码原子性执行,在进入临界区之后开启中断,操作系统正常运行。

这一方法的优点是:

  • 简单

这一方法的缺点是:

  • 会使所有线程都有机会获得 “不用中断” 这一特权

    可能会有恶意程序通过这一漏洞独占处理器

  • 不支持多处理器

    如果有多个处理器,则可以有多个进程进入临界区,虽然它们不会中断,但同样会产生并发问题

(2) 以变量为锁

简单来说,就是用一个变量来标志锁是否被线程占用,伪代码如下:

1
2
3
4
5
6
7
8
9
10
void lock() {
while (flag === 1) {
// 循环等待
}
flag = 1;
}

void unlock() {
flag = 0;
}

这一方法的缺点是:

  • 操作不是原子的,在并发的情况下,可能会出现多个线程同时加锁成功的情况

    因为判断条件和更新变量是两步操作

  • 有性能问题

    线程在等待锁时会循环等待

    假设线程 1 获取到锁,线程 2、3、4 均在尝试获取锁,在公平调度的假设下,CPU 只有 1/4 的时间在工作,另外 3/4 的时间在循环等待,这将造成极大的性能损失。

(3) 保证互斥 - 硬件支持

以变量为锁 + 硬件支持

一些操作系统提供了 “比较并交换” 指令,它能够将 判断条件; 更新变量 这两步操作合并为一步操作,从而能够正确保证互斥。

(4) 减少性能损失 - 线程主动放弃

操作系统可以提供一个 API,其作用是主动放弃 CPU,线程可以在发现尝试加锁发现锁已被占用时调用该方法,从而避免了循环等待。

(5) 减少性能损失 - 休眠队列

循环等待和线程主动放弃的方案都存在一个共有的问题,即存在太多偶然性。调度由操作系统控制,如果一旦调度不合理,将会出现线程一直循环等待 / 主动放弃的情况。

采用休眠队列是一个更好的方式,具体做法如下:

  • 操作系统提供 park() 用于休眠线程,unpark(进程ID) 用于唤醒线程
  • 维护一个队列,该队列维护所有正在休眠的线程
  • 当线程尝试加锁发现锁已被占用时,将自身线程 ID 存入队列中,休眠自己
  • 当线程释放锁时,如果发现队列非空,则唤醒某一个线程

参考

  • 操作系统导论