计算机面试重难点 之 操作系统

本文深入探讨了操作系统中的关键概念,包括用户态与核心态的切换、进程与线程的区别、进程状态转换、调度算法、死锁的条件与处理策略、进程间通信方式以及内存管理方法。详细阐述了各种调度算法的工作原理,如先来先服务、短作业优先等,并介绍了死锁的预防和避免策略。此外,还讲解了管道、消息队列、共享内存和信号量等进程间通信机制,以及分页、分段和段页式存储管理。最后,讨论了虚拟内存和页面淘汰算法,如最佳、先进先出和最近最久未使用等。
摘要由CSDN通过智能技术生成

用户态与核心态?哪些操作会导致用户态切换到核心态?

用户态与核心态是指操作系统两种运行级别。操作系统核心的功能与服务(进程)运行在内核态,例如:进程管理、内存管理、设备管理、文件管理等;用户进程只能访问用户代码和数据,当用户进程要访问内核态级别的功能与服务(进程)时,需要通过系统调用来实现。

通常,系统调用异常外设中断会导致用户态到内核态的切换:

系统调用:这是用户态进程主动要求切换到内核态的一种方式用户态进程通过系统调用申请使用操作系统提供的服务程序完成工作。例如Linux系统中常见的forkopenreadwriteclose等系统调用。

异常:当CPU在执行运行在用户态下的程序时,发生了某些事先不可知的异常,这时会触发由当前运行进程切换到处理此异常的内核相关程序中,也就转到了内核态,比如缺页异常。

外设中断:当外围设备完成用户请求的操作后,会向CPU发出相应的中断信号,这时CPU会暂停执行下一条即将要执行的指令转而去执行与中断信号对应的处理程序,如果先前执行的指令是用户态下的程序,那么这个转换的过程自然也就发生了由用户态到内核态的切换。比如硬盘读写操作完成,系统会切换到硬盘读写的中断处理程序中执行后续操作等。

进程与线程的区别?

简单来说,进程可以理解为代码在计算机上的一次完整的执行过程,一个进程通常包含多个线程,线程的出现是为了进一步提高程序并发执行的程度。

就资源分配来说: 进程是资源分配的基本单位;线程不拥有资源,但可以共享进程资源。就CPU调度来说: 线程是CPU调度的基本单位,同一进程中的线程切换,不会引起进程切换;不同进程中的线程切换,会引起进程切换。就系统开销来说: 进程创建和销毁时,系统都要单独为它分配和回收资源,开销远大于线程的创建和销毁;进程的上下文切换需要保存更多的信息,线程(同一进程中)的上下文切换只需要保存线程的私有数据:栈、程序计数器(PC)等,系统开销更小。通信方式: 进程拥有各自独立的地址空间,进程间的通信需要依靠IPC机制;线程由于共享进程资源,因此可以通过访问共享数据进行通信,通讯非常方便,但需要解决好共享全局变量的同步与互斥。

进程的基本状态?

进程有五种基本状态:创建状态、就绪状态、执行状态、阻塞状态、终止状态。状态的转换关系如下:

创建状态:为进程创建PCB(Process Control Block,进程控制块。它是操作系统为了管理进程设置的一个专门的数据结构)并分配除CPU时间片以外的必要资源;

就绪状态:当进程已分配到除CPU以外的所有必要资源后,只要再获得CPU,便可立即执行,这种状态称为就绪状态;

执行状态:进程处于就绪状态被调度后,进程进入执行状态;

阻塞状态:正在执行的进程由于某些事件(如:I/O请求)而暂时交出CPU资源,无法继续运行,进程受到阻塞,进入阻塞状态;

终止状态:进程自然结束,或出现错误,被系统终释放资源后进入终止状态,无法再执行。

进程调度算法有哪些?

先到先服务 (FCFS) 调度算法 : 从就绪队列中选择⼀个最先进⼊该队列的进程为其分配 CPU资源并使之运行。有利于长作业,但不利于短作业,短作业会因为前面的长作业长时间执行而迟迟得不到调度。同时,I/O密集型作业会因为多次被阻塞而多次重新排队。

短作业优先 (SJF) 调度算法: 从就绪队列中选出⼀个估计运⾏时间最短的进程为其分配 CPU资源并使之运行。不利于长作业,如果新的短作业不断到达就绪队列,长作业会一直不能被调度。

优先级调度调度算法: 为每个进程设置优先级,⾸先执⾏⾼优先级的进程,相同优先级的进程按照先来先服务的策略调度。

时间⽚轮转调度算法 : 按照先来先服务的策略依次调度进程,每个进程执行固定时间片后被重新放入队尾。

多级反馈队列调度算法 :前⾯介绍的⼏种进程调度算法都有⼀定的局限性,⽽该算法可以兼顾高优先级以及长、短作业。该算法由高到低设置多个优先级不同的队列,通常优先级越高的队列设置的时间片越小。该算法按照时间片轮转算法先调度高优先级队列里的进程,若高优先级队列中已没有需要调度的进程,则依次调度次优先级队列中的进程。高优先级队列中的进程如果被调度执行一个时间片的大小后仍没有完成,则依次放入次优先级队列中。如果低优先级队列中的进程被调度执行时高优先级队列中又有新的进程到达,那么执行完当前时间片后,CPU会马上分配给新到达高优先队列中的进程。

操作系统的僵死进程和孤儿进程的区别?

Linux系统中,子进程由父进程创建,子进程退出后虽然会释放部分资源,但进程描述等资源并没有被释放,需要父进程调用wait(会阻塞父进程)waitpid(可以为非阻塞)来释放,可以方便父进程拿到子进程的终止状态。

僵尸进程:当进程退出之后,他的父进程没有通过调用waitwaitpid回收他的资源,该进程会继续停留在系统的进程表中,占用内核资源,这样的进程就是僵尸进程。通过ps命令显示的僵尸进程状态为Z(zombie)。大量僵尸进程没被回收会导致资源浪费,更致命的是他们会占用大量进程号,导致系统无法给新进程分配进程号。

孤儿进程:进程结束后,它的一个或多个子进程还在运行,那么这些子进程就是孤儿进程。孤儿进程如果没有被自己所在的进程组收养,就会作为init(PID = 1)进程的子进程,他的的资源会由init进程回收。

僵尸进程在其父进程退出后转为孤儿进程。

如何解决僵尸进程:

  1. 通过kill -9 杀掉其父进程,僵死进程就可以转为孤儿进程,进而被init进程回收;
  2. 由于进程退出时会向父进程发送SIGCHLD信号,因此,可以在父进程捕获该信号并通过waitwaitpid释放子进程资源。

什么是死锁?死锁产生的必要条件?

死锁:在许多应用中进程需要以独占的方式访问资源,当多个进程并发执行时可能会出现相互等待对方所占用的资源而都无法继续向下执行的现象,此现象称为死锁

死锁产生的四个必要条件(发生死锁时,一定会有以下条件成立):

  • 互斥条件:一个资源只能被一个进程占有,进程应互斥且排他的使用这些资源。

  • 请求与保持条件:进程在请求资源得不到满足而等待时,不释放已占有的资源。

  • 不可剥夺条件:进程已经占有的资源,除非进程自己释放,其他进程不能强行剥夺 。

  • 循环等待条件:若干进程之间形成一种首位尾相接的环形等待资源关系。

处理死锁的基本策略和常用方法?

预防死锁(破坏四个必要条件):

  • 并不是所有应用场景都可以破坏互斥条件。案例:SPOOLing技术将一台独享打印机改造为可供多个用户共享的打印机。(破坏互斥条件 )

  • 当进程在运行前一次申请完他所需要的全部资源,在他的资源未满足前,不让他投入运行。(破坏请求和保持条件)

  • 给不同进程设置优先级,当某个高优先级进程需要的资源被其它进程占有的时候,可以由操作系统协助将想要的资源强行剥夺。(破坏不可剥夺条件)

  • 给资源编号,进程必须按照编号从小到大的顺序申请自己所需资源。(破坏循环等待条件)

避免死锁(银行家算法):

预防死锁的几种策略,会严重地损害系统性能。在避免死锁时,要施加较弱的限制,从而获得较为满意的系统性能。具有代表性的避免死锁算法是银行家算法

银行家算法的实质就是要设法保证系统动态分配资源后不会进入不安全状态,以避免可能产生的死锁。 即每当进程提出资源请求且系统的资源能够满足该请求时,系统将判断满足此次资源请求后系统状态是否安全,如果判断结果为安全,则给该进程分配资源,否则不分配资源,申请资源的进程将阻塞。

银行家算法所需数据结构

  1. Available[j] 向量:系统中可利用的各种资源数目

  2. Max[i, j] 矩阵:每个进程对每种资源的最大需求

  3. Allocation[i, j] 矩阵:每个进程已分配的各类资源的数目

  4. Need[i, j] 矩阵:每个进程还需要的各类资源数

其中三个矩阵间存在下述关系:

Need[i, j] = Max[i, j] - allocation[i, j]

银行家算法流程

Request是第i个进程P的请求向量,如果Request[j] = K,表示进程P需要Kj类型的资源。当P发出资源请求后,系统按下述步骤进行检查:

  1. Request[j] <= Need[i, j],转向2,否则认为出错(因为它所需的资源数目已超过它所宣布的最大值)。

  2. Requesti[j] <= Available[j],转向3,否则须等待(表现为进程P受阻)。

  3. 系统尝试把资源分配给进程P,并修改下面数据结构中的数值:

Available[j] = Available[j] – Request[j]Allocation[i, j] = Allocation[i, j] + Request[j]Need[i, j] = Need[i, j] –Request[j]

  1. 试分配后,执行安全性算法,检查此次分配后系统是否处于安全状态。若安全,才正式分配;否则,此次试分配作废,进程P等待。

安全性算法步骤:

检査当前的剩余可用资源是否能满足某个进程的最大需求,如果可以,就把该进程加入安全序列,并把该进程持有的资源全部回收不断重复上述过程,看最终是否能让所有进程都加入安全序列。

注:只要能找出一个安全序列,系统处于安全状态。当然,安全序列可能有多个。如果分配了资源之后,系统中找不出任何安全序列,系统就进入了不安全状态。

死锁检测和死锁解除:

如果系统中既不采用预防死锁也不采用避免死锁的措施,系统就很有可能发生死锁。这种情况下,系统应当提供两种算法。

  1. 死锁检测算法:用于检测系统状态,确定系统中是否已经发生了死锁。

    • 所需数据结构:资源分配图,又叫资源有向图,如下:

      圆圈代表一个进程,方框代表一类资源,方框内的圆圈代表该类资源的一个单位的资源。从进程到资源的有向边为请求边,表示该进程申请一个单位的该类资源;从资源到进程的边为分配边,表示该类资源已有一个资源分配给了该进程。图中,进程P1已经分得了两个R1资源,请求了一个R2资源;进程P2分得了一个R1资源和一个R2资源,并又请求了一个R1资源。

    • 算法流程:尝试将满足运行条件的进程有向边消去以简化资源分配图,如果能简化说明系统占时没有出现死锁。如果此时系统的资源分配图是不可简化的,那么此时发生了系统死锁(死锁定理)

      资源分配图简化实例:

      按照死锁定理中,找出的进程为P1,因为它申请的资源可以被满足,说明(a)时刻没有发生死锁。

  2. 死锁解除算法:该算法可将系统从死锁中解脱出来。

    • 资源剥夺法:将一些死锁进程暂时挂起来,并且抢占它的资源,并将这些资源分配给其他的死锁进程 ,要注意的是应该防止被挂起的进程长时间得不到资源而处于饥饿状态。
    • 撤销进程法:强制撤销部分甚至全部死锁并剥夺这些进程的资源。撤销的原则可以按照进程优先级和撤销进程的代价高低进行。
    • 进程回退法:让一或多个进程回退到足以回避死锁的地步,进程回退时自愿释放资源而非被剥夺。这个方法要求系统保持进程的历史信息,并设置还原点。

进程间通信(IPC)的方式有哪些?

  1. 管道(pipe)

    管道可用于具有亲缘关系的进程间的通信,通常指父子进程之间;同时,管道是一种半双工的通信方式,数据只能单向流动。所谓管道,实际是内核管理的一串缓存,生命周期随进程的创建而创建,随进程的结束而销毁。

    Linux系统(一种 UNIX系统)中,可以在C代码中调用pipe系统调用创建管道并通信:

    #include <unistd.h>
    int pipe(int pipedes[2]); // 创建管道

    成功返回0,失败返回-1pipedes[0]指向管道的读端,pipedes[1]指向管道的写端。使用时,先创建管道并得到两个分别指向管道两端的文件描述符,父进程通过fork函数创建子进程,然后子进程也有两个文件描述符分别指向同一管道的两端;父进程通过 close(pipedes[0]) 关闭管道读端,子进程通过close(pipedes[1])关闭管道写端;父进程通过write(pipedes[1], ... )系统调用往管道里写,子进程通过read(pipedes[0], ...)系统调用从管道里读。(这里是父写子读,也可以反过来)

    备注:

    头文件 unistd.h 意为:unix std,其提供了访问 POSIX 操作系统 API的功能。类似于 Windows系统提供的 windows.h

    POSIXPortable Operating System Interface of UNIX,可移植操作系统接口,是 IEEE为了方便在各种 UNIX系统之间移植软件而定制的标准。

    示例:

    #include <stdio.h>
    #include <unistd.h>
    #include <string.h>
    
    int main()
    {
        int pipedes[2];
    
        if (pipe(pipedes))
        {
            perror("pipe() fail");
            return -1;
        }
    
        pid_t pid = fork();
        if (pid < 0)
        {
            perror("fork() fail");
            return -2;
        }
    
        // child process
        if (pid == 0)
        {
            close(pipedes[0]);
            char str[] = "Hello, parent!";
            //这里用没有发送结束符'\0'
            int len = write(pipedes[1], str, strlen(str));
            printf("write len : %d \n", len);
            return 0;
    
        // parent process
        }
        else
        {
            close(pipedes[1]);
            char buf[1024];
            int len = read(pipedes[0], buf, sizeof(buf));
            //手动添加上结束符
            buf[len] = '\0';
            printf("read message from child process : %s , length %d \n", buf, len);
            return 0;
        }
    }
  2. 命名管道(named pipe)

    命名管道克服了管道没有名字的限制,除具有管道所具有的功能外,它还允许无亲缘关系进程间的通信。命名管道提供一个路径名与之关联,以文件形式存储文件系统中,只要进程可以访问该路径,就可以通过该命名管道相互通信。

    Linux系统中,可以在shell中通过mkfifo命令创建命名管道;也可以在C源代码中通过调用int mkfifo(const char * pathname, mode_t mode)系统调用创建,第一个参表示命名管道路径,第二个表示文件权限,通常为0666(可读可写)。要借助该命名管道通信的进程在源代码通过openwritereadclose系统调用配合进行通信。(命名管道也是半双工的)

    示例:

    1. shell中运行命令 mkfifo mypipe,会创建一个名为mypipe的命名管道。
    1. 创建 namepipe_test_write.c 作为写端:
    ```
    #include <stdio.h>
    #include <string.h>
    #include <fcntl.h>
    #include <unistd.h>
    
    // 一次传输的字符个数不超过127个字符,最后一位存'\0'
    #define LEN 128
    
    int main() 
    {
        int filedes = open("./mypipe", O_WRONLY);
    
        char buf[LEN] = {'\0'}; 
    
        while (1) {
            printf("please input message: \n");
    
            int i = 0;
            while (i < LEN - 1) {
                char ch = getchar();
    
                if (ch == '\n') {
                    break;
                }
    
                buf[i++] = ch;
            }
            //手动添加字符串结束符
            buf[i] = '\0';
    
            if (strcmp(buf, "quit") == 0) {
                break;
            }
    
            int len = write(filedes, buf, strlen(buf));
            printf("write len : %d \n", len);
            printf("-----------------------------------\n");
        }
    
        close(filedes);
        return 0;
    }
    
    ```
    1. 创建namepipe_test_read.c作为读端:
    ```
    #include <stdio.h>
    #include <string.h>
    #include <fcntl.h>
    #include <unistd.h>
    
    int main() 
    {
        int filedes = open("./mypipe", O_RDONLY); 
    
        char buf[128] = {'\0'};
        while (read(filedes, buf, sizeof(buf)) > 0) 
        {
            printf("read from pipe : \n%s\n", buf);
            memset(buf, '\0', sizeof(buf));
            printf("-----------------------------------\n");
        }
        
        close(filedes);
        return 0;
    }
    
    ```
    1. 编译后分别在两个终端中运行,无论谁先启动都行。当两个进程都启动后,写端才会打印出提示符,表示可以开始通信。在写端输入字符串并回车后,读端会读取并打印。当写端输入quit字符串时,通信结束。详情如下图:(编译运行环境:w10wsl2(ubuntu 20.04))
  3. 信号(signal)

    信号是在软件层次上对中断机制的一种模拟,是一种异步通信方式。信号用于通知接收进程有某种事件发生,接收到该信号的进程会相应的采取一些行动。

    Linux系统中,信号在signal.h中定义,信号的名称都以SIG开头,如:

    • SIGINT:终止信号 (ctrl + c)

    • SIGQUIT:退出信号 (ctrl + \)

    • SIGSTOP:暂停信号 (ctrl + z )

    • SIGSCONT:继续信号 (ctrl + z)

    • SIGALRM:闹钟信号,常用作定时器

    • SIGCHLD:子进程状态改变,父进程收到信号

    • SIGKILL:杀死信号( kill -9 pid)

    信号发送:通常,用户可以通过按键、调用kill命令、或在C源代码中调用int kill(pid_t pid, int sig)系统调用来向另一个进程发送信号。

    信号处理:接收进程可以通过signalsigaction函数注册对信号的处理方式。如:忽略(SIGKILLSIGSTOP不能被忽略)、默认处理、自定义处理。

  4. 消息队列(message queue)

    消息队列本质上是位于内核空间的链表,链表的每个节点都是一条消息,每一条消息都有自己的消息类型,这个消息类型是由发送方和接收方约定。不同于管道,从消息队列读取数据时不一定要以先进先出的次序读取,可以按消息的类型读取,借此可以实现提前查看紧急消息的功能。消息队列的生命周期是伴随着内核的,如果没有手动释放消息队列,他会直到操作系统关闭才释放。

    Linux系统中,利用C语言,通过调用int msgget(key_t key, int msgflg)系统调用创建(不存在时)消息队列并得到消息队列的唯一标识符。两个需要通信的进程中,只要传入msgget中的key相同,他们得到的消息队列标识符就相同。key通常使用key_t ftok(const char *pathname, int proj_id)系统调用来获得。进程获得消息队列唯一标识符后可以使用int msgsnd(int msqid, const void *msgp, size_t msgsz, int msgflg)ssize_t msgrcv(int msqid, void *msgp, size_t msgsz, long msgtyp, int msgflg)系统调用分别向消息队列发送消息和从消息队列接收消息。

    示例:

    msg.h:定义消息格式

    #ifndef _MSG_
    #define _MSG_
    
    typedef struct msg
    {
        long msgType;      //必须是long型且是结构体第一个变量
        char message[128]; //类型任意
        //...可以有更多数据
    } Msg;
    
    #endif

    msg_queue_test_write.c:发送端

    #include <stdio.h>
    #include <string.h>
    #include <sys/ipc.h>
    #include <sys/msg.h>
    #include <errno.h>
    #include "msg.h"
    
    int main()
    {
        key_t key = ftok("./", 2021);
        if (key == -1)
        {
            perror("ftok() fail");
            return -1;
        }
    
        //创建(不存在时)消息队列并得到消息队列的唯一标识符, 0666表示权限
        int msgqid = msgget(key, IPC_CREAT | 0666);
        if (msgqid == -1)
        {
            perror("msgget() fail");
            return -2;
        }
    
        //发送类型 1 ~ 5 共 5 种类型的消息
        for (int i = 1; i < 6; i++)
        {
            Msg msg;
    
            msg.msgType = i;
            strcpy(msg.message, "Hello : ");
    
            //四字节int型转化为字符串长度最多为12:一个符号 + 10个数字 + 一个'\0'结束符
            char tmp[12] = {'\0'};
            sprintf(tmp, "%d", i);
    
            //将i转化为字符串并拼接至message
            strcat(msg.message, tmp);
    
            //最后一个参数:阻塞方式发送消息,如果消息队列没有空间接收新发送的消息则阻塞
            int flag = msgsnd(msgqid, &msg, sizeof(msg) - sizeof(long), 0);
            if (flag == -1)
            {
                printf("msgsnd(): send type: %d failed: %s", i, strerror(errno));
            }
        }
    
        return 0;
    }

    msg_queue_test_read.c:接收端

    #include <stdio.h>
    #include <string.h>
    #include <sys/ipc.h>
    #include <sys/msg.h>
    #include <errno.h>
    #include "msg.h"
    
    int main()
    {
        key_t key = ftok("./", 2021);
        if (key == -1)
        {
            perror("ftok() fail");
            return -1;
        }
    
        //创建(不存在时)消息队列并得到消息队列的唯一标识符, 0666表示权限
        int msgqid = msgget(key, IPC_CREAT | 0666);
        if (msgqid == -1)
        {
            perror("msgget() fail");
            return -2;
        }
    
        Msg msg;
        // 第二个参数:指明消息长度(不包含消息类型)
        // 倒数第二个参数: 大于0时表示获取制定类型的消息,如果等于0表示获取最前面的消息
        // 最后一个参数:如果消息队列为空就阻塞,直到读取到消息
        int len = msgrcv(msgqid, &msg, sizeof(Msg) - sizeof(long), 0, 0);
        if (len < 0)
        {
            perror("msgrcv() fail");
        }
    
        printf("receive %d length: %s \n", len, msg.message);
    
        return 0;
    }

    编译后在两个终端中分别开启读端和写端(无论先后),消息队列开始为空,如果先启动读端,读端会被阻塞,直到写端写了数据后,读端读取到数据了才会退出。该例中,写端一次写入五个消息,读端可以成功读取五次,第六次读取会被阻塞,直到消息队列又有新的消息到达。如下:(编译运行环境:ubuntu 20.14wsl2(ubuntu 20.14)中编译可以通过,但运行时提示没有相应实现)

    以上代码并没有释放消息队列(msgctl(msgqid, IPC_RMID, 0)函数可以释放),因此,程序运行结束后,通过命令ipcs -q仍然可以看到我们创建的消息队列(系统重启后消失):

    最后,我们可以通过命令ipcrm -q msqid释放消息队列。

  1. 共享内存(shared memory)

    不同程序拥有各自独立的逻辑地址空间,不能相互访问。共享内存通过将一块物理内存映射到不同进程的逻辑地址空间,使得他们可以访问同一块物理内存,从而实现共享内存。访问共享内存区域和访问进程独有的内存区域一样快,读取和写入的过程中不用像管道和消息队列那在用户态与内核态之间拷贝信息,因此,共享内存是最快的进程间通信形式。由于多个进程共享同一块内存区域,为保证正确通信,需要借助同步机制,如:信号量,来进行同步。

    Linux系统中,利用C语言,通过调用int shmget(key_t key, size_t size, int shmflg)系统调用创建(不存在时)共享内存并得到共享内存的唯一标识符,其中key和消息队列中提到的key相同。进程获得共享内存唯一标识符后通过调用void *shmat(int shm_id, const void *shm_addr, int shmflg)系统调用建立(attach)用户进程空间到共享内存的映射,得到指向共享内存的指针。根据该指针就可以利用系统读写函数向共享内存中写数据或者从共享内存读取数据。通信完成后,进程通过调用int shmdt(const void *shmaddr)函数解除(detach)映射关系,shmaddr参数是之前调用shmat时的返回值(指向共享内存的指针)。

    示例:

    shared_memory_test_write.c:写端

    #include <stdio.h>
    #include <sys/ipc.h>
    #include <sys/shm.h>
    
    int main()
    {
        //根据路径和指定的id生成唯一的key
        key_t key = ftok("/", 2021);
        if (key == -1)
        {
            perror("ftok() fail");
        }
    
        //创建(不存在时)大小为1024KB的共享内存,0666代表权限
        int shmid = shmget(key, 1024, IPC_CREAT | 0666);
        if (shmid == -1)
        {
            perror("shmget() fail");
        }
    
        //attach,将共享内存映射到当前进程
        //第二个参数:共享内存连接到当前进程中的地址,通常为0,表示让系统来选择
        //第三个参数:shm_flg是一组标志位,通常为0
        //返回共享内存地址
        void *shm = shmat(shmid, 0, 0);
        if (shm == (void *)-1)
        {
            perror("shmat() fail");
        }
    
        //将键盘输入的数据写入共享内存
        fgets(shm, 1024, stdin);
    
        //detach,把共享内存从当前进程中分离出去
        int flag = shmdt(shm);
        if (flag == -1)
        {
            perror("shmdt() fail");
        }
    
        return 0;
    }

    shared_memory_test_read.c:读端

    #include <stdio.h>
    #include <sys/ipc.h>
    #include <sys/shm.h>
    
    int main()
    {
        //根据路径和指定的id生成唯一的key
        key_t key = ftok("/", 2021);
        if (key == -1)
        {
            perror("ftok() fail");
        }
    
        //创建(不存在时)大小为1024KB的共享内存,0666代表权限
        int shmid = shmget(key, 1024, IPC_CREAT | 0666);
        if (shmid == -1)
        {
            perror("shmget() fail");
        }
    
        //attach,将共享内存映射到当前进程
        //第二个参数:共享内存连接到当前进程中的地址,通常为0,表示让系统来选择
        //第三个参数:shm_flg是一组标志位,通常为0
        //返回共享内存地址
        void *shm = shmat(shmid, 0, 0);
        if (shm == (void *)-1)
        {
            perror("shmat() fail");
        }
    
        //将键盘输入的数据写入共享内存
        fputs(shm, stdout);
    
        //detach,把共享内存从当前进程中分离出去
        int flag = shmdt(shm);
        if (flag == -1)
        {
            perror("shmdt() fail");
        }
    
        return 0;
    }

    编译后先运行写端,输入hello world回车后程序退出;然后运行读端,程序读取并打印出hello world。如下:(编译运行环境:ubuntu 20.14)

    以上代码并没有释放共享内存(shmctl(shmid, IPC_RMID, 0)可以释放),因此,程序运行结束后,通过命令ipcs -m仍然可以看到我们创建的共享内存(系统重启后消失)。最后,我们可以通过命令ipcrm -m shmid释放共享内存。

  2. 信号量(semaphore)

    信号量的原理是一种数据操作锁的概念,可用于多个进程间的同步。它本身并不具备数据交换的功能,而是通过控制其他的通信资源来实现进程间通信。我们可以将其理解成一个具有原子性的计数器,每当有进程申请使用信号量,通过一个P操作来对信号量进行-1操作,当计数器减到0的时候就说明没有资源了,其他进程继续访问就会被阻塞,当该进程执行完这段工作释放临界资源之后,就会执行V操作来对信号量进行+1操作,被阻塞的进程就会被唤醒。

    Linux系统中,利用C语言,通过调用int semget(key_t key, int nsems, int semflg)系统调用创建(不存在时)信号量并得到信号量的唯一标识符,其中key和前述key相同。

    可以通过命令ipcs -s查看系统当前存在的信号量,通过命令ipcrm -s semid可以释放信号量。

  3. 套接字(socket)

    更为一般的进程间通信机制,可用于运行在不同主机上的进程之间通信。他通过IP地址端口号确定一台主机上一个进程,常用于网络编程。

备注:以上这些通信方式并不是所有的操作系统都提供了实现,即使操作系统提供了实现,编程语言也不一定提供了访问的接口。以上消息队列、共享内存和信号量都是基于System V规范的(另一种实现是基于POSIX规范的)。

常见内存管理方式有哪些?

内存管理机制分为连续分配管理方式和非连续分配管理方式。前者为进程分配一个连续的内存空间,是古老的内存管理方式,常见的有单一连续分配固定分区分配等。后者是充分利用离散的内存,将进程分散的装入内存分区中;根据分区大小是否固定可以分为页式管理(固定分区大小)和段式管理(不固定分区大小);还可以二者混用成段页式管理

什么是分页存储管理?什么是分段存储管理?区别是什么?

分页存储管理:

分页存储管理极大的提高了内存利用率,他将实际的物理内存分为大小相等的块,通常被称为页帧。页式管理将用户程序所需内存以离散的页帧形式分配给他们。每个用户程序都有自己的逻辑地址空间,逻辑空间也被划分为与页帧大小相同的页面,逻辑页面和物理页帧是一一对应的关系。

那么CPU寻址时,是如何完成逻辑地址到实际物内存地址的转换呢?首先要知道,逻辑地址被划分为高位和低位,高位代表当前逻辑地址所在页面对应的页号,低位代表的是页内偏移,以32位操作系统来说,他的逻辑地址共有32位,如果页面(由页帧大小决定)大小为4KB(4 * 1024 = 212注:地址单位为B,字节。)则需要占用低12位来表示页内偏移。显然,CPU仅仅借助逻辑地址是无法完成寻址的,还需要借助进程页表才能完成逻辑地址到物理地址的转换,页表中记录的是页面和页帧的对应关系。开始寻址时,CPU根据逻辑地址得到页号和页内偏移,查询页表可得到页号对应页帧在物理内存中的起始地址,页帧起始地址加上页内偏移即可得到实际的物理地址。如下图:

分段存储管理

分页存储是从计算机的角度进行设计的,目的是为了提高内存的利用率,每一页面并没有实际意义。段式存储管理从程序员和用户角度出发,把程序分割成具有逻辑意义的段,例如:主程序段、子程序段、数据段等等,每一段的大小不定。段式管理将用户程序所需内存以离散的内存段的形式分配给她们。借助段式管理容易实现数据的共享与保护。

那么分段存储管理中,CPU又是如何完成寻址的呢?分段存储管理中,逻辑地址同样被划分为高位和低位,高位表示段号,低位表示段内偏移。仅仅根据段号和段内偏移尚无法完成寻址,还需要借助进程段表,段表记录了逻辑段的大小(段长)以及逻辑段在内存中的起始地址(基址)。开始寻址时,CPU先拿到指明的段号和段内偏移(由于段长不定,段号和段内偏移无法像分页管理那样根据逻辑地址和页面大小直接计算出来页号和页内偏移,需要指明逻辑地址中哪部分表示段号,哪部分表示段内偏移,这也是段式管理中逻辑地址是二维的原因),继续查询段表可以得到逻辑段的基址(段表中的段长是用来检查当前段的段内偏移是否超过段长而发生越界),基址加上段内偏移即可得到实际的物理地址。如下图:

分页管理和分段管理区别:

  • 分页管理是站在计算机角度进行设计,每一页并无逻辑意义,目的是减少外部碎片,提高内存的利用率,对用户不可见;段式管理站在程序员和用户角度,是一种逻辑上的划分,是为了满足用户的需要,对用户是可见的,编程时需要指明段名和段内地址(汇编语言中指明了段名和段内地址就指明了逻辑地址的段号和段内偏移)。
  • 分页管理中,页面大小是固定的;而分段管理中段的大小取决于具体程序代码段,是变化的。
  • 分页管理中,逻辑地址是一维的;而分段管理中逻辑地址是二维的。
  • 在实现对程序和数据的共享与保护时,分段管理中,程序和数据本就按逻辑段存储在内存段中,容易实现对程序段、数据段的共享控制以及保护控制;而分页管理中,逻辑上的代码段或数据段是被分散的存储在各个离散的内存页帧当中,很难实现对逻辑程序段或逻辑数据段的共享与保护。

注:进程页表和进程段表都存放于物理内存中,且一个页表或段表是占用的连续空间。以上图中为了方便表达没有将页表或段表画在物理内存中。

段页式存储管理了解吗?

在分页存储管理中,内存利用率高,不会产生外部碎片,但不方便实现数据的共享与保护;而分段存储管理则刚好相反。段页式存储管理就是为了结合两者的优点。简单来说,段页式存储管理将逻辑空间划分为逻辑段,逻辑段再划分为逻辑页面;而物理内存划分为大小相同的页帧,逻辑页面和物理页帧一一对应,并装入物理页帧当中。

在段页式存储管理中,逻辑地址被划分为三段,由高到低依次代表段号页号页内偏移CPU寻址时需要借助段表和页表,段表记录了各个逻辑段对应页表的物理内存起始地址,以及页表长度;页表则记录了各个逻辑页面对应物理页帧的起始地址。 寻址开始时,CPU首先拿到指明的段号并根据页面(页帧)大小计算出页号和页内偏移,CPU根据段号和段表可以找到该逻辑段对应页表的起始物理内存地址,再根据页号和页表找到对应页帧首地址,该首地址加上页内偏移即可得到实际的物理地址。(段表中的页表长度用来检查页号是否越界)。如下图:

虚拟存储器(虚拟内存)了解吗?

基于局部性原理,在程序装入时,可以先装入当前运行需要的部分,然后就可以开始启动程序,而将其他的部分暂时留在外存。在程序执行时,如果访问的信息不在内存中,由操作系统将所需要的部分调入内存;如果此时内存已经没有空间给新调入的部分,那么操作系统按照某种淘汰策略将一部分旧的内容暂时换到外存上存放,然后再将新需要的部分调入内存,接着继续执行程序。这样,操作系统就可以执行比实际内存大的多的程序,就好像为用户提供了一个比实际内存大的多的存储器。

虚拟存储器种类:

  • 虚拟页式存储管理
  • 虚拟段式存储管理
  • 虚拟段页式存储管理

局部性原理:

  • 时间局部性原理:如果程序中的某条指令⼀旦执⾏,不久以后该指令可能再次执⾏;如果某数据被访问过,不久以后该数据可能再次被访问。
  • 空间局部性原理:⼀旦程序访问了某个存储单元,在不久之后,其附近的存储单元也将被访问, 即程序在⼀段时间内所访问的地址,可能集中在⼀定的范围之内。

页面淘汰(置换)算法有哪些?

  1. 最佳(Optimal)淘汰算法:淘汰将来最长时间内不再被访问的页面,该算法会保证最低的缺页率,但它是无法实现的可作为衡量其他算法优劣的一个标准

  2. 先进先出(FIFO)淘汰算法:淘汰最先进入的页面。该算法将那些经常被访问的页面也被换出,从而使缺页率升高。

  3. 最近最久未使用淘汰算法(LRU, Least Recently Used): 淘汰最近最久未使用的页面。

  4. 时钟(CLOCK)淘汰算法:该算法为每个页面设置一个访问位,再将内存中的页面都通过指针链接成一个循环队列。当某个页被访问时,其访问位置1。当需要淘汰一个页面时,只需检查页的访问位。如果是0,就选择该页淘汰;如果是1,暂不淘汰,将访问位改为0,继续检查下一个页面。若第一轮查找中所有的页面都是1,则这些页面的访问位会被依次置为0,在第二轮扫描中一定可以找到访问位为0的页面去淘汰。

    改进时钟淘汰算法:

    简单的时钟置换算法仅考虑到了一个页面最近是否被访问过,改进时钟淘汰算法还要考虑页面是否被修改过,因此还需要给页面增加一个修改位,表示当前页面是否被修改过。在淘汰页面时,第一轮首先查找第一个(0, 0)的页(最近没被访问,也没被修改)用于淘汰;没找到则进行第二轮查找,查找第一个(0,1)的页(最近没被访问,但被修改过)用于淘汰,本轮将所有查找过的页的访问位设为0。第二轮依然没找到就进行第三轮查找,查找第一个(0, 0)的页用于淘汰,本轮扫描不修改任何标志位。还是没找到则进行第四轮查找,查找第一个(0,1)的页淘汰。( 由于第二轮已将所有的页的访问位都设为0,因此第三轮、第四轮查找一定会选中一个页,因此改进时钟淘汰算法最多会进行四轮扫描。)

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值