【C++八股-第19期】操作系统 - 多线程锁与IO模型

❤此贴满十朵花花,速更下一贴

alt

提纲:

👉 八股:

  1. 说一下同步异步的区别

  2. 说一下 阻塞非阻塞 的区别

  3. 介绍一下 死锁活锁

  4. 概述一下 自旋锁互斥锁 的使用场景

  5. 简单介绍一下公平锁非公平锁

  6. 在 C++ 中 sleepwait 是如何控制线程行为的?

  7. 介绍一下什么是IO操作

  8. 介绍一下五种IO模型

  9. 阻塞I/O 和 非阻塞I/O 有什么区别

  10. 多路I/O复用机制有哪些,总结一下区别

  11. 说说epoll的原理

  12. epoll为什么高效?

👉 代码:

1. 说一下同步异步的区别

同步

  • 同步指的是调用者在发起请求后,必须等待所有操作完成并获取到结果后才能继续执行下一步操作。
  • 两方的操作是按顺序协调进行的,即操作是串行的,发起者必须等待,直到所有的操作完成。
  • 例如,发起一个数据库写操作时,用户需要等到数据库写入成功后,系统才给用户返回结果。用户体验不佳,因其需要等待较长时间。

异步

  • 异步指的是调用者在发起请求后,不必等待所有操作完成就能继续进行其他操作。操作完成后系统会通过回调、通知等方式告知调用者结果。
  • 调用者发起请求后可以继续执行其他任务,不必等待操作完成。
  • 例如,发起一个数据库写操作后,系统先返回响应给用户,后台再慢慢完成数据库操作,最终通过通知用户。用户体验较好,因为响应较快。

   

   

2. 说一下 阻塞非阻塞 的区别

阻塞

  • 定义: 阻塞是指当调用者发起请求后,调用者会一直等待直到请求完成,调用者无法进行其他任务。
  • 特点: 调用者在等待操作结果的过程中被挂起,无法执行其他任务。
  • 应用场景: 例如,在发起一个IO操作时,进程必须等待操作完成才能继续执行后续逻辑。

非阻塞

  • 定义: 非阻塞是指调用者发起请求后,不需要等待操作完成,可以继续执行其他任务。调用者通过轮询或事件机制定期检查操作是否完成。
  • 特点: 调用者不会被挂起,可以在等待结果的同时执行其他任务。
  • 应用场景: 例如,发起一个IO操作后,调用者可以继续处理其他任务,不会因为IO操作未完成而阻塞。

   

   

3. 介绍一下 死锁活锁

死锁(Deadlock): 相互侵占导致卡死

  • 定义: 死锁是指两个或多个线程因争夺资源而陷入互相等待的状态。如果没有外部干预,这些线程将永远无法继续执行。

  • 例子: 线程A持有资源1,等待资源2;线程B持有资源2,等待资源1。这种情况下两个线程互相等待,导致死锁。

活锁(Livelock): 动态让步导致卡死

  • 定义: 活锁是指多个线程在不断改变状态以响应彼此的动作,结果是没有线程能继续执行。线程虽然没有阻塞,但总是在忙于“礼貌”地相互让步,导致操作无法前进。

  • 特征:

    1. 动态变化: 线程可以继续执行,但由于相互间的让步,实际上并未推进。

    2. 无进展: 最终结果是所有线程处于一种忙碌但无效的状态。

  • 例子: 线程A想要获取资源,但发现线程B已经拥有该资源,于是它释放自己持有的资源,让线程B先执行。然而,线程B也采取同样的策略,结果导致两个线程始终处于相互等待的状态。

   

   

4. 概述一下 自旋锁互斥锁 的使用场景

自旋锁和互斥锁都是用于保护临界区的并发访问

互斥锁(Mutex Lock)使用场景: 锁持有时间长 存在阻塞或复杂操作

互斥锁适合在持锁时间较长的情况下使用,因为它会使线程在无法获取锁时进入睡眠,从而节省CPU资源。以下情况可以考虑使用互斥锁:

  1. 临界区有IO操作: 因为IO操作往往需要较长的时间等待,所以线程在等待时进入休眠状态可以避免浪费CPU时间。
  2. 临界区代码复杂或循环量大: 当临界区的操作较为复杂或有大量循环时,持锁时间较长,使用互斥锁更为合适。
  3. 临界区竞争非常激烈: 如果多个线程频繁争夺同一锁,互斥锁可以避免自旋锁带来的忙等待问题。
  4. 单核处理器: 在单核环境中,自旋锁会浪费CPU时间,因为无法调度其他线程来执行任务,互斥锁则更有效率。

自旋锁(Spin Lock)使用场景: 锁持有时间短 争用较少 CPU资源充裕

自旋锁适合在持锁时间非常短、线程获取锁的等待时间很短的情况下使用,因为它不会让线程进入睡眠,而是持续尝试获取锁。以下情况可以考虑使用自旋锁:

  1. 临界区持锁时间非常短: 如果临界区的操作简单、快速,可以使用自旋锁,以避免线程切换带来的开销。
  2. CPU资源不紧张: 自旋锁在获取锁时会消耗CPU资源,因此只有在CPU资源较为充裕时才适合使用自旋锁。

   

   

5. 简单介绍一下公平锁非公平锁

公平锁

  • 定义: 公平锁确保所有线程按照请求锁的顺序来获取锁。一个线程在尝试获取锁之前,会检查等待队列,如果它是队列中的第一个或队列为空,它才能获得锁;否则,它会被添加到等待队列中并等待。

  • 特点:

    • **FIFO顺序:**保证了线程获取锁的公平性,避免了某些线程长期得不到锁(即“饥饿”现象)。
    • 额外性能开销: 由于需要维护等待队列,公平锁可能会引入额外的上下文切换和管理开销。
  • 使用场景: 适合对公平性要求比较高的应用,如任务调度等场景。

非公平锁

  • 定义: 非公平锁不保证线程获取锁的顺序。线程在请求锁时,首先尝试直接获取,如果获取失败,则可能会进入等待队列,类似于公平锁的后处理方式。

  • 特点:

    • 吞吐量高: 由于非公平锁可以让线程更快地重试获取锁,所以在高竞争场景下通常能提供更好的性能和吞吐量。
    • 饥饿现象: 由于不遵循公平原则,某些线程可能会长时间无法获取锁,导致饥饿问题。
  • **使用场景:**适合对性能要求较高且可以容忍一定程度不公平的场景,如高并发的缓存系统等。

   

   

6. 在 C++ 中 sleepwait 是如何控制线程行为的?

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 使得线程在 readytrue 之前处于等待状态,同时释放了互斥锁,等待 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 操作可以由一个线程管理,减少线程开销。

总结

  • 阻塞 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 时间,避免频繁轮询。
    • 只需一次数据拷贝和挂起,开销更小,性能更优。
    • 使用事件通知机制,可以在就绪事件发生时直接通知应用程序。
  • 使用场景: 适合高并发、高性能的网络应用,如服务器端应用。

区别总结

特性selectpollepoll
最大描述符限制 受限(通常为 1024) 无限制 无限制
初始化次数 每次调用需重新设置 只需初始化一次 只需初始化一次
拷贝开销 每次调用需将 fd 集合拷贝到内核 每次调用需拷贝 fd 集合 仅需一次拷贝
CPU 使用效率 高并发时性能较低 相对较好,但仍需轮询 高效,使用就绪链表,节省 CPU 时间
事件通知机制

总结

  • selectpoll 适合简单场景,但在高并发时性能较低。
  • epoll 具备高效的性能和灵活性,特别适合处理大量并发连接的高性能网络应用。

   

   

11. 说说epoll的原理

epoll 是 Linux 内核提供的一种高效的 I/O 多路复用机制,主要通过红黑树和链表来管理需要监控的文件描述符(FD)。以下是 epoll 的工作原理及其主要函数:

  1. 创建 epoll 对象

    • 使用 epoll_create 函数创建一个 epoll 对象。这个对象用于管理一组文件描述符。
  2. 控制 epoll 对象

    • 使用 epoll_ctl 函数对 epoll 对象进行操作:
      • 添加文件描述符:将需要监控的文件描述符添加到 epoll 对象中。
      • 删除文件描述符:从 epoll 对象中删除已监控的文件描述符。
      • 修改文件描述符的监控事件:可以更改某个文件描述符监控的事件类型(如可读、可写等)。
  3. 事件的管理

    • epoll 内部,监控的文件描述符以 epoll_event 结构体的形式组织成一颗红黑树。红黑树提供了高效的插入、删除和查找操作。
    • 当文件描述符被添加到 epoll 对象后,它们会被放入红黑树中,方便后续的事件监控。
  4. 等待事件发生

    • 通过调用 epoll_wait 函数,进程进入阻塞状态,等待事件的发生。该函数会检查红黑树中是否有文件描述符就绪。
    • 当某个文件描述符上有事件发生(如数据可读、可写等),内核会将该文件描述符对应的 epoll_event 结构体放入一个链表中,并返回这个链表。
  5. 处理就绪事件

    • 应用程序在 epoll_wait 返回后,可以遍历返回的链表,处理每个就绪的文件描述符所对应的事件。

总结

通过使用红黑树和链表,epoll 能够高效地管理大量文件描述符,并快速响应事件的发生。它的主要函数 epoll_createepoll_ctlepoll_wait 提供了创建、控制和等待事件的功能,使得高并发的网络应用能够更灵活和高效地处理 I/O 操作。

   

   

12. epoll为什么高效?

epoll 被认为高效的原因主要体现在以下几个方面:

  1. 事件驱动机制

    • epoll 使用事件驱动的机制,可以在多个文件描述符上监视事件的发生。当某个文件描述符变为就绪状态(可读、可写等)时,内核会通知用户空间的应用程序。这种机制避免了频繁的轮询,减少了 CPU 的使用率。
  2. 就绪链表

    • epoll 内部使用就绪链表来维护已经就绪的文件描述符。在调用 epoll_wait 时,只需检查就绪链表是否为空,而不需要遍历所有监视的文件描述符。这极大地提高了性能,尤其是在监视大量文件描述符的情况下。
  3. 只需一次数据拷贝

    • selectpoll 中,每次调用都需要将文件描述符集合从用户态拷贝到内核态,这会导致性能开销。而 epoll 只需在初始化时拷贝一次,后续操作只需要维护就绪链表,降低了数据拷贝的开销。
  4. 支持大量文件描述符

    • epoll 可以处理比 selectpoll 更多的文件描述符,不受限制(理论上可以达到 UINT_MAX),这使得它适用于高并发场景,如大规模的网络服务器。
  5. 边缘触发与水平触发

    • epoll 支持边缘触发(ET)和水平触发(LT)两种触发模式。边缘触发模式只在状态发生变化时通知应用程序,进一步减少了 CPU 的占用率。应用程序只需在状态改变时进行处理,而不必在每次轮询中都检查。

总结

epoll 的高效性来自于它的事件驱动机制、内部就绪链表、减少的数据拷贝、对大量文件描述符的支持以及灵活的触发模式。这些特点使得 epoll 特别适合高并发、高性能的网络应用。

   

   

全部评论

相关推荐

点赞 收藏 评论
分享
牛客网
牛客企业服务