基础八股文:操作系统
操作系统
操作系统是什么?
- 是一个系统软件,介于应用程序和底层硬件资源之间;
- 能够控制和管理整个计算机系统的硬件和软件资源,调度计算机的工作和资源分配;
- 是计算机系统中最基本的系统软件;
什么是并发?什么是并行?二者的区别是什么?
并发:并发指的是两个或多个事件在同一时间间隔内发送;(不是同时发生,而是交替执行)
并行:并行指的是两个或多个事件在同一时刻发送;
并发是同一时间间隔,并行是同一时刻;
在实际的操作系统中:
- 单个处理核在很短时间内分别执行多个进程,称为并发;
- 多个处理核在同一时刻同时执行多个进程,称为并行;
- 宏观上,并发很像并行,但是并发只是并行的模拟,受限于一个CPU,一个CPU同一时刻只能执行一个进程,所以并发在任何时刻都只有一个进程在运行,只是切换速度够快,所以看上去向并发(切换速度不可能无限快,受限于上下文切换的资源消耗)
操作系统有什么特征?
- 并发:并发指的是两个或多个事件在同一时间间隔内发送;
- 共享:系统中的资源可以供内存中多个并发执行的进程使用;
- 虚拟:把一个物理上的实体变成多个逻辑上的对应物;
- 异步:进程的执行并不是一贯到底,而是以不可预知的速度(调度不可预知,可能先启动的进程反而很后面才完成)向前推进;
操作系统的功能是什么?
操作系统介于应用程序和硬件资源之间,为应用程序提供服务,同时管理应用程序;
- 资源分配,资源回收:为应用程序分配内存,以及考虑内存回收和内存回收之后的合并;
- 为应用程序提供服务:提供系统调用给应用程序使用,使得应用程序可以不考虑底层硬件逻辑可以直接安全地访问硬件资源;
- 管理应用程序:控制进程的生命周期,管理进程、线程,决定哪个进程、线程占用CPU资源;
什么是进程?什么是线程?二者有什么区别?
进程:就是运行中的程序,是资源分配的基本单位;(程序是一个静态的二进制代码,在编译之后变成二进制可执行文件,运行这个可执行文件,可执行文件就会被装载到内存中,CPU执行程序中每一条指令,这个运行中的程序,就是进程)
线程:线程是进程的子执行单元;是CPU调度的基本单位;
区别:
- 进程有自己独立的内存空间;线程只有自己的栈,但共享进程的地址空间和资源;
- 进程通信要复杂的机制,线程通信可以直接用共享变量;
- 线程切换速度快,无需切换内存空间(虚拟地址映射不变);进程切换需要切换内存空间;
- 一个进程崩溃不会影响其他进程,一个线程崩溃可能影响其他线程;
什么是进程控制块PCB?
每一个进程在运行时都有自己的内存空间来保存运行时产生的局部变量,如果进程切换时,必须保存自己内存空间(即虚拟地址空间要切换);此外,进程运行到了哪一条指令,在切换时也要保存;
进程控制块PCB包括以下信息:
- 进程描述信息:进程标识符(唯一标识进程,全局唯一),用户标识符;
- 进程控制和管理清单:进程当前状态(就绪、阻塞等),进程优先级;
- 进程资源分配清单:进程占用的内存空间和虚拟地址空间的信息(页表要保存切换),所打开的文件列表和所使用的I/O设备信息;
- CPU相关信息:CPU寄存器的值要先拷贝到PCB中,进程切换回来时,再将PCB中值恢复到CPU中;
核心:保证上下文切换可以顺利进行;
进程有几种状态?状态切换的过程是什么?发生什么事件时会发生状态切换?
进程状态:
- 创建态:进程正在被创建;
- 就绪态:进程可以运行,但是由于CPU被其他进程占据,所以不能运行;
- 运行态:进程正在占据CPU;
- 阻塞态:进程正在等待某一事件发生而处于暂时停止运行(可以是某个I/O调用,也可以是等待某个资源被其他进程释放等);
- 结束态:进程正在从系统中消失;
进程状态转换:(注意发生状态转换的条件)
- 进程被创建,从创建态到达就绪态;
- 进程等待CPU调度,当CPU调度进程时,从就绪态转换到运行态;
- 进程没有运行完成,但是时间片耗尽,从运行态回到就绪态;
- 进程运行完毕,从运行态转换到结束态;
- 进程在运行时,发生等待事件(比如I/O操作要等待I/O操作结果),从运行态转换到阻塞态;
- 当等待事件完成之后,进程从阻塞态回到就绪态(不会直接到运行态,因为到运行态必须CPU从就绪队列里调度);
挂起状态:阻塞状态的进程可能占据大量物理内存空间,所以操作系统通常会把阻塞状态的进程从物理内存空间换出到硬盘(外存)中,再次运行时从硬盘换入到物理内存空间中;挂起状态就是用来描述一个进程实际没有占据物理内存空间的状态;
- 阻塞挂起状态:进程在外存,并且还要等待某个事件发生;
- 就绪挂起状态:进程在外存,但是随时可以换入到内存中执行;(换入即可执行,不用换入之后还去就绪队列,直接去运行队列)
什么时候发生进程上下文切换?进程上下文切换要保存什么信息?
进程从运行态脱离时一般要继续上下文切换:
- 从运行态到阻塞态:等待某个事件,发生中断后进行中断处理、阻塞等待;
- 从运行态到就绪态:CPU时间片耗尽,CPU调度,高优先级队列抢占CPU运行;
- 从运行态到挂起态:通过sleep函数等主动挂起;
进程上下文切换要保存的信息:虚拟内存、栈、全局变量等用户资源,内核堆栈、寄存器等内核空间资源;
进程的创建、终止、阻塞、唤醒的简单实现过程。
进程创建:
- 一个进程可以通过
fork()
创建另一个进程,创建者为父进程,被创建者为子进程; - 为新进程分配进程控制块PCB;
- 为新进程分配资源,比如内存、CPU时间等;
- 初始化进程控制块PCB各字段;
- 将状态设置为就绪态,加入就绪队列;
进程终止:
- 根据进程标识符PID查找要终止的进程的PCB;
- 终止进程的活动;
- 如果进程有子进程,将子进程交给1号进程init进程接管(子进程并不终止,变为孤儿进程)
- 将进程所有资源归还给操作系统;
- 将进程PCB从所在队列中删除;
进程的阻塞:
- 根据PID找到PCB;
- 如果进程为运行态,则保护现场,进行上下文切换,将进程转换为阻塞态,停止运行,将该PCB加入到阻塞队列;
进程的唤醒:
- 在该事件的阻塞队列(注意是事件的阻塞队列不是进程的)中找到相应进程的PCB;
- 将其从阻塞队列中移出,并设置为就绪态;
- 将该进程的PCB插入的就绪队列中,等待CPU调度;
什么是线程?
线程是进程中的一个实体,是程序执行的最小单元,也是被系统独立调度和分配的基本单位;线程是进程中的一条执行流程,同一个进程内的多个线程可以共享代码段、数据段、打开的文件等资源;但是每个线程又有自己独立的一套寄存器和栈,可以保证线程的控制流相对独立;
特点:
- 一个进程中可以有多个线程,线程共享进程空间,但是也有自己独立的栈和寄存器;
- 各个线程之间可以并发执行;(并发执行也要频繁上下文切换实现)
- 线程也有自己的控制块PCB(称为TCB),创建线程使用的底层函数和创建进程使用的底层函数一样(都是**
clone
函数**); - 进程可以蜕变为线程;
Linux内核不区分进程和线程:
- 如果在创建时复制对方的地址空间,就是创建进程(进程有独立的内存空间)
- 如果在创建时共享对方的地址空间,就是创建线程(线程共享进程内存空间)
- 创建进程的
fork
函数和创建线程的pthread_create
函数底层都是clone
函数; - 线程所有操作函数都是在用户态,即所有函数都是库函数而不是系统调用;所以Linux内核不区分进程和线程,只在用户层面上区分;
进程和线程的差异是什么?
进程时资源分配和调度的基本单位;线程是操作系统能够进行运算调度的最小单位;(线程不涉及资源分配)
线程是进程的子任务,是进程的执行单元;一个进程至少有一个线程,一个进程可以运行多个线程,这些线程共享同一块内存;
资源开销:
- 进程有自己独立的内存空间,创建和删除的资源开销都比较大;进程切换要保存和恢复整个进程空间,上下文开销高;
- 线程共享进程的内存空间,创建和删除的资源开销都比较小,线程切换只需要保存和恢复少量线程上下文,上下文开销低;
通信和同步:
- 进程间相互隔离,通信要特殊机制:管道、消息队列、共享内存等;
- 线程共享进程空间,通信只需要直接访问内存共享数据即可;
安全性:
- 进程间相互隔离,一个进程奔溃不会影响其他进程;
- 线程共享进程空间,所以一个线程错误可能导致整个进程的稳定性,从而导致其他线程也可能奔溃;
什么是中断和异常,二者差异是什么,发生情况是什么?
中断和异常都会暂停当前CPU的执行,然后转向一个特定的处理程序;处理完成后回到之前暂停的地方继续执行;
中断:
- 软中断:
- 中断来源:CPU内部或软件发起的中断,通常由操作系统或应用程序主动触发;
- 处理方式:并不破坏当前任务执行流程,CPU在执行完指令之后,会主动检查是否有软中断请求,并立即响应处理;软中断是在内核态中完成的,可以不破坏当前任务的执行流程情况下进行;
- 举例:文件操作(程序里如果有write函数,只显示就会调用系统调用函数,就会触发软中断,可是程序在用户态并没有被打断,还是正常执行的)、信号(信号可以导致一个运行中的进程被另一个正在运行的异步进程终端,转而处理某一个突发中断);
- 硬中断:
- 中断来源:外部硬件设备或外部信号触发的中断;
- 处理方式:CPU立刻暂停当前程序的执行,跳转到中断服务程序进行处理;
- 举例:网卡中断、键盘中断、定时器中断;
异常:一般是由于计算机系统内部事件触发的,通常与正在执行的程序或指令有关;比如程序的非法操作,如地址溢出、运算溢出等,异常不能被屏蔽,发生异常时,计算机系统会暂停正常的执行流程,转到异常处理程序处理异常;
用户态和核心态的区别是什么?什么时候会发生切换?
区别:控制进程或程序对计算机硬件资源的访问权限和操作范围不同;
用户态:只能访问受限的资源和执行受限的指令集,不能直接访问操作系统的核心部分,也不能直接访问硬件资源;
核心态:允许进程或程序执行特权指令和访问操作系统的核心部分。在核心态下,进程可以直接访问硬件资源,执行系统调用,管理内存、文件系统等资源;
切换场景:
- 系统调用:用户态通过系统调用进入内核态;
- 异常:程序发生异常时,CPU自动进入内核态来方便操作系统处理异常;
- 中断:中断信号会导致CPU从用户态切换到内核态,操作系统处理完中断后会从内核态返回到用户态;
什么是内部碎片,什么是外部碎片?
内部碎片:分页式存储中,每页的大小固定,栈的顶部和堆的底部之间有部分空间没被进程使用,也无法分给其他进程;
外部碎片:分段式存储中,段的大小不固定,段和段之间存在一些小的空间即无法被分配也无法被使用;
内部碎片是已经分配的空间中浪费的部分;外部碎片是没有被分配空间中浪费的部分;
什么是僵尸进程和孤儿进程?
孤儿进程:
在一个进程终止时,它可能还存在一个或多个子进程,这些子进程并不会也终止,那么这些子进程就会成为孤儿进程;这些孤儿进程会被1号进程(init进程)收养,并由init进程对它们完成状态收集工作;
僵尸进程:
一个进程用fork()
创建子进程,子进程终止,但是父进程没有用调用wait
或者waitpid
获取子进程的状态信息,那么子进程的状态描述符仍然保存在系统中,这种子进程被称为僵尸进程(明明已经终止,却还是可以访问到);
多进程程序,父进程⼀般需要跟踪子进程的退出状态,当子进程退出,父进程在运行,子进程必须等到父进程捕获到了子进程的退出状态才真正结束。在子进程结束后,父进程读取状态前,此时子进程为僵尸进程。
信号和信号量有什么区别?
信号:一种处理异步事件的方式;用于通知接收进程有某种事件发生,或者发送信号到进程本身;比如阻塞队列中有一个进程正在等待某个事件发生,则该事件发生之后会向该进程发送一个信号唤醒该进程进入就绪队列;
常见信号:
- SIGKILL(信号编号为9):用于强制终止进程。该信号发送给进程后,进程将被立即终止,无法被忽略、阻塞或捕获。
- SIGTERM(信号编号为15):用于请求进程正常终止。通常由系统管理员或进程管理工具发送给进程,进程收到该信号后可以进行清理工作后终止。
- SIGINT(信号编号为2):用于终止前台进程。通常由用户在终端上按下
Ctrl+C
发送给前台进程,要求进程终止。 - SIGALRM(信号编号为14):用于定时器超时通知。当设置了定时器,并且定时器时间到达时,系统会向进程发送这个信号。
信号量:进程间通信处理同步互斥的机制;(本质就是资源的计数器)负责协调各个线程保证它们以合理、正确的顺序使用公共资源;信号量就是一个变量,可以初始化设定不同的值,来实现不同的功能;
局部性原理是什么?
在同一段时间内,程序倾向于多次访问相同的数据或者接近的数据,而不是随机地访问内存中地各个位置;
时间局部性原理:刚才访问的,之后更可能被访问;
空间局部性原理:一个数据被访问,它周围的数据更可能被访问;
进程之间的通信方式有哪些?
管道、命名管道、信号量、消息队列、信号、共享内存、Socket套接字;
- 管道:半双工的通信方式,只能单向流动;并且只能在父子进程通信中使用;(本质就是读写一个共享文件,但是不是普通的文件,不属于任何文件系统,值存在于内存之中,文件描述符只有父子进程知道,所以只能在父子进程通信中使用,传入数据是无格式的,并且遵循先入先出,从管道中读数据是一次性的,一旦被读取,数据就会从管道中抛弃,类似队列,通信效率低)
- 命名管道:可以在没有亲缘关系的进程中使用的管道;(因为命名管道的文件描述符可以让两个没有亲缘关系的进程知道)
- 信号量:本质是一个计数器,控制多个进程对共享资源的访问,作为一种锁机制;不能传递大量信息,只能作为一种不同进程间同步的手段;(使用PV操作来操作信号量,P操作信号量减1,V操作信号量加1)
- 消息队列:消息的链表,存放在内核中;发送消息时将消息挂在接收进程的消息缓冲队列上;(不适合较大数据的传输)
- 信号:用于通知接收进程某个事件已经发生;(是唯一的异步通信机制,可以在一个进程中通知另一个进程发生了某种事件从而实现进程通信)
- 共享内存:最快的进程通信方式;由一个进程创建一片共享内存,多个进程都可以访问该空间;(进程空间一般都是独立的,但是内核空间是共享的,所以进程之间通信一定要通过内核)
- Socket套接字:主要用于不同端的进程之间的通信;(可以是不同主机上的端口,也可以是相同主机上的不同端口)
// 管道就是读写一个共享文件,通过文件描述符读写文件;
#include <unistd.h>
/**
* 创建⽆名管道.
* @param pipefd 为int型数组的⾸地址,其存放了管道的⽂件描述符
* pipefd[0]、 pipefd[1].
* @return 创建成功返回0,创建失败返回-1.
*/
int pipe(int pipefd[2]);
/**
* 当⼀个管道建⽴时,它会创建两个⽂件描述符 fd[0] 和 fd[1]。其中
* fd[0] 固定⽤于读管道,⽽ fd[1] 固定⽤于写管道。
* ⼀般⽂件 I/O的函数都可以⽤来操作管道(lseek() 除外。)
*/
// 命名管道提供了一个路径名和管道相关联,即使不存在亲缘关系的进程,只要知道路径名可以访问该路径,即可通过管道通信;
#include <sys/types.h>
#include <sys/stat.h>
/**
* 命名管道的创建.
* @param pathname 普通的路径名,也就是创建后 FIFO 的名
* @param mode ⽂件的权限,
* 与打开普通⽂件的 open() 函数中的 mode 参数相同。 (066
* @return 成功: 0 状态码;
* 失败: 如果⽂件已经存在,则会出错且返回 -1.
*/
int mkfifo(const char *pathname, mode_t mode);
匿名管道和命名管道的不同之处:
- 命名管道在文件系统中作为一个特殊文件存在,但是管道的内容却存放在内存中;(匿名管道不属于任何文件系统,只是在内存中保存)
- 当使用命名管道的进程退出之后,命名管道文件依旧在文件系统中保存;
- 命名管道有名字,不相关进程可以通过名字打开命名管道通信;(匿名管道只能在父子进程中通信)
信号量实现互斥锁是如何实现的?信号量实现多进程同步是如何实现的?信号量实现条件变量是如何实现的?
互斥锁:是一种同步原语,保证同一时刻只有一个进程/线程可以访问临界区资源;
核心思想:
- 初始化信号量为1,表示资源可以使用;
- 进程/线程在访问临界区前,先使用
P()
操作获取资源,如果信号量值为1,则可以获取到资源,信号量值减1;如果信号量值为0,代表资源不可用,需要等待直到信号量变成1然后才能获取; - 进程/线程在退出临界区后,立刻使用
V()
操作释放资源,,即信号量加1;
(自旋锁思想也是一样的,线程在获取锁失败时会不断重试(自旋),直到获得锁。)
示例:
#include <iostream>
#include <thread>
#include <semaphore.h>
sem_t mutex; // 定义信号量
void critical_section(int id) {
// 进入临界区前,先获取信号量
sem_wait(&mutex);
// 临界区代码
std::cout << "Thread " << id << " is in critical section." << std::endl;
// 退出临界区,释放信号量
sem_post(&mutex);
}
int main() {
// 初始化信号量为1,表示资源可用
sem_init(&mutex, 0, 1);
// 创建两个线程访问临界区
std::thread t1(critical_section, 1);
std::thread t2(critical_section, 2);
t1.join(); // 非分离状态
t2.join();
// 销毁信号量
sem_destroy(&mutex);
return 0;
}
多进程同步的实现:
- 使用有名信号量,进程之间共享;
- 在需要同步的地方,对信号量进行
P()
和V()
操作,控制进程的执行顺序;
示例:
#include <iostream>
#include <unistd.h>
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/sem.h>
int main() {
// 创建一个有名信号量
int semid = semget(ftok(".", 'a'), 1, IPC_CREAT | 0666);
// 初始化信号量值为0
union semun {
int val;
struct semid_ds *buf;
ushort *array;
} arg;
arg.val = 0;
semctl(semid, 0, SETVAL, arg);
// 创建子进程
pid_t pid = fork();
if (pid == 0) { // 子进程
// 子进程等待信号量
struct sembuf op = {0, 1, 0};
semop(semid, &op, 1);
std::cout << "Child process" << std::endl;
} else { // 父进程
sleep(2); // 模拟其他工作
// 父进程释放信号量,唤醒子进程
struct sembuf op = {0, -1, 0};
semop(semid, &op, 1);
std::cout << "Parent process" << std::endl;
}
// 删除信号量
semctl(semid, 0, IPC_RMID, 0);
return 0;
}
读写锁:是一种特殊的互斥锁,读写锁**允许多个线程/进程同时读取共享资源,但只允许一个线程/进程写入共享资源。**读写锁可以使用两个信号量来实现:一个用于控制读取操作,一个用于控制写入操作;
核心思想:
- 定义两个信号量:
read_lock
读锁,write_lock
写锁;都初始化为1; - 定义一个
int
型变量reader_count
记录读者计数器; - 读线程获取锁时,先
P(read_lock)
,然后reader_count++
,之后判断是否是第一个读者,如果是第一个读者,则P(write_lock)
,防止读的时候写; - 读线程读数据时,并不需要加锁;(已经将写锁阻塞了,所以读的时候不用加锁)
- 读线程读取数据接收后,先
P(read_lock)
,然后reader_count--
,判断该读线程是否是最后一个读线程,如果是,则需要释放写锁(没人读了,可以写),即V(write_lock)
,然后V(read_lock)
; read_lock
读锁并不是用来保护读数据,而是保护读线程对写锁操作的过程;- 写线程获取锁时,先
P(write_lock)
,然后P(write_lock)
;
示例:
#include <iostream>
#include <thread>
#include <semaphore.h>
sem_t read_lock; // 读锁信号量
sem_t write_lock; // 写锁信号量
int reader_count = 0; // 读者计数器
void reader(int id) {
sem_wait(&read_lock); // 请求读锁
reader_count++; // 增加读者计数
if (reader_count == 1) {
sem_wait(&write_lock); // 如果是第一个读者,阻塞写入
}
sem_post(&read_lock); // 释放读锁
// 读取共享资源
std::cout << "Reader " << id << " is reading." << std::endl;
sem_wait(&read_lock); // 请求读锁
reader_count--; // 减少读者计数
if (reader_count == 0) {
sem_post(&write_lock); // 如果是最后一个读者,释放写锁
}
sem_post(&read_lock); // 释放读锁
}
void writer(int id) {
sem_wait(&write_lock); // 请求写锁
// 写入共享资源
std::cout << "Writer " << id << " is writing." << std::endl;
sem_post(&write_lock); // 释放写锁
}
int main() {
// 初始化读写锁
sem_init(&read_lock, 0, 1);
sem_init(&write_lock, 0, 1);
std::thread r1(reader, 1), r2(reader, 2), w1(writer, 1);
r1.join();
r2.join();
w1.join();
// 销毁读写锁
sem_destroy(&read_lock);
sem_destroy(&write_lock);
return 0;
}
条件变量 :条件变量用于线程/进程之间的同步,当某个条件满足时,唤醒等待该条件的线程。条件变量可以使用信号量来实现。(一般使用条件变量前要加锁)
#include <iostream>
#include <thread>
#include <semaphore.h>
sem_t mutex; // 互斥锁信号量(条件变量使用时加互斥锁)
sem_t cond; // 条件变量信号量
int count = 0; // 共享资源计数器
void producer(int id) {
while (true) {
sem_wait(&mutex); // 请求获取互斥锁
// 生产者代码
count++;
std::cout << "Producer " << id << " produced one item. Count: " << count << std::endl;
sem_post(&cond); // 发送信号量,唤醒消费者
sem_post(&mutex); // 释放互斥锁
}
}
void consumer(int id) {
while (true) {
sem_wait(&mutex); // 请求获取互斥锁
if (count == 0) {
sem_post(&mutex); // 如果没有资源,释放互斥锁并等待信号量
sem_wait(&cond);
sem_wait(&mutex); // 重新获取互斥锁
}
// 消费者代码
count--;
std::cout << "Consumer " << id << " consumed one item. Count: " << count << std::endl;
sem_post(&mutex); // 释放互斥锁
}
}
int main() {
// 初始化互斥锁和条件变量
sem_init(&mutex, 0, 1);
sem_init(&cond, 0, 0);
std::thread p1(producer, 1), p2(producer, 2), c1(consumer, 1), c2(consumer, 2);
p1.join();
p2.join();
c1.join();
c2.join();
// 销毁互斥锁和条件变量
sem_destroy(&mutex);
sem_destroy(&cond);
return 0;
}
线程同步机制有哪些?
互斥锁、条件变量、读写锁、信号量;同一进程中的线程共享进程空间,所以可以同一进程中的线程可以方便的用读写共享信息来通信,但是小心多个线程同时修改同一份信息,所以在通信之前最好要对临界区资源加锁;
- 互斥锁:最常见的线程同步机制,它允许只有一个线程同时访问被保护的临界区(共享资源);即同一时间内,只有一个进程可以进入临界区;
- 条件变量:用于线程之间通信;**允许一个线程等待某个条件满足,而其他线程可以发出信号通知等待线程。**一般和互斥变量一起使用,用互斥变量实现加锁之后线程修改条件变量,等待线程检测到条件变量改变之后开始执行;
- 条件不满足,阻塞线程;
- 条件满足,通知阻塞线程开始工作;
- 读写锁:允许多个线程同时读取共享资源,但是同一时间只允许一个线程进行写;(具体而言,如果一个线程申请了读锁,则其他线程既可以申请读锁但不能申请写锁;如果一个线程申请了写锁,则其他线程既不能申请读锁也不能申请写锁)
- 信号量:控制多个线程对共享资源进行访问的工具,就是共享资源的个数,每有一个线程获取到资源,信号量减一,如果信号量小于0时不能获取资源,一般使用PV操作来加减信号量;(
P
为申请资源,会减少信号量;V
为释放资源,会增加信号量;Linux中sem_wait
实现P
操作,sem_post
实现V
操作)
介绍一下你知道的锁。
两个基础的锁:
- 互斥锁:用于实现互斥访问共享资源;在任何时刻,只有一个线程可以持有互斥锁,其他线程必须等待该线程释放锁后才能获取,这保证了同一时间内只有一个线程可以访问被保护的资源;
- 自旋锁:基于忙等待的锁,即线程在无法获取到锁时,会不断轮询尝试获取锁,而不是睡眠,从而提高了响应速度和速率;
- 优点:减少上下文开销、快速获取锁;
- 缺点:不断轮询占用CPU资源、会有死锁风险;
其他锁都是基于这两个基础的锁的:
- 读写锁:允许多个线程同时读共享资源,但只允许一个线程进行写操作;
- 悲观锁:认为多线程同时修改共享资源的概率比较高,所以访问共享资源时也要上锁;
- 乐观锁:假设数据在更新期间不会被其他线程修改,所以先修改共享资源,如果出现同时修改的情况,则放弃本次操作并回滚;(实现方式:版本号或时间戳)
死锁的产生条件有哪些?如何预防死锁?如何处理死锁?如何避免死锁?
当两个或多个进程竞争临界区资源时,由于互相等待对方释放资源而无法继续执行的状态;
四个必要条件,破坏任何一个即可破坏死锁;
- 互斥条件(互斥):一个进程占用了某个资源,其他进程无法同时占用该资源;
- 请求保持条件(占有和等待):一个线程因为请求资源而被阻塞时,不会释放自己已经占有的资源;
- 不可剥夺条件(不可抢占):资源不能被强制性从一个进程中剥夺,只能等进程主动释放;
- 环路等待条件(环路等待):多个进程之间形成一个循环等待资源的环链,每个进程都占有环中上一个进程的资源,但又等待环中下一个进程占有的资源;
(你争我夺,我不让步,抢不过来,形成闭环,大家都死)
死锁预防:
- 破坏请求保持条件:一次性申请所有资源;如果申请不到,则继续等待;(要么全申请,要么不申请)
- 破坏不可剥夺条件:如果进程申请不到资源,可以主动释放已经占有的资源;
- 破坏循环等待条件:按序申请资源来预防,让所有进程都按照同样的顺序来申请资源,释放资源则相反的顺序;
- 即如果锁1、锁2都要被进程A、B申请,则必须规定同一个顺序;比如先申请锁1,后申请锁2;如果A先申请锁1,B先申请锁2,则很可能发生死锁;(在锁多的情况下,难以统一顺序,实际使用不方便)
- 要对所有要申请的资源做排序,并保证所有进程都按序申请;
解除死锁:
- 鸵鸟算法:不做任何处理,出了大问题就重启;(重启能解决百分之八十的问题,遇事不决就重启)
- 死锁检测和死锁恢复:
- 死锁检测:可以使用环路检测算法(比如深度优先搜索,设置标记为进行遍历等);
- 死锁恢复:
- 资源抢占:将某些资源抢占过来,分配给正在请求的死锁进程,破坏死锁的环路;
- 进程终止:终止环路上某些进程,释放占用的资源,解除死锁;
- 资源回滚:回滚进程状态,使得进程释放某些资源;
- 重启系统:由于进程以不可预知的速度往前进行,所以重启可能会换一种方式调度,从而避免死锁;
死锁避免:
安全状态:如果存在某种调度次序能使得每一个进程运行完毕,则称该状态是安全的;(至少题目有正确答案我们才做题,如果题目本身就错误,没必要找解题方法)
银行家算法:如果分配给一个进程资源后进入不安全状态,则拒绝分配;系统会统计每个进程的最大资源需求量、已分配资源数量、还需要资源数量、当前系统资源状况等,然后在进程申请资源时进行安全性检查,即分配之后是否存在某种调度次序使得每一个进程都能运行完毕,如果通过了安全性检查,则分配;
进程中调度算法有哪些?
批处理系统中的调度:
- 先来先服务FIFS:非抢占式的调度,有利于长作业不利于短作业;
- 最短作业优先:非抢占式的调度,按估计运行时间最短的顺序进行调度;
- 最短剩余时间优先:最短作业优先的抢占式版本;
- 高响应比优先算法:综合等待时间和服务时间的算法;每次进行进程调度都计算响应比优先级( 等待时间 + 要求服务时间 要求服务时间 等待时间+要求服务时间\over 要求服务时间 要求服务时间等待时间+要求服务时间),选择高响应比优先级的作业先执行;
交互式系统中的调度:
- 时间片轮转调度:按照FCFS的原则排成一个队列,队头的作业执行完一个时间片后加入队尾;
- 优先级调度:为每一个进程分配一个优先级,按照优先级调度;
- 多级队列:设置多个队列,每一个队列的时间片大小都不同,时间片耗尽之后如果没有执行完,移到下一个队列;
一种综合的考虑——多级反馈优先级队列:
- 系统中有多个队列,每个队列的优先级和时间片都不同,系统先执行优先级高的队列;
- 队列内使用FCFS算法(先来先服务算法);
- 开始时,所有任务都在最高优先级队列,然后每个任务都执行一个时间片之后未执行完的任务(未执行完指时间片结束之前任务没有主动释放CPU)放在次一级的优先级队列;
- 优先级高的队列时间片短(快速响应),优先级低的队列时间片长;
- 每隔一段时间,将所有队列中的任务都调回最高优先级队列(防止饿死)
- 并且记录作业在一个队列中花费的总时间,如果总时间达到了某个值,将作业调度到低一个优先级的队列中(防止I/O欺骗:任务会在每次时间片快结束时,进行I/O操作主动释放CPU,主动释放了CPU就不会将优先级降低,任务就可以一直在高优先级队列中)
每一种算法都有自己的特点,比如FIFS有利于长任务,不利于短任务,适用于I/O繁忙的情况,不适用于CPU繁忙的情况;没有最好的算法,只有最适合的算法;
在选择算法时还要防止“饿死”。对于I/O繁忙的情况,我们可以设置一定的倾向性让I/O任务更先执行,但是不能让非I/O任务彻底饿死(虽然少,可以忍受执行慢,但不能忍受彻底不执行),所以在设计算法时一定要考虑到如何将这些可能饿死的任务在饿死前救过来(比如多级优先级队列中每隔一段时间将所有队列中的任务都提到最高优先级队列中,就是为了防止有些任务一直在最低优先级队列中被饿死)
分段和分页的区别有哪些?
分段:将程序的地址空间划分为不同的逻辑段,每一个段都有独立的含义,比如代码段、数据段等,每个段的长度可以不一致,段的起始地址可以放在段指针寄存器中;
分页:将程序的地址空间划分为固定大小的页面,物理内存也被划为相同的固定大小的页面框;
区别:
- 分段将地址空间划分为不同意义的逻辑段,分页将地址空间划分为固定大小的页面;
- 分段倾向于产生内部碎片,分页倾向于产生外部碎片;
- 段的长度不固定,页的长度固定;
- 段的逻辑地址由段号+段内偏移组成,页的逻辑地址由页号+页内偏移组成;
分段可能导致内部碎片严重(即段和段之间存在未被分配却无法被分配利用的小空间),分页可能导致外部碎片严重(即页内部存在已经分配但是无法利用的小空间,位于页中栈的顶部和堆的底部),但是注意,分段也可能有外部碎片,分页也可能有内部碎片,只是程度不同,倾向不同;
页面置换算法有哪些?
- LRU(最近最少使用)算法:每次选择最长时间没有使用的角色进行切换;实现起来比较复杂,要记录每个角色使用的时间,换出时还要比较使用的时间找到最近最少使用的页面换出;
- FIFO(先入先出)算法:每次选择最早进入内存的角色进行切换;可能淘汰一些经常使用的页面;
- 最佳页面置换算法OPT:理想化实现,预知每个页面下一次访问的等待时间,找到等待时间最长的换出;
- 时钟页面置换算法:将所有页面保存在一个环形链表中(类似时钟),页面包含一个访问标志位,发生缺页中断时,顺时针遍历页面,访问位为1则改为0;访问位为0,则直接换出;
- 最不常用算法:将访问页面次数最少的页面换出去,要维护一个页面访问计数器;
不要纠结于最优:最优意味着高消耗,在工程实现上,我们要做的不是使用最优算法,而是找到效率最高的算法;最近最少使用算法虽然很理想,但是成本也很高,我们其实没必要找到最佳页面换出,我们只用找到一堆可能是最佳页面的页面中随便找一个换出即可。
如果拉长时间并且页面交换频繁,随机算法都可以达到一个不错的结果(甚至综合效率比上面设计的任何一种算法都要高);
I/O多路复用实现方式有几种?
I/O多路复用:一种I/O模型,用于同时处理多个I/O任务的机制;一般I/O任务比较慢,为了高效利用系统资源,可以使用I/O多路复用技术,允许一个进程监视多个文件描述符,并在其中任意一个文件描述符就绪时就绪处理;(就是一个进程监视一组文件描述符的技术)
如果没有I/O多路复用,一个进程只负责处理一个I/O任务,那么此时进程根据文件描述符运行系统调用函数去写文件,然后进程就闲着没事干了,干等着I/O完成,这明显很浪费CPU性能;
3种常见的I/O多路复用的系统调用:
方法 | 监视文件描述符的方法 | 特点 | 使用场景 |
---|---|---|---|
select | 遍历 | 有文件描述符限制,每次调用都要线性扫描所有文件描述符 | 文件描述符较少,支持多平台 |
poll | 轮询 | 无文件描述符数量限制,对于大量文件描述符情况适用 | 大量文件描述符并且不需要跨平台 |
epoll | 事件驱动机制 | 高性能,使用事件通知机制避免轮询,可以同时监听大量文件描述符 | 高性能大量并发场景 |
select/poll/epoll的区别和联系。
区别:
- select:
- select是最古老的I/O多路复用机制,支持的文件描述符数量有限,通常为1024(可通过修改定义进行调整)。
- 每次调用select都需要将所有的文件描述符集合从用户态拷贝到内核态,效率较低。
- select返回时需要遍历所有的文件描述符,效率随着文件描述符数量增加而下降。
- poll:
- poll也是一种I/O多路复用机制,解决了select的一些限制,支持的文件描述符数量没有限制。
- 每次等待事件时,需要将所有的文件描述符从用户态拷贝到内核态。
- 使用
fd
数组存储文件描述符,效率较select略高。 - 文件增多时,效率明显下降。
- epoll:
- epoll是Linux特有的I/O多路复用机制,使用内核事件表(eventpoll)管理文件描述符,无文件描述符数量限制。
- 当文件描述符增多时,epoll的性能不会随之下降,采用事件通知的方式避免了轮询。
- 提供了三种操作模式:ET(边缘触发)、LT(水平触发)、EPOLLONESHOT(一个事件只被一个线程处理)。
联系:
- 共同点:
- select、poll、epoll都是I/O多路复用的实现机制,能够监视多个文件描述符上的I/O事件。
- 都可以设置超时,指示最长等待时间。
- 区别:
- 随着文件描述符数量增加,select的性能下降更快,poll的性能次之,而epoll的性能最好。
- select和poll需要在每次调用时传递文件描述符集合(从用户态复制到内核态),而epoll在epoll_ctl注册事件后,只需要等待内核通知即可。
- epoll支持水平触发和边缘触发,可以更加灵活地监控文件描述符的状态。
总的来说,epoll相比select和poll在性能和扩展性上有明显优势,尤其适用于高并发的网络编程。在选择使用哪种I/O多路复用机制时,需要根据实际情况和需求综合考虑。
Linux中如何杀死一个进程?
使用kill
命令可以杀死一个进程,进程有自己的唯一标识符PID,所以指明PID再通过kill
命令即可杀死进程;被杀死的进程如果有子进程,则子进程成为孤儿进程,而1号进程init成为它的父进程;
什么是init进程?
init
进程是整个系统的第一个进程,是系统启动时内核创建的第一个用户空间进程,PID为1;
init
进程主要责任是初始化系统并在系统运行过程中管理系统的运行级别和进程生命周期;(运行级别,比如运行级别为3代表多用户命令行模式,运行级别为5代表图形化界面模式)
init
进程是用户空间进程的祖先,用户空间进程都是由init进程直接或者间接fork出来的子进程;
Linux中如何查看一个进程的信息?
ps aux | grep myprocess
// ps命令列出当前用户的进程信息,grep命令对结果进行过滤
pgrep myprocess // 通过进程名查找PID
pidof myprocess // 通过进程名查找PID
用户线程ULT和内核线程KLT比较。
用户线程:用户空间实现的线程,无需操作系统直接参与管理;
内核线程:由操作系统管理、调度的线程,其TCB存放在内核空间,线程的创建、终止、管理均由操作系统完成;
线程 | 优点 | 缺点 |
---|---|---|
用户线程 | 不需要切换内核空间,在用户态运行 | 一旦发起系统调用就要从用户态转到内核态,导致此进程下其他线程都被阻塞 |
用户线程 | 可以由不同于进程的针对线程的调度算法 | 线程执行时无法被打断,只有操作系统才有权限打断运行,但是操作系统不直接参与线程调度(线程不能抢占式运行) |
用户线程 | 和操作系统平台无关 | 时间片分配给进程,如果一个进程创建了很多线程,每个线程得到的时间片会很少,执行很慢 |
内核线程 | 发起系统调用不会阻塞其他内核线程的运行 | 内核要维护进程和线程的上下文切换,比如PCB和TCB |
内核线程 | 可以直接分配时间片到内核线程,多线程的进程可以获得更多CPU运行时间 | 线程的创建、终止、切换都通过系统调用,系统开销大 |
轻量级线程LWP:结合了用户线程和内核线程,是内核支持的用户线程;一个进程可以有一个或多个LWP,每个LWP和内核线程一对一映射,也就是每个LWP都有一个内核线程支持,LWP像内核线程一样被内核管理,但是像用户线程一样被调度;
手写Linux过程中线程创建、初始化、销毁的函数过程。
#include <pthread.h>
#include <iostream>
// 定义线程函数
void *thread_function(void *arg) {
std::cout << "Thread running." << std::endl;
pthread_exit(NULL);
}
int main() {
pthread_t tid; // 定义线程标识符
// 创建线程
if (pthread_create(&tid, NULL, thread_function, NULL) != 0) {
std::cerr << "Error creating thread." << std::endl;
return 1;
} else {
std::cout << "Thread created successfully." << std::endl;
}
// 初始化线程属性
pthread_attr_t attr;
if (pthread_attr_init(&attr) != 0) {
std::cerr << "Error initializing thread attributes." << std::endl;
return 1;
}
// 设置线程为分离状态,线程结束后自动释放资源
if (pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_DETACHED) != 0) {
std::cerr << "Error setting thread attributes." << std::endl;
return 1;
}
// 销毁线程属性
if (pthread_attr_destroy(&attr) != 0) {
std::cerr << "Error destroying thread attributes." << std::endl;
return 1;
}
// 等待线程结束
if (pthread_join(tid, NULL) != 0) {
std::cerr << "Error joining thread." << std::endl;
return 1;
} else {
std::cout << "Thread joined successfully." << std::endl;
}
return 0;
}
什么是线程的分离状态?
多线程编程中,join
是一个用于等待线程结束并进行同步的操作,即当一个线程join
另外一个线程时,该线程会阻塞,知道另一个线程执行完成;
- 非分离状态:线程默认是非分离状态的;在这种情况下,线程等待被
join
的线程结束,只有pthread_join()
函数返回之后,被join
的线程才能释放资源,真正的结束; - 分离状态:线程不能被
join
,一旦结束,直接释放资源;- 线程1调用
pthread_create()
函数创建线程2; - 线程2执行非常快,导致很快就释放资源,然后将资源又分配给了线程3;
- 线程2结束了
pthread_create()
函数都没有返回,所以此时pthread_create()
函数返回的是线程3的线程号而不是线程2的线程号; - 解决方法是在被创建的线程里调用一个暂时停止的函数,让线程等待一会,留出足够的时间让函数
pthread_create()
返回;
- 线程1调用
多线程的注意事项。
- 注意线程依赖关系,即线程之间有无先后访问顺序;
- 注意同步互斥问题,即线程访问临界区资源时注意加锁;
i++
看上去只有一句,但是涉及到多个操作,比如内存取数,计算和写入内存,改变多个操作顺序可能造成意想不到的错误,同样线程切换可能发生在上面的多个操作的任何一个环节;所以要注意线程的操作顺序;
进程切换比线程慢的核心原因是什么?为什么进程切换之后程序运行速度会变慢?
核心原因是进程切换涉及到虚拟地址空间的切换,而线程不涉及;
将虚拟地址空间转换为物理地址空间,然后将物理地址保存起来,等到进程再次执行时恢复现场;虚拟地址空间转换物理地址空间要查找页表,查页表是一个很慢的过程(至少访问内存2次,第一次访问内存中的页表,第二次根据页表找到内存地址访问内存),如果Cache中缓存常用地址映射会加快查页表的过程;
每一个进程都有自己的虚拟地址空间,所以页表是独立的(页表是虚拟地址到物理地址的映射),Cache是根据页表中常用地址缓存设置的,所以如果进程切换,之前的缓存会全部失效,所以进程被切换进来之后,Cache缓存为空,运行速度自然慢。
什么是守护进程?怎么创建守护进程?
守护进程是指在后台运行,没有控制终端与它相连的进程。它独立于控制终端,周期性地执行某种任务;(比如Linux中地大多数服务器是以守护进程的方式实现的)
创建守护进程:
- 调用
fork()
产生一个子进程;然后使父进程退出; - 调用
setid()
创建一个新对话期,并使得进程成为会话组组长;(守护进程要摆脱父进程的影响,所以调用setid()
使进程成为一个会话组长,和之前的控制终端脱离,避免被任何终端所产生的信息打断,并且执行过程中的信息也不在任何终端上显示) - 禁止进程重新打开控制终端,可以再次调用
fork()
函数创建新的子进程,然后使调用fork()
函数的进程退出;(执行过程中的信息也不在任何终端上显示) - 关闭子进程从父进程继承来的不需要的文件描述符;
- 将当前目录设置为根目录;
- 将子进程从父进程继承的文件创建屏蔽字清零(避免创建文件时拒绝某些许可权)(非必要)
- 将
SIGCHLD
信号设置为SIG_IGN
,避免子进程结束变成僵尸进程。因为服务器请求到来时会创建子进程来处理请求,如果子进程等待父进程捕获状态,则会有一段时间变成僵尸进程占用系统资源。(非必要)
子进程创建后发生了什么?
- 子进程获得了父进程的数据空间、堆、栈的副本,子进程拥有父进程全部的内存数据,将全部内存数据复制到了一片新的内存空间;
- 子进程会获得一个独立的进程PID;
- 子进程会继承父进程的一些属性,比如用户ID和组ID;
- 子进程会获得父进程文件描述符表的副本(父进程打开的文件子进程也可以查看)
- 父进程和子进程都会执行下一条指令,执行顺序取决于内核调度情况;如果想要子进程独立运行自己的代码,则可以通过
execv()
函数传递一个可执行文件的路径来重新加载新的代码段,和父进程的代码独立开。(也可以根据PID来分别指定父进程和子进程执行的代码,但是虽然两个进程执行的代码不同,但执行的还是一份代码,只是进入了不同的if
循环)
什么是可重入函数和不可重入函数?
安全性:在多线程中,可重入函数时安全的,不可重入函数时不安全的;(不安全是由于多线程时,函数以不可预知的速度向前执行,随时可能被打断,打断之后其他线程可能修改该函数的数据)
不可重入函数:不可重入函数指的是当一个函数正在执行时,不能被另一个线程中断执行,直到第一个函数执行完毕为止。如果该函数被中断执行,可能会导致数据错乱或者程序崩溃。
一个不可重入函数导致数据错乱的例子:
- 函数1为不可重入函数,
count
为全局变量; - 线程1执行函数1,并且执行过程中修改
count
的值为2; - 线程1被阻塞,函数1并没有被执行完;记录函数1未执行完的位置;
- 线程2执行函数2,修改全局变量
count
为3(count
为全局变量,所有函数都可以访问到) - 线程2执行完毕,
count
变为3,线程1被唤醒,从刚才中断的地方开始执行,但是count
值已经被修改为3,函数1后面的执行都是数据错误的;
如何避免不可重入函数?
- 避免使用静态数据结构和全局变量;尽量使用局部变量,如寄存器、栈中的变量等;
- 如果必须要使用全局变量,则在使用时需要加锁;
- 函数体内避免使用
malloc()
或者free()
函数,因为要谨慎使用堆,堆是进程共享的,所有不同线程都可以访问到,尽量使用栈而不是堆; - 避免使用标准I/O函数,因为标准I/O函数是对文件进行操作,而其他线程也可能对相同的文件进行操作,导致数据错误;
在生产者-消费者模型中,条件变量可以怎么设置?
一个条件变量实现:
- 设置一个条件变量
cond
代表缓存区资源; - 当生产者开始生产的时候,
cond
增加,当cond
达到n
时,缓存区满,唤醒消费者; - 消费者消费,
cond
减少,当cond
减少到0时,唤醒生产者;
两个条件变量实现:
full
和empty
两个条件变量:- 初始值:
full
为0,empty
为n
,n
为缓冲区大小; - 当
full == n
时,代表缓存区满;当empty == 0
时代表缓存区空; - 生产者:(只等待
full
条件变量)- 获取互斥锁(条件变量使用时加锁)
- 如果缓冲区满,即
full == n
,则等待full
条件变量; - 缓冲区未满,将产品放入缓冲区;
- 如果缓冲区不为空,唤醒等待在
empty
条件变量上的消费者; - 释放互斥锁;
- 消费者:(只等待
empty
条件变量)- 获取互斥锁;
- 如果缓冲区空,即
empty == 0
,则等待empty
条件变量; - 缓冲区非空,则取出产品消费;
- 如果缓冲区非满,唤醒等待在
full
条件变量上的生产者; - 释放互斥锁;
两个条件变量比一个条件变量的好处:生产者只能唤醒消费者而不是其他生产者(因为生产者等待的条件变量和消费者等待的条件变量不同);
int buffer[MAX_SIZE];
int count = 0;
mutex mtx;
condition_variable full, empty;
void producer() {
while(true) {
unique_lock<mutex> lock(mtx);
if(count == MAX_SIZE) {
full.wait(lock); // 等待full条件变量
}
buffer[count++] = new_item();
if(count == 1) {
empty.notify_one(); // 唤醒等待empty的条件变量(使用管程)
}
}
}
void consumer() {
while(true) {
unique_lock<mutex> lock(mtx);
if(count == 0) {
empty.wait(lock); // 被阻塞,等待empty条件变量
}
consume_item(buffer[--count]);
if(count == MAX_SIZE-1) {
full.notify_one(); // 唤醒等待full的条件变量(使用管程)
}
}
}
互斥锁、条件变量、信号量的区分
- 互斥锁(Mutex)
- 作用:用于保护共享资源,确保在同一时间只有一个线程能够访问共享资源,避免出现数据竞争问题。
- 特点:互斥锁是一种二进制的同步机制,只能有一个线程持有它,其余线程必须等待锁的释放。
- 操作:通常包括**加锁(lock)和解锁(unlock)**两个操作,加锁成功的线程可以访问共享资源,其他线程需要等待。
- 条件变量(Condition Variable)
- 作用:用于线程间的通信与同步,当某个特定条件满足时,唤醒等待该条件的线程。
- 特点:条件变量往往与互斥锁一起使用,等待条件变量的线程会在条件不满足时自动阻塞并释放锁。
- 操作:通常包括**等待(wait)和通知(notify)**两个操作,等待的线程会被阻塞直到其他线程发出通知。
- 信号量(Semaphore)
- 作用:用于进程或线程间的同步和互斥,控制对共享资源的访问。
- 特点:信号量是一个计数器,可以有多个线程同时持有它,在资源管理中也可以表示可用资源的数量。
- 操作:通常包括 P(wait)和 V(signal) 操作。P操作会使信号量减1,V操作会使信号量加1。信号量值大于零表示有可用资源,否则需要等待。
什么是管程?和信号量机制有什么关系?
管程是一种“高级抽象”,用于实现线程的同步和互斥;如果不使用管程机制,我们可以使用信号量实现互斥锁和条件变量来实现线程的同步和互斥,但是实现的机制我们要自己写;管程将这一切封装起来,我们在使用管程时就可以不用考虑底层的信号量的使用;
一个简单的管程类的实现:
#include <iostream>
#include <thread>
#include <mutex>
#include <condition_variable>
class Monitor { // 管程类
private:
std::mutex m; // 信号量
std::condition_variable cv; // 条件变量
public: // 管程的实现依靠互斥锁和条件变量
void critical_section(int id) {
std::unique_lock<std::mutex> lock(m); // 互斥锁
// 等待条件满足
cv.wait(lock); // 等待条件变量被唤醒
// 临界区代码
std::cout << "Thread " << id << " is in critical section." << std::endl;
// 临界区代码结束
}
void notify_one() {
cv.notify_one(); // 唤醒一个等待的线程
}
};
Monitor monitor; // 监视器(管程)
void thread_func(int id) { // 线程函数,传递给线程
monitor.critical_section(id); // 调用临界区函数
}
int main() {
std::thread t1(thread_func, 1); // 创建线程1
std::thread t2(thread_func, 2); // 创建线程2
// 唤醒一个等待的线程(上面的创建线程完毕之后被阻塞了,因为条件变量没有满足)
monitor.notify_one();
t1.join(); // 等待线程1执行完毕
t2.join(); // 等待线程2执行完毕
return 0;
}
使用信号量机制实现的⽣产者消费者问题需要客户端代码做很多控制(什么时候上锁,什么时候修改条件变量,什么时候阻塞等等),而管程把控制的代码独立出来,不仅不容易出错,也使得客户端代码调用更容易。
为什么需要虚拟内存?
直接使用物理内存有问题:
- 内存碎片化问题:进程每次都会在物理内存中申请一大块内存空间,进程多了,就会导致进程内存空间之间有很多小块无法被分配的碎片空间;
- 读写内存的安全性问题:物理内存本身不受限制访问,任何地址都可以读写,直接使用物理内存就没办法保证安全性;
- 进程安全性问题:由于物理空间不受限制访问,所以进程的内存空间不安全,进程可以随意访问到其他进程的空间;
- 读写效率问题:由于内存不够大,所以如果要分配给进程的内存大于物理内存,则会导致进程运行时有频繁的外存和内存交换页面的情况,导致效率很低;
为什么需要虚拟内存:
- 虚拟内存可以结合磁盘和物理内存的优势为进程提供看起来速度足够快并且容量⾜够⼤的存储
- 虚拟内存可以为进程提供独⽴的内存空间并引⼊多层的页表结构将虚拟内存翻译成物理内存,进程之间可以共享物理内存减少开销,也能简化程序的链接、装载以及内存分配过程;
- 虚拟内存可以控制进程对物理内存的访问,隔离不同进程的访问权限,提⾼系统的安全性。
I/O模型有哪些?
5种:阻塞式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操作未完成,之后用轮询或者循环调用检查操作是否完成;
- I/O复用(多路复用):通过select、poll或者epoll系统调用,允许进程同时监听多个文件描述符;
- 信号驱动式I/O:进程通过注册信号处理函数来处理I/O完成的通知,当I/O完成时,系统会向进程发送一个信号通知进程I/O完成,从而异步处理I/O事件;(虽然异步处理,但不是异步I/O,进程依旧要等待I/O完成之后才能完成其他任务)
异步式I/O模型:进程发起I/O操作后,可以继续执行其他任务,而不用等待I/O操作完成;当操作完成时,进程会受到通知,可以直接读取数据而不需要再次发起读取操作;异步I/O模型实现复杂度高,但是对系统性能和并发处理能力更有利;
Linux中的软链接和硬链接有什么区别?
软链接:
- 是一个指向目标文件的路径名,类似于Windows系统中的快捷方式;
- 创建软链接可以跨文件系统,甚至可以链接到不存在的文件;
- 删除原文件并不会影响软链接,但是如果原文件被删除,软链接就会失效;
- 通过
ln -s 文件名 链接名
命令创建软链接;
硬链接:
- 硬链接是指在文件系统中,不同的文件名指向同一个索引节点index;
- 硬链接必须是在同一个文件系统中;
- 硬链接实际是文件的一个拷贝;
- 删除原文件并不会影响硬链接,删除原文件只会减少原文件的链接计数,只有当链接计数降为0时才会真正删除文件内容;
- 使用
ln 文件名 链接名
来创建硬链接;
硬链接的底层原理:在Linux中,每个文件在文件系统中都有一个与之相对应的inode
,inode
记录了文件的元数据信息,比如文件大小、权限、所有者等,也包含了文件数据块的指针信息(文件在内存中的哪个位置);当创建了一个硬链接,实际上就是创建另一个文件名指向同一个inode
;(可以看成是文件的别名);删除原文件,只是删除了原文件名到inod
e的引用,只会减少inode
的引用计数,实际数据依旧在磁盘上,依旧可以通过硬链接找到inode
,通过inode
找到文件数据;
软链接的底层原理:在文件系统中建立一个新的inode
(索引节点),在这个inode
中记录了软链接文件的元数据信息和指向目标文件的inode
的路径,当应用程序通过软链接访问文件时,操作系统就会解析软链接得到inode
中的目标文件路径,然后去目标文件路径去找到目标文件的inode
,根据目标文件的inode
找到目标文件的数据;如果原文件名被删除,如果原文件inode
的引用不为0,则软链接还能访问到,但是如果原文件inode
的引用为0,原文件就被彻底删除,软链接去访问就会失效;
- 原文件有多个不同的硬链接,删除原文件,软链接可以访问到吗?可以;
- 原文件没有任何硬链接,删除原文件,软链接可以访问到吗?不可以;