关于后端:从-Linux-内核角度探秘-JDK-NIO-文件读写本质

10次阅读

共计 59187 个字符,预计需要花费 148 分钟才能阅读完成。

1. 前言

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

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

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

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

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

当初咱们曾经相熟了 socket 文件的相干操作及其在内核中的实现,但笔者感觉这还不够,还是有必要在为大家介绍一下 JDK NIO 如何利用 ByteBuffer 对一般文件进行读写的相干原理及其实现,为大家彻底买通 Linux 文件操作相干常识的零碎脉络,于是就有了本文的内容。

上面就让咱们从一个一般的 IO 读写操作开始聊起吧~~~

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。

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

操作系统在治理内存的时候是将内存分为一页一页来治理的,每页大小为 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 这里,马上就要进入内核了。

上面咱们就来看一下当零碎调用 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 的功绩。在本文的前面,笔者会为大家具体的介绍这一部分内容。

如果咱们应用在上篇文章《一步一图带你深刻分析 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 读取文件的工作流程:

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 中最为常见的 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,

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

从图中咱们能够看到 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

咱们能够通过 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

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);
    }
}

到这里对于文件读取的两种模式 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 中的高速缓存是对内存热点数据的一个缓存。

而本文咱们探讨的主题是内存与磁盘之间的关系,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 在内核中只会有一个。

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 中的二维标记数组,用于标记子节点的状态。};

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 标记数组。

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

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

struct page {unsigned long flags;}

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

这样内核在 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 的。

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

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

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

顺次类推咱们能够失去不同的 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 数组下标。

如果一颗 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 到用户缓冲区的拷贝。

所以内核须要一套十分精细的预读算法来依据过程是程序读文件还是随机读文件来准确地调控预读的文件页数,或者间接敞开预读。

  • 过程在读取文件数据的时候都是逐页进行读取的,因而在预读文件页的时候内核并不会思考页内偏移,而是依据申请数据在文件外部的页偏移进行读取。
  • 如果过程继续的程序拜访一个文件,那么预读页数也会随着逐渐减少。
  • 当发现过程开始随机拜访文件了(以后拜访的文件页和最初一次拜访的文件页 offset 不是间断的),内核就会逐渐缩小预读页数或者彻底禁止预读。
  • 当内核发现过程再反复的拜访同一文件页时或者文件中的文件页曾经简直全副缓存在 page cache 中了,内核此时就会禁止预读。

以上几点就是内核的预读算法的外围逻辑,从这个预读逻辑中咱们能够看出,过程在进行文件读取的时候波及到两种不同类型的页面汇合,一个是过程能够申请的文件页(曾经缓存在 page cache 中的文件页),另一个是内核预读的文件页。

而内核也的确依照这两种页面汇合分为两个窗口:

  • 以后窗口(current window): 示意过程本次文件申请能够间接读取的页面汇合,这个汇合中的页面全副曾经缓存在 page cache 中,过程能够间接读取返回。以后窗口中蕴含过程本次申请的文件页以及上次内核预读的文件页汇合。示意过程本次能够从 page cache 间接获取的页面范畴。
  • 预读窗口(ahead window):预读窗口的页面都是内核正在预读的文件页,它们此时并不在 page cache 中。这些页面并不是过程申请的文件页,然而内核依据空间局部性原理假设它们迟早会被过程申请。预读窗口内的页面紧跟着以后窗口前面,并且内核会动静调整预读窗口的大小(有点相似于 TCP 中的滑动窗口)。

如果过程本次文件申请的第一页的 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。

本例 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 这里,马上就要进入内核了。

这一次咱们来看一下当零碎调用 write 发动之后,用户过程在内核态具体做了哪些事件?

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

当初让咱们再次进入内核,来看一下内核中具体是如何解决文件写入操作的,这个过程会比文件读取要简单很多,大家须要有点急躁~~

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

11.1 Buffered IO

应用 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)

在 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,

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

因为 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);

在 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

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,
         ...... 省略.......
}

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

通过前边介绍文件读取的章节咱们晓得在读取文件的时候都是先从 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 的标记》大节中具体介绍过了,这里不在赘述。

另一个外围的步骤就是调用 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 目录下:

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

13.1 内核中的定时器距离多久唤醒 flusher 线程

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

该参数能够通过批改 /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 线程去回写脏页到磁盘。

零碎的可用内存 = 闲暇内存 + 可回收内存。能够通过 free 命令的 available 项查看。

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

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

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

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

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

咱们持续应用上大节中保洁阿姨的例子阐明:

之前你们曾经约定好了,保洁阿姨会每周日固定(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。

也就是说在默认配置下,脏页在内存中的驻留工夫为 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 目录下。

比方咱们间接将要配置的具体数值写入对应的配置文件中:

 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 的模式增加到文件的开端。

最初调用 sysctl -p /etc/sysctl.conf 使 /etc/sysctl.conf 配置文件中新增加的那些配置失效。


总结

本文笔者带大家从 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 文件操作相干常识的零碎脉络。感激大家的急躁观看,咱们下篇文章见~~~

正文完
 0