博客记录-day042-通信工具类、Fork/Join+内核Page Cache

81 阅读33分钟

一、沉默王二-并发编程

1、通信工具类

JDK 中提供了一些并发编程中常用的通信工具类以供我们开发者使用它们都在 JUC 包下。先总体概括一下都有哪些工具类,它们有什么作用,然后再分别介绍它们的主要使用方法和原理。

作用
Semaphore限制线程的数量
Exchanger两个线程交换数据
CountDownLatch线程等待直到计数器减为 0 时开始工作
CyclicBarrier作用跟 CountDownLatch 类似,但是可以重复使用
Phaser增强的 CyclicBarrier

1.1 Semaphore

Semaphore 翻译过来是信号的意思。顾名思义,这个工具类提供的功能就是多个线程彼此“传信号”。而这个“信号”是一个int类型的数据,也可以看成是一种“资源”。

可以在构造方法中传入初始资源总数,以及是否使用“公平”的同步器。默认情况下,是非公平的

// 默认情况下使用非公平
public Semaphore(int permits) {
    sync = new NonfairSync(permits);
}

public Semaphore(int permits, boolean fair) {
    sync = fair ? new FairSync(permits) : new NonfairSync(permits);
}

最主要的方法是 acquire 方法和 release 方法。acquire()方法会申请一个 permit,而 release 方法会释放一个 permit。当然,你也可以申请多个 acquire(int permits)或者释放多个 release(int permits)

每次 acquire,permits 就会减少一个或者多个。如果减少到了 0,再有其他线程来 acquire,那就要阻塞这个线程直到有其它线程 release permit 为止。

1.1.1 Semaphore 原理

Semaphore 内部有一个继承了 AQS 的同步器 Sync,重写了tryAcquireShared方法。在这个方法里,会去尝试获取资源。

如果获取失败(想要的资源数量小于目前已有的资源数量),就会返回一个负数(代表尝试获取资源失败)。然后当前线程就会进入 AQS 的等待队列。

1.2 Exchanger

Exchanger 类用于两个线程交换数据。它支持泛型,也就是说你可以在两个线程之间传送任何数据。

先来一个案例看看如何使用,比如两个线程之间想要传送字符串:

public class ExchangerDemo {
    public static void main(String[] args) throws InterruptedException {
        Exchanger<String> exchanger = new Exchanger<>();

        new Thread(() -> {
            try {
                System.out.println("这是线程A,得到了另一个线程的数据:"
                        + exchanger.exchange("这是来自线程A的数据"));
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }).start();

        System.out.println("这个时候线程A是阻塞的,在等待线程B的数据");
        Thread.sleep(1000);

        new Thread(() -> {
            try {
                System.out.println("这是线程B,得到了另一个线程的数据:"
                        + exchanger.exchange("这是来自线程B的数据"));
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }).start();
    }
}

输出:

这个时候线程 A 是阻塞的,在等待线程 B 的数据  
这是线程 B,得到了另一个线程的数据:这是来自线程 A 的数据  
这是线程 A,得到了另一个线程的数据:这是来自线程 B 的数据

可以看到,当一个线程调用 exchange 方法后,会处于阻塞状态,只有当另一个线程也调用了 exchange 方法,它才会继续执行。

看源码可以发现它是使用 park/unpark 来实现等待状态切换的,但是在使用 park/unpark 方法之前,使用了 CAS 检查,估计是为了提高性能。

因为 Exchanger 支持泛型,所以我们可以传输任何的数据,比如 IO 流或者 IO 缓存。根据 JDK 里面注释的说法,可以总结为以下特性:

  • 此类提供对外的操作是同步的
  • 用于成对出现的线程之间交换数据
  • 可以视作双向的同步队列
  • 可应用于基因算法、流水线设计等场景。

Exchanger 类还有一个有超时参数的方法,如果在指定时间内没有另一个线程调用 exchange,就会抛出一个超时异常。

那么问题来了,Exchanger 只能是两个线程交换数据吗?那三个调用同一个实例的 exchange 方法会发生什么呢?答案是只有前两个线程会交换数据,第三个线程会进入阻塞状态。

需要注意的是,exchange 是可以重复使用的。也就是说。两个线程可以使用 Exchanger 在内存中不断地再交换数据。

1.3 CountDownLatch

先来解读一下 CountDownLatch 这个类名的意义。CountDown 代表计数递减,Latch 是“门闩”的意思。也有人把它称为“屏障”。而 CountDownLatch 这个类的作用也很贴合这个名字的意义,假设某个线程在执行任务之前,需要等待其它线程完成一些前置任务,必须等所有的前置任务都完成,才能开始执行本线程的任务。

CountDownLatch 的方法也很简单,如下:

// 构造方法:
public CountDownLatch(int count)

public void await() // 等待
public boolean await(long timeout, TimeUnit unit) // 超时等待
public void countDown() // count - 1
public long getCount() // 获取当前还有多少count

需要注意的是构造器中的计数值(count)实际上就是闭锁需要等待的线程数量。这个值只能被设置一次,而且 CountDownLatch没有提供任何机制去重新设置这个计数值

1.4 CyclicBarrier

CyclicBarrirer 从名字上来理解是“循环屏障”的意思。前面提到了 CountDownLatch 一旦计数值count被降为 0 后,就不能再重新设置了,它只能起一次“屏障”的作用。而 CyclicBarrier 拥有 CountDownLatch 的所有功能,还可以使用reset()方法重置屏障。

如果参与者(线程)在等待的过程中,Barrier 被破坏,就会抛出 BrokenBarrierException。可以用isBroken()方法检测 Barrier 是否被破坏。

  1. 如果有线程已经处于等待状态,调用 reset 方法会导致已经在等待的线程出现 BrokenBarrierException 异常。并且由于出现了 BrokenBarrierException,将会导致始终无法等待。
  2. 如果在等待的过程中,线程被中断,会抛出 InterruptedException 异常,并且这个异常会传播到其他所有的线程。
  3. 如果在执行屏障操作过程中发生异常,则该异常将传播到当前线程中,其他线程会抛出 BrokenBarrierException,屏障被损坏。
  4. 如果超出指定的等待时间,当前线程会抛出 TimeoutException 异常,其他线程会抛出 BrokenBarrierException 异常。
1.4.1 CyclicBarrier 案例

我们同样用玩游戏的例子。如果玩一个游戏有多个“关卡”,那使用 CountDownLatch 显然不太合适,因为需要为每个关卡都创建一个实例。那我们可以使用 CyclicBarrier 来实现每个关卡的数据加载等待功能。

public class CyclicBarrierDemo {
    // 静态内部类,实现Runnable接口
    static class PreTaskThread implements Runnable {

        private String task; // 任务名称
        private CyclicBarrier cyclicBarrier; // CyclicBarrier实例,用于线程同步

        // 构造函数,初始化任务名称和CyclicBarrier实例
        public PreTaskThread(String task, CyclicBarrier cyclicBarrier) {
            this.task = task;
            this.cyclicBarrier = cyclicBarrier;
        }

        // 重写run方法,实现线程执行逻辑
        @Override
        public void run() {
            for (int i = 1; i < 4; i++) {
                try {
                    // 创建Random实例,用于生成随机数
                    Random random = new Random(); 
                    // 线程随机休眠一段时间,模拟任务执行
                    Thread.sleep(random.nextInt(1000)); 
                    // 输出任务完成信息
                    System.out.println(String.format("关卡%d的任务%s完成", i, task)); 
                    // 等待所有线程到达屏障点
                    cyclicBarrier.await(); 
                    } catch (InterruptedException | BrokenBarrierException e) {
                    // 捕获并处理异常
                    e.printStackTrace();
                }
            }
        }
    }

    // 主方法,程序入口
    public static void main(String[] args) {
        // 创建CyclicBarrier实例,设置屏障点为3,并指定当所有线程到达屏障点后执行的任务
        CyclicBarrier cyclicBarrier = new CyclicBarrier(3, () -> {
            // 输出所有前置任务完成信息
            System.out.println("本关卡所有前置任务完成,开始游戏..."); 
           
        });

        // 创建并启动三个线程,分别执行不同的任务
        new Thread(new PreTaskThread("加载地图数据", cyclicBarrier)).start();
        new Thread(new PreTaskThread("加载人物模型", cyclicBarrier)).start();
        new Thread(new PreTaskThread("加载背景音乐", cyclicBarrier)).start();
    }
}

输出:

> 关卡 1 的任务加载地图数据完成  
> 关卡 1 的任务加载背景音乐完成  
> 关卡 1 的任务加载人物模型完成  
> 本关卡所有前置任务完成,开始游戏...  
> 关卡 2 的任务加载地图数据完成  
> 关卡 2 的任务加载背景音乐完成  
> 关卡 2 的任务加载人物模型完成  
> 本关卡所有前置任务完成,开始游戏...  
> 关卡 3 的任务加载人物模型完成  
> 关卡 3 的任务加载地图数据完成  
> 关卡 3 的任务加载背景音乐完成  
> 本关卡所有前置任务完成,开始游戏...

注意这里跟 CountDownLatch 的代码有一些不同。CyclicBarrier 没有分为await()countDown(),而是只有单独的一个await()方法。

一旦调用 await 方法的线程数量等于构造方法中传入的任务总量(这里是 3),就代表达到屏障了。CyclicBarrier 允许我们在达到屏障的时候可以执行一个任务,可以在构造方法传入一个 Runnable 类型的对象。

上述案例就是在达到屏障时,输出“本关卡所有前置任务完成,开始游戏...”。

// 构造方法
public CyclicBarrier(int parties) {
    this(parties, null);
}
public CyclicBarrier(int parties, Runnable barrierAction) {
    // 具体实现
}

CyclicBarrier 虽说功能与 CountDownLatch 类似,但是实现原理却完全不同,CyclicBarrier 内部使用的是 Lock + Condition 实现的等待/通知模式。

1.5 Phaser

Phaser 是 Java 7 中引入的一个并发同步工具,它提供了对动态数量的线程的同步能力,这与 CyclicBarrier 和 CountDownLatch 不同,因为它们都需要预先知道等待的线程数量。Phaser 是多阶段的,意味着它可以同步不同阶段的多个操作。

前面我们介绍了 CyclicBarrier,可以发现它在构造方法里传入了“任务总量”parties之后,就不能修改这个值了,并且每次调用await()方法也只能消耗一个parties计数。但 Phaser 可以动态地调整任务总量!

Phaser 是阶段性的,所以它有一个内部的阶段计数器。每当我们到达一个阶段的结尾时,Phaser 会自动前进到下一个阶段。

名词解释:

  • Party:Phaser 的上下文中,一个 party 可以是一个线程,也可以是一个任务。当我们在 Phaser 上注册一个 party 时,Phaser 会递增它的参与者数量。
  • arrive:对应一个 party 的状态,初始时是 unarrived,当调用arriveAndAwaitAdvance()或者 arriveAndDeregister()进入 arrive 状态,可以通过getUnarrivedParties()获取当前未到达的数量。
  • register:注册一个新的 party 到 Phaser。
  • deRegister:减少一个 party。
  • phase:阶段,当所有注册的 party 都 arrive 之后,将会调用 Phaser 的onAdvance()方法来判断是否要进入下一阶段。

Phaser 的终止有两种途径,Phaser 维护的线程执行完毕或者onAdvance()返回true

1.5.1 Phaser 案例

还是游戏的案例。假设我们游戏有三个关卡,但只有第一个关卡有新手教程,需要加载新手教程模块。但后面的第二个关卡和第三个关卡都不需要。我们可以用 Phaser 来做这个需求。

代码:

public class PhaserDemo {
    // 静态内部类,实现Runnable接口
    static class PreTaskThread implements Runnable {
        private String task; // 任务名称
        private Phaser phaser; // Phaser对象,用于同步
        // 构造函数,初始化任务名称和Phaser对象
        public PreTaskThread(String task, Phaser phaser) {
            this.task = task;
            this.phaser = phaser;
        }
        @Override
        public void run() {
            // 循环执行三个关卡
            for (int i = 1; i < 4; i++) {
                try {
                    // 第二次关卡起不加载NPC,跳过
                    if (i >= 2 && "加载新手教程".equals(task)) {
                        continue;
                    }
                    Random random = new Random(); // 创建随机数生成器
                    Thread.sleep(random.nextInt(1000)); // 随机休眠时间,模拟加载时间
                    // 输出当前关卡信息
                    System.out.println
                    (String.format("关卡%d,需要加载%d个模块,当前模块【%s】",
                            i, phaser.getRegisteredParties(), task));

                    // 从第二个关卡起,不加载NPC
                    if (i == 1 && "加载新手教程".equals(task)) {
                        System.out.println("下次关卡移除加载【新手教程】模块");
                        phaser.arriveAndDeregister(); // 移除一个模块
                    } else {
                        phaser.arriveAndAwaitAdvance(); // 等待其他线程到达
                    }
                } catch (InterruptedException e) {
                    e.printStackTrace(); // 打印异常信息
                }
            }
        }
    }

    public static void main(String[] args) {
        // 创建Phaser对象,初始注册4个参与者
        Phaser phaser = new Phaser(4) {
            @Override
            protected boolean onAdvance(int phase, int registeredParties) {
                // 输出关卡准备完成信息
                System.out.println(String.format("第%d次关卡准备完成", phase + 1));
                // 当关卡数为3或参与者为0时,返回true,表示结束
                return phase == 3 || registeredParties == 0;
            }
        };

        // 创建并启动四个线程,每个线程执行不同的任务
        new Thread(new PreTaskThread("加载地图数据", phaser)).start();
        new Thread(new PreTaskThread("加载人物模型", phaser)).start();
        new Thread(new PreTaskThread("加载背景音乐", phaser)).start();
        new Thread(new PreTaskThread("加载新手教程", phaser)).start();
    }
}

输出:

> 关卡 1,需要加载 4 个模块,当前模块【加载背景音乐】  
> 关卡 1,需要加载 4 个模块,当前模块【加载新手教程】  
> 下次关卡移除加载【新手教程】模块  
> 关卡 1,需要加载 3 个模块,当前模块【加载地图数据】  
> 关卡 1,需要加载 3 个模块,当前模块【加载人物模型】  
> 第 1 次关卡准备完成  
> 关卡 2,需要加载 3 个模块,当前模块【加载地图数据】  
> 关卡 2,需要加载 3 个模块,当前模块【加载背景音乐】  
> 关卡 2,需要加载 3 个模块,当前模块【加载人物模型】  
> 第 2 次关卡准备完成  
> 关卡 3,需要加载 3 个模块,当前模块【加载人物模型】  
> 关卡 3,需要加载 3 个模块,当前模块【加载地图数据】  
> 关卡 3,需要加载 3 个模块,当前模块【加载背景音乐】  
> 第 3 次关卡准备完成

这里要注意关卡 1 的输出,在“加载新手教程”线程中调用了arriveAndDeregister()减少一个 party 之后,后面的线程使用getRegisteredParties()得到的是已经被修改后的 parties 了。但是当前这个阶段(phase),仍然是需要 4 个 parties 都 arrive 才触发屏障的。从下一个阶段开始,才需要 3 个 parties 都 arrive 就触发屏障。

Phaser 类用来控制某个阶段的线程数量很有用,但它并不在意这个阶段具体有哪些线程 arrive,只要达到它当前阶段的 parties 值,就触发屏障。所以我这里的案例虽然制定了特定的线程(加载新手教程)来更直观地表述 Phaser 的功能,但其实 Phaser 是没有分辨具体是哪个线程的功能的,它在意的只是数量,这一点需要大家注意。

Phaser 类的原理相比起来要复杂得多。它内部使用了两个基于 Fork-Join 框架的原子类辅助

private final AtomicReference<QNode> evenQ;
private final AtomicReference<QNode> oddQ;

static final class QNode implements ForkJoinPool.ManagedBlocker {
	// 实现代码
}

2、Fork/Join

并发编程领域的任务可以分为三种:简单并行任务、聚合任务和批量并行任务,见下图。

这些模型之外,还有一种任务模型被称为“分治”。分治是一种解决复杂问题的思维方法和模式;具体而言,它将一个复杂的问题分解成多个相似的子问题,然后再将这些子问题进一步分解成更小的子问题,直到每个子问题变得足够简单从而可以直接求解。

从理论上讲,每个问题都对应着一个任务,因此分治实际上就是对任务的划分和组织。分治思想在许多领域都有广泛的应用。例如,在算法领域,我们经常使用分治算法来解决问题(如归并排序和快速排序都属于分治算法,二分查找也是一种分治算法)。

由于分治这种任务模型的普遍性,Java 并发包提供了一种名为 Fork/Join 的并行计算框架,专门用于支持分治任务模型的应用。

2.1 什么是分治任务模型

分治任务模型可分为两个阶段:一个阶段是 任务分解,就是迭代地将任务分解为子任务,直到子任务可以直接计算出结果;另一个阶段是 结果合并,即逐层合并子任务的执行结果,直到获得最终结果。下图是一个简化的分治任务模型图,你可以对照着理解。

在这个分治任务模型里,任务和分解后的子任务具有相似性,这种相似性往往体现在任务和子任务的算法是相同的,但是计算的数据规模是不同的。具备这种相似性的问题,我们往往都采用递归算法。

2.2 Fork/Join 的使用

Fork/Join 是一个并行计算框架,主要用于支持分治任务模型。在这个计算框架中,Fork 代表任务的分解,而 Join 代表结果的合并。

Fork/Join 计算框架主要由两部分组成:分治任务的线程池 ForkJoinPool 和分治任务 ForkJoinTask。

这两部分的关系类似于 ThreadPoolExecutor 和 Runnable 之间的关系,都是用于提交任务到线程池的,只不过分治任务有自己独特的类型 ForkJoinTask。

ForkJoinTask 是一个抽象类,其中有许多方法,其中最核心的是 fork()方法和 join()方法。fork 方法用于异步执行一个子任务,而 join 方法通过阻塞当前线程来等待子任务的执行结果。

ForkJoinTask 有两个子类:RecursiveAction 和 RecursiveTask。

从它们的名字就可以看出,都是通过递归的方式来处理分治任务的。这两个子类都定义了一个抽象方法 compute(),不同之处在于 RecursiveAction 的 compute 方法没有返回值,而 RecursiveTask 的 compute 方法有返回值。这两个子类也都是抽象类,在使用时需要创建自定义的子类来扩展功能。

2.3 ForkJoinPool

Fork/Join 并行计算的核心组件是 ForkJoinPool。下面简单介绍一下 ForkJoinPool 的工作原理。

当我们通过 ForkJoinPool 的 invoke 或 submit 方法提交任务时,ForkJoinPool 会根据一定的路由规则将任务分配到一个任务队列中。如果任务执行过程中创建了子任务,那么子任务会被提交到对应工作线程的任务队列中。

ForkJoinPool 中有一个数组形式的成员变量 workQueue[],其对应一个队列数组,每个队列对应一个消费线程。丢入线程池的任务,根据特定规则进行转发。

当工作线程的任务队列为空时,它是否无事可做呢?

不是的。ForkJoinPool 引入了一种称为"任务窃取"的机制。当工作线程空闲时,它可以从其他工作线程的任务队列中"窃取"任务。

例如,下图中线程 T2 的任务队列已经为空,它可以窃取线程 T1 任务队列中的任务。这样,所有的工作线程都能保持忙碌的状态。

ForkJoinPool 中的任务队列采用双端队列的形式。工作线程从任务队列的一个端获取任务,而"窃取任务"从另一端进行消费。这种设计能够避免许多不必要的数据竞争。

2.4 与ThreadPoolExecutor的比较

ForkJoinPool 与 ThreadPoolExecutor 有很多相似之处,例如都是线程池,都是用于执行任务的。但是,它们之间也有很多不同之处。

首先,ForkJoinPool 采用的是"工作窃取"的机制,而 ThreadPoolExecutor 采用的是"工作复用"的机制。这两种机制各有优劣,ForkJoinPool 的优势在于能够充分利用 CPU 的多核能力,而 ThreadPoolExecutor 的优势在于能够避免线程间的上下文切换

其次,ForkJoinPool 采用的是分治任务模型,而 ThreadPoolExecutor 采用的是简单并行任务模型。这两种任务模型各有优劣,ForkJoinPool 的优势在于能够处理分治任务,而 ThreadPoolExecutor 的优势在于能够处理简单并行任务。

最后,ForkJoinPool 采用的是 LIFO 的任务队列,而 ThreadPoolExecutor 采用的是 FIFO 的任务队列。这两种任务队列各有优劣,ForkJoinPool 的优势在于能够避免数据竞争,而 ThreadPoolExecutor 的优势在于能够保证任务的顺序性。

假设:我们要计算 1 到 1 亿的和,为了加快计算的速度,我们自然想到算法中的分治原理,将 1 亿个数字分成 1 万个任务,每个任务计算 1 万个数值的综合,利用 CPU 的并发计算性能缩短计算时间。

由于 ThreadPoolExecutor 可以通过 Future获取到执行结果,因此利用 ThreadPoolExecutor 也是可行的。

当然 ForkJoinPool 实现也是可以的。下面我们将这两种方式都实现一下,看看这两种实现方式有什么不同。

无论哪种实现方式,其大致思路都是:

  1. 按照线程池里线程个数 N,将 1 亿个数划分成 N 等份,随后丢入线程池进行计算。
  2. 每个计算任务使用 Future 接口获取计算结果,最后积加即可。

对比 ThreadPoolExecutor 和 ForkJoinPool 这两者的实现,可以发现它们都有任务拆分的逻辑,以及最终合并数值的逻辑。但 ForkJoinPool 相比 ThreadPoolExecutor 来说,做了一些实现上的封装,例如:

  • 不用手动去获取子任务的结果,而是使用 join 方法直接获取结果
  • 将任务拆分的逻辑,封装到 RecursiveTask 实现类中,而不是裸露在外。

因此对于没有父子任务依赖,但是希望获取到子任务执行结果的并行计算任务,就可以使用 ForkJoinPool 来实现。在这种情况下,使用 ForkJoinPool 实现更多是代码实现方便,封装做得更加好。

2.5 小结

Fork/Join 并行计算框架主要解决的是分治任务。分治的核心思想是“分而治之”:将一个大的任务拆分成小的子任务去解决,然后再把子任务的结果聚合起来从而得到最终结果。这个过程非常类似于大数据处理中的 MapReduce,所以你可以把 Fork/Join 看作单机版的 MapReduce。

Fork/Join 并行计算框架的核心组件是 ForkJoinPool。ForkJoinPool 支持任务窃取机制,能够让所有线程的工作量基本均衡,不会出现有的线程很忙,而有的线程很闲的状况,所以性能很好。

Java 1.8 提供的 Stream API 里面并行流也是以 ForkJoinPool 为基础的。不过需要注意的是,默认情况下所有的并行流计算都共享一个 ForkJoinPool,这个共享的 ForkJoinPool 默认的线程数是 CPU 的核数;如果所有的并行流计算都是 CPU 密集型计算的话,完全没有问题,但是如果存在 I/O 密集型的并行流计算,那么很可能会因为一个很慢的 I/O 计算而拖慢整个系统的性能。

所以 建议用不同的 ForkJoinPool 执行不同类型的计算任务

二、小林-图解系统-内核Page Cache

进程写文件(使用缓冲 IO)过程中,写一半的时候,进程发生了崩溃,已写入的数据会丢失吗?

答案,是不会的。因为进程在执行 write (使用缓冲 IO)系统调用的时候,实际上是将文件数据写到了内核的 page cache,它是文件系统中用于缓存文件数据的缓冲,所以即使进程崩溃了,文件数据还是保留在内核的 page cache,我们读数据的时候,也是从内核的 page cache 读取,因此还是依然读的进程崩溃前写入的数据。

内核会找个合适的时机,将 page cache 中的数据持久化到磁盘。但是如果 page cache 里的文件数据,在持久化到磁盘化到磁盘之前,系统发生了崩溃,那这部分数据就会丢失了。

1、Page Cache

1.1 Page Cache 是什么?

为了理解 Page Cache,我们不妨先看一下 Linux 的文件 I/O 系统,如下图所示:

上图中,红色部分为 Page Cache。可见 Page Cache 的本质是由 Linux 内核管理的内存区域。我们通过 mmap 以及 buffered I/O 将文件读取到内存空间实际上都是读取到 Page Cache 中。

1.2 page 与 Page Cache

page 是内存管理分配的基本单位, Page Cache 由多个 page 构成。page 在操作系统中通常为 4KB 大小(32bits/64bits),而 Page Cache 的大小则为 4KB 的整数倍。

另一方面,并不是所有 page 都被组织为 Page Cache

Linux 系统上供用户可访问的内存分为两个类型,即:

  • File-backed pages文件备份页也就是 Page Cache 中的 page,对应于磁盘上的若干数据块;对于这些页最大的问题是脏页回盘;
  • Anonymous pages匿名页不对应磁盘上的任何磁盘数据块,它们是进程的运行是内存空间(例如方法栈、局部变量表等属性);

为什么 Linux 不把 Page Cache 称为 block cache,这不是更好吗?

这是因为从磁盘中加载到内存的数据不仅仅放在 Page Cache 中,还放在 buffer cache 中。

下面比较一下 File-backed pages 与 Anonymous pages 在 Swap 机制下的性能。

内存是一种珍惜资源,当内存不够用时,内存管理单元(Memory Mangament Unit)需要提供调度算法来回收相关内存空间。内存空间回收的方式通常就是 swap,即交换到持久化存储设备上。

File-backed pages(Page Cache)的内存回收代价较低。Page Cache 通常对应于一个文件上的若干顺序块,因此可以通过顺序 I/O 的方式落盘。另一方面,如果 Page Cache 上没有进行写操作(所谓的没有脏页),甚至不会将 Page Cache 回盘,因为数据的内容完全可以通过再次读取磁盘文件得到。

Page Cache 的主要难点在于脏页回盘

Anonymous pages 的内存回收代价较高。因为 Anonymous pages 通常随机地写入持久化交换设备。另一方面,无论是否有写操作,为了确保数据不丢失,Anonymous pages 在 swap 时必须持久化到磁盘。

1.3 Swap 与缺页中断

Swap 机制指的是当物理内存不够用,内存管理单元(Memory Mangament Unit,MMU)需要提供调度算法来回收相关内存空间,然后将清理出来的内存空间给当前内存申请方。

Swap 机制存在的本质原因是 Linux 系统提供了虚拟内存管理机制,每一个进程认为其独占内存空间,因此所有进程的内存空间之和远远大于物理内存。所有进程的内存空间之和超过物理内存的部分就需要交换到磁盘上。

操作系统以 page 为单位管理内存,当进程发现需要访问的数据不在内存时,操作系统可能会将数据以页的方式加载到内存中。上述过程被称为缺页中断,当操作系统发生缺页中断时,就会通过系统调用将 page 再次读到内存中。

但主内存的空间是有限的,当主内存中不包含可以使用的空间时,操作系统会从选择合适的物理内存页驱逐回磁盘,为新的内存页让出位置,选择待驱逐页的过程在操作系统中叫做页面替换(Page Replacement) ,替换操作又会触发 swap 机制。

如果物理内存足够大,那么可能不需要 Swap 机制,但是 Swap 在这种情况下还是有一定优势:对于有发生内存泄漏几率的应用程序(进程),Swap 交换分区更是重要,这可以确保内存泄露不至于导致物理内存不够用,最终导致系统崩溃。但内存泄露会引起频繁的 swap,此时非常影响操作系统的性能。

Linux 通过一个 swappiness 参数来控制 Swap 机制:这个参数值可为 0-100,控制系统 swap 的优先级:

  • 高数值:较高频率的 swap,进程不活跃时主动将其转换出物理内存。
  • 低数值:较低频率的 swap,这可以确保交互式不因为内存空间频繁地交换到磁盘而提高响应延迟。

最后,为什么 SwapCached 也是 Page Cache 的一部分?

这是因为当匿名页(Inactive(anon) 以及 Active(anon))先被交换(swap out)到磁盘上后,然后再加载回(swap in)内存中,由于读入到内存后原来的 Swap File 还在,所以 SwapCached 也可以认为是 File-backed page,即属于 Page Cache。这个过程如下图所示。

图片

1.4 Page Cache 与 buffer cache

Page Cache 用于缓存文件的页数据,buffer cache 用于缓存块设备(如磁盘)的块数据。

  • 页是逻辑上的概念,因此 Page Cache 是与文件系统同级的;
  • 块是物理上的概念,因此 buffer cache 是与块设备驱动程序同级的。

Page Cache 与 buffer cache 的共同目的都是加速数据 I/O

  • 写数据时首先写到缓存,将写入的页标记为 dirty,然后向外部存储 flush,也就是缓存写机制中的 write-back(另一种是 write-through,Linux 默认情况下不采用);
  • 读数据时首先读取缓存,如果未命中,再去外部存储读取,并且将读取来的数据也加入缓存。操作系统总是积极地将所有空闲内存都用作 Page Cache 和 buffer cache,当内存不够用时也会用 LRU 等算法淘汰缓存页。

所以在 2.4 版本内核之后,两块缓存近似融合在了一起:如果一个文件的页加载到了 Page Cache,那么同时 buffer cache 只需要维护块指向页的指针就可以了。只有那些没有文件表示的块,或者绕过了文件系统直接操作(如dd命令)的块,才会真正放到 buffer cache 里。

因此,我们现在提起 Page Cache,基本上都同时指 Page Cache 和 buffer cache 两者,本文之后也不再区分,直接统称为 Page Cache

下图近似地示出 32-bit Linux 系统中可能的一种 Page Cache 结构,其中 block size 大小为 1KB,page size 大小为 4KB。

图片

Page Cache 中的每个文件都是一棵基数树(radix tree,本质上是多叉搜索树),树的每个节点都是一个页。根据文件内的偏移量就可以快速定位到所在的页。

1.5 Page Cache 与预读

操作系统为基于 Page Cache 的读缓存机制提供预读机制(PAGE_READAHEAD),一个例子是:

  • 用户线程仅仅请求读取磁盘上文件 A 的 offset 为 0-3KB 范围内的数据,由于磁盘的基本读写单位为 block(4KB),于是操作系统至少会读 0-4KB 的内容,这恰好可以在一个 page 中装下。
  • 但是操作系统出于局部性原理会选择将磁盘块 offset [4KB,8KB)、[8KB,12KB) 以及 [12KB,16KB) 都加载到内存,于是额外在内存中申请了 3 个 page

下图代表了操作系统的预读机制:

上图中,应用程序利用 read 系统调动读取 4KB 数据,实际上内核使用 readahead 机制完成了 16KB 数据的读取。

2、Page Cache 与文件持久化的一致性&可靠性

现代 Linux 的 Page Cache 正如其名,是对磁盘上 page(页)的内存缓存,同时可以用于读/写操作。

任何系统引入缓存,就会引发一致性问题:内存中的数据与磁盘中的数据不一致,例如常见后端架构中的 Redis 缓存与 MySQL 数据库就存在一致性问题。

Linux 提供多种机制来保证数据一致性,但无论是单机上的内存与磁盘一致性,还是分布式组件中节点 1 与节点 2 、节点 3 的数据一致性问题,理解的关键是 trade-off:吞吐量与数据一致性保证是一对矛盾。

首先,需要我们理解一下文件的数据。文件 = 数据 + 元数据。元数据用来描述文件的各种属性,也必须存储在磁盘上。因此,我们说保证文件一致性其实包含了两个方面:数据一致+元数据一致。

文件的元数据包括:文件大小、创建时间、访问时间、属主属组等信息。

我们考虑如下一致性问题:如果发生写操作并且对应的数据在 Page Cache 中,那么写操作就会直接作用于 Page Cache 中,此时如果数据还没刷新到磁盘,那么内存中的数据就领先于磁盘,此时对应 page 就被称为 Dirty page。

当前 Linux 下以两种方式实现文件一致性:

  1. Write Through(写穿) :向用户层提供特定接口,应用程序可主动调用接口来保证文件一致性;
  2. Write back(写回) :系统中存在定期任务(表现形式为内核线程),周期性地同步文件系统中文件脏数据块,这是默认的 Linux 一致性方案;

上述两种方式最终都依赖于系统调用,主要分为如下三种系统调用:

方法含义
fsync(intfd)fsync(fd):将 fd 代表的文件的脏数据和脏元数据全部刷新至磁盘中。
fdatasync(int fd)fdatasync(fd):将 fd 代表的文件的脏数据刷新至磁盘,同时对必要的元数据刷新至磁盘中,这里所说的必要的概念是指:对接下来访问文件有关键作用的信息,如文件大小,而文件修改时间等不属于必要信息
sync()sync():则是对系统中所有的脏的文件数据元数据刷新至磁盘中

上述三种系统调用可以分别由用户进程与内核进程发起。下面我们研究一下内核线程的相关特性。

  1. 创建的针对回写任务的内核线程数由系统中持久存储设备决定,为每个存储设备创建单独的刷新线程;

  2. 关于多线程的架构问题,Linux 内核采取了 Lighthttp 的做法,即系统中存在一个管理线程和多个刷新线程(每个持久存储设备对应一个刷新线程)。管理线程监控设备上的脏页面情况,若设备一段时间内没有产生脏页面,就销毁设备上的刷新线程;若监测到设备上有脏页面需要回写且尚未为该设备创建刷新线程,那么创建刷新线程处理脏页面回写。而刷新线程的任务较为单调,只负责将设备中的脏页面回写至持久存储设备中。

  3. 刷新线程刷新设备上脏页面大致设计如下:

    • 每个设备保存脏文件链表,保存的是该设备上存储的脏文件的 inode 节点。所谓的回写文件脏页面即回写该 inode 链表上的某些文件的脏页面;
    • 系统中存在多个回写时机,第一是应用程序主动调用回写接口(fsync,fdatasync 以及 sync 等),第二管理线程周期性地唤醒设备上的回写线程进行回写,第三是某些应用程序/内核任务发现内存不足时要回收部分缓存页面而事先进行脏页面回写,设计一个统一的框架来管理这些回写任务非常有必要。

Write Through 与 Write back 在持久化的可靠性上有所不同:

  • Write Through 以牺牲系统 I/O 吞吐量作为代价,向上层应用确保一旦写入,数据就已经落盘,不会丢失;
  • Write back 在系统发生宕机的情况下无法确保数据已经落盘,因此存在数据丢失的问题。不过,在程序挂了,例如被 kill -9,Page Cache 中的数据操作系统还是会确保落盘;

3、Page Cache 的优劣势

3.1 Page Cache 的优势

1.加快数据访问

如果数据能够在内存中进行缓存,那么下一次访问就不需要通过磁盘 I/O 了,直接命中内存缓存即可。

由于内存访问比磁盘访问快很多,因此加快数据访问是 Page Cache 的一大优势。

2.减少 I/O 次数,提高系统磁盘 I/O 吞吐量

得益于 Page Cache 的缓存以及预读能力,而程序又往往符合局部性原理,因此通过一次 I/O 将多个 page 装入 Page Cache 能够减少磁盘 I/O 次数, 进而提高系统磁盘 I/O 吞吐量。

3.2 Page Cache 的劣势

page cache 也有其劣势,最直接的缺点是需要占用额外物理内存空间,物理内存在比较紧俏的时候可能会导致频繁的 swap 操作,最终导致系统的磁盘 I/O 负载的上升。

Page Cache 的另一个缺陷是对应用层并没有提供很好的管理 API,几乎是透明管理。应用层即使想优化 Page Cache 的使用策略也很难进行。因此一些应用选择在用户空间实现自己的 page 管理,而不使用 page cache,例如 MySQL InnoDB 存储引擎以 16KB 的页进行管理。

Page Cache 最后一个缺陷是在某些应用场景下比 Direct I/O 多一次磁盘读 I/O 以及磁盘写 I/O。

Direct I/O 即直接 I/O。其名字中的”直接”二字用于区分使用 page cache 机制的缓存 I/O。

  • 缓存文件 I/O:用户空间要读写一个文件并不直接与磁盘交互,而是中间夹了一层缓存,即 page cache;
  • 直接文件 I/O:用户空间读取的文件直接与磁盘交互,没有中间 page cache 层;

“直接”在这里还有另一层语义:其他所有技术中,数据至少需要在内核空间存储一份,但是在 Direct I/O 技术中,数据直接存储在用户空间中,绕过了内核。

此时用户空间直接通过 DMA 的方式与磁盘以及网卡进行数据拷贝。

Direct I/O 的读写非常有特点

  • Write 操作:由于其不使用 page cache,所以其进行写文件,如果返回成功,数据就真的落盘了(不考虑磁盘自带的缓存);
  • Read 操作:由于其不使用 page cache,每次读操作是真的从磁盘中读取,不会从文件系统的缓存中读取。