博客记录-day030-乐观锁CAS、抽象队列同步器AQS+Linux 物理内存管理

148 阅读32分钟

一、沉默王二-并发编程

1、乐观锁CAS

CAS 的全称是:比较并交换(Compare And Swap)。在 CAS 中,有这样三个值:

  • V:要更新的变量(var)
  • E:预期值(expected)
  • N:新值(new)

比较并交换的过程如下:

判断 V 是否等于 E,如果等于,将 V 的值设置为 N;如果不等,说明已经有其它线程更新了 V,于是当前线程放弃更新,什么都不做。

这里的预期值 E 本质上指的是“旧值”

CAS(Compare-and-Swap)是一种乐观锁的实现方式,全称为“比较并交换”,是一种无锁的原子操作

在并发编程中,我们都知道i++操作是非线程安全的,这是因为 i++操作不是原子操作,我们之前在讲多线程带来了什么问题中有讲到,大家应该还记得吧?

如何保证原子性呢?

常见的做法就是加锁。

在 Java 中,我们可以使用 synchronized关键字和 CAS 来实现加锁效果。

synchronized 是悲观锁,尽管随着 JDK 版本的升级,synchronized 关键字已经“轻量级”了很多,但依然是悲观锁,线程开始执行第一步就要获取锁,一旦获得锁,其他的线程进入后就会阻塞并等待锁

CAS 是乐观锁,线程执行的时候不会加锁,它会假设此时没有冲突,然后完成某项操作;如果因为冲突失败了就重试,直到成功为止。

1.1 乐观锁与悲观锁

锁可以从不同的角度来分类。比如我们在前面讲 synchronized 四种锁状态的时候,提到过偏向锁、轻量级锁、重量级锁,对吧?乐观锁和悲观锁也是一种分类方式。

1.1.1 悲观锁

对于悲观锁来说,它总是认为每次访问共享资源时会发生冲突,所以必须对每次数据操作加上锁,以保证临界区的程序同一时间只能有一个线程在执行。

1.1.2 乐观锁

乐观锁,顾名思义,它是乐观派。乐观锁总是假设对共享资源的访问没有冲突,线程可以不停地执行,无需加锁也无需等待。一旦多个线程发生冲突,乐观锁通常使用一种称为 CAS 的技术来保证线程执行的安全性。

由于乐观锁假想操作中没有锁的存在,因此不太可能出现死锁的情况,换句话说,乐观锁天生免疫死锁

  • 乐观锁多用于“读多写少“的环境,避免频繁加锁影响性能;
  • 悲观锁多用于”写多读少“的环境,避免频繁失败和重试影响性能。

1.2 什么是 CAS

在 CAS 中,有这样三个值:

  • V:要更新的变量(var)
  • E:预期值(expected)
  • N:新值(new)

比较并交换的过程如下:

判断 V 是否等于 E,如果等于,将 V 的值设置为 N如果不等,说明已经有其它线程更新了 V,于是当前线程放弃更新,什么都不做。

这里的预期值 E 本质上指的是“旧值”

我们以一个简单的例子来解释这个过程:

  1. 如果有一个多个线程共享的变量i原本等于 5,我现在在线程 A 中,想把它设置为新的值 6;
  2. 我们使用 CAS 来做这个事情;
  3. 首先我们用 i 去与 5 对比,发现它等于 5,说明没有被其它线程改过,那我就把它设置为新的值 6,此次 CAS 成功,i的值被设置成了 6;
  4. 如果不等于 5,说明i被其它线程改过了(比如现在i的值为 2),那么我就什么也不做,此次 CAS 失败,i的值仍然为 2。

在这个例子中,i就是 V,5 就是 E,6 就是 N。

那有没有可能我在判断了i为 5 之后,正准备更新它的新值的时候,被其它线程更改了i的值呢?

不会的。因为CAS 是一种原子操作,它是一种系统原语,是一条 CPU 的原子指令,从 CPU 层面已经保证它的原子性。

当多个线程同时使用 CAS 操作一个变量时,只有一个会胜出,并成功更新,其余均会失败,但失败的线程并不会被挂起,仅是被告知失败,并且允许再次尝试,当然也允许失败的线程放弃操作。

1.3 CAS 的原理

前面提到,CAS 是一种原子操作。那么 Java 是怎样来使用 CAS 的呢?我们知道,在 Java 中,如果一个方法是 native 的,那 Java 就不负责具体实现它,而是交给底层的 JVM 使用 C 语言 或者 C++ 去实现。

Unsafe 对 CAS 的实现是通过 C++ 实现的,它的具体实现和操作系统、CPU 都有关系。

Linux 的 X86 下主要是通过cmpxchgl这个指令在 CPU 上完成 CAS 操作的,但在多处理器情况下,必须使用lock指令加锁来完成。当然,不同的操作系统和处理器在实现方式上肯定会有所不同。除了上面提到的方法,Unsafe 里面还有其它的方法。比如支持线程挂起和恢复的parkunpark 方法, LockSupport 类(后面会讲)底层就调用了这两个方法。还有支持反射操作的allocateInstance()方法。

1.4 CAS 如何实现原子操作?

上面介绍了 Unsafe 类的几个支持 CAS 的方法。那 Java 具体是如何通过这几个方法来实现原子操作的呢?

  • 原子更新基本类型
  • 原子更新数组
  • 原子更新引用
  • 原子更新字段(属性)

这里我们以AtomicInteger类的getAndAdd(int delta)方法为例,来看看 Java 是如何实现原子操作的。

先来看 getAndAdd 方法的源码:

public final int getAndAdd(int delta) {
    return unsafe.getAndAddInt(this, valueOffset, delta);
}

这里的 unsafe 其实就是一个Unsafe对象:

// setup to use Unsafe.compareAndSwapInt for updates
private static final Unsafe unsafe = Unsafe.getUnsafe();

所以,AtomicInteger类的getAndAdd()方法是通过调用Unsafe类的方法实现的:

public final int getAndAddInt(Object var1, long var2, int var4) {
    int var5;
    do {
        var5 = this.getIntVolatile(var1, var2);
    } while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));

    return var5;
}

让我们详细分析下这段代码,先看参数:

  • Object var1,这个参数代表你想要进行操作的对象。
  • long var2,这个参数是你想要操作的 var1 对象中的某个字段的偏移量。这个偏移量可以通过 Unsafe 类的 objectFieldOffset 方法获得。
  • int var4,这个参数是你想要增加的值。

再来看方法执行的过程:

  • 首先,在 do while 循环开始,通过this.getIntVolatile(var1, var2)获取当前对象指定字段的值,将其存入临时变量 var5 中。这里的 getIntVolatile 方法能保证读操作的可见性,即读取的结果是最新的写入结果,不会因为 JVM 的优化策略(如指令重排序)或者 CPU 的缓存导致读取到过期的数据。
  • 然后,执行compareAndSwapInt(var1, var2, var5, var5 + var4)进行 CAS 操作。如果对象 var1 在内存地址 var2 处的值等于预期值 var5,则将该位置的值更新为 var5 + var4,并返回 true;否则,不做任何操作并返回 false。
  • 如果 CAS 操作成功,说明我们成功地将 var1 对象的 var2 偏移量处的字段的值更新为 var5 + var4,并且这个更新操作是原子性的,因此我们跳出循环并返回原来的值 var5。
  • 如果 CAS 操作失败,说明在我们尝试更新值的时候,有其他线程修改了该字段的值,所以我们继续循环,重新获取该字段的值,然后再次尝试进行 CAS 操作。

这里使用的是do-while 循环。这种循环不多见,它的目的是保证循环体内的语句至少会被执行一遍。这样才能保证 return 的值是我们期望的值。

1.5 CAS 的三大问题

尽管 CAS 提供了一种有效的同步手段,但也存在一些问题,主要有以下三个:ABA 问题、长时间自旋、多个共享变量的原子操作

1.5.1 ABA 问题

所谓的 ABA 问题,就是一个值原来是 A,变成了 B,又变回了 A。这个时候使用 CAS 是检查不出变化的,但实际上却被更新了两次。

ABA 问题的解决思路是在变量前面追加上版本号或者时间戳。从 JDK 1.5 开始,JDK 的 atomic 包里提供了一个类AtomicStampedReference类来解决 ABA 问题。

这个类的compareAndSet方法的作用是首先检查当前引用是否等于预期引用,并且检查当前标志是否等于预期标志,如果二者都相等,才使用 CAS 设置为新的值和标志

先来看参数:

  • expectedReference:预期引用,也就是你认为原本应该在那个位置的引用。
  • newReference:新引用,如果预期引用正确,将被设置到该位置的新引用。
  • expectedStamp:预期标记,这是你认为原本应该在那个位置的标记。
  • newStamp:新标记,如果预期标记正确,将被设置到该位置的新标记。

执行流程:

①、Pair<V> current = pair; 这行代码获取当前的 pair 对象,其中包含了引用和标记。

②、接下来的 return 语句做了几个检查:

  • expectedReference == current.reference && expectedStamp == current.stamp:首先检查当前的引用和标记是否和预期的引用和标记相同。如果二者中有任何一个不同,这个方法就会返回 false。
  • 如果上述检查通过,也就是说当前的引用和标记与预期的相同,那么接下来就会检查新的引用和标记是否也与当前的相同。如果相同,那么实际上没有必要做任何改变,这个方法就会返回 true。
  • 如果新的引用或者标记与当前的不同,那么就会调用 casPair 方法来尝试更新 pair 对象。casPair 方法会尝试用 newReference 和 newStamp 创建的新的 Pair 对象替换当前的 pair 对象。如果替换成功,casPair 方法会返回 true;如果替换失败(也就是说在尝试替换的过程中,pair 对象已经被其他线程改变了),casPair 方法会返回 false
1.5.2 长时间自旋

CAS 多与自旋结合。如果自旋 CAS 长时间不成功,会占用大量的 CPU 资源。

解决思路是让 JVM 支持处理器提供的pause 指令

pause 指令能让自旋失败时 cpu 睡眠一小段时间再继续自旋,从而使得读操作的频率降低很多,为解决内存顺序冲突而导致的 CPU 流水线重排的代价也会小很多。

1.5.3 多个共享变量的原子操作

当对一个共享变量执行操作时,CAS 能够保证该变量的原子性。但是对于多个共享变量,CAS 就无法保证操作的原子性,这时通常有两种做法:

  1. 使用AtomicReference类保证对象之间的原子性,把多个变量放到一个对象里面进行 CAS 操作
  2. 使用锁。锁内的临界区代码可以保证只有当前线程能操作。

2、抽象队列同步器AQS

AQSAbstractQueuedSynchronizer的简称,即抽象队列同步器,从字面上可以这样理解:

  • 抽象:抽象类,只实现一些主要逻辑,有些方法由子类实现
  • 队列:使用先进先出(FIFO)的队列存储数据
  • 同步:实现了同步的功能。

那 AQS 有什么用呢?

AQS 是一个用来构建锁和同步器的框架,使用 AQS 能简单且高效地构造出应用广泛的同步器,比如我们后面会细讲的 ReentrantLock,Semaphore,ReentrantReadWriteLoc,SynchronousQueue,FutureTask 等等,都是基于 AQS 的。

当然了,我们也可以利用 AQS 轻松定制专属的同步器,只要实现它的几个protected方法就可以了。

2.1 AQS 的数据结构

AQS 内部使用了一个 volatile 的变量 state 来作为资源的标识

/**
 * The synchronization state.
 */
private volatile int state;

同时定义了几个获取和改变 state 的 protected 方法,子类可以覆盖这些方法来实现自己的逻辑:

getState()
setState()
compareAndSetState()

这三种操作均是原子操作,其中 compareAndSetState 的实现依赖于 Unsafe的 compareAndSwapInt() 方法。

AQS 内部使用了一个先进先出(FIFO)的双端队列,并使用了两个引用 head 和 tail 用于标识队列的头部和尾部。其数据结构如下图所示:

但它并不直接储存线程,而是储存拥有线程的 Node 节点。

2.2 AQS 的 Node 节点

资源有两种共享模式,或者说两种同步方式:

  • 独占模式(Exclusive)资源是独占的,一次只能有一个线程获取。如 ReentrantLock。
  • 共享模式(Share)同时可以被多个线程获取,具体的资源个数可以通过参数指定。如 Semaphore/CountDownLatch。

一般情况下,子类只需要根据需求实现其中一种模式就可以,当然也有同时实现两种模式的同步类,如 ReadWriteLock。

通过 Node 我们可以实现两种队列:

1)一是通过 prev 和 next 实现 CLH(Craig, Landin, and Hagersten)队列(线程同步队列、双向队列)。

在 CLH 锁中,每个等待的线程都会有一个关联的 Node,每个 Node 有一个 prev 和 next 指针。当一个线程尝试获取锁并失败时,它会将自己添加到队列的尾部并自旋,等待前一个节点的线程释放锁。

2)二是通过 nextWaiter 实现 Condition上的等待线程队列(单向队列),这个 Condition 主要用在 ReentrantLock 类中。

2.3 AQS 的源码解析

AQS 的设计是基于模板方法模式的,它有一些方法必须要子类去实现的,它们主要有:

  • isHeldExclusively()该线程是否正在独占资源。只有用到 condition 才需要去实现它。
  • tryAcquire(int)独占方式。尝试获取资源,成功则返回 true,失败则返回 false。
  • tryRelease(int)独占方式。尝试释放资源,成功则返回 true,失败则返回 false。
  • tryAcquireShared(int)共享方式。尝试获取资源。负数表示失败;0 表示成功,但没有剩余可用资源;正数表示成功,且有剩余资源。
  • tryReleaseShared(int)共享方式。尝试释放资源,如果释放后允许唤醒后续等待结点返回 true,否则返回 false。

这些方法虽然都是protected的,但是它们并没有在 AQS 具体实现,而是直接抛出异常

protected boolean tryAcquire(int arg) {
    throw new UnsupportedOperationException();
}

这里不使用抽象方法的目的是:避免强迫子类中把所有的抽象方法都实现一遍,减少无用功,这样子类只需要实现自己关心的抽象方法即可。

2.3.1 获取资源

获取资源的入口是 acquire(int arg)方法。arg 是要获取的资源个数,在独占模式下始终为 1。我们先来看看这个方法的逻辑:

public final void accquire(int arg) {
    // tryAcquire 再次尝试获取锁资源,如果尝试成功,返回true,尝试失败返回false
    if (!tryAcquire(arg) &&
        // 走到这,代表获取锁资源失败,需要将当前线程封装成一个Node,追加到AQS的队列中
        acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
        // 线程中断
        selfInterrupt();
}

首先调用tryAcquire 尝试去获取资源。前面提到了这个方法是在子类中具体实现的,可以直接进入 ReentrantLock 中 查看。

如果获取资源失败,就通过 addWaiter(Node.EXCLUSIVE) 方法把这个线程插入到等待队列中

需要注意的是由于 AQS 中会存在多个线程同时争夺资源的情况,因此肯定会出现多个线程同时插入节点的操作,在这里是通过 CAS 自旋的方式保证了操作的线程安全性

OK,现在回到最开始的 aquire 方法。现在通过 addWaiter 方法,已经把一个 Node 放到等待队列尾部了。而处于等待队列的结点是从头结点一个一个去获取资源的。

结点进入等待队列后,是调用 park 使它进入阻塞状态的。只有头结点的线程是处于活跃状态的

当然,获取资源的方法除了 acquire 外,还有以下三个:

  • acquireInterruptibly:申请可中断的资源(独占模式)
  • acquireShared:申请共享模式的资源
  • acquireSharedInterruptibly:申请可中断的资源(共享模式)

可中断的意思是,在线程中断时可能会抛出InterruptedException

总结起来的一个acquire流程图:

acquire流程

2.3.2 释放资源

释放资源相比于获取资源来说,会简单许多。在 AQS 中只有一小段实现。

java.util.concurrent.locks.ReentrantLock的实现中,tryRelease(arg)会减少持有锁的数量,如果持有锁的数量变为0,释放锁并返回true

如果tryRelease(arg)成功释放了锁,那么接下来会检查队列的头结点。如果头结点存在并且waitStatus不为0(这意味着有线程在等待),那么会调用unparkSuccessor(Node h)方法来唤醒等待的线程

二、小林-图解系统-Linux 物理内存管理

1、内核如何管理 NUMA 节点

在前边我们介绍物理内存模型和物理内存架构的时候提到过:在 NUMA 架构下,只有 DISCONTIGMEM 非连续内存模型和 SPARSEMEM 稀疏内存模型是可用的。而 UMA 架构下,前面介绍的三种内存模型均可以配置使用。

无论是 NUMA 架构还是 UMA 架构在内核中都是使用相同的数据结构来组织管理的,在内核的内存管理模块中会把 UMA 架构当做只有一个 NUMA 节点的伪 NUMA 架构。这样一来这两种架构模式就在内核中被统一管理起来。

下面我先从最顶层的设计开始为大家介绍一下内核是如何管理这些 NUMA 节点的~~

image.png

NUMA 节点中可能会包含多个 CPU,这些 CPU 均是物理 CPU,这点大家需要注意一下。

1.1 内核如何统一组织 NUMA 节点

首先我们来看第一个问题,在内核中是如何将这些 NUMA 节点统一管理起来的?

内核中使用了 struct pglist_data 这样的一个数据结构来描述 NUMA 节点在内核 2.4 版本之前,内核是使用一个 pgdat_list 单链表将这些 NUMA 节点串联起来的,单链表定义在 /include/linux/mmzone.h 文件中:

每个 NUMA 节点的数据结构 struct pglist_data 中有一个 next 指针,用于将这些 NUMA 节点串联起来形成 pgdat_list 单链表,链表的末尾节点 next 指针指向 NULL。

typedef struct pglist_data {
    struct pglist_data *pgdat_next;
}

在内核 2.4 之后的版本中,内核移除了 struct pglist_data 结构中的 pgdat_next 之指针, 同时也删除了 pgdat_list 单链表。取而代之的是,内核使用了一个大小为 MAX_NUMNODES ,类型为 struct pglist_data 的全局数组 node_data[] 来管理所有的 NUMA 节点。

1.2 NUMA 节点物理内存区域的划分

我们都知道内核对物理内存的管理都是以页为最小单位来管理的,每页默认 4K 大小,理想状况下任何种类的数据都可以存放在任何页框中,没有什么限制。比如:存放内核数据,用户数据,磁盘缓冲数据等。

但是实际的计算机体系结构受到硬件方面的制约,间接导致限制了页框的使用方式。

比如在 X86 体系结构下,ISA 总线的 DMA (直接内存存取)控制器,只能对内存的前16M 进行寻址,这就导致了 ISA 设备不能在整个 32 位地址空间中执行 DMA,只能使用物理内存的前 16M 进行 DMA 操作。

因此直接映射区的前 16M 专门让内核用来为 DMA 分配内存,这块 16M 大小的内存区域我们称之为 ZONE_DMA。

用于 DMA 的内存必须从 ZONE_DMA 区域中分配。

image.png

而直接映射区中剩下的部分也就是从 16M 到 896M(不包含 896M)这段区域,我们称之为 ZONE_NORMAL。从字面意义上我们可以了解到,这块区域包含的就是正常的页框(没有任何使用限制)。

所以内核会根据各个物理内存区域的功能不同将 NUMA 节点内的物理内存主要划分为以下四个物理内存区域

  1. ZONE_DMA:用于那些无法对全部物理内存进行寻址的硬件设备,进行 DMA 时的内存分配。例如前边介绍的 ISA 设备只能对物理内存的前 16M 进行寻址。该区域的长度依赖于具体的处理器类型。
  2. ZONE_DMA32:与 ZONE_DMA 区域类似,该区域内的物理页面可用于执行 DMA 操作,不同之处在于该区域是提供给 32 位设备(只能寻址 4G 物理内存)执行 DMA 操作时使用的。该区域只在 64 位系统中起作用,因为只有在 64 位系统中才会专门为 32 位设备提供专门的 DMA 区域。
  3. ZONE_NORMAL:这个区域的物理页都可以直接映射到内核中的虚拟内存,由于是线性映射,内核可以直接进行访问。
  4. ZONE_HIGHMEM:这个区域包含的物理页就是我们说的高端内存,内核不能直接访问这些物理页,这些物理页需要动态映射进内核虚拟内存空间中(非线性映射)。该区域只在 32 位系统中才会存在,因为 64 位系统中的内核虚拟内存空间太大了(128T),都可以进行直接映射。

1.3 NUMA 节点中的内存规整与回收

内存可以说是计算机系统中最为宝贵的资源了,再怎么多也不够用,当系统运行时间长了之后,难免会遇到内存紧张的时候,这时候就需要内核将那些不经常使用的内存页面回收起来,或者将那些可以迁移的页面进行内存规整,从而可以腾出连续的物理内存页面供内核分配。

内核会为每个 NUMA 节点分配一个 kswapd 进程用于回收不经常使用的页面,还会为每个 NUMA 节点分配一个kcompactd 进程用于内存的规整避免内存碎片。

1.4 NUMA 节点的状态 node_states

如果系统中的 NUMA 节点多于一个,内核会维护一个位图 node_states,用于维护各个 NUMA 节点的状态信息。

如果系统中只有一个 NUMA 节点,则没有节点位图。

在稀疏内存模型中,NUMA 节点的状态可以在系统运行的过程中随时切换 online ,offline 的状态,用来支持内存的热插拔。

image.png

2、内核如何管理 NUMA 节点中的物理内存区域

image.png

由于实际的计算机体系结构受到硬件方面的制约,间接限制了页框的使用方式。于是内核会根据各个物理内存区域的功能不同,将 NUMA 节点内的物理内存划分为:ZONE_DMA,ZONE_DMA32,ZONE_NORMAL,ZONE_HIGHMEM 这几个物理内存区域。

ZONE_MOVABLE 区域是内核从逻辑上的划分,区域中的物理页面来自于上述几个内存区域,目的是避免内存碎片和支持内存热插拔

2.1 物理内存区域中的预留内存

除了前边介绍的关于物理内存区域的这些基本信息之外,每个物理内存区域 struct zone 还为操作系统预留了一部分内存,这部分预留的物理内存用于内核的一些核心操作,这些操作无论如何是不允许内存分配失败的。

什么意思呢?内核中关于内存分配的场景无外乎有两种方式:

  1. 当进程请求内核分配内存时,如果此时内存比较充裕,那么进程的请求会被立刻满足,如果此时内存已经比较紧张,内核就需要将一部分不经常使用的内存进行回收,从而腾出一部分内存满足进程的内存分配的请求,在这个回收内存的过程中,进程会一直阻塞等待。
  2. 另一种内存分配场景,进程是不允许阻塞的,内存分配的请求必须马上得到满足,比如执行中断处理程序或者执行持有自旋锁等临界区内的代码时,进程就不允许睡眠,因为中断程序无法被重新调度。这时就需要内核提前为这些核心操作预留一部分内存,当内存紧张时,可以使用这部分预留的内存给这些操作分配。

那么什么是高位内存区域 ?什么是低位内存区域 ? 高位内存区域为什么会对低位内存区域进行侵占挤压呢 ?

因为物理内存区域比如前边介绍的 ZONE_DMA,ZONE_DMA32,ZONE_NORMAL,ZONE_HIGHMEM 这些都是针对物理内存进行的划分,所谓的低位内存区域和高位内存区域其实还是按照物理内存地址从低到高进行排列布局:

image.png

根据物理内存地址的高低,低位内存区域到高位内存区域的顺序依次是:ZONE_DMA,ZONE_DMA32,ZONE_NORMAL,ZONE_HIGHMEM。

高位内存区域为什么会对低位内存区域进行挤压呢 ?

一些用于特定功能的物理内存必须从特定的内存区域中进行分配,比如外设的 DMA 控制器就必须从 ZONE_DMA 或者 ZONE_DMA32 中分配内存。

但是一些用于常规用途的物理内存则可以从多个物理内存区域中进行分配,当 ZONE_HIGHMEM 区域中的内存不足时,内核可以从 ZONE_NORMAL 进行内存分配,ZONE_NORMAL 区域内存不足时可以进一步降级到 ZONE_DMA 区域进行分配。

而低位内存区域中的内存总是宝贵的,内核肯定希望这些用于常规用途的物理内存从常规内存区域中进行分配,这样能够节省 ZONE_DMA 区域中的物理内存保证 DMA 操作的内存使用需求,但是如果内存很紧张了,高位内存区域中的物理内存不够用了,那么内核就会去占用挤压其他内存区域中的物理内存从而满足内存分配的需求。

但是内核又不会允许高位内存区域对低位内存区域的无限制挤压占用,因为毕竟低位内存区域有它特定的用途,所以每个内存区域会给自己预留一定的内存,防止被高位内存区域挤压占用。而每个内存区域为自己预留的这部分内存就存储在 lowmem_reserve 数组中。

2.2 物理内存区域中的水位线

内存资源是系统中最宝贵的系统资源,是有限的。当内存资源紧张的时候,系统的应对方法无非就是三种:

  1. 产生 OOM,内核直接将系统中占用大量内存的进程,将 OOM 优先级最高的进程干掉,释放出这个进程占用的内存供其他更需要的进程分配使用。
  2. 内存回收,将不经常使用到的内存回收,腾挪出来的内存供更需要的进程分配使用。
  3. 内存规整,将可迁移的物理页面进行迁移规整,消除内存碎片。从而获得更大的一片连续物理内存空间供进程分配。

我们都知道,内核将物理内存划分成一页一页的单位进行管理(每页 4K 大小)。内存回收的单位也是按页来的在内核中,物理内存页有两种类型,针对这两种类型的物理内存页,内核会有不同的回收机制。

  • 第一种就是文件页,所谓文件页就是其物理内存页中的数据来自于磁盘中的文件,当我们进行文件读取的时候,内核会根据局部性原理将读取的磁盘数据缓存在 page cache 中,page cache 里存放的就是文件页。当进程再次读取读文件页中的数据时,内核直接会从 page cache 中获取并拷贝给进程,省去了读取磁盘的开销。

    对于文件页的回收通常会比较简单,因为文件页中的数据来自于磁盘,所以当回收文件页的时候直接回收就可以了,当进程再次读取文件页时,大不了再从磁盘中重新读取就是了。

    但是当进程已经对文件页进行修改过但还没来得及同步回磁盘,此时文件页就是脏页,不能直接进行回收,需要先将脏页回写到磁盘中才能进行回收

    我们可以在进程中通过 fsync() 系统调用将指定文件的所有脏页同步回写到磁盘,同时内核也会根据一定的条件唤醒专门用于回写脏页的 pflush 内核线程

  • 另外一种物理页类型是匿名页,所谓匿名页就是它背后并没有一个磁盘中的文件作为数据来源匿名页中的数据都是通过进程运行过程中产生的,比如我们应用程序中动态分配的堆内存

    当内存资源紧张需要对不经常使用的那些匿名页进行回收时,因为匿名页的背后没有一个磁盘中的文件做依托,所以匿名页不能像文件页那样直接回收,无论匿名页是不是脏页,都需要先将匿名页中的数据先保存在磁盘空间中,然后在对匿名页进行回收。

    并把释放出来的这部分内存分配给更需要的进程使用,当进程再次访问这块内存时,在重新把之前匿名页中的数据从磁盘空间中读取到内存就可以了,而这块磁盘空间可以是单独的一片磁盘分区(Swap 分区)或者是一个特殊的文件(Swap 文件)。匿名页的回收机制就是我们经常看到的 Swap 机制

    所谓的页面换出就是在 Swap 机制下,当内存资源紧张时,内核就会把不经常使用的这些匿名页中的数据写入到 Swap 分区或者 Swap 文件中。从而释放这些数据所占用的内存空间

    所谓的页面换入就是当进程再次访问那些被换出的数据时,内核会重新将这些数据从 Swap 分区或者 Swap 文件中读取到内存中来。

综上所述,物理内存区域中的内存回收分为文件页回收(通过 pflush 内核线程)和匿名页回收(通过 kswapd 内核进程)。Swap 机制主要针对的是匿名页回收。

image.png

  • 当该物理内存区域的剩余内存容量高于 _watermark[WMARK_HIGH] 时,说明此时该物理内存区域中的内存容量非常充足,内存分配完全没有压力。
  • 当剩余内存容量在 _watermark[WMARK_LOW] 与_watermark[WMARK_HIGH] 之间时,说明此时内存有一定的消耗但是还可以接受,能够继续满足进程的内存分配需求。
  • 当剩余内容容量在 _watermark[WMARK_MIN] 与 _watermark[WMARK_LOW] 之间时,说明此时内存容量已经有点危险了,内存分配面临一定的压力,但是还可以满足进程的内存分配要求,当给进程分配完内存之后,就会唤醒 kswapd 进程开始内存回收,直到剩余内存高于 _watermark[WMARK_HIGH] 为止。

在这种情况下,进程的内存分配会触发内存回收,但请求进程本身不会被阻塞,由内核的 kswapd 进程异步回收内存。

  • 当剩余内容容量低于 _watermark[WMARK_MIN] 时,说明此时的内容容量已经非常危险了,如果进程在这时请求内存分配,内核就会进行直接内存回收,这时请求进程会同步阻塞等待,直到内存回收完毕。

2.3 物理内存区域中的冷热页

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

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

CPU缓存结构.png

CPU 访问高速缓存的速度比访问内存的速度快大约10倍,引入高速缓存的目的在于消除CPU与内存之间的速度差距,CPU 用高速缓存来用来存放内存中的热点数据。

另外我们根据程序的时间局部性原理可以知道,内存的数据一旦被访问,那么它很有可能在短期内被再次访问,如果我们把经常访问的物理内存页缓存在 CPU 的高速缓存中,那么当进程再次访问的时候就会直接命中 CPU 的高速缓存,避免了进一步对内存的访问,极大提升了应用程序的性能。

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

本文我们的主题是 Linux 物理内存的管理,那么在 NUMA 内存架构下,这些 NUMA 节点中的物理内存区域 zone 管理的这些物理内存页,哪些是在 CPU 的高速缓存中?哪些又不在 CPU 的高速缓存中呢?内核如何来管理这些加载进 CPU 高速缓存中的物理内存页呢?

在 2.6.25 版本之前的内核源码中,物理内存区域 struct zone 包含了一个 struct per_cpu_pageset 类型的数组 pageset。其中内核关于冷热页的管理全部封装在 struct per_cpu_pageset 结构中。

因为每个 CPU 都有自己独立的高速缓存,所以每个 CPU 对应一个 per_cpu_pageset 结构,pageset 数组容量 NR_CPUS 是一个可以在编译期间配置的宏常数,表示内核可以支持的最大 CPU个数,注意该值并不是系统实际存在的 CPU 数量。

在 NUMA 内存架构下,每个物理内存区域都是属于一个特定的 NUMA 节点,NUMA 节点中包含了一个或者多个 CPU,NUMA 节点中的每个内存区域会关联到一个特定的 CPU 上,但 struct zone 结构中的 pageset 数组包含的是系统中所有 CPU 的高速缓存页

因为虽然一个内存区域关联到了 NUMA 节点中的一个特定 CPU 上,但是其他CPU 依然可以访问该内存区域中的物理内存页,因此其他 CPU 上的高速缓存仍然可以包含该内存区域中的物理内存页。

每个 CPU 都可以访问系统中的所有物理内存页,尽管访问速度不同(这在前边我们介绍 NUMA 架构的时候已经介绍过),因此特定的物理内存区域 struct zone 不仅要考虑到所属 NUMA 节点中相关的 CPU,还需要照顾到系统中的其他 CPU。

以上则是内核版本 2.6.25 之前管理 CPU 高速缓存冷热页的相关数据结构,我们看到在 2.6.25 之前,内核是使用两个 per_cpu_pages 结构来分别管理冷页和热页集合的

后来内核开发人员通过测试发现,用两个列表来管理冷热页,并不会比用一个列表集中管理冷热页带来任何的实质性好处,因此在内核版本 2.6.25 之后,将冷页和热页的管理合并在了一个列表中,热页放在列表的头部,冷页放在列表的尾部。