从 Linux 内核角度探秘 JDK NIO 文件读写本质

1年前 (2023) 程序员胖胖胖虎阿
113 0 0

1. 前言

笔者在 《从 Linux 内核角度看 IO 模型的演变》一文中曾对 Socket 文件在内核中的相关数据结构为大家做了详尽的阐述。

从 Linux 内核角度探秘 JDK NIO 文件读写本质

又在此基础之上介绍了针对 socket 文件的相关操作及其对应在内核中的处理流程:

从 Linux 内核角度探秘 JDK NIO 文件读写本质

并与 epoll 的工作机制进行了串联:

从 Linux 内核角度探秘 JDK NIO 文件读写本质

通过这些内容的串联介绍,我想大家现在一定对 socket 文件非常熟悉了,在我们利用 socket 文件接口在与内核进行网络数据读取,发送的相关交互的时候,不可避免的涉及到一个新的问题,就是我们如何在用户空间设计一个字节缓冲区来高效便捷的存储管理这些需要和 socket 文件进行交互的网络数据。

于是笔者又在 《一步一图带你深入剖析 JDK NIO ByteBuffer 在不同字节序下的设计与实现》 一文中带大家从 JDK NIO Buffer 的顶层设计开始,详细介绍了 NIO Buffer 中的顶层抽象设计以及行为定义,随后我们选取了在网络应用程序中比较常用的 ByteBuffer 来详细介绍了这个Buffer具体类型的实现,并以 HeapByteBuffer 为例说明了JDK NIO 在不同字节序下的 ByteBuffer 实现。

从 Linux 内核角度探秘 JDK NIO 文件读写本质

现在我们已经熟悉了 socket 文件的相关操作及其在内核中的实现,但笔者觉得这还不够,还是有必要在为大家介绍一下 JDK NIO 如何利用 ByteBuffer 对普通文件进行读写的相关原理及其实现,为大家彻底打通 Linux 文件操作相关知识的系统脉络,于是就有了本文的内容。

下面就让我们从一个普通的 IO 读写操作开始聊起吧~~~

从 Linux 内核角度探秘 JDK NIO 文件读写本质

2. JDK NIO 读取普通文件

我们先来看一个利用 NIO FileChannel 来读写普通文件的例子,由这个简单的例子开始,慢慢地来一步一步深入本质。

JDK NIO 中的 FileChannel 比较特殊,它只能是阻塞的,不能设置非阻塞模式。FileChannel的读写方法均是线程安全的。

注意:下面的例子并不是最佳实践,之所以这里引入 HeapByteBuffer 是为了将上篇文章的内容和本文衔接起来。事实上,对于 IO 的操作一般都会选择 DirectByteBuffer ,关于 DirectByteBuffer 的相关内容笔者会在后面的文章中详细为大家介绍。

        FileChannel fileChannel = new RandomAccessFile(new File("file-read-write.txt"), "rw").getChannel();
        ByteBuffer heapByteBuffer = ByteBuffer.allocate(4096);
        fileChannel.read(heapByteBuffer);

我们首先利用 RandomAccessFile 在内核中打开指定的文件 file-read-write.txt 并获取到它的文件描述符 fd = 5000。

从 Linux 内核角度探秘 JDK NIO 文件读写本质

随后我们在 JVM 堆中开辟一块 4k 大小的虚拟内存 heapByteBuffer,用来读取文件中的数据。

从 Linux 内核角度探秘 JDK NIO 文件读写本质

操作系统在管理内存的时候是将内存分为一页一页来管理的,每页大小为 4k ,我们在操作内存的时候一定要记得进行页对齐,也就是偏移位置以及读取的内存大小需要按照 4k 进行对齐。具体为什么?文章后边会从内核角度详细为大家介绍。

最后通过 FileChannel#read 方法触发底层系统调用 read。进行文件读取。

public class FileChannelImpl extends FileChannel {
  // 前边介绍打开的文件描述符 5000
  private final FileDescriptor fd;
  // NIO 中用它来触发 native read 和 write 的系统调用
  private final FileDispatcher nd;
  // 读写文件时加锁,前边介绍 FileChannel 的读写方法均是线程安全的
  private final Object positionLock = new Object();

  public int read(ByteBuffer dst) throws IOException {

        synchronized (positionLock) {
            .......... 省略 .......
            try {
                .......... 省略 .......
                do {
                    n = IOUtil.read(fd, dst, -1, nd);
                } while ((n == IOStatus.INTERRUPTED) && isOpen());
                return IOStatus.normalize(n);
            } finally {
                 .......... 省略 .......
            }
        }
    }
}

我们看到在 FileChannel 中会调用 IOUtil 的 read 方法,NIO 中的所有 IO 操作全部封装在 IOUtil 类中。

而 NIO 中的 SocketChannel 以及这里介绍的 FileChannel 底层依赖的系统调用可能不同,这里会通过 NativeDispatcher 对具体 Channel 操作实现分发,调用具体的系统调用。对于 FileChannel 来说 NativeDispatcher 的实现类为 FileDispatcher。对于 SocketChannel 来说 NativeDispatcher 的实现类为 SocketDispatcher。

下面我们进入 IOUtil 里面来一探究竟~~

public class IOUtil {

   static int read(FileDescriptor fd, ByteBuffer dst, long position,
                    NativeDispatcher nd)
        throws IOException
    {
         .......... 省略 .......

         .... 创建一个临时的directByteBuffer....

        try {
            int n = readIntoNativeBuffer(fd, directByteBuffer, position, nd);

            .......... 省略 .......

         .... 将directByteBuffer中读取到的内容再次拷贝到heapByteBuffer中给用户返回....

            return n;
        } finally {
            .......... 省略 .......
        }
    }

   private static int readIntoNativeBuffer(FileDescriptor fd, ByteBuffer bb,
                                            long position, NativeDispatcher nd)
        throws IOException
    {
        int pos = bb.position();
        int lim = bb.limit();
        assert (pos <= lim);
        int rem = (pos <= lim ? lim - pos : 0);

        .......... 省略 .......

        if (position != -1) {
          .......... 省略 .......
        } else {
            n = nd.read(fd, ((DirectBuffer)bb).address() + pos, rem);
        }
        if (n > 0)
            bb.position(pos + n);
        return n;
    }
}

我们看到 FileChannel 的 read 方法最终会调用到 NativeDispatcher 的 read 方法。前边我们介绍了这里的 NativeDispatcher 就是 FileDispatcher 在 NIO 中的实现类为 FileDispatcherImpl,用来触发 native 方法执行底层系统调用。

class FileDispatcherImpl extends FileDispatcher {

    int read(FileDescriptor fd, long address, int len) throws IOException {
        return read0(fd, address, len);
    }

   static native int read0(FileDescriptor fd, long address, int len)
        throws IOException;
}

最终在 FileDispatcherImpl 类中触发了 native 方法 read0 的调用,我们继续到 FileDispatcherImpl.c 文件中去查看 native 方法的实现。

// FileDispatcherImpl.c 文件
JNIEXPORT jint JNICALL Java_sun_nio_ch_FileDispatcherImpl_read0(JNIEnv *env, jclass clazz,
                             jobject fdo, jlong address, jint len)
{
    jint fd = fdval(env, fdo);
    void *buf = (void *)jlong_to_ptr(address);
    // 发起 read 系统调用进入内核
    return convertReturnVal(env, read(fd, buf, len), JNI_TRUE);
}

系统调用 read(fd, buf, len) 最终是在 native 方法 read0 中被触发的。下面是系统调用 read 在内核中的定义。

SYSCALL_DEFINE3(read, unsigned int, fd, char __user *, buf, size_t, count){  

    ...... 省略 ......
}

这样一来我们就从 JDK NIO 这一层逐步来到了用户空间与内核空间的边界处 --- OS 系统调用 read 这里,马上就要进入内核了。

从 Linux 内核角度探秘 JDK NIO 文件读写本质

下面我们就来看一下当系统调用 read 发起之后,用户进程在内核态具体做了哪些事情?

3. 从内核角度探秘文件读取本质

内核将文件的 IO 操作根据是否使用内存(页高速缓存 page cache)做磁盘热点数据的缓存,将文件 IO 分为:Buffered IO 和 Direct IO 两种类型。

进程在通过系统调用 open() 打开文件的时候,可以通过将参数 flags 赋值为 O_DIRECT 来指定文件操作为 Direct IO。默认情况下为 Buffered IO。

int open(const char *pathname, int flags, mode_t mode);

而 Java 在 JDK 10 之前一直是不支持 Direct IO 的,到了 JDK 10 才开始支持 Direct IO。但是在 JDK 10 之前我们可以使用第三方的 Direct IO 框架 Jaydio 来通过 Direct IO 的方式对文件进行读写操作。

Jaydio GitHub :https://github.com/smacke/jaydio

下面笔者就带大家从内核角度深度剖析下这两种 IO 类型各自的特点:

3.1 Buffered IO

大部分文件系统默认的文件 IO 类型为 Buffered IO,当进程进行文件读取时,内核会首先检查文件对应的页高速缓存 page cache 中是否已经缓存了文件数据,如果有则直接返回,如果没有才会去磁盘中去读取文件数据,而且还会根据非常精妙的预读算法来预先读取后续若干文件数据到 page cache 中。这样等进程下一次顺序读取文件时,想要的数据已经预读进 page cache 中了,进程直接返回,不用再到磁盘中去龟速读取了,这样一来就极大地提高了 IO 性能。

比如一些著名的消息队列中间件 Kafka , RocketMq 对消息日志文件进行顺序读取的时候,访问速度接近于内存。这就是 Buffered IO 中页高速缓存 page cache 的功劳。在本文的后面,笔者会为大家详细的介绍这一部分内容。

从 Linux 内核角度探秘 JDK NIO 文件读写本质

如果我们使用在上篇文章 《一步一图带你深入剖析 JDK NIO ByteBuffer 在不同字节序下的设计与实现》 中介绍的 HeapByteBuffer 来接收 NIO 读取文件数据的时候,整个文件读取的过程分为如下几个步骤:

  1. NIO 首先会将创建一个临时的 DirectByteBuffer 用于临时接收文件数据。

具体为什么会创建一个临时的 DirectByteBuffer 来接收数据以及关于 DirectByteBuffer 的原理笔者会在后面的文章中为大家详细介绍。这里大家可以把它简单看成在 OS 堆中的一块虚拟内存地址。

  1. 随后 NIO 会在用户态调用系统调用 read 向内核发起文件读取的请求。此时发生第一次上下文切换
  2. 用户进程随即转到内核态运行,进入虚拟文件系统层,在这一层内核首先会查看读取文件对应的页高速缓存 page cache 中是否含有请求的文件数据,如果有直接返回,避免一次磁盘 IO。并根据内核预读算法从磁盘中异步预读若干文件数据到 page cache 中(文件顺序读取高性能的关键所在)。

在内核中,一个文件对应一个 page cache 结构,注意:这个 page cache 在内存中只会有一份。

  1. 如果进程请求数据不在 page cache 中,则会进入文件系统层,在这一层调用块设备驱动程序触发真正的磁盘 IO。并根据内核预读算法同步预读若干文件数据。请求的文件数据和预读的文件数据将被一起填充到 page cache 中。
  2. 在块设备驱动层完成真正的磁盘 IO。在这一层会从磁盘中读取进程请求的文件数据以及内核预读的文件数据。
  3. 磁盘控制器 DMA 将从磁盘中读取的数据拷贝到页高速缓存 page cache 中。发生第一次数据拷贝
  4. 随后 CPU 将 page cache 中的数据拷贝到 NIO 在用户空间临时创建的缓冲区 DirectByteBuffer 中,发生第二次数据拷贝
  5. 最后系统调用 read 返回。进程从内核态切换回用户态。发生第二次上下文切换
  6. NIO 将 DirectByteBuffer 中临时存放的文件数据拷贝到 JVM 堆中的 HeapBytebuffer 中。发生第三次数据拷贝

我们看到如果使用 HeapByteBuffer 进行 NIO 文件读取的整个过程中,一共发生了 两次上下文切换三次数据拷贝,如果请求的数据命中 page cache 则发生两次数据拷贝省去了一次磁盘的 DMA 拷贝。

3.2 Direct IO

在上一小节中,笔者介绍了 Buffered IO 的诸多好处,尤其是在进程对文件进行顺序读取的时候,访问性能接近于内存。

但是有些情况,我们并不需要 page cache。比如一些高性能的数据库应用程序,它们在用户空间自己实现了一套高效的高速缓存机制,以充分挖掘对数据库独特的查询访问性能。所以这些数据库应用程序并不希望内核中的 page cache起作用。否则内核会同时处理 page cache 以及预读相关操作的指令,会使得性能降低。

另外还有一种情况是,当我们在随机读取文件的时候,也不希望内核使用 page cache。因为这样违反了程序局部性原理,当我们随机读取文件的时候,内核预读进 page cache 中的数据将很久不会再次得到访问,白白浪费 page cache 空间不说,还额外增加了预读的磁盘 IO。

基于以上两点原因,我们很自然的希望内核能够提供一种机制可以绕过 page cache 直接对磁盘进行读写操作。这种机制就是本小节要为大家介绍的 Direct IO。

下面是内核采用 Direct IO 读取文件的工作流程:

从 Linux 内核角度探秘 JDK NIO 文件读写本质

Direct IO 和 Buffered IO 在进入内核虚拟文件系统层之前的流程全部都是一样的。区别就是进入到虚拟文件系统层之后,Direct IO 会绕过 page cache 直接来到文件系统层通过 direct_io 调用来到块驱动设备层,在块设备驱动层调用 __blockdev_direct_IO 对磁盘内容直接进行读写。

  • 和 Buffered IO 一样,在系统调用 read 进入内核以及 Direct IO 完成从内核返回的时候各自会发生一次上下文切换。共两次上下文切换
  • 磁盘控制器 DMA 从磁盘中读取数据后直接拷贝到用户空间缓冲区 DirectByteBuffer 中。只发生一次 DMA 拷贝
  • 随后 NIO 将 DirectByteBuffer 中临时存放的数据拷贝到 JVM 堆 HeapByteBuffer 中。发生第二次数据拷贝
  • 注意块设备驱动层的 __blockdev_direct_IO 需要等到所有的 Direct IO 传送数据完成之后才会返回,这里的传送指的是直接从磁盘拷贝到用户空间缓冲区中,当 Direct IO 模式下的 read() 或者 write() 系统调用返回之后,进程就可以安全放心地去读取用户缓冲区中的数据了。

从整个 Direct IO 的过程中我们看到,一共发生了两次上下文的切换两次的数据拷贝

4. Talk is cheap ! show you the code

下面是系统调用 read 在内核中的完整定义:

SYSCALL_DEFINE3(read, unsigned int, fd, char __user *, buf, size_t, count) { 
      // 根据文件描述符获取文件对应的 struct file结构
      struct fd f = fdget_pos(fd);
        ..... 
      // 获取当前文件的读取位置 offset
      loff_t pos = file_pos_read(f.file); 
      
      // 进入虚拟文件系统层,执行具体的文件操作
      ret = vfs_read(f.file, buf, count, &pos);
       ......
}

首先会根据文件描述符 fd 通过 fdget_pos 方法获取 struct fd 结构,进而可以获取到文件的 struct file 结构。

struct fd {
      struct file *file;
      int need_put;
};

file_pos_read 获取当前文件的读取位置 offset,并通过 vfs_read 进入虚拟文件系统层。

ssize_t __vfs_read (struct file *file, char __user *buf, size_t count,  loff_t *pos) {  

       if (file->f_op->read)    
            return file->f_op->read(file, buf, count, pos); 
       else if (file->f_op->read_iter)    
            return new_sync_read(file, buf, count, pos);  
       else    
            return -EINVAL;
}

这里我们看到内核对文件的操作全部定义在 struct file 结构中的 f_op 字段中。

struct file {
    const struct file_operations  *f_op;
}

对于 Java 程序员来说,file_operations 大家可以把它当做内核针对文件相关操作定义的一个公共接口(其实就是一个函数指针),它只是一个接口。具体的实现根据不同的文件类型有所不同。

比如我们在《聊聊Netty那些事儿之从内核角度看IO模型》一文中详细介绍过的 Socket 文件。针对 Socket 文件类型,这里的 file_operations 指向的是 socket_file_ops。

static const struct file_operations socket_file_ops = {
  .owner =  THIS_MODULE,
  .llseek =  no_llseek,
  .read_iter =  sock_read_iter,
  .write_iter =  sock_write_iter,
  .poll =    sock_poll,
  .unlocked_ioctl = sock_ioctl,
  .mmap =    sock_mmap,
  .release =  sock_close,
  .fasync =  sock_fasync,
  .sendpage =  sock_sendpage,
  .splice_write = generic_splice_sendpage,
  .splice_read =  sock_splice_read,
};

从 Linux 内核角度探秘 JDK NIO 文件读写本质

而本小节中我们讨论的是对普通文件的操作,针对普通文件的操作定义在具体的文件系统中,这里我们以 Linux 中最为常见的 ext4 文件系统为例说明:

在 ext4 文件系统中管理的文件对应的 file_operations 指向 ext4_file_operations,专门用于操作 ext4 文件系统中的文件。


const struct file_operations ext4_file_operations = {

      ......省略........

      .read_iter  = ext4_file_read_iter,
      .write_iter  = ext4_file_write_iter,

      ......省略.........
}

从 Linux 内核角度探秘 JDK NIO 文件读写本质

从图中我们可以看到 ext4 文件系统定义的相关文件操作 ext4_file_operations 并未定义 .read 函数指针。而是定义了 .read_iter 函数指针,指向 ext4_file_read_iter 函数。

ssize_t __vfs_read (struct file *file, char __user *buf, size_t count,  loff_t *pos) {  

       if (file->f_op->read)    
            return file->f_op->read(file, buf, count, pos); 
       else if (file->f_op->read_iter)    
            return new_sync_read(file, buf, count, pos);  
       else    
            return -EINVAL;
}

所以在虚拟文件系统 VFS 中,__vfs_read 调用的是 new_sync_read 方法,在该方法中会对系统调用传进来的参数进行重新封装。比如:

  • struct file *filp : 要读取文件的 struct file 结构。
  • char __user *buf :用户空间的 Buffer,这里指的我们例子中 NIO 创建的临时 DirectByteBuffer。
  • size_t count :进行读取的字节数。也就是我们传入的用户态缓冲区 DirectByteBuffer 剩余可容纳的容量大小。
  • loff_t *pos :文件当前读取位置偏移 offset。

将这些参数重新封装到 struct iovec 和 struct kiocb 结构体中。

ssize_t new_sync_read(struct file *filp, char __user *buf, size_t len, loff_t *ppos)
{
    // 将 DirectByteBuffer 以及要读取的字节数封装进 iovec 结构体中
    struct iovec iov = { .iov_base = buf, .iov_len = len };
    struct kiocb kiocb;
    struct iov_iter iter;
    ssize_t ret;
        
    // 利用文件 struct file 初始化 kiocb 结构体
    init_sync_kiocb(&kiocb, filp);
    // 设置文件读取偏移
    kiocb.ki_pos = *ppos;
    // 读取文件字节数
    kiocb.ki_nbytes = len;
    // 初始化 iov_iter 结构
    iov_iter_init(&iter, READ, &iov, 1, len);
    // 最终调用 ext4_file_read_iter
    ret = filp->f_op->read_iter(&kiocb, &iter);
        .......省略......
    return ret;
}

struct iovec 结构体主要用来封装用来接收文件数据用的用户缓存区相关的信息:

struct iovec
{
    void __user *iov_base;     // 用户空间缓存区地址 这里是 DirectByteBuffer 的地址
    __kernel_size_t iov_len; // 缓冲区长度
}

但是内核中一般会使用 struct iov_iter 结构体对 struct iovec 进行包装,iov_iter 中可以包含多个 iovec。这一点从 struct iov_iter 结构体的命名关键字 iter 上可以看得出来。


struct iov_iter {
        ......省略.....
    const struct iovec *iov; 
}

之所以使用 struct iov_iter 结构体来包装 struct iovec 是为了兼容 readv() 系统调用,它允许用户使用多个用户缓存区去读取文件中的数据。JDK NIO Channel 支持的 scatter 操作底层原理就是 readv 系统调用

       FileChannel fileChannel = new RandomAccessFile(new File("file-read-write.txt"), "rw").getChannel();

       ByteBuffer  heapByteBuffer1 = ByteBuffer.allocate(4096);
       ByteBuffer  heapByteBuffer2 = ByteBuffer.allocate(4096);

       ByteBuffer[] scatter = { heapByteBuffer1, heapByteBuffer2 };

       fileChannel.read(scatter);

struct kiocb 结构体则是用来封装文件 IO 相关操作的状态和进度信息:

struct kiocb {
    struct file        *ki_filp;  // 要读取的文件 struct file 结构
    loff_t            ki_pos; // 文件读取位置偏移,表示文件处理进度
    void (*ki_complete)(struct kiocb *iocb, long ret); // IO完成回调    
    int            ki_flags; // IO类型,比如是 Direct IO 还是 Buffered IO
      
        ........省略.......
};

当 struct iovec 和 struct kiocb 在 new_sync_read 方法中被初始化好之后,最终通过 file_operations 中定义的函数指针 .read_iter 调用到 ext4_file_read_iter 方法中,从而进入 ext4 文件系统执行具体的读取操作。

static ssize_t ext4_file_read_iter(struct kiocb *iocb, struct iov_iter *to)
{
        ........省略........

    return generic_file_read_iter(iocb, to);
}
ssize_t generic_file_read_iter(struct kiocb *iocb, struct iov_iter *iter)
{
    ........省略........

    if (iocb->ki_flags & IOCB_DIRECT) {

        ........ Direct IO ........
        // 获取 page cache
        struct address_space *mapping = file->f_mapping;

        ........省略........
        // 绕过 page cache 直接从磁盘中读取数据
        retval = mapping->a_ops->direct_IO(iocb, iter);
    }

    ........ Buffered IO ........
    // 从 page cache 中读取数据
    retval = generic_file_buffered_read(iocb, iter, retval);
}

generic_file_read_iter 会根据 struct kiocb 中的 ki_flags 属性判断文件 IO 操作是 Direct IO 还是 Buffered IO。

4.1 Direct IO

从 Linux 内核角度探秘 JDK NIO 文件读写本质

我们可以通过 open 系统调用在打开文件的时候指定相关 IO 操作的模式是 Direct IO 还是 Buffered IO:

int open(const char *pathname, int flags, mode_t mode);
  • char *pathname : 指定要文件的路径。
  • int flags :指定文件的访问模式。比如:O_RDONLY(只读),O_WRONLY,(只写), O_RDWR(读写),O_DIRECT(Direct IO)。默认为 Buffered IO。
  • mode_t mode :可选,指定打开文件的权限

而 Java 在 JDK 10 之前一直是不支持 Direct IO,到了 JDK 10 才开始支持 Direct IO。

Path path = Paths.get("file-read-write.txt");
FileChannel fc = FileChannel.open(p, ExtendedOpenOption.DIRECT);

如果在文件打开的时候,我们设置了 Direct IO 模式,那么以后在对文件进行读取的过程中,内核将会绕过 page cache,直接从磁盘中读取数据到用户空间缓冲区 DirectByteBuffer 中。这样就可以避免一次数据从内核 page cache 到用户空间缓冲区的拷贝。

当应用程序期望使用自定义的缓存算法从而可以在用户空间实现更加高效更加可控的缓存逻辑时(比如数据库等应用程序),这时应该使用直接 Direct IO。在随机读取,随机写入的场景中也是比较适合用 Direct IO。

操作系统进程在接下来使用 read() 或者 write() 系统调用去读写文件的时候使用的是 Direct IO 方式,所传输的数据均不经过文件对应的高速缓存 page cache (这里就是网上常说的内核缓冲区)。

我们都知道操作系统是将内存分为一页一页的单位进行组织管理的,每页大小 4K ,那么同样文件中的数据在磁盘中的组织形式也是按照一块一块的单位来组织管理的,每块大小也是 4K ,所以我们在使用 Direct IO 读写数据时必须要按照文件在磁盘中的组织单位进行磁盘块大小对齐,缓冲区的大小也必须是磁盘块大小的整数倍。具体表现在如下几点:

  • 文件的读写位置偏移需要按照磁盘块大小对齐。
  • 用户缓冲区 DirectByteBuffer 起始地址需要按照磁盘块大小对齐。
  • 使用 Direct IO 进行数据读写时,读写的数据大小需要按照磁盘块大小进行对齐。这里指 DirectByteBuffer 中剩余数据的大小。

当我们采用 Direct IO 直接读取磁盘中的文件数据时,内核会从 struct file 结构中获取到该文件在内存中的 page cache。而我们多次提到的这个 page cache 在内核中的数据结构就是 struct address_space 。我们可以根据 file->f_mapping 获取。

struct file {
  // page cache
  struct address_space    *f_mapping;
}

和前面我们介绍的 struct file 结构中的 file_operations 一样,内核中将 page cache 相关的操作全部定义在 struct address_space_operations 结构中。这里和前边介绍的 file_operations 的作用是一样的,只是内核针对 page cache 操作定义的一个公共接口。

struct address_space {
    const struct address_space_operations *a_ops;
}

具体的实现会根据文件系统的不同而不同,这里我们还是以 ext4 文件系统为例:

static const struct address_space_operations ext4_aops = {
  .direct_IO  = ext4_direct_IO,
};

内核通过 struct address_space_operations 结构中定义的 .direct_IO 函数指针,具体函数为 ext4_direct_IO 来绕过 page cache 直接对磁盘进行读写。

采用 Direct IO 的方式对文件的读写操作全部是在 ext4_direct_IO 这一个函数中完成的。

由于磁盘文件中的数据是按照块为单位来组织管理的,所以文件系统其实就是一个块设备,通过 ext4_direct_IO 绕过 page cache 直接来到了文件系统的块设备驱动层,最终在块设备驱动层调用 __blockdev_direct_IO 来完成磁盘的读写操作。

注意:块设备驱动层的 __blockdev_direct_IO 需要等到所有的 Direct IO 传送数据完成之后才会返回,这里的传送指的是直接从磁盘拷贝到用户空间缓冲区中,当 Direct IO 模式下的 read() 或者 write() 系统调用返回之后,进程就可以安全放心地去读取用户缓冲区中的数据了。

4.2 Buffered IO

从 Linux 内核角度探秘 JDK NIO 文件读写本质

Buffered IO 相关的读取操作封装在 generic_file_buffered_read 函数中,其核心逻辑如下:

  1. 由于文件在磁盘中是以块为单位组织管理的,每块大小为 4k,内存是按照页为单位组织管理的,每页大小也是 4k。文件中的块数据被缓存在 page cache 中的缓存页中。所以首先通过 find_get_page 方法查找我们要读取的文件数据是否已经缓存在了 page cache 中。
  2. 如果 page cache 中不存在文件数据的缓存页,就需要通过 page_cache_sync_readahead 方法从磁盘中读取数据并缓存到 page cache 中。于此同时还需要同步预读若干相邻的数据块到 page cache 中。这样在下一次顺序读取的时候,直接就可以从 page cache 中读取了。
  3. 如果此次读取的文件数据已经存在于 page cache 中了,就需要调用 PageReadahead 来判断是否需要进一步预读数据到缓存页中。如果是,则从磁盘中异步预读若干页到 page cache 中。具体预读多少页是根据内核相关预读算法来动态调整的。
  4. 经过上面几个流程,此时文件数据已经存在于 page cache 中的缓存页中了,最后内核调用 copy_page_to_iter 方法将 page cache 中的数据拷贝到用户空间缓冲区 DirectByteBuffer 中。
static ssize_t generic_file_buffered_read(struct kiocb *iocb,
    struct iov_iter *iter, ssize_t written)
{
  // 获取文件在内核中对应的 struct file 结构
  struct file *filp = iocb->ki_filp;
  // 获取文件对应的 page cache
  struct address_space *mapping = filp->f_mapping;
  // 获取文件的 inode
  struct inode *inode = mapping->host;

   ...........省略...........

  // 开始 Buffered IO 读取逻辑
  for (;;) {
    // 用于从 page cache 中获取缓存的文件数据 page
    struct page *page;
    // 根据文件读取偏移计算出 第一个字节所在物理页的索引
    pgoff_t index;
    // 根据文件读取偏移计算出 第一个字节所在物理页中的页内偏移
    unsigned long offset; 
    // 在 page cache 中查找是否有读取数据在内存中的缓存页
    page = find_get_page(mapping, index);
    if (!page) {
      if (iocb->ki_flags & IOCB_NOWAIT) {
           ....... 如果设置的是异步IO,则直接返回 -EAGAIN ......
      }
      // 要读取的文件数据在 page cache 中没有对应的缓存页
      // 则从磁盘中读取文件数据,并同步预读若干相邻的数据块到 page cache中
      page_cache_sync_readahead(mapping,
          ra, filp,
          index, last_index - index);

      // 再一次触发缓存页的查找,这一次就可以找到了
      page = find_get_page(mapping, index);
      if (unlikely(page == NULL))
        goto no_cached_page;
    }

    //如果读取的文件数据已经在 page cache 中了,则判断是否进行近一步的预读操作
    if (PageReadahead(page)) {
      //异步预读若干文件数据块到 page cache 中
      page_cache_async_readahead(mapping,
          ra, filp, page,
          index, last_index - index);
    }
    
    ..............省略..............
    //将 page cache 中的数据拷贝到用户空间缓冲区 DirectByteBuffer 中
    ret = copy_page_to_iter(page, offset, nr, iter);
    }
}

从 Linux 内核角度探秘 JDK NIO 文件读写本质

到这里关于文件读取的两种模式 Buffered IO 和 Direct IO 在内核中的主干逻辑流程笔者就为大家介绍完了。

但是大家可能会对 Buffered IO 中的两个细节比较感兴趣:

  1. 如何在 page cache 中查找我们要读取的文件数据 ?也就是说上面提到的 find_get_page 函数是如何实现的?
  2. 文件预读的过程是怎么样的?内核中的预读算法又是什么样的呢?

在为大家解答这两个疑问之前,笔者先为大家介绍一下内核中的页高速缓存 page cache。

5. 页高速缓存 page cache

笔者在《一文聊透对象在 JVM 中的内存布局,以及内存对齐和压缩指针的原理及应用》 文章中为大家介绍 CPU 的高速缓存时曾提到过,根据摩尔定律:芯片中的晶体管数量每隔 18 个月就会翻一番。导致 CPU 的性能和处理速度变得越来越快,而提升 CPU 的运行速度比提升内存的运行速度要容易和便宜的多,所以就导致了 CPU 与内存之间的速度差距越来越大。

CPU 与内存之间的速度差异到底有多大呢? 我们知道寄存器是离 CPU 最近的,CPU 在访问寄存器的时候速度近乎于 0 个时钟周期,访问速度最快,基本没有时延。而访问内存则需要 50 - 200 个时钟周期。

所以为了弥补 CPU 与内存之间巨大的速度差异,提高 CPU 的处理效率和吞吐,于是我们引入了 L1 , L2 , L3 高速缓存集成到 CPU 中。CPU 访问高速缓存仅需要用到 1 - 30 个时钟周期,CPU 中的高速缓存是对内存热点数据的一个缓存。

从 Linux 内核角度探秘 JDK NIO 文件读写本质

而本文我们讨论的主题是内存与磁盘之间的关系,CPU 访问磁盘的速度就更慢了,需要用到大概约几千万个时钟周期.

我们可以看到 CPU 访问高速缓存的速度比访问内存的速度快大约10倍,而访问内存的速度要比访问磁盘的速度快大约 100000 倍。

引入 CPU 高速缓存的目的在于消除 CPU 与内存之间的速度差距,CPU 用高速缓存来存放内存中的热点数据。那么同样的道理,本小节中我们引入的页高速缓存 page cache 的目的是为了消除内存与磁盘之间的巨大速度差距,page cache 中缓存的是磁盘文件的热点数据。

另外我们根据程序的时间局部性原理可以知道,磁盘文件中的数据一旦被访问,那么它很有可能在短期被再次访问,如果我们访问的磁盘文件数据缓存在 page cache 中,那么当进程再次访问的时候数据就会在 page cache 中命中,这样我们就可以把对磁盘的访问变为对物理内存的访问,极大提升了对磁盘的访问性能。

程序局部性原理表现为:时间局部性和空间局部性。时间局部性是指如果程序中的某条指令一旦执行,则不久之后该指令可能再次被执行;如果某块数据被访问,则不久之后该数据可能再次被访问。空间局部性是指一旦程序访问了某个存储单元,则不久之后,其附近的存储单元也将被访问。

在前边的内容中我们多次提到操作系统是将物理内存分为一个一个的页面来组织管理的,每页大小为 4k ,而磁盘中的文件数据在磁盘中是分为一个一个的块来组织管理的,每块大小也为 4k。

page cache 中缓存的就是这些内存页面,页面中的数据对应于磁盘上物理块中的数据。page cache 中缓存的大小是可以动态调整的,它可以通过占用空闲内存来扩大缓存页面的容量,当内存不足时也可以通过回收页面来缓解内存使用的压力。

正如我们上小节介绍的 read 系统调用在内核中的实现逻辑那样,当用户进程发起 read 系统调用之后,内核首先会在 page cache 中检查请求数据所在页面是否已经缓存在 page cache 中。

  • 如果缓存命中,内核直接会把 page cache 中缓存的磁盘文件数据拷贝到用户空间缓冲区 DirectByteBuffer 中,从而避免了龟速的磁盘 IO。
  • 如果缓存没有命中,内核会分配一个物理页面,将这个新分配的页面插入 page cache 中,然后调度磁盘块 IO 驱动从磁盘中读取数据,最后用从磁盘中读取的数据填充这个物里页面。

根据前面介绍的程序时间局部性原理,当进程在不久之后再来读取数据的时候,请求的数据已经在 page cache 中了。极大地提升了文件 IO 的性能。

page cache 中缓存的不仅有基于文件的缓存页,还会缓存内存映射文件,以及磁盘块设备文件。这里大家只需要有这个概念就行,本文我们主要聚焦于基于文件的缓存页。在笔者后面的文章中,我们还会再次介绍到这些剩余类型的缓存页。

在我们了解了 page cache 引入的目的以及 page cache 在磁盘 IO 中所发挥的作用之后,大家一定会很好奇这个 page cache 在内核中到底是怎么实现的呢?

让我们先从 page cache 在内核中的数据结构开始聊起~~~~

6. page cache 在内核中的数据结构

page cache 在内核中的数据结构是一个叫做 address_space 的结构体:struct address_space。

这个名字起的真是有点词不达意,从命名上根本无法看出它是表示 page cache 的,所以大家在日常开发中一定要注意命名的精准规范。

每个文件都会有自己的 page cache。struct address_space 结构在内存中只会保留一份。

什么意思呢?比如我们可以通过多个不同的进程打开一个相同的文件,进程每打开一个文件,内核就会为它创建 struct file 结构。这样在内核中就会有多个 struct file 结构来表示同一个文件,但是同一个文件的 page cache 也就是 struct address_space 在内核中只会有一个。

从 Linux 内核角度探秘 JDK NIO 文件读写本质

struct address_space {
    struct inode        *host;        // 关联 page cache 对应文件的 inode
    struct radix_tree_root    page_tree; // 这里就是 page cache。里边缓存了文件的所有缓存页面
    spinlock_t        tree_lock; // 访问 page_tree 时用到的自旋锁
    unsigned long        nrpages;    // page cache 中缓存的页面总数
         ..........省略..........
    const struct address_space_operations *a_ops; // 定义对 page cache 中缓存页的各种操作方法
         ..........省略..........
}
  • struct inode *host :一个文件对应一个 page cache 结构 struct address_space ,文件的 inode 描述了一个文件的所有元信息。在 struct address_space 中通过 host 指针与文件的 inode 关联。而在 inode 结构体 struct inode 中又通过 i_mapping 指针与文件的 page cache 进行关联。
struct inode {
    struct address_space    *i_mapping; // 关联文件的 page cache
}
  • struct radix_tree_root page_tree : page cache 中缓存的所有文件页全部存储在 radix_tree 这样一个高效搜索树结构当中。在文件 IO 相关的操作中,内核需要频繁大量地在 page cache 中搜索请求页是否已经缓存在页高速缓存中,所以针对 page cache 的搜索操作必须是高效的,否则引入 page cache 所带来的性能提升将会被低效的搜索开销所抵消掉。
  • unsigned long nrpages :记录了当前文件对应的 page cache 缓存页面的总数。
  • const struct address_space_operations *a_ops :a_ops 定义了 page cache 中所有针对缓存页的 IO 操作,提供了管理 page cache 的各种行为。比如:常用的页面读取操作 readPage() 以及页面写入操作 writePage() 等。保证了所有针对缓存页的 IO 操作必须是通过 page cache 进行的。
struct address_space_operations {
    // 写入更新页面缓存
    int (*writepage)(struct page *page, struct writeback_control *wbc);
    // 读取页面缓存
    int (*readpage)(struct file *, struct page *);
    // 设置缓存页为脏页,等待后续内核回写磁盘
    int (*set_page_dirty)(struct page *page);
    // Direct IO 绕过 page cache 直接操作磁盘
    ssize_t (*direct_IO)(struct kiocb *, struct iov_iter *iter);

        ........省略..........
}

前边我们提到 page cache 中缓存的不仅仅是基于文件的页,它还会缓存内存映射页,以及磁盘块设备文件,况且基于文件的内存页背后也有不同的文件系统。所以内核只是通过 a_ops 定义了操作 page cache 缓存页 IO 的通用行为定义。而具体的实现需要各个具体的文件系统通过自己定义的 address_space_operations 来描述自己如何与 page cache 进行交互。比如前边我们介绍的 ext4 文件系统就有自己的 address_space_operations 定义。

static const struct address_space_operations ext4_aops = {
    .readpage        = ext4_readpage,
    .writepage        = ext4_writepage,
    .direct_IO        = ext4_direct_IO,

      ........省略.....
};

在我们从整体上了解了 page cache 在内核中的数据结构 struct address_space 之后,我们接下来看一下 radix_tree 这个数据结构是如何支持内核来高效搜索文件页的,以及 page cache 中这些被缓存的文件页是如何组织管理的。

7. 基树 radix_tree

正如前边我们提到的,在文件 IO 相关的操作中,内核会频繁大量地在 page cache 中查找请求页是否在页高速缓存中。还有就是当我们访问大文件时(linux 能支持大到几个 TB 的文件),page cache 中将会充斥着大量的文件页。

基于上面提到的两个原因:一个是内核对 page cache 的频繁搜索操作,另一个是 page cache 中会缓存大量的文件页。所以内核需要采用一个高效的搜索数据结构来组织管理 page cache 中的缓存页。

本小节我们就来介绍下,page cache 中用来存储缓存页的数据结构 radix_tree。

在 linux 内核 5.0 版本中 radix_tree 已被替换成 xarray 结构。感兴趣的同学可以自行了解下。

在 page cache 结构 struct address_space 中有一个类型为 struct radix_tree_root 的字段 page_tree,它表示的是 radix_tree 的根节点。

struct address_space {

    struct radix_tree_root  page_tree; // 这里就是 page cache。里边缓存了文件的所有缓存页面

    ..........省略..........
}
struct radix_tree_root {
    gfp_t            gfp_mask;
    struct radix_tree_node    __rcu *rnode;  // radix_tree 根节点
};

radix_tree 中的节点类型为 struct radix_tree_node。

struct radix_tree_node {
    void __rcu    *slots[RADIX_TREE_MAP_SIZE]; //包含 64 个指针的数组。用于指向下一层节点或者缓存页
    unsigned char    offset; //父节点中指向该节点的指针在父节点 slots 数组中的偏移
    unsigned char    count;//记录当前节点的 slots 数组指向了多少个节点
    struct radix_tree_node *parent;    // 父节点指针
    struct radix_tree_root *root;    // 根节点
    
         ..........省略.........

    unsigned long    tags[RADIX_TREE_MAX_TAGS][RADIX_TREE_TAG_LONGS]; // radix_tree 中的二维标记数组,用于标记子节点的状态。
};

从 Linux 内核角度探秘 JDK NIO 文件读写本质

void __rcu *slots[RADIX_TREE_MAP_SIZE] :radix_tree 树中的每个节点中包含一个 slots ,它是一个包含 64 个指针的数组,每个指针指向它的下一层节点或者缓存页描述符 struct page。

radix_tree 将缓存页全部存放在它的叶子结点中,所以它的叶子结点类型为 struct page。其余的节点类型为 radix_tree_node。最底层的 radix_tree_node 节点中的 slots 指向缓存页描述符 struct page。

unsigned char offset 用于表示父节点的 slots 数组中指向当前节点的指针,在父节点的slots数组中的索引。

unsigned char count 用于记录当前 radix_tree_node 的 slots 数组中指向的节点个数,因为 slots 数组中的指针有可能指向 null 。

这里大家可能已经注意到了在 struct radix_tree_node 结构中还有一个 long 型的 tags 二维数组 tags[RADIX_TREE_MAX_TAGS][RADIX_TREE_TAG_LONGS]。那么这个二维数组到底是用来干嘛的呢?我们接着往下看~~

7.1 radix_tree 的标记

经过前面的介绍我们知道,页高速缓存 page cache 的引入是为了在内存中缓存磁盘的热点数据尽可能避免龟速的磁盘 IO。

而在进行文件 IO 的时候,内核会频繁大量的在 page cache 中搜索请求数据是否已经缓存在 page cache 中,如果是,内核就直接将 page cache 中的数据拷贝到用户缓冲区中。从而避免了一次磁盘 IO。

这就要求内核需要采用一种支持高效搜索的数据结构来组织管理这些缓存页,所以引入了基树 radix_tree。

到目前为止,我们还没有涉及到缓存页的状态,不过在文章的后面我们很快就会涉及到,这里提前给大家引出来,让大家脑海里先有个概念。

那么什么是缓存页的状态呢?

我们知道在 Buffered IO 模式下,对于文件 IO 的操作都是需要经过 page cache 的,后面我们即将要介绍的 write 系统调用就会将数据直接写到 page cache 中,并将该缓存页标记为脏页(PG_dirty)直接返回,随后内核会根据一定的规则来将这些脏页回写到磁盘中,在会写的过程中这些脏页又会被标记为 PG_writeback,表示该页正在被回写到磁盘。

PG_dirty 和 PG_writeback 就是缓存页的状态,而内核不仅仅是需要在 page cache 中高效搜索请求数据所在的缓存页,还需要高效搜索给定状态的缓存页。

比如:快速查找 page cache 中的所有脏页。但是如果此时 page cache 中的大部分缓存页都不是脏页,那么顺序遍历 radix_tree 的方式就实在是太慢了,所以为了快速搜索到脏页,就需要在 radix_tree 中的每个节点 radix_tree_node
中加入一个针对其所有子节点的脏页标记,如果其中一个子节点被标记被脏时,那么这个子节点对应的父节点 radix_tree_node 结构中的对应脏页标记位就会被置 1 。

而用来存储脏页标记的正是上小节中提到的 tags 二维数组。其中第一维 tags[] 用来表示标记类型,有多少标记类型,数组大小就为多少,比如 tags[0] 表示 PG_dirty 标记数组,tags[1] 表示 PG_writeback 标记数组。

从 Linux 内核角度探秘 JDK NIO 文件读写本质

第二维 tags[][] 数组则表示对应标记类型针对每一个子节点的标记位,因为一个 radix_tree_node 节点中包含 64 个指针指向对应的子节点,所以二维 tags[][] 数组的大小也为 64 ,数组中的每一位表示对应子节点的标记。tags0 指向 PG_dirty 标记数组,tags1 指向PG_writeback 标记数组。

而缓存页( radix_tree 中的叶子结点)这些标记是存放在其对应的页描述符 struct page 里的 flag 中。

struct page {
    unsigned long flags;
}

从 Linux 内核角度探秘 JDK NIO 文件读写本质

只要一个缓存页(叶子结点)被标记,那么从这个叶子结点一直到 radix_tree 根节点的路径将会全部被标记。这就好比你在一盆清水中滴入一滴墨水,不久之后整盆水就会变为黑色。

从 Linux 内核角度探秘 JDK NIO 文件读写本质

这样内核在 radix_tree 中搜索被标记的脏页(PG_dirty)或者正在回写的页(PG_writeback)时,就可以迅速跳过哪些标记为 0 的中间节点的所有子树,中间节点对应的标记为 0 说明其所有的子树中包含的缓存页(叶子结点)都是干净的(未标记)。从而达到在 radix_tree 中迅速搜索指定状态的缓存页的目的。

8. page cache 中查找缓存页

在我们明白了 radix_tree 这个数据结构之后,接下来我们来看一下在《4.2 Buffered IO》小节中遗留的问题:内核如何通过 find_get_page 在 page cache 中高效查找缓存页?

在介绍 find_get_page 之前,笔者先来带大家看看 radix_tree 具体是如何组织和管理其中的缓存页 page 的。

从 Linux 内核角度探秘 JDK NIO 文件读写本质

经过上小节相关内容的介绍,我们了解到在 radix_tree 中每个节点 radix_tree_node 包含一个大小为 64 的指针数组 slots 用于指向它的子节点或者缓存页描述符(叶子节点)。

一个 radix_tree_node 节点下边最多可容纳 64 个子节点,如果 radix_tree 的深度为 1 (不包括叶子节点),那么这颗 radix_tree 就可以缓存 64 个文件页。而每页大小为 4k,所以一颗深度为 1 的 radix_tree 可以缓存 256k 的文件内容。

从 Linux 内核角度探秘 JDK NIO 文件读写本质

而如果一颗 radix_tree 的深度为 2,那么它就可以缓存 64 * 64 = 4096 个文件页,总共可以缓存 16M 的文件内容。

从 Linux 内核角度探秘 JDK NIO 文件读写本质

依次类推我们可以得到不同的 radix_tree 深度可以缓存多大的文件内容:

radix_tree 深度 page 最大索引值 缓存文件大小
1 2^6 - 1 = 63 256K
2 2^12 - 1 = 4095 16M
3 2^18 - 1 = 262143 1G
4 2^24 -1 =16777215 64G
5 2^30 - 1 4T
6 2^36 - 1 64T

通过以上内容的介绍,我们看到在 radix_tree 是根据缓存页的 index (索引)来组织管理缓存页的,内核会根据这个 index 迅速找到对应的缓存页。在缓存页描述符 struct page 结构中保存了其在 page cache 中的索引 index。

struct page {
    unsigned long flags;  //缓存页标记
    struct address_space *mapping; // 缓存页所在的 page cache
    unsigned long index;  // 页索引
    ...  
} 

事实上 find_get_page 函数也是根据缓存页描述符中的这个 index 来在 page cache 中高效查找对应的缓存页。

static inline struct page *find_get_page(struct address_space *mapping,
                    pgoff_t offset)
{
    return pagecache_get_page(mapping, offset, 0, 0);
}
  • struct address_space *mapping : 为读取文件对应的 page cache 页高速缓存。
  • pgoff_t offset : 为所请求的缓存页在 page cache 中的索引 index,类型为 long 型。

那么在内核是如何利用这个 long 型的 offset 在 page cache 中高效搜索指定的缓存页呢?

经过前边我们对 radix_tree 结构的介绍,我们已经知道 radix_tree 中每个节点 radix_tree_node 包含一个大小为 64 的指针数组 slots 用于指向它的子节点或者缓存页描述符。

一个 radix_tree_node 节点下边最多可容纳 64 个子节点,如果 radix_tree 的深度为 1 (不包括叶子节点),那么这颗 radix_tree 就可以缓存 64 个文件页。只能表示 0 - 63 的索引范围,所以 long 型的缓存页 offset 的低 6 位可以表示这个范围,对应于第一层 radix_tree_node 节点的 slots 数组下标。

从 Linux 内核角度探秘 JDK NIO 文件读写本质

如果一颗 radix_tree 的深度为 2(不包括叶子节点),那么它就可以缓存 64 * 64 = 4096 个文件页,表示的索引范围为 0 - 4095,在这种情况下,缓存页索引 offset 的低 12 位可以分成 两个 6 位的字段,高位的字段用来表示第一层节点的 slots 数组的下标,低位字段用于表示第二层节点的 slots 数组下标。

依次类推,如果 radix_tree 的深度为 6 那么它可以缓存 64T 的文件页,表示的索引范围为:0 到 2^36 - 1。 缓存页索引 offset 的低 36 位可以分成 六 个 6 位的字段。缓存页索引的最高位字段来表示 radix_tree 中的第一层节点中的 slots 数组下标,接下来的 6 位字段表示第二层节点中的 slots 数组下标,这样一直到最低的 6 位字段表示第 6 层节点中的 slots 数组下标。

通过以上根据缓存页索引 offset 的查找过程,我们看出内核在 page cache 查找缓存页的时间复杂度和 radix_tree 的深度有关。

在我们理解了内核在 radix_tree 中的查找缓存页逻辑之后,再来看 find_get_page 的代码实现就变得很简单了~~

struct page *pagecache_get_page(struct address_space *mapping, pgoff_t offset,
    int fgp_flags, gfp_t gfp_mask)
{
    struct page *page;

repeat:
    // 在 radix_tree 中根据 缓存页 offset 查找缓存页
    page = find_get_entry(mapping, offset);
    // 缓存页不存在的话,跳转到 no_page 处理逻辑
    if (!page)
        goto no_page;

   .......省略.......
no_page:
    if (!page && (fgp_flags & FGP_CREAT)) { 
         // 分配新页
        page = __page_cache_alloc(gfp_mask);
        if (!page)
            return NULL;
    
        if (fgp_flags & FGP_ACCESSED)
            //增加页的引用计数
            __SetPageReferenced(page);
        // 将新分配的内存页加入到页高速缓存 page cache 中
        err = add_to_page_cache_lru(page, mapping, offset, gfp_mask);

              .......省略.......
    }

    return page;
}
  • 内核首先调用 find_get_entry 方法根据缓存页的 offset 到 page cache 中去查找看请求的文件页是否已经在页高速缓存中。如果存在直接返回。
  • 如果请求的文件页不在 page cache 中,内核则会首先会在物理内存中分配一个内存页,然后将新分配的内存页加入到 page cache 中,并增加页引用计数。
  • 随后会通过 address_space_operations 重定义的 readpage 激活块设备驱动从磁盘中读取请求数据,然后用读取到的数据填充新分配的内存页。
static const struct address_space_operations ext4_aops = {
    .readpage       = ext4_readpage,
    .writepage      = ext4_writepage,
    .direct_IO      = ext4_direct_IO,

      ........省略.....
};

9. 文件页的预读

之前我们在引入 page cache 的时候提到过,根据程序时间局部性原理:如果进程在访问某一块数据,那么在访问的不久之后,进程还会再次访问这块数据。所以内核引入了 page cache 在内存中缓存磁盘中的热点数据,从而减少对磁盘的 IO 访问,提升系统性能。

而本小节我们要介绍的文件页预读特性是根据程序空间局部性原理:当进程访问一段数据之后,那么在不就的将来和其临近的一段数据也会被访问到。所以当进程在访问文件中的某页数据的时候,内核会将它和临近的几个页一起预读到 page cache 中。这样当进程再次访问文件的时候,就不需要进行龟速的磁盘 IO 了,因为它所请求的数据已经预读进 page cache 中了。

我们常提到的当你顺序读取文件的时候,性能会非常的高,因为相当于是在读内存,这就是文件预读的功劳。

但是在我们随机访问文件的时候,文件预读不仅不会提高性能,返回会降低文件读取的性能,因为随机读取文件并不符合程序空间局部性原理,因此预读进 page cache 中的文件页通常是无效的,下一次根本不会再去读取,这无疑是白白浪费了 page cache 的空间,还额外增加了不必要的预读磁盘 IO。

事实上,在我们对文件进行随机读取的场景下,更适合用 Direct IO 的方式绕过 page cache 直接从磁盘中读取文件,还能减少一次从 page cache 到用户缓冲区的拷贝。

所以内核需要一套非常精密的预读算法来根据进程是顺序读文件还是随机读文件来精确地调控预读的文件页数,或者直接关闭预读。

  • 进程在读取文件数据的时候都是逐页进行读取的,因此在预读文件页的时候内核并不会考虑页内偏移,而是根据请求数据在文件内部的页偏移进行读取。

从 Linux 内核角度探秘 JDK NIO 文件读写本质

  • 如果进程持续的顺序访问一个文件,那么预读页数也会随着逐步增加。
  • 当发现进程开始随机访问文件了(当前访问的文件页和最后一次访问的文件页 offset 不是连续的),内核就会逐步减少预读页数或者彻底禁止预读。
  • 当内核发现进程再重复的访问同一文件页时或者文件中的文件页已经几乎全部缓存在 page cache 中了,内核此时就会禁止预读。

以上几点就是内核的预读算法的核心逻辑,从这个预读逻辑中我们可以看出,进程在进行文件读取的时候涉及到两种不同类型的页面集合,一个是进程可以请求的文件页(已经缓存在 page cache 中的文件页),另一个是内核预读的文件页。

而内核也确实按照这两种页面集合分为两个窗口:

  • 当前窗口(current window): 表示进程本次文件请求可以直接读取的页面集合,这个集合中的页面全部已经缓存在 page cache 中,进程可以直接读取返回。当前窗口中包含进程本次请求的文件页以及上次内核预读的文件页集合。表示进程本次可以从 page cache 直接获取的页面范围。
  • 预读窗口(ahead window):预读窗口的页面都是内核正在预读的文件页,它们此时并不在 page cache 中。这些页面并不是进程请求的文件页,但是内核根据空间局部性原理假定它们迟早会被进程请求。预读窗口内的页面紧跟着当前窗口后面,并且内核会动态调整预读窗口的大小(有点类似于 TCP 中的滑动窗口)。

从 Linux 内核角度探秘 JDK NIO 文件读写本质

如果进程本次文件请求的第一页的 offset,紧跟着上一次文件请求的最后一页的 offset,内核就认为是顺序读取。在顺序读取文件的场景下,如果请求的第一页在当前窗口内,内核随后就会检查是否建立了预读窗口,如果没有就会创建预读窗口并触发相应页的读取操作。

在理想情况下,进程会继续在当前窗口内请求页,于此同时,预读窗口内的预读页同时异步传送着,这样进程在顺序读取文件的时候就相当于直接读取内存,极大地提高了文件 IO 的性能。

以上包含的这些文件预读信息,比如:如何判断进程是顺序读取还是随机读取,当前窗口信息,预读窗口信息。全部保存在 struct file 结构中的 f_ra 字段中。

struct file {
    struct file_ra_state    f_ra;
}

用于描述文件预读信息的结构体在内核中用 struct file_ra_state 结构体来表示:

struct file_ra_state {
    pgoff_t start; // 当前窗口第一页的索引
    unsigned int size;  // 当前窗口的页数,-1表示临时禁止预读
    unsigned int async_size;    // 异步预读页面的页数
    unsigned int ra_pages;  // 文件允许的最大预读页数
    loff_t prev_pos;  // 进程最后一次请求页的索引
};

内核可以根据 start 和 prev_pos 这两个字段来判断进程是否在顺序访问文件。

ra_pages 表示当前文件允许预读的最大页数,进程可以通过系统调用 posix_fadvise() 来改变已打开文件的 ra_page 值来调优预读算法。

int posix_fadvise(int fd, off_t offset, off_t len, int advice);

该系统调用用来通知内核,我们将来打算以特定的模式 advice 访问文件数据,从而允许内核执行适当的优化。

advice 参数主要有下面几种数值:

  • POSIX_FADV_NORMAL : 设置文件最大预读页数 ra_pages 为默认值 32 页。
  • POSIX_FADV_SEQUENTIAL : 进程期望顺序访问指定的文件数据,ra_pages 值为默认值的两倍。
  • POSIX_FADV_RANDOM :进程期望以随机顺序访问指定的文件数据。ra_pages 设置为 0,表示禁止预读。

后来人们发现当禁止预读后,这样一页一页的读取性能非常的低下,于是 linux 3.19.8 之后 POSIX_FADV_RANDOM 的语义被改变了,它会在 file->f_flags 中设置 FMODE_RANDOM 属性(后面我们分析内核预读相关源码的时候还会提到),当遇到 FMODE_RANDOM 的时候内核就会走强制预读的逻辑,按最大 2MB 单元大小的 chunk 进行预读。

This fixes inefficient page-by-page reads on POSIX_FADV_RANDOM.
POSIX_FADV_RANDOM used to set ra_pages=0, which leads to poor
performance: a 16K read will be carried out in 4 _sync_ 1-page reads.
  • POSIX_FADV_WILLNEED :通知内核,进程指定这段文件数据将在不久之后被访问。

而触发内核进行文件预读的场景,分为以下几种:

  1. 当进程采用 Buffered IO 模式通过系统调用 read 进行文件读取时,内核会触发预读。
  2. 通过 POSIX_FADV_WILLNEED 参数执行系统调用 posix_fadvise,会通知内核这个指定范围的文件页不就将会被访问。触发预读。
  3. 当进程显示执行 readahead() 系统调用时,会显示触发内核的预读动作。
  4. 当内核为内存文件映射区域分配一个物理页面时,会触发预读。关于内存映射的相关内容,笔者会在后面的文章为大家详细介绍。
  5. 和 posix_fadvise 一样的道理,系统调用 madvise 主要用来指定内存文件映射区域的访问模式。可通过 advice = MADV_WILLNEED 通知内核,某个文件内存映射区域中的指定范围的文件页在不久将会被访问。触发预读。
int madvise(caddr_t addr, size_t len, int advice);

从触发内核预读的这几种场景中我们可以看出,预读分为主动触发和被动触发,在《4.2 Buffered IO》小节中遗留的 page_cache_sync_readahead 函数为被动触发,接下来我们来看下它在内核中的实现逻辑。

9.1 page_cache_sync_readahead

void page_cache_sync_readahead(struct address_space *mapping,
                   struct file_ra_state *ra, struct file *filp,
                   pgoff_t offset, unsigned long req_size)
{
    // 禁止预读,直接返回
    if (!ra->ra_pages)
        return;

    if (blk_cgroup_congested())
        return;

    // 通过 posix_fadvise 设置了 POSIX_FADV_RANDOM,内核走强制预读逻辑
    if (filp && (filp->f_mode & FMODE_RANDOM)) {
        // 按最大2MB单元大小的chunk进行预读
        force_page_cache_readahead(mapping, filp, offset, req_size);
        return;
    }

    // 执行预读逻辑
    ondemand_readahead(mapping, ra, filp, false, offset, req_size);
}

!ra->ra_pages 表示 ra_pages 设置为 0 ,预读被禁止,直接返回。

如果进程通过前边介绍的 posix_fadvise 系统调用并且 advice 参数设置为 POSIX_FADV_RANDOM。在 linux 3.19.8 之后文件的 file->f_flags 属性会被设置为 FMODE_RANDOM,这样内核会走强制预读逻辑,按最大 2MB 单元大小的 chunk 进行预读。

int posix_fadvise(int fd, off_t offset, off_t len, int advice);
// mm/fadvise.c
switch (advice) {

      .........省略........

     case POSIX_FADV_RANDOM:
              .........省略........
        file->f_flags |= FMODE_RANDOM;
              .........省略........
         break;

      .........省略........
}

而真正的预读逻辑封装在 ondemand_readahead 函数中。

9.2 ondemand_readahead

该方法中封装了前边介绍的预读算法逻辑,动态的调整当前窗口以及预读窗口的大小。

/*
 * A minimal readahead algorithm for trivial sequential/random reads.
 */
static unsigned long
ondemand_readahead(struct address_space *mapping,
           struct file_ra_state *ra, struct file *filp,
           bool hit_readahead_marker, pgoff_t offset,
           unsigned long req_size)
{
    struct backing_dev_info *bdi = inode_to_bdi(mapping->host);
    unsigned long max_pages = ra->ra_pages; // 默认32页
    unsigned long add_pages;
    pgoff_t prev_offset;

    ........预读算法逻辑,动态调整当前窗口和预读窗口.........

    //根据条件,计算本次预读最大预读取多少个页,一般情况下是max_pages=32个页
    if (req_size > max_pages && bdi->io_pages > max_pages)
        max_pages = min(req_size, bdi->io_pages);


    //offset即page index,如果page index=0,表示这是文件第一个页,
    //内核认为是顺序读,跳转到initial_readahead进行处理
    if (!offset)
        goto initial_readahead;

initial_readahead:
    // 当前窗口第一页的索引
    ra->start = offset;
    // get_init_ra_size初始化第一次预读的页的个数,一般情况下第一次预读是4个页 
    ra->size = get_init_ra_size(req_size, max_pages);
    // 异步预读页面个数也就是预读窗口大小
    ra->async_size = ra->size > req_size ? ra->size - req_size : ra->size;

 
    // 默认情况下是 ra->start=0, ra->size=0, ra->async_size=0 ra->prev_pos=0
    // 但是经过第一次预读后,上面三个值会出现变化
    if ((offset == (ra->start + ra->size - ra->async_size) ||
         offset == (ra->start + ra->size))) {
        ra->start += ra->size;
        ra->size = get_next_ra_size(ra, max_pages);
        ra->async_size = ra->size;
        goto readit;
    }
  
    //异步预读的时候会进入这个判断,更新ra的值,然后预读特定的范围的页
    //异步预读的调用表示Readahead出来的页连续命中  
    if (hit_readahead_marker) {
        pgoff_t start;
 
        rcu_read_lock();
        // 这个函数用于找到offset + 1开始到offset + 1 + max_pages这个范围内,第一个不在page cache的页的index
        start = page_cache_next_miss(mapping, offset + 1, max_pages);
        rcu_read_unlock();
 
        if (!start || start - offset > max_pages)
            return 0;
 
        ra->start = start;
        ra->size = start - offset;    /* old async_size */
        ra->size += req_size;
         
        // 由于连续命中,get_next_ra_size会加倍上次的预读页数
        // 第一次预读了4个页
        // 第二次命中以后,预读8个页
        // 第三次命中以后,预读16个页
        // 第四次命中以后,预读32个页,达到默认情况下最大的读取页数
        // 第五次、第六次、第N次命中都是预读32个页 
        ra->size = get_next_ra_size(ra, max_pages);
        ra->async_size = ra->size;
        goto readit;

       ........ 省略.........
    return __do_page_cache_readahead(mapping, filp, offset, req_size, 0);
}
  • struct address_space *mapping : 读取文件对应的 page cache 结构。
  • struct file_ra_state *ra : 文件对应的预读状态信息,封装在 file->f_ra 中。
  • struct file *filp : 读取文件对应的 struct file 结构。
  • pgoff_t offset : 本次请求文件页在 page cache 中的索引。(文件页偏移)
  • long req_size : 要完成当前读操作还需要读取的页数。

在预读算法逻辑中,内核通过 struct file_ra_state 结构中封装的文件预读信息来判断文件的读取是否为顺序读。比如:

  • 通过检查 ra->prev_pos 和 offset 是否相同,来判断当前请求页是否和最近一次请求的页相同,如果重复访问同一页,预读就会停止。
  • 通过检查 ra->prev_pos 和 offset 是否相邻,来判断进程是否顺序读取文件。如果是顺序访问文件,预读就会增加。
  • 当进程第一次访问文件时,并且请求的第一个文件页在文件中的偏移量为 0 时表示进程从头开始读取文件,那么内核就会认为进程想要顺序的访问文件,随后内核就会从文件的第一页开始创建一个新的当前窗口,初始的当前窗口总是 2 的次幂,窗口具体大小与进程的读操作所请求的页数有一定的关系。请求页数越大,当前窗口就越大,直到最大值 ra->ra_pages 。
static unsigned long get_init_ra_size(unsigned long size, unsigned long max)
{
    unsigned long newsize = roundup_pow_of_two(size);

    if (newsize <= max / 32)
        newsize = newsize * 4;
    else if (newsize <= max / 4)
        newsize = newsize * 2;
    else
        newsize = max;

    return newsize;
}
  • 相反,当进程第一次访问文件,但是请求页在文件中的偏移量不为 0 时,内核就会假定进程不准备顺序读取文件,函数就会暂时禁止预读。
  • 一旦内核发现进程在当前窗口内执行了顺序读取,那么预读窗口就会被建立,预读窗口总是紧挨着当前窗口的最后一页。
  • 预读窗口的大小和当前窗口有关,如果已经被预读的页不在 page cache 中(可能内存紧张,预读页被回收),那么预读窗口就会是 当前窗口大小 - 2,最小值为 4。否则预读窗口就会是当前窗口的4倍或者2倍。
  • 当进程继续顺序访问文件时,最终预读窗口就会变为当前窗口,随后新的预读窗口就会被建立,随着进程顺序地读取文件,预读会越来越大,但是内核一旦发现对于文件的访问 offset 相对于上一次的请求页 ra->prev_pos 不是顺序的时候,当前窗口和预读窗口就会被清空,预读被暂时禁止。

当内核通过以上介绍的预读算法确定了预读窗口的大小之后,就开始调用 __do_page_cache_readahead 从磁盘去预读指定的页数到 page cache 中。

9.3 __do_page_cache_readahead

unsigned int __do_page_cache_readahead(struct address_space *mapping,
        struct file *filp, pgoff_t offset, unsigned long nr_to_read,
        unsigned long lookahead_size)
{
    struct inode *inode = mapping->host;
    struct page *page;
    unsigned long end_index;    /* The last page we want to read */
    int page_idx;
    unsigned int nr_pages = 0;
    loff_t isize = i_size_read(inode);
    end_index = ((isize - 1) >> PAGE_SHIFT);

    /*
     * 尽可能的一次性分配全部需要预读的页 nr_to_read
     * 注意这里是尽可能的分配,意思就是能分配多少就分配多少,并不一定要全部分配
     */
    for (page_idx = 0; page_idx < nr_to_read; page_idx++) {
        pgoff_t page_offset = offset + page_idx;

        if (page_offset > end_index)
            break;

        .......省略.....

        // 首先在内存中为预读数据分配物理页面
        page = __page_cache_alloc(gfp_mask);
        if (!page)
            break;
        // 设置新分配的物理页在 page cache 中的索引
        page->index = page_offset;
        // 将新分配的物理页面加入到 page cache 中
        list_add(&page->lru, &page_pool);
        if (page_idx == nr_to_read - lookahead_size)
            // 设置页面属性为 PG_readahead 后续会开启异步预读
            SetPageReadahead(page);
        nr_pages++;
    }

    /*
     * 当需要预读的页面分配完毕之后,开始真正的 IO 动作,从磁盘中读取
     * 数据填充 page cache 中的缓存页。
     */
    if (nr_pages)
        read_pages(mapping, filp, &page_pool, nr_pages, gfp_mask);
    BUG_ON(!list_empty(&page_pool));
out:
    return nr_pages;
}

内核调用 read_pages 方法激活磁盘块设备驱动程序从磁盘中读取文件数据之前,需要为本次进程读取请求所需要的所有页面尽可能地一次性全部分配,如果不能一次性分配全部页面,预读操作就只在分配好的缓存页面上进行,也就是说只从磁盘中读取数据填充已经分配好的页面。

10. JDK NIO 对普通文件的写入

注意:下面的例子并不是最佳实践,之所以这里引入 HeapByteBuffer 是为了将上篇文章的内容和本文衔接起来。事实上,对于 IO 的操作一般都会选择 DirectByteBuffer ,关于 DirectByteBuffer 的相关内容笔者会在后面的文章中详细为大家介绍。

        FileChannel fileChannel = new RandomAccessFile(new File("file-read-write.txt"), "rw").getChannel();
        ByteBuffer  heapByteBuffer = ByteBuffer.allocate(4096);
        fileChannel.write(heapByteBuffer);

在对文件进行读写之前,我们需要首先利用 RandomAccessFile 在内核中打开指定的文件 file-read-write.txt ,并获取到它的文件描述符 fd = 5000。

从 Linux 内核角度探秘 JDK NIO 文件读写本质

本例 heapByteBuffer 中存放着需要写入文件的内容,随后来到 FileChannelImpl 实现类调用 IOUtil 触发底层系统调用 write 来写入文件。

public class FileChannelImpl extends FileChannel {
  // 前边介绍打开的文件描述符 5000
  private final FileDescriptor fd;
  // NIO中用它来触发 native read 和 write 的系统调用
  private final FileDispatcher nd;
  // 读写文件时加锁,前边介绍 FileChannel 的读写方法均是线程安全的
  private final Object positionLock = new Object();
  
    public int write(ByteBuffer src) throws IOException {
        ensureOpen();
        if (!writable)
            throw new NonWritableChannelException();
        synchronized (positionLock) {
            //写入的字节数
            int n = 0;
            try {
                ......省略......
                if (!isOpen())
                    return 0;
                do {
                    n = IOUtil.write(fd, src, -1, nd);
                } while ((n == IOStatus.INTERRUPTED) && isOpen());
                // 返回写入的字节数
                return IOStatus.normalize(n);
            } finally {
                  ......省略......
            }
        }
    }

}

NIO 中的所有 IO 操作全部封装在 IOUtil 类中,而 NIO 中的 SocketChannel 以及这里介绍的 FileChannel 底层依赖的系统调用可能不同,这里会通过 NativeDispatcher 对具体 Channel 操作实现分发,调用具体的系统调用。对于 FileChannel 来说 NativeDispatcher 的实现类为 FileDispatcher。对于 SocketChannel 来说 NativeDispatcher 的实现类为 SocketDispatcher。

public class IOUtil {

    static int write(FileDescriptor fd, ByteBuffer src, long position,
                     NativeDispatcher nd)
        throws IOException
    {
        // 标记传递进来的 heapByteBuffer 的 position 位置用于后续恢复
        int pos = src.position();
        // 获取 heapByteBuffer 的 limit 用于计算 写入字节数
        int lim = src.limit();
        assert (pos <= lim);
        // 写入的字节数
        int rem = (pos <= lim ? lim - pos : 0);
        // 创建临时的 DirectByteBuffer,用于通过系统调用 write 写入数据到内核
        ByteBuffer bb = Util.getTemporaryDirectBuffer(rem);
        try {
            // 将 heapByteBuffer 中的内容拷贝到临时 DirectByteBuffer 中
            bb.put(src);
            // DirectByteBuffer 切换为读模式,用于后续发送数据
            bb.flip();
            // 恢复 heapByteBuffer 中的 position
            src.position(pos);

            int n = writeFromNativeBuffer(fd, bb, position, nd);
            if (n > 0) {
                // 此时 heapByteBuffer 中的内容已经发送完毕,更新它的 postion + n 
                // 这里表达的语义是从 heapByteBuffer 中读取了 n 个字节并发送成功
                src.position(pos + n);
            }
            // 返回发送成功的字节数
            return n;
        } finally {
            // 释放临时创建的 DirectByteBuffer
            Util.offerFirstTemporaryDirectBuffer(bb);
        }
    }

   private static int writeFromNativeBuffer(FileDescriptor fd, ByteBuffer bb,
                                             long position, NativeDispatcher nd)
        throws IOException
    {
        int pos = bb.position();
        int lim = bb.limit();
        assert (pos <= lim);
        // 要发送的字节数
        int rem = (pos <= lim ? lim - pos : 0);

        int written = 0;
        if (rem == 0)
            return 0;
        if (position != -1) {
             ........省略.......
        } else {
            written = nd.write(fd, ((DirectBuffer)bb).address() + pos, rem);
        }
        if (written > 0)
            // 发送完毕之后更新 DirectByteBuffer 的position
            bb.position(pos + written);
        // 返回写入的字节数
        return written;
    }
}

在 IOUtil 中首先创建一个临时的 DirectByteBuffer,然后将本例中 HeapByteBuffer 中的数据全部拷贝到这个临时的 DirectByteBuffer 中。这个 DirectByteBuffer 就是我们在 IO 系统调用中经常提到的用户空间缓冲区。

随后在 writeFromNativeBuffer 方法中通过 FileDispatcher 触发 JNI 层的
native 方法执行底层系统调用 write 。

class FileDispatcherImpl extends FileDispatcher {

   int write(FileDescriptor fd, long address, int len) throws IOException {
        return write0(fd, address, len);
    }

  static native int write0(FileDescriptor fd, long address, int len)
        throws IOException;
}

NIO 中关于文件 IO 相关的系统调用全部封装在 JNI 层中的 FileDispatcherImpl.c 文件中。里边定义了各种 IO 相关的系统调用的 native 方法。

// FileDispatcherImpl.c 文件
JNIEXPORT jint JNICALL
Java_sun_nio_ch_FileDispatcherImpl_write0(JNIEnv *env, jclass clazz,
                              jobject fdo, jlong address, jint len)
{
    jint fd = fdval(env, fdo);
    void *buf = (void *)jlong_to_ptr(address);
    // 发起 write 系统调用进入内核
    return convertReturnVal(env, write(fd, buf, len), JNI_FALSE);
}

系统调用 write 在内核中的定义如下所示:

SYSCALL_DEFINE3(write, unsigned int, fd, const char __user *, buf,
    size_t, count)
{
  struct fd f = fdget_pos(fd);
         ......
  loff_t pos = file_pos_read(f.file);
  ret = vfs_write(f.file, buf, count, &pos);
         ......
}

现在我们就从用户空间的 JDK NIO 这一层逐步来到了内核空间的边界处 --- OS 系统调用 write 这里,马上就要进入内核了。

从 Linux 内核角度探秘 JDK NIO 文件读写本质

这一次我们来看一下当系统调用 write 发起之后,用户进程在内核态具体做了哪些事情?

11. 从内核角度探秘文件写入本质

现在让我们再次进入内核,来看一下内核中具体是如何处理文件写入操作的,这个过程会比文件读取要复杂很多,大家需要有点耐心~~

再次强调一下,本文所举示例中用到的 HeapByteBuffer 只是为了与上篇文章 《一步一图带你深入剖析 JDK NIO ByteBuffer 在不同字节序下的设计与实现》介绍的内容做出呼应,并不是最佳实践。笔者会在后续的文章中一步一步为大家展开这块内容的最佳实践。

11.1 Buffered IO

从 Linux 内核角度探秘 JDK NIO 文件读写本质

使用 JDK NIO 中的 HeapByteBuffer 在对文件进行写入的过程,主要分为如下几个核心步骤:

  1. 首先会在用户空间的 JDK 层将位于 JVM 堆中的 HeapByteBuffer 中的待写入数据拷贝到位于 OS 堆中的 DirectByteBuffer 中。这里发生第一次拷贝
  2. 随后 NIO 会在用户态通过系统调用 write 发起文件写入的请求,此时发生第一次上下文切换
  3. 随后用户进程进入内核态,在虚拟文件系统层调用 vfs_write 触发对 page cache 写入的操作。相关操作封装在 generic_perform_write 函数中。这个后面笔者会细讲,这里我们只关注核心总体流程。
  4. 内核调用 iov_iter_copy_from_user_atomic 函数将用户空间缓冲区 DirectByteBuffer 中的待写入数据拷贝到 page cache 中。发生第二次拷贝动作,这里的操作就是我们常说的 CPU 拷贝。
  5. 当待写入数据拷贝到 page cache 中时,内核会将对应的文件页标记为脏页。

脏页表示内存中的数据要比磁盘中对应文件数据要新。

  1. 此时内核会根据一定的阈值判断是否要对 page cache 中的脏页进行回写,如果不需要同步回写,进程直接返回。文件写入操作完成。这里发生第二次上下文切换

从这里我们看到在对文件进行写入时,内核只会将数据写入到 page cache 中。整个写入过程就完成了,并不会写到磁盘中。

  1. 脏页回写又会根据脏页数量在内存中的占比分为:进程同步回写和内核异步回写。当脏页太多了,进程自己都看不下去的时候,会同步回写内存中的脏页,直到回写完毕才会返回。在回写的过程中会发生第三次拷贝,通过DMA 将 page cache 中的脏页写入到磁盘中。

所谓内核异步回写就是内核会定时唤醒一个 flusher 线程,定时将内存中的脏页回写到磁盘中。这部分的内容笔者会在后续的章节中详细讲解。

在 NIO 使用 HeapByteBuffer 在对文件进行写入的过程中,一般只会发生两次拷贝动作和两次上下文切换,因为内核将数据拷贝到 page cache 中后,文件写入过程就结束了。如果脏页在内存中的占比太高了,达到了进程同步回写的阈值,那么就会发生第三次 DMA 拷贝,将脏页数据回写到磁盘文件中。

如果进程需要同步回写脏页数据时,在本例中是要发生三次拷贝动作。但一般情况下,在本例中只会发生两次,没有第三次的 DMA 拷贝。

11.2 Direct IO

在 JDK 10 中我们可以通过如下的方式采用 Direct IO 模式打开文件:

FileChannel fc = FileChannel.open(p, StandardOpenOption.WRITE,
             ExtendedOpenOption.DIRECT)

从 Linux 内核角度探秘 JDK NIO 文件读写本质

在 Direct IO 模式下的文件写入操作最明显的特点就是绕过 page cache 直接通过 DMA 拷贝将用户空间缓冲区 DirectByteBuffer 中的待写入数据写入到磁盘中。

  • 同样发生两次上下文切换、
  • 在本例中只会发生两次数据拷贝,第一次是将 JVM 堆中的 HeapByteBuffer 中的待写入数据拷贝到位于 OS 堆中的 DirectByteBuffer 中。第二次则是 DMA 拷贝,将用户空间缓冲区 DirectByteBuffer 中的待写入数据写入到磁盘中。

12. Talk is cheap ! show you the code

下面是系统调用 write 在内核中的完整定义:

SYSCALL_DEFINE3(write, unsigned int, fd, const char __user *, buf,
    size_t, count)
{
  // 根据文件描述符获取文件对应的 struct file 结构
  struct fd f = fdget_pos(fd);
         ......
  // 获取当前文件的写入位置 offset
  loff_t pos = file_pos_read(f.file);
  // 进入虚拟文件系统层,执行具体的文件写入操作
  ret = vfs_write(f.file, buf, count, &pos);
         ......
}

这里和文件读取的流程基本一样,也是通过 vfs_write 进入虚拟文件系统层。

ssize_t __vfs_write(struct file *file, const char __user *p, size_t count,
        loff_t *pos)
{
  if (file->f_op->write)
    return file->f_op->write(file, p, count, pos);
  else if (file->f_op->write_iter)
    return new_sync_write(file, p, count, pos);
  else
    return -EINVAL;
}

在虚拟文件系统层,通过 struct file 中定义的函数指针 file_operations 在具体的文件系统中执行相应的文件 IO 操作。我们还是以 ext4 文件系统为例。

struct file {
    const struct file_operations  *f_op;
}

在 ext4 文件系统中 .write_iter 函数指针指向的是 ext4_file_write_iter 函数执行具体的文件写入操作。

const struct file_operations ext4_file_operations = {

      ......省略........

      .read_iter  = ext4_file_read_iter,
      .write_iter  = ext4_file_write_iter,

      ......省略.........
}

从 Linux 内核角度探秘 JDK NIO 文件读写本质

由于 ext4_file_operations 中只定义了 .write_iter 函数指针,所以在 __vfs_write 函数中流程进入 else if {......} 分支来到 new_sync_write 函数中:

static ssize_t new_sync_write(struct file *filp, const char __user *buf, size_t len, loff_t *ppos)
{
    // 将 DirectByteBuffer 以及要写入的字节数封装进 iovec 结构体中
    struct iovec iov = { .iov_base = (void __user *)buf, .iov_len = len };
    // 用来封装文件 IO 相关操作的状态和进度信息:
    struct kiocb kiocb;
    // 用来封装用用户缓存区 DirectByteBuffer 的相关的信息
    struct iov_iter iter;
    ssize_t ret;
    // 利用文件 struct file 初始化 kiocb 结构体
    init_sync_kiocb(&kiocb, filp);
    // 设置文件写入偏移位置
    kiocb.ki_pos = (ppos ? *ppos : 0);
    iov_iter_init(&iter, WRITE, &iov, 1, len);
    // 调用 ext4_file_write_iter
    ret = call_write_iter(filp, &kiocb, &iter);
    BUG_ON(ret == -EIOCBQUEUED);
    if (ret > 0 && ppos)
        *ppos = kiocb.ki_pos;
    return ret;
}

在文件读取的相关章节中,我们介绍了用于封装传递进来的用户空间缓冲区 DirectByteBuffer 相关信息的 struct iovec 结构体,也介绍了用于封装文件 IO 相关操作的状态和进度信息的 struct kiocb 结构体,这里笔者不在赘述。

不过在这里笔者还是想强调的一下,内核中一般会使用 struct iov_iter 结构体对 struct iovec 进行包装,iov_iter 中包含多个 iovec。


struct iov_iter {
        ......省略.....
    const struct iovec *iov; 
}

这是为了兼容 readv() ,writev() 等系统调用,它允许用户使用多个缓存区去读取文件中的数据或者从多个缓冲区中写入数据到文件中。

  • JDK NIO Channel 支持的 Scatter 操作底层原理就是 readv 系统调用。
  • JDK NIO Channel 支持的 Gather 操作底层原理就是 writev 系统调用。
       FileChannel fileChannel = new RandomAccessFile(new File("file-read-write.txt"), "rw").getChannel();

       ByteBuffer  heapByteBuffer1 = ByteBuffer.allocate(4096);
       ByteBuffer  heapByteBuffer2 = ByteBuffer.allocate(4096);

       ByteBuffer[] gather = { heapByteBuffer1, heapByteBuffer2 };

       fileChannel.write(gather);

最终在 call_write_iter 中触发 ext4_file_write_iter 的调用,从虚拟文件系统层进入到具体文件系统 ext4 中。

static inline ssize_t call_write_iter(struct file *file, struct kiocb *kio,
                      struct iov_iter *iter)
{
    return file->f_op->write_iter(kio, iter);
}
static ssize_t
ext4_file_write_iter(struct kiocb *iocb, struct iov_iter *from)
{
        ..........省略..........
    ret = __generic_file_write_iter(iocb, from);
    return ret;
}

我们看到在文件系统 ext4 中调用的是 __generic_file_write_iter 方法。内核针对文件写入的所有逻辑都封装在这里。

ssize_t __generic_file_write_iter(struct kiocb *iocb, struct iov_iter *from)
{
    struct file *file = iocb->ki_filp;
    struct address_space * mapping = file->f_mapping;
    struct inode     *inode = mapping->host;
    ssize_t        written = 0;
    ssize_t        err;
    ssize_t        status;

        ........省略基本校验逻辑和更新文件原数据逻辑........

    if (iocb->ki_flags & IOCB_DIRECT) {
        loff_t pos, endbyte;
        // Direct IO
        written = generic_file_direct_write(iocb, from);
            .......省略......
    } else {
        // Buffered IO
        written = generic_perform_write(file, from, iocb->ki_pos);
        if (likely(written > 0))
            iocb->ki_pos += written;
    }
           .......省略......
    // 返回写入文件的字节数 或者 错误
    return written ? written : err;
}

这里和我们在介绍文件读取时候提到的 generic_file_read_iter 函数中的逻辑是一样的。都会处理 Direct IO 和 Buffered IO 的场景。

这里对于 Direct IO 的处理都是一样的,在 generic_file_direct_write 中也是会调用 address_space 中的 address_space_operations 定义的 .direct_IO 函数指针来绕过 page cache 直接写入磁盘。

struct address_space {
    const struct address_space_operations *a_ops;
}
written = mapping->a_ops->direct_IO(iocb, from);

从 Linux 内核角度探秘 JDK NIO 文件读写本质

在 ext4 文件系统中实现 Direct IO 的函数是 ext4_direct_IO,这里直接会调用到块设备驱动层,通过 do_blockdev_direct_IO 直接将用户空间缓冲区 DirectByteBuffer 中的内容写入磁盘中。do_blockdev_direct_IO 函数会等到所有的 Direct IO 写入到磁盘之后才会返回

static const struct address_space_operations ext4_aops = {
  .direct_IO  = ext4_direct_IO,
};

Direct IO 是由 DMA 直接从用户空间缓冲区 DirectByteBuffer 中拷贝到磁盘中。

下面我们主要介绍下 Buffered IO 的写入逻辑 generic_perform_write 方法。

12.1 Buffered IO

从 Linux 内核角度探秘 JDK NIO 文件读写本质

ssize_t generic_perform_write(struct file *file,
                struct iov_iter *i, loff_t pos)
{
    // 获取 page cache。数据将会被写入到这里
    struct address_space *mapping = file->f_mapping;
    // 获取 page cache 相关的操作函数
    const struct address_space_operations *a_ops = mapping->a_ops;
    long status = 0;
    ssize_t written = 0;
    unsigned int flags = 0;

    do {
        // 用于引用要写入的文件页
        struct page *page;
        // 要写入的文件页在 page cache 中的 index
        unsigned long offset;    /* Offset into pagecache page */
        unsigned long bytes;    /* Bytes to write to page */
        size_t copied;        /* Bytes copied from user */
    
        offset = (pos & (PAGE_SIZE - 1));
        bytes = min_t(unsigned long, PAGE_SIZE - offset,
                        iov_iter_count(i));

again:
        // 检查用户空间缓冲区 DirectByteBuffer 地址是否有效
        if (unlikely(iov_iter_fault_in_readable(i, bytes))) {
            status = -EFAULT;
            break;
        }
        // 从 page cache 中获取要写入的文件页并准备记录文件元数据日志工作
        status = a_ops->write_begin(file, mapping, pos, bytes, flags,
                        &page, &fsdata);
        // 将用户空间缓冲区 DirectByteBuffer 中的数据拷贝到 page cache 中的文件页中
        copied = iov_iter_copy_from_user_atomic(page, i, offset, bytes);
        flush_dcache_page(page);
       // 将写入的文件页标记为脏页并完成文件元数据日志的写入
        status = a_ops->write_end(file, mapping, pos, bytes, copied,
                        page, fsdata);
        // 更新文件 ppos
        pos += copied;
        written += copied;
        // 判断是否需要回写脏页
        balance_dirty_pages_ratelimited(mapping);
    } while (iov_iter_count(i));
    // 返回写入字节数
    return written ? written : status;
}

由于本文中笔者是以 ext4 文件系统为例来介绍文件的读写流程,本小节中介绍的文件写入流程涉及到与文件系统相关的两个操作:write_begin,write_end。这两个函数在不同的文件系统中都有不同的实现,在不同的文件系统中,写入每一个文件页都需要调用一次 write_begin,write_end 这两个方法。


static const struct address_space_operations ext4_aops = {
          ......省略.......
  .write_begin    = ext4_write_begin,
  .write_end    = ext4_write_end,
         ......省略.......
}

下图为本文中涉及文件读写的所有内核数据结构图:

从 Linux 内核角度探秘 JDK NIO 文件读写本质

经过前边介绍文件读取的章节我们知道在读取文件的时候都是先从 page cache 中读取,如果 page cache 正好缓存了文件页就直接返回。如果没有在进行磁盘 IO。

文件的写入过程也是一样,内核会将用户缓冲区 DirectByteBuffer 中的待写数据先拷贝到 page cache 中,写完就直接返回。后续内核会根据一定的规则把这些文件页回写到磁盘中。

从这个过程我们可以看出,内核将数据先是写入 page cache 中但是不会立刻写入磁盘中,如果突然断电或者系统崩溃就可能导致文件系统处于不一致的状态。

为了解决这种场景,于是 linux 内核引入了 ext3 , ext4 等日志文件系统。而日志文件系统比非日志文件系统在磁盘中多了一块 Journal 区域,Journal 区域就是存放管理文件元数据和文件数据操作日志的磁盘区域。

  • 文件元数据的日志用于恢复文件系统的一致性。
  • 文件数据的日志用于防止系统故障造成的文件内容损坏,

ext3 , ext4 等日志文件系统分为三种模式,我们可以在挂载的时候选择不同的模式。

  • 日志模式(Journal 模式):这种模式在将数据写入文件系统前,必须等待元数据和数据的日志已经落盘才能发挥作用。这样性能比较差,但是最安全。
  • 顺序模式(Order 模式): 在 Order 模式不会记录数据的日志,只会记录元数据的日志,但是在写元数据的日志前,必须先确保数据已经落盘。这样可以减少文件内容损坏的机会,这种模式是对性能的一种折中,是默认模式。
  • 回写模式(WriteBack 模式):WriteBack 模式 和 Order 模式一样它们都不会记录数据的日志,只会记录元数据的日志,不同的是在 WriteBack 模式下不会保证数据比元数据先落盘。这个性能最好,但是最不安全。

而 write_begin,write_end 正是对文件系统中相关日志的操作,在 ext4 文件系统中对应的是 ext4_write_begin,ext4_write_end。下面我们就来看一下在 Buffered IO 模式下对于 ext4 文件系统中的文件写入的核心步骤。

12.2 ext4_write_begin

static int ext4_write_begin(struct file *file, struct address_space *mapping,
                loff_t pos, unsigned len, unsigned flags,
                struct page **pagep, void **fsdata)
{
    struct inode *inode = mapping->host;
    struct page *page;
    pgoff_t index;

        ...........省略.......

retry_grab:
    // 从 page cache 中查找要写入文件页
    page = grab_cache_page_write_begin(mapping, index, flags);
    if (!page)
        return -ENOMEM;
    unlock_page(page);

retry_journal:
    // 相关日志的准备工作
    handle = ext4_journal_start(inode, EXT4_HT_WRITE_PAGE, needed_blocks);

         ...........省略.......

在写入文件数据之前,内核在 ext4_write_begin 方法中调用 ext4_journal_start 方法做一些相关日志的准备工作。

还有一个重要的事情是在 grab_cache_page_write_begin 方法中从 page cache 中根据 index 查找要写入数据的文件缓存页。


struct page *grab_cache_page_write_begin(struct address_space *mapping,
          pgoff_t index, unsigned flags)
{
  struct page *page;
  int fgp_flags = FGP_LOCK|FGP_WRITE|FGP_CREAT;
  // 在 page cache 中查找写入数据的缓存页
  page = pagecache_get_page(mapping, index, fgp_flags,
      mapping_gfp_mask(mapping));
  if (page)
    wait_for_stable_page(page);
  return page;
}

通过 pagecache_get_page 在 page cache 中查找要写入数据的缓存页。如果缓存页不在 page cache 中,内核则会首先会在物理内存中分配一个内存页,然后将新分配的内存页加入到 page cache 中。

相关的查找过程笔者已经在 《8. page cache 中查找缓存页》小节中详细介绍过了,这里不在赘述。

12.3 iov_iter_copy_from_user_atomic

这里就是写入过程的关键所在,图中描述的 CPU 拷贝是将用户空间缓存区 DirectByteBuffer 中的待写入数据拷贝到内核里的 page cache 中,这个过程就发生在这里。


size_t iov_iter_copy_from_user_atomic(struct page *page,
    struct iov_iter *i, unsigned long offset, size_t bytes)
{
  // 将缓存页临时映射到内核虚拟地址空间的高端地址上
  char *kaddr = kmap_atomic(page), 
  *p = kaddr + offset;
  // 将用户缓存区 DirectByteBuffer 中的待写入数据拷贝到文件缓存页中
  iterate_all_kinds(i, bytes, v,
    copyin((p += v.iov_len) - v.iov_len, v.iov_base, v.iov_len),
    memcpy_from_page((p += v.bv_len) - v.bv_len, v.bv_page,
         v.bv_offset, v.bv_len),
    memcpy((p += v.iov_len) - v.iov_len, v.iov_base, v.iov_len)
  )
  // 解除内核虚拟地址空间与缓存页之间的临时映射,这里映射只是为了拷贝数据用
  kunmap_atomic(kaddr);
  return bytes;
}

但是这里不能直接进行拷贝,因为此时从 page cache 中取出的缓存页 page 是物理地址,而在内核中是不能够直接操作物理地址的,只能操作虚拟地址

那怎么办呢?所以就需要调用 kmap_atomic 将缓存页临时映射到内核空间的一段虚拟地址上,然后将用户空间缓存区 DirectByteBuffer 中的待写入数据通过这段映射的虚拟地址拷贝到 page cache 中的相应缓存页中。这时文件的写入操作就已经完成了。

从这里我们看出,内核对于文件的写入只是将数据写入到 page cache 中就完事了并没有真正地写入磁盘。

由于是临时映射,所以在拷贝完成之后,调用 kunmap_atomic 将这段映射再解除掉。

12.4 ext4_write_end

static int ext4_write_end(struct file *file,
              struct address_space *mapping,
              loff_t pos, unsigned len, unsigned copied,
              struct page *page, void *fsdata)
{
        handle_t *handle = ext4_journal_current_handle();
        struct inode *inode = mapping->host;

        ......省略.......
        // 将写入的缓存页在 page cache 中标记为脏页
        copied = block_write_end(file, mapping, pos, len, copied, page, fsdata);
        
        ......省略.......
        // 完成相关日志的写入
        ret2 = ext4_journal_stop(handle);

        ......省略.......
}

在这里会对文件的写入流程做一些收尾的工作,比如在 block_write_end 方法中会调用 mark_buffer_dirty 将写入的缓存页在 page cache 中标记为脏页。后续内核会根据一定的规则将 page cache 中的这些脏页回写进磁盘中。

具体的标记过程笔者已经在《7.1 radix_tree 的标记》小节中详细介绍过了,这里不在赘述。

从 Linux 内核角度探秘 JDK NIO 文件读写本质

另一个核心的步骤就是调用 ext4_journal_stop 完成相关日志的写入。这里日志也只是会先写到缓存里,不会直接落盘。

12.5 balance_dirty_pages_ratelimited

当进程将待写数据写入 page cache 中之后,相应的缓存页就变为了脏页,我们需要找一个时机将这些脏页回写到磁盘中。防止断电导致数据丢失。

本小节我们主要聚焦于脏页回写的主体流程,相应细节部分以及内核对脏页的回写时机我们放在下一小节中在详细为大家介绍。

void balance_dirty_pages_ratelimited(struct address_space *mapping)
{
  struct inode *inode = mapping->host;
  struct backing_dev_info *bdi = inode_to_bdi(inode);
  struct bdi_writeback *wb = NULL;
  int ratelimit;
    ......省略......
  if (unlikely(current->nr_dirtied >= ratelimit))
    balance_dirty_pages(mapping, wb, current->nr_dirtied);
   ......省略......
}

在 balance_dirty_pages_ratelimited 会判断如果脏页数量在内存中达到了一定的规模 ratelimit 就会触发 balance_dirty_pages 回写脏页逻辑。

static void balance_dirty_pages(struct address_space *mapping,
                struct bdi_writeback *wb,
                unsigned long pages_dirtied)
{
    .......根据内核异步回写阈值判断是否需要唤醒 flusher 线程异步回写脏页...

    if (nr_reclaimable > gdtc->bg_thresh)
        wb_start_background_writeback(wb);
}

如果达到了脏页回写的条件,那么内核就会唤醒 flusher 线程去将这些脏页异步回写到磁盘中。

void wb_start_background_writeback(struct bdi_writeback *wb)
{
  /*
   * We just wake up the flusher thread. It will perform background
   * writeback as soon as there is no other work to do.
   */
  wb_wakeup(wb);
}

13. 内核回写脏页的触发时机

经过前边对文件写入过程的介绍我们看到,用户进程在对文件进行写操作的时候只是将待写入数据从用户空间的缓冲区 DirectByteBuffer 写入到内核中的 page cache 中就结束了。后面内核会对脏页进行延时写入到磁盘中。

当 page cache 中的缓存页比磁盘中对应的文件页的数据要新时,就称这些缓存页为脏页。

延时写入的好处就是进程可以多次频繁的对文件进行写入但都是写入到 page cache 中不会有任何磁盘 IO 发生。随后内核可以将进程的这些多次写入操作转换为一次磁盘 IO ,将这些写入的脏页一次性刷新回磁盘中,这样就把多次磁盘 IO 转换为一次磁盘 IO 极大地提升文件 IO 的性能。

那么内核在什么情况下才会去触发 page cache 中的脏页回写呢?

  1. 内核在初始化的时候,会创建一个 timer 定时器去定时唤醒内核 flusher 线程回写脏页。
  2. 当内存中脏页的数量太多了达到了一定的比例,就会主动唤醒内核中的 flusher 线程去回写脏页。
  3. 脏页在内存中停留的时间太久了,等到 flusher 线程下一次被唤醒的时候就会回写这些驻留太久的脏页。
  4. 用户进程可以通过 sync() 回写内存中的所有脏页和 fsync() 回写指定文件的所有脏页,这些是进程主动发起脏页回写请求。
  5. 在内存比较紧张的情况下,需要回收物理页或者将物理页中的内容 swap 到磁盘上时,如果发现通过页面置换算法置换出来的页是脏页,那么就会触发回写。

现在我们了解了内核回写脏页的一个大概时机,这里大家可能会问了:

  1. 内核通过 timer 定时唤醒 flush 线程回写脏页,那么到底间隔多久唤醒呢?
  2. 内存中的脏页数量太多会触发回写,那么这里的太多指的具体是多少呢?
  3. 脏页在内存中驻留太久也会触发回写,那么这里的太久指的到底是多久呢?

其实这三个问题中涉及到的具体数值,内核都提供了参数供我们来配置。这些参数的配置文件存在于 proc/sys/vm 目录下:

从 Linux 内核角度探秘 JDK NIO 文件读写本质

下面笔者就为大家介绍下内核回写脏页涉及到的这 6 个参数,并解答上面我们提出的这三个问题。

13.1 内核中的定时器间隔多久唤醒 flusher 线程

内核中通过 dirty_writeback_centisecs 参数来配置唤醒 flusher 线程的间隔时间。

从 Linux 内核角度探秘 JDK NIO 文件读写本质

该参数可以通过修改 /proc/sys/vm/dirty_writeback_centisecs 文件来配置参数,我们也可以通过 sysctl 命令或者通过修改 /etc/sysctl.conf 配置文件来对这些参数进行修改。

这里我们先主要关注这些内核参数的含义以及源码实现,文章后面笔者有一个专门的章节来介绍这些内核参数各种不同的配置方式。

dirty_writeback_centisecs 内核参数的默认值为 500。单位为 0.01 s。也就是说内核会每隔 5s 唤醒一次 flusher 线程来执行相关脏页的回写。该参数在内核源码中对应的变量名为 dirty_writeback_interval

笔者这里在列举一个生活中的例子来解释下这个 dirty_writeback_interval 的作用。

假设大家的工作都非常繁忙,于是大家就到家政公司请了专门的保洁阿姨(内核 flusher 回写线程)来帮助我们打扫房间卫生(回写脏页)。你和保洁阿姨约定每周(dirty_writeback_interval)来你房间(内存)打扫一次卫生(回写脏页),保洁阿姨会固定每周日按时来到你房间打扫。记住这个例子,我们后面还会用到~~~

13.2 内核中如何使用 dirty_writeback_interval 来控制 flusher 唤醒频率

在磁盘中数据是以块的形式存储于扇区中的,前边在介绍文件读写的章节中,读写流程的最后都会从文件系统层到块设备驱动层,由块设备驱动程序将数据写入对应的磁盘块中存储。

内存中的文件页对应于磁盘中的一个数据块,而这块磁盘就是我们常说的块设备。而每个块设备在内核中对应一个 backing_dev_info 结构用于存储相关信息。其中最重要的信息是 workqueue_struct *bdi_wq 用于缓存块设备上所有的回写脏页异步任务的队列。

/* bdi_wq serves all asynchronous writeback tasks */
struct workqueue_struct *bdi_wq;

static int __init default_bdi_init(void)
{
    int err;
    // 创建 bdi_wq 队列
    bdi_wq = alloc_workqueue("writeback", WQ_MEM_RECLAIM | WQ_FREEZABLE |
                          WQ_UNBOUND | WQ_SYSFS, 0);
    if (!bdi_wq)
        return -ENOMEM;
    // 初始化 backing_dev_info
    err = bdi_init(&noop_backing_dev_info);

    return err;
}

在系统启动的时候,内核会调用 default_bdi_init 来创建 bdi_wq 队列和初始化 backing_dev_info。

static int bdi_init(struct backing_dev_info *bdi)
{
    int ret;

    bdi->dev = NULL;
    // 初始化 backing_dev_info 相关信息
    kref_init(&bdi->refcnt);
    bdi->min_ratio = 0;
    bdi->max_ratio = 100;
    bdi->max_prop_frac = FPROP_FRAC_BASE;
    INIT_LIST_HEAD(&bdi->bdi_list);
    INIT_LIST_HEAD(&bdi->wb_list);
    init_waitqueue_head(&bdi->wb_waitq);
    // 这里会设置 flusher 线程的定时器 timer
    ret = cgwb_bdi_init(bdi);
    return ret;
}

在 bdi_init 中初始化 backing_dev_info 结构的相关信息,并在 cgwb_bdi_init 中调用 wb_init 初始化回写脏页任务 bdi_writeback *wb,并创建一个 timer 用于定时启动 flusher 线程。

static int wb_init(struct bdi_writeback *wb, struct backing_dev_info *bdi,
       int blkcg_id, gfp_t gfp)
{
  ......... 初始化 bdi_writeback 结构该结构表示回写脏页任务相关信息.....

  // 创建 timer 定时执行 flusher 线程
  INIT_DELAYED_WORK(&wb->dwork, wb_workfn);
  
   ......
}


#define __INIT_DELAYED_WORK(_work, _func, _tflags)      \
  do {                \
    INIT_WORK(&(_work)->work, (_func));      \
    __setup_timer(&(_work)->timer, delayed_work_timer_fn,  \
            (unsigned long)(_work),      \

bdi_writeback 有个成员变量 struct delayed_work dwork,bdi_writeback 就是把 delayed_work 结构挂到 bdi_wq 队列上的。

而 wb_workfn 函数则是 flusher 线程要执行的回写核心逻辑,全部封装在 wb_workfn 函数中。

/*
 * Handle writeback of dirty data for the device backed by this bdi. Also
 * reschedules periodically and does kupdated style flushing.
 */
void wb_workfn(struct work_struct *work)
{
    struct bdi_writeback *wb = container_of(to_delayed_work(work),
                        struct bdi_writeback, dwork);
    long pages_written;

    set_worker_desc("flush-%s", bdi_dev_name(wb->bdi));
    current->flags |= PF_SWAPWRITE;

        .......在循环中不断的回写脏页..........

     // 如果 work-list 中还有回写脏页的任务,则立即唤醒flush线程
    if (!list_empty(&wb->work_list))
        wb_wakeup(wb);
     // 如果回写任务已经被全部执行完毕,但是内存中还有脏页,则延时唤醒
    else if (wb_has_dirty_io(wb) && dirty_writeback_interval)
        wb_wakeup_delayed(wb);

    current->flags &= ~PF_SWAPWRITE;
}

在 wb_workfn 中会不断的循环执行 work_list 中的脏页回写任务。当这些回写任务执行完毕之后调用 wb_wakeup_delayed 延时唤醒 flusher线程。大家注意到这里的 dirty_writeback_interval 配置项终于出现了,后续会根据 dirty_writeback_interval 计算下次唤醒 flusher 线程的时机。


void wb_wakeup_delayed(struct bdi_writeback *wb)
{
    unsigned long timeout;

    // 使用 dirty_writeback_interval 配置设置下次唤醒时间 
    timeout = msecs_to_jiffies(dirty_writeback_interval * 10);
    spin_lock_bh(&wb->work_lock);
    if (test_bit(WB_registered, &wb->state))
        queue_delayed_work(bdi_wq, &wb->dwork, timeout);
    spin_unlock_bh(&wb->work_lock);
}

13.3 脏页数量多到什么程度会主动唤醒 flusher 线程

这一节的内容中涉及到四个内核参数分别是:

drity_background_ratio :当脏页数量在系统的可用内存 available 中占用的比例达到 drity_background_ratio 的配置值时,内核就会调用 wakeup_flusher_threads 来唤醒 flusher 线程异步回写脏页。默认值为:10。表示如果 page cache 中的脏页数量达到系统可用内存的 10% 的话,就主动唤醒 flusher 线程去回写脏页到磁盘。

从 Linux 内核角度探秘 JDK NIO 文件读写本质

系统的可用内存 = 空闲内存 + 可回收内存。可以通过 free 命令的 available 项查看。

从 Linux 内核角度探秘 JDK NIO 文件读写本质

dirty_background_bytes :如果 page cache 中脏页占用的内存用量绝对值达到指定的 dirty_background_bytes。内核就会调用 wakeup_flusher_threads 来唤醒 flusher 线程异步回写脏页。默认为:0。

从 Linux 内核角度探秘 JDK NIO 文件读写本质

dirty_background_bytes 的优先级大于 drity_background_ratio 的优先级。

dirty_ratio : dirty_background_ 相关的内核配置参数均是内核通过唤醒 flusher 线程来异步回写脏页。下面要介绍的 dirty_ 配置参数,均是由用户进程同步回写脏页。表示内存中的脏页太多了,用户进程自己都看不下去了,不用等内核 flusher 线程唤醒,用户进程自己主动去回写脏页到磁盘中。当脏页占用系统可用内存的比例达到 dirty_ratio 配置的值时,用户进程同步回写脏页。默认值为:20 。

从 Linux 内核角度探秘 JDK NIO 文件读写本质

dirty_bytes :如果 page cache 中脏页占用的内存用量绝对值达到指定的 dirty_bytes。用户进程同步回写脏页。默认值为:0。

_bytes 相关配置参数的优先级要大于 _ratio 相关配置参数。

从 Linux 内核角度探秘 JDK NIO 文件读写本质

我们继续使用上小节中保洁阿姨的例子说明:

之前你们已经约定好了,保洁阿姨会每周日固定(dirty_writeback_centisecs)来到你的房间打扫卫生(脏页),但是你周三回家的时候,发现屋子里太脏了,是在是脏到一定程度了(drity_background_ratio ,dirty_background_bytes),你实在是看不去了,这时你就不会等这周日(dirty_writeback_centisecs)保洁阿姨过来才打扫,你会直接给阿姨打电话让阿姨周三就来打扫一下(内核主动唤醒 flusher 线程异步回写脏页)。

还有一种更极端的情况就是,你的房间已经脏到很夸张的程度了(dirty_ratio ,dirty_byte)连你自己都忍不了了,于是你都不用等保洁阿姨了(内核 flusher 回写线程),你自己就乖乖的开始打扫房间卫生了。这就是用户进程同步回写脏页。

13.4 内核如何主动唤醒 flusher 线程

通过 《12.5 balance_dirty_pages_ratelimited》小节的介绍,我们知道在 generic_perform_write 函数的最后一步会调用 balance_dirty_pages_ratelimited 来判断是否要触发脏页回写。

void balance_dirty_pages_ratelimited(struct address_space *mapping)
{
        ................省略............

    if (unlikely(current->nr_dirtied >= ratelimit))
        balance_dirty_pages(mapping, wb, current->nr_dirtied);

    wb_put(wb);
}

这里会触发 balance_dirty_pages 函数进行脏页回写。

static void balance_dirty_pages(struct address_space *mapping,
                struct bdi_writeback *wb,
                unsigned long pages_dirtied)
{
        ..................省略.............

    for (;;) {
        // 获取系统可用内存
        gdtc->avail = global_dirtyable_memory();
        // 根据 *_ratio 或者 *_bytes 相关内核配置计算脏页回写触发的阈值
        domain_dirty_limits(gdtc);
                .............省略..........
     }

        .............省略..........

在 balance_dirty_pages 中首先通过 global_dirtyable_memory() 获取系统当前可用内存。在 domain_dirty_limits 函数中根据前边我们介绍的 _ratio 或者 _bytes 相关内核配置计算脏页回写触发的阈值。

static void domain_dirty_limits(struct dirty_throttle_control *dtc)
{
    // 获取可用内存
    const unsigned long available_memory = dtc->avail;
    // 封装触发脏页回写相关阈值信息
    struct dirty_throttle_control *gdtc = mdtc_gdtc(dtc);
    // 这里就是内核参数 dirty_bytes 指定的值
    unsigned long bytes = vm_dirty_bytes;
    // 内核参数 dirty_background_bytes 指定的值
    unsigned long bg_bytes = dirty_background_bytes;
    // 将内核参数 dirty_ratio 指定的值转换为以 页 为单位
    unsigned long ratio = (vm_dirty_ratio * PAGE_SIZE) / 100;
     // 将内核参数 dirty_background_ratio 指定的值转换为以 页 为单位
    unsigned long bg_ratio = (dirty_background_ratio * PAGE_SIZE) / 100;
     // 进程同步回写 dirty_* 相关阈值
    unsigned long thresh;
     // 内核异步回写 direty_background_* 相关阈值
    unsigned long bg_thresh;
    struct task_struct *tsk;

    if (gdtc) {
        // 系统可用内存
        unsigned long global_avail = gdtc->avail;
        // 这里可以看出 bytes 相关配置的优先级大于 ratio 相关配置的优先级
        if (bytes)
            // 将 bytes 相关的配置转换为以页为单位的内存占用比例ratio
            ratio = min(DIV_ROUND_UP(bytes, global_avail),
                    PAGE_SIZE);
        // 设置 dirty_backgound_* 相关阈值
        if (bg_bytes)
            bg_ratio = min(DIV_ROUND_UP(bg_bytes, global_avail),
                       PAGE_SIZE);
        bytes = bg_bytes = 0;
    }
        
    // 这里可以看出 bytes 相关配置的优先级大于 ratio 相关配置的优先级
    if (bytes)
        // 将 bytes 相关的配置转换为以页为单位的内存占用比例ratio
        thresh = DIV_ROUND_UP(bytes, PAGE_SIZE);
    else
        thresh = (ratio * available_memory) / PAGE_SIZE;
    // 设置 dirty_background_* 相关阈值
    if (bg_bytes)
         // 将 dirty_background_bytes 相关的配置转换为以页为单位的内存占用比例ratio
        bg_thresh = DIV_ROUND_UP(bg_bytes, PAGE_SIZE);
    else
        bg_thresh = (bg_ratio * available_memory) / PAGE_SIZE;

    // 保证异步回写 backgound 的相关阈值要比同步回写的阈值要低
    if (bg_thresh >= thresh)
        bg_thresh = thresh / 2;

    dtc->thresh = thresh;
    dtc->bg_thresh = bg_thresh;
        
        ..........省略..........
}

domain_dirty_limits 函数会分别计算用户进程同步回写脏页的相关阈值 thresh 以及内核异步回写脏页的相关阈值 bg_thresh。逻辑比较好懂,笔者将每一步的注释已经为大家标注出来了。这里只列出几个关键核心点:

  • 从源码中的 if (bytes) {....} else {.....} 分支以及 if (bg_bytes) {....} else {.....} 我们可以看出内核配置 _bytes 相关的优先级会高于 _ratio 相关配置的优先级。
  • *_bytes 相关配置我们只会指定脏页占用内存的 bytes 阈值,但在内核实现中会将其转换为 页 为单位。(每页 4K 大小)。
  • 内核中对于脏页回写阈值的判断是通过 ratio 比例来进行判断的。
  • 内核异步回写的阈值要小于进程同步回写的阈值,如果超过,那么内核异步回写的阈值将会被设置为进程通过回写的一半。
static void balance_dirty_pages(struct address_space *mapping,
                struct bdi_writeback *wb,
                unsigned long pages_dirtied)
{
        ..................省略.............

    for (;;) {
        // 获取系统可用内存
        gdtc->avail = global_dirtyable_memory();
        // 根据 *_ratio 或者 *_bytes 相关内核配置计算 脏页回写触发的阈值
        domain_dirty_limits(gdtc);
                .............省略..........
     }

    // 根据进程同步回写阈值判断是否需要进程直接同步回写脏页  
    if (writeback_in_progress(wb))
        return
    // 根据内核异步回写阈值判断是否需要唤醒flusher异步回写脏页
    if (nr_reclaimable > gdtc->bg_thresh)
        wb_start_background_writeback(wb);

如果是异步回写,内核则唤醒 flusher 线程开始异步回写脏页,直到脏页数量低于阈值或者全部回写到磁盘。

void wb_start_background_writeback(struct bdi_writeback *wb)
{
    /*
     * We just wake up the flusher thread. It will perform background
     * writeback as soon as there is no other work to do.
     */
    trace_writeback_wake_background(wb);
    wb_wakeup(wb);
}

13.5 脏页到底在内存中能驻留多久

内核为了避免 page cache 中的脏页在内存中长久的停留,所以会给脏页在内存中的驻留时间设置一定的期限,这个期限可由前边提到的 dirty_expire_centisecs 内核参数配置。默认为:3000。单位为:0.01 s。

从 Linux 内核角度探秘 JDK NIO 文件读写本质

也就是说在默认配置下,脏页在内存中的驻留时间为 30 s。超过 30 s 之后,flusher 线程将会在下次被唤醒的时候将这些脏页回写到磁盘中

这些过期的脏页最终会在 flusher 线程下一次被唤醒时候被 flusher 线程回写到磁盘中。而前边我们也多次提到过 flusher 线程执行逻辑全部封装在 wb_workfn 函数中。接下来的调用链为 wb_workfn->wb_do_writeback->wb_writeback。在 wb_writeback 中会判断根据 dirty_expire_interval 判断哪些是过期的脏页。

/*
 * Explicit flushing or periodic writeback of "old" data.
 *
 * Define "old": the first time one of an inode's pages is dirtied, we mark the
 * dirtying-time in the inode's address_space.  So this periodic writeback code
 * just walks the superblock inode list, writing back any inodes which are
 * older than a specific point in time.
 *
 * Try to run once per dirty_writeback_interval.  But if a writeback event
 * takes longer than a dirty_writeback_interval interval, then leave a
 * one-second gap.
 *
 * older_than_this takes precedence over nr_to_write.  So we'll only write back
 * all dirty pages if they are all attached to "old" mappings.
 */
static long wb_writeback(struct bdi_writeback *wb,
             struct wb_writeback_work *work)
{
        ........省略.......
    work->older_than_this = &oldest_jif;
    for (;;) {
                ........省略.......
        if (work->for_kupdate) {
            oldest_jif = jiffies -
                msecs_to_jiffies(dirty_expire_interval * 10);
        } else if (work->for_background)
            oldest_jif = jiffies;
        }
         ........省略.......
}

13.6 脏页回写参数的相关配置方式

前面的几个小节笔者结合内核源码实现为大家介绍了影响内核回写脏页时机的六个参数。

内核越频繁的触发脏页回写,数据的安全性就越高,但是同时系统性能会消耗很大。所以我们在日常工作中需要结合数据的安全性和 IO 性能综合考虑这六个内核参数的配置。

本小节笔者就为大家介绍一下配置这些内核参数的方式,前面的小节中也提到过,内核提供的这些参数存在于 proc/sys/vm 目录下。

从 Linux 内核角度探秘 JDK NIO 文件读写本质

比如我们直接将要配置的具体数值写入对应的配置文件中:

 echo "value" > /proc/sys/vm/dirty_background_ratio

我们还可以使用 sysctl 来对这些内核参数进行配置:

sysctl -w variable=value

sysctl 命令中定义的这些变量 variable 全部定义在内核 kernel/sysctl.c 源文件中。

  • 其中 .procname 定义的就是 sysctl 命令中指定的配置变量名字。
  • .data 定义的是内核源码中引用的变量名字。这在前边我们介绍内核代码的时候介绍过了。比如配置参数 dirty_writeback_centisecs 在内核源码中的变量名为 dirty_writeback_interval , dirty_ratio 在内核中的变量名为 vm_dirty_ratio。
static struct ctl_table vm_table[] = {

        ........省略........

    {
        .procname    = "dirty_background_ratio",
        .data        = &dirty_background_ratio,
        .maxlen        = sizeof(dirty_background_ratio),
        .mode        = 0644,
        .proc_handler    = dirty_background_ratio_handler,
        .extra1        = SYSCTL_ZERO,
        .extra2        = SYSCTL_ONE_HUNDRED,
    },
    {
        .procname    = "dirty_background_bytes",
        .data        = &dirty_background_bytes,
        .maxlen        = sizeof(dirty_background_bytes),
        .mode        = 0644,
        .proc_handler    = dirty_background_bytes_handler,
        .extra1        = SYSCTL_LONG_ONE,
    },
    {
        .procname    = "dirty_ratio",
        .data        = &vm_dirty_ratio,
        .maxlen        = sizeof(vm_dirty_ratio),
        .mode        = 0644,
        .proc_handler    = dirty_ratio_handler,
        .extra1        = SYSCTL_ZERO,
        .extra2        = SYSCTL_ONE_HUNDRED,
    },
    {
        .procname    = "dirty_bytes",
        .data        = &vm_dirty_bytes,
        .maxlen        = sizeof(vm_dirty_bytes),
        .mode        = 0644,
        .proc_handler    = dirty_bytes_handler,
        .extra1        = (void *)&dirty_bytes_min,
    },
    {
        .procname    = "dirty_writeback_centisecs",
        .data        = &dirty_writeback_interval,
        .maxlen        = sizeof(dirty_writeback_interval),
        .mode        = 0644,
        .proc_handler    = dirty_writeback_centisecs_handler,
    },
    {
        .procname    = "dirty_expire_centisecs",
        .data        = &dirty_expire_interval,
        .maxlen        = sizeof(dirty_expire_interval),
        .mode        = 0644,
        .proc_handler    = proc_dointvec_minmax,
        .extra1        = SYSCTL_ZERO,
    }

       ........省略........
}

而前边介绍的这两种配置方式全部是临时的,我们可以通过编辑 /etc/sysctl.conf 文件来永久的修改内核相关的配置。

我们也可以在目录 /etc/sysctl.d/下创建自定义的配置文件。

 vi /etc/sysctl.conf

/etc/sysctl.conf 文件中直接以 variable = value 的形式添加到文件的末尾。

从 Linux 内核角度探秘 JDK NIO 文件读写本质

最后调用 sysctl -p /etc/sysctl.conf 使 /etc/sysctl.conf 配置文件中新添加的那些配置生效。


总结

从 Linux 内核角度探秘 JDK NIO 文件读写本质

本文笔者带大家从 Linux 内核的角度详细解析了 JDK NIO 文件读写在 Buffered IO 以及 Direct IO 这两种模式下的内核源码实现,探秘了文件读写的本质。并对比了 Buffered IO 和 Direct IO 的不同之处以及各自的适用场景。

在这个过程中又详细地介绍了与 Buffered IO 密切相关的文件页高速缓存 page cache 在内核中的实现以及相关操作。

最后我们详细介绍了影响文件 IO 的两个关键步骤:文件预读和脏页回写的详细内核源码实现,以及内核中影响脏页回写时机的 6 个关键内核配置参数相关的实现及应用。

  • dirty_background_bytes
  • dirty_background_ratio
  • dirty_bytes
  • dirty_ratio
  • dirty_expire_centisecs
  • dirty_writeback_centisecs

以及关于内核参数的三种配置方式:

  • 通过直接修改 proc/sys/vm 目录下的相关参数配置文件。
  • 使用 sysctl 命令来对相关参数进行修改。
  • 通过编辑 /etc/sysctl.conf 文件来永久的修改内核相关配置。

好了,本文的内容到这里就结束了,能够看到这里的大家一定是个狠人儿,但是辛苦的付出总会有所收获,恭喜大家现在已经彻底打通了 Linux 文件操作相关知识的系统脉络。感谢大家的耐心观看,我们下篇文章见~~~

版权声明:程序员胖胖胖虎阿 发表于 2023年8月31日 上午4:24。
转载请注明:从 Linux 内核角度探秘 JDK NIO 文件读写本质 | 胖虎的工具箱-编程导航

相关文章

暂无评论

暂无评论...