高速I/O(上)

Mr.ZhangJava IO大约 15 分钟

高速I/O(上)

普通的I/O读写流程都存在哪些性能问题?

前两节,我们介绍了IO类库和NIO类库,尽管在平时的业务开发中,我们很少会用到它们,但是,对于一些常用中间件、基础系统,比如Kakfa、RocketMQ、MySQL等,其内部实现涉及大量的文件和网络等I/O读写操作。I/O读写是否高效,直接决定了这些中间件和基础系统的性能,是优化的重中之重。I/O读写的优化方式,也是面试中经常被问及的知识点。

关于高速I/O,我们分两节来讲解。本节,我们介绍普通I/O读写的底层实现原理,让你知道I/O读写慢在哪,下一节,我们介绍提高I/O读写速度的方法,让你知道如何让它快。

一、用户态和内核态

要了解I/O读写的底层实现原理,我们需要先了解两个非常重要的概念:内核态和用户态。

对于软件的开发,分层是一种非常常见的设计思路。对于操作系统这种特殊软件来说,也不例外。我们拿Linux操作系统举例。简单来讲,Linux操作系统可以分为下图所示的这样几层。

img
img

操作系统中包含计算机运行所需要的核心程序,这部分程序用来访问硬件资源,比如调度CPU、读写磁盘、网卡、内存等,我们把这部分程序叫做操作系统内核(简称内核)。应用程序运行在操作系统之上。因为操作硬件资源非常容易出错,并且一旦出错,错误将非常严重,大部分情况下都会导致计算机宕机,所以,操作系统不允许应用程序直接访问硬件资源(比如读写磁盘)。如果应用程序需要访问硬件资源,那么只能通过操作系统提供的API来实现。我们把操作系统提供的这些API称为系统调用。

系统调用比较底层,使用起来不够方便,于是,Linux操作系统在此之上又提供了库函数,比如Glibc库、Posix库,对系统调用进行封装,提供更加简单易用的函数,供应用程序开发使用,比如Glibc中的malloc()函数底层封装了sbrk()系统调用,fread()、fwrite()函数底层封装了read()、write()系统调用。在开发应用程序时,我们既可以使用库函数,也可以直接使用系统调用。比如对于内存分配,我们一般使用malloc()库函数,对于文件读写,我们一般直接使用read()、write()系统调用。

除此之外,Linux操作系统还提供了Shell这一特别的程序,也就是我们平时所说的命令行。Shell让我们能够在不进行编程的情况下,通过在命令行中运行Shell命令或脚本,达到访问硬件的目的,比如使用cp拷贝文件,使用rm删除文件等。

为了避免应用程序在运行时,访问到内核所用的内存空间,操作系统将虚拟内存空间分为内核空间和用户空间两部分。而我们经常提到的内核态和用户态,实际上指的是CPU所处的状态。当CPU执行内核程序时,CPU进入内核态。在内核态下,CPU拥有最高权限,可以执行所有的机器指令,当然,也可以访问硬件设备。当CPU执行应用程序时,CPU进入用户态,在用户态下,CPU权限被限制,只能执行部分机器指令,因此,无法访问硬件设备。除此之外,CPU在内核态下,可以访问所有的虚拟内存空间,包括用户空间和内核空间,在用户态下,只能访问用户空间,不能访问内核空间。

二、系统调用与上下文切换

当应用程序调用操作系统的系统调用时,CPU从用户态切换到内核态,当系统调用执行完成之后,CPU又从内核态切换到用户态。我们把这种状态的切换叫做上下文切换。实际上,上下文切换是一个比较宽泛的概念,在很多场景中的都会用到,比如线程切换也会引起上下文切换。对于线程引起的上下文切换,我们在多线程模块讲解。本节我们聚焦在内核态与用户态切换引起的上下文切换上。

对于普通函数调用来说,应用程序只需要将局部变量、参数、返回地址等信息存入函数调用栈,并同步保存和修改一些寄存器即可,比如SP、BP寄存器,函数调用产生的额外耗时相对较少。尽管系统调用从本质上也是一种函数调用,但相比于应用程序内的普通函数调用来说,系统调用要慢很多,耗时的地方主要在于内核态与用户态的上下文切换 。详细来讲,主要有以下两方面。

1)寄存器保存与恢复耗时

对于系统调用来说,因为操作系统内核是不信赖应用程序的,所以,操作系统内核不会使用应用程序开辟的位于用户空间的函数调用栈。操作系统会在内核空间中分配新的函数调用栈,供内核程序执行的过程中使用。当调用系统调用时,从用户空间的函数调用栈切换到内核空间的函数调用栈,操作系统需要更新更多跟栈相关的寄存器,比如SS栈基址寄存器。除此之外,因为应用程序的代码和内核程序的代码所存储的位置也不同,所以,当从应用程序代码切换到内核代码时,CS代码段基址寄存器也需要更新。并且,在更新之前,操作系统需要将这些寄存器原来的值保存下来,以便重新切换回用户态之后恢复执行。

2)缓存失效带来的性能损耗

除此之外,我们知道,根据局部性原理,CPU有L1、L2、L3三级Cache,用于缓存将要执行的代码以及所需的内存数据。应用程序的代码以及所需的数据存储在用户空间,内核代码以及所需的数据存储在内核空间,显然不是相邻的。因此,用户态到内核态的转化会导致CPU缓存失效。

三、I/O读写的底层实现原理

了解了用户态、内核态、系统调用、上下文切换这些基础概念之后,我们来再来看下I/O读写的底层实现原理。

尽管Java I/O类库(java.io和java.nio)使用起来比较简单,但其底层实现原理却比较复杂。Java作为一个跨平台的语言,为了屏蔽操作系统的差异,提供了统一的Java I/O类库。在不同操作系统下,Java I/O类库中的函数底层调用不同的系统调用和库函数来实现。

在Linux操作系统下,Java I/O类库中的read()、write()函数,底层通过调用Linux操作系统的read()、write()系统调用来实现。因此,使用Java中的read()、write()函数进行I/O读写,势必会涉及用户态和内核态的切换。I/O读写流程如下图所示。

img
img

实际上,上图只包含I/O读写的过程,还缺少一些必要的环节。

在进行I/O读写之前,我们需要先建立与I/O设备的连接。在Linux操作系统下,一切皆文件,I/O设备也不例外。Linux操作系统会为每个与I/O设备建立的连接,分配一个文件描述符(file descriptor)。对应到代码层面,当我们调用open()系统调用建立连接时,open()系统调用会将连接对应的文件描述符作为返回值返回,后续对文件描述符的读写就等价于对I/O设备的读写。

操作系统为每个文件描述符都分配一个读缓冲区和一个写缓冲区,分别对应到图中的内核读缓冲区和内核写缓冲区。内核读写缓冲区只有在第一次被使用(调用read()或write()系统调用)时,才会真正被分配内存空间。默认读缓冲区的大小一般为8192字节,写缓冲区的大小一般为16384字节,当然,我们也可以根据业务需求,通过系统调用,来重新设置内核读写缓冲区的大小。

因为文件描述会附带一些信息存储在内存中,并且每个文件描述符都分配有内核缓冲区,会占用一定的存储空间,所以,在读写完成之后,应用程序需要调用close()系统调用,释放文件描述符及其内核缓冲区。

我们拿文件读写举例,示例代码如下所示。代码中的buffer就是上图中的应用程序缓冲区。

// Linux下读写文件的C语言代码实现
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
int main (int argc, char *argv[]) {
    int rfd;
    int wfd;
    int nbytes;
    char rfile[] = "/users/root/in.txt";
    char wfile[] = "/users/root/out.txt";
    char buffer[256]; //应用程序缓冲区
    rfd = open(rfile, O_RDONLY, 0666);
    wfd = open(wfile, O_CREAT | O_WRONLY, 0666);
    if(rfd < 0 || wfd < 0){
        printf("open file failed!\n");
        return -1;
    }
    while((nbytes = read(rfd, buf, 255)) > 0){
        write(wfd, buffer, nbytes);
    }
    close(rfd);
    close(wfd);
    return 0;
}

接下来,我们重点剖析一下I/O读写流程。

1)读操作流程

当应用程序调用Java I/O类库中的read()函数时,read()函数会调用操作系统的read()系统调用,操作系统会先检查内核读缓冲区中有没有足够的数据,如果有足够数据,那么就直接将数据从内核读缓冲区拷贝到应用程序缓冲区。否则,操作系统将从磁盘中读取数据,拷贝到内核读缓冲区中,然后再拷贝到应用程序缓冲区,之后read()函数便返回。

2)写操作流程

当应用程序调用Java I/O类库中的write()函数时,write()函数会调用操作系统的write()系统调用,操作系统会先将应用程序缓冲区中的数据,拷贝到内核写缓冲区,这个时候,对于应用程序来说,写操作就完成了,write()函数便返回。

操作系统会根据一定的规则,比如写缓冲满了或到达了一定时间间隔,在某个时刻,将写缓冲区中的数据,一并写入I/O设备。如果我们希望write()返回之后,数据立刻存储到磁盘,那么需要显式地调用强制落盘函数,比如使用Linux操作系统下的sync()系统调用。当然,如果我们调用了close()系统调用,就不需要再调用sync()系统调用了,这是因为调用close()系统调用会释放内核缓冲区,为了防止数据丢失,调用close()系统调用会默认自动调动sync()系统调用,将内核缓冲区中的数据写入I/O设备。

在上述读写流程中,读写数据均需要进行2次数据拷贝。我们拿读取来举例,当调用read()函数读取数据时,数据从I/O设备拷贝到内核缓冲区,再从内核缓冲区拷贝到应用程序缓冲区,总共发生了2次数据拷贝。既然操作系统内核既可以访问内核空间,又可以访问用户空间,那么它为什么不直接将数据从I/O设备拷贝到应用程序缓冲区呢?如果这样做,不就能减少了一次数据拷贝吗?

这是因为,应用程序缓冲区是应用程序维护的,应用程序对其有主宰权,内核代码无法控制其大小和生命周期,出于稳妥起见,操作系统为I/O设备在内核空间申请了内核缓冲区,用于缓存从I/O设备中读取的数据,进而减少与I/O设备的交互次数。

那么问题又来了,既然已经有了内核缓冲区为I/O设备提供数据缓存功能,那么,为什么Java I/O类库还提供支持缓存功能的BufferedInputStream、BufferedOutputStream类呢?

BufferedInputStream和BufferedOutputStream的作用类似。我们拿BufferedInputStream举例讲解。BufferedInputStream相当于在用户空间又增加一层缓存。当应用程序调用read()函数读取数据时,会先从位于用户空间的缓存中读取,如果缓存中无数据可读,这时才会调用Linux操作系统的read()系统调用,因此,增加了一层用户空间的缓存,可以减少系统调用的次数。我们知道,系统调用会导致上下文切换,上下文切换是比较耗时的,所以,减少系统调用也会提高read()函数的性能。

四、CPU减负神器之DMA技术

从I/O读写的底层实现原理,我们可以发现,在I/O读写过程中,CPU一直参与其中,负责I/O设备与内核缓冲区之间,以及内核缓冲区与应用程序缓冲区之间的数据拷贝。而CPU最擅长的是运算,比如加法运算、位运算,让CPU去做拷贝数据这种简单工作(将二进制位0或1从一个存储单元移动到另一个存储单元),实际上是大材小用。除此之外,相对于CPU来说,像硬盘、网卡等I/O设备的读写速度非常慢,在I/O读写的过程中,CPU会一直被占用,无法去处理其他事情,无疑是非常浪费CPU资源的。

于是,科学家们便发明了DMA(Direct Memory Access)技术。通过在主板上安装一个叫做DMAC(DMA Controller,DMA控制器)的协处理器(或叫芯片),协助CPU来完成I/O设备的数据读写工作。随着计算机的发展,安装在计算机上的I/O设备越来越多,仅在主板上安装一个通用的DMAC已经远远不够了,因此,现在很多I/O设备都自带DMAC,比如硬盘、网卡、显示器都有各自的DMAC。

具体来讲,DMA是怎样工作的呢?

当调用read()函数从I/O设备读取数据时,通过系统调用,CPU会进入内核态,发送I/O请求到DMAC,告知DMAC从I/O设备中读取哪些数据到哪块内存,之后CPU便去做其他事情。由DMAC来完成将数据从I/O设备拷贝到内核缓冲区的工作。当DMAC完成之后,通过中断,通知CPU内核缓冲区中的数据已经准备就绪,然后CPU再将内核读缓冲区中的数据拷贝到应用程序缓冲区。

当调用write()函数将数据写入I/O设备时,通过系统调用,CPU会进入内核态,将数据从应用程序缓冲区拷贝到内核缓冲区,然后发送I/O请求给DMAC,告知将哪块内存中的数据拷贝到I/O设备中,之后CPU便去做其他事情了。由DMAC来完成将数据从从内核写缓冲区中拷贝到I/O设备中的工作。

如下图所示,通过DMA技术,不管是读取I/O数据还是写入I/O数据,CPU只需要参与一次数据拷贝,I/O操作不再占用大量的CPU资源,CPU利用率提高。

img
img

不过,你可能会说,DMA技术只不过是对CPU“减负”而已,由CPU拷贝换成了DMA拷贝,貌似并不能提高I/O读写速度呀?实际上,我们前面提到,数据拷贝是非常简单的,利用通用的CPU来进行数据拷贝,反倒没有使用"专项专用"的DMA高效,毕竟针对数据拷贝,DMA可以做大量的优化,让性能达到极致。

DMA技术可以提高I/O读写速度,目前大部分的计算机都已经支持。调用read()、write()函数进行普通的I/O读写,底层已经在使用DMA技术了,因此,对于我们应用程序开发者来说,DMA技术是无感的。这也是为什么我把DMA放到这一节而不是下一节讲解的原因。

五、课后思考题

既然DMA技术能够给CPU“减负”,那么为什么不让它也负责内核缓冲区和应用程序缓冲区之间的数据拷贝呢?这样减负效果不是更好吗?

Loading...