OS

[OS] 用户态与内核态

操作系统的用户态与内核态,及Java的IO调用

Posted by Penistrong on April 20, 2023

用户态与内核态

Linux操作系统的体系架构分为用户空间和内核(即用户态与内核态),运行在用户态的应用程序必须依托于内核提供的资源,比如CPU资源、I/O资源等

站在操作系统的角度,由于进程是操作系统分配资源并调度的最小单位,而线程是CPU任务调度和执行的最小单位

进程与线程的区别:

本质区别: 进程是操作系统分配资源并调度的最小单位,而线程是CPU任务调度和执行的最小单位

包含关系: 一个进程可以拥有多个线程,线程是进程的一部分,进程之间可以相互通信,同一进程下的线程之间也可以相互通信

资源开销: 每个进程拥有独立的内存空间,进程之间切换的开销较大,而同一进程内的线程共享进程的内存空间(当然线程有自己的有一小片工作内存),每个线程拥有独立的运行栈和程序计数器,线程之间的切换开销相对较小。线程不利于资源的管理和保护

为什么要区分用户态与内核态

  • 在CPU提供的所有指令中,有一些指令较为危险,比如内存分配、IO处理等,一旦错用将导致系统崩溃,如果所有程序都可以直接使用这些指令就将会大大增加系统崩溃的概率。CPU将指令分为特权指令和非特权指令,对于危险的特权指令,只允许操作系统及相关模块使用,普通应用程序只能使用非特权指令

  • 如果只有一个内核态,那么所有的程序或进程都必须共享系统资源,导致资源争用和冲突,影响系统性能和效率还会大大降低系统安全性。区分用户态与内核态主要是为了保证计算机系统的安全性、稳定性和性能

Intel CPU将特权等级分为4个级别: Ring0 ~ Ring3。Linux只使用了Ring0和Ring3,运行在Ring3级别的进程被称为运行在用户态,运行在Ring0级别时被称为运行在内核态

从用户态到内核态的切换方式

用户态进程在执行过程中的一些操作只能在内核权限下执行,即CPU需要从用户态切换到内核态,转而执行内核态中的相关程序

一般有3种方式:

  1. 系统调用(Trap):操作系统对内核级别的指令进行封装,统一管理硬件资源,然后向用户程序提供系统服务,其实就是操作系统为用户特别开放的一种中断,称作软中断

  2. 硬中断(Interrupt): 外围设备完成用户请求的操作后,会向CPU发送中断信号,CPU会暂停执行下一条将要执行的指令转而去执行中断信号对应的中断处理程序。如果先前执行的指令是用户态下的程序,在转换时就对应了用户态到内核态的切换。

  3. 异常(Exception): CPU执行用户态下的程序时,发生了某些不可知的异常,这时需要从用户态切换到内核态中的相关异常处理程序,比如缺页中断

这3种方式本质上都是中断触发,只是软中断是进程主动请求,而硬中断和异常对于进程来说是被动的

用户线程与内核线程的映射关系

用户线程与内核线程的映射关系有三种模型:一对一模型、多对一模型、多对多模型

用户线程不是直接使用内核线程,而是使用后者的高级接口:轻量级进程(Light Weight Process, LWP)

  • 一对一: 每个用户线程被映射到一个内核线程,用户线程在其生命期内都会映射到该内核线程。内核负责线程调度,将线程分配到各个处理器上。缺点就是操作系统限制了内核线程数量,且线程调度时需要切换上下文,开销较大

  • 多对一: 将多个用户线程映射到一个内核线程上,线程之间的切换由用户态的代码来进行,因此相对一对一模型,多对一模型的线程切换速度要快许多。此外,多对一模型对用户线程的数量几乎无限制

  • 多对多: 结合了一对一模型和多对一模型的优点,将多个用户线程映射到多个内核线程上

JVM运行在用户态还是内核态?

首先,JVM是以进程的方式运行的,作为用户应用程序工作在用户态

对于JVM里运作的实际线程而言,JVM规范没有限定Java线程需要使用哪种线程模型,根据操作系统和JVM设计者的想法执行线程映射

如果是HotSpot VM,在所有平台上都是采用一对一的线程模型,即每个JVM线程对应一个内核线程

BIO/NIO/AIO

用户态程序执行I/O时,需要发起系统调用,让CPU切换到内核态

BIO

BIO(Blocking I/O),即同步阻塞IO模型

BIO中,用户态应用程序发起系统调用后,会一直阻塞到调用返回

比如,用户态线程发起read()系统调用,然后CPU切换到内核态执行相关调用,内核等待数据准备就绪后(利用DMA(Direct Memory Access)将磁盘文件资源拷贝到内核的Read Buffer中),再从Read Buffer中将数据拷贝到用户空间的Application Buffer中,然后read()调用返回

以上过程,用户态线程自发起调用后就一直阻塞到调用返回,不能继续执行用户程序

Java I/O中的字节流、字符流、缓冲流等其实都是阻塞的,属于BIO模型,调用read()write()时,对应线程一直阻塞到操作完成

NIO

NIO(Non-blocking I/O or New I/O),即同步非阻塞IO模型,Java中NIO也称为新IO,采用的是同步非阻塞IO模型的升级版: I/O多路复用模型

基础的同步非阻塞I/O的思想如下:

用户态线程发起read()系统调用,CPU切换到内核态时让负责该调用的内核程序将文件资源拷贝到内核的Read Buffer中,但是,此时系统调用会直接返回,告知用户态线程”内核在准备数据但还未就绪“。尔后用户态线程可以不断轮询(继续发起read()调用),直到内核完成文件资源到Read Buffer的拷贝后,当次的read()调用后不会立即返回,而是等待内核将Read Buffer中的数据拷贝到用户空间的Application Buffer中,然后read()调用返回,用户态线程就获取到了所需的数据

上述过程中,用户态线程只有在内核缓冲区到用户空间缓冲区的拷贝过程中是阻塞的,其余时间都是不阻塞的,缺点就在于需要不断发起系统调用轮询,而发起调用就会导致用户态切换到内核态,上下文切换是比较消耗资源的

I/O多路复用模型

根据NIO基础模型的缺点,I/O多路复用模型改进了不断轮询所带来的上下文切换消耗,利用selectepoll等系统调用

线程发起selectepoll等支持I/O多路复用的系统调用后,告知内核准备相关数据,然后该调用返回,用户态线程不阻塞转而去处理其他I/O调用。内核准备好数据后(相关文件描述符处于就绪态),通知用户态线程可以发起read()调用了,尔后再进入阻塞的read()调用的缓存拷贝过程

Java的NIO底层利用的就是各个操作系统上提供的支持I/O多路复用的系统调用selectepoll等,利用Selector多路选择器绑定多个Channel通道,一旦Buffer缓冲区中的数据准备就绪,选择器就知道某个通道已准备就绪,通知线程可以发起读取数据的系统调用了

零拷贝

零拷贝是指CPU不需要执行内核缓冲区到用户缓冲区的拷贝动作,从BIO和NIO模型中可以发现,从内核缓冲区到用户缓冲区,必须由CPU完成拷贝工作,还是会导致CPU忙于执行拷贝,而用户态线程阻塞

零拷贝的思想就是让CPU不需要参与数据的拷贝过程,直接由DMA芯片完成(DMA控制外部设备到内存之间的I/O传输),而且零拷贝面向的是一次完整的读写过程(比如读文件然后通过网络发送出去)

以客户端请求服务端的文件为例,利用mmap+write或者sendFile()系统调用,内核通过DMA将磁盘中的文件拷贝到内核缓冲区后,CPU不必执行(1)Read Buffer -> Application Buffer (2) Application Buffer -> Socket Buffer这两次需要切换上下文的拷贝操作,直接让CPU内核读缓冲区的数据拷贝到内核套接字缓冲区里,省去了两次用户态与内核态的切换。最后,DMA再将Socket Buffer中的数据拷贝到网卡设备中

上面的过程中:

  1. 上下文切换次数从4次减少到2次
  2. 数据拷贝次数从4次(2次CPU+2次DMA)减少到了3次(1次CPU+2次DMA)

Linux2.4版本后引入了SG-DMA技术,在DMA拷贝过程中添加了scatter/gather操作,CPU不再需要拷贝了,而是将Read Buffer中的文件描述符信息(内存地址和偏移量),直接发送到Socket Buffer中,DMA根据该文件描述符把对应的数据从Read Buffer中直接拷贝到网卡设备里。这就是真正意义上的零拷贝,CPU完全不需要亲自搬运数据

Java NIO的零拷贝方法为transferTo(),将数据从某个Channel对象传送到另一个Channel通道中,该方法内部是调用了一个native方法transferTo0(),根据不同操作系统,比如Linux中就是利用sendFile()等系统调用实现零拷贝