【系统面试篇】进程和线程类(1)(笔记)——区别、通讯方式、同步、互斥、死锁
目录
一、问题综述
1. 进程和线程的区别?
2. 进程的状态有哪些?
3. 进程之间的通信方式?
(1)管道
(2)消息队列
(3)共享内存
(4)信号量
(5)信号
(6)Socket
4. 解释一下进程同步和互斥,以及解决这些问题的方法?
(1)互斥的概念
(2)同步的概念
(3)锁
(4)信号量
(5)使用信号量和 PV 操作
5. 什么是死锁?如何避免死锁?
(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)互斥条件
互斥条件是 指 多个线程 不能 同时使用 同一个资源。比如下图,如果 线程 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 总是以 相同的顺序 申请自己 想要的 资源。