【C++八股-第15期】操作系统-进程与线程
你的花花是我更新的动力~
提纲:
👉 八股:
你了解共享内存么?常见的同步机制有哪些?
介绍下进程五种状态及状态间转换关系
Linux中如何创建子进程
写时复制有什么用?原理是什么?
守护进程是什么?如何创建守护进程
介绍一下孤儿进程和僵尸进程?如何解决僵尸进程?
介绍常见的进程间通信(IPC)
1. 你了解共享内存么?常见的同步机制有哪些?
定义及特点
共享内存 (Shared Memory)是一种高效的进程间通信(IPC)机制,它允许多个进程访问同一块内存区域,以便共享数据。
共享内存是 最快
的IPC方式之一,因为数据不需要在进程间复制,而是直接在共享内存区域进行读写操作。
一个进程向共享的内存区域写入数据,其他共享这个内存区域的进程可以立即看到这些内容。
同步问题 :使用共享内存时,需要注意多个进程之间对同一存储区访问的互斥问题。
如果一个进程正在向共享内存区写入数据,则在它完成此操作之前,其他进程不应读取或写入这些数据,以避免数据竞争和不一致性。
常用的同步机制
-
互斥锁(Mutex) : 用于保护对共享内存的访问,确保同一时间只有一个进程可以对其进行读写操作。
-
信号量(Semaphore) : 是一个计数器,用于控制对共享资源的访问数量,适用于需要计数的场景。
P操作
(递减操作)可以用于阻塞一个进程,V操作
(增加操作)可以用于解除阻塞一个进程。 -
管程 :一个进程通过调用管程的一个过程进入管程。在任何时候,只能有一个进程在管程中执行,调用管程的任何其他进程都被阻塞,以等待管程可用。
-
消息队列 :消息的链接表,放在内核中。消息队列独立于发送与接收进程,进程终止时,消息队列及其内容并不会被删除;消息队列可以实现消息的随机查询,可以按照消息的类型读取。
-
条件变量(Condition Variable) : 用于线程间的协调,适用于需要等待某个条件满足的场景。
2. 介绍进程五种状态及状态间转换关系
进程状态:
新建
、就绪
、运行
、阻塞
、终止
-
新建(New):
- 需要获取系统资源创建进程管理块(PCB:Process Control Block)完成资源分配
-
就绪(Ready)
- 进程已创建并且准备好执行,但尚未被分配到CPU,未获得处理器资源
-
运行(Running)
- 获取处理器资源,被系统调度。当调度器选择该进程执行时,状态转换为运行(Running)。
- 若进程时间片用完或被抢占,则状态转换为就绪(Ready)
-
阻塞(Blocked)
- 进程在等待某个事件(如I/O操作、信号等)完成,暂时无法执行
-
终止(Terminated)
- 进程已完成执行或被终止。
图片来源于网路
3. Linux中如何创建子进程
在 Linux 中,创建子进程通常通过 fork
系统调用实现
-
父进程
:fork()
函数返回新创建子进程的PID -
子进程
:fork()
函数,调用成功返回0,失败会返回 -1
代码示例
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
int main() {
pid_t pid;
// 调用 fork() 创建子进程
pid = fork();
if (pid < 0) {
// 创建子进程失败
fprintf(stderr, "Fork Failed\n");
return 1;
} else if (pid == 0) {
// 子进程执行的代码
printf("This is the child process. PID: %d, Parent PID: %d\n", getpid(), getppid());
// 在这里可以执行子进程特有的任务
} else {
// 父进程执行的代码
printf("This is the parent process. PID: %d, Child PID: %d\n", getpid(), pid);
// 父进程可以选择等待子进程结束
wait(NULL);
printf("Child process complete.\n");
}
return 0;
}
4. 写时复制有什么用?原理是什么?
写时复制 是一种内存管理优化策略,用于高效地管理进程创建和内存使用,可以显著减少内存消耗和提高效率。
写时复制的基本原理
① 进程创建:
- 当一个进程通过 fork() 函数创建子进程时,操作系统并不立即复制父进程的所有内存内容给子进程。相反,它们开始共享相同的内存空间和资源。
② 共享阶段:
- 父进程和子进程在开始阶段共享同一个内存映像,包括代码段、数据段和堆栈段。这意味着他们看到的内存内容是一样的,不需要额外的内存复制开销。
③ 写操作处理:
-
当父进程或者子进程真的准备修改共享内存内的数据时(例如下面例子中的
写入新值
),操作系统会进行介入 -
首先,当操作系统检测到你要进行写操作时,操作系统会在写操作实际执行之前,将要修改的内存页面复制一份形成新的独立副本。
-
之后再进行之前要进行的写操作,此时父子进程修改的就是各自的独立副本了,互不影响
④ 减耗提效原因:
- 因为大部分情况下进程只需要读取数据而不需要修改数据,因此写时复制延迟了内存的实际复制时间,只有在进程真正需要修改数据时才进行,从而达到了节省内存和提高性能的效果。
代码示例:
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int main() {
char *shared_memory = (char *) malloc(1024); // 分配共享内存
strcpy(shared_memory, "Initial data");
pid_t pid = fork(); // 创建子进程
if (pid == -1) {
fprintf(stderr, "Fork failed\n");
return 1;
} else if (pid == 0) {
// 子进程
printf("Child process: shared_memory = %s\n", shared_memory);
strcpy(shared_memory, "Child's new data");
printf("Child process: shared_memory after write = %s\n", shared_memory);
} else {
// 父进程
wait(NULL); // 等待子进程结束
printf("Parent process: shared_memory = %s\n", shared_memory);
}
free(shared_memory); // 释放共享内存
return 0;
}
// 输出:
Child process: shared_memory = Initial data
Child process: shared_memory after write = Child's new data
Parent process: shared_memory = Initial data // 子进程修改并不会影响父进程
5. 守护进程是什么?如何创建守护进程
守护进程(Daemon)
是在计算机系统后台运行的一种特殊类型的长期进程
,通常独立于控制终端,并且没有用户交互界面。它们通常用于执行系统级别的任务、服务和管理,如网络服务、日志处理等。
创建守护进程的步骤
① 造子杀父 - 创建子进程,终止父进程:
- 父进程通过
fork()
创建一个子进程,然后立即退出,这样子进程就成为孤儿进程
,这一步确保了守护进程不会成为终端的会话首进程。
pid_t pid = fork();
if (pid < 0) {
// 创建子进程失败
exit(EXIT_FAILURE);
} else if (pid > 0) {
// 父进程退出
exit(EXIT_SUCCESS);
}
② 调用 setsid()
创建新会话:
- 子进程调用
setsid()
函数创建一个新的会话,并成为该会话的首进程和组长进程。这一步确保守护进程不再有控制终端,从而独立于任何终端会话。
if (setsid() < 0) {
// 创建新会话失败
exit(EXIT_FAILURE);
}
③ 将当前工作目录更改为根目录:
- 为了避免守护进程占用挂载的文件系统,通常将当前工作目录更改为根目录。
if (chdir("/") < 0) {
// 切换工作目录失败
exit(EXIT_FAILURE);
}
④ 重设文件权限掩码:
- 守护进程需要确保不会创建不必要的文件或者过于开放的文件权限,因此需要重设文件创建掩码。
umask(0);
⑤ 关闭文件描述符:
- 守护进程不再需要继承自父进程的文件描述符,因此需要关闭所有已打开的文件描述符。
close(STDIN_FILENO);
close(STDOUT_FILENO);
close(STDERR_FILENO);
6. 介绍一下孤儿进程和僵尸进程?如何解决僵尸进程?
孤儿进程
父进程终止了,但是自己没有终止,还活着就成孤儿了
-
孤儿进程的处理:
-
当父进程终止时,孤儿进程会被重新分配给
init
(PID 1) 进程。init
进程会负责对这些孤儿进程进行善后处理。 -
init
进程会周期性地调用wait()
或waitpid()
函数来收集这些孤儿进程的退出状态,避免它们成为僵尸进程。
-
僵尸进程
自己终止了,但是父进程不知道你终止了,你就成僵尸了
僵尸进程 是指一个已经终止但其退出状态信息尚未被父进程读取的进程。尽管它已经停止运行,但在系统的进程表中仍然保留一个条目,以保存它的退出状态信息,直到父进程读取它,这会占用系统资源。
僵尸进程的产生:
-
子进程终止后,它会向父进程发送一个
SIGCHLD
信号。 -
父进程需要调用
wait()
或waitpid()
函数来读取子进程的退出状态。 -
如果父进程没有读取到子进程的退出状态,子进程的条目会一直保留在进程表中,形成僵尸进程。
解决僵尸进程的方法:
-
父进程调用
wait()
或waitpid()
:- 父进程在适当的时机调用
wait()
或waitpid()
函数来收集所有已终止子进程的退出状态。
- 父进程在适当的时机调用
-
使用
SIGCHLD
信号处理程序:- 父进程可以注册一个
SIGCHLD
信号处理程序,当子进程终止时,这个信号处理程序会被调用,父进程在处理程序中调用wait()
或waitpid()
来收集子进程的退出状态。
- 父进程可以注册一个
-
使用
wait()
的自动化处理: - 在父进程的主循环中定期调用wait()
或waitpid()
,以确保及时处理已终止的子进程。
7. 介绍常见的进程间通信(IPC)
常见的:
管道(Pipe)
、消息队列(Message Queue)
、信号量(Semaphore)
、信号(Signal)
、共享内存(Shared Memory)
、套接字(Socket)
① 管道(Pipes)
管道是一种最基本的进程间通信方式,提供了一个单向的数据流,父子进程之间常用。
-
匿名管道
- 适用于具有亲缘关系的进程(如父子进程)。使用
pipe()
函数创建。
- 适用于具有亲缘关系的进程(如父子进程)。使用
-
命名管道(FIFO)
- 适用于不具有亲缘关系的进程。使用
mkfifo()
函数创建。
- 适用于不具有亲缘关系的进程。使用
② 消息队列(Message Queues)
消息队列允许进程以消息为单位进行通信,支持消息的优先级。
消息的链接表,放在内核中。消息队列独立于发送与接收进程,进程终止时,消息队列及其内容并不会被删除;消息队列可以实现消息的随机查询,可以按照消息的类型读取。
使用 msgget()
、msgsnd()
、msgrcv()
等函数操作。
③ 信号量(Semaphore)
**信号量(Semaphore)**用于控制多个进程对共享资源的访问。
信号量的基本思想是通过信号量变量对进程进行控制,确保同一时间只有一定数量的进程能够访问共享资源。
信号量有两个基本操作:
P操作
(等待,wait):将信号量的值减1。如果信号量的值已经是0,进程将阻塞,直到信号量的值大于0。V操作
(信号,signal):将信号量的值加1。如果有进程因为信号量的值为0而阻塞,将唤醒一个进程。
使用信号量的步骤
- 创建信号量:使用
semget
函数创建或获取一个信号量。 - 初始化信号量:使用
semctl
函数初始化信号量的值。 - P操作:使用
semop
函数进行P操作。 - V操作:使用
semop
函数进行V操作。 - 删除信号量:使用
semctl
函数删除信号量。
④ 信号(Signal)
信号 是一种用于通知进程某个事件发生的机制,可以异步地通知进程进行相应的处理。
#include <signal.h>
#include <unistd.h>
#include <iostream>
void signal_handler(int signum) {
std::cout << "Received signal: " << signum << std::endl;
}
int main() {
signal(SIGUSR1, signal_handler); // 注册信号处理程序
pid_t pid = fork();
if (pid == 0) {
// 子进程
sleep(2); // 等待父进程设置信号处理程序
kill(getppid(), SIGUSR1); // 向父进程发送信号
} else {
// 父进程
pause(); // 等待信号
}
return 0;
}
⑤ 共享内存(Shared Memory)
共享内存允许多个进程直接访问同一块内存区域,是最快的进程间通信方式之一,但需要同步机制来避免竞争。
⑥ 套接字(Socket)
套接字提供了在网络和本地进程间通信的机制。支持TCP和UDP协议。套接字不仅用于网络通信,也可以用于同一主机上不同进程之间的通信。