对IO概念模糊:计算机IO过程与零拷贝

2023年 10月 8日 25.2k 0

一、前置知识

1.1 内存分段

现代计算机在加载操作系统、正常启动后,其内存会主要分成两大段:

  • 内核段
  • 用户段

内核段:

操作系统本质上是一个计算机的管理程序,该程序相关的所有资源,被存放在内核段中。

用户段:

用户段用来存放各个进程的数据和指令。

根据所访问的内存段的不同,CPU会处于不同的态,访问用户段的时候处于用户态,访问内核段的时候处于内核态。

1.2 CPU的态

1.2.1 CPU的工作过程

CPU要执行的指令的地址存在寄存器中,指令存放在内存中,而CPU本质上就是一个去内存中根据地址取指令,然后执行指令的硬件。

举一个例子:

例如PC寄存器中存放50,CPU读到存放的50,发出一条取址指令,去取出地址为50的内存单元中的指令,再传回给CPU。

1.2.2 寄存器

众所周知,为了配平CPU和内存之间速率的差距,CPU和内存之间存在着一个由寄存器组成的中间层,寄存器种会存放着CPU接下来要执行的指令,以及后续可能要执行到的指令以及可能要用到的数据。只有预先装载进去这部分可能要用到的东西才能抹平CPU和内存之间的速率差距,不然每次都要去内存取内容,可能是会拉低CPU的效率的。

但该预先装载哪些内容进寄存器中呢?这里遵循了程序的局部性原理。

程序的局部性原理:

程序在执行的时候呈现出局部性规律,在一段时间内,整个程序的执行仅限于程序中的某一个部分,相应的,执行所访问的存储空间也局限于某个内存区域。局部性又分为时间局部性和空间局部性。时间局部性指的是,如果程序中的某条指令一旦执行,则不久后可能会被再次执行,执行指令时访问的数据单元在不久后会被再次访问。空间局部性指的是,一旦访问了某个存储单元,不久后,其附近的存储单元也将被访问。

1.2.3 CPU的上下文

为了抹平内存和CPU之间的速率差,给CPU配备了寄存器。寄存器中存储着当前执行的指令、数据、以及下一条指令在内存中的地址等等事关程序正常运行的关键信息。所以寄存器中存储的内容合称为CPU的上下文。

1.2.4 系统调用

系统中将一些对系统级别资源的调用封装成了一个个函数,称为系统调用,常见的系统调用有很多,比如IO操作就是个系统调用。

1.2.5 CPU的态

操作系统在启动后,内存被分为两部分(两段):

  • 内核段从0地址开始编址,存放操作系统程序,里面包含系统调用。
  • 用户段,从内核段之后开始编址,存放用户程,也就是各个进程的数据和指令。

由于内核段存放的是系统相关的内容,基于安全的考虑,肯定是不允许被CPU随意访问的,需要特权才行。因此将CPU的权限设计为了两种状态:

  • 用户态,只能访问用户段
  • 内核态,能访问用户段和内核段

所谓的态就是能访问用户段的上下文以及能访问内核段的上下文。当我们调用系统调用的时候会引起上下文的切换,也就是CPU态的切换。上下文切换的意思是,先把前一个任务的 CPU 上下文保存起来,然后加载新任务的上下文到这些寄存器和程序计数器,最后再跳转到程序计数器所指的新位置,运行新任务。之所以会切换上下文,这是因为寄存器加载数据和指令的时候遵循了程序的局部性原理。CPU访问用户段时,寄存器里预加载的是用户段的资源;CPU访问内核段时,寄存器里预加载的是内核段的资源。

所以CPU进行态切换的时候,上下文一定会完全换一套的。总的来说为了保证多数情况下程序执行的效率,“局部性原理”是必须存在的,为了内核的安全,CPU态的划分是必须存在的。所以,CPU上下文切换是不得不接受的一种代价。

CPU的上下文切换是种耗时的操作:

寄存器保存和恢复:在上下文切换过程中,需要保存当前任务的寄存器状态,并恢复下一个任务的寄存器状态。寄存器保存和恢复涉及将寄存器的值从CPU保存到内存(或者栈)中,以及从内存中恢复到CPU中。这涉及到数据的读写和复制操作,会引入一定的延迟和开销。

内存刷新和缓存失效:上下文切换可能涉及刷新CPU缓存和内存管理单元(MMU)的操作。当切换到一个新的任务时,之前的任务的缓存内容可能需要刷新,新任务的页表和内存映射需要加载和设置,这些操作可能导致缓存失效和内存访问延迟。

上下文数据复制:在上下文切换过程中,需要将当前任务的上下文数据保存到内存中,同时从内存中加载下一个任务的上下文数据。这包括寄存器状态、程序计数器、标志位和其他与任务执行相关的数据。数据的复制和加载需要占用CPU和内存带宽,并引入一定的延迟。

任务切换开销:上下文切换不仅仅涉及寄存器和内存的操作,还包括任务切换本身的开销。这包括切换内核栈、更新任务控制块(TCB)、更新调度器数据结构等操作。这些操作可能需要修改内核数据结构,增加了上下文切换的开销。

总的来说CPU上下文切换很耗时,我们常见的就是IO操作、进程切换这些都会引起CPU上下文切换。

1.3 计算机IO的过程

在程序执行时有很多高耗时操作,比如IO操作就是。当计算机执行IO操作的时候,IO设备的速度肯定是远远落后于CPU的速度的,IO没有完成,后续依赖的数据没到位,程序也没办法继续向下执行,于是CPU就只好赋闲,傻傻的等IO执行完成,再继续向下运行程序,无疑这会造成CPU资源的浪费,使得计算机的工作效率变得很低。

于是现代操作系统中将CPU划分成了很多时间片,不同时间片可以去运行不同的程序,比如:

  • 这一秒运行的A程序,
  • 下一秒运行的B程序,
  • 再下一秒再运行A程序。
  • 这样间插执行就会避免傻等带来的CPU资源的浪费,如果IO耗时2秒,那么CPU至少还有1秒被其它程序使用到了。

    后来操作系统用了更激进的方式来处理IO指令,让CPU的时间一丝一毫都不被浪费,这种处理方式就是遇见IO指令,直接启动IO后,CPU直接转去执行其它任务,当IO完成后发送一个中断信号给CPU,让CPU中断当前的任务,转过来继续执行IO后的程序

    1.4 IO与内存

    计算机进行IO的时候,本质上会为每一个IO设备在内存中分配一块空间,向这块空间里进行读写,即可完成IO。为什么给IO设备分配的内存会是在内核段里喃?主要是基于两点进行考虑的:

    • 安全性
    • 特权操作

    1.4.1 安全性:

    I/O 操作通常需要与计算机的外部设备(如磁盘、网络设备等)进行交互,如果允许各个进程自己私自与外部设备进行交互,IO的内存放在各个进程内部,太散了,不是很好进行安全控制,相反,如果将IO的内存放在内核段,就很便于集中管理,可以附加一些安全机制上去。

    1.4.2 特权操作:

    首先IO指令本身就是特权指令,会让CPU进入内核态,其次进行IO的时候会用到中断信号,也涉及到特权指令,也要求CPU处于内核态,所以如果IO内存是在内核段中,让CPU提前进入内核状态,也避免了后面来回切状态造成的时间浪费。

    整个IO在内存中的流转过程如下:

    读的时候磁盘拷贝到内核段、内核段拷贝到用户段,

    写的时候用户段拷贝到内核段、内核段拷贝到磁盘。

    一共四次复制。

    特别说明:

    我知道其它很多地方这里将图画成了这个样子:

    这是因为他描绘的这次IO是从磁盘上读出来然后写到网络上去,网卡和磁盘可以理解为两个不同的IO设备,所以他们在内核段中的IO内存,地址是不同的。但是如果仅仅是对磁盘的一次本地IO,那么进行IO的内核段地址会是同一个,在同一个地址内进行读写。这里为了涵盖多种情况,所以博主没有将它分开,读者悉知。

    二、零拷贝

    零拷贝(Zero-copy)是一种优化技术,并不是一次拷贝都不做,而是旨在减少数据在系统内部的复制操作,从而提高数据传输的效率。它的主要目标是减少内存到内存之间的数据拷贝。零拷贝有两种实现方式:

  • MMap
  • SendFile
  • 2.1.MMap

    通过上文我们知道一次IO,数据会进行四次拷贝,MMap这种方式在将内核段中的数据拷贝到用户段的这次拷贝中,拷贝的不是数据,而是数据的映射,这样在用户段中进行数据处理完后,就不必再从用户段拷贝回内核段,从而减少了一次拷贝。

    之所以能实现这样的效果是得益于操作系统底层有两种读操作:

    读取数据:常见的系统调用如 read()(用于文件描述符)或 recv()(用于套接字)用于从文件或套接字中读取数据。这些系统调用从相应的输入源(如磁盘、网络等)读取数据,并将其复制到应用程序提供的缓冲区中。这种方式涉及了数据的复制,因为数据需要从内核态复制到用户态缓冲区中。

    读取映射:另一种方式是通过内存映射(Memory Mapping)来实现读取操作。通过将文件或设备的数据映射到进程的内存区域中,应用程序可以直接访问内存映射区域中的数据,而无需使用传统的 read() 系统调用。在这种情况下,应用程序可以通过直接读取内存映射区域中的数据来获取文件或设备的内容,避免了中间的数据复制。

    特别说明:

    还是和上文类似,画图的问题。这里为了涵盖,本地IO和网络IO两种情况,内核段没拆成几个设备的不同地址空间,但是如果是从磁盘中读,然后向网络中写,是跨了IO设备的,所以中间有个内核段地址间的复制过程,如下图:

    2.2.SendFile

    SendFile更狠,直接就不走用户段,直接就是从内核段的一个内存地址复制到另一个内存地址,主要是拿来进行网络传输的,从本地磁盘读数据,读到一个地址里,然后将这个地址里的数据复制给另一个IO设备的地址,这个地址就可以是网络IO的地址。很明显sendFile有一个弊病,就是没走用户段的话,数据没办法处理,所以其只是一种用于实现数据传输的 "零拷贝" 技术,而不能直接进行数据处理。并且SendFile还存在大小限制。

    三、JAVA中的零拷贝

    零拷贝需要进行系统调用才能实现,很明显要我们手写实现零拷贝是很底层、很麻烦的,好在JAVA在NIO中封装了mmap、SendFile两种零拷贝的API,当我们想在JAVA中使用零拷贝时,直接调API即可。

    很多同学在NIO中老是搞不明白channel和buffer的关,容易晕,这里博主一句话总结一下:

    JavaNlO中 的Channel就相当于操作系统中的内核缓冲区,而Buffer就相当于操作系统中的用户缓冲区。

    mmap:

    MappedByteBuffer mappedByteBuffer = new RandomAccessFile(file, "r") 
                                     .getChannel() 
                                    .map(FileChannel.MapMode.READ_ONLY, 0, len);

    SendFile:

    sendFile进行网络传输:

    FileChannel sourceChannel = new RandomAccessFile(sourceFile, "rw").getChannel();
    SocketChannel socketChannel = SocketChannel.open(sa);
    sourceChannel.transferTo(0, sourceChannel.size(), socketChannel);

    sendFile进行文件拷贝:

    try (FileChannel srcChannel = new FileInputStream(src).getChannel();
         FileChannel targetChannel = new FileInputStream(target).getChannel()) {
         srcChannel.transferTo(0, srcChannel.size(), targetChannel );
     } catch (IOException e) {
         e.printStackTrace();
     }

    相关文章

    JavaScript2024新功能:Object.groupBy、正则表达式v标志
    PHP trim 函数对多字节字符的使用和限制
    新函数 json_validate() 、randomizer 类扩展…20 个PHP 8.3 新特性全面解析
    使用HTMX为WordPress增效:如何在不使用复杂框架的情况下增强平台功能
    为React 19做准备:WordPress 6.6用户指南
    如何删除WordPress中的所有评论

    发布评论