操作系统的高频20点

虚拟内存与分段分页

虚拟技术

虚拟技术就是把一个物理实体转换为多个逻辑实体。
主要分为:

  1. 时分复用技术
    多进程与多线程:多个进程能在同一个处理器上并发执行使用了时分复用技术,让每个进程轮流占用处理器,每次只执行一小个时间片并快速切换。
  2. 空分复用技术
    虚拟内存使用了空分复用技术,它将物理内存抽象为地址空间,每个进程都有各自的地址空间。地址空间的页被映射到物理内存,地址空间的页并不需要全部在物理内存中,当使用到个没有在物理内存的页时,执行页面置换算法,将该页置换到内存中。

为什么需要虚拟内存

举个例子,51单片机就没有虚拟内存技术,但是我们能够明显的感觉出来他和平常的计算机明显的区别就是一次只能烧录一个程序进去跑,是可以直接操作系统的资源的。

按照上图所述,所以虚拟内存就是在程序和物理内存中加载一段中间层,通过MMU可以是实现虚拟内存和物理内存之间的映射。

内存分段与内存分页

  1. 为什么分段
    内存是随机访问设备,对于内存来说,不需要从头开始查找,只需要直接给出地址即可,为了解决这个问题,操作系统设计人员提出了让 CPU 使用 段基址 + 段内偏移 的方式来访问任意内存。这样的好处是让程序可以重定位,这也是内存为什么要分段的第一个原因。
    总的来说就是为了方便管理虚拟内存,加快寻址。
  2. 怎么分段
    程序是由若干个逻辑分段组成的,如可由代码分段、数据分段、栈段、堆段组成。不同的段是有不同的属性的,所以就用分段的形式把这些段分离出来。还记得c++的内存模型中的各个分段其实就是内存分段。
  3. 分段的缺点
    1)第一个就是内存碎片的问题。
    2)第二个就是内存交换的效率低的问题。

    从上图中可以看出内存中的200M无法插入进碎片的两个128内存,所以要采用内存交换技术,将256的音乐内存加载到磁盘中,然后重新加载到游戏的512内存的后面。这样就可以解决内存的碎片化问题,但是同样的内存交换的效率比较低。
  4. 为什么分页
    分页是在分段后的一段时间出的是为了解决分段粒度大,因为段需要整段的加载进内存以及整段换出,造成内存碎片大,不易于管理,虽然可以通过将段置换出磁盘再加载的方式减少碎片,但是效率实在太低
    分页管理通过划分物理空间为一块块固定大小的页与之对应,能够将程序分割成一页一页加载进内存,提升了内存的利用率。
  5. 怎么分页
    分页是把整个虚拟和物理内存空间分成一个个页(固定的大小)。这样一个连续并且尺寸固定的内存空间,叫做页。在 Linux 下,每一页的大小为 4KB。通过页表进行映射

    分页会造成内部碎片,也就是页内可能还没有占满内存。
  6. 分页的缺点
    1)一个是内部存储的浪费,内存碎片
    2)一个是页表的大小;虚拟内存假如是4G,那么对于4kb一页,需要4MB的大小
  7. 多级页表的工作机理(局部性原理)
    如果某个一级页表的页表项没有被用到,也就不需要创建这个页表项对应的二级页表了,即可以在需要时才创建二级页表,也就是在有需要的时候再创建二级页表,那么页表占用的内存空间就只有 4KB(一级页表) + 20% * 4MB(二级页表)= 0.804MB远小于4MB。

进程与线程的区别

进程以及其在linux下的操作

  1. 创建进程
#include<stdio.h>
#include <sys/types.h>
#include <unistd.h>
int main(){
    int num=10;
    pid_t pid=fork();
    char *name;
    if(pid>0){
        printf("i am parent,pid=%d\n",getpid());
        printf("%d\n",num+10);
        name="father";
    }
    else if(pid==0){
        printf("i am son,pid=%d,ppid=%d\n",getpid(),getppid());
        printf("%d\n",num+20);
        name="son";
    }
    else{
        perror("fork");
        return -1;
    }
    for(int i=0;i<2;i++){
        printf("%s 执行了\n",name);
    }
    
    return 0;
}

linux下的进程采用fork函数产生,并且可以根据返回的进程号判断是不是子进程以及父进程。
2. 回收子进程资源

1)wait
int st;
int ret = wait(&st);
if(ret == -1) {
    break;
}
2)waitpid
int state;
//阻塞的情况
//int ret=waitpid(-1,&state,0);
//非阻塞的情况:
int ret=waitpid(-1,&state,WNOHANG);

采用wait函数或者waitpid函数来回收资源

线程及其在linux下的操作

  1. 创建线程
#include<stdio.h>
#include<pthread.h>
#include<string.h>
#include<unistd.h>
void* callback (void * arg){
    printf("child thread\n");
    return NULL;
}
int main(){
    pthread_t tid;
    int ret = pthread_create(&tid,NULL,callback,NULL);
    if(ret!=0){
        char* errorstr = strerror(ret);
        printf("error:%s\n",errorstr);
    }
    for(int i=0;i<5;i++){
        printf("%d\n",i);
    }
    sleep(1);
    return 0;
}
  1. 线程的资源回收
 ret = pthread_join(tid,NULL);
if(ret!=0){
    char* ch = strerror(ret);
    printf("error:%s",ch);
}   
printf("回收成功\n"); 
  1. 线程退出
    主要是想让主线程的退出不影响其他的线程的运行,你要是直接使用return 0的话,那么主线程退出之后子线程也会退出。
#include<stdio.h>
#include<pthread.h>
#include<string.h>
#include<unistd.h>
void* callback(void* arg){
    while(1){
        printf("child thread id:%ld\n",pthread_self());
        sleep(1);
    }
}
int main(){
    pthread_t tid;
    int ret = pthread_create(&tid,NULL,callback,NULL);
    if(ret!=0){
        char* ch = strerror(ret);
        printf("%s\n",ch);
    }
    for(int i=0;i<5;i++){
        printf("%d\n",i);
    }
    printf("tid:%ld,main thread id:%ld\n",tid,pthread_self());
    //让主线程退出,不影响子线程
    pthread_exit(NULL);
    return 0;
}
  1. 线程分离
include<pthread.h>
int pthread_detach(pthread_t thread);
-功能:分离一个线程,被分离的线程在终止的时候,会自动释放资源返回给系统
1)不多次分离,会产生不可预料的行为
2)不能去连接一个已经分离的线程

线程与进程的区别

进程 线程
地位 程序分配资源的最小单位 cpu调度的最小单位
包含关系 一个进程包含多个线程 一个线程只属于一个进程
创建代价
切换效率
存在形式 程序运行之间相互独立 同一个进程的线程共享全局变量,栈区独立
通信方式 必须借助外部手段(通道、内存映射、socket......) 同一个进程的线程通过共享区进行通信
安全问题 不存在安全问题 存在线程之间的线程安全与同步问题

守护进程、僵尸进程、孤儿进程

守护进程

守护进程又叫精灵进程,守护进程是一种运行在后台的一种特殊的进程,它独立于控制终端并且周期性的执行某种任务或是等待处理某些发生的时间。守护进程一般在系统启动时开始运行,除非强行终止,否则直到系统关机都保持运行。在linux下,一个守护进程的父进程是init进程,因为它真正的父进程在fork出子进程后就先于子进程exit退出了,所以他是一个由init继承的孤儿进程。

孤儿进程

当一个父进程运行退出之后(没有wait等待子进程回收资源),这个子进程就变成了孤儿进程,并且被进程号为1的init进程所收养,由init进程做它的资源回收等收尾工作。

僵尸进程

当子进程退出之后,父进程还没有结束,子进程的进程描述符依然保存在系统当中,指着进程被称为僵尸进程。如果父进程没有回收子进程就会一直存在(不处理一直是僵尸进程)。

怎么避免僵尸进程

僵尸进程通过kill 9也是无法销毁的,所以一定是通过父进程去处理。

  1. 如果父进程不忙碌,父进程调用wait或者waitpid函数对子进程进行资源回收。
  2. 如果父进程很忙碌不是只是等待子进程的,可以使用信号捕获函数signal函数进程捕获。
  3. 父进程直接采用signal函数忽略sigchild信号,之后子进程会被init进程接管。

守护进程产生的方式

https://www.bilibili.com/video/BV1z5411K7XY/?spm_id_from=333.337.search-card.all.click(产生方式)
https://zhuanlan.zhihu.com/p/266720121(进程组、会话、终端的关系)

  1. nohup命令直接让进程不挂起。直接放入后台运行。
  2. 从fork函数开始按照一定的步骤开始创建。
    1)调用fork函数创建子进程,然后让父进程退出,让子进程变成孤儿进程从而被init接受
    2)调用setsid()创建一个新对话。
    3)再次调用fork()函数然后结束父进程,然后子进程就变成了不是组长的进程,无法打开一个新的终端。
    4)改变工作目录为根目录。
    5)忽略sigchild信号。
  3. 直接使用daemon()函数直接创建守护进程。

进程间的通信方式

进程间的通信方式一般有有名、匿名管道、内存映射、消息队列、信号量、信号、socket

匿名管道(pipe)

只能在有关系的进程之间的通信,采用如下的系统调用:

int pipe(int fd[2])
  1. 需要注意:
    1)管道的通信是单向的,如果需要相互通信,那么需要建立两个匿名管道。
    2)数据的进出是先进先出的。
    3)在fork之前创建管道,然后采用fork函数创建子进程,这个时候父子进程各有一组读写端,要实现通信,那么父子进程中分别保留一端。
  2. 匿名管道通信的读写端关系

    1)写端全部关闭,读端用read()读到的是0。
    2)读端全部关闭,写端会导致程序崩溃,进程收到sigpipe信号。
    3)写端不再写数据,读端会阻塞在那里。
    4)读端不再读取数据,写端写道数据满了之后也会阻塞。
  3. 管道的优缺点
    1)优点:简单易用,数据是有序的。
    2)缺点:单向通信,只能在有关系的进程、管道的大小有限制。

有名管道(fifo)

其他的和匿名管道很像,可以用于没有关系的进程之间的通信。

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/types.h>
#include <sys/stat.h>

int main() {
    const char *fifo_path = "/tmp/myfifo";
    mode_t fifo_mode = 0666; // read/write for owner, group, others

    // Create named pipe
    if (mkfifo(fifo_path, fifo_mode) == -1) {
        perror("mkfifo");
        exit(EXIT_FAILURE);
    }

    printf("Named pipe created successfully at %s\n", fifo_path);

    return 0;
}

内存映射(mmap)


共享内存的机制,就是拿出一块虚拟地址空间来,映射到相同的物理内存中,在c++的内存模型中有一段是共享区,就是可以用来映射到物理内存当中。
优缺点:
1.优点:
1)数据的共享使进程间的数据不用传送,而是直接访问内存,加快了程序的效率速度。
2)它也不像匿名管道那样要求通信的进程有一定的“血缘”关系,只要是系统中的任意进程都可以对共享内存进行读写操作。
3)注意共享内存不用像其他的通信方式有内核态到用户态的拷贝过程。
2. 缺点:
1)共享内存没有提供同步的机制,这使得我们在使用共享内存进行进程间通信时,往往要借助其他的手段(如信号量、互斥量等)来进行进程间的同步工作。(注意进程间也会存在同步和安全问题)

消息队列(msgget)

https://blog.csdn.net/bzhxuexi/article/details/46549735

#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/msg.h>
int msgget(key_t key, int msgflg);
//key 是消息队列的键值,用于标识特定的消息队列。
//msgflg 是控制消息队列创建方式的标志,通常使用 IPC_CREAT 来创建新的消息队列,还可以结合 IPC_EXCL 确保如果消息队列已存在则失败等。
int msgctl(int msqid, int cmd, struct msqid_ds *buf);
//msqid 是消息队列的标识符。
//cmd 是要执行的操作命令,如 IPC_RMID(删除消息队列)等。
//buf 是一个指向 msqid_ds 结构的指针,用于存储或获取消息队列的状态信息
int msgsnd(int msqid, const void *msgp, size_t msgsz, int msgflg);
//msqid 是消息队列的标识符。
//msgp 是指向要发送消息的缓冲区的指针。
//msgsz 是要发送的消息的大小。
//msgflg 是控制发送行为的标志,如 IPC_NOWAIT(非阻塞发送)等。
ssize_t msgrcv(int msqid, void *msgp, size_t msgsz, long msgtyp, int msgflg);
//msqid 是消息队列的标识符。
//msgp 是指向用于接收消息的缓冲区的指针。
//msgsz 是接收消息缓冲区的大小。
//msgtyp 是消息的类型,用于选择接收哪种类型的消息。
//msgflg 是控制接收行为的标志,如 IPC_NOWAIT(非阻塞接收)等。

消息队列的优缺点
1.优点:
1)消息队列提供一种异步的通信方式,可以不耽误线程的其他事情。
2)我们可以通过发送消息来几乎完全避免命名管道的同步和阻塞问题。

  1. 缺点
    1) 通信不及时,会有延迟。
    2)与管道一样,每个数据块有一个最大长度的限制,并且队列的长度一般也会有限制。
    3)消息队列通信过程中,存在用户态与内核态之间的数据拷贝开销。

信号量

信号量其实是一个整型的计数器,主要用于实现进程间的互斥与同步,而不是用于缓存进程间通信的数据。

  1. 信号量设置为0,实现进程间的同步问题,为同步信号量
  2. 信号量设置为1,可以实现进程的互斥问题,为互斥信号量。和互斥锁的意思是一样的。
  3. 信号量设置为>=1,可以表示某个资源的数量上限,来限制访问资源的进程量。
int sem_init(sem_t *sem,int pshared,unsigned int value);
int sem_post(sem_t *sem);  //v操作,加一
int sem_wait(sem_t *sem);  //p操作,减一
int sem_destroy(sem_t *sem); 

信号

对于异常情况下的工作模式,就需要用「信号」的方式来通知进程。

  1. 信号集
    https://blog.csdn.net/m0_60663280/article/details/121461762

1)当进程收到一个SIGINT信号(信号编号为2),首先这个信号会保存在未决信号集合中,此时对应的2号编号的这个位置上置为1,表示处于未决状态;在这个信号需要被处理之前首先要在阻塞信号集中的编号为2的位置上去检查该值是否为1;
2)如果为1,表示SIGNIT信号被当前进程阻塞了,这个信号暂时不被处理,所以未决信号集 上该位置上的值保持为1,表示该信号处于未决状态;
3)如果为0,表示SIGINT信号没有被当前进程阻塞,这个信号需要被处理,内核会对SIGINT信号进行处理(执行默认动作,忽略或者执行用户自定义的信号处理函数),并将未决信号集中编号为2的位置上将1变为0,表示该信号已经处理了,这个时间非常短暂,用户感知不到;
4)当SIGINT信号从阻塞信号集中解除阻塞之后,该信号就会被处理。
注意:
Ctrl+C 产生 SIGINT 信号,表示终止该进程;
Ctrl+Z 产生 SIGTSTP 信号,表示停止该进程,但还未结束;
有两个信号是应用进程无法捕捉和忽略的,即 SIGKILL 和 SEGSTOP,它们用于在任何时候中断或结束某一进程。

socket通信

通信的过程tcp

通信的过程udp

进程的状态转换

五状态的时候

  1. 创建态->就绪态:当一切资源准备就绪,等待被cpu调度的时候就进入了就绪态,存放在就绪队列。
  2. 就绪态<=>运行态:就绪态需要接收到cpu的调度才会进入运行态,运行态时间片用完的时候就会进入就绪态。
  3. 运行态->阻塞态:某些数据还没有准备好,比如等待另外进程的消息,比如执行p操作的时候没有资源等。
  4. 阻塞态->就绪态:当进程要等待的事件完成时,它从阻塞状态变到就绪状态。

七状态的时候


当有很多的进程处于阻塞态或者就绪态的时候,可能会占用大量的物理空间,被阻塞状态的进程占用着物理内存就一种浪费物理内存的行为,通常会把阻塞状态的进程的物理内存空间换出到硬盘。

进程控制块pcb与进程上下文切换、cpu上下文切换

pcb

  1. 什么是pcb
    1)pcb是进程控制块,是用来描述进程的,是进程的唯一标识。
    2)pcb包含进程的哪些信息:进程号pid,用户号,当前进程的状态,切换的时候一些寄存器的值,虚拟地址空间的信息(通过一个结构体指针指向)等。
  2. pcb的组织形式
    以链表的形式组织

    一般以链表的形式创建,有利于频繁的删除和插入操作

进程上下文切换

  1. 什么是上下文
    CPU 寄存器和程序计数是 CPU 在运行任何任务前,所必须依赖的环境,这些环境就叫做 CPU 上下文。
  2. 什么是上下文切换
    先把前一个任务的 CPU 上下文(也就是 CPU 寄存器和程序计数器)保存起来,然后加载新任务的上下文到这些寄存器和程序计数器,最后再跳转到程序计数器所指的新位置,运行新任务。
  3. cpu上下文切换分类
    CPU上下文切换可以分成:进程上下文切换、线程上下文切换和中断上下文切换。
    注意,进程上下文切换属于cpu上下文切换的一种
    进程上下文切换的时候发生的事情:
    1)进程的上下文切换不仅包含了虚拟内存、栈、全局变量等用户空间的资源,还包括了内核堆栈、寄存器等内核空间的资源。会把信息保存在进程的 PCB。
    2)从pcb中将寄存器和计数器交给cpu进行调度。
    线程上下文切换发生的事情:
    1)当两个线程不是属于同一个进程,则切换的过程就跟进程上下文切换一样;
    2)当两个线程是属于同一个进程,因为虚拟内存是共享的,所以在切换时,虚拟内存这些资源就保持不动,只需要切换线程的私有数据、寄存器等不共享的数据;

线程的锁与死锁问题(乐观锁和悲观锁)

互斥锁

  1. 什么是互斥
    当多线程相互竞争操作共享变量时,由于运气不好,即在执行过程中发生了上下文切换,我们得到了错误的结果,事实上,每次运行都可能得到不同的结果,因此输出的结果存在不确定性,这个时候就需要加锁,通常用的最多的就是互斥锁。我们希望这段代码是互斥(mutualexclusion)的,也就说保证一个线程在临界区执行时,其他线程应该被阻止进入临界区,说白了,就是这段代码执行过程中,最多只能出现一个线程。
  2. 互斥锁
    任何想进入临界区的线程,必须先执行加锁操作。若加锁操作顺利通过,则线程可进入临界区;在完成对临界资源的访问后再执行解锁操作,以释放该临界资源。

    保证同一个时间只有一个线程在临界区资源内部。

读写锁

互斥锁的效率在有些场景是比较低的
互斥量,它只有两个状态,要么是加锁状态,要么是不加锁。假如现在一个线程 a 只是想读一个共享变量 i ,因为不确定是否会有线程去写他,所以我们还是要对它进行加锁。但是这时候又一个线程 b 试图读共享变量i ,于是发现被锁住,那么b不得不等到a释放了锁后才能获得锁并读取 i 的值,但是两个读取操作即使是几乎同时发生也并不会像写操作那样造成竞争,应为他们不修改变量的值。所以我们期望如果是多个线程试图读取共享变量的值的话,那么他们应该可以立刻获取而不需要等待前一个线程释放因为读而加的锁。
读写锁解决了上面的问题。他提供了比互斥量跟好的并行性。因为以读模式加锁后当又有多个线程仅仅是试图再以读模式加锁然时,并不会造成这些线程阻塞在等待锁的释放上。
读写锁是为了写线程的安全考虑,在有写线程的时候不能读也不能写,但是没有写线程的时候可以进行多个读线程
读写锁的特点:
1)多个读进程是可以同时读操作的。
2)写者必须互斥(只允许一个写者写,也不能读者写者同时进行)。
3)写者优先于读者(一旦有写者,则后续读者必须等待,唤醒时优先考虑写者)。

自旋锁

利用while一直自旋,一直占有cpu的资源,然后不断查询该互斥资源是不是可用,当这个程序同时有cpu和互斥资源的时候程序继续运行。
一个线程一直占有cpu资源,不会发生线程的切换。

#include <iostream>
#include <thread>
#include <atomic>
 
class MyLock
{
private:
	std::atomic_flag lk = ATOMIC_FLAG_INIT;;//初始化
public:
	MyLock() {};
	void lock();
	void unlock();
};
 
void MyLock::lock() //锁住(适合持有锁时间短的程序) 线程不会被挂起,而是在不断的消耗CPU的时间,不停的试图获取自旋锁。
{
	while (lk.test_and_set()) 
	{
		
	}
};
void MyLock::unlock() 
{
	lk.clear();
};
 

自旋锁和互斥锁的使用场景

  1. 两者的特点
    1)互斥锁在获取锁时会导致线程阻塞,线程会被放入阻塞队列中,并在锁释放时被唤醒。这会引起线程上下文切换的开销。
    2)自旋锁在获取锁时会循环检查锁的状态,直到获取到锁为止,期间线程会一直占用CPU资源,但不会进入阻塞状态,也不会加入到阻塞队列中。
    总的来说
    互斥锁加锁失败后,线程会释放 CPU ,给其他线程,自旋锁加锁失败后,线程会忙等待,直到它拿到锁
  2. 适用的场景
    1)如果锁粒度足够小、持有锁时间足够短(也就是临界区的代码执行时间很短),建议使用自旋锁,反之,使用互斥锁。
    2)如果临界区内含有IO操作(阻塞较长的时间),建议使用互斥锁(临界区内不建议存在IO,一定要存在,使用互斥锁)。
    上下切换的耗时有大佬统计过,大概在几十纳秒到几微秒之间,如果你锁住的代码执行时间比较短,那可能上下文切换的时间都比你锁住的代码执行时间还要长,所以才需要使用自旋锁

悲观锁和乐观锁

  1. 悲观锁:悲观锁做事比较悲观,它认为多线程同时修改共享资源的概率比较高,于是很容易出现冲突,所以访问共享资源前,先要上锁。
  2. 乐观锁:先修改完共享资源,再验证这段时间内有没有发生冲突,如果没有其他线程在修改资源,那么操作完成,如果发现有其他线程已经修改过这个资源,就放弃本次操作。(最好的例子:在线文档)。当真正发生冲突的时候要手动修改。

死锁

  1. 死锁的四大条件
    1)互斥条件。
    多个线程不能同时使用同一个资源。

    2)持有并保持条件。
    线程 A 在等待资源 2 的同时并不会释放自己已经持有的资源 1。

    3)不可剥夺条件。
    资源在线程中不会被其他线程抢走(线程对资源的访问没有优先级)

    4)循环等待条件。
    两个线程获取资源的顺序构成了环形链,不是循环等待的条件。

解决死锁问题

  1. 资源的有序分配,使得AB线程获取ab资源的顺序是一样的。
  2. 超时释放资源,比如A线程获取了a资源,超时之后释放资源a。
  3. 设置不同的优先级。
  4. 银行家算法(银行家算法通过预先分析和模拟资源分配的过程来确保系统在分配资源时不会陷入死锁状态。)
  5. 鸵鸟算法

死锁排查

pstack + gdb

内核级线程、用户级线程和轻量级线程

用户线程和内核线程的对应关系。

  1. 多对一
  2. 一对一

    注意:
    1)用户线程的整个线程管理和调度,操作系统是不直接参与的,而是由用户级线程库函数来完成线程的管理,包括线程的创建、终止、同步和调度等。
    2)用户级线程的调度不会有用户态到内核态的切换。
    3)当一个线程开始运行后,除非它主动地交出 CPU 的使用权,否则它所在的进程当中的其他线程无法运行,因为用户态的线程没法打断当前运行中的线程,它没有这个特权,只有操作系统才有,但是用户线程不是由操作系统管理的。

轻量级进程

轻量级进程(Light-weight process,LWP)是内核支持的用户线程,一个进程可有一个或多个 LWP,每个 LWP 是跟内核线程一对一映射的,也就是 LWP 都是由一个内核线程支持,而且 LWP 是由内核管理并像普通进程一样被调度。

线程先对应到LWP,LWP再对应到内核级线程进行cpu调度

同步异步,阻塞与非阻塞

https://blog.csdn.net/wuweiwuju___/article/details/105883115
https://blog.csdn.net/wangpaiblog/article/details/117236684

  1. 概念:
    1)同步:执行完这个代码之后我需要立即得到结果,不然会卡住。
    2)异步:执行完这个代码之后我不用立即得到结果,等待通知,继续后面的事情。
    3)阻塞:执行这个代码中没有获取完整的服务的时候,挂起不干事情。
    4)非阻塞:当执行此函数时,立即获得瞬时的结果,然后马上继续执行当前的代码块。如果获得的瞬时资源不是完整的资源,之后周期性发送类似的请求,直至获得完整的资源,这称为非阻塞。
    总结:可以看出同步和异步的概念相当于结果是怎么通知我的(立即要通知,还是等待通知),阻塞与非阻塞相当于过程是要不要完整服务(不完整的时候我就等,还是我不断的问你完不完整)
  2. 同步阻塞、同步非阻塞、异步阻塞、异步非阻塞
    以小王下载文件为例,对上述概念做以理解:
    1)同步阻塞:小王一直盯着下载进度条,到100%的时候就完成。
    同步:等待下载完成通知;
    阻塞:等待下载完成通知过程中,不能做其它任务处理;
    2)同步非阻塞:小王提交下载任务后就去干别的,每过一段时间就去瞄一眼进度条,看是否完成。
    同步:等待下载完成通知;
    非阻塞:等待下载完成通知过程中,去看动漫去了,只是时不时会瞄一眼进度条;【小王必须在两个任务操作中来回切换,关注下载进度】
    3)异步阻塞:小王打开了下载完成后的提示音,下载完成会通知他,不过在这期间,他一直会等待这个声音。
    异步:下载完成后的提示音;
    阻塞:等待提示音的过程中,不能做其它任务处理;
    4)异步非阻塞小王打开了下载完成后的提示音,下载完成会通知他,不过在这期间,他可以去干其它事情。
    异步:下载完成后的提示音;
    非阻塞:等待提示音的过程中,去干其它事情了,只需要接收提示音的通知;

用read函数来解释这些概念(注意cpu的作用)

  1. 一般的read就是同步阻塞的(线程挂起,cpu去干其他事情)
  2. 如果设置fd为非阻塞的,那么同步read就是立即返回,同时用while()不断查询read的返回值(主动的查询这个状态,一直占用cpu资源)。
  3. 如果是aio_read函数(异步函数非阻塞),则通过回调函数来通知程序执行后续操作(这个步骤不会一直占用cpu)
  4. 如果是异步阻塞的话,也是会卡住的(线程挂起)
    注意同步函数默认是阻塞的,异步函数默认是非阻塞的

io复用模型

多线程和多进程模型

  1. 多进程模型

    在父进程中实现监听的过程,在子进程中实现每一个用户的读写操作。这样开销会很大,在面对高并发的情况下,资源占用情况严重。
  2. 多线程模型

    采用线程池、生产消费者模型来实现多线程的实现,同样的要实现高并发也是不现实的,因为线程的开辟虽然要比进程开销小,但是要维护 1 万个进程/线程,操作系统就算死扛也是扛不住的。

一个进程/线程中实现多个socket连接处理(i/o多路复用)

https://www.cnblogs.com/flashsun/p/14591563.html
把一个进程看成cpu,相当于cpu的高并发,一个进程处理多个socket连接就是i/o多路复用。

  1. select
    https://p6-tt-ipv6.byteimg.com/img/pgc-image/567fa1ffacc84953ba48cb8074acce03~tplv-obj.image
    1)将监听到的文件描述符加入文件描述符集合。
    2)然后调用select将集合内容拷贝到内核中,内核负责遍历查看哪个连接有读写事件做标记。
    3)然后整体又拷贝进用户态,用户态又遍历查看哪些fd有读写消息。
    缺点:
    • 拷贝了两次,并且遍历了两次,对于并发量高的的场景并不适合
    • 在 Linux 系统中,由内核中的FD_SETSIZE限制, 默认最大值为 1024,只能监听 0~1023 的文件描述符。
  2. poll
    poll 用动态数组,以链表形式来组织,突破了 select 的文件描述符个数限制,当然还会受到系统文件描述符限制。
    缺点:
    poll 和 select 并没有太大的本质区别,都是使用「线性结构」存储进程关注的 Socket 集合,因此都需要遍历文件描述符集合来找到可读或可写的 Socket,时间复杂度为 O(n),而且也需要在用户态与内核态之间拷贝文件描述符集合,这种方式随着并发数上来,性能的损耗会呈指数级增长。
  3. epoll

1)epoll 在内核里使用红黑树来跟踪进程所有待检测的文件描述字,把需要监控的 socket 通过 epoll_ctl() 函数加入内核中的红黑树里,红黑树是个高效的数据结构,增删改一般时间复杂度是 O(logn)。
2)epoll 使用事件驱动的机制,内核里维护了一个链表来记录就绪事件,当某个 socket 有事件发生时,通过回调函数内核会将其加入到这个就绪事件列表中,当用户调用 epoll_wait() 函数时,只会返回有事件发生的文件描述符的个数,不需要像 select/poll 那样轮询扫描整个 socket 集合,大大提高了检测的效率。
3)边缘触发模式(ET),事件只会触发一次,需要将数据一次性读完。读数据与定义的接受数据的数组大小有关

/*
    #include<sys/epoll.h>
    int epoll_creat(int size);
        -参数:
            -size:目前是没有意义的,但是要大于0
        -返回值:
            --成功:返回内核中实例的文件描述符
            -失败:返回-1,并且设置争取的errno号

    int epoll_ctl(int epfd,int op,int fd,struct epoll_event* event);
        -参数:
            -epfd:epoll实例对应的文件描述符
            -op:   要进行的操作
                -EPOLL_CTL_ADD:添加
                -EPOLL_CTL_MOD:修改
                -EPOLL_CTL_DEL:删除
            -fd:要检测的描述符
            -event:检测文件描述符是什么事件
                -struct epoll_event{
                    uint32_t events;
                    epoll_data_t data;//用户数据信息
                };
                常见的检测事件:
                EPOLLIN,EPOLLOUT,EPOLLOUTERR

    int epoll_wait(int epfd,struct epoll_event* events,int maxevent,int timeout);
        -参数:
            -epfd:epoll实例对应的文件描述符
            -events:传出参数,保存了发送文件描述符的信息
            -maxevent:第二个结构体数组的大小
            -timeout:阻塞时间
                -0:不阻塞
                --1:阻塞,直到检测到fd变化,解除阻塞
                ->0:阻塞时长(毫秒)
        -返回值:
            -成功:返回发生改变的文件描述符数量
            -0:没有改变的fd
            --1:失败
*/
//水平出发的服务端会发生很多次检测
#include<sys/epoll.h>
#include<arpa/inet.h>
#include<stdlib.h>
#include<string.h>
#include<unistd.h>
#include<stdio.h>
#include<fcntl.h>
#include<errno.h>
#include<signal.h>
int main(){

    int lfd=socket(AF_INET,SOCK_STREAM,0);
    struct sockaddr_in saddr;
    saddr.sin_family=AF_INET;
    saddr.sin_port=htons(9999);
    inet_pton(AF_INET,"192.168.16.143",&saddr.sin_addr.s_addr);
    //绑定
    bind(lfd,(struct sockaddr*)&saddr,sizeof(saddr));
    //监听
    listen(lfd,8);
    //用epoll_create创建一个epoll实例
    int epfd = epoll_create(100);
    //把监听文件描述符lfd加入到epoll实例中
    struct epoll_event epevent;//需要监听的动作
    epevent.events=EPOLLIN;
    epevent.data.fd=lfd;//data是一个联合体,也就是一次只有一个有用的
    epoll_ctl(epfd,EPOLL_CTL_ADD,lfd,&epevent);//实际上是加到rbr中(也就是那个红黑树)

    //用于接受epoll_wait检测后的数据,也就是哪些文件描述符有动作
    struct epoll_event epevents[1024];//一次最多检测1024个文件描述符有变化

    while(1){
        //检测哪些文件描述符发生变化
        int ret = epoll_wait(epfd,epevents,1024,-1);
        if(ret==-1){
            perror("epoll_wait");
            exit(-1);
        }
        printf("一共有%d个客户端连接\n",ret);
        //遍历这些确定有信息的数组元素,但是对于监听文件描述符和
        //客户端对应的文件描述符是不一样的操作,所以要用if判断
        for(int i=0;i<ret;i++){
            //如果这个是rbr红黑树里面的监听文件名描述符,那么
            //就用accept创建新的文件描述符,并且加入到rbr红黑树里面
            if(epevents[i].data.fd==lfd){
                //第一次循环的话肯定是监听到的lfd有连接
                struct sockaddr_in cliaddr;
                int len=sizeof(cliaddr);
                int cfd = accept(lfd,(struct sockaddr*)&cliaddr,&len);
                //因为read非阻塞完全取决于文件描述符的阻塞情况,所以这里设置fd为非阻塞
                //默认是阻塞模式,所以需要重新设置
                int flag = fcntl(cfd,F_GETFL);
                flag = flag| O_NONBLOCK;
                fcntl(cfd,F_SETFL,flag);
                //将监听到的文件描述符添加到内核中的epoll实例中去
                //加入到rbr红黑树的时候采用边沿触发的模式进行通知
                epevent.events=EPOLLIN | EPOLLET;//除了lfd之外的客户端对应的文件描述符都要进行边沿触发模式
                epevent.data.fd=cfd;
                epoll_ctl(epfd,EPOLL_CTL_ADD,cfd,&epevent);
            }else{//要是是客户端的文件描述符的话那么通信
                //如果这个逻辑是输出的话那么就不管它
                if(epevents[i].events&EPOLLOUT){
                    continue;
                }
               
                char buf[5];
                int len=0;
                //非阻塞,而且一次读完
                while((len=read(epevents[i].data.fd,buf,sizeof(buf)))>0){
                    //打印数据
                    printf("recv data:%s\n",buf);
                    write(epevents[i].data.fd,buf,len);
                }
                if(len==0){
                    printf("客户端关闭\n");
                }else if(len==-1){
                    if(errno == EAGAIN){//数据数组用完了
                        printf("data over");
                    }
                    perror("read");
                    exit(-1);
                }
            }
        }
    }
    close(lfd);
    close(epfd);
    return 0;
}

4)水平出发模式(LT),没有读完会不断触发。

/*
    #include<sys/epoll.h>
    int epoll_creat(int size);
        -参数:
            -size:目前是没有意义的,但是要大于0
        -返回值:
            --成功:返回内核中实例的文件描述符
            -失败:返回-1,并且设置争取的errno号

    int epoll_ctl(int epfd,int op,int fd,struct epoll_event* event);
        -参数:
            -epfd:epoll实例对应的文件描述符
            -op:   要进行的操作
                -EPOLL_CTL_ADD:添加
                -EPOLL_CTL_MOD:修改
                -EPOLL_CTL_DEL:删除
            -fd:要检测的描述符
            -event:检测文件描述符是什么事件
                -struct epoll_event{
                    uint32_t events;
                    epoll_data_t data;//用户数据信息
                };
                常见的检测事件:
                EPOLLIN,EPOLLOUT,EPOLLOUTERR

    int epoll_wait(int epfd,struct epoll_event* events,int maxevent,int timeout);
        -参数:
            -epfd:epoll实例对应的文件描述符
            -events:传出参数,保存了发送文件描述符的信息
            -maxevent:第二个结构体数组的大小
            -timeout:阻塞时间
                -0:不阻塞
                --1:阻塞,直到检测到fd变化,解除阻塞
                ->0:阻塞时长(毫秒)
        -返回值:
            -成功:返回发生改变的文件描述符数量
            -0:没有改变的fd
            --1:失败
*/
//水平出发的服务端会发生很多次检测
#include<sys/epoll.h>
#include<arpa/inet.h>
#include<stdlib.h>
#include<string.h>
#include<unistd.h>
#include<stdio.h>
int main(){
    int lfd=socket(AF_INET,SOCK_STREAM,0);
    struct sockaddr_in saddr;
    saddr.sin_family=AF_INET;
    saddr.sin_port=htons(9999);
    inet_pton(AF_INET,"192.168.16.161",&saddr.sin_addr.s_addr);
    //绑定
    bind(lfd,(struct sockaddr*)&saddr,sizeof(saddr));
    //监听
    listen(lfd,8);
    //用epoll_create创建一个epoll实例
    int epfd = epoll_create(100);
    //把监听文件描述符lfd加入到epoll实例中
    struct epoll_event epevent;//需要监听的动作
    epevent.events=EPOLLIN;
    epevent.data.fd=lfd;//data是一个联合体,也就是一次只有一个有用的
    epoll_ctl(epfd,EPOLL_CTL_ADD,lfd,&epevent);//实际上是加到rbr中(也就是那个红黑树)

    //用于接受epoll_wait检测后的数据,也就是哪些文件描述符有动作
    struct epoll_event epevents[1024];//一次最多检测1024个文件描述符有变化

    while(1){
        //检测哪些文件描述符发生变化
        int ret = epoll_wait(epfd,epevents,1024,-1);
        if(ret==-1){
            perror("epoll_wait");
            exit(-1);
        }
        printf("一共有%d个客户端连接\n",ret);
        //遍历这些确定有信息的数组元素,但是对于监听文件描述符和
        //客户端对应的文件描述符是不一样的操作,所以要用if判断
        for(int i=0;i<ret;i++){
            //如果这个是rbr红黑树里面的监听文件名描述符,那么
            //就用accept创建新的文件描述符,并且加入到rbr红黑树里面
            if(epevents[i].data.fd==lfd){
                //第一次循环的话肯定是监听到的lfd有连接
                struct sockaddr_in cliaddr;
                int len=sizeof(cliaddr);
                int cfd = accept(lfd,(struct sockaddr*)&cliaddr,&len);

                //将监听到的文件描述符添加到内核中的epoll实例中去
                epevent.events=EPOLLIN;
                epevent.data.fd=cfd;
                epoll_ctl(epfd,EPOLL_CTL_ADD,cfd,&epevent);
            }else{//要是是客户端的文件描述符的话那么通信
                //如果这个逻辑是输出的话那么就不管它
                if(epevents[i].events&EPOLLOUT){
                    continue;
                }
                char buf[5]={0};//减少每次接受数据的大小,看看是不是会再次通知
                int len=read(epevents[i].data.fd,buf,sizeof(buf));
                if(len == -1){
                    perror("read");
                    exit(-1);
                }else if(len == 0){
                    printf("客户端断开\n");
                    epoll_ctl(epfd,EPOLL_CTL_DEL,epevents[i].data.fd,NULL);
                    close(epevents[i].data.fd);
                }else if(len>0){
                    printf("read buf = %s\n",buf);
                    write(epevents[i].data.fd,buf,strlen(buf));
                    memset(buf,'\0',1024);
                }
            }
        }


    }
    close(lfd);
    close(epfd);
    return 0;
}
//也就是设置了数组epevents对应的元素fd水平触发模式之后,fd缓冲区的数据要是有的话就一直通知。
  1. 注意的点:
    1)默认的是LT模式的,也就是不写的话,lfd就是LT模式,它会不断触发然后直到操作了为止。
    2)ET模式只支持非阻塞的fd,所以采用ET模式下的cfd就需要设置为非阻塞。

Reactor和Proactor模型

Reactor模型

1.什么是Reactor模型
Reactor 翻译过来的意思是「反应堆」,反应指的是「对事件反应」,也就是来了一个事件,Reactor 就有相对应的反应。它是一个非阻塞同步网络模式
2. 分类
1)单 Reactor 单进程 / 线程;
只有一个Reactor反应堆

2)单 Reactor 多线程 / 进程;
缺点:因为一个 Reactor 对象承担所有事件的监听和响应,而且只在主线程中运行,在面对瞬间高并发的场景时,容易成为性能的瓶颈的地方。

3)多 Reactor 多进程 / 线程;
分为子反应堆和主反应堆,主线程和子线程分工明确,主线程只负责接收新连接,子线程负责完成后续的业务处理。主线程和子线程的交互很简单,主线程只需要把新连接传给子线程,子线程无须返回数据,直接就可以在子线程将处理结果发送给客户端。

Proactor

Proactor采用了异步 I/O 技术,所以被称为异步网络模型。

两种模式的对比

  1. Reactor 是非阻塞同步网络模式,感知的是就绪可读写事件。在每次感知到有事件发生(比如可读就绪事件)后,就需要应用进程主动调用 read 方法来完成数据的读取,也就是要应用进程主动将 socket 接收缓存中的数据读到应用进程内存中,这个过程是同步的,读取完数据后应用进程才能处理数据。
  2. Proactor 是异步网络模式, 感知的是已完成的读写事件。在发起异步读写请求时,需要传入数据缓冲区的地址(用来存放结果数据)等信息,这样系统内核才可以自动帮我们把数据的读写工作完成,这里的读写工作全程由操作系统来做,并不需要像 Reactor 那样还需要应用进程主动发起 read/write 来读写数据,操作系统完成读写工作后,就会通知应用进程直接处理数据。
    总结:Reactor 模式是基于「待完成」的 I/O 事件,而 Proactor 模式则是基于「已完成」的 I/O 事件

协程

协程(coroutine)是一种程序运行的方式,即在单线程里多个函数并发地执行。
协程与线程的区别:

  1. 由于协程的特性, 适合执行大量的I/O 密集型任务, 而线程在这方面弱于协程。
  2. 协程涉及到函数的切换, 多线程涉及到线程的切换, 所以都有执行上下文, 但是协程不是被操作系统内核所管理, 而完全是由程序所控制(也就是在用户态执行), 这样带来的好处就是性能得到了很大的提升, 不会像线程那样需要在内核态进行上下文切换来消耗资源,因此协程的开销远远小于线程的开销。
  3. 由于协程在同一个线程中, 所以不需要用来守卫临界区段的同步性原语(primitive)比如互斥锁、信号量等,并且不需要来自操作系统的支持。
  4. 在协程之间的切换不需要涉及任何系统调用。

进程调度算法

先来先服务调度


每次从就绪队列选择最先进入队列的进程,然后一直运行,直到进程退出或被阻塞,才会继续从队列中选择第一个进程接着运行。
缺点:当一个长作业先运行了,那么后面的短作业等待的时间就会很长

最短作业优先调度

它会优先选择运行时间最短的进程来运行,这有助于提高系统的吞吐量。

缺点:
一个长作业在就绪队列等待运行,而这个就绪队列有非常多的短作业,那么就会使得长作业不断延后

高响应比优先调度

高响应比优先 (Highest Response Ratio Next, HRRN)调度算法主要是权衡了短作业和长作业。通过计算响应比优先级来调度:

时间片轮转调度


缺点:

  1. 如果时间片设得太短会导致过多的进程上下文切换,降低了 CPU 效率;
  2. 如果设得太长又可能引起对短作业进程的响应时间变长。

最高优先级调度

  1. 从就绪队列中选择最高优先级的进程进行运行,这称为最高优先级调度。
    1) 静态优先级:创建进程时候,就已经确定了优先级了,然后整个运行时间优先级都不会变化;
    2)动态优先级:根据进程的动态变化调整优先级,比如如果进程运行时间增加,则降低其优先级,如果进程等待时间(就绪队列的等待时间)增加,则升高其优先级,也就是随着时间的推移增加等待进程的优先级
  2. 该算法也有两种处理优先级高的方法,非抢占式和抢占式:
    1)非抢占式:当就绪队列中出现优先级高的进程,运行完当前进程,再选择优先级高的进程。
    2)抢占式:当就绪队列中出现优先级高的进程,当前进程挂起,调度优先级高的进程运行。

多级反馈队列调度算法

  1. 将新的进程加入第一个队列的队尾,然后经过cpu调度之后若没有执行完加入第二队列队尾。
  2. 当较高级的队列不为空的,cpu不能调度其他的队列。可以兼顾到长短作业,同时具有较好的响应时间。

文件系统

文件系统的基本组成

Linux 文件系统会为每个文件分配两个数据结构:索引节点(index node)和目录项(directory entry),它们主要用来记录文件的元信息目录层次结构

  1. 索引节点:记录了修改时间、数据在磁盘的位置等等。索引节点是文件的唯一标识,同样占用磁盘空间。
  2. 目录项:是由内核维护的一个数据结构,不存放于磁盘,而是缓存在内存。记录文件的名字、索引节点指针以及与其他目录项的层级关联关系。多个目录项关联起来,就会形成目录结构。

目录是个文件,持久化存储在磁盘,而目录项是内核一个数据结构,缓存在内存

虚拟文件系统

文件系统的种类众多,而操作系统希望对用户提供一个统一的接口,于是在用户层与文件系统层引入了中间层,这个中间层就称为虚拟文件系统。
在 Linux 文件系统中,用户空间、系统调用、虚拟文件系统、缓存、文件系统以及存储之间的关系如下图:

文件的存储

  1. 连续的存放方式
    1)优点:文件存放在磁盘连续的物理空间中。这种模式下,文件的数据都是紧密相连,读写效率很高,因为一次磁盘寻道就可以读出整个文件。
    2)缺点:
    磁盘空间碎片文件长度不易扩展的缺陷
  2. 链式的存放方式
    2.1. 隐式链接:
    实现的方式是文件头要包含第一块最后一块的位置,并且每个数据块里面留出一个指针空间,用来存放下一个数据块的位置。
    缺点:
    缺点在于无法直接访问数据块,只能通过指针顺序访问文件,以及数据块指针消耗了一定的存储空间。
    2.2. 显式链接:
    把用于链接文件各数据块的指针,显式地存放在内存的一张链接表中,该表在整个磁盘仅设置一张,每个表项中存放链接指针,指向下一个数据块号。
    优点:
    仅显著地提高了检索速度,而且大大减少了访问磁盘的次数。
    缺点:
    但也正是整个表都存放在内存中的关系,它的主要的缺点是不适用于大磁盘。

内存页面置换算法

进程、进程组、作业、会话、终端的关系

进程组

  1. 每个进程除了有一个进程ID之外,还属于一个进程组。
  2. 每个进程都有父进程,而所有的进程以init进程为根,形成一个树状结构。
  3. 通常是“辈分”最高的那个,通常该进程的ID也就作为进程组的ID。
  4. 只要在某个进程组中一个进程存在,则该进程组就存在,这与其组长进程是否终止无关。(组长终止则会换一个组长)
    5.进程必定属于一个进程组,也只能属于一个进程组

作业

  1. Shell分前后台来控制的不是进程而是作业或者进程组。一个前台作业可以由多个进程组成,一个后台也可以由多个进程组成
    2.Shell可以运行一个前台作业和任意多个后台作业。
    3.作业与进程组的区别:如果作业中的某个进程又创建了子进程,则子进程不属于作业。

会话

  1. 一个会话可包含多个进程组,但只能有一个前台进程组。
  2. 建立与终端连接的会话首进程被称为控制进程。
  3. 一次登录形成一个会话。一个会话可包含多个进程组,但只能有一个前台进程组

终端

  1. 与控制终端建立连接的会话领头进程称为控制进程
  2. 一个会话只能有一个控制终端 。

总结

  1. 进程属于一个进程组,进程组属于一个会话,会话可能有也可能没有控制终端。
  2. 用户通过控制终端,可以向该控制终端所控制的会话中的进程发送键盘信号。
  3. 同一会话中只能有一个前台进程组,属于前台进程组的进程可从控制终端获得输入,而其他进程均是后台进程,可能分属于不同的后台进程组。
  4. 当我们打开多个终端窗口时,实际上就创建了多个终端会话。每个会话都会有自己的前台工作和后台工作。

系统调用

https://zhuanlan.zhihu.com/p/505419261

  1. 触发中断:系统调用是通过软中断来实现的(所谓的软中断式程序员设计的一条中断指令)。执行int指令前将系统调用号放入eax寄存器中,执行int 0x80指令。
  2. 切换堆栈:从用户栈切换到内核栈。
  3. 中断处理程序:
    在int指令切换内核栈之后,程序就切换到了中断向量表中的0x80号中断处理程序。Linux中0x80向量对应的中断处理程序是system_call。

生产消费者问题(互斥与同步)、哲学家就餐问题、读写者问题

生产消费者问题

https://blog.csdn.net/weixin_45670785/article/details/126300859

  1. 问题描述与分析
    1)生产者在生成数据后,放在一个缓冲区中;
    2)消费者从缓冲区取出数据处理;
    3)任何时刻,只能有一个生产者或消费者可以访问缓冲区;
    4)分析:

    • 任何时刻只能有一个线程操作缓冲区,所以需要互斥
    • 消费者必须等待生产者生产了才能消费,所以需要同步
  2. 问题解决:
    因为牵涉互斥与同步问题,所以要用到互斥锁+信号量,或者互斥锁+条件变量
    1)互斥锁与条件变量
    条件变量的虚假唤醒

#include <thread>
#include <queue>
#include <iostream>
#include <condition_variable>
#include <mutex>

using namespace std;
//没有队列长度限制的实现只用一个条件变量或者信号量
template <typename T>
class Queue
{
public:
    Queue(int maxn = 20) : max_size(maxn) {}

    void Push(const T &val)
    {
        unique_lock<mutex> locker(mtx);//和lock_guard相比更加灵活,可以半途手动释放,所以搭配信号量来用
        while (qu.size() >= max_size)//虚假唤醒的判断,如果虚假唤醒就停在这里。
        {
            cond_full.wait(locker);//超过了队列的大小所有的生产者应该停止生产,等待条件变量的通知(释放cpu,释放锁,并且阻塞)
        }
        qu.push(val);
        cond_empty.notify_all();
    }

    T Pop()
    {
        unique_lock<mutex> locker(mtx);
        while (qu.empty())
        {
            cond_empty.wait(locker);
        }
        T val = qu.front();
        qu.pop();
        cond_full.notify_all();
        return val;
    }

private:
    queue<T> qu;
    mutex mtx;
    condition_variable cond_empty;
    condition_variable cond_full;
    int max_size;
};

void producer(Queue<int> *q)
{
    for (int i = 0; i < 10; ++i)
    {
        q->Push(i);
        cout << "Pushed: " << i << endl;
        // Simulate varying production time
        this_thread::sleep_for(chrono::milliseconds(10));
    }
}

void consumer(Queue<int> *q)
{
    for (int i = 0; i < 10; ++i) // Consume 1000 items
    {
        int val = q->Pop();
        cout << "Popped: " << val << endl;
        // Simulate varying consumption time
        this_thread::sleep_for(chrono::milliseconds(20));
    }
}

int main()
{
    Queue<int> q(20);

    thread producer_thread(producer, &q);
    thread consumer_thread(consumer, &q);

    producer_thread.join();
    consumer_thread.join();

    return 0;
}

2)互斥锁与信号量
https://blog.csdn.net/weixin_44205087/article/details/119742972

哲学家进餐问题

  1. 问题描述与分析
    1)5 个老大哥哲学家,闲着没事做,围绕着一张圆桌吃面;
    2)巧就巧在,这个桌子只有 5 支叉子,每两个哲学家之间放一支叉子;
    3)哲学家围在一起先思考,思考中途饿了就会想进餐;
    4)奇葩的是,这些哲学家要两支叉子才愿意吃面,也就是需要拿到左右两边的叉子才进餐;
    5)吃完后,会把两支叉子放回原处,继续思考;
    6)分析:
    • 叉子不能同时拿,所以牵涉到互斥锁
    • 要让科学家尽量多的同时可以吃饭,所以牵涉到数学方法或者策略,即并发量问题。
  2. 解决方法
    1)所有科学家去拿左边的叉子:可能死锁。
    2)拿两边的叉子的时候上锁:并发量不高。
    3)按照偶数的科学家先拿左边后拿右边,奇数的先拿右边再拿左边:可以同时两个人进餐,不会发生死锁(因为获取的顺序不同)。
    4)记录每个科学家的状态,通过查询状态来看能不能拿叉子:也可以很好的解决问题。

读写者问题

同读写锁。

posted @ 2024-06-26 18:24  铜锣湾陈昊男  阅读(51)  评论(0)    收藏  举报