操作系统常见面试总结

前言

本人准研三,面临秋招压力,遂总结部分计算基础知识,以备不时之需,有些是从大佬博文摘抄的,都附有相应博文链接,如有遗漏,烦请联系本人更改,如有侵权,我会修改矫正。最后祝各位OFFER多多,进大厂!

以下是操作系统常见面试题

**一、操作系统的线程和进程的区别,线程的几种状态

1.进程的概述

① 进程和线程

进程(Process)是资源分配的基本单位,线程(Thread)是CPU调度的基本单位。

  • 线程将进程的资源分和CPU调度分离开来。 以前进程既是资源分配又是CPU调度的基本单位,后来为了更好的利用高性能的CPU,将资源分配和CPU调度分开。因此,出现了线程。
  • 进程和线程的联系: 一个线程只能属于一个进程,一个进程可以拥有多个线程。线程之间共享进程资源。
  • 进程和线程的实例: 打开一个QQ,向朋友A发文字消息是一个线程,向朋友B发语音是一个线程,查看QQ空间是一个线程。QQ软件的运行是一个进程,软件中支持不同的操作,需要由线程去完成这些不同的任务。

② 进程和线程的区别

广义上的区别

  • 资源: 进程是资源分配的基本单位,线程不拥有资源,但可以共享进程资源。
  • 调度: 线程是CPU调度的基本单位,同一进程中的线程切换,不会引起进 程切换;不同进程中的线程切换,会引起进程切换。
  • 系统开销: 进程的创建和销毁时,系统都要单独为它分配和回收资源,开销远大于线程的创建和销毁;进程的上下文切换需要保存更多的信息,线程(同一进程中)的上下文切换系统开销更小。
  • 通信方式: 进程拥有各自独立的地址空间,进程间的通信需要依靠IPC;线程共享进程资源,线程间可以通过访问共享数据进行通信。
    进程间通信(IPC,InterProcess Communication)是指在不同进程之间传播或交换信息。
    IPC的方式通常有管道(包括无名管道和命名管道)消息队列信号量共享存储SocketStreams等。其中 Socket和Streams支持不同主机上的两个进程IPC

Linux系统中进程和线程的区别

  • 在Linux系统中,内核调度的单元是struct task_struct,每个进程对应一个task_struct。
  • 2.6以前的内核中没有线程的概念,内核将线程视为轻量级进程(LWP),并为每一个线程分配一个task_struct。
  • 2.6以后的内核中出现了线程组的概念,同一个进程中的线程放入一个线程组中;内核仍然视线程为轻量级进程,每个task_struct对应一个进程或者线程组中的一个线程。
  • 如果线程完全在内核态中实现(内核线程,KLT),内核调度的单元是线程。此时,进程与线程的区别非常微妙。
  • 如果线程完全在用户态实现(用户线程,ULT),内核调度的单元是进程,内核对用户线程一无所知。内核只负责分配CPU给进程,进程得到CPU会后再分配给内部的线程

③ 进程的组成

进程由程序代码、数据、进程控制块(Process Control Block, PCB)三个部分组成,即进程映像(Process Image)。
关于PCB :

  • PCB描述进程的基本信息和运行状态,所谓的创建撤销进程,都是指对 PCB 的操作。
  • PCB是一个数据结构,它常驻内存,其中的进程ID(PID)唯一标识一个进程。
  • 标识符:自身ID(PID)、父进程ID(PPID)、用户ID(UID)
  • 处理机状态:主要由处理机的各种寄存器中的内容组成,包括通用寄存器,程序计数器(PC),存放下一条要访问的指令地址;程序状态字(PSW),包含条件码、执行方式、中断屏蔽标志等状态信息;用户栈指针,存放过程和系统调用的参数及调用地址。
  • 进程调度信息 :包括进程状态,指明进程的当前状态;进程优先级;进程调度所需的其它信息,如进程已等待CPU的时间总和、进程已执行的时间总和等;事件,由执行状态转变为阻塞状态所等待发生的事件,即阻塞原因。
  • 进程控制信息:包括程序和数据的地址,是指进程的程序和数据所在的内存或外存地址;进程同步和通信机制,指实现进程同步和进程通信时必需的机制,如消息队列指针、信号量等;资源清单,进程所需的全部资源及已经分配到该进程的资源的清单;链接指针。

PCB的组织方式: 链接和索引

  • 链接:运行态、就绪态、阻塞态分别维护一个链表,每种状态的PCB通过链表连接。其中就绪态的链表只有一个PCB,因为同一时刻只有一个进程处于就绪态。
  • 索引: 运行态、就绪态、阻塞分别维护一个PCB表,该表中的每个entry指向一个PCB。
    每个进程都有自己的PID,进程依靠进程树进行组织。其中根进程的PID = 1,父进程的撤销会撤销全部的子进程。

2. 进程的生命周期

① 进程的状态切换

关于几种状态:

  • 就绪态: 刚创建的进程完成PCB的初始化后,被加到就绪队列中,等待CPU的调度。
  • 运行态: 处于就绪态的进程获得CPU时间后,变为运行态。运行态的进程使用完CPU时间后,变为就绪带态。只有就绪态和运行态可以相互转换。
  • 阻塞态: 进程在运行的过程中由于缺少必须的资源,变为阻塞态。注意: 资源不包括CPU时间,一般指I/O操作等。
  • 挂起态: 五状态模型中,缺少对I/O操作的描述,于是增加交换(Swapping) 这一概念。处于阻塞态的进程由于长时间未等待到资源,通过换出将其从内存转到外存变为挂起态,这样可以减少内存占用;当资源可用时,处于挂起态的进程通过换入从外存转到内存变为就绪态。
    操作系统常见面试总结_第1张图片

②进程的创建

操作系统常见面试总结_第2张图片

Linux系统中的进程创建

  • 父进程调用fork()、vfork()、clone()中的任一方法创建新进程,这些方法对应的系统调用入口分别为sys_fork()、sys_vfork()、sys_clone()。这些系统调用内部都会调用do_fork()函数完成进程创建,只是携带的参数不同。
  • do_fork()函数会调用copy_process()函数,执行进程创建的实际工作。
  • copy_process()函数调用dup_task_struct()函数复制当前的task_struct。
  • copy_process()函数调用shed_fork()函数初始化进程数据结构,将进程状态设位置为 TASK_RUNNING(统计结果是: 1,只有该状态的进程才可能在CPU上运行)。
  • copy_process()函数复制父进程的所有信息。
  • copy_process()函数调用copy_thread()函数初始化子进程的内核栈,copy_thread()函数会将子进程的返回值设为0:childregs->ax = 0,返回地址设为ret_from_fork: p->thread.ip = (unsigned long) ret_from_fork;,因此子进程是从ret_from_fork开始执行的。
  • copy_process()函数为子进程分配并设置PID。至此,子进程准备就绪,等待被调度。

关于进程创建的一些说明:

  • 新创建的子进程和父进程拥有相同的代码段,代码段的内容是从fork()函数之后开始的。即从ret_from_fork开始的。
  • 若创建后的子进程想要执行不同的程序,可以调用exec()函数族覆盖进程映像,但是子进程的PID不会发生改变。
  • fork()函数调用失败返回-1,调用成功在父进程中返回子进程的PID,在子进程中返回0。
  • 子进程和父进程拥有相同的代码段,但是由于fork()函数的返回值不同,导致子进程和父进程会打印不同的信息。
  • 子进程可以通过getpid()获取自己的PID,通过getppid()获取父进程的PID;父进程只能通过getpid()获取自身的PID,要想获取子进程的PID,必须使用变量保存fork()函数的返回值。
#include 
#include 
#include 
#include 
int main(void)
{
    pid_t pid;
    int count=0;
    pid = fork();
    if(pid < 0)
    {
        perror("fork failed");
        exit(1);
    }
    if(pid == 0)
    {
        printf("This is the child process. My PID is: %d. My PPID is: %d.\n", getpid(), getppid());
        count++;
    }
    else
    {
        printf("This is the parent process. My PID is %d, child process PID is %d.\n", getpid(), pid);
        count++;
    }
    printf("统计结果是: %d\n", count);
    return 0;
}

This is the child process. My PID is: 12778. My PPID is: 12777
统计结果是: 1
This is the parent process. My PID is 12777, child process PID is 12778.
统计结果是: 1

fork()函数:

  • 传统的fork()函数,子进程复制父进程的虚拟空间,共享父进程的代码段,数据段、栈、堆都会分配新的物理内存。除了PID和PCB中的某些参数不同之外,子进程是父进程的精确复制。
  • Linux下的fork()函数,采用写时复制(Copy on Write,CoW):内核一开始只为子进程复制父进程的虚拟空间,并不分配物理内存,而是共享父进程的物理内存。当子进程或者父进程想要修改物理内存时,再为子进程分配单独的物理内存。
  • fork() 函数执行后,子进程和父进程的运行是无关的,二者执行的先后顺序不固定。

vfork()函数:

  • 比fork()函数更加激进,子进程直接共享父进程的虚拟空间。
  • 如果子进程修改了父进程地址空间,父进程被唤醒时会发现发现自己的数据被改了,完整性丢失,所以这是不安全的。
  • vfork()函数保证子进程先运行,只有当子进程退出(调用_exit()函数)或者执行其他程序(调用exec()函数),被阻塞的父进程才可能被调度运行。
    如果在调用_exit()函数和exec()函数之前,子进程依赖父进程的进一步动作,则会导致死锁。
  • vfork()函数一般紧接着调用exec()函数,这时会为子进程分配新的物理内存,省掉了fork()函数中笨重的数据复制过程。 可以说,vfork()函数就是为了exec()函数而生。
  • clone()函数: 留给轻量级进程,提供选项,自己选择复制哪些信息。

③ 进程的终止

进程终止的原因:

  • 正常结束: exit、halt、logoff。
  • 异常结束: 无可用内存、越界、保护错误、算术错误、I/O失败、无效指令、特权指令等
  • 外界干预: kill进程、父进程终止(父进程终止,其子进程可能被系统终止)、父进程请求(父进程可以请求终止子进程)。

进程终止的过程:

  • 检索PCB,检查进程状态
  • 将进程从运行态转换成终止态
  • 检查是否有子进程需要终止
  • 将获取到的资源归还给父进程或系统
  • 将该进程的PCB从PCB队列中移出

④ 进程的上下文切换

上下文切换: 是指CPU从一个进程(或线程)切换到另一个进程(或线程),涉及到控制权的转移。

  • 保存当前进程的上下文
  • 选择某个进程,恢复其上一次换出时保存的上下文
  • 当前进程将控制权转移给新恢复的进程

task_struct是Linux中的PCB,进程上下切换时需要保存task_struct中的内容。包含以下内容:

  • 标识符: 唯一标识一个进程
  • 状态: 记录进程状态,如阻塞、就绪、运行等状态
  • 优先级: 记录进程的优先级,可以根据优先级对进程执行调度
  • 程序计数器PC: 指向进程中下一条将要执行的指令
  • 内存指针: 程序代码和进程相关诗句的指针
  • 上下文数据: 进程运行时,CPU中寄存器的内容
  • I/O状态信息: 显示的I/O请求,分配给进程的I/O设备、被进程使用的文件列表等
  • 记账信息: 处理器的时间总和、记账号等

线程的切换: 线程共享进程的资源,进行线程切换时,只需要保存线程的私有数据:栈、程序计数器、寄存器
进程切换的开销比线程切换的开销大:

  • 显式原因: 进程的上下文切换需要保存更多的信息,比线程的上下文切换开销更大。
  • 隐式原因: 进程切换时使用不同资源的task_struct之间的调度,而线程切换是使用相同资源的task_struct之间的调度。进程切换使得原有的缓存不适用,会触发缺页中断;而线程切换时,由于使用相同资源,缓存的命中率更高很多。
  • Linux中使用TLB(Translation Lookaside Buffer,转换检测缓冲)来管理虚拟内存到物理内存的映射关系。虚拟内存更新后,TLB也需要刷新,内存的访问会随之变慢。

该部分节选自CSDN大佬文章

**二、进程和线程的通信方式,实际案例

进程和线程的区别:
对于进程来说,子进程是父进程的复制品,从父进程那里获得父进程的数据空间,堆和栈的复制品。

而线程,相对于进程而言,是一个更加接近于执行体的概念,可以和同进程的其他线程之间直接共享数据,而且拥有自己的栈空间,拥有独立序列。

共同点: 它们都能提高程序的并发度,提高程序运行效率和响应时间。线程和进程在使用上各有优缺点。 线程执行开销比较小,但不利于资源的管理和保护,而进程相反。同时,线程适合在SMP机器上运行,而进程可以跨机器迁移。

他们之间根本区别在于 多进程中每个进程有自己的地址空间,线程则共享地址空间。所有其他区别都是因为这个区别产生的。比如说:

  1. 速度。线程产生的速度快,通讯快,切换快,因为他们处于同一地址空间。
  2. 线程的资源利用率好。
  3. 线程使用公共变量或者内存的时候需要同步机制,但进程不用。

而他们通信方式的差异也仍然是由于这个根本原因造成的。

通信方式之间的差异
因为那个根本原因,实际上只有进程间需要通信,同一进程的线程共享地址空间,没有通信的必要,但要做好同步/互斥,保护共享的全局变量。

而进程间通信无论是信号,管道pipe还是共享内存都是由操作系统保证的,是系统调用.

一、进程间的通信方式

  • 管道( pipe )
    管道是一种半双工的通信方式,数据只能单向流动,而且只能在具有亲缘关系的进程间使用。进程的亲缘关系通常是指父子进程关系。
  • 有名管道 (namedpipe)
    有名管道也是半双工的通信方式,但是它允许无亲缘关系进程间的通信。
  • 信号量(semophore )
    信号量是一个计数器,可以用来控制多个进程对共享资源的访问。它常作为一种锁机制,防止某进程正在访问共享资源时,其他进程也访问该资源。因此,主要作为进程间以及同一进程内不同线程之间的同步手段。
  • 消息队列( messagequeue )
    消息队列是由消息的链表,存放在内核中并由消息队列标识符标识。消息队列克服了信号传递信息少、管道只能承载无格式字节流以及缓冲区大小受限等缺点。
  • 信号 (sinal )
    信号是一种比较复杂的通信方式,用于通知接收进程某个事件已经发生。
  • 共享内存(shared memory )
    共享内存就是映射一段能被其他进程所访问的内存,这段共享内存由一个进程创建,但多个进程都可以访问。共享内存是最快的 IPC 方式,它是针对其他进程间通信方式运行效率低而专门设计的。它往往与其他通信机制,如信号两,配合使用,来实现进程间的同步和通信。
  • 套接字(socket )
    套接口也是一种进程间通信机制,与其他通信机制不同的是,它可用于不同设备及其间的进程通信。
    二、线程间的通信方式
  • 锁机制:包括互斥锁、条件变量、读写锁
    互斥锁提供了以排他方式防止数据结构被并发修改的方法。读写锁允许多个线程同时读共享数据,而对写操作是互斥的。条件变量可以以原子的方式阻塞进程,直到某个特定条件为真为止。对条件的测试是在互斥锁的保护下进行的。条件变量始终与互斥锁一起使用。
  • 信号量机制(Semaphore)
    包括无名线程信号量和命名线程信号量
  • 信号机制(Signal):类似进程间的信号处理
    线程间的通信目的主要是用于线程同步,所以线程没有像进程通信中的用于数据交换的通信机制。

该部分选自CSDN博客

实际应用场景

1. 多进程应用场景

  • nginx主流的工作模式是多进程模式(也支持多线程模型)
  • 几乎所有的web server服务器服务都有多进程的,至少有一个守护进程配合一个worker进程,例如apached,httpd等等以d结尾的进程包括init.d本身就是0级总进程,所有你认知的进程都是它的子进程;
  • chrome浏览器也是多进程方式。 (原因:①可能存在一些网页不符合编程规范,容易崩溃,采用多进程一个网页崩溃不会影响其他网页;而采用多线程会。②网页之间互相隔离,保证安全,不必担心某个网页中的恶意代码会取得存放在其他网页中的敏感信息。)
  • redis也可以归类到“多进程单线程”模型(平时工作是单个进程,涉及到耗时操作如持久化或aof重写时会用到多个进程)
    2. 多线程应用场景
  • 线程间有数据共享,并且数据是需要修改的(不同任务间需要大量共享数据或频繁通信时)。
  • 提供非均质的服务(有优先级任务处理)事件响应有优先级。
  • 单任务并行计算,在非CPU Bound的场景下提高响应速度,降低时延。
  • 与人有IO交互的应用,良好的用户体验(键盘鼠标的输入,立刻响应)
  • 案例:
    桌面软件,响应用户输入的是一个线程,后台程序处理是另外的线程;
    memcached

该部分选自CSDN博客

**三、操作系统的孤儿进程和僵尸进程的区别

1.产生的原因

1)一般进程
正常情况下:子进程由父进程创建,子进程再创建新的进程。父子进程是一个异步过程,父进程永远无法预测子进程的结束,所以,当子进程结束后,它的父进程会调用wait()或waitpid()取得子进程的终止状态,回收掉子进程的资源。

2)孤儿进程

孤儿进程:父进程结束了,而它的一个或多个子进程还在运行,那么这些子进程就成为孤儿进程(father died)。子进程的资源由init进程(进程号PID = 1)回收。

3)僵尸进程

僵尸进程:子进程退出了,但是父进程没有用wait或waitpid去获取子进程的状态信息,那么子进程的进程描述符仍然保存在系统中,这种进程称为僵死进程。

2.问题危害

注意:unix提供了一种机制保证父进程知道子进程结束时的状态信息。

这种机制是:在每个进程退出的时候,内核会释放所有的资源,包括打开的文件,占用的内存等。但是仍保留一部分信息(进程号PID,退出状态,运行时间等)。直到父进程通过wait或waitpid来取时才释放。

但是这样就会产生问题:如果父进程不调用wait或waitpid的话,那么保留的信息就不会被释放,其进程号就会被一直占用,但是系统所能使用的进程号是有限的,如果大量产生僵死进程,将因没有可用的进程号而导致系统无法产生新的进程,这就是僵尸进程的危害

孤儿进程是没有父进程的进程,它由init进程循环的wait()回收资源,init进程充当父进程。因此孤儿进程并没有什么危害。

补充:任何一个子进程(init除外)在exit()之后,并非马上就消失掉,而是留下一个称为僵尸进程的数据结构,等待父进程去处理。如果父进程在子进程exit()之后,没有及时处理,出现僵尸进程,并可以用ps命令去查看,它的状态是“Z”。

3.解决方案

1)kill杀死元凶父进程(一般不用)

严格的说,僵尸进程并不是问题的根源,罪魁祸首是产生大量僵死进程的父进程。因此,我们可以直接除掉元凶,通过kill发送SIGTERM或者SIGKILL信号。元凶死后,僵尸进程进程变成孤儿进程,由init充当父进程,并回收资源。

或者运行:kill -9 父进程的pid值、

2)父进程用wait或waitpid去回收资源(方案不好)

父进程通过wait或waitpid等函数去等待子进程结束,但是不好,会导致父进程一直等待被挂起,相当于一个进程在干活,没有起到多进程的作用。

3)通过信号机制,在处理函数中调用wait,回收资源

通过信号机制,子进程退出时向父进程发送SIGCHLD信号,父进程调用signal(SIGCHLD,sig_child)去处理SIGCHLD信号,在信号处理函数sig_child()中调用wait进行处理僵尸进程。什么时候得到子进程信号,什么时候进行信号处理,父进程可以继续干其他活,不用去阻塞等待。

ps -a -o pid,ppid,state,cmd

显示:

显示:(状态Z代表僵尸进程)

S PID PPID CMD

S 3213 2529 ./pid1

Z 3214 3213 [pid1]

Z 3215 3213 [pid1]

Z 3219 3213 [pid1]

Z 3220 3213 [pid1]

Z 3221 3213 [pid1]

R 3223 3104 ps -a -o state,pid,ppid,cmd

用第一种方法,解决僵尸进程,杀死其父进程

运行:kill -9 3213

注意:僵尸进程无法用kill直接杀死,如kill -9 3214,再用上面命令去查看进程状态,发现3214进程还在。
该部分选自CSDN博客

**四、操作系统的虚拟内存,分段分页,缺页调度的流程

1. 早期的内存分配机制
在早期的计算机中,要运行一个程序,会把这些程序全都装入内存,程序都是直接运行在内存上的,也就是说程序中访问的内存地址都是实际的物理内存地址。当计算机同时运行多个程序时,必须保证这些程序用到的内存总量要小于计算机实际物理内存的大小。那当程序同时运行多个程序时,操作系统是如何为这些程序分配内存的呢?下面通过实例来说明当时的内存分配方法:

某台计算机总的内存大小是128M,现在同时运行两个程序A和B,A需占用内存10M,B需占用内存110。计算机在给程序分配内存时会采取这样的方法:先将内存中的前10M分配给程序A,接着再从内存中剩余的118M中划分出110M分配给程序B。这种分配方法可以保证程序A和程序B都能运行,但是这种简单的内存分配策略问题很多。

图一 早期的内存分配方法
操作系统常见面试总结_第3张图片
问题1:进程地址空间不隔离。由于程序都是直接访问物理内存,所以恶意程序可以随意修改别的进程的内存数据,以达到破坏的目的。有些非恶意的,但是有bug的程序也可能不小心修改了其它程序的内存数据,就会导致其它程序的运行出现异常。这种情况对用户来说是无法容忍的,因为用户希望使用计算机的时候,其中一个任务失败了,至少不能影响其它的任务。

问题2:内存使用效率低。在A和B都运行的情况下,如果用户又运行了程序C,而程序C需要20M大小的内存才能运行,而此时系统只剩下8M的空间可供使用,所以此时系统必须在已运行的程序中选择一个将该程序的数据暂时拷贝到硬盘上,释放出部分空间来供程序C使用,然后再将程序C的数据全部装入内存中运行。可以想象得到,在这个过程中,有大量的数据在装入装出,导致效率十分低下。

问题3:程序运行的地址不确定。当内存中的剩余空间可以满足程序C的要求后,操作系统会在剩余空间中随机分配一段连续的20M大小的空间给程序C使用,因为是随机分配的,所以程序运行的地址是不确定的。

2. 分段
  为了解决上述问题,人们想到了一种变通的方法,就是增加一个中间层,利用一种间接的地址访问方法访问物理内存。按照这种方法,程序中访问的内存地址不再是实际的物理内存地址,而是一个虚拟地址,然后由操作系统将这个虚拟地址映射到适当的物理内存地址上。这样,只要操作系统处理好虚拟地址到物理内存地址的映射,就可以保证不同的程序最终访问的内存地址位于不同的区域,彼此没有重叠,就可以达到内存地址空间隔离的效果。当创建一个进程时,操作系统会为该进程分配一个4GB大小的虚拟进程地址空间。之所以是4GB,是因为在32位的操作系统中,一个指针长度是4字节,而4字节指针的寻址能力是从0x00000000~ 0xFFFFFFFF,最大值0xFFFFFFFF表示的即为4GB大小的容量。与虚拟地址空间相对的,还有一个物理地址空间,这个地址空间对应的是真实的物理内存。如果你的计算机上安装了512M大小的内存,那么这个物理地址空间表示的范围是0x00000000~0x1FFFFFFF。当操作系统做虚拟地址到物理地址映射时,只能映射到这一范围,操作系统也只会映射到这一范围。当进程创建时,每个进程都会有一个自己的4GB虚拟地址空间。要注意的是这个4GB的地址空间是“虚拟”的,并不是真实存在的,而且每个进程只能访问自己虚拟地址空间中的数据,无法访问别的进程中的数据,通过这种方法实现了进程间的地址隔离。那是不是这4GB的虚拟地址空间应用程序可以随意使用呢?很遗憾,在Windows系统下,这个虚拟地址空间被分成了4部分:NULL指针区、用户区、64KB禁入区、内核区。应用程序能使用的只是用户区而已,大约2GB左右(最大可以调整到3GB)。内核区为2GB,内核区保存的是系统线程调度、内存管理、设备驱动等数据,这部分数据供所有的进程共享,但应用程序是不能直接访问的。

人们之所以要创建一个虚拟地址空间,目的是为了解决进程地址空间隔离的问题。但程序要想执行,必须运行在真实的内存上,所以,必须在虚拟地址与物理地址间建立一种映射关系。这样,通过映射机制,当程序访问虚拟地址空间上的某个地址值时,就相当于访问了物理地址空间中的另一个值。人们想到了一种分段(Sagmentation)的方法,它的思想是在虚拟地址空间和物理地址空间之间做一一映射。比如说虚拟地址空间中某个10M大小的空间映射到物理地址空间中某个10M大小的空间。这种思想理解起来并不难,操作系统保证不同进程的地址空间被映射到物理地址空间中不同的区域上,这样每个进程最终访问到的

物理地址空间都是彼此分开的。通过这种方式,就实现了进程间的地址隔离。还是以实例说明,假设有两个进程A和B,进程A所需内存大小为10M,其虚拟地址空间分布在0x00000000到0x00A00000,进程B所需内存为100M,其虚拟地址空间分布为0x00000000到0x06400000。那么按照分段的映射方法,进程A在物理内存上映射区域为0x00100000到0x00B00000,,进程B在物理内存上映射区域为0x00C00000到0x07000000。于是进程A和进程B分别被映射到了不同的内存区间,彼此互不重叠,实现了地址隔离。从应用程序的角度看来,进程A的地址空间就是分布在0x00000000到0x00A00000,在做开发时,开发人员只需访问这段区间上的地址即可。应用程序并不关心进程A究竟被映射到物理内存的那块区域上了,所以程序的运行地址也就是相当于说是确定的了。 图二显示的是分段方式的内存映射方法。

图二 分段方式的内存映射方法
操作系统常见面试总结_第4张图片
这种分段的映射方法虽然解决了上述中的问题一和问题三,但并没能解决问题二,即内存的使用效率问题。在分段的映射方法中,每次换入换出内存的都是整个程序,这样会造成大量的磁盘访问操作,导致效率低下。所以这种映射方法还是稍显粗糙,粒度比较大。实际上,程序的运行有局部性特点,在某个时间段内,程序只是访问程序的一小部分数据,也就是说,程序的大部分数据在一个时间段内都不会被用到。基于这种情况,人们想到了粒度更小的内存分割和映射方法,这种方法就是分页(Paging)。

3. 分页
分页的基本方法是,将地址空间分成许多的页。每页的大小由CPU决定,然后由操作系统选择页的大小。目前Inter系列的CPU支持4KB或4MB的页大小,而PC上目前都选择使用4KB。按这种选择,4GB虚拟地址空间共可以分成1048576个页,512M的物理内存可以分为131072个页。显然虚拟空间的页数要比物理空间的页数多得多。
在分段的方法中,每次程序运行时总是把程序全部装入内存,而分页的方法则有所不同。分页的思想是程序运行时用到哪页就为哪页分配内存,没用到的页暂时保留在硬盘上。当用到这些页时再在物理地址空间中为这些页分配内存,然后建立虚拟地址空间中的页和刚分配的物理内存页间的映射。
下面通过介绍一个可执行文件的装载过程来说明分页机制的实现方法。一个可执行文件(PE文件)其实就是一些编译链接好的数据和指令的集合,它也会被分成很多页,在PE文件执行的过程中,它往内存中装载的单位就是页。当一个PE文件被执行时,操作系统会先为该程序创建一个4GB的进程虚拟地址空间。前面介绍过,虚拟地址空间只是一个中间层而已,它的功能是利用一种映射机制将虚拟地址空间映射到物理地址空间,所以,创建4GB虚拟地址空间其实并不是要真的创建空间,只是要创建那种映射机制所需要的数据结构而已,这种数据结构就是页目和页表。
当创建完虚拟地址空间所需要的数据结构后,进程开始读取PE文件的第一页。在PE文件的第一页包含了PE文件头和段表等信息,进程根据文件头和段表等信息,将PE文件中所有的段一一映射到虚拟地址空间中相应的页(PE文件中的段的长度都是页长的整数倍)。这时PE文件的真正指令和数据还没有被装入内存中,操作系统只是根据PE文件的头部等信息建立了PE文件和进程虚拟地址空间中页的映射关系而已。当CPU要访问程序中用到的某个虚拟地址时,当CPU发现该地址并没有相相关联的物理地址时,CPU认为该虚拟地址所在的页面是个空页面,CPU会认为这是个页错误(Page Fault),CPU也就知道了操作系统还未给该PE页面分配内存,CPU会将控制权交还给操作系统。操作系统于是为该PE页面在物理空间中分配一个页面,然后再将这个物理页面与虚拟空间中的虚拟页面映射起来,然后将控制权再还给进程,进程从刚才发生页错误的位置重新开始执行。由于此时已为PE文件的那个页面分配了内存,所以就不会发生页错误了。随着程序的执行,页错误会不断地产生,操作系统也会为进程分配相应的物理页面来满足进程执行的需求。

分页方法的核心思想就是当可执行文件执行到第x页时,就为第x页分配一个内存页y,然后再将这个内存页添加到进程虚拟地址空间的映射表中,这个映射表就相当于一个y=f(x)函数。应用程序通过这个映射表就可以访问到x页关联的y页了。

怎样通俗的理解操作系统中内存管理分页和分段?

理解分段和分页,那么得理解为什么会出现分段和分页的技术

首先,这两个技术都是为了利用和管理好计算机的资源–内存。

在分段这个技术还没有出现之前,程序运行是需要从内存中分配出足够多的连续的内存,然后把整个程序装载进去。举个例子,某个程序大小是10M,然后,就需要有连续的10M内存空间才能把这个程序装载到内存里面。如果无法找到连续的10M内存,就无法把这个程序装载进内存里面,程序也就无法得到运行。

上面这种直接把整个程序装载进内存的方式是有一定的问题的。例如:

1、地址空间不隔离

如何理解地址空间不隔离?

举个例子,假设我有两个程序,一个是程序A,一个是程序B。程序A在内存中的地址假设是0x00000000~ 0x00000099,程序B在内存中的地址假设是0x00000100~x00000199。那么假设你在程序A中,本来想操作地址0x00000050,不小心手残操作了地址0x00000150,那么,不好的事情或许会发生。你影响了程序A也就罢了,你把程序B也搞了一顿。

2、程序运行时候的地址不确定

如何理解程序运行时候的地址不确定?

因为我们程序每次要运行的时候,都是需要装载到内存中的,假设你在程序中写死了要操作某个地址的内存,例如你要地址0x00000010。但是问题来了,你能够保证你操作的地址0x00000010真的就是你原来想操作的那个位置吗?很可能程序第一次装载进内存的位置是0x00000000~ 0x00000099,而程序第二次运行的时候,这个程序装载进内存的位置变成了0x00000200~0x00000299,而你操作的0x00000010地址压根就不是属于这个程序所占有的内存。

3、内存使用率低下

如何理解内存使用率低下呢?

举个例子,假设你写了3个程序,其中程序A大小为10M,程序B为70M,程序C的大小为30M你的计算机的内存总共有100M。

这三个程序加起来有110M,显然这三个程序是无法同时存在于内存中的。

并且最多只能够同时运行两个程序。可能是这样的,程序A占有的内存空间是0x00000000~0x00000009,程序B占有的内存空间是0x00000010~0x00000079。假设这个时候程序C要运行该怎么做?可以把其中的一个程序换出到磁盘上,然后再把程序C装载到内存中。假设是把程序A换出,那么程序C还是无法装载进内存中,因为内存中空闲的连续区域有两块,一块是原来程序A占有的那10M,还有就是从0x00000080~0x00000099这20M,所以,30M的程序C无法装载进内存中。那么,唯一的办法就是把程序B换出,保留程序A,但是,此时会有60M的内存无法利用起来,很浪费对吧。

然后,人们就去寻求一种办法来解决这些问题。

有一句话说的好:计算机科学领域的任何问题都可以通过增加一个间接的中间层来解决。

(这种思想在现在也用的很广泛,例如很多优秀的中间层:Nginx、Redis等等)

所以,分段这种技术就出现了。

为了实现分段的这个技术,需要引入虚拟地址空间的概念。那么什么是地址空间呢?简单的说就是可以寻址的一片空间。如果这个空间是虚拟的,我们就叫做虚拟地址空间;如果这个空间是真实存在的,我们就叫做物理地址空间。虚拟地址空间是可以任意的大的,因为是虚拟的。而物理地址空间是真实存在的,所以是有限的。

然后,分段这个技术做了一件什么事情呢?

它把虚拟地址空间映射到了物理地址空间,并且你写的程序操作的是虚拟地址。假设,程序A的虚拟地址空间是0x00000100~0x00000200。此时,不仅需要一块连续的物理内存来存放程序A,还需要把程序A的虚拟地址空间映射到(转换为)物理地址空间。可能,程序A的虚拟地址空间从0x00000100~0x00000200映射到了物理地址空间0x00000000~0x00000100。

那么分段的技术可以解决什么问题呢?可以解决上面1、2两个问题。

在问题1中,假设程序A的虚拟地址空间是0x00000000~ 0x00000099,映射到的物理地址空间是0x00000600~ 0x00000699,程序B的虚拟地址空间是0x00000100~ 0x00000199,映射到的物理地址空间是0x00000300~0x00000399。假设你还是手残,在程序A中操作了地址0x00000150,但是英文此时的地址0x00000150是虚拟的,而虚拟化的操作是在操作系统的掌控中的,所以,操作系统有能力判断,这个虚拟地址0x00000150是有问题的,然后阻止后续的操作。所以,体现出了隔离性。(另一种体现隔离性的方式就是,操作同一个虚拟地址,实际上可能操作的是不同的物理地址)

(注意,实际上,很可能程序A和程序B的虚拟地址都是0x00000000~0x00000099。这里的举例只是为了方便理解。)

问题2也很好的解决了。正是因为这种映射,使得程序无需关注物理地址是多少,只要虚拟地址没有改变,那么,程序就不会操作地址不当。

但是问题3仍然没有解决。

因为第三个问题是换入换出的问题,这个问题的关键是能不能在换出一个完整的程序之后,把另一个完整的程序换进来。而这种分段机制,映射的是一片连续的物理内存,所以问题3得不到解决。

而问题出在哪呢?就是完整和连续。

而分页技术的出现就是为了解决这个问题的。分页这个技术仍然是一种虚拟地址空间到物理地址空间映射的机制。但是,粒度更加的小了。单位不是整个程序,而是某个“页”,一段虚拟地址空间组成的某一页映射到一段物理地址空间组成的某一页。(如何理解这个“页”的概念,这个问题下的其他同学回答过)

分页这个技术,它的虚拟地址空间仍然是连续的,但是,每一页映射后的物理地址就不一定是连续的了。正是因为有了分页的概念,程序的换入换出就可以以页为单位了。那么,为什么就可以只换出某一页呢?实际上,不是为什么可以换出某一页,而是可以换出CPU还用不到的那些程序代码、数据。但是,把这些都换出到磁盘,万一下次CPU就要使用这些代码和数据怎么办?又得把这些代码、数据装载进内存。性能有影响对吧。所以,我们把换入换出的单位变小,变成了“页”。(实际上,这利用了空间局部性)

所以,分段和分页的区别在于:粒度

以上选自博客园

4.缺页调度

缺页是引入了虚拟内存后的一个概念。操作系统启动后,在内存中维护着一个虚拟地址表,进程需要的虚拟地址在虚拟地址表中记录。一个程序被加载运行时,只是加载了很少的一部分到内存,另外一部分在需要时再从磁盘载入。被加载到内存的部分标识为“驻留”,而未被加载到内存的部分标为“未驻留”。操作系统根据需要读取虚拟地址表,如果读到虚拟地址表中记录的地址被标为“未驻留”,表示这部分地址记录的程序代码未被加载到内存,需要从磁盘读入,则这种情况就表示"缺页"。这个时候,操作系统触发一个“缺页”的硬件陷阱,系统从磁盘换入这部分未“驻留”的代码。
引入了分页机制(也就有了缺页机制),则系统只需要加载程序的部分代码到内存,就可以创建进程运行, 需要程序的另一部分时再从磁盘载入并运行,从而允许比内存大很多的程序同时在内存运行。

该部分选自CSDN博客

**五、操作系统的死锁定义、四个必要条件、解决方法、避免策略

1.什么是死锁
多线程以及多进程改善了系统资源的利用率并提高了系统 的处理能力。然而,并发执行也带来了新的问题——死锁。
死锁是指两个或两个以上的进程(线程)在运行过程中因争夺资源而造成的一种僵局(Deadly-Embrace) ) ,若无外力作用,这些进程(线程)都将无法向前推进。

下面我们通过一些实例来说明死锁现象。

先看生活中的一个实例,2个人一起吃饭但是只有一双筷子,2人轮流吃(同时拥有2只筷子才能吃)。某一个时候,一个拿了左筷子,一人拿了右筷子,2个人都同时占用一个资源,等待另一个资源,这个时候甲在等待乙吃完并释放它占有的筷子,同理,乙也在等待甲吃完并释放它占有的筷子,这样就陷入了一个死循环,谁也无法继续吃饭。。。
在计算机系统中也存在类似的情况。例如,某计算机系统中只有一台打印机和一台输入设备,进程P1正占用输入设备,同时又提出使用打印机的请求,但此时打印机正被进程P2 所占用,而P2在未释放打印机之前,又提出请求使用正被P1占用着的输入设备。这样两个进程相互无休止地等待下去,均无法继续执行,此时两个进程陷入死锁状态。

关于死锁的一些结论:

  • 参与死锁的进程数至少为两个
  • 参与死锁的所有进程均等待资源
  • 参与死锁的进程至少有两个已经占有资源
  • 死锁进程是系统中当前进程集合的一个子集
  • 死锁会浪费大量系统资源,甚至导致系统崩溃。

2.死锁与饥饿
饥饿(Starvation)指一个进程一直得不到资源。

死锁和饥饿都是由于进程竞争资源而引起的。饥饿一般不占有资源,死锁进程一定占有资源。

3.资源的类型
3.1 可重用资源和消耗性资源
3.1.1 可重用资源(永久性资源)

可被多个进程多次使用,如所有硬件。

  • 只能分配给一个进程使用,不允许多个进程共享。
  • 进程在对可重用资源的使用时,须按照请求资源、使用资源、释放资源这样的顺序。
  • 系统中每一类可重用资源中的单元数目是相对固定的,进程在运行期间,既不能创建,也不能删除。

3.1.2 消耗性资源(临时性资源)
又称临时性资源,是由进程在运行期间动态的创建和消耗的。

  • 消耗性资源在进程运行期间是可以不断变化的,有时可能为0。
  • 进程在运行过程中,可以不断地创造可消耗性资源的单元,将它们放入该资源类的缓冲区中,以增加该资源类的单元数目。
  • 进程在运行过程中,可以请求若干个可消耗性资源单元,用于进程自己消耗,不再将它们返回给该资源类中。
  • 可消耗资源通常是由生产者进程创建,由消费者进程消耗。最典型的可消耗资源是用于进程间通信的消息。

3.2 可抢占资源和不可抢占资源
3.2.1 可抢占资源

可抢占资源指某进程在获得这类资源后,该资源可以再被其他进程或系统抢占。对于这类资源是不会引起死锁的。

CPU 和主存均属于可抢占性资源。

3.2.2 不可抢占资源
一旦系统把某资源分配给该进程后,就不能将它强行收回,只能在进程用完后自行释放。

磁带机、打印机等属于不可抢占性资源。

4.死锁产生的原因

  • 竞争不可抢占资源引起死锁
    通常系统中拥有的不可抢占资源,其数量不足以满足多个进程运行的需要,使得进程在运行过程中,会因争夺资源而陷入僵局,如磁带机、打印机等。只有对不可抢占资源的竞争 才可能产生死锁,对可抢占资源的竞争是不会引起死锁的。
  • 竞争可消耗资源引起死锁
  • 进程推进顺序不当引起死锁
    进程在运行过程中,请求和释放资源的顺序不当,也同样会导致死锁。例如,并发进程 P1、P2分别保持了资源R1、R2,而进程P1申请资源R2,进程P2申请资源R1时,两者都会因为所需资源被占用而阻塞。
    信号量使用不当也会造成死锁。进程间彼此相互等待对方发来的消息,结果也会使得这 些进程间无法继续向前推进。例如,进程A等待进程B发的消息,进程B又在等待进程A 发的消息,可以看出进程A和B不是因为竞争同一资源,而是在等待对方的资源导致死锁。

5.产生死锁的四个必要条件
5.1 互斥条件
进程要求对所分配的资源(如打印机)进行排他性控制,即在一段时间内某资源仅为一个进程所占有。此时若有其他进程请求该资源,则请求进程只能等待。

5.2 不可剥夺条件:
进程所获得的资源在未使用完毕之前,不能被其他进程强行夺走,即只能由获得该资源的进程自己来释放(只能是主动释放)。

5.3 请求与保持条件
进程已经保持了至少一个资源,但又提出了新的资源请求,而该资源已被其他进程占有,此时请求进程被阻塞,但对自己已获得的资源保持不放。

5.4 循环等待条件:
存在一种进程资源的循环等待链,链中每一个进程已获得的资源同时被 链中下一个进程所请求。即存在一个处于等待状态的进程集合{Pl, P2, …, pn},其中Pi等 待的资源被P(i+1)占有(i=0, 1, …, n-1),Pn等待的资源被P0占有,如图2-15所示。

直观上看,循环等待条件似乎和死锁的定义一样,其实不然。按死锁定义构成等待环所 要求的条件更严,它要求Pi等待的资源必须由P(i+1)来满足,而循环等待条件则无此限制。 例如,系统中有两台输出设备,P0占有一台,PK占有另一台,且K不属于集合{0, 1, …, n}。

Pn等待一台输出设备,它可以从P0获得,也可能从PK获得。因此,虽然Pn、P0和其他 一些进程形成了循环等待圈,但PK不在圈内,若PK释放了输出设备,则可打破循环等待, 如图2-16所示。因此循环等待只是死锁的必要条件。
操作系统常见面试总结_第5张图片
资源分配图含圈而系统又不一定有死锁的原因是同类资源数大于1。但若系统中每类资 源都只有一个资源,则资源分配图含圈就变成了系统出现死锁的充分必要条件。

以上这四个条件是死锁的必要条件,只要系统发生死锁,这些条件必然成立,而只要上述条件之一不满足,就不会发生死锁。

6.处理死锁的方法

  • 预防死锁:通过设置某些限制条件,去破坏产生死锁的四个必要条件中的一个或几个条件,来防止死锁的发生。
  • 避免死锁:在资源的动态分配过程中,用某种方法去防止系统进入不安全状态,从而避免死锁的发生。
  • 检测死锁:允许系统在运行过程中发生死锁,但可设置检测机构及时检测死锁的发生,并采取适当措施加以清除。
  • 解除死锁:当检测出死锁后,便采取适当措施将进程从死锁状态中解脱出来。

6.1 预防死锁
破坏“互斥”条件:
就是在系统里取消互斥。若资源不被一个进程独占使用,那么死锁是肯定不会发生的。但一般来说在所列的四个条件中,“互斥”条件是无法破坏的。因此,在死锁预防里主要是破坏其他几个必要条件,而不去涉及破坏“互斥”条件。

注意:互斥条件不能被破坏,否则会造成结果的不可再现性。

破坏“占有并等待”条件:
破坏“占有并等待”条件,就是在系统中不允许进程在已获得某种资源的情况下,申请其他资源。即要想出一个办法,阻止进程在持有资源的同时申请其他资源。
方法一:创建进程时,要求它申请所需的全部资源,系统或满足其所有要求,或什么也不给它。这是所谓的 “ 一次性分配”方案。
方法二:要求每个进程提出新的资源申请前,释放它所占有的资源。这样,一个进程在需要资源S时,须先把它先前占有的资源R释放掉,然后才能提出对S的申请,即使它可能很快又要用到资源R。

破坏“不可抢占”条件:
破坏“不可抢占”条件就是允许对资源实行抢夺。
方法一:如果占有某些资源的一个进程进行进一步资源请求被拒绝,则该进程必须释放它最初占有的资源,如果有必要,可再次请求这些资源和另外的资源。
方法二:如果一个进程请求当前被另一个进程占有的一个资源,则操作系统可以抢占另一个进程,要求它释放资源。只有在任意两个进程的优先级都不相同的条件下,方法二才能预防死锁。

破坏“循环等待”条件:
破坏“循环等待”条件的一种方法,是将系统中的所有资源统一编号,进程可在任何时刻提出资源申请,但所有申请必须按照资源的编号顺序(升序)提出。这样做就能保证系统不出现死锁。

6.2 避免死锁
理解了死锁的原因,尤其是产生死锁的四个必要条件,就可以最大可能地避免、预防和解除死锁。所以,在系统设计、进程调度等方面注意如何让这四个必要条件不成立,如何确定资源的合理分配算法,避免进程永久占据系统资源。此外,也要防止进程在处于等待状态的情况下占用资源。因此,对资源的分配要给予合理的规划。

预防死锁和避免死锁的区别:
预防死锁是设法至少破坏产生死锁的四个必要条件之一,严格的防止死锁的出现,而避免死锁则不那么严格的限制产生死锁的必要条件的存在,因为即使死锁的必要条件存在,也不一定发生死锁。避免死锁是在系统运行过程中注意避免死锁的最终发生。

6.2.1 常用避免死锁的方法
6.2.1.1 有序资源分配法
这种算法资源按某种规则系统中的所有资源统一编号(例如打印机为1、磁带机为2、磁盘为3、等等),申请时必须以上升的次序。系统要求申请进程:
  1、对它所必须使用的而且属于同一类的所有资源,必须一次申请完;
  2、在申请不同类资源时,必须按各类设备的编号依次申请。例如:进程PA,使用资源的顺序是R1,R2; 进程PB,使用资源的顺序是R2,R1;若采用动态分配有可能形成环路条件,造成死锁。
  采用有序资源分配法:R1的编号为1,R2的编号为2;
  PA:申请次序应是:R1,R2
  PB:申请次序应是:R1,R2
  这样就破坏了环路条件,避免了死锁的发生。

6.2.1.2 银行家算法
详见银行家算法


记得添加链接

6.2.2 常用避免死锁的技术
加锁顺序(线程按照一定的顺序加锁)
加锁时限(线程尝试获取锁的时候加上一定的时限,超过时限则放弃对该锁的请求,并释放自己占有的锁)
死锁检测
6.2.2.1 加锁顺序
当多个线程需要相同的一些锁,但是按照不同的顺序加锁,死锁就很容易发生。

如果能确保所有的线程都是按照相同的顺序获得锁,那么死锁就不会发生。
看下面这个例子:

Thread 1:
lock A
lock B
Thread 2:
wait for A
lock C (when A locked)
Thread 3:
wait for A
wait for B
wait for C

如果一个线程(比如线程3)需要一些锁,那么它必须按照确定的顺序获取锁。它只有获得了从顺序上排在前面的锁之后,才能获取后面的锁。

例如,线程2和线程3只有在获取了锁A之后才能尝试获取锁C(译者注:获取锁A是获取锁C的必要条件)。因为线程1已经拥有了锁A,所以线程2和3需要一直等到锁A被释放。然后在它们尝试对B或C加锁之前,必须成功地对A加了锁。

按照顺序加锁是一种有效的死锁预防机制。但是,这种方式需要你事先知道所有可能会用到的锁(译者注:并对这些锁做适当的排序),但总有些时候是无法预知的。

6.2.2.2 加锁时限
另外一个可以避免死锁的方法是在尝试获取锁的时候加一个超时时间,这也就意味着在尝试获取锁的过程中若超过了这个时限该线程则放弃对该锁请求。若一个线程没有在给定的时限内成功获得所有需要的锁,则会进行回退并释放所有已经获得的锁,然后等待一段随机的时间再重试。这段随机的等待时间让其它线程有机会尝试获取相同的这些锁,并且让该应用在没有获得锁的时候可以继续运行(译者注:加锁超时后可以先继续运行干点其它事情,再回头来重复之前加锁的逻辑)。

以下是一个例子,展示了两个线程以不同的顺序尝试获取相同的两个锁,在发生超时后回退并重试的场景:

Thread 1 locks A
Thread 2 locks B
Thread 1 attempts to lock B but is blocked
Thread 2 attempts to lock A but is blocked
Thread 1’s lock attempt on B times out
Thread 1 backs up and releases A as well
Thread 1 waits randomly (e.g. 257 millis) before retrying.
Thread 2’s lock attempt on A times out
Thread 2 backs up and releases B as well
Thread 2 waits randomly (e.g. 43 millis) before retrying.
————————————————
版权声明:本文为CSDN博主「wljliujuan」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
原文链接:https://blog.csdn.net/wljliujuan/article/details/79614019

在上面的例子中,线程2比线程1早200毫秒进行重试加锁,因此它可以先成功地获取到两个锁。这时,线程1尝试获取锁A并且处于等待状态。当线程2结束时,线程1也可以顺利的获得这两个锁(除非线程2或者其它线程在线程1成功获得两个锁之前又获得其中的一些锁)。

需要注意的是,由于存在锁的超时,所以我们不能认为这种场景就一定是出现了死锁。也可能是因为获得了锁的线程(导致其它线程超时)需要很长的时间去完成它的任务。

此外,如果有非常多的线程同一时间去竞争同一批资源,就算有超时和回退机制,还是可能会导致这些线程重复地尝试但却始终得不到锁。如果只有两个线程,并且重试的超时时间设定为0到500毫秒之间,这种现象可能不会发生,但是如果是10个或20个线程情况就不同了。因为这些线程等待相等的重试时间的概率就高的多(或者非常接近以至于会出现问题)。
(译者注:超时和重试机制是为了避免在同一时间出现的竞争,但是当线程很多时,其中两个或多个线程的超时时间一样或者接近的可能性就会很大,因此就算出现竞争而导致超时后,由于超时时间一样,它们又会同时开始重试,导致新一轮的竞争,带来了新的问题。)

这种机制存在一个问题,在Java中不能对synchronized同步块设置超时时间。你需要创建一个自定义锁,或使用Java5中java.util.concurrent包下的工具。写一个自定义锁类不复杂,但超出了本文的内容。后续的Java并发系列会涵盖自定义锁的内容。

6.2.2.3 死锁检测
死锁检测是一个更好的死锁预防机制,它主要是针对那些不可能实现按序加锁并且锁超时也不可行的场景。

每当一个线程获得了锁,会在线程和锁相关的数据结构中(map、graph等等)将其记下。除此之外,每当有线程请求锁,也需要记录在这个数据结构中。

当一个线程请求锁失败时,这个线程可以遍历锁的关系图看看是否有死锁发生。例如,线程A请求锁7,但是锁7这个时候被线程B持有,这时线程A就可以检查一下线程B是否已经请求了线程A当前所持有的锁。如果线程B确实有这样的请求,那么就是发生了死锁(线程A拥有锁1,请求锁7;线程B拥有锁7,请求锁1)。

当然,死锁一般要比两个线程互相持有对方的锁这种情况要复杂的多。线程A等待线程B,线程B等待线程C,线程C等待线程D,线程D又在等待线程A。线程A为了检测死锁,它需要递进地检测所有被B请求的锁。从线程B所请求的锁开始,线程A找到了线程C,然后又找到了线程D,发现线程D请求的锁被线程A自己持有着。这是它就知道发生了死锁。

下面是一幅关于四个线程(A,B,C和D)之间锁占有和请求的关系图。像这样的数据结构就可以被用来检测死锁。

操作系统常见面试总结_第6张图片
6.3 检测死锁
一般来说,由于操作系统有并发,共享以及随机性等特点,通过预防和避免的手段达到排除死锁的目的是很困难的。这需要较大的系统开销,而且不能充分利用资源。为此,一种简便的方法是系统为进程分配资源时,不采取任何限制性措施,但是提供了检测和解脱死锁的手段:能发现死锁并从死锁状态中恢复出来。因此,在实际的操作系统中往往采用死锁的检测与恢复方法来排除死锁。
死锁检测与恢复是指系统设有专门的机构,当死锁发生时,该机构能够检测到死锁发生的位置和原因,并能通过外力破坏死锁发生的必要条件,从而使得并发进程从死锁状态中恢复出来。
这时进程P1占有资源R1而申请资源R2,进程P2占有资源R2而申请资源R1,按循环等待条件,进程和资源形成了环路,所以系统是死锁状态。进程P1,P2是参与死锁的进程。
下面我们再来看一看死锁检测算法。算法使用的数据结构是如下这些:
占有矩阵A:nm阶,其中n表示并发进程的个数,m表示系统的各类资源的个数,这个矩阵记录了每一个进程当前占有各个资源类中资源的个数。
申请矩阵R:n
m阶,其中n表示并发进程的个数,m表示系统的各类资源的个数,这个矩阵记录了每一个进程当前要完成工作需要申请的各个资源类中资源的个数。
空闲向量T:记录当前m个资源类中空闲资源的个数。
完成向量F:布尔型向量值为真(true)或假(false),记录当前n个并发进程能否进行完。为真即能进行完,为假则不能进行完。
临时向量W:开始时W:=T。
算法步骤:
(1)W:=T,
对于所有的i=1,2,…,n,
如果A[i]=0,则F[i]:=true;否则,F[i]:=false
(2)找满足下面条件的下标i:
F[i]:=false并且R[i]〈=W
如果不存在满足上面的条件i,则转到步骤(4)。
(3)W:=W+A[i]
F[i]:=true
转到步骤(2)
(4)如果存在i,F[i]:=false,则系统处于死锁状态,且Pi进程参与了死锁。什么时候进行死锁的检测取决于死锁发生的频率。如果死锁发生的频率高,那么死锁检测的频率也要相应提高,这样一方面可以提高系统资源的利用率,一方面可以避免更多的进程卷入死锁。如果进程申请资源不能满足就立刻进行检测,那么每当死锁形成时即能被发现,这和死锁避免的算法相近,只是系统的开销较大。为了减小死锁检测带来的系统开销,一般采取每隔一段时间进行一次死锁检测,或者在CPU的利用率降低到某一数值时,进行死锁的检测。

6.4 解除死锁
一旦检测出死锁,就应立即釆取相应的措施,以解除死锁。
死锁解除的主要方法有:

  1. 资源剥夺法。挂起某些死锁进程,并抢占它的资源,将这些资源分配给其他的死锁进程。但应防止被挂起的进程长时间得不到资源,而处于资源匮乏的状态。
  2. 撤销进程法。强制撤销部分、甚至全部死锁进程并剥夺这些进程的资源。撤销的原则可以按进程优先级和撤销进程代价的高低进行。
  3. 进程回退法。让一(多)个进程回退到足以回避死锁的地步,进程回退时自愿释放资源而不是被剥夺。要求系统保持进程的历史信息,设置还原点。

**六、操作系统的常见进程调度算法

a.先来先服务 (FCFS,first come first served)
在所有调度算法中,最简单的是非抢占式的FCFS算法。
算法原理:进程按照它们请求CPU的顺序使用CPU.就像你买东西去排队,谁第一个排,谁就先被执行,在它执行的过程中,不会中断它。当其他人也想进入内存被执行,就要排队等着,如果在执行过程中出现一些事,他现在不想排队了,下一个排队的就补上。此时如果他又想排队了,只能站到队尾去。
算法优点:易于理解且实现简单,只需要一个队列(FIFO),且相当公平
算法缺点:比较有利于长进程,而不利于短进程,有利于CPU 繁忙的进程,而不利于I/O 繁忙的进程

b.最短作业优先(SJF, Shortest Job First)
短作业优先(SJF, Shortest Job First)又称为“短进程优先”SPN(Shortest Process Next);这是对FCFS算法的改进,其目标是减少平均周转时间。
算法原理:对预计执行时间短的进程优先分派处理机。通常后来的短进程不抢先正在执行的进程。
算法优点:相比FCFS 算法,该算法可改善平均周转时间和平均带权周转时间,缩短进程的等待时间,提高系统的吞吐量。
算法缺点:对长进程非常不利,可能长时间得不到执行,且未能依据进程的紧迫程度来划分执行的优先级,以及难以准确估计进程的执行时间,从而影响调度性能。

c.最高响应比优先法(HRRN,Highest Response Ratio Next)
最高响应比优先法(HRRN,Highest Response Ratio Next)是对FCFS方式和SJF方式的一种综合平衡。FCFS方式只考虑每个作业的等待时间而未考虑执行时间的长短,而SJF方式只考虑执行时间而未考虑等待时间的长短。因此,这两种调度算法在某些极端情况下会带来某些不便。HRN调度策略同时考虑每个作业的等待时间长短和估计需要的执行时间长短,从中选出响应比最高的作业投入执行。这样,即使是长作业,随着它等待时间的增加,W / T也就随着增加,也就有机会获得调度执行。这种算法是介于FCFS和SJF之间的一种折中算法。
算法原理:响应比R定义如下: R =(W+T)/T = 1+W/T
其中T为该作业估计需要的执行时间,W为作业在后备状态队列中的等待时间。每当要进行作业调度时,系统计算每个作业的响应比,选择其中R最大者投入执行。
算法优点:由于长作业也有机会投入运行,在同一时间内处理的作业数显然要少于SJF法,从而采用HRRN方式时其吞吐量将小于采用SJF 法时的吞吐量。
算法缺点:由于每次调度前要计算响应比,系统开销也要相应增加。

d.时间片轮转算法(RR,Round-Robin)
该算法采用剥夺策略。时间片轮转调度是一种最古老,最简单,最公平且使用最广的算法,又称RR调度。每个进程被分配一个时间段,称作它的时间片,即该进程允许运行的时间。
算法原理:让就绪进程以FCFS 的方式按时间片轮流使用CPU 的调度方式,即将系统中所有的就绪进程按照FCFS 原则,排成一个队列,每次调度时将CPU 分派给队首进程,让其执行一个时间片,时间片的长度从几个ms 到几百ms。在一个时间片结束时,发生时钟中断,调度程序据此暂停当前进程的执行,将其送到就绪队列的末尾,并通过上下文切换执行当前的队首进程,进程可以未使用完一个时间片,就出让CPU(如阻塞)。
算法优点:时间片轮转调度算法的特点是简单易行、平均响应时间短。
算法缺点:不利于处理紧急作业。在时间片轮转算法中,时间片的大小对系统性能的影响很大,因此时间片的大小应选择恰当
怎样确定时间片的大小:

时间片大小的确定
1.系统对响应时间的要求
2.就绪队列中进程的数目
3.系统的处理能力

e.多级反馈队列(Multilevel Feedback Queue)
多级反馈队列调度算法是一种CPU处理机调度算法,UNIX操作系统采取的便是这种调度算法。
多级反馈队列调度算法描述:
  1、进程在进入待调度的队列等待时,首先进入优先级最高的Q1等待。
  2、首先调度优先级高的队列中的进程。若高优先级中队列中已没有调度的进程,则调度次优先级队列中的进程。例如:Q1,Q2,Q3三个队列,只有在Q1中没有进程等待时才去调度Q2,同理,只有Q1,Q2都为空时才会去调度Q3。
  3、对于同一个队列中的各个进程,按照时间片轮转法调度。比如Q1队列的时间片为N,那么Q1中的作业在经历了N个时间片后若还没有完成,则进入Q2队列等待,若Q2的时间片用完后作业还不能完成,一直进入下一级队列,直至完成。
  4、在低优先级的队列中的进程在运行时,又有新到达的作业,那么在运行完这个时间片后,CPU马上分配给新到达的作业(抢占式)。
  在多级反馈队列调度算法中,如果规定第一个队列的时间片略大于多数人机交互所需之处理时间时,便能够较好的满足各种类型用户的需要。

**七、磁盘调度算法寻道问题

常用的磁盘调度算法有四种:

  • 先来先服务算法(FCFS)
  • 最短寻道时间优先算法(SSTF)
  • 扫描算法(SCAN)
  • 循环扫描算法(CSCAN)

先来先服务算法(First Come First Service)
FCFS算法根据进程请求访问磁盘的先后顺序进行调度,是一种最简单的调度算法。
例1:某一磁盘请求序列(磁道号):98、 183、 37、122、14、124、 65、 61,按照先来先服务FCFS磁盘调度对磁盘进行请求服务,假设当前磁头在53道上,则磁臂总移动道数为多少?

先来先服务,按进程请求访问磁盘的先后次序进行调度。
当前磁道:53
操作系统常见面试总结_第7张图片
总移动道数=45+85+146+85+108+110+59+4=642

最短寻道时间优先磁盘调度算法(SSTF)Shortest Seek Time First
算法选择这样的进程,其要求访问的磁道与当前磁头所在的磁道距离最近,以使每次的寻道时间最短,该算法可以得到比较好的吞吐量,但却不能保证平均寻道时间最短。其缺点是对用户的服务请求的响应机会不是均等的,因而导致响应时间的变化幅度很大。在服务请求很多的情况下,对内外边缘磁道的请求将会无限期的被延迟,有些请求的响应时间将不可预期。

例2:若干个等待访问磁盘者依次要访问的磁道为 19, 43, 40, 4, 79,11,76,当前磁头位于 40 号柱面,若用最短寻道时间优先磁盘调度算法,则访问序列为_

根据最短寻道时间优先磁盘调度算法,每次在寻找下一个磁道时,都要选择离自己最近的,所以当前磁头位于40号,下一道选择与40绝对值最小的,即40,再下一道选择43,差值为3,以此类推,最后的访问序列应该为,40,43,19,11,4,76,79.

扫描算法(SCAN)电梯调度
优先考虑磁头的当前移动方向,并且考虑当前磁道与下一磁道之间的距离。例如,当磁头正在自里向外移动时,扫描算法所选择的下一个访问对象应该是,即在当前磁道之外,又距离最近。这样自里向外的访问,直到再无更外的磁道需要访问才将磁臂换向,自外向里移动。移动原则同前一致。
由于这种算法中磁头移动的规律与电梯的运行相似,因此又称为电梯调度算法。

循环扫描算法(CSCAN)

循环扫描算法是对扫描算法的改进。如果对磁道的访问请求是均匀分布的,当磁头到达磁盘的一端,并反向运动时落在磁头之后的访问请求相对较少。这是由于这些磁道刚被处理,而磁盘另一端的请求密度相当高,且这些访问请求等待的时间较长,为了解决这种情况,循环扫描算法规定磁头单向移动。例如,只自里向外移动,当磁头移到最外的被访问磁道时,磁头立即返回到最里的欲访磁道,即将最小磁道号紧接着最大磁道号构成循环,进行扫描。

前两道题目为牛客网上的练习题,之前应该是某家互联网公司网申的笔试题目,下面一道例题将用四种不同磁盘调度算法来解答。

例题
假设移动头磁盘有200个磁道(从0号到199号)。目前正在处理143号磁道上的请求,而刚刚处理结束的请求是125号,如果下面给出的顺序是按FIFO排成的等待服务队列顺序:86,147,91,177,94,150,102,175,130
那么,用下列各种磁盘调度算法来满足这些请求所需的总磁头移动量是多少?
(1) FCFS;(2) SSTF;(3) SCAN;(4) LOOK;(5) C-SCAN?

FCFS(先来先服务)
操作系统常见面试总结_第8张图片
SSTF(最短寻道时间优先)
寻道顺序:当前143,147,150,130,102,94,91,86,175,177;
4+3+20+28+8+3+5+89+2=162;

SCAN(电梯调度算法)
当前方向:从143向磁道号增加的方向
依次访问:147,150,175,177
反方向:130,102,94,91,86
4+3+25+2+47+28+8+3+5=125

CSCAN
当前方向:从143向磁道号增加的方向
依次访问:147,150,175,177
再从0开始增加方向:86,91,94,102,130
此处移动总距离存在疑问

**八、对待死锁的策略、死锁预防、死锁避免(银行家算法)、死锁检测、死锁解除

具体参看第5点-死锁的四个必要条件

**九、线程模型(go的调度模型,java的调度模型,python的调度模型)

多线程内部情况(三种线程模型)

1.一对一模型
一般直接使用API或系统调用创建的线程均为一对一的线程。一个用户使用的线程就唯一对应一个内核使用的线程。

优点:用户线程具有了和内核线程一致的有点

缺点:1、由于许多操作系统箱子了内核线程数量,因此一对一线程会让用户的线程数量受到限制/2、许多操作系统内核线程调度时,上下文切换的开销较大,导致用户线程的执行效率下降。
操作系统常见面试总结_第9张图片
2.多对一模型
多对一模型将多个用户线程映射到一个内核线程上,线程之间的切换由用户的代码来进行。

优点:因此相对于一对一模型,多对一模型的线程切换要快速许多。
缺点:多对一模型一大问题是,如果其中一个用户线程阻塞,那么所有的线程将都无法执行,因为此时内核里的线程也会随之阻塞。另外,在多处理器系统上,处理器的增多线程性能也不会有明显的帮助。但同时,多对一模型得到的好处是高效的上下文切换和几乎无限制的线程数量。

操作系统常见面试总结_第10张图片
3.多对多模型
结合了多对一模型和一对一模型的特点,将多个用户线程映射到少数但不止一个内核线程上。
操作系统常见面试总结_第11张图片
该部分选自CSDN博客
python线程模型,好多内容,慢慢看
**十、单核CPU中的线程会存在线程安全问题吗?

单核cpu上多线程仍然会存在线程安全问题,因为单核cpu仍然存在线程切换,在执行非原子操作的时候,仍然存在线程问题。
**十一、前台进程和后台进程的区别

后台进程又叫守护进程,你知道吗?
操作系统中,前台进程和后台进程有什么区别?特征是什么?

后台程序基本上不和用户交互,优先级别稍微低一点
前台的程序和用户交互,需要较高的响应速度,优先级别稍微高一点

直接从后台手工启动一个进程用得比较少一些,除非是该进程甚为耗时,且用户也不急着需要结果的时候。假设用户要启动一个需要长时间运行的格式化文本文件的进程。为了不使整个shell在格式化过程中都处于“瘫痪”状态,从后台启动这个进程是明智的选择。

LINUX后台进程与前台进程的区别

LINUX后台进程也叫守护进程(Daemon),是运行在后台的一种特殊进程。它独立于控制终端并且周期性地执行某种任务或等待处理某些发生的事件。

一般用作系统服务,可以用crontab提交,编辑或者删除相应得作业。

守护的意思就是不受终端控制。Linux的大多数服务器就是用守护进程实现的。比如,Internet服务器inetd,Web服务器httpd等。同时,守护进程完成许多系统任务。比如,作业规划进程crond,打印进程lpd等。

前台进程就是用户使用的有控制终端的进程

shell下,进程的前台与后台运行
跟系统任务相关的几个命令:fg、bg、jobs、&、ctrl+z

1.& 最经常被用到

这个用在一个命令的最后,可以把这个命令放到后台执行

2.ctrl + z

可以将一个正在前台执行的命令放到后台,并且暂停

3.jobs

查看当前有多少在后台运行的命令

4.fg 将后台中的命令调至前台继续运行

如果后台中有多个命令,可以用 fg %jobnumber将选中的命令调出,%jobnumber是通过jobs命令查到的后台正在执行的命令的序号(不是pid)

5.bg 将一个在后台暂停的命令,变成继续执行
如果后台中有多个命令,可以用bg %jobnumber将选中的命令调出,%jobnumber是通过jobs命令查到的后台正在执行的命令的序号(不是pid)

  • 1.jobs列举出后台作业信息。([作业号] 运行状态 作业名称)

  • 2.ctrl+z 将任务放到后台去,并暂停;

  • 3.bg <%int> 将后台任务唤醒,在后台运行;

  • 4.fg <%int> 将后任务的程序放到前台;

  • 5.ctrl+z 将任务放到后台去,并暂停.

主进程waitpid(pid,&status,WUNTRACED)时,子进程

退出时,父进程被唤醒

  • 6.将后台任务唤醒,在后台运行;

kill(pid,SIGCONT);

  • 7.将后台运行的程序放到前台;

kill(pid,SIGCONT);

waitpid(pid,&status,WUNTRACED);

可见,后台运行与前台运行的区别只在于前台运行等待子进程的退出而阻塞父进程操作。而后台运行时,可以在父进程中输入命令继续其他操作。本质上没有区别,都是给子进程发送SIGCONT信号。

该部分内容节选自博客园博客

**十二、五种I/O模型、epoll、poll和select的区别

1.五种IO模型,分别是:阻塞IO、非阻塞IO、多路复用IO、信号驱动IO以及异步IO。

我们在这里就介绍并实现这5种模型。介绍之前,请允许我引用某段比喻

阻塞IO, 给女神发一条短信, 说我来找你了, 然后就默默的一直等着女神下楼, 这个期间除了等待你不会做其他事情, 属于备胎做法.
非阻塞IO, 给女神发短信, 如果不回, 接着再发, 一直发到女神下楼, 这个期间你除了发短信等待不会做其他事情, 属于专一做法.
IO多路复用, 是找一个宿管大妈来帮你监视下楼的女生, 这个期间你可以些其他的事情. 例如可以顺便看看其他妹子,玩玩王者荣耀, 上个厕所等等. IO复用又包括 select, poll, epoll 模式. 那么它们的区别是什么?

1) select大妈 每一个女生下楼, select大妈都不知道这个是不是你的女神, 她需要一个一个询问, 并且select大妈能力还有限, 最多一次帮你监视1024个妹子

2) poll大妈不限制盯着女生的数量, 只要是经过宿舍楼门口的女生, 都会帮你去问是不是你女神

3) epoll大妈不限制盯着女生的数量, 并且也不需要一个一个去问. 那么如何做呢? epoll大妈会为每个进宿舍楼的女生脸上贴上一个大字条,上面写上女生自己的名字, 只要女生下楼了, epoll大妈就知道这个是不是你女神了, 然后大妈再通知你.
上面这些同步IO有一个共同点就是, 当女神走出宿舍门口的时候, 你已经站在宿舍门口等着女神的, 此时你属于阻塞状态

接下来是异步IO的情况:
你告诉女神我来了, 然后你就去打游戏了, 一直到女神下楼了, 发现找不见你了, 女神再给你打电话通知你, 说我下楼了, 你在哪呢? 这时候你才来到宿舍门口。 此时属于逆袭做法

阻塞IO 与 非阻塞IO
在Linux 系统编程中,我们 在 有关概念 章节中介绍了阻塞的概念。那么也很容易理解什么是阻塞IO与非阻塞IO。直接看图

阻塞IO
最传统的一种IO模型,即在读写数据过程中会发生阻塞现象。

当用户线程发出IO请求之后,内核会去查看数据是否就绪,如果没有就绪就会等待数据就绪,而用户线程就会处于阻塞状态,用户线程交出CPU。当数据就绪之后,内核会将数据拷贝到用户线程,并返回结果给用户线程,用户线程才解除block状态。
操作系统常见面试总结_第12张图片
代码如下:

	printf("Calling recv(). \n");
	ret =  recv(socket, recv_buf, sizeof(recv_buf), 0); 
	printf("Had called recv(). \n");

也许有人会说,可以采用多线程+ 阻塞IO 来解决效率问题,但是由于在多线程 + 阻塞IO 中,每个socket对应一个线程,这样会造成很大的资源占用,并且尤其是对于长连接来说,线程的资源一直不会释放,如果后面陆续有很多连接的话,就会造成性能上的瓶颈。

非阻塞IO
当用户线程发起一个IO操作后,并不需要等待,而是马上就得到了一个结果。如果结果是一个error时,它就知道数据还没有准备好,于是它可以再次发送IO操作。一旦内核中的数据准备好了,并且又再次收到了用户线程的请求,那么它马上就将数据拷贝到了用户线程,然后返回。

在非阻塞IO模型中,用户线程需要不断地询问内核数据是否就绪,也就说非阻塞IO不会交出CPU,而会一直占用CPU。

对于非阻塞IO就有一个非常严重的问题,在while循环中需要不断地去询问内核数据是否就绪,这样会导致CPU占用率非常高,因此一般情况下很少使用while循环这种方式来读取数据。
操作系统常见面试总结_第13张图片
代码如下:

while(1)
{
	printf("Calling recv(). \n");
	ret =  recv(socket, recv_buf, sizeof(recv_buf), 0); 
	if (EAGAIN == ret) {continue;}
    else if(ret > -1) { break;}
	printf("Had called recv(), retry.\n");
}

IO复用模型 ( I/O multiplexing )
所谓I/O多路复用机制,就是说通过一种机制,可以监视多个描述符,一旦某个描述符就绪(一般是读就绪或者写就绪),能够通知程序进行相应的读写操作。这种机制的使用需要额外的功能来配合: select、poll、epoll

select、poll,epoll本质上都是同步I/O,因为他们都需要在读写事件就绪后自己负责进行读写,也就是说这个读写过程是阻塞的。

select时间复杂度O(n)

它仅仅知道了,有I/O事件发生了,却并不知道是哪那几个流(可能有一个,多个,甚至全部),我们只能无差别轮询所有流,找出能读出数据,或者写入数据的流,对他们进行操作。所以select具有O(n)的无差别轮询复杂度,同时处理的流越多,无差别轮询时间就越长。

poll时间复杂度O(n)

poll本质上和select没有区别,它将用户传入的数组拷贝到内核空间,然后查询每个fd对应的设备状态, 但是它没有最大连接数的限制,原因是它是基于链表来存储的.

epoll时间复杂度O(1)

epoll可以理解为event poll,不同于忙轮询和无差别轮询,epoll会把哪个流发生了怎样的I/O事件通知我们。所以我们说epoll实际上是事件驱动(每个事件关联上fd)的,此时我们对这些流的操作都是有意义的。(复杂度降低到了O(1))

在多路复用IO模型中,会有一个内核线程不断去轮询多个socket的状态,只有当真正读写事件发生时,才真正调用实际的IO读写操作。因为在多路复用IO模型中,只需要使用一个线程就可以管理多个socket,系统不需要建立新的进程或者线程,也不必维护这些线程和进程,并且只有在真正有读写事件进行时,才会使用IO资源,所以它大大减少了资源占用。

这部分内容太多了,读者自己去细看,这是大佬博客链接

信号驱动IO:
调用sigaltion系统调用。当内核中IO数据就绪时以SIGIO信号通知请求进程,请求进程再把数据从内核读入到用户空间,这一步是阻塞的。
异步IO:
在该模型中,当用户线程收到通知时,数据已经被内核读取完毕,并放在了用户线程指定的缓冲区内,内核在IO完成后通知用户线程直接使用即可。“真正”异步IO需要操作系统更强的支持。
信号驱动和异步IO并不十分常用。我们一般使用IO多路复用模拟异步IO的方式。

2.select、poll和epoll的区别

首先我们从他们所占有的平台来看

select 是跨平台的 windows、unix、linux下都有
poll 在linux、unix下有,windows下没有
epoll 只有linux特有,unix和windows下没有

其次从函数原型来看

一. select多路复用
select()函数允许进程指示内核等待多个事件中(文件描述符)的任何一个发生,并只在有一个或多个事件发生或经历一段指定时间后才唤醒它,然后接下来判断究竟是哪个文件描述符发生了事件并进行相应的处理。
函数原型及说明

struct timeval
{
    long tv_sec;     //seconds
    long tv_usec;   //microseconds
 };
 
FD_ZERO(fd_set *fds)      //清空集合
FD_SET(int fd,fd_set *fds)     //将给定的描述符加入集合
FD_ISSET(int fd,fd_set *fds)   //判断指定描述符是否在集合中
FD_CLR(int fd,fd_set *fds)     //将给定的描述符从文件中删除

int select(int max_fd , fd_set * readset , fd_set * writeset , fd_set * exceptset , struct timeval * timeout);
说明:select监视的并等待多个文件描述符的属性发生变化,监视的属性分为3类,分别是readfds(文件描述符有数据到来可读),writefds(文件描述符可写),exceptfds(文件描述符异常)。调用select函数会阻塞,直到有文件描述符就绪(有数据可读、可写、错误异常),或者超时(timeout 指定等待时间)发生函数才返回。当select()函数返回后,可以通过遍历fdset,来找到究竟是哪些文件描述符就绪。
1.select函数的返回值是就绪描述符的数目,超时时返回0,出错返回-1;
2.max_fd指待测试的fd的总个数,它的值是待测试的最大文件描述符加1(linux内核是从0开始到max_fd-1扫描文件描述符)。
3.中间三个参数readset、writeset和exxceptset指定要让内核测试读、写和异常条件的fd集合,如果不需要测试的可以设置为NULL;
4.最后一个参数是设置select的超时时间,如果设置为NULL则永不超时。

二. poll多路复用

poll函数的原型及说明

struct poll
{ 
    int    fd;
    short  events;
    short revents;
 };
 int  poll(struct  pollfd *fds , nfds_t   nfds,int  timeout);

说明:
第一个参数:用来指向一个struct pollfd类型的一个数组,结构体的events域是用户传给内核的,revents域是内核返还给用户的。
第二个参数:nfds指定数组中监听的元素个数。
第三个参数:timeout指定等待的毫秒数,无论I/O是否准备好,poll都会返回。timeout指定为负数值表示永不超时,使poll()一直挂起直到一个指定事件发生;timeout为0指示poll调用立即返回并列出准备好I/O的文件描述符,但并不等待其他事件的发生。

三. epoll多路复用

epoll的设计和实现与select完全不同。epoll通过在linux内核中申请一个简易的文件系统,把原先的select/poll调用分成3个部分:
1.调用epoll_create()建立一个epoll对象

int epoll_create(int size);

若创建成功返回文件描述符,若失败返回-1。参数size指定了我们想要通过epoll实例来检查的文件描述符个数,该参数并不是一个上限,而是告诉内核应该如何为内部数据结构划分初始大小。

2.调用epoll_ctl向epoll对象中添加这100万个连接的套接字

int epoll_ctl(int  epfd,int op,int fd,struct epoll_event *ev);

系统调用epoll_ctl()能够修改由文件描述符epfd所代表的epoll实例中的兴趣列表。若成功返回0,若出错返回-1。
第一个参数:epfd是epoll_create()的返回值。

第二个参数op:用来指定需要执行的操作,它的值可以是如下几种值:

EPOLL_CTL_ADD:将描述符fd添加到epoll实例中的兴趣列表中去。对于fd上我们感兴趣的事件,都指定在ev所指向的结构体中。如果我们试图向兴趣列表中添加一个已存在的文件描述符,epoll_ctl()将出现EEXIST错误。
EPOLL_CTL_MOD:修改描述符上设定的事件。需要用到由ev所指向的结构体中的信息。
EPOLL_CTL_DEL:将文件描述符fd从epfd的兴趣列表中移除,该操作忽略参数ev。

第三个参数fd:指明了要修改兴趣列表中的哪一个文件描述符的设定。

第四个参数ev:指向结构体epoll_event的指针。
结构体定义如下:

typedef union epoll_data
{
    void   *ptr;
    int      fd;
    uint32_t   u32;
    uint64_t   u64;
};

struct epoll_event
{
    uint32_t   events;
    epoll_data_t  data;
};


参数ev为文件描述符fd所做的设置如下:
events字段是一个位掩码,他指定了我们为待检查的描述符fd上所感兴趣的事件的集合;

data字段是一个联合体,当描述符fd稍后称为就绪态,联合的成员可用来指定传回给调用进程的信息;

3.调用epoll_wait收集发生的事件的连接

int epoll_wait(int  epfd , struct epoll_event  * evlist , int  maxevent , int  timeout);

系统调用epoll_wait()返回实例中处于就绪态的文件描述符信息,单个epoll_wait()调用能够返回多个就绪态文件描述符的信息。调用成功后epoll_wait()返回数组evlist中的元素个数。如果在timeout超时间隔内没有任何文件描述符处于就绪态的话就返回0,出错时返回-1。
**第一个参数epfd:**epoll_create()的返回值。

**第二个参数evlist:**所指向的结构体数组中返回的是有关就绪态文件描述符的信息,数组evlist的空间由调用者负责申请。

**第三个参数maxevents:**指定evlist数组中所包含的元素个数。

**第四个参数timeout:**用来确定epoll_wait()的阻塞行为,由如下几种:

  • 如果timeout等于-1,调用将一直阻塞,直到兴趣列表中的文件描述符上有事件产生或直到捕获到一个信号为止。
  • 如果timeout等于0,执行一次非阻塞式的检查,看兴趣列表中的文件描述符上产生了哪个事件。
  • 如果timeout大于0,调用将阻塞至多timeout毫秒,直到文件描述符上有事件发生,或者直到捕获到一个信号为止。

最后从各自的优缺点来看

select 的缺点:
它不但对所监视的文件描述符有数量上的限制(虽然数量可以修改,但也会造成效率的降低),而且每次访问一个文件描述符都要遍历所有的文件描述符。内核/用户空间存在拷贝问题,select需要复制大量的句柄数据结构,这对系统产生了巨大的开销。select的触发方式是水平触发,应用程序如果没有完成对一个已经就绪的文件描述符进行I/O操作,那么之后每次select调用还是会将这些文件描述符通知进程。

**poll:**由于使用链表保存文件描述符,所以没有监视文件描述符数量上的限制,但其他缺点依然存在。

**epoll:**它能显著提高程序在大量并发连接中只有少量活跃的情况下的系统CPU利用率。在获取事件的时候无需遍历所有被监听的文件描述符集,只需要遍历被内核IO事件异步唤醒而加入就绪队列的文件描述符集就行。它还采用两种触发模式边沿触发和水平触发。

该部分选自CSDN博客

**十三、linux共享内存如何实现?

说起共享内存,一般来说会让人想起下面一些方法:
**1、多线程。**线程之间的内存都是共享的。更确切的说,属于同一进程的线程使用的是同一个地址空间,而不是在不同地址空间之间进行内存共享;
**2、父子进程间的内存共享。**父进程以MAP_SHARED|MAP_ANONYMOUS选项mmap一块匿名内存,fork之后,其子孙进程之间就能共享这块内存。这种共享内存由于受到进程父子关系的限制,一般较少使用;
**3、mmap文件。**多个进程mmap到同一个文件,实际上就是大家在共享文件page cache中的内存。不过文件牵涉到磁盘的读写,用来做共享内存显然十分笨重,所以就有了不跟磁盘扯上关系的内存文件,也就是我们这里要讨论的tmpfs和shmem;

tmpfs是一套虚拟的文件系统,在其中创建的文件都是基于内存的,机器重启即消失。
shmem是一套ipc,通过相应的ipc系统调用shmget能够以指定key创建一块的共享内存。需要使用这块内存的进程可以通过shmat系统调用来获得它。
虽然是两套不同的接口,但是在内核里面的实现却是同一套。shmem内部挂载了一个tmpfs分区(用户不可见),shmget就是在该分区下获取名为"SYSV${key}"的文件。然后shmat就相当于mmap这个文件。
所以我们接下来就把tmpfs和shmem当作同一个东西来讨论了。

tmpfs/shmem是一个介于文件和匿名内存之间的东西。
一方面,它具有文件的属性,能够像操作文件一样去操作它。它有自己inode、有自己的page cache;
另一方面,它也有匿名内存的属性。由于没有像磁盘这样的外部存储介质,内核在内存紧缺时不能简单的将page从它们的page cache中丢弃,而需要swap-out;(参阅《linux页面回收浅析》)

对tmpfs/shmem内存的读写,就是对page cache中相应位置的page所代表的内存进行读写,这一点跟普通的文件映射没有什么不同。
如果进程地址空间的相应位置尚未映射,则会建立到page cache中相应page的映射;
如果page cache中的相应位置还没有分配page,则会分配一个。当然,由于不存在磁盘上的源数据,新分配的page总是空的(特别的,通过read系统调用去读一个尚未分配page的位置时,并不会分配新的page,而是共享ZERO_PAGE);
如果page cache中相应位置的page被回收了,则会先将其恢复;

对于第三个“如果”,tmpfs/shmem和普通文件的page回收及其恢复方式是不同的:
page回收时,跟普通文件的情况一样,内核会通过prio_tree反向映射找到映射这个page的每一个page table,然后将其中对应的pte清空。
不同之处是普通文件的page在确保与磁盘同步(如果page为脏的话需要刷回磁盘)之后就可以丢弃了,而对于tmpfs/shmem的page则需要进行swap-out。
注意,匿名page在被swap-out时,并不是将映射它的pte清空,而是得在pte上填写相应的swap_entry,以便知道page被换出到哪里去,否则再需要这个page的时候就没法swap-in了。
而tmpfs/shmem的page呢?page table中对应的pte被清空,swap_entry会被存放在page cache的radix_tree的对应slot上。

等下一次访问触发page fault时,page需要恢复。
普通文件的page恢复跟page未分配时的情形一样,需要新分配page、然后根据映射的位置重新从磁盘读出相应的数据;
而tmpfs/shmem则是通过映射的位置找到radix_tree上对应的slot,从中得到swap_entry,从而进行swap-in,并将新的page放回page cache;

这里就有个问题了,在page cache的radix_tree的某个slot上,怎么知道里面存放着的是正常的page?还是swap-out后留下的swap_entry?
如果是swap_entry,那么slot上的值将被加上RADIX_TREE_EXCEPTIONAL_ENTRY标记(值为2)。swap_entry的值被左移两位后OR上RADIX_TREE_EXCEPTIONAL_ENTRY,填入slot。
也就是说,如果KaTeX parse error: Expected 'EOF', got '&' at position 8: {slot} &̲ RADIX_TREE_EXC…{slot} >> 2;否则它代表page,${slot}就是指向page的指针,当然其值可能是NULL,说明page尚未分配。
那么显然,page的地址值其末两位肯定是0,否则就可能跟RADIX_TREE_EXCEPTIONAL_ENTRY标记冲突了;而swap_entry的值最大只能是30bit或62bit(对应32位或64位机器),否则左移两位就溢出了。

最后以一张图说明一下匿名page、文件映射page、tmpfs/shmem page的回收及恢复过程:
操作系统常见面试总结_第14张图片
该部分选自博客园

**十四、fork命令作用是什么?开启一个shell界面对应什么操作?

1.fork命令作用:
在Linux中fork函数是非常重要的函数,它的作用是从已经存在的进程中创建一个子进程,而原进程称为父进程。

#include//引用fork时的头文件
pid_t   fork(void);//fork的返回类型为空1

操作系统常见面试总结_第15张图片
调用fork(),当控制转移到内核中的fork代码后,内核开始做:
1.分配新的内存块和内核数据结构给子进程。
2.将父进程部分数据结构内容拷贝至子进程。
3.将子进程添加到系统进程列表。
4.fork返回开始调度器,调度。
来段代码:

1 #include<stdio.h> 
 2 #include<unistd.h> 
 3 #include<stdlib.h> 
 4 int main() 
 5 { 
 6 pid_t pid; 
 7 printf("before :pid is %d\n",getpid()); 
 8 if((pid=fork())==-1) 
 9 perror("fork()"),exit(1); 
 10 printf("After:pid=%d,fork return %d\n",getpid(),pid); 
 11 sleep(1); 
 12 
 13 return 0; 
 14 } 
 15 

在这里插入图片描述
这个简单的例子有一些微妙的方面:

•调用一次,返回两次
fork函数被父进程调用一次,但是却返回两次;一次是返回到父进程,一次是返回到新创建的子进程。
操作系统常见面试总结_第16张图片
•并发执行
子进程和父进程是并发运行的独立进程。内核能够以任意的方式交替执行他们的逻辑控制流中的指令。在我们的系统上运行这个程序时,父进程先运行它的printf语句,然后是子进程。

•相同但是独立的地址空间
因为父进程和子进程是独立的进程,他们都有自己私有的地址空间,当父进程或者子进程单独改变时,不会影响到彼此,类似于c++的写实拷贝的形式自建一个副本。

•fork的返回值
1.fork的子进程返回为0;
2.父进程返回的是子进程的pid。

•fork的常规用法
1.一个父进程希望复制自己,使得子进程同时执行不同的代码段,例如:父进程等待客户端请求,生成一个子进程来等待请求处理。
2.一个进程要执行一个不同的程序。

•fokr调用失败的原因
1.系统中有太多进程
2.实际用户的进程数超过限制

该部分选自CSDN博客

2.开启shell界面操作
在BIOS阶段,计算机的行为基本上被写死了,程序员可以做的事情并不多;但是一旦进入操作系统,程序员几乎可以定制所有方面。所以,这个部分与程序员的关系更密切。

主要关心的是Linux操作系统,它是目前服务器端的主流操作系统,大致需要以下步骤:

加载内核
启动初始化进程
确定运行级别
加载开机启动程序
用户登录
进入 login shell:login shell用于表示登陆进程,是指用户刚登录系统时,由系统创建,用以运行shell 的进程。
打开 non-login shell

最好参看该博文

**十五、程序的地址保存的是虚拟地址还是物理地址?

虚拟地址

**十六、虚拟地址怎么映射到物理地址?

缺页异常
一个是4G,一个是2G,怎么算都不会相等,其实内核的内存管理机制并不会立刻对这些地址进行映射,有基础的人应该都听说过直接映射和动态映射的概念,内核中是会划分一部分物理内存作为直接映射区,这部分是会在系统启动时直接映射到虚拟内存区域中,比如我们的kernel代码运行区域,除此之外,其余的物理内存都会保留下来供内核统一管理,这些同一个管理的内存只会在需要的时候才会进行映射,比如当我们的进程访问一个虚拟地址,而此时该虚拟地址恰好没有映射,那么就会产生缺页异常,此异常会被内核接收到,然后内存管理机制就开始进行处理,把需要访问的虚拟地址进行映射,映射到实际的物理内存上去。
swap机制
除了这个之外还有一个问题,系统中运行了那么多的进程,如果不停的访问不存在的虚拟内存,触发内核的缺页异常进行动态映射,那么总有一刻,2G的内存都被映射完了,那么此时还要继续访问更多虚拟地址,那么改怎么办呢?此时就涉及到内核的另一个内存管理机制,那就是swap机制,我们知道当安装一个Linux系统时,我们都要制定一个swap分区,这个分区就是在此时时候的,内核会把一些不常使用的内存页置换出来,数据保存到我们的硬盘上,这样就会有空余出来的内存继续被系统映射了,那么当其他进程要访问被置换的数据时,系统同样再从swap分区把对应数据恢复到内存中来。就是利用这种方式实现了2G物理内存当4G使用的目的。

**十七、mmap

mmap将一个文件或者其它对象映射进内存。文件被映射到多个页上,如果文件的大小不是所有页的大小之和,最后一个页不被使用的空间将会清零。mmap在用户空间映射调用系统中作用很大。

1.什么是 mmap()
mmap, 从函数名就可以看出来这是memory map, 即地址的映射, 是一种内存映射文件的方法, (其他的还有mmap()系统调用,Posix共享内存,以及系统V共享内存,这些我们有机会在后续的文章讨论,今天的男主角是mmap),将一个文件或者其它对象映射到进程的地址空间,实现文件磁盘地址和进程虚拟地址空间中一段虚拟地址的一一对映关系。mmap()系统调用使得进程之间通过映射同一个普通文件实现共享内存。普通文件被映射到进程地址空间后,进程可以向访问普通内存一样对文件进行访问,不必再调用read(),write()等操作。

注:实际上,mmap()系统调用并不是完全为了用于共享内存而设计的。它本身提供了不同于一般对普通文件的访问方式,进程可以像读写内存一样对普通文件的操作。而Posix或系统V的共享内存IPC则纯粹用于共享目的,当然mmap()实现共享内存也是其主要应用之一。

2.为什么使用 mmap()
Linux通过内存映像机制来提供用户程序对内存直接访问的能力。内存映像的意思是把内核中特定部分的内存空间映射到用户级程序的内存空间去。也就是说,用户空间和内核空间共享一块相同的内存。这样做的直观效果显而易见:内核在这块地址内存储变更的任何数据,用户可以立即发现和使用,根本无须数据拷贝。举个例子理解一下,使用mmap方式获取磁盘上的文件信息,只需要将磁盘上的数据拷贝至那块共享内存中去,用户进程可以直接获取到信息,而相对于传统的write/read IO系统调用, 必须先把数据从磁盘拷贝至到内核缓冲区中(页缓冲),然后再把数据拷贝至用户进程中。两者相比,mmap会少一次拷贝数据,这样带来的性能提升是巨大的。

使用内存访问来取代read()和write()系统调用能够简化一些应用程序的逻辑。
在一些情况下,它能够比使用传统的I/O系统调用执行文件I/O这种做法提供更好的性能。
原因是:

正常的read()或write()需要两次传输:一次是在文件和内核高速缓冲区之间,另一次是在高速缓冲区和用户空间缓冲区之间。使用mmap()就不需要第二次传输了。对于输入来讲,一旦内核将相应的文件块映射进内存之后,用户进程就能够使用这些数据了;对于输出来讲,用户进程仅仅需要修改内核中的内容,然后可以依靠内核内存管理器来自动更新底层的文件。
除了节省内核空间和用户空间之间的一次传输之外,mmap()还能够通过减少所需使用的内存来提升性能。当使用read()或write()时,数据将被保存在两个缓冲区中:一个位于用户空间,另个一位于内核空间。当使用mmap()时,内核空间和用户空间会共享同一个缓冲区。此外,如果多个进程正在同一个文件上执行I/O,那么它们通过使用mmap()就能够共享同一个内核缓冲区,从而又能够节省内存的消耗。

3.如何使用mmap()

 #include 

 void *mmap (void *addr, size_t length, int prot, int flags, int fd, off_t offset);


Arguments Describes (参数描述)

  • 参数addr指定文件应被映射到进程空间的起始地址,一般被指定一个空指针,此时选择起始地址的任务留给内核来完成。
  • len是映射到调用进程地址空间的字节数,它从被映射文件开头offset个字节开始算起。
  • prot 参数指定共享内存的访问权限。可取如下几个值的或:PROT_READ(可读) , PROT_WRITE (可写), PROT_EXEC (可执行), PROT_NONE(不可访问)。
  • flags由以下几个常值指定:MAP_SHARED , MAP_PRIVATE , MAP_FIXED,其中,MAP_SHARED , MAP_PRIVATE必选其一,而MAP_FIXED则不推荐使用。offset参数一般设为0,表示从文件头开始映射。
  • 参数fd为即将映射到进程空间的文件描述字,一般由open()返回,同时,fd可以指定为-1,此时须指定flags参数中的MAP_ANON,表明进行的是匿名映射(不涉及具体的文件名,避免了文件的创建及打开,很显然只能用于具有亲缘关系的进程间通信)。
  • offset参数一般设为0,表示从文件头开始映射, 代表偏移量。

Return Value (返回值)
函数的返回值为最后文件映射到进程空间的地址,进程可直接操作起始地址为该值的有效地址

4.两种映射方式

1. 基于文件的映射:
适用于任何进程之间, 此时,需要打开或创建一个文件,然后再调用mmap(), 典型调用代码如下:

...
fd = open (name, flag, mode);
if(fd<0)
{
	printf("error!\n");
}
        
/* 这块内存可读可写可执行 */
ptr = mmap(NULL, len , PROT_READ|PROT_WRITE|PROT_EXEC, MAP_SHARED , fd , 0); 

这样用户进程就可以像读取内存一样读取文件了,效率非常高。


2. 匿名映射
匿名映射是一种没用对应文件的一种映射,是使用特殊文件提供的匿名内存映射:
一个匿名映射没有对应的文件,这种映射的分页会被初始化为0。可以把它看成是一个内容总是被初始化为0的虚拟文件映射,比如在具有血缘关系的进程之间,如父子进程之间, 当一个进程调用mmap().之后又调用了fork(), 之后子进程会继承(拷贝)父进程映射后的空间,同时也继承了mmap()的返回地址,通过修改数据共享内存里的数据, 父子进程够可以感知到数据的变化,这样一来,父子进程就可以通过这块
共享内存来实现进程间通信。


/* 例如一些网络套接字进行共享*/
ptr = mmap(NULL, len , PROT_READ|PROT_WRITE, MAP_SHARED , fd , 0); 

pid = fork();
switch (pid)
{
	case pid < 0:
		printf ("err\n");
	case pid = 0:
     	/* 使用互斥的方式访问共享内存 */
	 	lock(ptr)
	 	修改数据;
	 	unlock(ptr);
	case pid > 0:
	 	/* 使用互斥的方式访问共享内存 */
	 	lock(ptr)
	 	修改数据;
	 	unlock(ptr);
}


5. mmap 具体原理

mmap内存映射的实现过程,总的来说可以分为三个阶段:

(一)进程启动映射过程,并在虚拟地址空间中为映射创建虚拟映射区域
进程在用户空间调用库函数mmap,原型:void *mmap(void *start, size_t length, int prot, int flags, int fd, off_t offset);

在当前进程的虚拟地址空间中,寻找一段空闲的满足要求的连续的虚拟地址

为此虚拟区分配一个vm_area_struct结构,接着对这个结构的各个域进行了初始化

将新建的虚拟区结构(vm_area_struct)插入进程的虚拟地址区域链表或树中


(二)调用内核空间的系统调用函数mmap(不同于用户空间函数),实现文件物理地址和进程虚拟地址的一一映射关系
为映射分配了新的虚拟地址区域后,通过待映射的文件指针,在文件描述符表中找到对应的文件描述符,通过文件描述符,链接到内核“已打开文件集”中该文件的文件结构体(struct file),每个文件结构体维护着和这个已打开文件相关各项信息。

通过该文件的文件结构体,链接到file_operations模块,调用内核函数mmap,其原型为:int mmap(struct file *filp, struct vm_area_struct *vma),不同于用户空间库函数。

内核mmap函数通过虚拟文件系统inode模块定位到文件磁盘物理地址。

通过remap_pfn_range函数建立页表,即实现了文件地址和虚拟地址区域的映射关系。此时,这片虚拟地址并没有任何数据关联到主存中。


(三)进程发起对这片映射空间的访问,引发缺页异常,实现文件内容到物理内存(主存)的拷贝
注:前两个阶段仅在于创建虚拟区间并完成地址映射,但是并没有将任何文件数据的拷贝至主存。真正的文件读取是当进程发起读或写操作时。

进程的读或写操作访问虚拟地址空间这一段映射地址,通过查询页表,发现这一段地址并不在物理页面上。因为目前只建立了地址映射,真正的硬盘数据还没有拷贝到内存中,因此引发缺页异常。

缺页异常进行一系列判断,确定无非法操作后,内核发起请求调页过程。

调页过程先在交换缓存空间(swap cache)中寻找需要访问的内存页,如果没有则调用nopage函数把所缺的页从磁盘装入到主存中。

之后进程即可对这片主存进行读或者写的操作,如果写操作改变了其内容,一定时间后系统会自动回写脏页面到对应磁盘地址,也即完成了写入到文件的过程(是不是有点像写时复制技术呢,哦,这篇博客拖了好久了)。

注:修改过的脏页面并不会立即更新回文件中,而是有一段时间的延迟,可以调用msync()来强制同步, 这样所写的内容就能立即保存到文件里了。

6.mmap()优缺点总结

  • mmap()的优点

对文件的读取操作跨过了页缓存,减少了数据的拷贝次数,用内存读写取代I/O读写,提高了文件读取效率。

实现了用户空间和内核空间的高效交互方式。两空间的各自修改操作可以直接反映在映射的区域内,从而被对方空间及时捕捉。
mmap映射的页和其它的页并没有本质的不同.
所以得益于主要的3种数据结构的高效,其页映射过程也很高效:
(1) radix tree,用于查找某页是否已在缓存.
(2) red black tree ,用于查找和更新vma结构.
(3) 双向链表,用于维护active和inactive链表,支持LRU类算法进行内存回收.

提供进程间共享内存及相互通信的方式。不管是父子进程还是无亲缘关系的进程,都可以将自身用户空间映射到同一个文件或匿名映射到同一片区域。从而通过各自对映射区域的改动,达到进程间通信和进程间共享的目的。同时,如果进程A和进程B都映射了区域C,当A第一次读取C时通过缺页从磁盘复制文件页到内存中;但当B再读C的相同页面时,虽然也会产生缺页异常,但是不再需要从磁盘中复制文件过来,而可直接使用已经保存在内存中的文件数据。

可用于实现高效的大规模数据传输。内存空间不足,是制约大数据操作的一个方面,解决方案往往是借助硬盘空间协助操作,补充内存的不足。但是进一步会造成大量的文件I/O操作,极大影响效率。这个问题可以通过mmap映射很好的解决。换句话说,但凡是需要用磁盘空间代替内存的时候,mmap都可以发挥其功效。

  • mmap()的缺点

对变长文件不适合.
如果更新文件的操作很多,mmap避免两态拷贝的优势就被摊还,最终还是落在了大量的脏页回写及由此引发的随机IO上. 所以在随机写很多的情况下,mmap方式在效率上不一定会比带缓冲区的一般写快.

彻底理解

**十八、乐观锁与悲观锁

何谓悲观锁与乐观锁
乐观锁对应于生活中乐观的人总是想着事情往好的方向发展,悲观锁对应于生活中悲观的人总是想着事情往坏的方向发展。这两种人各有优缺点,不能不以场景而定说一种人好于另外一种人。

悲观锁
总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁(共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程)。传统的关系型数据库里边就用到了很多这种锁机制,比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁。Java中synchronized和ReentrantLock等独占锁就是悲观锁思想的实现。

乐观锁
总是假设最好的情况,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,可以使用版本号机制和CAS算法实现。乐观锁适用于多读的应用类型,这样可以提高吞吐量,像数据库提供的类似于write_condition机制,其实都是提供的乐观锁。在Java中java.util.concurrent.atomic包下面的原子变量类就是使用了乐观锁的一种实现方式CAS实现的。

两种锁的使用场景
从上面对两种锁的介绍,我们知道两种锁各有优缺点,不可认为一种好于另一种,像乐观锁适用于写比较少的情况下(多读场景),即冲突真的很少发生的时候,这样可以省去了锁的开销,加大了系统的整个吞吐量。但如果是多写的情况,一般会经常产生冲突,这就会导致上层应用会不断的进行retry,这样反倒是降低了性能,所以一般多写的场景下用悲观锁就比较合适。

乐观锁常见的两种实现方式
乐观锁一般会使用版本号机制或CAS算法实现。

1. 版本号机制
一般是在数据表中加上一个数据版本号version字段,表示数据被修改的次数,当数据被修改时,version值会加一。当线程A要更新数据值时,在读取数据的同时也会读取version值,在提交更新时,若刚才读取到的version值为当前数据库中的version值相等时才更新,否则重试更新操作,直到更新成功。

举一个简单的例子:

假设数据库中帐户信息表中有一个 version 字段,当前值为 1 ;而当前帐户余额字段( balance )为 $100 。当需要对账户信息表进行更新的时候,需要首先读取version字段。

操作员 A 此时将其读出( version=1 ),并从其帐户余额中扣除 $50( $100-$50 )。
在操作员 A 操作的过程中,操作员B 也读入此用户信息( version=1 ),并从其帐户余额中扣除 $20 ( $100-$20 )。
操作员 A 完成了修改工作,提交更新之前会先看数据库的版本和自己读取到的版本是否一致,一致的话,就会将数据版本号加1( version=2 ),连同帐户扣除后余额( balance=$50 ),提交至数据库更新,此时由于提交数据版本大于数据库记录当前版本,数据被更新,数据库记录 version 更新为 2 。
操作员 B 完成了操作,提交更新之前会先看数据库的版本和自己读取到的版本是否一致,但此时比对数据库记录版本时发现,操作员 B 提交的数据版本号为 2 ,而自己读取到的版本号为1 ,不满足 “ 当前最后更新的version与操作员第一次读取的版本号相等 “ 的乐观锁策略,因此,操作员 B 的提交被驳回。
这样,就避免了操作员 B 用基于 version=1 的旧数据修改的结果覆盖操作员A 的操作结果的可能。

2. CAS算法
即compare and swap(比较与交换),是一种有名的无锁算法。无锁编程,即不使用锁的情况下实现多线程之间的变量同步,也就是在没有线程被阻塞的情况下实现变量的同步,所以也叫非阻塞同步(Non-blocking Synchronization)。CAS算法涉及到三个操作数

需要读写的内存值 V
进行比较的值 A
拟写入的新值 B
当且仅当 V 的值等于 A时,CAS通过原子方式用新值B来更新V的值,否则不会执行任何操作(比较和替换是一个原子操作)。一般情况下是一个自旋操作,即不断的重试。

乐观锁的缺点
ABA 问题是乐观锁一个常见的问题

1 ABA 问题
如果一个变量V初次读取的时候是A值,并且在准备赋值的时候检查到它仍然是A值,那我们就能说明它的值没有被其他线程修改过了吗?很明显是不能的,因为在这段时间它的值可能被改为其他值,然后又改回A,那CAS操作就会误认为它从来没有被修改过。这个问题被称为CAS操作的 "ABA"问题。

JDK 1.5 以后的 AtomicStampedReference 类就提供了此种能力,其中的 compareAndSet 方法就是首先检查当前引用是否等于预期引用,并且当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值。

2 循环时间长开销大
自旋CAS(也就是不成功就一直循环执行直到成功)如果长时间不成功,会给CPU带来非常大的执行开销。 如果JVM能支持处理器提供的pause指令那么效率会有一定的提升,pause指令有两个作用,第一它可以延迟流水线执行指令(de-pipeline),使CPU不会消耗过多的执行资源,延迟的时间取决于具体实现的版本,在一些处理器上延迟时间是零。第二它可以避免在退出循环的时候因内存顺序冲突(memory order violation)而引起CPU流水线被清空(CPU pipeline flush),从而提高CPU的执行效率。

3 只能保证一个共享变量的原子操作
CAS 只对单个共享变量有效,当操作涉及跨多个共享变量时 CAS 无效。但是从 JDK 1.5开始,提供了AtomicReference类来保证引用对象之间的原子性,你可以把多个变量放在一个对象里来进行 CAS 操作.所以我们可以使用锁或者利用AtomicReference类把多个共享变量合并成一个共享变量来操作。

CAS与synchronized的使用情景
简单的来说CAS适用于写比较少的情况下(多读场景,冲突一般较少),synchronized适用于写比较多的情况下(多写场景,冲突一般较多)

对于资源竞争较少(线程冲突较轻)的情况,使用synchronized同步锁进行线程阻塞和唤醒切换以及用户态内核态间的切换操作额外浪费消耗cpu资源;而CAS基于硬件实现,不需要进入内核,不需要切换线程,操作自旋几率较少,因此可以获得更高的性能。
对于资源竞争严重(线程冲突严重)的情况,CAS自旋的概率会比较大,从而浪费更多的CPU资源,效率低于synchronized。
补充: Java并发编程这个领域中synchronized关键字一直都是元老级的角色,很久之前很多人都会称它为 “重量级锁” 。但是,在JavaSE 1.6之后进行了主要包括为了减少获得锁和释放锁带来的性能消耗而引入的 偏向锁 和 轻量级锁 以及其它各种优化之后变得在某些情况下并不是那么重了。synchronized的底层实现主要依靠 Lock-Free 的队列,基本思路是 自旋后阻塞,竞争切换后继续竞争锁,稍微牺牲了公平性,但获得了高吞吐量。在线程冲突较少的情况下,可以获得和CAS类似的性能;而线程冲突严重的情况下,性能远高于CAS。

该部分选自CSDN博客

**十九、信号量、自旋锁和互斥锁

前言
信号量:是一种锁机制用于协调进程之间互斥的访问临界资源。以确保某种共享资源不被多个进程同时访问。
互斥锁(Mutex):是初始值为1的信号量
自旋锁:与互斥锁类似,但在无法得到资源时,互斥锁内核线程处于睡眠阻塞状态,而自旋锁处于忙等待状态。

1.信号量
Linux内核的信号量在概念和原理上与用户态的System V的IPC机制信号量(如semget,semop)是一样的,但是它绝不可能在内核之外使用,因此它与System V的IPC机制信号量毫不相干。

信号量的初始值:表示同时可以有几个任务可以访问该信号量保护的共享资源

一个任务要想访问共享资源,首先必须得到信号量,获取信号量的操作将把信号量的值减1。

  • 若当前信号量的值为负数,表明无法获得信号量,该任务必须挂起在该信号量的等待队列,等待该信号量可用;
  • 若当前信号量的值为非负数,表示可以获得信号量,因而可以立刻访问被该信号量保护的共享资源。

当任务访问完被信号量保护的共享资源后,必须释放信号量,释放信号量通过把信号量的值加1实现,
如果信号量的值为非正数,表明有任务等待当前信号量,因此它也唤醒所有等待该信号量的任务的其中一个。

2.互斥锁
如果初始值为1,表示只有1个任务可以访问,信号量变成互斥锁(Mutex)。可见互斥锁是信号量的特例。

互斥锁(mutex)是在原子操作API的基础上实现的信号量行为。互斥锁不能进行递归锁定或解锁,能用于进程上下文,同一时间只能有一个任务持有互斥锁。

互斥锁功能上基本上与信号量一样,互斥锁占用空间比信号量小,运行效率比信号量高。

3.自旋锁
自旋锁与互斥锁比较类似,它们都是为了解决对某项资源的互斥使用。无论是互斥锁,还是自旋锁,在任何时刻,最多只能有一个保持者,也就说,在任何时刻**最多只能有一个执行单元(**进程或中断处理程序)获得锁。但是两者在调度机制上略有不同。

自旋锁实际上是忙等待,自旋锁可能导致系统死锁
死锁:简单说,就是当前任务在获得自旋锁之后,释放自旋锁之前,主动调用一些函数让出CPU的控制权(比如主动调用sleep休眠或主动调用内核调度函数schedule去调度其他任务),这时候,如果被调度进来的任务也想去获得该自旋锁,便会进入死循环轮询等待,即发生了死锁。

自旋锁不允许睡眠或阻塞
只有一个进程不会死锁,两个或两个以上的进程才会死锁

4.自旋锁使用注意事项
注意:
自旋锁与中断:进程A占用锁后,内核的抢占暂时被禁止(但无法禁止中断抢占进程)。这时候突然发生中断,然后中断处理函数里想要获得这个锁时,发生了死锁。
所以需要改用spin_lock_irq或spin_lock_irqsave。解锁换成对应的spin_unlock_irq或spin_unlock_irqrestore。
进程上下文与软中断上下文
只在进程上下文访问和软中断上下文访问,那么当在进程上下文访问共享资源时,可能被软中断打断,从而可能进入软中断上下文来对被保护的共享资源访问,因此对于这种情况,对共享资源的访问必须使用spin_lock_bh和spin_unlock_bh来保护。
当然使用spin_lock_irq和spin_unlock_irq以及spin_lock_irqsave和spin_unlock_irqrestore也可以,它们失效了本地硬中断,失效硬中断,隐式地也失效了软中断。但是使用spin_lock_bh和spin_unlock_bh是最恰当的,它比其他两个快。
进程上下文 和 tasklet或timer上下文
在进程上下文和tasklet或timer上下文访问,那么应该使用与上面情况(进程上下文和软中断上下文)相同的获得和释放锁的宏,因为tasklet和timer是用软中断实现的。
进程上下文 和 中断上下文
在进程或软中断上下文需要使用spin_lock_irq和spin_unlock_irq来保护对共享资源的访问。 在中断处理句柄中使用,如果只有一个(硬),仅需要spin_lock和spin_unlock,不同的(硬)中断,使用spin_lock_irq和spin_unlock_irq
tasklet或timer上下文
不需要任何自旋锁保护,同一个tasklet或timer只能在一个CPU上运行
一个或多个软中断上下文
只在一个软中断(tasklet和timer除外)上下文访问,那么这个共享资源需要用spin_lock和spin_unlock来保护,在两个或多个软中断上下文访问,那么这个共享资源当然更需要用spin_lock和spin_unlock来保护 , 没必要使用spin_lock_bh

操作系统常见面试总结_第17张图片
**二十、什么是线程安全

线程安全是多线程领域的问题,线程安全可以简单理解为一个方法或者一个实例可以在多线程环境中使用而不会出现问题。

产生线程不安全的原因
在同一程序中运行多个线程本身不会导致问题,问题在于多个线程访问了相同的资源。如,同一内存区(变量,数组,或对象)、系统(数据库,web services等)或文件。实际上,这些问题只有在一或多个线程向这些资源做了写操作时才有可能发生,只要资源没有发生变化,多个线程读取相同的资源就是安全的。

多线程同时执行下面的代码可能会出错:

public class Counter {
    protected long count = 0;

    public void add(long value){
        this.count = this.count + value;  
    }
}

想象下线程A和B同时执行同一个Counter对象的add()方法,我们无法知道操作系统何时会在两个线程之间切换。JVM并不是将这段代码视为单条指令来执行的,而是按照下面的顺序:

从内存获取 this.count 的值放到寄存器
将寄存器中的值增加value
将寄存器中的值写回内存

观察线程A和B交错执行会发生什么:

   this.count = 0;
   A:   读取 this.count 到一个寄存器 (0)
   B:   读取 this.count 到一个寄存器 (0)
   B:   将寄存器的值加2
   B:   回写寄存器值(2)到内存. this.count 现在等于 2
   A:   将寄存器的值加3
   A:   回写寄存器值(3)到内存. this.count 现在等于 3

两个线程分别加了2和3到count变量上,两个线程执行结束后count变量的值应该等于5。然而由于两个线程是交叉执行的,两个线程从内存中读出的初始值都是0。然后各自加了2和3,并分别写回内存。最终的值并不是期望的5,而是最后写回内存的那个线程的值,上面例子中最后写回内存的是线程A,但实际中也可能是线程B。如果没有采用合适的同步机制,线程间的交叉执行情况就无法预料。

竞态条件 & 临界区
当两个线程竞争同一资源时,如果对资源的访问顺序敏感,就称存在竞态条件。导致竞态条件发生的代码区称作临界区。上例中add()方法就是一个临界区,它会产生竞态条件。在临界区中使用适当的同步就可以避免竞态条件。

共享资源
允许被多个线程同时执行的代码称作线程安全的代码。线程安全的代码不包含竞态条件。当多个线程同时更新共享资源时会引发竞态条件。因此,了解Java线程执行时共享了什么资源很重要。

局部变量
局部变量存储在线程自己的栈中。也就是说,局部变量永远也不会被多个线程共享。所以,基础类型的局部变量是线程安全的。下面是基础类型的局部变量的一个例子:

public void someMethod(){
  long threadSafeInt = 0;
  threadSafeInt++;
}

局部的对象引用
上面提到的局部变量是一个基本类型,如果局部变量是一个对象类型呢?对象的局部引用和基础类型的局部变量不太一样。尽管引用本身没有被共享,但引用所指的对象并没有存储在线程的栈内,所有的对象都存在共享堆中,所以对于局部对象的引用,有可能是线程安全的,也有可能是线程不安全的。

那么怎样才是线程安全的呢?如果在某个方法中创建的对象不会被其他方法或全局变量获得,或者说方法中创建的对象没有逃出此方法的范围,那么它就是线程安全的。实际上,哪怕将这个对象作为参数传给其它方法,只要别的线程获取不到这个对象,那它仍是线程安全的。下面是一个线程安全的局部引用样例:

public void someMethod(){
  LocalObject localObject = new LocalObject();
  localObject.callMethod();
  method2(localObject);
}

public void method2(LocalObject localObject){
  localObject.setValue("value");
}

引用不是线程安全的!
重要的是要记住,即使一个对象是线程安全的不可变对象,指向这个对象的引用也可能不是线程安全的。

Java中实现线程安全的方法

在Java多线程编程当中,提供了多种实现Java线程安全的方式:

  • 最简单的方式,使用Synchronization关键字:Java Synchronization介绍
  • 使用java.util.concurrent.atomic 包中的原子类,例如 AtomicInteger
  • 使用java.util.concurrent.locks 包中的锁
  • 使用线程安全的集合ConcurrentHashMap
  • 使用volatile关键字,保证变量可见性(直接从内存读,而不是从线程cache读)

**二十一、线程安全及解决机制

1:为什么需要多线程?

线程是Java语言中不可或缺的重要部分,它们能使复杂的异步代码变得简单,简化复杂系统的开发;能充分发挥多处理器系统的强大计算能力。多线程和多进程的区别与选择可以参考我的另一篇博客。

(1) 优点

  1. 充分利用硬件资源。由于线程是cpu的基本调度单位,所以如果是单线程,那么最多只能同时在一个处理器上运行,意味着其他的CPU资源都将被浪费。而多线程可以同时在多个处理器上运行,只要各个线程间的通信设计正确,那么多线程将能充分利用处理器的资源。

  2. 结构优雅。多线程程序能将代码量巨大,复杂的程序分成一个个简单的功能模块,每块实现复杂程序的一部分单一功能,这将会使得程序的建模,测试更加方便,结构更加清晰,更加优雅。

  3. 简化异步处理。为了避免阻塞,单线程应用程序必须使用非阻塞I/O,这样的I/O复杂性远远高于同步I/O,并且容易出错。

(2) 缺点

  1. 线程安全:由于统一进程下的多个线程是共享同样的地址空间和数据的,又由于线程执行顺序的不可预知性,一个线程可能会修改其他线程正在使用的变量,这一方面是给数据共享带来了便利;另一方面,如果处理不当,会产生脏读,幻读等问题,好在Java提供了一系列的同步机制来帮助解决这一问题,例如内置锁。

  2. 活跃性问题。可能会发生长时间的等待锁,甚至是死锁。

  3. 性能问题。 线程的频繁调度切换会浪费资源,同步机制会导致内存缓冲区的数据无效,以及增加同步流量。

2:线程安全

(1) 定义:当多个线程访问某个类时,不管运行时环境采用何种调度方式或者这些线程将如何交替运行,并且在主调试代码中不需要任何额外的同步或者协同,这个类都能表现出正确的行为,则称这个类时线程安全的。线程安全类中封装了必要的同步机制,因此客户端无须进一步采取同步措施。

(2) 线程安全产生的原因:正确性取决于多个线程的交替执行时序,产生了竞态条件。

(3) 原子类: 应尽量使用原子类,这样会让你分析线程安全时更加方便,但需要注意的是用线程安全类构建的类并不能保证线程安全。例如,一个AtomicInteger get() 和 AtomicInteger set() 是线程安全的,在一个类的一个方法 f()中同时用到了这两个方法,此时的f()就是线程不安全的,因为你不能保证这个复合操作中的get 和 set同时更新。

3:解决机制

1. 加锁。

(1) 锁能使其保护的代码以串行的形式来访问,当给一个复合操作加锁后,能使其成为原子操作。一种错误的思想是只要对写数据的方法加锁,其实这是错的,对数据进行操作的所有方法都需加锁,不管是读还是写。

(2) 加锁时需要考虑性能问题,不能总是一味地给整个方法加锁synchronized就了事了,应该将方法中不影响共享状态且执行时间比较长的代码分离出去。

(3) 加锁的含义不仅仅局限于互斥,还包括可见性。为了确保所有线程都能看见最新值,读操作和写操作必须使用同样的锁对象。

2. 不共享状态

(1) 无状态对象: 无状态对象一定是线程安全的,因为不会影响到其他线程。

(2) 线程关闭: 仅在单线程环境下使用。

3. 不可变对象

可以使用final修饰的对象保证线程安全,由于final修饰的引用型变量(除String外)不可变是指引用不可变,但其指向的对象是可变的,所以此类必须安全发布,也即不能对外提供可以修改final对象的接口。

4.i++线程安全
i++是不安全的,前面我们讲解volatile关键字的时候,我们说过了i++是一个复合操作,可分为三个阶段:
读值,从内存到寄存器
+1,寄存器自增
写值,写回内存
在这三步之间的都可能会有CPU调度,造成i的值被修改。造成脏读脏写。

如果是方法里定义的,一定是线程安全的,因为每个方法栈是线程私有的;如果是类的静态成员变量,i++则不是线程安全的,因为 线程共享栈区,不共享堆区和全局区

如何解决线程安全性呢?
用volatile修饰虽然能保证可见性,但是不能保证原子性。如果想要保证其多线程下的安全性,可以使用原子变量(AtomicInteger,参考 Java并发编程之原子变量)、
sychronized关键字、Lock锁实现(参考 Java关键字 volatile、synchronized和Lock锁)。

AtomicInteger 和 各种 Lock 都可以确保线程安全。AtomicInteger 的效率高是因为它是互斥区非常小,只有一条指令,而 Lock 的互斥区是拿锁到放锁之间的区域,至少三条指令。

**二十二、cpu调度方法

批处理系统中采用的调度算法

重要指标(吞吐量,周转时间,CPU利用率,公平平衡)

  • 非抢占式的先来先服务算法(FCFS):按照进程就绪的先后顺序使用CPU

    • 特点:公平,实现简单,但是长进程后面的短进程需要等待很长时间,不利于用户体验。
      非抢占式的最短作业优先(SJF):具有最短完成时间的进程优先执行
  • 最短剩余时间优先(SRTN):SJF抢占式版本,即当一个新就绪的进程比当前运行进程具有更短完成时间时,系统抢占当前进程,选择新就绪的进程执行。

    • 短作业优先调度算法特点:改善短作业的周转时间,但如果源源不断有短任务到来,可能使长的任务长时间得不到运行,产生饥饿现象。
  • 最高相应比优先算法(HRRN):是一个综合算法,调度时,首先计算每个进程的响应比R,之后总是选择R最高的进程执行。

    • 响应比R=(等待时间+处理时间)/处理时间

交互系统中采用的调度算法:
重要指标(响应时间,公平平衡)

  • 时间片轮转调度算法: 每个进程被分配一个时间片,允许该进程在该时间段运行,如果在时间片结束时该进程还在运行,则剥夺CPU并分配给另一个进程,如果该进程在时间片结束前阻塞或结束,则CPU立即进行切换。

    • 当时间片选择太长,其降级为先来先服务算法,引起对短的交互请求响应时间长
    • 当时间片选择太短,会导致频繁的进程切换,浪费CPU时间。
    • 通常选择为20ms~50ms.
    • 对进程表中不同进程的大小差异较大的有利,而对进程都是相同大小的不利。
  • 虚拟轮转法:主要基于时间片轮转法进行改进,解决在CPU调度中对于I/O密集型进程的不友好。其设置了一个辅助队列,对于I/O型进程执行完一个时间片之后,则进入辅助队列,CPU调度时总是先检查辅助队列是否为空,如果不为空总是优先调度辅助队列里的进程,直到为空,才调度就绪队列的进程。

各种调度算法比较
操作系统常见面试总结_第18张图片
**二十三、空间局部性和时间局部性

在CPU访问寄存器时,无论是存取数据还是存取指令,都趋于聚集在一片连续的区域中,这就被称为局部性原理。
局部性原理又分为时间局部性(temporal locality) 和空间局部性 (spatial locality) 。

1. 时间局部性:
如果程序中的某条指令一旦执行,不久以后该指令可能再次执行;如果某数据被访问过,不久以后该数据可能再次被访问。产生时间局部性的典型原因,是由于在程序中存在着大量的循环操作。
----被引用过一次的存储器位置在未来会被多次引用(通常在循环中)。

2. 空间局部性:
一旦程序访问了某个存储单元,在不久之后,其附近的存储单元也将被访问,即程序在一段时间内所访问的地址,可能集中在一定的范围之内,这是因为指令通常是顺序存放、顺序执行的,数据也一般是以向量、数组、表等形式簇聚存储的。
如果一个存储器的位置被引用,那么将来他附近的位置也会被引用。

**二十四、线程并发问题

1.概述
a. 初识并发问题
问题原型:多个线程操作同一个对象 —> 买火车票
b. 线程同步
现实生活中,我们会遇到“同一个资源,多个人都想使用”的问题,比如,食堂排队打饭,每个人都想吃饭,最天然的解决方法就是排队,一个个来。

处理多线程问题时,多个线程访问同一个对象,并且某些线程还想修改这个对象。这时我们就需要线程同步。

什么是线程同步:线程同步其实就是一种等待机制,多个需要同时访问此对象的线程进入这个 对象的等待池 形成队列,等待前面线程使用完毕,下一个线程再使用。

怎么实现线程同步: 队列 + 锁

存在问题:
由于同一个进程的多个线程共享同一块存储空间,在带来方便的同时,也带来了访问冲突问题。

解决方案:
为了保证数据在方法中被访问时的正确性,在访问时加入 锁机制 synchronized,当一个线程获得对象的排它锁、独占资源,其他线程必须等待,使用后释放锁即可,但是也可能存在以下问题:

  1. 一个线程持有锁会导致其他所有需要此锁的线程挂起;
  2. 在多线程竞争下,加锁、释放锁会导致比较多的 上下文切换 和 调度延时,引起性能问题;
  3. 如果一个优先级高的线程等待一个优先级低的线程释放锁 会导致优先级 倒置,引起性能问题。

以下这部分可不看

a.volatile
保证共享数据一旦被修改就会立即同步到共享内存(堆或者方法区)中。

b.线程访问堆中数据的过程
线程在栈中建立一个数据的副本,修改完毕后将数据同步到堆中。

c.指令重排
为了提高执行效率,CPU会将没有依赖关系的指令重新排序。如果希望控制重新排序,可以使用volatile修饰一个变量,包含该变量的指令前后的指令各自独立排序,前后指令不能交叉排序。

2 常见问题及应对

a.原子性问题
所谓原子性,指的是一个操作不可中断,即在多线程并发的环境下,一个操作一旦开始,就会在同一个CPU时间片内执行完毕。如果同一个线程的多个操作在不同的CPU时间片上执行,由于中间出现停滞,后面的操作在执行时可能某个共享数据被其他线程修改,而该修改并未同步到当前线程中,导致当前线程操作的数据与实际不符,这种由于执行不连贯导致的数据不一致问题被称作原子性问题。

b.可见性问题
可见性问题的出现与线程访问共享数据的方式有关。线程访问堆(方法区)中的变量时,先在栈中建立一个变量的副本,修改后再同步到堆中。如果一个线程刚建立副本,这时另一线程修改了变量,尚未同步到堆中,这时就会出现两个线程操作同一变量的同一种状态的现象,比如i=9,变量i的初始值为9,每一个线程的操作都是减1。两个线程A与B同时访问变量,B先执行i-1,在将结果i=8同步到堆中前,A线程也执行i-1,这时i=9的状态就被执行两次,出现线程安全问题。
线程安全问题产生的原因:一个线程对共享数据的修改不能立即为其他线程所见。

volatile提供了一种解决方案:
一旦一个线程修改了被volatile修饰的共享数据,这种修改就会立即同步到堆中,这样其他数据从堆中访问共享数据时始终获得的是在多个线程中的最新值。
volatile的缺陷:

volatile只能保证一个线程从堆中获取数据时获取的是当前所有线程中的最新值,假如一个线程已经从堆中复制了数据,在操作完成前,其他线程修改了数据,修改后的数据并不会同步到当前线程中。

c.有序性问题
为了提高执行效率,CPU会对那些没有依赖关系的指令重新排序,重新排序后的执行结果与顺序执行结果相同。
例如,在源代码中:
线程A:

s=new String("sssss");//指令1
flag=false;//指令2

线程B:

while(flag){
doSome();
}
s.toUpperCase();//指令3

如果线程A顺序执行,即执行指令1,再执行指令2,线程B的执行不会出现问题。指令重排后,假如线程A先执行指令2,这时flag=true,切换到线程2,终止循环,执行指令3,由于s对象尚未创建就会出现空指针异常。
有序性问题产生的原因:

一个线程对其他线程对共享数据的修改操作有顺序要求,比如线程B要求线程A先执行指令1,再执行指令2,由于指令重排,实际并未按照要求的顺序执行,这时就出现了线程安全问题。

解决思路:

利用同步机制,使得同一时间只有一个线程可以访问共享数据,效率低。
使用volatile,一个指令包含volatile修饰的变量,那么这条指令的执行顺序不变,该指令前后的指令可以各自独立重排,无法交叉重排。

**二十五、等待所有线程执行完后,执行某个方法

在多线程环境下,我们可能会需要等待开辟的线程执行完后,再去执行某个方法,例如输出并行计算结果等。

但是在多线程下,线程的执行是不阻塞主线程的,这点其实也是多线程的优势,提高代码执行效率,不必相互等待可以并行执行

一般的处理方法有以下几种
1:使用Task.WaitAll (会阻塞主线程)

可以使用Task.WaitAll让主线程一直等待开辟的线程,直到所有子线程都执行完后再执行线程

2:使用Task.WhenAll(不会阻塞主线程)

Task.WhenAll和Task.WaitAll类似都可以做到线程执行后在向下执行,但是wait是等待也就是会阻塞主线程,而when表示当的意思,就是当子线程都执行完后在执行一个回调函数。可以把子线程执行完毕后想执行的代码放入该回调函数里边。

3:使用Parallel.Invoke或者 Parallel.For(会阻塞主线程)

Parallel.Invoke或者 Parallel.For可以很方便的开辟多线程并行执行,自动会阻塞多线程。所以他后面写得代码会

自动等待子线程执行完毕后再执行

4:自己另开线程,监控线程状态(不会阻塞主线程)

其实我们可以单独另外开辟一个新的线程了,用来监控所有子线程的运行状态,当这些被监控的线程都执行完毕后即可执行自己的逻辑,当然也可以封装一个回调函数用于方便调用。

线程的状态有很多:例如Created(已经创建),Running(运行中),RanToCompletion(运行完毕)等

该部分选自CSDN博客

**二十六.操作系统管理功能:

(1)作业管理:包括任务、界面管理、人机交互、图形界面、语音控制和虚拟现实等;
(2)文件管理:又称为信息管理;
(3)存储管理:实质是对存储“空间”的管理,主要指对内存的管理;
(4)设备管理:实质是对硬件设备的管理,其中包括对输入输出设备的分配、启动、完成和回收;
(5)进程管理:实质上是对处理机执行“时间”的管理,即如何将CPU真正合理地分配给每个任务。

**二十七进程调度的时机

进程调度发生的时机(也称为调度点)与进程的状态变化有直接的关系。回顾进程状态变化图,我们可以看到进程调度的时机直接与进程在运行态<–>退出态/就绪态/阻塞态的转变时机相关。简而言之,引起进程调度的时机可归结为以下几类:

  • 正在执行的进程执行完毕,需要选择新的就绪进程执行。
  • 正在执行的进程调用相关系统调用(包括与I/O操作,同步互斥操作等相关的系统调用)导致需等待某事件发生或等待资源可用,从而将白己阻塞起来进入阻塞状态。
  • 正在执行的进程主动调用放弃CPU的系统调用,导致自己的状态为就绪态,且把自己重新放到就绪队列中。
  • 等待事件发生或资源可用的进程等待队列,从而导致进程从阻塞态回到就绪态,并可参与到调度中。
  • 正在执行的进程的时间片已经用完,致自己的状态为就绪态,且把自己重新放到就绪队列中。
  • 在执行完系统调用后准备返回用户进程前的时刻,可调度选择一新用户进程执行
  • 就绪队列中某进程的优先级变得高于当前执行进程的优先级,从而也将引发进程调度。
  • 有序列表内容

**二十八\交换机计费方式与采用的计费系统、业务的类型以及 入网的方式有关,主要有如下三种:

1 CAMA计费系统
CAMA 计 费 系 统 即 Centralized Automatic Message Accounting System-集中式自动通话记帐系统的简称,又称为详细帐单 (Detail billing)方式。
这种详细话单方式在我国主要用于长途通信(国际或国 内),也有个别使用在农话中。所有以这种方式计费的呼叫,在呼结束时产生一张详 细帐单,其中包括主叫和被叫用户标识、通话日期、计费开始及结束时间、通话时长 以及呼费期间计数到的脉冲数值等信息。CAMA计费方式的计费点设在发话端长途局或者长市合一局。
2 LAMA计费系统
LAMA计费系统即Local Automatic Message Accounting System-本地自动 通话记帐系统的简称,也叫市话计费方式。
这种计费系统目前用于市内电话通信 时,采用汇总帐单的形式,也称简式同(BUIK Billing)。这种计费在通知期间,采用 脉冲计次的方式,将计费脉冲在主叫用户的汇总计数器中累加。
LAMA计费方式的计费点在发端市话或者长市 合一局。在一些本地网呼叫时,根据需要,LAMA 计费系统也可以在市话局实现详细计费(Detail Billing)。
3 PAMA计费系统
PAMA计费系统即Private Automatic Message Accounting System-专用自 动通话记帐系统的简称,也称用户交换机计费系统。如果用户交换机(或者利用用户 交换作为农话端)作为发端局采用全自动直拨方式(DOD1+DID)入网,则发端局本 身即采用PAMA计费 系 统 , 其 帐 单 方 式 类 似 于 CAMA,但较为简单。
在国内交换机中,长途交换局一般采用CAMA方式,市话局采用 LAMA方式,而长市 合一局二者均需采用。

**二十九\操作系统分类

A.批处理是指用户将一批作业提交给操作系统后就不再干预,由操作系统控制它们自动运行。
B.分时操作系统是使一台计算机采用时间片轮转的方式同时为几个、几十个甚至几百个用户服务的一种操作系统。由于时间间隔很短,每个用户的感觉就像他独占计算机一样。
C.实时操作系统(RTOS)是指当外界事件或数据产生时,能够接受并以足够快的速度予以处理,其处理的结果又能在规定的时间之内来控制生产过程或对处理系统做出快速响应,并控制所有实时任务协调一致运行的操作系统。
D.网络操作系统,是一种能代替操作系统的软件程序,是网络的心脏和灵魂,是向网络计算机提供服务的特殊的操作系统。

**三十 子进程继承父进程

子进程继承父进程

  • 用户号UIDs和用户组号GIDs
  • 环境Environment
  • 堆栈
  • 共享内存
  • 打开文件的描述符
  • 执行时关闭(Close-on-exec)标志
  • 信号(Signal)控制设定
  • 进程组号
  • 当前工作目录
  • 根目录
  • 文件方式创建屏蔽字
  • 资源限制
  • 控制终端

子进程独有

  • 进程号PID
  • 不同的父进程号
  • 自己的文件描述符和目录流的拷贝
  • 子进程不继承父进程的进程正文(text),数据和其他锁定内存(memory locks)
  • 不继承异步输入和输出

父进程和子进程拥有独立的地址空间和PID参数

  • 子进程从父进程继承了用户号和用户组号,用户信息,目录信息,环境(表),打开的文件描述符,堆栈,(共享)内存等。
  • 经过fork()以后,父进程和子进程拥有相同内容的代码段、数据段和用户堆栈,就像父进程把自己克隆了一遍。事实上,父进程只复制了自己的PCB块。而代码段,数据段和用户堆栈内存空间并没有复制一份,而是与子进程共享。只有当子进程在运行中出现写操作时,才会产生中断,并为子进程分配内存空间。由于父进程的PCB和子进程的一样,所以在PCB中断中所记录的父进程占有的资源,也是与子进程共享使用的。这里的“共享”一词意味着“竞争”

**三十一 SPOOLing

SPOOLing (即 外部设备 联机并行操作),即Simultaneous Peripheral Operation On-Line的缩写,它是关于慢速 字符设备 如何与计算机主机交换信息的一种技术,通常称为“ 假脱机 技术”。
SPOOLing 技术特点

  • (1)提高了I/O速度.从对低速I/O设备进行的I/O操作变为对输入井或输出井的操作,如同脱机操作一样,提高了I/O速度,缓和了CPU与低速I/O设备速度不匹配的矛盾.
  • (2)设备并没有分配给任何进程.在输入井或输出井中,分配给进程的是一存储区和建立一张I/O请求表.
  • (3)实现了虚拟设备功能.多个进程同时使用一独享设备,而对每一进程而言,都认为自己独占这一设备,不过,该设备是逻辑上的设备.

**三十二 管程

  1. 管程可以看做一个软件模块,它是将共享的变量和对于这些共享变量的操作封装起来,形成一个具有一定接口的功能模块,进程可以调用管程来实现进程级别的并发控制。

  2. 进程只能互斥得使用管程,即当一个进程使用管程时,另一个进程必须等待。当一个进程使用完管程后,它必须释放管程并唤醒等待管程的某一个进程。

  3. 在管程入口处的等待队列称为入口等待队列,由于进程会执行唤醒操作,因此可能有多个等待使用管程的队列,这样的队列称为紧急队列,它的优先级高于等待队列。

  4. 管程外的进程或其他软件模块只能通过管程对外的接口来访问管程提供的操作,管程内部的实现细节对外界是透明的。

**三十三 文件物理结构

  • 连续文件(顺序文件)
    定义:将一个文件中逻辑上连续的信息存放到存储介质的依次相邻的块上便形成顺序结构,这类文件叫连续文件,又称顺序文件。
    优点:简单; 支持顺序存取和随机存取(直接存取);顺序存取速度快;所需的磁盘寻道次数和寻道时间最少。
    缺点:建立文件前需要能预先确定文件长度,以便分配存储空间;修改、插入和增生文件记录有困难;对直接存储器作连续分配,会造成少量空闲块的浪费。
  • 链接文件
    定义:一个文件的信息存放在若干不连续的物理块中,各块之间通过指针连接,前一个物理块指向下一个物理块。
    优点:提高了磁盘空间利用率,不存在外部碎片问题。有利于文件插入和删除。有利于文件动态扩充。
    缺点:存取速度慢,不适于随机存取。可靠性问题,如指针出错。更多的寻道次数和寻道时间。链接指针占用一定的空间。
  • 索引文件
    定义:一个文件的信息存放在若干不连续物理块中,系统为每个文件建立一个专用数据结构----索引表,表中每一栏目指出文件信息所在的逻辑块号和与之对应的物理块号。索引表的物理地址则由文件说明信息项给出。
    索引项的组织:
    稠密索引:每个逻辑纪录设置一个索引项。
    稀疏索引:一组逻辑纪录设置一个索引项。
    优点:保持了链接结构的优点,又解决了其缺点:即能顺序存取,又能随机存取。满足了文件动态增长、插入删除的要求。也能充分利用外存空间。
    缺点:较多的寻道次数和寻道时间。索引表本身带来了系统开销 如:内外存空间,存取时间。

**三十四 I/O控制方式

有几种I/O控制方式?各有何特点?

答:I/O控制方式有四种:程序直接控制方式、中断控制方式、DMA方式和通道控制方式。

(1) 程序直接控制方式:优点是控制简单,不需要多少硬件支持。但CPU和外设只能串行工作,且CPU的大部分时间处于循环测试状态,使CPU的利用率大大降低,因此该方式只适用于那些CPU执行速度较慢且外设较少的系统。

(2) 中断处理方式:优点是能实现CPU与外设间的并行操作,CPU的利用率较程序直接控制方式大大提高。由于在一次数据传送过程中CPU通常以字节为单位进行干预,中断次数较多而耗去大量的CPU时间。

(3) DMA方式:与中断方式相比,DMA方式是在一批数据传送完成后中断CPU,从而大大减少CPU进行中断处理的次数,且DMA方式下的数据传送实在DMA控制下完成的。但DMA方式仍有一定的局限,如对外设的管理和某些操作仍由CPU控制,多个DMA控制器的使用也不经济。

(4) 通道控制方式:CPU只需发出I/O指令,通道完成相应的I/O操作,并在操作结束时向CPU发出中断信号;同时一个通道还能控制多台外设。但是通道价格较高,从经济角度出发不宜过多使用。

**三十五 网络管理

ISO在ISO/IEC 7498-4文档中定义了网络管理的五大功能,并被广泛接受。
这五大功能是:
1、 故障管理:故障管理是网络管理中最基本的功能之一。用户都希望有一个可靠的计算机网络,当网络中某个部件出现问题时,网络管理员必须迅速找到故障并及时排除。
2、 计费管理:用来记录网络资源的使用,目的是控制和检测网络操作的费用和代价,它对一些公共商业网络尤为重要。
3、 配置管理:配置管理同样重要,它负责初始化网络并配置网络,以使其提供网络服务。
4、 性能管理 :不言而喻,性能管理估计系统资源的运行情况及通信效率情况。
5、 安全管理 :安全性一直是网络的薄弱环节之一,而用户对网络安全的要求有相当高。

**三十六、临界区

临界区是资源对象,只能被一个进程访问

临界区是非内核对象,只在用户态进行锁操作。
内核对象有:存取符号对象、事件对象、文件对象、文件映射对象、I / O完成端口对象、作业对象、信箱对象、互斥对象、管道对象、进程对象、信标对象、线程对象和等待计时器对象等。这些对象都是通过调用函数来创建的。

1、什么是临界区?
答:每个进程中访问临界资源的那段程序称为临界区(临界资源是一次仅允许一个进程使用的共享资源)。每次只准许一个进程进入临界区,进入后不允许其他进程进入。

2、进程进入临界区的调度原则是:
①如果有若干进程要求进入空闲的临界区,一次仅允许一个进程进入。②任何时候,处于临界区内的进程不可多于一个。如已有进程进入自己的临界区,则其它所有试图进入临界区的进程必须等待。③进入临界区的进程要在有限时间内退出,以便其它进程能及时进入自己的临界区。④如果进程不能进入自己的临界区,则应让出CPU,避免进程出现“忙等”现象。

**三十七、Belady现象

Belady现象是指:在分页式虚拟存储器管理中,发生缺页时的置换算法采用FIFO(先进先出)算法时,如果对一个进程未分配它所要求的全部页面,有时就会出现分配的页面数增多但缺页率反而提高的异常现象。
Belady现象的描述:一个进程P要访问M个页,OS分配N(N FIFO是最早出现的页置换算法之一。Belady现象的原因是FIFO算法的置换特征与进程访问内存的动态特征是矛盾的,即被置换的页面并不是进程不会访问的,因而FIFO并不是一个好的置换算法。

**三十八、CPU六大典型的寄存器:

  • 程序计数器(PC),存放下一条要执行的指令在内存中的地址
  • 指令寄存器(IR),存放当前正在执行的指令
  • 地址寄存器(AR),存放将被访问的内存单元的地址
  • 程序状态字寄存器(PSW),存放一些状态标志位
  • 数据缓冲寄存器(DR),存放将要存入存储中的数据,或是从存储器读出来的数据
  • 通用寄存器,存放运算结果和取出的操作数
    操作系统常见面试总结_第19张图片

你可能感兴趣的:(操作系统,面试)