【C++八股-第19期】操作系统 - 多线程锁与IO模型
❤此贴满十朵花花,速更下一贴
提纲:
👉 八股:
说一下
同步
与异步
的区别说一下
阻塞
与非阻塞
的区别介绍一下
死锁
和活锁
概述一下
自旋锁
和互斥锁
的使用场景简单介绍一下
公平锁
和非公平锁
在 C++ 中
sleep
和wait
是如何控制线程行为的?介绍一下什么是IO操作
介绍一下五种IO模型
阻塞I/O 和 非阻塞I/O 有什么区别
多路I/O复用机制有哪些,总结一下区别
说说epoll的原理
epoll为什么高效?
👉 代码:
1. 说一下同步
与异步
的区别
同步
:
- 同步指的是调用者在发起请求后,必须等待所有操作完成并获取到结果后才能继续执行下一步操作。
- 两方的操作是按顺序协调进行的,即操作是串行的,发起者必须等待,直到所有的操作完成。
- 例如,发起一个数据库写操作时,用户需要等到数据库写入成功后,系统才给用户返回结果。用户体验不佳,因其需要等待较长时间。
异步
:
- 异步指的是调用者在发起请求后,不必等待所有操作完成就能继续进行其他操作。操作完成后系统会通过回调、通知等方式告知调用者结果。
- 调用者发起请求后可以继续执行其他任务,不必等待操作完成。
- 例如,发起一个数据库写操作后,系统先返回响应给用户,后台再慢慢完成数据库操作,最终通过通知用户。用户体验较好,因为响应较快。
2. 说一下 阻塞
与 非阻塞
的区别
阻塞
:
- 定义: 阻塞是指当调用者发起请求后,调用者会一直等待直到请求完成,调用者无法进行其他任务。
- 特点: 调用者在等待操作结果的过程中被挂起,无法执行其他任务。
- 应用场景: 例如,在发起一个IO操作时,进程必须等待操作完成才能继续执行后续逻辑。
非阻塞
:
- 定义: 非阻塞是指调用者发起请求后,不需要等待操作完成,可以继续执行其他任务。调用者通过轮询或事件机制定期检查操作是否完成。
- 特点: 调用者不会被挂起,可以在等待结果的同时执行其他任务。
- 应用场景: 例如,发起一个IO操作后,调用者可以继续处理其他任务,不会因为IO操作未完成而阻塞。
3. 介绍一下 死锁
和 活锁
死锁(Deadlock): 相互侵占导致卡死
-
定义: 死锁是指两个或多个线程因争夺资源而陷入互相等待的状态。如果没有外部干预,这些线程将永远无法继续执行。
-
例子: 线程A持有资源1,等待资源2;线程B持有资源2,等待资源1。这种情况下两个线程互相等待,导致死锁。
活锁(Livelock): 动态让步导致卡死
-
定义: 活锁是指多个线程在不断改变状态以响应彼此的动作,结果是没有线程能继续执行。线程虽然没有阻塞,但总是在忙于“礼貌”地相互让步,导致操作无法前进。
-
特征:
-
动态变化: 线程可以继续执行,但由于相互间的让步,实际上并未推进。
-
无进展: 最终结果是所有线程处于一种忙碌但无效的状态。
-
-
例子: 线程A想要获取资源,但发现线程B已经拥有该资源,于是它释放自己持有的资源,让线程B先执行。然而,线程B也采取同样的策略,结果导致两个线程始终处于相互等待的状态。
4. 概述一下 自旋锁
和 互斥锁
的使用场景
自旋锁和互斥锁都是用于保护临界区的并发访问
互斥锁(Mutex Lock)使用场景: 锁持有时间长
存在阻塞或复杂操作
互斥锁适合在持锁时间较长的情况下使用,因为它会使线程在无法获取锁时进入睡眠,从而节省CPU资源。以下情况可以考虑使用互斥锁:
- 临界区有IO操作: 因为IO操作往往需要较长的时间等待,所以线程在等待时进入休眠状态可以避免浪费CPU时间。
- 临界区代码复杂或循环量大: 当临界区的操作较为复杂或有大量循环时,持锁时间较长,使用互斥锁更为合适。
- 临界区竞争非常激烈: 如果多个线程频繁争夺同一锁,互斥锁可以避免自旋锁带来的忙等待问题。
- 单核处理器: 在单核环境中,自旋锁会浪费CPU时间,因为无法调度其他线程来执行任务,互斥锁则更有效率。
自旋锁(Spin Lock)使用场景: 锁持有时间短
争用较少
CPU资源充裕
自旋锁适合在持锁时间非常短、线程获取锁的等待时间很短的情况下使用,因为它不会让线程进入睡眠,而是持续尝试获取锁。以下情况可以考虑使用自旋锁:
- 临界区持锁时间非常短: 如果临界区的操作简单、快速,可以使用自旋锁,以避免线程切换带来的开销。
- CPU资源不紧张: 自旋锁在获取锁时会消耗CPU资源,因此只有在CPU资源较为充裕时才适合使用自旋锁。
5. 简单介绍一下公平锁
和 非公平锁
公平锁
-
定义: 公平锁确保所有线程按照请求锁的顺序来获取锁。一个线程在尝试获取锁之前,会检查等待队列,如果它是队列中的第一个或队列为空,它才能获得锁;否则,它会被添加到等待队列中并等待。
-
特点:
- **FIFO顺序:**保证了线程获取锁的公平性,避免了某些线程长期得不到锁(即“饥饿”现象)。
- 额外性能开销: 由于需要维护等待队列,公平锁可能会引入额外的上下文切换和管理开销。
-
使用场景: 适合对公平性要求比较高的应用,如任务调度等场景。
非公平锁
-
定义: 非公平锁不保证线程获取锁的顺序。线程在请求锁时,首先尝试直接获取,如果获取失败,则可能会进入等待队列,类似于公平锁的后处理方式。
-
特点:
- 吞吐量高: 由于非公平锁可以让线程更快地重试获取锁,所以在高竞争场景下通常能提供更好的性能和吞吐量。
- 饥饿现象: 由于不遵循公平原则,某些线程可能会长时间无法获取锁,导致饥饿问题。
-
**使用场景:**适合对性能要求较高且可以容忍一定程度不公平的场景,如高并发的缓存系统等。
6. 在 C++ 中 sleep
和 wait
是如何控制线程行为的?
sleep
:
-
功能: sleep 是一个延时函数,主要用于让当前线程进入休眠一段时间,休眠结束后继续执行线程的后续操作。
-
线程行为: 在休眠期间,线程会暂时停止执行,释放 CPU 资源。但在休眠时,线程不会释放它所持有的任何锁资源,也不会被其他线程唤醒。
-
常用场景: sleep 通常用于等待一段时间再执行操作,比如在多线程编程中模拟定时任务、避免高频率轮询等。
-
例子:
#include <iostream>
#include <thread>
void task() {
std::cout << "Task started\n";
std::this_thread::sleep_for(std::chrono::seconds(2)); // 休眠2秒
std::cout << "Task resumed after sleep\n";
}
int main() {
std::thread t(task);
t.join();
return 0;
}
在这个例子中,线程休眠 2 秒后继续执行,但它没有释放任何资源锁。
wait
:
-
功能: wait 通常用于同步线程,主要用于条件变量(std::condition_variable)的等待操作。线程在 wait 时会阻塞并等待某个条件被满足,同时会释放它所持有的互斥锁。
-
线程行为: 调用 wait 后,线程会释放它占用的互斥锁,并进入等待状态,直到其他线程通过 notify 或 notify_all 唤醒它 。wait 通常用于线程之间的协作,防止忙等待。
-
常用场景: wait 用于实现线程之间的同步,确保某些条件被满足后再继续执行。例如,生产者-消费者模型中,消费者可以使用 wait 等待生产者提供数据。
-
例子:
#include <iostream>
#include <thread>
#include <mutex>
#include <condition_variable>
std::mutex mtx;
std::condition_variable cv;
bool ready = false;
void worker() {
std::unique_lock<std::mutex> lock(mtx);
cv.wait(lock, [] { return ready; }); // 等待 ready 为 true
std::cout << "Worker thread proceeding\n";
}
void signal() {
std::unique_lock<std::mutex> lock(mtx);
ready = true;
cv.notify_one(); // 唤醒等待的线程
}
int main() {
std::thread t(worker);
std::this_thread::sleep_for(std::chrono::seconds(1)); // 模拟一些操作
signal(); // 唤醒等待线程
t.join();
return 0;
}
在这个例子中,wait
使得线程在 ready
为 true
之前处于等待状态,同时释放了互斥锁,等待 signal()
来唤醒它。
7. 介绍一下什么是IO操作
IO
(Input/Output,输入/输出)即数据的读取(接收)或写入(发送)操作。
通常用户进程中的一个完整IO分为两阶段 :用户进程空间 与 内核空间之间 的相互切换、 内核空间 与 设备空间 的相互切换(磁盘、网络等)。我们通常说的IO是指 网络IO 和 磁盘IO 两种。
Linux中进程无法直接操作I/O设备,其必须通过系统调用请求内核来协助完成I/O动作;内核会为每个I/O设备维护一个缓冲区。
对于一个输入操作来说,进程IO系统调用后,内核会先看缓冲区中有没有相应的缓存数据,没有的话再到设备中读取,因为设备IO一般速度较慢,需要等待;内核缓冲区有数据则直接复制到进程空间。
所以,对于一个网络输入操作通常包括两个不同阶段:
- 等待网络数据到达网卡 → 读取到内核缓冲区,数据准备好;
- 从内核缓冲区复制数据到进程空间。
8. 介绍一下五种IO模型
1. 阻塞IO(Blocking I/O)
阻塞IO是指进程发起IO系统调用后,进程被完全阻塞转到内核空间处理,直到整个IO处理完毕后返回进程。
发起IO系统调用的进程将一直等待,不停的检查这个函数有没有返回,必须等这个函数返回后才能进行下一步动作,操作成功则进程获取到数据。
2. 非阻塞IO(Non-blocking I/O)
非阻塞IO是指进程发起IO系统调用后,如果内核数据还未准备好,内核不会让进程阻塞,而是立即返回错误或状态码。
进程可以继续执行其他任务,而不是等待IO完成。进程需要通过轮询的方式定期检查IO操作是否就绪。这样就实现非阻塞。
每个进程都有一个时间片,轮询的时候读取IO,时间片到了就要换另一个进程做其他事情了,这样就做到了每隔一段时间发起IO系统调用。
3. IO多路复用(I/O Multiplexing)
IO多路复用允许一个进程同时监控多个IO事件。当某个IO事件准备就绪时,进程才会对该事件进行IO操作。
Linux中常用 select()
、poll()
和 epoll()
来实现IO多路复用。这些系统调用会使进程阻塞,但与阻塞IO不同,进程可以同时监控多个IO操作,直到有数据可读或可写时,才真正调用IO操作函数。
4. 信号驱动IO(Signal-driven I/O)
信号驱动IO采用信号机制,当IO事件准备就绪时,内核通过发送信号通知进程进行相应的IO操作。进程不需要一直阻塞或轮询,而是可以继续执行其他任务,直到收到IO事件的信号通知(如 SIGIO)。
5. 异步IO(Asynchronous I/O, AIO)
异步IO是真正的异步模型,进程发起IO系统调用后,内核立即返回,进程无需等待IO操作完成。当数据准备好并从设备拷贝到用户空间后,内核会通知进程。进程可以同时执行其他任务,内核全权处理IO操作。
9. 阻塞I/O 和 非阻塞I/O 有什么区别
定义 | 进程发起 I/O 请求后,进程会被挂起,直到 I/O 完成。 | 进程发起 I/O 请求后,立即返回,不会挂起进程。 |
调用方式 | 线程调用会阻塞,等待 I/O 操作完成。 | 线程调用立即返回,继续执行其他操作。 |
执行效率 | 在高并发环境中,线程会因等待而浪费资源,效率低下。 | 支持高并发,线程可继续处理其他任务,提高效率。 |
复杂性 | 实现相对简单,容易理解和使用。 | 实现相对复杂,需要处理状态检查或回调机制。 |
适用场景 | 适合处理较少并发请求的场景,或对实时性要求低的任务。 | 适合高并发、大量 I/O 请求的场景,能提高系统响应能力。 |
线程管理 | 每个 I/O 请求通常会占用一个线程,导致线程开销大。 | 多个 I/O 操作可以由一个线程管理,减少线程开销。 |
总结
- 阻塞 I/O 适用于简单且并发量低的情况,但在高并发下效率低下。
- 非阻塞 I/O 更适合需要处理大量并发请求的场景,虽然实现复杂,但能显著提高系统的响应能力和资源利用率。
10. 多路I/O复用机制有哪些,总结一下区别
多路 I/O 复用是一种通过机制监视多个文件描述符的技术。一旦某个文件描述符就绪(如读就绪或写就绪),可以通知应用程序进行相应的读写操作。常见的多路 I/O 复用机制有以下三种:
1. select:
- 特点:
- 限制:最大可监视的文件描述符数量有限(通常为 1024),这一限制在高并发场景下可能成为瓶颈。
- 每次调用
select
时,必须重新设置文件描述符集合。 - 内核在每次调用时,需要将文件描述符集合从用户态拷贝到内核态,这带来额外的性能开销。
- 使用场景: 适用于简单的应用程序,但在高并发环境下效率较低。
2. poll:
- 特点:
- 没有文件描述符数量的限制,只需被初始化一次,通过
pollfd
数组传递需要关注的事件。 pollfd
数组中的events
字段和revents
字段分别用于标识关注的事件和发生的事件。- 相比
select
,在处理大量文件描述符时更加灵活,但仍需轮询整个数组,性能不如epoll
。
- 没有文件描述符数量的限制,只需被初始化一次,通过
- 使用场景: 适合于文件描述符数量变化较大的应用,但仍不如
epoll
高效。
3. epoll:
- 特点:
- 高效处理大规模文件描述符,能够支持大于 1024 个文件描述符。
- 内部使用就绪链表,检查是否为空即可,节省 CPU 时间,避免频繁轮询。
- 只需一次数据拷贝和挂起,开销更小,性能更优。
- 使用事件通知机制,可以在就绪事件发生时直接通知应用程序。
- 使用场景: 适合高并发、高性能的网络应用,如服务器端应用。
区别总结
最大描述符限制 | 受限(通常为 1024) | 无限制 | 无限制 |
初始化次数 | 每次调用需重新设置 | 只需初始化一次 | 只需初始化一次 |
拷贝开销 | 每次调用需将 fd 集合拷贝到内核 | 每次调用需拷贝 fd 集合 | 仅需一次拷贝 |
CPU 使用效率 | 高并发时性能较低 | 相对较好,但仍需轮询 | 高效,使用就绪链表,节省 CPU 时间 |
事件通知机制 | 无 | 无 | 有 |
总结
- select 和 poll 适合简单场景,但在高并发时性能较低。
- epoll 具备高效的性能和灵活性,特别适合处理大量并发连接的高性能网络应用。
11. 说说epoll的原理
epoll
是 Linux 内核提供的一种高效的 I/O 多路复用机制,主要通过红黑树和链表来管理需要监控的文件描述符(FD)。以下是 epoll
的工作原理及其主要函数:
-
创建 epoll 对象
- 使用
epoll_create
函数创建一个epoll
对象。这个对象用于管理一组文件描述符。
- 使用
-
控制 epoll 对象
- 使用
epoll_ctl
函数对epoll
对象进行操作:- 添加文件描述符:将需要监控的文件描述符添加到
epoll
对象中。 - 删除文件描述符:从
epoll
对象中删除已监控的文件描述符。 - 修改文件描述符的监控事件:可以更改某个文件描述符监控的事件类型(如可读、可写等)。
- 添加文件描述符:将需要监控的文件描述符添加到
- 使用
-
事件的管理
- 在
epoll
内部,监控的文件描述符以epoll_event
结构体的形式组织成一颗红黑树。红黑树提供了高效的插入、删除和查找操作。 - 当文件描述符被添加到
epoll
对象后,它们会被放入红黑树中,方便后续的事件监控。
- 在
-
等待事件发生
- 通过调用
epoll_wait
函数,进程进入阻塞状态,等待事件的发生。该函数会检查红黑树中是否有文件描述符就绪。 - 当某个文件描述符上有事件发生(如数据可读、可写等),内核会将该文件描述符对应的
epoll_event
结构体放入一个链表中,并返回这个链表。
- 通过调用
-
处理就绪事件
- 应用程序在
epoll_wait
返回后,可以遍历返回的链表,处理每个就绪的文件描述符所对应的事件。
- 应用程序在
总结
通过使用红黑树和链表,epoll
能够高效地管理大量文件描述符,并快速响应事件的发生。它的主要函数 epoll_create
、epoll_ctl
和 epoll_wait
提供了创建、控制和等待事件的功能,使得高并发的网络应用能够更灵活和高效地处理 I/O 操作。
12. epoll为什么高效?
epoll
被认为高效的原因主要体现在以下几个方面:
-
事件驱动机制
epoll
使用事件驱动的机制,可以在多个文件描述符上监视事件的发生。当某个文件描述符变为就绪状态(可读、可写等)时,内核会通知用户空间的应用程序。这种机制避免了频繁的轮询,减少了 CPU 的使用率。
-
就绪链表
epoll
内部使用就绪链表来维护已经就绪的文件描述符。在调用epoll_wait
时,只需检查就绪链表是否为空,而不需要遍历所有监视的文件描述符。这极大地提高了性能,尤其是在监视大量文件描述符的情况下。
-
只需一次数据拷贝
- 在
select
和poll
中,每次调用都需要将文件描述符集合从用户态拷贝到内核态,这会导致性能开销。而epoll
只需在初始化时拷贝一次,后续操作只需要维护就绪链表,降低了数据拷贝的开销。
- 在
-
支持大量文件描述符
epoll
可以处理比select
和poll
更多的文件描述符,不受限制(理论上可以达到UINT_MAX
),这使得它适用于高并发场景,如大规模的网络服务器。
-
边缘触发与水平触发
epoll
支持边缘触发(ET)和水平触发(LT)两种触发模式。边缘触发模式只在状态发生变化时通知应用程序,进一步减少了 CPU 的占用率。应用程序只需在状态改变时进行处理,而不必在每次轮询中都检查。
总结
epoll
的高效性来自于它的事件驱动机制、内部就绪链表、减少的数据拷贝、对大量文件描述符的支持以及灵活的触发模式。这些特点使得 epoll
特别适合高并发、高性能的网络应用。