IO系列(一):IO

                                           最基础的 IO

  • 系统级I/O架构
  • I/O控制和访问
  • I/O流

I/O系列是一个大系列,或者说是大系列的一部分,I/O是一部分,还会有线程系列和并发系列。笔者认为这三部分都是息息相关的。所以,会从最基础的I/O讲起。I/O系列会从基础I/O讲起,之后是NIO,最后是AIO。由于这是一套大系列的文章,笔者会一点点的更新和修改。

I/O(input/output),即输入/输出端口。每个设备都会有一个专用的I/O地址,用来处理自己的输入输出信息。CPU与外部设备、存储器的连接和数据交换都需要通过接口设备来实现,前者被称为I/O接口,而后者则被称为存储器接口。存储器通常在CPU的同步控制下工作,接口电路比较简单;习惯上说到接口只是指I/O接口。

系统级I/O结构

所有计算机都有一条总线, 用来连接大部分内部硬件设备. CPU和IO设备之间的数据通路称为IO总线, 80x86使用16位的地址总线对IO设备进行寻址, 使用8位,16位或者32位的数据总线传输数据. 每个IO设备以此连接到IO总线上, 这种连接使用了包含三个元素的硬件组织层次: IO端口, 接口和设备控制器。

PC的通用I/O架构如下所示:

接下来,我将按照从上而下的顺序(即I/O controller--> I/O Interface--->I/O Port)对该图进行详细的介绍。

I/O设备

按照数据的组织方式对I/O设备进行分类:

块设备:每个块都有自己的地址,传输依次是以块为单位进行的通常在512-32768之间,典型的就是磁盘。

字符设备:没有固定的地址,每次以字符为单位接收或发送一个字符流。如键盘、鼠标。

总线

一个系统要能正确工作,必须要有数据通道(data paths)的机制,软件和硬件系统都概莫能外。对于计算机系统而言,必须要有data paths的机制来确保CPU, RAMI/O设备之间的信息数据能正确的流动。这些data paths,通常被称为总线(BUS),是计算机内部主要的通信通道。

计算机内部一般有系统总线来连接内部所有的硬件设备。一个典型的系统总线是PCI((Peripheral Component Interconnect)总线。其他类型的用得较多的总线还有ISA,EISA, MCA, SCSI, 和USB。一个计算机有多个不同类型的总线,这些总线由桥(bridge)链接起来。

连接I/O设备和CPU的数据通道可统称为I/O总线。外设是为了用来适配一个外设总线而存在的, 并且大部分流行的 I/O 总线成型在个人计算机上。可见,总线的类型影响到I/O设备的内部设计同时影响到Linux内核对它的处理方式。

设备控制器

复杂的设备可能需要一个device controller来驱动,输入输出设备本身并不能直接跟CPU打交道,而是通过它的设备控制器跟CPU打交道。每一种输入输出设备都有一个相应的设备控制器,设备与设备控制器结合起来,才能完成相应的输入输出功能。具体来讲,每个设备控制器里都有一些寄存器,即,I/O端口,用于和CPU进行通信,包括控制寄存器(控制设备发送数据,接收数据,打开或关闭等操作),状态寄存器(了解该设备的当前状态,就绪或忙碌)和数据寄存器等等。通过这些寄存器,cpu可以命令寄存器进行一些操作,比如发送数据,接收数据,开启或者关闭。另外设备寄存器还有一个操作系统可以读写的缓冲区

I/O设备一般由机械部分和电子部分组成,电子部分就称为设备控制器或者适配器。控制器一般有一个连接器,通往设备本身的电缆可以连接到这个连接器,一个设备控制器可以控制2,4,8或者更多个设备。控制器的主要任务是将串行的位流转换为字节块,并进行必要的校正工作。比如说磁盘,从磁盘驱动器出来的是字节流,因此到设备控制器后,在设备控制器的内部的缓冲区进行组装,然后进行校验,最后再将它复制进入内存

设备控制器(I/O Controller)的作用有以下两个:

1、解析从I/O Interface接收到的高层次命令,并通过发送时序正确的电信号给I/O设备,I/O设备则根据这些信号去执行相  应的动作。

2、正确的解析和翻译来自I/O设备的电信号并通过I/O Interface修改相应的I/O 状态寄存器值。(I/O状态寄存器是I/O PORT的一种。) 

I/O接口

对于CPU而言,如果它要发数据到某个设备,其实是发到对应的接口(I/O Interface)I/O Interface是一种硬件电路,插入在I/O Port和相应的设备控制器之间。它充当一个解释器的功能I/O Port里的值解析为对应设备的命令或数据。相应的,它也会读取设备的状态值,并更新I/O port里的值。

有两种类型的interface:

1、定制化接口。常用的有键盘接口,图形接口,磁盘接口,总线鼠标接口,网络接口等。

2、通用接口。常见的有串口,并口,PCMCIA(如硬盘、网卡等)接口,SCSI接口,USB接口等。

I/O端口

           每个连接到IO总线上的设备都有自己的IO地址集, 通常称为IO端口. 访问设备实际上是读写这些寄存器,所有的信息会由接口转给它的设备。有四条专用的汇编语言指令允许CPU对IO端口读写, 它们是in,ins,out和outs, 利用这些指令, CPU使用地址总线选择所请求的IO端口, 使用数据总线在CPU寄存器和端口之间传送数据。一般来说,CPU会根据需要向设备控制寄存器写入需要执行的命令,并会从设备状态寄存器读取设备的内部状态。CPU也会从输入寄存器获取(Featch or pull)字节数据,或向输出寄存器以推送的方式写入(Push)字节数据。

IO端口还可以直接映射到物理地址空间. 因此, CPUIO设备之间的通信就可以使用对内存直接操作的汇编指令(mov,and,or). 现代的硬件设备倾向于选择映射IO, 因为速度更快, 并且可以结合DMA.

系统设计者的目的是对IO编程提供统一的方法, 但又不牺牲性能. 为此每个设备的IO端口都被组织使用专用寄存器. CPU将指令写到device control register, 从status register读取设备状态. CPU通过input register取得数据, 通过output register写入数据.。

示意图如下:

1)控制寄存器

2)状态寄存器

3)输入寄存器

4)输出寄存器

一个外部设备要想接入系统,就用自己的接口和总线上的某个匹配接口对接。通常会有多个外设,每个外设的接口电路中,又会有多个端口,每个端口都需要一个地址,为他们标识一个具体的地址值,是系统必须解决的事,与此同时,还有个内存条他们的每一个地址也都需要分配一个标识值。

I/O地址

CPU如何访问它们的内容,解决方法主要有三种:

I/O独立编址:给所有设备控制器中的每一个寄存器分配一个唯一的I/O端口编号,也称为I/O端口地址,然后用专门的输入输出指令来对这些端口进行操作。(IN,OUT指令)

内存映像编址:把所有设备控制器中的每一个寄存器都映射为一个内存地址,专门用于输入输出操作。端口地址空间和内存地址空间是统一编址的,总共只有一个地址空间,端口地址空间是内存地址空间的一部分,一般位于内存地址的高端,而地址低端为普通的内存地址。

混合编址:把两种编址方法结合起来。对于设备控制器当中的寄存器来说,采用的是I/O独立编址的方法,每一个寄存器都有一个独立的I/O端口地址。但是对于设备的数据缓冲区来说,采用的是内存映像编址的方法,把它们的地址统一到内存地址空间中。

设备驱动模型

Linux 2.6提供了一些数据结构和辅助函数, 它们为系统中所有的总线,设备以及设备驱动程序提供了一个统一的视图, 这个框架被成为设备驱动程序模型。

sysfs
             sysfs文件系统的目标是展现设备驱动程序模型组件间的层次关系. 该文件系的高层目录:
             - block: 块设备, 独立于所连接的总线.
             - devices: 所有被内核识别的硬件设备, 依照连接它们的总线对其进行组织.
             - bus: 系统中用于连接设备的总线.
             - drivers: 内核中注册的设备驱动程序.
             - class: 系统中设备的类型.
             - power: 处理一些硬件设备电源状态的文件.
             - firmware: 处理一些硬件设备的固件的文件.

kobject, kset
             是设备驱动程序的核心数据结构, 每个kobject对应于sysfs文件系统中的一个目录。kobject被嵌入一个叫做"容器"的更大对象中, 容器描述设备驱动程序模型中的组件, 容器的典型例子有总线, 设备以及驱动程序的描述符。相关的kobject包含在同类型的容器中, 通过kset数据结构表示. kset集合组成subsystem, 一个subsystem可以包含不同类型的kset。

内存

Linux最常见的可执行文件格式为elf(Executable and Linkable Format)。在elf格式的可执行代码中,ld总是从0x800 0000开始安排程序的“代码段”,对每个程序都是这样。至于程序执行时在物理内存中的实际地址,则由内核为其建立内存映射时临时分配,具体地址取决于当时所分配的物理内存页面。 

# include <stdio.h>
        greeting ( )
        {
                printf(“Hello,world!\n”);
        }
        main()

有一个简单的C程序Hello.c 

之所以把这样简单的程序写成两个函数,是为了说明指令的转移过程。我们用gcc和ld对其进行编译和连接,得到可执行代码hello。然后,用Linux的实用程序obj对其进行反汇编:$obj –d hello

           其中,像08048568这样的地址,就是我们常说的虚地址(这个地址实实在在的存在,只不过因为物理地址的存在,显得它是的罢了)。

虚拟内存、内核空间和用户空间

Linux虚拟内存的大小为2^32(在32位的x86机器上),内核将这4G字节的空间分为两部分。最高的1G字节(从虚地址0xC0000000到0xFFFFFFFF)供内核使用,称为“内核空间”。 而较低的3G字节(从虚地址0x00000000到0xBFFFFFFF),供各个进程使用,称为“用户空间”。 因为每个进程可以通过系统调用进入内核,因此,Linux内核空间由系统内的所有进程共享于是,从具体进程的角度来看,每个进程可以拥有4G字节的虚拟地址空间(也叫虚拟内存)

每个进程有各自的私有用户空间(03G),这个空间对系统中的其他进程是不可见的。最高的1GB内核空间则为所有进程以及内核所共享。另外,进程的“用户空间”也叫“地址空间”。 用户空间不是进程共享的,而是进程隔离的。每个进程最大都可以有3GB的用户空间。一个进程对其中一个地址的访问,与其它进程对于同一地址的访问绝不冲突。比如,一个进程从其用户空间的地址0x1234ABCD处可以读出整数8,而另外一个进程从其用户空间的地址0x1234ABCD处可以读出整数20,这取决于进程自身的逻辑。

任意一个时刻,在一个CPU上只有一个进程在运行。所以对于此CPU来讲,在这一时刻,整个系统只存在一个4GB的虚拟地址空间,这个虚拟地址空间是面向此进程的。当进程发生切换的时候,虚拟地址空间也随着切换。由此可以看出,每个进程都有自己的虚拟地址空间,只有此进程运行的时候,其虚拟地址空间才被运行它的CPU所知。在其它时刻,其虚拟地址空间对于CPU来说,是不可知的。所以尽管每个进程都可以有4 GB的虚拟地址空间,但在CPU眼中,只有一个虚拟地址空间存在。虚拟地址空间的变化,随着进程切换而变化。

从上面我们知道,一个程序编译连接后形成的地址空间是一个虚拟地址空间但是程序最终还是要运行在物理内存中。因此,应用程序所给出的任何虚地址最终必须被转化为物理地址,所以,虚拟地址空间必须被映射到物理内存空间中,这个映射关系需要通过硬件体系结构所规定的数据结构来建立。这就是我们所说的段描述符表和页表,Linux主要通过页表来进行映射。于是,我们得出一个结论,如果给出的页表不同,那么CPU将某一虚拟地址空间中的地址转化成的物理地址就会不同。所以我们为每一个进程都建立其页表,将每个进程的虚拟地址空间根据自己的需要映射到物理地址空间上。既然某一时刻在某一CPU上只能有一个进程在运行,那么当进程发生切换的时候,将页表也更换为相应进程的页表,这就可以实现每个进程都有自己的虚拟地址空间而互不影响。所以,在任意时刻,对于一个CPU来说,只需要有当前进程的页表,就可以实现其虚拟地址到物理地址的转化。

内核空间到物理内存的映射

在驱动中我们提的比较多的就是内核空间与硬件内存地址,那么我们下面来详细介绍下内核空间和实际的硬件物理地址。

内核空间对所有的进程都是共享的,其中存放的是内核代码和数据而进程的用户空间中存放的是用户程序的代码和数据,不管是内核程序还是用户程序,它们被编译和连接以后,所形成的指令和符号地址都是虚地址,而不是物理内存中的物理地址。虽然内核空间占据了每个虚拟空间中的最高1GB字节,但映射到物理内存却总是从最低地址(0x00000000)开始的,之所以这么规定,是为了在内核空间与物理内存之间建立简单的线性映射关系。其中,3GB(0xC0000000)就是物理地址与虚拟地址之间的位移量,在Linux代码中就叫做PAGE_OFFSET。

对于内核空间而言,给定一个虚地址x,其物理地址为“x- PAGE_OFFSET”,给定一个物理地址x,其虚地址为“x+ PAGE_OFFSET”。

I/O控制和访问

I/O控制方式

当我们要给一个硬件设备编写驱动程序,如何让它正常地运转起来,这就需要用到I/O控制方式。I/O控制就是要与I/O设备的设备控制器的各种寄存器进行通信

当前的I/O控制方式主要有三种:程序循环检测方式(Programmed I/O)、中断驱动方式(Inerrupt-driven I/O)和直接内存访问方式(Direct Memory Access)。

程序循环检测方式

程序循环检测方式:在进行I/O操作之前要不断地检测该设备的控制器中的状态寄存器,看它是否空闲。如果空闲,就向控制器发出一条命令,启动这次I/O操作。然后,在这个操作的进行过程中,也要循环地检测设备的当前状态,看它是否已经完成。最后,在I/O操作完成之后,如果这是一次输入操作,那么就要把读进来的数据保存到内存中的某个位置。总之,完成I/O的整个过程中,控制I/O设备的所有工作都是由CPU来完成的。

缺点:在进行一个I/O操作时,要一直占用着CPU,这样就会浪费CPU的时间。

中断驱动方式

循环检测的控制方式会占用大量的CPU时间,为了让CPU在等待I/O完成的时候去执行其它的进程。我们采用中断技术,这种方法被称为是中断驱动的控制方式。中断控制器负责管理系统中的所有的I/O中断,只有它才能向CPU发出中断请求。对于I/O设备,当它需要发送中断时,不是直接发给CPU,而是先发给中断控制器,并由它来决定是否要转发给CPU

过程:

  1. 当一个I/O设备完成了CPU交给它的任务的时候,它的控制器就会向中断控制器发出一个信号,中断控制器会判断一下,看看当前是否有一个中断正在处理,或者是否有一个优先级更高的中断。如果没有,就开始处理这个中断。
  2. 一方面,中断控制器把一个编号放在地址总线上,这个编号指明是哪一个设备发出的中断请求。另一方面,它会向CPU发出一个中断信号。
  3. 然后,CPU就会中断当前的工作,并且用这个编号作为索引去访问一个中断向量表。在这个向量表里存放的是中断处理程序的起始地址,这样就能跳转到该程序执行了,中断程序是对刚才的I/O操作的继续执行。
  4. 当中断处理程序运行不久后,就向中断控制器发出一个确认信号,表示这个中断已经被处理。这时,中断控制器就可以发出新的中断请求了。

直接内存访问方式

1、在中断驱动方式中,每一次的数据读写都是通过CPU来完成的,而且每次处理的数据量很少,因此中断的次数就很多,中断需要额外的系统开销,因此会浪费CPU的时间

2、I/O操作一般分为两个阶段。第一个阶段是CPU或内存与设备控制器之间的通信。第二个阶段是设备控制器与I/O设备之间的通信。

如果是一次写操作,那么在I/O操作启动时,CPU需要把数据从内存中写入到设备控制器内部的缓冲区,然后设备控制器自己去和I/O设备打交道,把这些数据写到设备上

如果是一次读操作,CPU先给控制器发信号,让它去启动I/O操作,把数据读入到控制器内部的缓冲区中,然后CPU再把这些数据读入到内存中

每次只能传送一个字节或一个字,因此如果交换的数据量比较大,就会浪费大量的CPU时间。解决方法是:采用直接内存访问(Direct Memory Access,DMA)的控制方式,采用硬件的方式来实现I/O数据的移动。

不使用DMA方式,而是使用中断驱动的控制方式,来实现从磁盘上读取一个数据块:

1、CPU向磁盘控制器发出命令,读取一个数据块。(由CPU执行)

2、磁盘控制器从磁盘驱动器中一位接着一位地读取这个数据块,一直到整个数据块都保存在控制器内部的缓冲区中。(硬件完成)

3、磁盘控制器通过校验位来验证这个数据块是否传送正确,如果正确,就向CPU发出一个中断。(硬件完成)

4、当操作系统执行一个循环,从控制器的数据寄存器中,读取一个字节保存在内存中。(由CPU执行)

如果使用DMA控制器硬件来完成第四步,则CPU会省出更多的时间去执行其它的进程

下面介绍使用DMA控制器以后,从磁盘上读取一个数据块的过程:

1、CPUDMA控制器进行编程,对它的各个寄存器的值进行设置,告诉它应该把数据传送到内存的什么地方。然后DMA向磁盘控制器发出命令,让它从磁盘中读取数据保存在自己的数据缓冲区中

2、DMA控制器通过总线向磁盘控制器发出一个读操作的请求信号,并且把将要写入的内存地址打在总线上

3、磁盘控制器从内部缓冲区中取出一个字节,按照DMA控制器给的地址写入到内存中

4、当这个写操作完成之后,磁盘控制器让DMA发出一个确认信号。然后DMA控制器就会把内存地址加1,把需要传送的字节数减1,就转到第二步继续执行。

5、当所有的数据传送完毕之后,DMA控制器就向CPU发出一个中断,告诉它数据传输已经完成。这样当中断处理程序开始运行时,它就知道,从磁盘中读出来的数据块已经在DMA的控制下,被传送到了内存中。也就是说,当中断发生时,这个I/O操作也完成了。

IO工作方式

数据传输方式

I/O设备和内存之间的数据传输方式:

PIO

拿磁盘来说,很早以前,磁盘和内存之间的数据传输是需要CPU控制的,也就是说如果读取磁盘文件到内存中,数据要经过CPU存储转发的,这种方式称为PIO。显然这种方式非常不合适,需要占用大量的CPU时间来读取文件,造成文件访问时系统几乎停止响应。

DMA

DMA(直接内存访问)取代了PIO,它可以不经过CPU而直接进行磁盘和内存的数据交换。在DMA模式下,CPU只需要向DMA控制器下达指令,让DMA控制器处理数据的传送即可,DMA控制器通过系统总线来传输数据,传送完毕之后再通知CPU,这样就在很大程度上降低了CPU的占用率,大大节省了系统资源,而它的传输速度与PIO的差异其实并不十分明显。

标准文件访问方式

当应用程序调用read接口时,操作系统检查在内核的高速缓存有没有需要的数据,如果已经缓存了,那么直接从缓存中返回,如果没有则从磁盘中读取,然后缓存在操作系统的缓存中。

应用层调用writer接口时,将数据从用户地址空间复制到内核地址空间缓存中,这时对用户程序来说,写曹组已经完成了,至于什么时候再写到磁盘中,由操作系统决定,除非显示调用了sync同步命令

操作步骤:

  1. 用户进程使用read()系统调用,要求其用户空间的缓冲区被填满。
  2. 内核向磁盘控制器硬件发命令,要求从磁盘读入数据。
  3. 磁盘控制器以DMA方式(数据不经过CPU)把数据复制到内核缓冲区。
  4. 内核将数据从内核缓冲区复制到用户进程发起read()调用时指定的用户缓冲区。

 

从上图可以看出:磁盘中的数据是先读取到内核的缓冲区中。然后再从内核的缓冲区复制到用户的缓冲区。为什么会这样呢?

因为用户空间的进程是不能直接硬件的(操作磁盘控制器)。磁盘是基于块存储的硬件设备,它一次操作固定大小的块,而用户请求请求的可能是任意大小的数据块。因此,将数据从磁盘传递到用户空间,由内核负责数据的分解、再组合。

内存映射I/O

cpu与设备控制器和数据缓冲区的通信有两种方式:

1、给每个设备控制器分配一个端口号,所有的I/O端口构成一个I/O端口空间,并且受到保护,普通的用户程序无法访问这些端口,只有操作系统能够访问。

2、将所有的设备控制器映射到内存中,这样的话每个设备控制器都被分配一个地址,而不会再有内存被分配这个地址,这样的系统被称为内存映射I/O 。内存映射是指将硬盘上文件的位置与进程逻辑地址空间中一块大小相同的区域一一对应,当要访问内存中一段数据时,转换为访问文件的某一段数据。这种方式的目的就是减少数据从内核空间缓存到用户空间缓存的数据复制操作,因为这两个空间的数据是共享的。当大量数据需要传输的时候,采用内存映射方式去访问文件会获得比较好的效率。

使用内存映射文件处理存储于磁盘上的文件时,将不用再对文件执行I/O操作,这意味着在对文件进行处理时将不必再为文件申请并分配缓存,所有的文件缓存操作均由系统直接管理,由于取消了将文件数据加载到内存、数据从内存到文件的回写以及释放内存块等步骤,使得内存映射文件在处理大数据量的文件时能起到相当重要的作用。

 内存映射IO:就是复用一个以上的虚拟地址可以指向同一个物理内存地址。将内核空间的缓冲区地址(内核地址空间)映射到物理内存地址区域,将用户空间的缓冲区地址(用户地址空间)也映射到相同的物理内存地址区域。从而数据不需要从内核缓冲区映射的物理内存地址移动到用户缓冲区映射的物理内存地址了。

访问步骤

要求:

1、用户缓冲区与内核缓冲区必须使用相同的页大小对齐。

2、缓冲区的大小必须是磁盘控制器块大小(512字节磁盘扇区)的倍数---因为磁盘是基于块存储的硬件设备,一次只能操作固定大小的数据块。

用户缓冲区按页对齐,会提高IO的效率---这也是为什么在JAVA中new 一个字节数组时,指定的大小为2的倍数(4096)的原因吧。

在大多数情况下,使用内存映射可以提高磁盘I/O的性能,它无须使用read()或write()等系统调用来访问文件,而是通过mmap()系统调用来建立内存和磁盘文件的关联,然后像访问内存一样自由地访问文件。

有两种类型的内存映射,共享型和私有型,前者可以将任何对内存的写操作都同步到磁盘文件,而且所有映射同一个文件的进程都共享任意一个进程对映射内存的修改;后者映射的文件只能是只读文件,所以不可以将对内存的写同步到文件,而且多个进程不共享修改。显然,共享型内存映射的效率偏低,因为如果一个文件被很多进程映射,那么每次的修改同步将花费一定的开销。

直接I/O

在Linux 2.6中,内存映射和直接访问文件没有本质上差异,因为数据从进程用户态内存空间到磁盘都要经过两次复制,即在磁盘与内核缓冲区之间以及在内核缓冲区与用户态内存空间。

引入内核缓冲区的目的在于提高磁盘文件的访问性能,因为当进程需要读取磁盘文件时,如果文件内容已经在内核缓冲区中,那么就不需要再次访问磁盘;而当进程需要向文件中写入数据时,实际上只是写到了内核缓冲区便告诉进程已经写成功,而真正写入磁盘是通过一定的策略进行延迟的。

然而,对于一些较复杂的应用,比如数据库服务器,它们为了充分提高性能,希望绕过内核缓冲区,由自己在用户态空间实现并管理I/O缓存区,包括缓存机制和写延迟机制等,以支持独特的查询机制,比如数据库可以根据更加合理的策略来提高查询缓存命中率。另一方面,绕过内核缓冲区也可以减少系统内存的开销,因为内核缓冲区本身就在使用系统内存。

应用程序直接访问磁盘数据,不经过操作系统内核数据缓冲区,这样做的目的是减少一次从内核缓存区到用户程度缓存的数据复制。这种方式通常是在对数据的缓存管理由应用程序实现的数据库管理系统中。

直接I/O的缺点就是如果访问的数据不在应用程序缓存中,那么每次数据都会直接从磁盘进行加载,这种直接加载会非常缓慢。通常直接I/O跟异步I/O结合使用会得到较好的性能。

访问步骤

Linux提供了对这种需求的支持,即在open()系统调用中增加参数选项O_DIRECT,用它打开的文件便可以绕过内核缓冲区的直接访问,这样便有效避免了CPU和内存多余时间开销。

sendfile/零拷贝

网络I/O和kafka用到此特性。

普通网络传输步骤如下:

1)操作系统将数据从磁盘复制到操作系统内核的页缓存中

2)应用将数据从内核缓存复制到应用的缓存中

3)应用将数据写回内核的Socket缓存中

4)操作系统将数据从Socket缓存区复制到网卡缓存,然后将其通过网络发出

 

1)当调用read系统调用时,通过DMA(Direct Memory Access)将数据copy到内核模式

2)然后由CPU控制将内核模式数据copy到用户模式下的 buffer中

3)read调用完成后,write调用首先将用户模式下 buffer中的数据copy到内核模式下的socket buffer中

4)最后通过DMA copy将内核模式下的socket buffer中的数据copy到网卡设备中传送

从上面的过程可以看出,数据白白从内核模式到用户模式走了一圈,浪费了两次copy,而这两次copy都是CPU copy,即占用CPU资源。

Sendfile

通过sendfile传送文件只需要一次系统调用,当调用 sendfile时:

1)首先通过DMA copy将数据从磁盘读取到kernel buffer中

2)然后通过CPU copy将数据从kernel buffer copy到sokcet buffer中

3)最终通过DMA copy将socket buffer中数据copy到网卡buffer中发送

sendfile与read/write方式相比,少了 一次模式切换一次CPU copy。但是从上述过程中也可以发现从kernel buffer中将数据copy到socket buffer是没必要的。

为此,Linux2.4内核对sendfile做了改进,下图所示

改进后的处理过程如下:

1、DMA copy将磁盘数据copy到kernel buffer中

2、向socket buffer中追加当前要发送的数据在kernel buffer中的位置和偏移量

3、DMA gather copy根据socket buffer中的位置和偏移量直接将kernel buffer中的数据copy到网卡上。

经过上述过程,数据只经过了2次copy就从磁盘传送出去了。(事实上这个Zero copy是针对内核来讲的,数据在内核模式下是Zero-copy的)。当前许多高性能http server都引入了sendfile机制,如nginx,lighttpd等。

FileChannel.transferTo

Java NIO中FileChannel.transferTo(long position, long count, WriteableByteChannel target)方法将当前通道中的数据传送到目标通道target中,在支持Zero-Copy的linux系统中,transferTo()的实现依赖于 sendfile()调用。

传统方式对比零拷贝方式:

整个数据通路涉及4次数据复制和2个系统调用,如果使用sendfile则可以避免多次数据复制,操作系统可以直接将数据从内核页缓存中复制到网卡缓存,这样可以大大加快整个过程的速度。

大多数时候,我们都在向Web服务器请求静态文件,比如图片、样式表等,根据前面的介绍,我们知道在处理这些请求的过程中,磁盘文件的数据先要经过内核缓冲区,然后到达用户内存空间,因为是不需要任何处理的静态数据,所以它们又被送到网卡对应的内核缓冲区,接着再被送入网卡进行发送。

数据从内核出去,绕了一圈,又回到内核,没有任何变化,看起来真是浪费时间。在Linux 2.4的内核中,尝试性地引入了一个称为khttpd的内核级Web服务器程序,它只处理静态文件的请求。引入它的目的便在于内核希望请求的处理尽量在内核完成,减少内核态的切换以及用户态数据复制的开销。

同时,Linux通过系统调用将这种机制提供给了开发者,那就是sendfile()系统调用。它可以将磁盘文件的特定部分直接传送到代表客户端的socket描述符,加快了静态文件的请求速度,同时也减少了CPU和内存的开销。

在OpenBSD和NetBSD中没有提供对sendfile的支持。通过strace的跟踪看到了Apache在处理151字节的小文件时,使用了mmap()系统调用来实现内存映射,但是在Apache处理较大文件的时候,内存映射会导致较大的内存开销,得不偿失,所以Apahce使用了sendfile64()来传送文件,sendfile64()是sendfile()的扩展实现,它在Linux 2.4之后的版本中提供。

这并不意味着sendfile在任何场景下都能发挥显著的作用。对于请求较小的静态文件,sendfile发挥的作用便显得不那么重要,通过压力测试,我们模拟100个并发用户请求151字节的静态文件,是否使用sendfile的吞吐率几乎是相同的,可见在处理小文件请求时,发送数据的环节在整个过程中所占时间的比例相比于大文件请求时要小很多,所以对于这部分的优化效果自然不十分明显。

直接存储器存储

不管是否存在内存映射I/O,cpu都要用设备控制器中按照字来读取数据,这样会特别的浪费cpu 的时间(因为cpu的速度是非常的快的),所以说使用一种称为直接存储器的设备。大多数计算机系统中都有这种设备,有的是一个设备配备一个DMA,但更多的是一台主机只配备一个DMA,由它来操控多个设备的数据传输,DMA能够访问地址总线,其中包括内存地址寄存器,字节计数寄存器,和多个控制寄存器(表示I/O端口号,传送方向)在没有DMA控制器的时候,首先设备控制器按位将字节流读进缓冲区中,然后缓冲区校验,如果没有错误,产生中断,然后cpu一个字一个字的从缓冲区中读取,然后放进内存中。如果有了DMA控制器之后,就不需要cpu了,它发起一个读的请求,然后控制总线,就能够将缓冲中的数据读进内存中。

其他

JAVA中的IO,本质上是把数据移进或者移出缓冲区。read()和write()系统调用完成的作用是:把内核缓冲区映射的物理内存空间中的数据 拷贝到 用户缓冲区映射的物理内存空间中。因此,当使用内存映射IO时,可视为:用户进程直接把文件数据当作内存,也就不需要使用read()或write()系统调用了。当发起一个read()系统调用时,根据待读取的数据的位置生成一个虚拟地址(用户进程使用的是虚拟地址),由MMU转换成物理地址,若内核中没有相应的数据,产生一个缺页请求,内核负责页面调入从而将数据从磁盘读取到内核缓冲区映射的物理内存中。对用户程序而言,这一切都是在不知不觉中进行。

总之,从根本上讲数据从磁盘装入内存是以页为单位通过分页技术装入内存的。

I/O流

并非所有的I/O是面向块的。还有流I/O,它是管道的原型,必须顺序访问I/O数据流的字节。常见的数据流有TTY(控制台)设备、打印端口和网络连接。

流是一组有顺序的,有起点和终点的字节集合,是对数据传输的总称或抽象。即数据在两设备间的传输称为流,流的本质是数据传输,根据数据传输特性将流抽象为各种类,方便更直观的进行数据操作。从流中取得数据的操作称为提取操作,而向流中添加数据的操作称为插入操作。用来进行输入输出操作的流就称为IO流。换句话说,IO流就是以流的方式进行输入输出。

Java的I/O类库的基本架构

用于包装非缓存流的缓冲流类有4个:BufferedInputStream 和 BufferedOutputStream 用于创建字节缓冲字节流,BufferedReader 和 BufferedWriter 用于创建字符缓冲字节流。

Java 的 I/O 操作类在包 java.io 下,大概有将近 80 个类,但是这些类大概可以分成四组,分别是:

           基于字节操作的I/O接口:InputStreamOutputStream
           
基于字符操作的I/O接口:WriterReader
           基于磁盘操作的I/O接口:File
           基于网络操作的I/O接口:Socket
           前两组主要是根据传输数据的数据格式,后两者主要是根据传输方式。I/O的核心问题要么是数据格式影响I/O操作,要么是传输方式影响I/O操作。也就是将什么样的数据写到什么地方的问题。也就是说数据格式和传输方式是影响效率最关键的因素了。我们后面的分析也是基于这两个因素来展开的。

字节流和字符流

字节流:一次读入或读出是8位二进制。

字符流:一次读入或读出是16位二进制。

基于字节的I/O操作接口

InputStream

OutputStream

 注意:流到底要写到什么地方必须指定,要么是写到磁盘要么是写到网络中,写网络其实也是写文件,只不过写网络还有一步需要处理就是底层操作系统再将数据传到其他地方而不是本地磁盘。

基于字符的I/O操作接口

不管是磁盘还是网络传输,最小的存储单元都是字节,而不是字符,所以I/O操作的都是字节而不是字符,之所以有操作字符的I/O接口是因为我们的程序中通常操作的都是以字符形式。从字符到字节一定会经过编码转,而编码的过程非常的耗时,而且经常会出现乱码问题。

Writer类接口 

Reader类接口

字节与字符的转换接口 

数据的持久化或网络传输都是字节进行的,所以必须要有字符到字节到字符的转化。字符到字节需要转化。

try { 
   StringBuffer str = new StringBuffer(); 
   char[] buf = new char[1024]; 
   FileReader f = new FileReader("file"); 
   while(f.read(buf)>0){ 
               str.append(buf); 
           } 

读的转换过程如下图

FileReader 类就是按照上面的工作方式读取文件的,FileReader 是继承了 InputStreamReader 类,实际上是读取文件流,然后通过 StreamDecoder 解码成 char,只不过这里的解码字符集是默认字符集。

InputStreamReader类是字节到字符的转换桥梁,inputStream到reader的过程要指定编码字符集,否则将采用操作系统默认字符集,很可能会出现乱码。StreamDecoder正式完成字节到字符的解码的实现类。

写入的转换过程如图

总结

java中io中常用的流

字节输入流:

字节输出流:

字符输入流:

字符输出流:

对文件进行操作:FileInputStream(字节输入流),FileOutputStream(字节输出流),FileReader(字符输入流),FileWriter(字符输出流)

对管道进行操作:PipedInputStream(字节输入流),PipedOutStream(字节输出流),PipedReader(字符输入流),PipedWriter(字符输出流)

PipedInputStream的一个实例要和PipedOutputStream的一个实例共同使用,共同完成管道的读取写入操作。主要用于线程操作。

字节/字符数组:ByteArrayInputStream,ByteArrayOutputStream,CharArrayReader,CharArrayWriter是在内存中开辟了一个字节或字符数组。

Buffered缓冲流:BufferedInputStream,BufferedOutputStream,BufferedReader,BufferedWriter,是带缓冲区的处理流,缓冲区的作用的主要目的是:避免每次和硬盘打交道,提高数据访问的效率。

  • 转化流:InputStreamReader/OutputStreamWriter,把字节转化成字符。
  • 数据流:DataInputStream,DataOutputStream。

因为平时若是我们输出一个8个字节的long类型或4个字节的float类型,那怎么办呢?可以一个字节一个字节输出,也可以把转换成字符串输出,但是这样转换费时间,若是直接输出该多好啊,因此这个数据流就解决了我们输出数据类型的困难。数据流可以直接输出float类型或long类型,提高了数据读写的效率。

  • 打印流:printStream,printWriter,一般是打印到控制台,可以进行控制打印的地方。
  • 对象流:ObjectInputStream,ObjectOutputStream,把封装的对象直接输出,而不是一个个在转换成字符串再输出。
  • 序列化流:SequenceInputStream。
  • 对象序列化:把对象直接转换成二进制,写入介质中。

使用对象流需要实现Serializable接口,否则会报错。而若用transient关键字修饰成员变量,不写入该成员变量,若是引用类型的成员变量为null,值类型的成员变量为0。

其他

  • 字节流读取的时候,读到一个字节就返回一个字节; 字符流使用了字节流读到一个或多个字节(中文对应的字节数是两个,在UTF-8码表中是3个字节)时。先去查指定的编码表,将查到的字符返回
  • 字节流可以处理所有类型数据,如:图片,MP3AVI视频文件,而字符流只能处理字符数据。只要是处理纯文本数据,就要优先考虑使用字符流,除此之外都用字节流
  • 一个类自另一个类派生,是指派生类是在另一个类上添加新特性而得到的。即:类A是类B的派生类,则类A具有类B的全部特性,并且还新增了一些特性。

流类之间的继承关系:fstream是istream的派生类,在函数调用中,istream类型的参数可以替换为ifstream类型的参数。如果像定义一个函数,要求它去一个输入流作为实参,并希望实参在某些情况下是cin流,在另一些情况下是输入文件流没救应该用istream类型的形参。但是ifstream作为实参时,必须声明ifstream类型。输出流ostream也类似。

派生类经常借助继承关系和家族关系来讨论。如果类B是类A的派生类,则类B是类A的子,类A是类B的父。我们可以说,派生类继承了父类的成员函数。

磁盘I/O工作机制

前面介绍的是最基本的java I/O的操作接口,这些接口主要定义了如何操作数据,以及操作的两种数据结构。接下来的另一个问题就是数据写入到何处,其中最主要的方式就是将数据持久化到物理磁盘。

         数据在磁盘的唯一最小描述就是文件,也就是说上层应用只能通过文件来操作磁盘上的数据,文件也是操作系统和磁盘驱动器交互的一个最小单元。Java中通常的File并不代表一个真实存在的文件对象,当你通过指定一个路径描述符时,它就会返回一个代表这个路径相关的一个虚拟对象,这个可能是一个真实存在的文件或是一个包含多个文件的目录。之所以是虚拟的,是因为在大部分情况下,我们并不关心这个文件是否真的存在,而是关心这个文件到底如何操作。只有在真正读取这个文件的时候才会关心文件是否存在。例如 FileInputStream 类都是操作一个文件的接口,注意到在创建一个 FileInputStream 对象时,会创建一个 FileDescriptor 对象,其实这个对象就是真正代表一个存在的文件对象的描述,当我们在操作一个文件对象时可以通过 getFD() 方法获取真正操作的与底层操作系统关联的文件描述。例如可以调用 FileDescriptor.sync() 方法将操作系统缓存中的数据强制刷新到物理磁盘中。

程序可以转换的非缓冲流为缓冲流,这里用非缓冲流对象传递给缓冲流类的构造器。

inputStream = new BufferedReader(new FileReader("xanadu.txt"));
outputStream = new BufferedWriter(new FileWriter("characteroutput.txt"));

用于包装非缓存流的缓冲流类有4个:BufferedInputStream 和 BufferedOutputStream 用于创建字节缓冲字节流,BufferedReader 和 BufferedWriter 用于创建字符缓冲字节流。

从磁盘读取文件

当传入一个文件路径,就会根据这个路径创建一个File对象来标识这个文件,然后将会根据这个File对象创建真正读取文件的操作对象,这时将会真正创建一个关联真实存在的磁盘文件描述符FileDescriptor,通过这个对象可以直接控制这个磁盘文件。由于读取的是字符格式,所以需要StreamDecoder来将byte解码为char格式,从磁盘驱动器上读取一段数据的过程则由操作系统完成。

Java Socket的工作机制

Socket这个概念没有对应到一个具体的实体,它是描述计算机之间完成相互通信一种相互功能。在介绍socket之前,先看一下网络的分层结构。

基于TCP/IP协议族的网络,被分为四层,分别为应用层、传输层、网际层以及网络接口。每一层都设计了相应的协议,我们常用的协议(HTTP、FTP等),都是属于应用层协议,他们大多是基于传输层的TCP、UDP设计出来的。

         Socket是处于应用层之下,传输层之上的一个接口层,也就是操作系统提供给用户访问网络的系统接口,我们可以借助于socket接口层,对传输层、网际层以及网络接口层进行操作,来实现不同的应用层协议。例如,HTTP是基于TCP实现的,ping和tracerouter是基于ICMP实现的,libpacp(用wireshare做过网络抓包)则是直接读取了网络接口层的数据,但是他们的实现,都借助于socket完成的。可见,对于应用层,我们向实现网络功能,归根究底都是要通过socket来实现的,否则,无法访问处于操作的传输层,网际层以及网络接口层。

         传输层分为两种TCP和UDP,两者最大的区别在于,TCP是可靠的,也就是说,通过TCP发送的数据,网络协议栈会保证数据可靠的传输到对端,而UDP是不可靠的,如果出现丢包,协议栈不会做任何处理,可靠性的保证交由应用层处理。因此TCP的性能会比UDP低,但是可靠性会比UDP好很多。除此之外,两者在传输数据时,也有形式上的不同,TCP数据是流,而UDP则是基于数据包,也就是说数据会被打成包发送。TCP没有数据边界,每次接收数据以字节为单位,如果想区分两次发送的数据,除非在数据中加入分隔符,否则,TCP无法区分数据边界,而UDP每次发送的数据都被打为一个独立的数据包,因此几次发送的数据边界很清晰,每次接收也是按照数据包为单位进行接收

下图是典型的基于 Socket 的通信的场景:

主机 A 的应用程序要能和主机 B 的应用程序通信,必须通过 Socket 建立连接,而建立 Socket 连接必须需要底层 TCP/IP 协议来建立 TCP 连接。建立 TCP 连接需要底层 IP 协议来寻址网络中的主机。我们知道网络层使用的 IP 协议可以帮助我们根据 IP 地址来找到目标主机,但是一台主机上可能运行着多个应用程序,如何才能与指定的应用程序通信,就要通 TCP UPD 的地址也就是端口号来指定。这样就可以通过一个 Socket 实例唯一代表一个主机上的一个应用程序的通信链路了

建立通信链路

当客户端要与服务端通信,客户端首先要创建一个 Socket 实例,操作系统将为这个 Socket 实例分配一个没有被使用的本地端口号,并创建一个包含本地和远程地址和端口号的套接字数据结构,这个数据结构将一直保存在系统中直到这个连接关闭。在创建 Socket 实例的构造函数正确返回之前,将要进行 TCP 的三次握手协议,TCP 握手协议完成后,Socket 实例对象将创建完成,否则将抛出 IOException 错误。

与之对应的服务端将创建一个 ServerSocket 实例,ServerSocket 创建比较简单只要指定的端口号没有被占用,一般实例创建都会成功,同时操作系统也会为 ServerSocket 实例创建一个底层数据结构,这个数据结构中包含指定监听的端口号和包含监听地址的通配符,通常情况下都是“*”即监听所有地址。之后当调用 accept() 方法时,将进入阻塞状态,等待客户端的请求。当一个新的请求到来时,将为这个连接创建一个新的套接字数据结构,该套接字数据的信息包含的地址和端口信息正是请求源地址和端口。这个新创建的数据结构将会关联到 ServerSocket 实例的一个未完成的连接数据结构列表中,注意这时服务端与之对应的 Socket 实例并没有完成创建,而要等到与客户端的三次握手完成后,这个服务端的 Socket 实例才会返回,并将这个 Socket 实例对应的数据结构从未完成列表中移到已完成列表中。所以 ServerSocket 所关联的列表中每个数据结构,都代表与一个客户端的建立的 TCP 连接。

数据传输

传输数据是我们建立连接的主要目的,如何通过 Socket 传输数据,下面将详细介绍。

当连接已经建立成功,服务端和客户端都会拥有一个 Socket 实例,每个 Socket 实例都有一个 InputStream 和 OutputStream,正是通过这两个对象来交换数据。同时我们也知道网络 I/O 都是以字节流传输的。当 Socket 对象创建时,操作系统将会为 InputStream 和 OutputStream 分别分配一定大小的缓冲区,数据的写入和读取都是通过这个缓存区完成的。写入端将数据写到 OutputStream 对应的 SendQ 队列中,当队列填满时,数据将被发送到另一端 InputStream 的 RecvQ 队列中,如果这时 RecvQ 已经满了,那么 OutputStream 的 write 方法将会阻塞直到 RecvQ 队列有足够的空间容纳 SendQ 发送的数据。值得特别注意的是,这个缓存区的大小以及写入端的速度和读取端的速度非常影响这个连接的数据传输效率,由于可能会发生阻塞,所以网络 I/O 与磁盘 I/O 在数据的写入和读取还要有一个协调的过程,如果两边同时传送数据时可能会产生死锁,在后面 NIO 部分将介绍避免这种情况。

I/O使用

字节流(Byte Stream)

字节流处理原始的二进制数据I/O。输入输出的是8位字节,相关的类是InputStream和OutputStream。

字节流的类有许多。为了演示字节流的工作,重点放在文件 I/O字节流 FileInputStream和FileOutputStream上。其他种类的字节流用法类似,主要区别在于它们构造的方式,大家可以举一反三。

从 source.txt 文件复制到 target.txt,每次只复制一个字节:

public class CopyBytes {
    /**
     * @param args
     * @throws IOException
     */
    public static void main(String[] args) throws IOException {
        FileInputStream in = null;
        FileOutputStream out = null;
 
        try {
            in = new FileInputStream("resources/source.txt");
            out = new FileOutputStream("resources/target.txt");
            int c;
 
            while ((c = in.read()) != -1) {
                out.write(c);
            }
        } finally {
            if (in != null) {
                in.close();
            }
            if (out != null) {
                out.close();
            }
        }
    }

CopyBytes 花费其大部分时间在简单的循环里面,从输入流每次读取一个字节到输出流,如图所示:

缺点:

CopyBytes 虽然是一个正常的程序,但它实际上代表了一种低级别的 I/O,应该避免。因为xanadu.txt 包含字符数据时,最好的方法是使用字符流。字节流应只用于最原始的 I/O。所有其他流类型是建立在字节流之上的。

字符流

字符流处理字符数据的 I/O,自动处理与本地字符集转化。

Java 平台存储字符值使用 Unicode 约定。字符流 I/O 会自动将这个内部格式与本地字符集进行转换。在西方的语言环境中,本地字符集通常是 ASCII 的8位超集。对于大多数应用,字符流的 I/O 不会比 字节流 I/O操作复杂。输入和输出流的类与本地字符集进行自动转换。使用字符的程序来代替字节流可以自动适应本地字符集,并可以准备国际化,而这完全不需要程序员额外的工作。如果国际化不是一个优先事项,你可以简单地使用字符流类,而不必太注意字符集问题。以后,如果国际化成为当务之急,你的程序可以方便适应这种需求的扩展。

字符流类描述在 Reader 和 Writer。而对应文件 I/O ,在 FileReader 和 FileWriter,下面是一个 CopyCharacters 例子:

public class CopyCharacters {
    /**
     * @param args
     * @throws IOException 
     */
    public static void main(String[] args) throws IOException {
        FileReader inputStream = null;
        FileWriter outputStream = null;
 
        try {
            inputStream = new FileReader("resources/source.txt");
            outputStream = new FileWriter("resources/charactertarget.txt");
 
            int c;
            while ((c = inputStream.read()) != -1) {
                outputStream.write(c);
            }
        } finally {
            if (inputStream != null) {
                inputStream.close();
            }
            if (outputStream != null) {
                outputStream.close();
            }
        }
    }
}

CopyCharacters 与 CopyBytes 是非常相似的。最重要的区别在于 CopyCharacters 使用的 FileReader 和 FileWriter 用于输入输出,而 CopyBytes 使用 FileInputStream 和FileOutputStream 中的。请注意,这两个CopyBytes和CopyCharacters使用int变量来读取和写入;在 CopyCharacters,int 变量保存在其最后的16位字符值;在 CopyBytes,int 变量保存在其最后的8位字节的值。

字符流使用字节流

字符流往往是对字节流的“包装”。字符流使用字节流来执行物理I/O,同时字符流处理字符和字节之间的转换。例如,FileReader 使用 FileInputStream,而 FileWriter使用的是FileOutputStream。

面向行的I/O

字符 I/O 通常发生在较大的单位不是单个字符。一个常用的单位是行:用行结束符结尾。行结束符可以是回车/换行序列(“\r\n”),一个回车(“\r”),或一个换行符(“\n”)。支持所有可能的行结束符,程序可以读取任何广泛使用的操作系统创建的文本文件。

修改 CopyCharacters 来演示如使用面向行的 I/O。要做到这一点,我们必须使用两个类,BufferedReader 和PrintWriter 的。我们会在缓冲 I/O 和Formatting 章节更加深入地研究这些类。

该 CopyLines 示例调用 BufferedReader.readLine 和 PrintWriter.println 同时做一行的输

入和输出。

调用 readLine 按行返回文本行。CopyLines 使用 println 输出带有当前操作系统的行终止符的每一行。这可能与输入文件中不是使用相同的行终止符。

public class CopyLines {
    /**
     * @param args
     * @throws IOException 
     */
    public static void main(String[] args) throws IOException {
        BufferedReader inputStream = null;
        PrintWriter outputStream = null;
 
        try {
            inputStream = new BufferedReader(new FileReader("resources/source.txt"));
            outputStream = new PrintWriter(new FileWriter("resources/charactertarget.txt"));
 
            String l;
            while ((l = inputStream.readLine()) != null) {
                outputStream.println(l);

缓冲流

缓冲流通过减少调用本地 API 的次数来优化的输入和输出

目前为止,大多数时候我们到看到使用非缓冲 I/O 的例子。这意味着每次读或写请求是由基础 OS 直接处理。这可以使一个程序效率低得多,因为每个这样的请求通常引发磁盘访问,网络活动,或一些其它的操作,而这些是相对昂贵的。

为了减少这种开销,所以 Java 平台实现缓冲 I/O 流。缓冲输入流从被称为缓冲区(buffer)的存储器区域读出数据;仅当缓冲区是空时,本地输入 API 才被调用。同样,缓冲输出流,将数据写入到缓存区,只有当缓冲区已满才调用本机输出 API

更多的文章,可以关注我的公众号,我是一个初入行者,有兴趣的读者可以一起学习,一起进步。 

 

  • 2
    点赞
  • 10
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值