八股文-0907

知识列表

  1. 进程可以创建多少个线程
  2. 外中断和异常有什么区别
  3. 进程的状态及其转换
  4. 进程的创建需要系统分配什么资源
  5. 进程控制块(PCB)的作用是什么
  6. 进程之间的通信方式
  7. 线程之间的通信方式有哪些?
  8. 线程之间的数据竞争和数据同步
  9. 异步通信和同步通信
  10. 进程同步方式
  11. 生产者消费者模型
  12. 信号量与自旋锁
  13. 多线程中的栈之间如何传递数据
  14. Linux文件标识符相关问题:父进程fork子进程后,子进程能否修改父进程的文件?
  15. 管道会溢出吗?如何处理?
  16. 操作系统最小单位是线程,那协程怎么实现?
  17. 如何设计和管理多线程中的多协程

1.进程可以创建多少个线程

影响因素
  1. 系统资源限制
    1. 内存:每个线程都需要分配一定的栈空间,通常在几百KB到几MB之间。如果系统内存有限,能够创建的线程数量也会收到限制
    2. 虚拟内存:即使由足够的物理物理内存,操作系统的虚拟内存地址空间也会对线程数量施加控制
  2. 硬件资源
    1. CPU核心数:多核处理器可以并行执行更多的线程,但线程数量不一定与核心数成正比,即使在单核系统上,也可以创建和管理多个线程,但需要时间分片
    2. 缓存和带宽:硬件资源的带宽和缓存的大小也会影响线程调度和性能,从而间接影响可创建的线程数量
  3. 操作系统配置
    1. 线程限制:操作系统通常对每个进程的最大线程数量有默认的限制。可以通过系统配置文件进行调整。。例如,在 Linux 系统中,可以通过修改 /etc/security/limits.conf 文件或使用ulimit 命令来改变线程限制
    2. 栈大小:可以通过编程语言或库的API调整每个线程的栈大小,从而间接影响可以创建的线程数量。例如,POSIX 线程(pthread)允许在创建线程时指定栈大小。

2.外中断和异常有什么区别

外中断

外中断是由外部硬件设备(如键鼠,鼠标,网络卡,定时器等)触发的中断信号,目的是通知CPU有外部事件需要处理

特点:

  1. 异步性:外中断发生的时间是异步的,不依赖于CPU当前执行的指令
  2. 硬件触发:通常由硬件设备产生,通过中断控制器传递给CPU
  3. 优先级和屏蔽:外中断可以有优先级,操作系统可以屏蔽某些中断以避免干扰关键操作

常见外中断:

1)键盘输入中断

2)定时器中断

3)网络包到达中断

4)硬盘 I/O 完成中断

异常

异常是由CPU在执行指令过程中检测到的异常情况(如非法指令,除零操作,内存访问违规等)引发的中断信号

特点:

  1. 同步性:异常发生的时间是同步的,直接与当前执行的指令相关
  2. 软件或硬件触发:由CPU在执行指令时检测到异常的情况触发
  3. 处理优先级:异常处理的优先级通常较高,因为它们可能指示程序错误或系统异常状态异常

常见异常:

1)除零错误

2)页面错误(Page Fault)

3)无效指令错误

4)系统调用(通过软件中断指令触发)

3.进程的状态及其转换

进程状态

1)新建(New):进程正在被创建中,但尚未准备好运行。

2)就绪(Ready):进程已准备好运行,等待 CPU 的分配。

3)运行(Running):进程正在 CPU 上执行。

4)等待(Waiting/Blocked):进程无法继续执行,等待某个事件(如 I/O 操作完成、信号

到达等)。

5)终止(Terminated):进程执行完成或因某种原因被终止。

4.进程的创建需要系统分配什么资源

  1. 内存空间
    1. 代码段:存储程序的可执行代码。每个进程都会分配一个代码段,用于存放程序指令
    2. 数据段:存储全局变量和静态变量。数据段在程序运行期间保持固定大小
    3. 堆区:用于动态内存分配。在程序运行期间,可以通过malloc或new动态分配和释放内存
    4. 栈区:用于存储局部变量和函数调用栈帧。每次函数调用都会在栈上分配新的栈帧
  2. 进程控制块(PCB)
    1. 进程状态:表示进程当前的状态,如运行,就绪,等待
    2. 寄存器上下文:保存进程的CPU寄存器值,包括程序计数器,栈指针等,用于在上下文切换时恢复进程的执行状态
    3. 调度信息:包含进程优先级,调度队列等信息,用于操作系统调度进程
  3. 文件描述符每个进程都有一个文件描述符表,存储进程打开的文件及其对应的文件描述符。
  4. 进程号每个进程都有一个唯一的进程号 (PID),用于标识进程
  5. CPU时间片操作系统为每个进程分配 CPU 时间片,用于进程调度和执行
  6. 系统资源和权限包括网络连接、设备访问权限等。操作系统在创建进程时会为其分配必要的系统资源和权限。

5.进程控制块(PCB)的作用是什么

  1. 唯一标识进程:通过进程标识符(PID)唯一标识每个进程
  2. 存储进程状态:记录进程的当前状态,如运行,就绪,等待
  3. 保存寄存器上下文:保存进程的CPU寄存器值,如程序计数器,栈指针等,用于上下文切换
  4. 管理内存分配:跟踪进程的内存使用情况,包括代码段,数据段,堆,栈等
  5. 文件描述符表:记录进程打开的文件及其对应的文件描述符
  6. 进程调度信息:包含进程的优先级,时间片等调度信息
  7. 进程间通信:管理进程通信的相关信息,如管道,消息队列,共享内存等

6.进程之间的通信方式

  1. 管道(Pipes):单向数据流,基于内核缓冲区。
  2. 信号(Signals):软件中断,用于通知事件
  3. 消息队列:独立于发送接收进程的消息存储。
  4. 共享内存:允许多个进程访问同一内存区域
  5. 信号量:同步工具,控制对共享资源的额访问
  6. 套接字(Sockets):网络通信的端点,支持不同主机间的数据交换
  7. 内存映射文件:使用文件映射实现内存共享,使用mmap将文件映射至进程的地址空间

7.线程之间的通信方式有哪些?

  1. 共享变量:线程访问共享内存中的变量
  2. 条件变量:允许线程在条件未满足时等待,直到条件满足
  3. 读写锁:支持多读单鞋的同步机制
  4. 信号量:计数器,控制多线程对共享资源的访问
  5. 凭证:同步一组线程的进度
  6. 消息传递:线程通过消息交换数据

8.线程之间的数据竞争和数据同步

数据竞争

数据竞争发生在两个或多个线程在没有适当的同步机制的情况下同时访问同一数据资源,并且至少有一个线程在进行写操作。当多个线程以不可预测的顺序访问数据时,可能会导致程序的输出依赖于程序执行的具体时序,从而产生错误或不一致的结果

数据同步

数据同步是指在多线程环境中,通过各种机制确保对共享数据的访问以一种可控和一致的方式进行。

数据同步的目的是防止数据竞争,确保数据的完整性和一致性。

同步机制:

1)互斥锁(Mutex):互斥锁保证同一时间只有一个线程可以执行某段代码,以此来访问或

修改共享数据。适用于保护关键区域,确保一次只有一个线程可以进入修改共享变量。

2)读写锁(Read-Write Locks):允许多个读取者同时访问共享数据,但写入者会独占访问

权。适用于读多写少的情况,可以提高读取效率。

3)条件变量(Condition Variables):用于在某些条件下阻塞一个或多个线程,直到收到另

一线程的通知。常用于生产者-消费者问题。

4)信号量(Semaphores):包含一个计数器,用于控制同时访问共享资源的线程数。适合控

制有限资源的访问。

5)原子操作(Atomic Operations):不可分割的操作单元,确保在读取、修改和更新值的

过程中不会被其他线程中断。用于实现无锁的数据结构

9.异步通信和同步通信

同步通信

在同步通信模式中,发送方发出通信请求后必须等待接收方的响应,才能继续执行后续操作,因此调用操作是阻塞的,导致调用者在等待期间不能执行其他任务

特点:

1)阻塞操作:发送方在发送请求后,必须等待直到接收方处理完消息并响应。

2)确定性:发送方总是知道何时接收方处理完毕,因为其必须接收到响应才能继续执行。

3)易于理解和实现:同步通信模式逻辑简单直接,易于实现和理解。

适用场景:

对实时性要求较高的环境,如用户交互或需要即时反馈的操作。

简单的客户端-服务器交互,如 HTTP 请求(尽管 HTTP/2 支持异步操作)

异步通信

在异步通信模式中,发送发发出通信请求后不需要立即得到响应,它可以继续执行其他操作。接收方处理完消息后,通过状态,通知或回调机制来通知发送方

特点:

1)非阻塞操作:发送方发送请求后可以继续进行其他任务,不需等待响应。

2)并发性:提高了系统的并发性,因为发送方和接收方可以同时进行操作。

3)复杂的控制流:管理异步操作可能导致代码更复杂,需要处理诸如回调地狱等问题

10.进程同步方式

(1)互斥锁(Mutex)

互斥锁是一种提供互斥访问共享资源的同步机制。当一个进程或线程需要访问共享资源时,它

必须首先获取互斥锁。如果锁已被另一个进程持有,请求锁的进程将被阻塞,直到锁被释放。

1)用途:用于保护对共享数据的访问,确保在任意时刻只有一个进程可以操作共享资源。

2)优点:简单且有效地保护资源不发生冲突。

3)缺点:可能造成死锁,如果不正确管理锁的获取和释放。

(2)信号量(Semaphores)

1)工作原理:信号量是一种更高级的同步机制,包含一个计数器,表示可用资源的数量。进程

通过增加(释放资源)或减少(请求资源)信号量的值来进行同步,如果信号量的值为零,则进程

阻塞,直到信号量值大于零。

2)用途:广泛用于控制有限数量的资源访问,如限制访问数据库的连接数。

3)优点:灵活,可以用于多种同步问题,包括互斥作为特殊情况(二进制信号量)。

4)缺点:如果不正确使用,可能导致死锁或饥饿。

(3)条件变量(Condition Variables)

1)工作原理:条件变量用于阻塞一个或多个进程,直到满足某个特定条件。通常与互斥锁一起

使用,以防止条件检查和条件发生之间的竞态条件。

2)用途:适用于生产者-消费者问题,其中消费者等待生产者生成数据。

3)优点:提供了一种等待特定条件的有效方式,而不是轮询。

4)缺点:使用复杂,需要小心编码以避免死锁和竞态条件。

(4)读写锁(Read-Write Locks)

1)工作原理:读写锁允许并发的读取操作,但写入操作是互斥的。这意味着多个读操作可以同

时进行,但写操作需要独占访问。

2)用途:适合读操作远多于写操作的情况,如缓存系统。

3)优点:提高了读操作的并发性,降低了写操作对读操作的阻塞。

4)缺点:管理复杂,写操作可能会由于频繁的读操作而延迟。

(5)屏障(Barriers)

1)工作原理:屏障是同步点,所有进程必须到达屏障后才能继续执行。这确保所有进程在继续

执行前达到一定的执行点。

2)用途:用于并行计算,确保所有并行处理的阶段同步完成。

3)优点:确保所有进程同步前进,无一个落后。4)缺点:整体性能受到最慢参与者的影响

11.生产者消费者模型

生产者消费者模型的基本思想是将生产和消费的过程分开,生产者负责生产数据,消费者负责处理数据。

生产者负责将生成的数据放入一个共享的缓冲区(队列中)

消费者负责从共享的缓冲区取出数据

优点

  1. 解耦合:生产者和消费者之间的解耦合使得系统更灵活,可以独立地扩展或修改生产和消费的逻辑。
  2. 提高效率:通过使用缓冲区,生产者和消费者可以并行工作,提高系统的整体效率。
  3. 流量控制:通过控制缓冲区的大小,可以实现对生产和消费速率的调节,避免系统过载。

代码

共享变量

const int BUFFER_SIZE = 10;
std::vector<int> buffer;
std::mutex mtx;
std::conditon_variable cond_var;
bool done = false;//用于通知消费者结束
bool ready = false;//用于通知消费者开始消费

生产者

void producer(int n){
   for(int i = 0 ; i < n ; i ++)
  {
       std::unqiue_lock<std::mutex> locker(mtx);
       cond_var.wait(locker,[](){
           return buffer.size() < BUFFER_SIZE;
      });
       buffer.push_back(i);
       ready = true;
       locker.unlock();
       cond_var.notify_one();
  }
   done = true;
   cond_var.notify_one();
}

如果 buffer.size() < BUFFER_SIZEtrue,那么 wait 方法会返回,线程将继续执行;如果为 false,线程将被挂起,直到条件变量被通知

使用 cond_var.wait 的好处在于,它避免了忙等待(busy waiting),即线程在等待条件成立时不占用 CPU 资源。通过条件变量,线程可以高效地挂起,直到有数据可处理或有空间可用,从而提高了系统的整体性能和响应性。

消费者

void consumer(){
   while(!done)
  {
       std::unique_lock<std::mutex> locker(mutex);
       cond_var.wait(locker,[](){return ready||done;});
       while(!buffer.empty())
      {
           int val = buffer.back();
           buffer.pop_back();
           // 处理val
}
       ready = false;
       locker.unlock();
       cond_var.notify_one();
  }
}

12.信号量与自旋锁

信号量用于控制有限数量的资源的访问,线程在无法获取资源时进入睡眠状态,避免CPU资源浪费;适用于资源数量有限的情况,以及需要复杂的同步机制,性能开销:上下文切换(线程或进程阻塞和唤醒),适用问题:生产者消费者问题,读者写者问题

自旋锁用于实现快速的互斥访问,线程在等待锁释放时持续占用CPU(忙等),消耗CPU资源,适用于预期锁只会被短时间持有的情况,CPU时间(在获取锁之前线程一直占用CPU),适用于线程只需要短时间访问临界区的情况

信号量

信号量是一个同步对象,用于控制对共享资源的访问。信号量的值表示可用资源的数量,如果信号量的值大于0,表示可以访问资源;如果等于资源已经被完全使用,后续的线程或进程需要等待

二元信号量(Binary Semaphore):又称为互斥锁,其值只能是 0 或 1,用于实现互斥访问。

计数信号量(Counting Semaphore):可以取大于 1 的值,用于控制多个资源的访问。

自旋锁

自旋锁是一种忙等待锁,当线程尝试获取锁而锁已被其他线程持有时,线程将在一个循环中不断检查锁的状态,这被称为自旋

通常用在等待时间非常短的场景中,因为自旋锁不会使线程进入睡眠状态,避免了上下文切换

的开销。

13.多线程中的栈之间如何传递数据

  1. 使用全局变量或共享内存、共享内存是最直接的方式,但需要注意同步问题,以避免数据竞争和不一致
  2. 使用条件变量条件变量用于线程间的同步和通信,允许一个线程等待某个条件满足后再继续执行
  3. 使用线程安全队列使用线程安全的数据结构(如队列)可以简化多线程之间的数据传递和管理。
  4. 使用消息传递机制使用消息传递机制(如消息队列、管道等)在线程间传递数据,适用于复杂的多线程通信。

14.Linux文件标识符相关问题:父进程fork子进程后,子进程能否修改父进程的文件?

  1. 文件描述符与fork调用当一个父进程执行 fork()系统调用创建子进程时,子进程会继承父进程的文件描述符。这意味着父进程和子进程将共享相同的文件描述符集合。但是,虽然文件描述符的数字是相同的,父进程和子进程中的这些文件描述符都指向同一个打开文件表的条目。
  2. 共享文件描述符的影响由于子进程从父进程继承了文件描述符,这意味着它们实际上共享了对打开文件的访问。具体来说,子进程可以通过继承的文件描述符对文件进行读写操作。这些操作会影响到文件的当前偏移量,因为文件偏移量是在打开文件表条目中维护的,而不是在文件描述符或进程中。
  3. 子进程对文件的修改子进程可以使用从父进程继承的文件描述符来修改文件。这些修改会影响到父进程打开的相同文件,因为它们共享同一文件偏移量。例如,如果子进程写入了文件,父进程读取同一个文件时也会看到这些变化。
  4. 独立文件偏移量的处理如果需要父进程和子进程独立地操作文件(即不共享文件偏移量),可以在 fork()后立即对共享的文件描述符执行 dup2()或重新打开文件。这将为每个进程创建单独的打开文件表条目,使得它们可以独立地管理文件偏移量

15.管道会溢出吗?如何处理?

(1)管道的工作原理

管道是基于内核缓冲区的,这意味着它们在内核中有一个有限的缓冲区来存储在读取之前发送

的数据。对于匿名管道,通常用在具有亲缘关系的进程之间(如父子进程),而命名管道则可以在

任何两个进程之间建立通信,即使它们没有直接的亲缘关系。

(2)管道溢出的可能性

管道是否会溢出取决于管道缓冲区的处理方式和管道的使用情况。管道的缓冲区大小在不同的

系统和配置中可能有所不同,但一般来说,大小是有限的。

写入管道:当一个进程向管道写入数据时,数据被存储在内核缓冲区中。如果缓冲区已满,尝

试写入的进程将会阻塞,直到有足够的空间可用来存放新数据。这意味着正常情况下,写进程不会

继续执行,直到缓冲区中有足够的空间来存放它试图写入的数据。

读取管道:当另一个进程从管道读取数据时,它从缓冲区中取出数据。如果缓冲区为空,读进

程将会阻塞,直到有新的数据写入管道

16.操作系统最小单位是线程,那协程怎么实现?

虽然线程是操作系统调度的最小单位,但协程(coroutine)提供了一种更轻量级的并发模型。

协程的实现主要在用户态完成,不需要操作系统内核的直接支持。以下是协程的实现原理和一些代

码示例,展示如何在 C++中实现和使用协程。

(1)协程的实现原理

1)用户态调度:协程的调度完全在用户态进行,不依赖操作系统内核。用户代码显式地控制协

程的切换。

2)保存和恢复上下文:协程在切换时需要保存当前的执行上下文(如寄存器、栈指针)并在恢

复时恢复这些上下文。

3)栈管理:每个协程有自己的栈,存储协程的局部变量和函数调用信息。协程的栈通常比线程

的栈更小。

(2)协程的实现方式

1)使用语言支持的协程:许多现代编程语言提供了对协程的内置支持,例如 Python 的

asyncio,C++20 的协程,Go 语言的 goroutine 等。

2)手动实现协程:可以通过手动保存和恢复上下文来实现协程,通常使用 setjmp/longjmp

(C 语言)或汇编语言进行上下文切换

17.如何设计和管理多线程中的多协程

设计多线程中的多协程

1)线程池管理:使用线程池来管理和复用线程,避免频繁创建和销毁线程带来的开销。

2)协程调度器:每个线程运行一个协程调度器,负责在该线程上调度和执行协程。协程调度器

可以基于事件驱动或时间片轮转的方式调度协程。

3)任务队列:使用任务队列来存储待执行的协程任务。线程从任务队列中获取任务并执行相应

的协程。

4)协程同步和通信:使用协程安全的同步机制(如信号量、通道)来管理协程之间的同步和通

信。避免使用线程级的锁机制,以防止阻塞线程,影响协程的调度。

// 任务包装器,用于存储协程任务
struct Task {
   struct promise_type; // 前向声明 promise_type
   using handle_type = std::coroutine_handle<promise_type>; // 定义协程句柄类型

   handle_type coro; // 协程句柄

   // 构造函数
   Task(handle_type h) : coro(h) {}

   // 析构函数
   ~Task() {
       if (coro) coro.destroy(); // 销毁协程
  }

   // promise_type 结构
   struct promise_type {
       auto get_return_object() {
           return Task{ handle_type::from_promise(*this) }; // 获取返回对象
      }

       auto initial_suspend() {
           return std::suspend_always{}; // 初始挂起
      }

       auto final_suspend() noexcept {
           return std::suspend_always{}; // 最终挂起
      }

       void return_void() {} // 返回空
       void unhandled_exception() {
           std::terminate(); // 未处理异常
      }
  };
};

// 线程池类,管理一组线程并调度任务
class ThreadPool {
public:
   ThreadPool(size_t numThreads); // 构造函数,创建指定数量的线程
   ~ThreadPool(); // 析构函数,销毁所有线程
   void enqueue(Task::handle_type task); // 向任务队列中添加一个任务

private:
   std::vector<std::thread> workers; // 工作线程集合
   std::queue<Task::handle_type> tasks; // 任务队列
   std::mutex queueMutex; // 保护任务队列的互斥锁
   std::condition_variable condition; // 用于任务调度的条件变量
   bool stop; // 标志线程池是否停止

   void worker(); // 工作线程的执行函数
};

// 线程池构造函数
ThreadPool::ThreadPool(size_t numThreads) : stop(false) {
   for (size_t i = 0; i < numThreads; ++i) {
       workers.emplace_back(&ThreadPool::worker, this); // 创建工作线程
  }
}

// 线程池析构函数
ThreadPool::~ThreadPool() {
  {
       std::unique_lock<std::mutex> lock(queueMutex);
       stop = true; // 设置停止标志
  }
   condition.notify_all(); // 通知所有线程
   for (std::thread& worker : workers) {
       worker.join(); // 等待所有线程完成
  }
}

// 向任务队列中添加任务
void ThreadPool::enqueue(Task::handle_type task) {
  {
       std::unique_lock<std::mutex> lock(queueMutex);
       tasks.push(task); // 将任务加入队列
  }
   condition.notify_one(); // 通知一个工作线程
}

// 工作线程的执行函数
void ThreadPool::worker() {
   while (true) {
       Task::handle_type task;
      {
           std::unique_lock<std::mutex> lock(queueMutex);
           condition.wait(lock, [this] { return stop || !tasks.empty(); }); // 等待任务或停止信号
           if (stop && tasks.empty()) return; // 如果停止且任务为空,退出
           task = tasks.front(); // 获取任务
           tasks.pop(); // 移除任务
      }
       task.resume(); // 恢复协程
  }
}

// 协程任务函数
Task coroutineTask(ThreadPool& pool) {
   std::cout << "Coroutine started in thread " << std::this_thread::get_id() << std::endl;
   co_await std::suspend_always{}; // 暂停协程
   std::cout << "Coroutine resumed in thread " << std::this_thread::get_id() << std::endl;
}

// 主函数
int main() {
   ThreadPool pool(4); // 创建一个包含 4 个线程的线程池

   // 向线程池中添加 8 个协程任务
   for (int i = 0; i < 8; ++i) {
       pool.enqueue(coroutineTask(pool).coro);
  }

   // 主线程休眠一段时间,以确保所有协程任务完成
   std::this_thread::sleep_for(std::chrono::seconds(1));
   return 0;
}

任务包装器 (Task):

Task 结构包含了协程句柄,用于管理协程的生命周期。

promise_type 内部类定义了协程的挂起和恢复行为。

线程池 (ThreadPool):

ThreadPool 类管理一组工作线程和任务队列。

enqueue 方法将任务添加到队列中,并通知一个工作线程。

worker 方法是工作线程的主循环,等待任务并执行它们。

协程任务 (coroutineTask):

coroutineTask 是一个协程函数,展示了协程的启动和暂停。

co_await std::suspend_always{} 用于暂停协程的执行。

主函数 (main):在 main 函数中,创建一个包含 4 个线程的线程池。

向线程池中添加 8 个协程任务,并让主线程休眠一段时间以确保所有任务完成