操作系统面试视角

1,424 阅读33分钟

计算机组成原理相关

简述一下冯诺依曼模型

冯诺依曼模型.png

  • 输入/输出设备 输入设备向计算机输入数据,计算机经过计算,将结果通过输出设备向外界传达。如果输入设备、输出设备想要和 CPU 进行交互,比如说用户按键需要 CPU 响应,这时候就需要用到控制总线。

  • 内存
    在冯诺依曼模型中,程序和数据被存储在一个被称作内存的线性排列存储区域。存储的数据单位是一个二进制位(bit)。最小的存储单位叫作字节,也就是 8 位,英文是 byte,每一个字节都对应一个内存地址。内存地址由 0 开始编号。

    通常说的内存都是随机存取器,也就是读取和写入任何一个地址数据的速度是一样的。

  • 中央处理器
    冯诺依曼模型中 CPU 负责控制和计算。为了方便计算较大的数值,CPU 每次可以计算多个字节的数据(因为一个字节表示的范围是0-255,那要计算大的数值就需要多个 byte 一起计算)。

    • 如果 CPU 每次可以计算 4 个 byte,那么我们称作 32 位 CPU;
    • 如果 CPU 每次可以计算 8 个 byte,那么我们称作 64 位 CPU。

    这里的 32 和 64,称作 CPU 的位宽。

    CPU 中有一个控制单元专门负责控制 CPU 工作,还有逻辑运算单元专门负责计算。CPU 需要进行计算,而 CPU 离内存太远,所以需要一种离自己近的存储来存储将要被计算的数字。这种存储就是寄存器。

  • 总线
    CPU 和内存以及其他设备之间的通信就需要总线。总线有三种:

    • 地址总线,专门用来指定 CPU 将要操作的内存地址。
    • 数据总线,用来读写内存中的数据。当 CPU 需要读写内存的时候,先要通过地址总线来指定内存地址,再通过数据总线来传输数据。
    • 控制总线,用来发送和接收关键信号,比如中断信号,还有设备复位、就绪等信号,都是通过控制总线传输。同样的,CPU 需要对这些信号进行响应,这也需要控制总线。

计算机为什么用二进制

因为电平高、低刚好是1,0; 所以就成了2进制

计算机中数据如何通过线路传递

数据传递其实是通过操作电压,低电压是 0,高电压是 1。如果只有一条线路,每次只能传递 1 个信号,因为你必须在 0,1 中选一个。比如你构造高高低低这样的信号,其实就是 1100,相当于你传了一个数字 10 过去就需要传递 4 次。

这种一个 bit 一个 bit 发送的方式,叫作串行。如果希望每次多传一些数据,就需要增加线路,也就是需要并行。

如果只有 1 条地址总线,那每次只能表示 0-1 两种情况,所以只能操作 2 个内存地址;如果有 10 条地址总线,一次就可以表示 2的10次方种情况,也就是可以操作 1024 个内存地址;如果你希望操作 4G 的内存,那么就需要 32 条线,因为 2的32次方 是 4G。

CPU 的位宽会对计算造成什么影响

假如有个场景:要用 32 位宽的 CPU,加和两个 64 位的数字。

64 位数字拆成 2 个 32 位数字来计算,这样就需要一个算法,先加和两个低位的 32 位数字,算出进位,然后加和两个高位的 32 位数字,最后再加上进位。

而 64 位的 CPU 就可以一次读入 64 位的数字,同时 64 位的 CPU 内部的逻辑计算单元,也支持 64 位的数字进行计算。但是不要仅仅因为位宽的区别,就认为 64 位 CPU 性能比 32 位高很多。

因为大部分应用不需要计算超过 32 位的数字,32 位有符号整数,最大可以到 20 亿,所以大部分场景还都是满足的,所以这样的计算在 32 位还是 64 位中没有什么区别。

还有一点要注意,32 位宽的 CPU 没办法控制超过 32 位的地址总线、数据总线工作。比如说你有一条 40 位的地址总线(其实就是 40 条线),32 位的 CPU 没有办法一次给 40 个信号,因为它最多只有 32 位的寄存器。因此 32 位宽的 CPU 最多操作 2的32次方个内存地址,也就是 4G 内存地址。

当然,如果寄存器只有32位也可以操作40位地址。 比如一个1位的CPU也可以接1000根引脚连到1000位的总线上,只不过需要1000次操作才能把数据写到总线上。如果寄存器只有32位要操作40位地址,那么就需要分步骤操作,类似虚拟内存的方法

简述程序的执行过程

  1. 首先,CPU 读取 PC 指针指向的指令,将它导入指令寄存器。具体有3个步骤:
  • CPU 的控制单元操作地址总线指定需要访问的内存地址(可以理解为把 PC 指针中的值拷贝到地址总线中)。
  • CPU 通知内存设备准备数据(内存设备准备好了,就通过数据总线将数据传送给 CPU)。
  • CPU 收到内存传来的数据后,将这个数据存入指令寄存器。

完成以上 3 步,CPU 成功读取了 PC 指针指向指令,存入了指令寄存器。

  1. 然后,CPU 分析指令寄存器中的指令,确定指令的类型和参数。
  2. 如果是计算类型的指令,那么就交给逻辑运算单元计算;如果是存储类型的指令,那么由控制单元执行。
  3. PC 指针自增,并准备获取下一条指令。 image.png

PC (程序指针) 指针读取指令、到执行、再到下一条指令,构成了一个循环,这个不断循环的过程叫作CPU 的指令周期

PC(程序指针) 也是一个寄存器,64 位的 CPU 会提供 64 位的寄存器。注意,64 位的寄存器可以寻址的范围非常大,但是也会受到地址总线条数的限制。比如和 64 位 CPU 配套工作的地址总线只有 40 条,那么可以寻址的范围就只有 1T,也就是 2的40次方。

详解 a = 11 + 15 的执行过程

a = 11 + 15 是高级语言,计算机无法直接识别,需要编译器编译成指令

  1. 编译器通过分析,发现 11 和 15 是数据,因此编译好的程序启动时,会在内存中开辟出一个专门的区域存这样的常数,这个区域就是数据段,如下图:11 被存储到了地址 0x100;15 被存储到了地址 0x104;

image.png

  1. a = 11 + 15 会被编译器编译成 4条指令,程序启动后,这些指令被导入了一个专门用来存储指令的区域,也就是正文段,如上图,

    • 0x200 位置的 load 指令将地址 0x100 中的数据 11 导入寄存器 R0;
    • 0x204 位置的 load 指令将地址 0x104 中的数据 15 导入寄存器 R1;
    • 0x208 位置的 add 指令将寄存器 R0 和 R1 中的值相加,存入寄存器 R2;
    • 0x20c 位置的 store 指令将寄存器 R2 中的值存回数据区域中的 0x1108 位置。
  2. 具体执行的时候,PC 指针先指向 0x200 位置,然后依次执行这 4 条指令。

注意:

  1. 变量 a 实际上是内存中的一个地址,a 是给程序员的助记符
  2. 上面的例子中,每次操作 4 个地址,也就是 32 位( 一个地址是一个字节,是同时将4个字节读进来),这是用 32 位宽的 CPU 举例。在 32 位宽的 CPU 中,指令也是 32 位的。但是数据可以小于 32 位,比如可以加和两个 8 位的字节。

指令

在上面 “详解 a = 11 + 15 的执行过程” 中,为什么 0x200 中代表加载数据到寄存器的指令是 0x8c000100 (将数据 11 导入寄存器 R0)?

16 进制的 0x8c000100 拆分成二进制:10001100000000000000000100000000 image.png

  • 最左边的 6 位,叫作操作码,英文是 OpCode,100011 代表 load 指令;
  • 中间的 4 位 0000是寄存器的编号,这里代表寄存器 R0;
  • 后面的 22 位代表要读取的地址,也就是 0x100。

所以把操作码、寄存器的编号、要读取的地址合并到了一个 32 位的指令中

再来看案例中加法运算的 add 指令,16 进制表示是 0x08048000,换算成二进制:00001000000001000000000000000000 image.png

  • 最左边的 6 位是指令编码,代表指令 add;
  • 紧接着的 4 位 0000 代表寄存器 R0;
  • 然后再接着的 4 位 0001 代表寄存器 R1;
  • 再接着的 4 位 0010 代表寄存器 R2;
  • 最后剩下的 14 位没有被使用。

指令周期

构造指令的过程,叫作指令的编码,通常由编译器完成;
解析指令的过程,叫作指令的解码,由 CPU 完成。

由此可见 CPU 内部有一个循环:

  • CPU 通过 PC 指针读取对应内存地址的指令,这个步骤叫作 Fetch,就是获取。
  • CPU 对指令进行解码,这个部分叫作 Decode。
  • CPU 执行指令,这个部分叫作 Execution。
  • CPU 将结果存回寄存器或者将寄存器存入内存,这个步骤叫作 Store。

上面 4 个步骤,叫作 CPU 的指令周期。CPU 的工作就是一个周期接着一个周期,周而复始。 image.png

指令的执行速度

CPU 是用石英晶体产生的脉冲转化为时钟信号驱动的,每一次时钟信号高低电平的转换就是一个周期,称为时钟周期。CPU 的主频,说的就是时钟信号的频率。比如一个 1GHz 的 CPU,说的是时钟信号的频率是 1G。

那是不是每个时钟周期都可以执行一条指令?其实,不是的,多数指令不能在一个时钟周期完成,通常需要 2 个、4 个、6 个时钟周期。

指令的类型

不同类型(不同 OpCode)的指令,参数个数、每个参数的位宽,都不一样。而参数可以是三种类型:寄存器、内存地址、数值(一般是整数和浮点)。

当然,无论是寄存器、内存地址还是数值,它们都是数字。

指令从功能角度来划分,大概有以下 5 类:

  • I/O 类型的指令,比如处理和内存间数据交换的指令 store/load 等;再比如将一个内存地址的数据转移到另一个内存地址的 mov 指令。
  • 计算类型的指令,最多只能处理两个寄存器,比如加减乘除、位运算、比较大小等。
  • 跳转类型的指令,用处就是修改 PC 指针。比如编程中大家经常会遇到需要条件判断+跳转的逻辑,比如 if-else,swtich-case、函数调用等。
  • 信号类型的指令,比如发送中断的指令 trap。
  • 闲置 CPU 的指令 nop,一般 CPU 都有这样一条指令,执行后 CPU 会空转一个周期。

指令还有一种分法,就是从寻址模式上划分。

比如求和指令,可能会有 2 个版本:

  • 将两个寄存器的值相加的 add 指令。
  • 将一个寄存器和一个整数相加的 addi 指令。

比如 load 指令也有不同的寻址模式:

  • 直接加载一个内存地址中的数据到寄存器的指令la,叫作直接寻址。
  • 直接将一个数值导入寄存器的指令li,叫作寄存器寻址。
  • 将一个寄存器中的数值作为地址,然后再去加载这个地址中数据的指令lw,叫作间接寻址。

寻址模式是从指令如何获取数据的角度,对指令的一种分类,目的是给编写指令的人更多选择。

相比 32 位,64 位有什么优势?

这里的32、64 位要分为操作系统、是软件、 CPU分别说明。

  • 如果说的是 64 位宽 CPU,那么有 2 个优势

    • 64 位 CPU 可以执行更大数字的运算,这个优势在普通应用上不明显,但是对于数值计算较多的应用就非常明显。
    • 64 位 CPU 可以寻址更大的内存空间
  • 如果 32 位/64 位说的是程序
    那么说的是指令是 64 位还是 32 位的。32 位指令在 64 位机器上执行,困难不大,可以兼容。 如果是 64 位指令,在 32 位机器上执行就困难了,因为 32 位的寄存器都存不下 64 位指令的参数。

  • 如果是 64 位操作系统,也就是操作系统中程序的指令都是 64 位指令,因此不能安装在 32 位机器上。

1 位的 CPU 能操作多大的内存空间?

可以说是无限大

举个例子,地址总线 40 位,说明 CPU 上有 40 个引脚接了地址总线。CPU 只有 1 位,因此操作这 40 个引脚可以分成 40 步。每次设置 1 根引脚的电平是 0 还是 1。
所以本身 CPU 多少位和能操作多少位地址总线,没有本质联系。但是如果需要分步操作,效率会低,需要多次操作,不如一次完成来得划算。 因此通常不拿 32 位 CPU 操作 40 位地址总线,而是用 64 位 CPU 操作。

操作系统基础

什么是操作系统内核?内核有啥作用?

内核是操作系统中应用连接硬件设备的桥梁,内核至少应该提供以下 4 种基本能力:

  • 管理进程、线程(决定哪个进程、线程使用 CPU);
  • 管理内存(决定内存用来做什么);
  • 连接硬件设备(为进程、和设备间提供通信能力);
  • 提供系统调用(接收进程发送来的系统调用)。

通常可以把操作系统分成 3 层,最底层的硬件设备抽象、中间的内核和最上层的应用 image.png

内核权限非常高,它可以管理进程、可以直接访问所有的内存,因此确实需要和进程之间有一定的隔离。这个隔离用类似请求/响应的模型 image.png
但不同的是在浏览器、服务端模型中,浏览器和服务端是用不同的机器在执行,不需要共享一个 CPU。但是在进程调用内核的过程中,这里是存在资源共享的。

比如,一个机器有 4 个 CPU,不可能让内核用一个 CPU,其他进程用剩下的 CPU。这样太浪费资源了。 再比如,进程向内核请求 100M 的内存,内核把 100M 的数据传回去。 这个模型不可行,因为传输太慢了。

所以,这里多数操作系统的设计都遵循一个原则:进程向内核发起一个请求,然后将 CPU 执行权限让出给内核。内核接手 CPU 执行权限,然后完成请求,再转让出 CPU 执行权限给调用进程

用户态和内核态

Kernel 运行在超级权限模式(Supervisor Mode)下,拥有很高的权限。按照权限管理的原则,多数应用程序应该运行在最小权限下。因此,很多操作系统,将内存分成了两个区域:

  • 内核空间(Kernal Space),这个空间只有内核程序可以访问,内核空间的代码可以访问所有内存,我们称这些程序在内核态(Kernal Mode) 执行
  • 用户空间(User Space),这部分内存专门给应用程序使用,用户空间的代码被限制只能使用一个局部的内存空间,我们说这些程序在用户态(User Mode) 执行

系统调用过程

用户态程序需要执行系统调用,就需要切换到内核态执行: image.png 如上图:
用户态的程序发起系统调用时,因为系统调用中牵扯特权指令,用户态程序权限不足,因此会中断执行,也就是 Trap(Trap 是一种中断)。

发生中断后,当前 CPU 执行的程序会中断,跳转到中断处理程序。内核程序开始执行,也就是开始处理系统调用。内核处理完成后,主动触发 Trap,这样会再次发生中断,切换回用户态工作

读取硬盘数据到内存中这个过程,CPU 需不需要一个个字节处理?

通常是不用的,因为在今天的计算机中有一个叫作 Direct Memory Access(DMA)的模块,这个模块允许硬件设备直接通过 DMA 写内存,而不需要通过 CPU(占用 CPU 资源)。 image.png

进程与线程

什么是进程,Linux中使用什么命令查看进程?

一个应用程序启动后会在内存中创建一个执行副本,这就是进程,进程是操作系统分配资源的最小单位。Linux 的内核是一个 Monolithic Kernel(宏内核),因此可以看作一个进程。也就是开机的时候,磁盘的内核镜像被导入内存作为一个执行副本,成为内核进程。

进程可以分成用户态进程和内核态进程两类。用户态进程通常是应用程序的副本,内核态进程就是内核本身的进程。如果用户态进程需要申请资源,比如内存,可以通过系统调用向内核申请。

命令:

  • ps(p 代表 processes,也就是进程;s 代表 snapshot,也就是快照)
    如果想看到所有的进程,可以用ps -e,-e没有特殊含义,只是为了和-A区分开。通常不直接用 ps -e 而是用ps -ef,因为-f可以带上更多的描述字段。还有一个 ps -aux,它和ps -ef能力差不多,但是是 BSD 风格的。 image.png

    • UID 指进程的所有者;
    • PID 是进程的唯一标识;
    • PPID 是进程的父进程 ID;
    • C 是 CPU 的利用率(就是 CPU 占用);
    • STIME 是开始时间;
    • TTY 是进程所在的 TTY,如果没有 TTY 就是 ?号;
    • TIME;
    • CMD 是进程启动时的命令,如果不是一个 Shell 命令,而是用方括号括起来,那就是系统进程或者内核过程。
  • top(和ps能力差不多,但是显示的不是快照而是实时更新数据的top指令)

线程

线程是一种轻量级进程(Light Weighted Process),一个进程可以拥有多个线程。进程创建的时候,一般会有一个主线程随着进程创建而创建。那进程分为用户态进程和内核态进程,那线程也分为用户态线程和内核态线程。

那是用户态的进程创建用户态的线程,内核态的进程创建内核态的线程吗?其实不是,进程可以通过 API 创建用户态的线程,也可以通过系统调用创建内核态的线程。

用户态线程

用户态线程操作系统内核并不知道它的存在,它完全是在用户空间中创建
它的优点:

  • 管理开销小:创建、销毁不需要系统调用。
  • 切换成本低:用户空间程序可以自己维护,不需要走操作系统调度

它的缺点:

  • 与内核协作成本高:这种线程完全是用户空间程序在管理,当它进行 I/O 的时候,无法利用到内核的优势,需要频繁进行用户态到内核态的切换。
  • 线程间协作成本高:设想两个线程需要通信,通信需要 I/O,I/O 需要系统调用,因此用户态线程需要支付额外的系统调用成本。
  • 无法利用多核优势:比如操作系统调度的仍然是这个线程所属的进程,所以无论每次一个进程有多少用户态的线程,都只能并发执行一个线程,因此一个进程的多个线程无法利用多核的优势。
  • 操作系统无法针对线程调度进行优化:当一个进程的一个用户态线程阻塞(Block)了,操作系统无法及时发现和处理阻塞问题,它不会更换执行其他线程,从而造成资源浪费

内核态线程

内核态线程是执行在内核态,可以通过系统调用创造一个内核级线程。 它的优势:

  • 可以利用多核 CPU 优势:内核拥有较高权限,因此可以在多个 CPU 核心上执行内核线程。
  • 操作系统级优化:内核中的线程操作 I/O 不需要进行系统调用;一个内核线程阻塞了,可以立即让另一个执行。

它的缺点。

  • 创建成本高:创建的时候需要系统调用,也就是切换到内核态。
  • 扩展性差:由一个内核程序管理,不可能数量太多。
  • 切换成本较高:切换的时候,也同样存在需要内核操作,需要切换内核态。

用户态和内核态线程映射关系

  • 多对一关系
    用户态进程中的多线程复用一个内核态线程

    • 优点是:极大地减少了创建内核态线程的成本
    • 缺点是:线程不可以并发

    疑问:用户态线程怎么用内核态线程执行程序?
    程序是存储在内存中的指令,用户态线程是可以准备好程序让内核态线程执行的。

    image.png

  • 一对一关系
    每个用户态的线程分配一个单独的内核态线程,即每个用户态都需要通过系统调用创建一个绑定的内核线程,并附加在上面执行

    • 优点是:模型允许所有线程并发执行,能够充分利用多核优势,Windows NT 内核采取的就是这种模型
    • 缺点是:因为内核线程较多,对内核调度的压力会明显增加

    image.png

  • 多对多关系
    n 个用户态线程分配 m 个内核态线程。m 通常可以小于 n。一种可行的策略是将 m 设置为核数。

    • 优点是:减少了内核线程,同时也保证了多核心并发
    • 缺点是:m对n 实现太复杂且多内核改动太多 image.png
  • 混合了多对多和一对一 多数用户态线程和内核线程是 n 对 m 的关系,少量用户线程可以指定成 1 对 1 的关系 image.png

用户态线程和内核态线程的区别

用户态线程工作在用户空间,内核态线程工作在内核空间。用户态线程调度完全由进程负责,通常就是由进程的主线程负责。相当于进程主线程的延展,使用的是操作系统分配给进程主线程的时间片段。内核线程由内核维护,由操作系统调度。

用户态线程无法跨核心,一个进程的多个用户态线程不能并发,阻塞一个用户态线程会导致进程的主线程阻塞,直接交出执行权限。这些都是用户态线程的劣势。内核线程可以独立执行,操作系统会分配时间片段。因此内核态线程更完整,也称作轻量级进程。内核态线程创建成本高,切换成本高,创建太多还会给调度算法增加压力,因此不会太多。

实际操作中,往往结合两者优势,将用户态线程附着在内核态线程中执行。

JVM 的线程是用户态线程还是内核态线程?

JVM 自己本身有一个线程模型。在 JDK 1.1 的时候,JVM 自己管理用户级线程。这样做缺点非常明显,操作系统只调度内核级线程,用户级线程相当于基于操作系统分配到进程主线程的时间片,再次拆分,因此无法利用多核特性。

为了解决这个问题,后来 Java 改用线程映射模型,因此,需要操作系统支持。在 Windows 上是 1 对 1 的模型,在 Linux 上是 n 对 m 的模型。顺便说一句,Linux 的PThreadAPI 创建的是用户级线程,如果 Linux 要创建内核级线程有KThreadAPI。映射关系是操作系统自动完成的,用户不需要管。

线程调度算法

调度,是一个制定计划的过程,线程调度是指操作系统如何决定未来执行哪些线程。

先到先服务(First Come First Service,FCFS)算法

早期的操作系统使用先到先服务算法,也就是先到的作业先被计算,后到的作业,排队进行。
这时会使用一种队列的数据结构,具有先入先出(First In First Out,FIFO)性质。

  • 优点:先到队列作业先处理,体现了公平性,另外,处理作业不会发生切换(按顺序处理),没有额外开销,吞吐量最优。
  • 缺点:对于等待作业的用户来说,是有问题的,举个例子,如果某个作业需要1天时间,而等待的作业只需要几分钟,那对于等待的用户来说就是个噩梦了

短作业优先(Shortest Job First,SJF)算法

由于先到先服务算法的缺点,所以出现了短作业优先算法,会同时考虑到来顺序和作业预估时间的长短。

短作业优先的优势,就是平均等待时间减少,举个例子:假如有三个作业,分别花费 10、3、3分钟 image.png
按照 3,3,10 作业顺序,平均等待时间是:(0 + 3 + 6) / 3 = 3 分钟;
按照 10,3,3 作业顺序,平均等待时间是:( 0 + 10 + 13 )/ 3 = 7.66 分钟;

所以,在大多数情况下,应该优先处理用时少的,从而降低平均等待时长。

但是这种算法也有问题:

  1. 紧急任务如何插队?
  2. 等待太久的任务如何插队?
  3. 先执行的大任务导致后面来的小任务没有执行如何处理?比如先处理了一个 1 天才能完成的任务,工作半天后才发现预估时间 1 分钟的任务也到来了。

优先级队列(PriorityQueue)算法

优先队列算法主要解决 “紧急任务如何插队,等待太久的任务如何插队” 这两个问题的。

优先级队列可以给队列中每个元素一个优先级,优先级越高的任务就会被先执行。一种实现方法就是用到了堆(Heap)数据结构,更最简单的实现方法,就是每次扫描一遍整个队列找到优先级最高的任务。也就是说,堆(Heap)可以帮助你在 O(1) 的时间复杂度内查找到最大优先级的元素。

那这个优先级怎么算呢?

  • 对于紧急任务,就可以指定一个更高的优先级;
  • 对于普通任务,可以在等待时间(W) 和预估执行时间(P) 中,找一个数学关系来描述。比如:优先级 = W/P。W 越大,或者 P 越小,就越排在前面,当然还有其他数学方法;

抢占(Preemption)算法

抢占是用来解决 “先执行的大任务导致后面来的小任务没有执行的情况如何处理”问题的。

抢占就是把执行能力分时,分成时间片段。 让每个任务都执行一个时间片段。如果在时间片段内,任务完成,那么就调度下一个任务。如果任务没有执行完成,则中断任务,让任务重新排队,调度下一个任务。

结合抢占和优先级队列算法,就构成了一个基本的线程调度模型:
线程相对于操作系统是排队的,操作系统为每个线程分配一个优先级,然后放入一个优先级队列中,优先级最高的线程下一个执行。 image.png
每个线程执行一个时间片段,然后每次执行完一个线程就执行一段调度程序

image.png
红色代表调度程序,其他颜色代表被调度线程的时间片段,调度程序可以考虑实现为一个单线程模型,这样不需要考虑竞争条件。

这种模型已经很好了,不过可以优化:

  1. 如果一个线程优先级非常高,其实没必要再抢占,因为无论如何调度,下一个时间片段还是给它。那么这种情况如何实现?
  2. 如果希望实现最短作业优先的抢占,就必须知道每个线程的执行时间,而这个时间是不可预估的,那么这种情况又应该如何处理?

多级队列模型算法

多级队列,就是多个队列执行调度

image.png

如上图:

  • 紧急任务走高优队列,非抢占执行,只要紧急队列有任务,下层队列就会让出执行权限
  • 普通任务先放到优先级仅次于高优任务的队列中,并且只分配很小的时间片;如果没有执行完成,说明任务不是很短,就将任务下调一层。下面一层,最低优先级的队列中时间片很大,长任务就有更大的时间片可以用。

通过这种方式,短任务会在更高优先级的队列中执行完成,长任务优先级会下调,也就类似实现了最短作业优先的问题。

实际操作中,可以有 n 层,一层层把大任务筛选出来。 最长的任务,放到最闲的时间去执行。要知道,大部分时间 CPU 不是满负荷的。

所以对于面试题:线程调度都有哪些方法?

非抢占的先到先服务的模型是最朴素的,公平性和吞吐量可以保证。但是因为希望减少用户的平均等待时间,操作系统往往需要实现抢占。操作系统实现抢占,仍然希望有优先级,希望有最短任务优先。

但是这里有个困难,操作系统无法预判每个任务的预估执行时间,就需要使用分级队列。最高优先级的任务可以考虑非抢占的优先级队列。 其他任务放到分级队列模型中执行,从最高优先级时间片段最小向最低优先级时间片段最大逐渐沉淀。这样就同时保证了小任务先行和高优任务最先执行。

进程的开销比线程大在了哪里

为啥说线程是轻量级进程,说到这个,就要说操作系统分配资源,最重要的 3 种资源是:计算资源(CPU)、内存资源和文件资源。
早期的 OS 设计中没有线程,3 种资源都分配给进程,多个进程通过分时技术交替执行,进程之间通过管道技术等进行通信,但是这样的话,一个应用需要开多个进程,因为应用总是有很多必须要并行做的事情。

所以设计了线程,只被分配了计算资源(CPU),由操作系统调度线程(也被称为内核级线程)。

操作系统创建一个进程后,进程的入口程序被分配到了一个主线程执行,这样看上去操作系统是在调度进程,其实是调度进程中的线程

Linux 中创建一个进程自然会创建一个线程,也就是主线程。创建进程需要为进程划分出一块完整的内存空间,有大量的初始化操作,比如要把内存分段(堆栈、正文区等)。创建线程则简单得多,只需要确定 PC 指针和寄存器的值,并且给线程分配一个栈用于执行程序,同一个进程的多个线程间可以复用堆栈。因此,创建进程比创建线程慢,而且进程的内存开销更大。

进程间通信都有哪些方法?

从单机和分布式角度阐述。

  • 如果考虑单机模型,有管道、内存共享、消息队列。这三个模型中,内存共享程序最难写,但是性能最高。管道程序最好写,有标准接口。消息队列程序也比较好写,比如用发布/订阅模式实现具体的程序。

  • 如果考虑分布式模型,就有远程调用、消息队列和网络请求。直接发送网络请求程序不好写,不如直接用实现好的 RPC 调用框架。RPC 框架会增加系统的耦合,可以考虑 消息队列,以及发布订阅事件的模式,这样可以减少系统间的耦合。

Liunx 相关

什么是 Shell

Shell 把输入的指令,传递给操作系统去执行,所以 Shell 是一个命令行的用户界面。

bash(Bourne Again Shell),它是用 Shell 组成的程序。这里的 Bourne 是一个人名,Steve Bourne 是 bash 的发明者。

Liunx 的所有指令不是写死在操作系统中的,而是一个个程序。比如 rm 指令,可以用 which 指令查看它所在的目录(在 /usr/bin/rm 目录中)。

Linux 几种常见的文件类型

Linux 常见的文件类型有以下 7 种:

  • 普通文件(比如一个文本文件);
  • 目录文件(目录也是一个特殊的文件,它用来存储文件清单,比如/也是一个文件);
  • 可执行文件(rm就是一个可执行文件);
  • 管道文件;
  • Socket 文件;
  • 软链接文件(相当于指向另一个文件所在路径的符号);
  • 硬链接文件(相当于指向另一个文件的指针)

可以使用 ls -F 就可以看到当前目录下的文件和它的类型:

  • * 结尾的是可执行文件;
  • = 结尾的是 Socket 文件;
  • @ 结尾的是软链接;
  • | 结尾的管道文件;
  • 没有符号结尾的是普通文件;
  • / 结尾的是目录。

什么是管道?以及管道有什么实际作用?

管道(Pipeline)的作用是在命令和命令之间,传递数据。比如说一个命令的结果,就可以作为另一个命令的输入,这个命令就是进程,所以,更准确地说,管道在进程间传递数据(也就是将一个进程的输出流定向到另一个进程的输入流,就像水管一样,作用就是把这两个文件接起来。如果一个进程输出了一个字符 X,那么另一个进程就会获得 X 这个输入)。

Linux 中的管道也是文件,有两种类型的管道:

  • 匿名管道(Unnamed Pipeline),这种管道也在文件系统中,但是它只是一个存储节点,不属于任何一个目录。说白了,就是没有路径,用|就可以创造和使用。
  • 命名管道(Named Pipeline),这种管道就是一个文件,有自己的路径,用mkfifo指令可以创建一个命名管道。

使用场景:

  • 排序 例如把ls列出来的文件名倒叙排序,就可以使用管道,命令:ls | sort -r

  • 去重
    比如有个字典文件,里面都是词语,想要去重

    Apple
    Banana
    Apple
    Banana
    

    去重可以使用uniq指令,uniq指令能够找到文件中相邻的重复行,然后去重。但上面的文件重复行是交替的,所以不可以直接用uniq,因此可以先sort这个文件,然后利用管道将sort的结果重定向到uniq指令。 命令:sort a.txt | uniq

  • 筛选
    比如想找到项目文件下所有文件名中含有Spring的文件。就可以利用grep指令,命令:find ./ | grep 'Spring'

  • 数行数
    还有比较常见的场景是数行数,例如,数文件有多少行,命令:wc -l text.txt,如果要统计目录下多少文件,命令:ls | wc -l,还有比如要统计当前java项目有多少行,命令:find ./ -iname "*.txt" |xargs wc -l

如何将当前目录下所有.java的文件的名字加一个前缀 prefix_ ?

使用xargs指令,该指令从标准数据流中构造并执行一行行的指令。xargs从输入流获取字符串,然后利用空白、换行符等切割字符串,在这些字符串的基础上构造指令,最后一行行执行这些指令。 命令:ls | grep ".java" | xargs -I GG mv GG pre_GG

  • ls找到所有的文件;
  • 使用grep过滤只有java结尾的文件;
  • -I参数是查找替换符,这里我们用GG替代ls找到的结果;-I GG后面的字符串 GG 会被替换为*.java

Liunx 远程操作指令

远程操作指令用得最多的是sshssh指令允许远程登录到目标计算机并进行远程操作和管理。
还有一个scp帮助我们远程传送文件。

PS:本文章内容整理自《重学操作系统》