目录
一、问题综述
1. 进程和线程的区别?
2. 进程的状态有哪些?
3. 进程之间的通信方式?
(1)管道
(2)消息队列
(3)共享内存
(4)信号量
(5)信号
(6)Socket
4. 解释一下进程同步和互斥,以及解决这些问题的方法?
(1)互斥的概念
(2)同步的概念
(3)锁
(4)信号量
(5)使用信号量和 PV 操作
5. 你知道的线程同步的方式有哪些?
(1)互斥锁
(2)读写锁
读写锁的工作原理
(3)条件变量
(4)信号量
二、相关问题
1. 介绍一下你知道的锁?
(1)互斥锁
(2)自旋锁
(3)读写锁
(4)悲观锁
(5)乐观锁
2. 什么是死锁?如何避免死锁?
(1)互斥条件
(2)持有并等待条件
(3)不可剥夺条件
(4)环路等待条件
(5)方法
一、问题综述
1. 进程和线程的区别?
进程是 系统进行 资源分配 和 调度 的基本单位。
线程 Thread 是 操作系统能够 进行运算调度的 最小单位,线程是 进程的 子任务,是进程内的 执行单元。
一个进程 至少有一个线程,一个 进程可以 运行多个线程,这些线程 共享同一块 内存。
资源开销:
- 进程:由于 每个进程都有 独立的 内存空间,创建 和 销毁 进程的 开销较大。进程间 切换 需要 保存 和 恢复 整个进程的 状态,因此 上下文切换 的 开销较高。
- 线程:线程 共享相同的 内存空间,创建 和 销毁线程的 开销较小。线程间 切换 只需要 保存 和 恢复少量 的线程上下文,因此 上下文切换的 开销较小。
通信与同步:
- 进程:由于 进程间 相互隔离,进程之间的 通信需要 使用一些 特殊机制,如 管道、消息队列、共享内存 等。
- 线程:由于 线程共享相同的 内存空间,它们之间 可以 直接访问 共享数据,线程间通信 更加方便。
安全性:
- 进程:由于 进程间 相互隔离,一个进程的 崩溃不会 直接影响 其他进程的 稳定性。
- 线程:由于 线程共享相同的 内存空间,一个线程的 错误可能会 影响整个进程的 稳定性。
2. 进程的状态有哪些?
进程有着「运行-暂停-运行」的活动规律。一般说来,一个进程 并不是 自始至终 连续不停地运行的,它与 并发执行 中的 其他 进程的 执行是 相互制约 的。它 有时 处于 运行状态,有时又 由于 某种 原因而 暂停运行 处于 等待状态,当使它 暂停的 原因消失后,它又进入 准备 运行状态
下述为 五个基本状态:
- 运行状态(Running):该时刻 进程占用 CPU;
- 就绪状态(Ready):可运行,由于 其他进程 处于运行状态 而暂时停止运行;
- 阻塞状态(Blocked):该 进程 正在等待某一事件发生(如 等待输入/输出操作 的完成)而 暂时 停止运行,这时,即使给它 CPU 控制权,它也无法运行;
- 创建状态(new):进程 正在被创建时的 状态;
- 结束状态(Exit):讲程 正在从系统中 消失时的 状态;
再来 详细说明一下进程的 状态变迁:
- NULL -> 创建状态:一个新进程被 创建时的 第一个状态;
- 创建状态 -> 就绪状态:当 进程 被创建完成 并初始化后,一切 就绪准备运行 时,变为 就绪状态,这个 过程是 很快的;
- 就绪态 -> 运行状态:处于 就绪状态的 进程被操 作系统的 进程调度器 选中后,就分配给 CPU 正式运行 该进程;
- 运行状态 ->结束状态:当进程 已经运行 完成 或 出错时,会被 操作系统 作结束 状态处理;
- 运行状态 -> 就绪状态:处于 运行状态的 进程在 运行过程 中,由于 分配给 它的 运行 时间片用完,操作系统 会把 该进程变为 就绪态,接着 从就绪态 选中另外一个 进程运行;
- 运行状态 -> 阻塞状态:当 进程请求 某个事件且 必须等待时,例如 请求 I/O 事件;
- 阻塞状态 -> 就绪状态:当 进程要等待的 事件完成 时,它从 阻塞状态 变到 就绪状态;
如果 有大量 处于 阻塞状态的 进程,进程 可能会 占用着 物理 内存空间,显然 不是我们 所希望的,毕竟 物理 内存空间是 有限的,被 阻塞状态的 进程 占用着 物理内存 就一种 浪费 物理 内存的行为。所以,在 虚拟内存 管理的 操作系统 中,通常会 把阻塞状态的 进程 的 物理内存 空间 换出到 硬盘,等 需要再次 运行的时候,再从 硬盘 换入到 物理内存。
那么,就需要 一个 新的状态,来 描述进程 没有 占用实际的 物理 内存空间的 情况,这个状态就是 挂起状态。这跟 阻塞状态是不一样,阻塞状态 是等待 某个事件的 返回。
挂起状态 可以分为 两种:
- 阻塞挂起状态:进程 在外存(硬盘)并 等待某个事件的 出现;
- 就绪挂起状态:进程 在外存(硬盘),但只要 进入内存,即刻 立刻运行;
这 两种挂起状态加上 前面的 五种状态,就变成了 七种状态变迁,见如下图:
3. 进程之间的通信方式?
每个进程的 用户地址空间 都是 独立的,一般 而言是 不能互相访问的,但内核空间 是 每个进程都 共享的,所以 进程之间要 通信必须通过内核。
(1)管道
所谓的 管道,就是 内核 里面的 一串缓存。从 管道的 一段 写入的数据,实际上 是 缓存在 内核中的,另一端 读取,也就是从内核中读取这段数据。另外,管道传输的数据是无格式的流且大小受限。
这 两个 描述符都是 在一个 进程里面,并 没有起到 进程间 通信的 作用,怎么样 才能 使得 管道是 跨过 两个进程的 呢 ?
我们可以 使用 fork 创建 子进程,创建的 子进程 会复制 父进程的 文件描述符,这样 就 做到了 两个进程各 有两个「fd[0]
与 fd[1]
」,两个进程 就可以 通过 各自的 fd 写入 和 读取 同一个管道文件 实现 跨进程 通信了。
管道 只能 一端写入,另一端读出,所以上面 这种模式容易 造成混乱,因为 父进程 和 子进程 都可以同时写入,也都可以读出。为了避 免 这种情况,通常的做法是:
- 父进程 关闭读取 的 fd[0],只保留 写入 的 fd[1];
- 子进程 关闭写入 的 fd[1],只保留 读取 的 fd[0];
所以说 如果 需要 双向通信,则应该 创建 两个管道。
(2)消息队列
管道的 通信方式 是 效率低的,因此 管道 不适合进程间频繁地交换数据。对于这个问题,消息队列 的 通信模式就 可以解决。
比如,A 进程要 给 B 进程 发送消息,A 进程 把数据放在对应的 消息队列 后就可以 正常 返回了,B 进程 需要的 时候再 去读取数据 就可以了。同理,B 进程 要给 A 进程 发送消息 也是如此。
再来,消息队列 是 保存在 内核中的 消息链表,在发送数据时,会分成 一个一个 独立的 数据单元,也就是 消息体(数据块),消息体是 用户 自定义的 数据类型,消息的 发送方 和 接收方 要约定好 消息体的 数据类型,所以 每个消息体都 是 固定大小的 存储块,不像 管道是 无格式的 字节流 数据。
如果 进程 从 消息队列 中读取了 消息体,内核就会 把这个 消息体 删除。消息队列 生命周期 随内核,如果 没有释放 消息队列 或者 没有关闭 操作系统,消息队列 会一直存在。
消息 这种模型,两个进程之间的 通信 就像 平时发邮件 一样,你来一封,我回一封,可以 频繁沟通了。但 邮件的 通信方式 存在 不足 的地方有 两点,一是 通信 不及时,二是 附件也有 大小限制,这同样也是 消息队列 通信不足的 点。
消息队列 不适合 比较大 数据的 传输,因为 在内核中 每个消息体 都有一个 最大长度的 限制,同时 所有队列 所包含的 全部消息体 的总长度 也是有 上限。
在 Linux 内核中,会有 两个宏定义 MSGMAX 和 MSGMNB,它们以 字节 为单位,分别 定义了 一条消息的 最大长度 和 一个队列的 最大长度。消息队列 通信 过程中,存在 用户态 与 内核态 之间的 数据拷贝 开销,因为 进程 写入数据到 内核中 的消息队列 时,会发生 从用户态 拷贝 数据到 内核态的 过程,同理 另一进程 读取 内核中的 消息数据时,会 发生从 内核态拷贝数据 到 用户态的过程。
(3)共享内存
消息队列的读取和写入的过程,都会有 发生 用户态 与 内核态 之间的 消息拷贝 过程。那 共享内存的方式,就很好的解决了这一问题。
现代操作系统,对于 内存管理,采用的是 虚拟内存技术,也就是 每个进程 都有 自己 独立的 虚拟内存空间,不同进程的 虚拟内存 映射到 不同的 物理内存 中。所以,即使 进程 A 和 进程 B 的虚拟地址 是一样的,其实 访问的是 不同的 物理内存地址,对于 数据的 增删查改 互不影响。
共享内存的 机制,就是拿出一块 虚拟地址 空间 来,映射到 相同的 物理内存 中。这样 这个 进程写入的 东西,另外一个 进程马上 就能看到了,都不需要 拷贝来拷贝 去,传来传去,大大提高了进程间通信的速度。
(4)信号量
用了共享内存通信方式,带来新的问题,那就是如果 多个进程 同时修改 同一个 共享内存,很有 可能就 冲突了。例如 两个进程都 同时 写一个地址,那 先写的 那个进程 会发现 内容 被别人覆盖了。为了防止 多进程竞争 共享资源,而 造成的 数据错乱,所以 需要 保护机制,使得 共享的 资源,在 任意时刻 只能被一个 进程访问。正好,信号量就实现了这一保护机制。
信号量 其实是一个 整型的 计数器,主要 用于 实现 进程间的 互斥与同步,而不是 用于 缓存进程间通信的 数据。信号量 表示 资源的 数量,控制信号量 的方式 有两种 原子操作:
- 一个是 P 操作,这个操作会 把信号量 减去 1,相減后 如果 信号量<0,则 表明资源 已被 占用,进程需 阻塞等待;相減后 如果 信号量 >=0,则 表明 还有资源 可使用,进程 可正常 继续执行。
- 另一个是 V 操作,这个 操作 会 把信号量 加上 1,相加后 如果 信号量<=0,则 表明 当前 有阻塞中的 进程,于是 会将 该进程 唤醒运行;相加后 如果 信号量 >0,则 表明 当前 没有阻塞中的 进程;
P 操作是 用在进入 共享资源 之前,V 操作 是用在 离开 共享资源 之后,这 两个操作是 必须 成对出现的。
接下来,举个例子,如果要使得两个进程互斥访问共享内存,我们可以初始化信号量为 1。
具体的过程如下:
- 进程 A 在 访问 共享内存 前,先执行了 P 操作,由于 信号量的 初始值为 1,故在 进程 A 执行 P 操作后 信号量 变为 0,表示 共享资源 可用,于是 进程 A 就可以 访问 共享内存。
- 若此时,进程 B 也想访问 共享内存,执行了 P 操作,结果 信号量 变为了 -1,这就 意味着 临界资源 已被占用,因此 进程 B 被阻塞。
- 直到 进程 A 访问完 共享内存,才会 执行 V 操作,使得 信号量 恢复为 0,接着 就会 唤醒 阻塞中的 进程 B,使得 进程 B 可以 访问 共享内存,最后 完成 共享内存的 访问后,执行 V 操作,使 信号量 恢复到 初始值 1。
可以发现,信号 初始化 为 1,就 代表着是 互斥信号量,它 可以 保证 共享内存 在任何 时刻 只有一个 进程在 访问,这就 很好的 保护了 共享内存。
另外,在 多进程 里,每个 进程 并不一定是 顺序执行的,它们 基本是 以各自独立的、不可预知的速度 向前推进,但 有时候 我们 又希望 多个进程能 密切合作,以 实现一个 共同的 任务。
例如,进程 A 是 负责 生产数据,而 进程 B 是负责 读取数据,这 两个进程是 相互合作、相互依赖的,进程 A 必须先 生产了数据,进程 B 才能 读取到数据,所以 执行是 有前后 顺序的。那么 这时候,就可以 用信号量 来实现 多进程 同步的 方式,我们 可以 初始化 信号量为 0。
具体过程:
- 如果 进程 B 比 进程 A 先执行了,那么 执行到 P 操作时,由于 信号量 初始值 为 0,故 信号量 会变为 -1,表示 进程 A 还没 生产数据,于是 进程 B 就 阻塞等待;
- 接着,当 进程 A 生产完数据 后,执行了 V 操作,就会 使得 信号量 变 0,于是就会 唤醒 阻塞在 P 操作的 进程 B;
- 最后,进程 B 被唤醒 后,意味着 进程 A 已经 生产了 数据,于是 进程 B 就 可以 正常 读取 数据了。
可以发现,信号 初始化 为 0,就代表着是 同步信号量,它 可以 保证 进程 A 应在 进程 B 之前执行。
(5)信号
上述进程间通信,都是常规状态下的工作模式。对于异常情况下的工作模式,就需要 用「信号」的方式来 通知 进程。
信号用于 通知 接收进程 某个事件 已经发生,从而 迫使进程 执行信号 处理程序。
运行在 shell 终端的 进程,我们 可以通过 键盘输入 某些 组合键的时候,给 进程发送 信号。例如:
- Ctrl+C 产生 SIGINT 信号,表示终止该进程;
- Ctrl+Z 产生 SIGTSTP 信号,表示停止该进程,但还未结束;
- 如果进程在后台运行,且知道进程 PID 号,可以通过 kill 命令的方式给进程发送信号:kill-91050,表示给 PID 为 1050 的进程发送 SIGKILL 信号,用来 立即结束 该进程;
所以,信号事件的 来源 主要有 硬件来源(如键盘 Cltr+C)和 软件来源(如 kill 命令)。
(6)Socket
上述 管道、消息队列、共享内存、信号量 和 信号 都是在 同一台主机上 进行 进程间通信,那 要想跨网络 与 不同主机上的 进程之间 通信,就需要 Socket 通信。
4. 解释一下进程同步和互斥,以及解决这些问题的方法?
(1)互斥的概念
假设同一个进程中的 线程 1 和 线程 2 同时执行对变量 i 的 加 1 操作,每个线程执行 10000 次,那么它对应的汇编指令执行过程是这样的:
但由于 时钟中断发生 造成上下文切换,使得 最后的结果 不等于 20000,针对上面 线程 1 和 线程 2 的执行过程,产生这种情况的 流程图如下:
上面展示的 情况 称为 竞争条件(race condition),当 多线程 相互竞争 操作 共享变量 时,由于运气 不好,即在 执行过程中 发生了 上下文切换,我们 得到了 错误的结果,事实上,每次 运行 都可能 得到不同的 结果,因此 输出的 结果 存在 不确定性(indeterminate)。
由于 多线程 执行 操作共享变量的 这段代码 可能会 导致 竞争状态,因此 我们将 此段 代码 称为临界区(criticalsection),它是 访问 共享资源的 代码片段,一定 不能 给 多线程 同时执行。我们希望 这段代码是 互斥(mutualexclusion)的,也就 说保证 一个线程在 临界区执行 时,其他 线程 应该被 阻止进入临界区,说白了,就是这段代码执行过程中,最多只能出现一个线程。
(2)同步的概念
互斥解决了 并发进程/线程 对临界区的 使用问题。这种 基于临界区 控制的 交互作用 是 比较简单的,只要 一个进程/线程 进入了 临界区,其他 试图想 进入临界区的 进程/线程 都会 被阻塞着,直到 第一个进程/线程 离开了 临界区。在 多线程里,每个线程 并不一定 是 顺序执行的,它们 基本是 以各自 独立的、不可预知的 速度 向前推进,但 有时候 我们又希望 多个 线程 能密切合作,以 实现一个 共同的 任务。
例如,线程 1 是负责 读入 数据的,而 线程 2 是负责 处理数据的,这 两个线程 是 相互合作、相互依赖的。线程 2 在 没有 收到 线程 1 的唤醒通知时,就会 一直阻塞等 待,当 线程 1 读完 数据需要把 数据传给 线程 2 时,线程 1 会唤醒 线程 2,并 把数据 交给 线程 2 处理。
进程同步是 指 多个 并发执行的进程 之间协调 和 管理 它们的 执行顺序,以 确保它们 按照一定的顺序 或 时间间隔 执行。
- 同步就好比:「操作 A 应在 操作 B 之前 执行」,「操作 C 必须在 操作 A 和 操作 B 都完成之后才能执行」等;
- 互斥就好比:「操作 A 和 操作 B 不能在 同一时刻 执行」;
(3)锁
使用 加锁操作 和 解锁操作 可以解决 并发线程/进程 的互斥问题。任何想 进入临界区 的线程,必须 先执行 加锁操作。若 加锁操作 顺利通过,则 线程 可进入 临界区;在 完成 对临界资源的 访问后 再执行 解锁操作,以 释放该 临界资源。
(4)信号量
信号量 是 操作系统 提供的 一种 协调共享资源 访问的方法。通常 信号量 表示 资源的数量,对应的 变量是一个 整型(sem)变量。另外,还有 两个 原子操作的 系统调用函数 来控制信号量的,分别是:
- P 操作:将 sem 减1,相減后,如果 sem<0,则 进程/线程 进入 阻塞等待,否则继续,表明 P 操作可能会 阻塞;
- V 操作:将 sem 加1,相加后,如果 sem<=0,唤醒一个 等待中的 进程/线程,表明 V 操作 不会阻塞;
原子操作 就是 要么全部执行,要么 都不执行,不能 出现执行到 一半的 中间状态。
P 操作 是 用在 进入临界区 之前,V 操作 是用在 离开临界区 之后,这 两个操作 是必须 成对 出现的。举个类比,2 个 资源的 信号量,相当于 2 条 火车轨道,PV 操作 如下图过程:
PV 操作的函数是由操作系统管理和实现的,所以 操作系统 已经使得 执行 PV 函数时是 具有 原子性的。
(5)使用信号量和 PV 操作
为 每类 共享资源 设置一个 信号量 s,其初值为 1,表示 该临界资源 未被占用。只要 把 进入临界区的 操作置于 P(s)和 V(s)之间,即可 实现 进程/线程 互斥:
此时,任何 想进入 临界区 的线程,必先在 互斥信号量 上 执行 P 操作,在 完成 对临界资源的 访问后再 执行 V 操作。
由于 互斥信号量 的 初始值 为 1,故在 第一个 线程 执行 P 操作后 s 值 变为 0,表示临界资源 为空闲,可 分配给 该线程,使之 进入 临界区。若此时 又有 第二个 线程 想进入临界区,也应 先执行 P 操作,结果使 s 变为 负值,这就 意味着 临界资源 已被 占用,因此,第二个线程 被 阻塞。
并且,直到 第一个 线程 执行 V 操作,释放 临界资源 而 恢复 s 值 为 0 后,才 唤醒 第二个 线程,使之 进入 临界区,待它 完成 临界资源 的 访问后,又 执行 V 操作,使 s 恢复到 初始值 1。对于 两个 并发线程,互斥信号量 的值 仅 取 1、0 和 -1 三个值,分别表示:
- 如果 互斥信号量为 1,表示 没有线程 进入临界区。
- 如果 互斥信号量为 0,表示 有一个线程 进入临界区。
- 如果 互斥信号量为 -1,表示 一个线程 进入临界区,另一个线程 等待进入。
通过 互斥信号量 的方式,就能 保证临界区 任何时刻 只有 一个线程 在执行,就达到了 互斥的效果。
5. 你知道的线程同步的方式有哪些?
线程同步机制是 指在 多线程编程 中,为了 保证 线程之间 的 互不干扰,而 采用的 一种机制。常见的 线程同步机制 有以下 几种:
(1)互斥锁
加锁的 目的 就是 保证共享资源 在任意时间里,只有 一个线程 访问,这样就 可以 避免 多线程 导致 共享数据 错乱的问题。当 已经 有一个 线程加锁 后,其他 线程 加锁 则就会失败。互斥锁 加锁失败后,线程会释放 CPU,给其他 线程。
互斥锁 是一种「独占锁」,比如当 线程 A 加锁 成功后,此时 互斥锁 已经 被 线程 A 独占了,只要 线程 A 没有 释放 手中的 锁,线程 B 加锁 就会 失败,于是 就会 释放 CPU 让给 其他 线程,既然 线程 B 释放掉了 CPU,自然 线程 B 加锁的 代码 就会 被阻塞。
对于 互斥锁 加锁失败 而阻塞的 现象,是由 操作系统 内核 实现的。当 加锁失败时,内核 会 将线程置为「睡眠」状态,等到 锁被 释放后,内核 会在合适的 时机 唤醒线程,当 这个线程 成功获取到 锁 后,于是就可以 继续执行。如下图:
所以,互斥锁 加锁失败时,会从 用户态 陷入到 内核态,让 内核 帮我们 切换线程,虽然 简化了使用 锁的 难度,但是 存在一定的 性能 开销成本。
这个 开销成本即 会有 两次线程 上下文切换的 成本:
- 当线程 加锁失败 时,内核会 把线程的 状态 从「运行」状态 设置为「睡眠」状态,然后把 CPU 切换给 其他线程 运行。
- 接着,当 锁 被释放时,之前「睡眠」状态的 线程会 变为「就绪」状态,然后 内核会 在 合适的 时间,把 CPU 切换给该线程运行。
线程的上下文切换,即当两个线程是属于同一个进程,因为虚拟内存是共享的,所以在切换时,虚拟内存这些资源就保持不动,只需要切换线程的私有数据、寄存器等不共享的数据。
上下切换的 耗时 有大佬 统计过,大概在 几十纳秒 到 几微秒之间,如果 锁住的 代码执行 时间 比较短,那可能 上下文切换的 时间 都比锁住的代码 执行时间 还要长。
(2)读写锁
读写锁 由「读锁」和「写锁」两部分 构成,如果 只读取 共享资源 用「读锁」加锁,如果 要 修改共享资源 则用「写锁」加锁。所以,读写锁 适用于 能明确 区分 读操作 和 写操作的 场景。
读写锁的工作原理
- 当「写锁」没有 被线程 持有时,多个线程 能够 并发地 持有 读锁,这 大大 提高了 共享资源的 访问效率,因为「读锁」是 用于 读取 共享资源的 场景,所以 多个线程 同时 持有 读锁也 不会 破坏共享资源的 数据。
- 但是,一旦「写锁」被线程持有后,读线程的 获取 读锁的 操作 会被阻塞,而且 其他 写线程的 获取 写锁 的操作 也会 被阻塞。
所以说,写锁是 独占锁,因为 任何时刻 只能有一个 线程 持有写锁,类似 互斥锁,而 读锁 是共享锁,因为 读锁 可以被 多个线程 同时 持有。
(3)条件变量
条件变量 用于 线程间 通信,允许 一个线程 等待 某个 条件满足,而 其他线程 可以 发出 信号通知 等待线程。通常 与 互斥锁 一起使用。
(4)信号量
用于控制 多个线程 对共享资源 进行 访问的工具。
二、相关问题
1. 介绍一下你知道的锁?
两个基础的锁。
(1)互斥锁
互斥锁 是一种 最 常见的锁 类型,用于 实现 互斥访问 共享资源。在 任何时刻,只有 一个线程 可以持有 互斥锁,其他线程 必须 等待 直到 锁被释放。这 确保了 同一时间 只有一个 线程 能够访问 被保护的 资源。
(2)自旋锁
加锁的 目的就是 保证 共享资源 在任意时间里,只有 一个 线程访问,这样就 可以 避免 多线程 导致 共享 数据错乱的 问题。当已经 有一个 线程 加锁后,其他线程 加锁则就 会失败。
- 自旋锁 加锁 失败后,线程会 忙等待,直到 它拿到锁。
自旋锁是 通过 CPU 提供的 CAS 函数(Compare And Swap),在「用户态」完成 加锁 和 解锁 操作,不会 主动 产生 线程 上下文切换,所以 相比 互斥锁 来说,会快一些,开销也小一些。一般加锁的过程,包含两个步骤:
- 第一步,查看 锁的状态,如果 锁是 空闲的,则 执行 第二步;
- 第二步,将 锁 设置为 当前 线程 持有;
CAS 函数 就 把 这两个步骤 合并成一条 硬件级 指令,形成 原子 指令,这样就 保证了 这两个步骤是 不可分割的,要么 一次性 执行 完两个步骤,要么 两个步骤 都不执行。
比如,设 锁 为变量 lock,整数 0 表示 锁 是空闲状态,整数 pid 表示线程 ID,那么 CAS(lock, 0, pid)就表示 自旋锁的 加锁 操作,CAS(lock, pid, 0) 则表示 解锁操作。
使用 自旋锁 的时候,当 发生 多线程 竞争锁的 情况,加锁 失败的 线程会「忙等待」,直到它 拿到锁。这里的「忙等待」可以用 while 循环等待实现,不过 最好是 使用 CPU 提供的 PAUSE 指令来实现「忙等待」,因为可以减少 循环等待时的 耗电量。
自旋锁 是 最比较简单的 一种锁,一直 自旋,利用 CPU 周期,直到 锁可用。
注意:在 单核 CPU 上,需要 抢占式的 调度器(即 不断 通过时钟 中断一个 线程,运行 其他线程)。否则,自旋锁在 单 CPU 上 无法使用,因为 一个自旋的 线程 永远不会 放弃 CPU。
自旋锁 开销少,在 多核系统下 一般 不会 主动 产生 线程 切换,适合 异步、协程等 在用户态 切换请求的 编程方式,但 如果 被锁住的 代码 执行时间 过长,自旋的 线程会 长时间 占用 CPU 资源,所以 自旋的时间 和 被锁住的 代码 执行的 时间是 成「正比」的关系。
自旋锁 与 互斥锁 使用 层面比较 相似,但 实现 层面上 完全不同:当 加锁失败 时,互斥锁 用「线程切换」来 应对,自旋锁 则用「忙等待」来 应对。
自旋锁 与 互斥锁 是锁的 最基本 处理方式,更高级的锁 都会 选择 其中一个来 实现,比如 读写锁 既可以选择互斥锁实现,也可以基于自旋锁实现。
(3)读写锁
允许 多个线程 同时 读共享资源,只 允许一个 线程 进行 写操作。分为读(共享)和 写(排他)两种状态。
(4)悲观锁
互斥锁、自旋锁、读写锁,都是属于 悲观锁。悲观锁 做事 比较悲观,它认为 多线程 同时 修改 共享资源的 概率 比较高,于是 很容易 出现冲突,所以 访问共享 资源前,先要 上锁。
(5)乐观锁
与 悲观锁 相反的,如果 多线程 同时 修改 共享资源的 概率 比较低,就可以 采用 乐观锁。
乐观锁 做事 比较 乐观,它 假定 冲突的 概率 很低,它的 工作方式 是:先 修改完 共享资源,再 验证 这段时间内 有没有 发生冲突,如果 没有 其他线程 在修改资源,那么 操作完成,如果 发现有 其他线程 已经 修改过 这个资源,就 放弃 本次操作。放弃后 如何重试,这跟 业务场景 息息相关,虽然 重试的 成本很高,但是 冲突的 概率足够低的话,还是可以接受的。可见,乐观锁的 心态是,不管 三七二十一,先改了 资源再说。
乐观锁 全程并 没有加锁,所以它也叫 无锁编程。
这里 举一个 场景例子:在线文档。
在线文档 可以 同时 多人编辑,如果 使用了 悲观锁,那么 只要 有一个用户 正在 编辑文档,此时其他用户 就 无法打开 相同的 文档了,这 用户体验 当然不好了。那 实现多人 同时编辑,实际上是用了 乐观锁,它 允许 多个用户 打开 同一个 文档 进行编辑,编辑完 提交之后 才 验证修改的 内容 是否 有冲突。怎么样才算发生冲突?
这里 举个例子,比如 用户 A 先在 浏览器 编辑文档,之后 用户 B 在浏览器 也 打开了 相同的 文档进行 编辑,但是 用户 B 比 用户 A 提交 早,这一过程 用户 A 是 不知道的,当 A 提交 修改完的 内容时,那么 A 和 B 之间 并行 修改的 地方就 会 发生冲突。服务端 要 怎么验证 是否 冲突了呢?
通常方案如下:
- 由于 发生冲突的 概率 比较低,所以 先让 用户 编辑文档,但是 浏览器 在下载文档时 会记录 下服务端 返回的 文档 版本号。
- 当 用户 提交修改 时,发给 服务端的 请求会 带上 原始 文档版本号,服务器 收到后 将它 与 当前 版本号 进行比较,如果 版本号 不一致则 提交失败,如果 版本号 一致则 修改成功,然后 服务端版本号 更新到 最新的 版本号。
实际上,我们常见的 SVN 和 Git 也是用了 乐观锁的 思想,先 让 用户 编辑代码,然后 提交的时候,通过 版本号 来 判断 是否 产生了 冲突,发生了 冲突的 地方,需要 我们 自己修改后,再 重新提交。
乐观锁 虽然 去除了 加锁 解锁的 操作,但是 一旦 发生冲突,重试的 成本 非常高,所以 只有 在 冲突概率 非常低,且 加锁成本 非常高的 场景时,才 考虑使用 乐观锁。
2. 什么是死锁?如何避免死锁?
在多线程编程中,为了防止多线程竞争共享资源而导致数据错乱,都会在操作共享资源之前加上互斥锁,只有成功获得到锁的线程,才能操作共享资源,获取不到锁的线程就只能等待,直到锁被释放。
那么,当 两个线程 为了 保护 两个不同的 共享资源 而使用了 两个互斥锁,那么 这两个 互斥锁 应用不当的 时候,可能会 造成 两个线程 都在 等待对方 释放锁,在 没有外力的 作用下,这些 线程会 一直 相互等待,就 没办法 继续运行,这种情况 就是发生了 死锁。
死锁 只有 同时满足以下 四个条件 才会发生:
- 互斥条件
- 持有并等待条件
- 不可剥夺条件
- 环路等待条件
(1)互斥条件
互斥条件是 指 多个线程 不能 同时使用 同一个资源。比如下图,如果 线程 A 已经 持有的 资源,不能再 同时 被 线程 B 持有,如果 线程 B 请求 获取 线程 A 已经 占用的资源,那 线程 B 只能等待,直到 线程 A 释放了资源。
(2)持有并等待条件
持有并等待条件是指,当 线程 A 已经持有了 资源 1,又想 申请 资源 2,而 资源 2 已经 被 线程 C 持有了,所以 线程 A 就会处于 等待状态,但是 线程 A 在 等待 资源 2 的同时 并不会 释放 自己已经持有的 资源 1。
(3)不可剥夺条件
不可剥夺条件 是指,当 线程 已经持有了 资源,在 自己 使用完 之前 不能被 其他 线程获取,线程 B 如果 也想使用 此资源,则 只能在 线程 A 使用完 并释放后 才能获取。
(4)环路等待条件
环路等待条件 指的是,在 死锁 发生的 时候,两个线程 获取资源的 顺序 构成了 环形链。比如,线程 A 已经 持有 资源 2,而 想请求 资源 1,线程 B 已经 获取了 资源 1,而想 请求 资源 2,这就形成 资源请求 等待的 环形图。
(5)方法
只需要破坏上面一个条件就可以破坏死锁:
- 破坏持有并等待条件:一次性申请所有的资源。
- 破坏不可剥夺条件:占用 部分资源的 线程 进一步 申请 其他资源时,如果 申请不到,可以 主动释放 它占有的 资源。
- 破坏环路等待条件:靠 按序 申请资源 来预防。让 所有 进程 按照 相同的 顺序 请求资源,释放 资源则 反序释放。
破坏环路等待条件下:
线程 A 和 线程 B 获取资源的 顺序要 一样,当 线程 A 是 先尝试 获取 资源 A,然后 尝试 获取 资源 B 的时候,线程 B 同样也是先 尝试 获取资源 A,然后 尝试 获取 资源 B。也就是说,线程 A 和 线程 B 总是以 相同的顺序 申请自己 想要的 资源。