Skip to the content.

如何实现顺序读写

1. 为什么顺序读写更快

顺序读写快的原因主要有俩:

  1. 顺序写比随机写更节约磁盘指针的移动总长度;
  2. 顺序写最大化利用到了文件系统的缓存机制,提高了缓存命中率;

下面我们说一说原因 2 的由来:

PageCache

如上图所示,以顺序读为例,当用户发起一个 fileChannel.read(4kb) 之后,实际发生了两件事:

  1. 操作系统从磁盘加载了 16kb 进入 PageCache,这被称为预读
  2. 操作通从 PageCache 拷贝 4kb 进入用户内存;

最终我们在用户内存访问到了 4kb,为什么顺序读快?很容量想到,当用户继续访问接下来的 [4kb,16kb] 的磁盘内容时,便是直接从 PageCache 去访问了。试想一下,当需要访问 16kb 的磁盘内容时,是发生 4 次磁盘 I/O 快,还是发生 1 次磁盘 I/O+4 次内存 I/O 快呢?答案是显而易见的,这一切都是 PageCache 带来的优化。

深度思考:当内存吃紧时,PageCache 的分配会受影响吗?PageCache 的大小如何确定,是固定的 16kb 吗?我可以监控 PageCache 的命中情况吗? PageCache 会在哪些场景失效,如果失效了,我们又要哪些补救方式呢?

我进行简单的自问自答,背后的逻辑还需要读者去推敲:

顺序写的原理和顺序读一致,都是收到了 PageCache 的影响,留给读者自己推敲一下。

2. Java 上利用锁实现顺序读写

一个公认的事实是:无论是传统机械磁盘,还是固体硬盘,顺序读写比随机读写效率更高,那么如何实现顺序读写呢?

为了说明这个问题,我们首先需要分清读写过程涉及的两种顺序(以写操作为例):

  1. 应用层的顺序性:接收端先后接收到两个消息:消息A、消息B,我们要求磁盘最终落盘时,消息就是以 A、B 次序保存的;
  2. 磁盘指针的顺序性:如果有两个线程负责写入 I/O 操作,线程 1 负责写消息 B,线程 2 负责写消息 A,但如果要确保磁盘指针的顺序移动,在消息 A 必须先于消息 B 落盘的大前提下,必然要求线程 2 先写,线程 B 后写。

我们再举一个代码上的例子,这里没有应用层的顺序要求,只有磁盘指针的顺序要求。

写入方式一:64 个线程,用户自己使用一个 atomic 变量记录写入指针的位置,并发写入:

ExecutorService executor = Executors.newFixedThreadPool(64);//64 大小的线程池
AtomicLong wrotePosition = new AtomicLong(0);//指针
for(int i=0;i<1024;i++){
    final int index = i;
    executor.execute(()->{
        fileChannel.write(ByteBuffer.wrap(new byte[4*1024]),wrote.getAndAdd(4*1024));
    })
}

写入方式二:给 write 加了锁,保证了同步:

ExecutorService executor = Executors.newFixedThreadPool(64);
AtomicLong wrotePosition = new AtomicLong(0);
for(int i=0;i<1024;i++){
    final int index = i;
    executor.execute(()->{
        write(new byte[4*1024]);
    })
}

public synchronized void write(byte[] data){
    fileChannel.write(ByteBuffer.wrap(new byte[4*1024]),wrote.getAndAdd(4*1024));
}

只有方式二才算顺序写,顺序读也是同理。在方式 1 中,可能有如下顺序的写入:

所以,方式一并不是完全的“顺序写”。

对于文件操作,加锁并不是一件非常可怕的事,不敢同步 write/read 才可怕!

读时加锁 —> 读的阻塞耗时期间锁会迟迟不释放。但是即使如此,为了顺序 I/O,我们还是要使用锁机制。

有人会问:FileChannel 内部不是已经有 positionLock 保证写入的线程安全了吗,为什么还要自己加同步?

确实如此,但是 FileChannel#write 方法入口处的 position 生成(即wrotePosition.getAndAdd(4*1024))与 FileChannel#write 方法内部的上锁不是原子操作,此时如果不粗粒度地上锁,就会导致顺序写失效。

3. 扩展-锁

操作系统提供的 write 系统调用并没有确保原子语义,这意味着如果多个线程同时执行多个 write 操作来对同一个文件的同一个部分进行修改操作,那么最终执行结果取决于实际的执行逻辑顺序(这是无法预计的)。这种写场景类似于多线程访问共享变量,都是线程不安全的操作。

不过,文件系统提供两个操作的原子性,即当 open 系统调用以 flag 为 O_CREAT 或 O_APPEND 打开文件时。

不过,即使确保原子操作,也无法确保并发安全性,因为原子性只是并发安全的一个条件。

我们通常使用锁来实现并发安全性,例如 Linux 有提供两种类型的文件锁:

但在事实上,我们通常会避免使用操作系统提供的文件锁,而是要么选择避免多线程同时写一个文件的情况,要么在应用层上实现锁,比如 Java 代码中的 synchronized 关键字。

REFERENCE