淘汰算法

将计算机简单看作一个 「计算+存储」 的工具,那么 CPU 的主要作用就是 「计算」,硬盘的主要作用就是 「存储」。

计算需要从硬盘里将数据搬运到 CPU 中,但是硬盘的速度是在太慢,从而有了折中的「内存」。即能存储一定的数据,又比硬盘速度更快些,虽然对于 CPU 而言还是太慢了。

计算机将部分数据从硬盘搬到内存中,在 CPU 需要时可以快速给到 CPU,从而提高 CPU 使用率。

但是内存是有限的,她被发明的本意就是为了加快访问。计算机不可能同时将全部的程序都放在内存中,但是我们又需要同时运行许多程序,于是操作系统就站出来负责这些工作。

一台计算机只有 4G 的内存,但是却可以运行 8G 的程序,其原因是由于程序的局部性原理,我们不需要同时运行 8G 的程序。

我们可以将程序中目前需要的部分先加载到内存中,随着程序的运行访问到某个子程序不在内存中,这个时候再去硬盘中加载到内存,这称为「内存页面置换」,也就是操作系统的内存管理的内容之一。

站在操作系统的视角,现在 A 程序运行的时候需要有个子程序 A’ ,但不在内存中,毫无疑问需要去硬盘中加载。但是加载之前有个问题需要考虑:目前内存是否有足够空间容纳子程序 A’?

如果空间足够,那么加载进来就完事儿,如果不够怎么办?答案就是把一部分数据抱回硬盘,再把需要的子程序 A’ 抱进来内存中。

那么,把哪部分数据抱回去?也就是个淘汰谁的问题。于是诞生出许多的淘汰算法。这就是淘汰算法的由来。

!!! summary 由于资源的稀缺,我们建立一个兼顾速度和容量的中间件——内存。但是内存容量依然有限,而我们又有多程序同时运行的需求,所以操作系统站出来负责内存管理。

在进行内存管理的时候,需要将旧数据换下去,把新数据换上来,所以有了把谁换下去的问题,于是有了淘汰算法。

解决方案

淘汰算法不仅仅适用于操作系统的内存管理,同样适用其他空间不足时需要替换数据的场景中。

常见的淘汰算法有:

  • OPT (Optimum 最佳淘汰算法) (理想化,未来没准能实现)
  • FIFO (First In First Out 先进先出算法) (绝对公平,但不实用)
  • LRU (Least Recently Used 最近最少使用算法) (容易把热点数据淘汰掉)
  • LFU (Least Frequently Used 最近最不常使用算法) (实现比较复杂)
  • ARC (Adaptive Replacement Cache 自适应缓存替换算法) (结合了 LRU 和 LFU)

首先我们准备一个空队列,然后准备一组编号代表第几号页被访问的顺序:701203042303212

每当访问一个页面的时候,我们就将其编号入队,通过观察其位置来区别不同算法的差异。

LRU

LRU 不仅记录了编号,还记录了其上次被访问至当前的时长。但是我们可以通过每次访问都将该编号放到队头,这样队尾自然演化成上次访问时间最长的那些。

701203042303212备注
701203042303212队头
70120304230321
7012230422033队尾

前面三列可以看出,队列未满的时候, 7、0、1 依次入队,此时队头至队尾顺序为 107;

接着 2 入队,这时候 7 是距离入队时间(上次访问时间)最久的,所以被淘汰,于是队列成了 210;

然后 0 再次入队,因为 0 已经在队列中,所以把 0 调整到队头即可,此时队列成了 021;

然后 3 入队,此时 1 是距离入队时间最久的,所以被淘汰,于是队列成了 302,后面的情况也是如此。

这就是朴素的 LRU 算法,之所以实现简单就在于,虽然她带着一个「上次访问时间」的维度,但是这个维度可以通过将编号调整至队头,慢慢的队尾就是最久的一个。

那么实现起来只需要一个 链队 即可搞定。访问的编号在队中,只需要执行链表结点删除、插入队头两个操作即可;访问的编号不在队中,那么将队尾结点删除,插入队头两个操作即可。由于是链表,所以插入删除的时间复杂度仅为 O(1)。

但是要插入删除之前得先找到该结点,这样就得去遍历链表,时间复杂度是 O(n),这个问题也简单,只需要增加一个哈希表,在插入和删除的时候维护这个哈希表即可。

??? example

```go
package main

type Node struct {
    Pre, Next *Node
    key       int
    Data      any
}

type LRUCache struct {
    length   int
    capacity int
    H        map[int]*Node
    Head     *Node
    Tail     *Node
}

func NewLRU(capacity int) *LRUCache {
    return &LRUCache{
        H:        make(map[int]*Node, capacity),
        capacity: capacity,
    }
}

func (l *LRUCache) Get(idx int) *Node {
    node := l.H[idx]
    if node == nil {
        return nil
    }
    l.moveToHead(idx)
    return node
}

func (l *LRUCache) moveToHead(idx int) {
    node := l.H[idx]
    node.Next.Pre = node.Pre
    node.Pre.Next = node.Next
    first := l.Head
    first.Pre = node
    node.Next = first
    l.Head = node
}

func (l *LRUCache) eliminate() {
    if l.length < l.capacity {
        return
    }
    last := l.Tail
    if last == nil {
        return
    }
    // 清理map
    delete(l.H, last.key)
    // 断链
    l.Tail = last.Pre
    last.Pre.Next = nil
    last = nil
    // 长度减1
    l.length--
}

func (l *LRUCache) Put(idx int, data any) {
    node := &Node{
        key:  idx,
        Data: data,
    }
    // 数据存在,就放到队头,不存在,就淘汰一个再放队头
    if _, ok := l.H[idx]; ok {
        l.moveToHead(idx)
    }
    // 淘汰数据
    l.eliminate()
    l.put(node)
}

func (l *LRUCache) put(node *Node) {
    if l.Head == nil {
        l.Head = node
        l.Tail = node
        l.length++
        return
    }
    first := l.Head
    first.Pre = node
    node.Next = first
    l.Head = node
    l.H[node.key] = node
    l.length++
}
```
  • 优点 热点数据命中率高。

  • 缺点

朴素的 LRU 可能会导致热点数据被淘汰下去。例如,765432176576543243218,除了 1,其他都是反复被访问的页面,然后 1 就一直排在队尾,队列长这样 2345671。

然后访问了一下 1,变成 1234567,再访问下 8,这时候直接把 7 淘汰掉了,变成 8123456。而我们知道 7 的访问次数是比 1 高的。

这样就造成 7 这个热点数据被淘汰,而 1 这个冷数据却留下来了。

还有一种情况就是,当 1234567 的访问次数都很高,也就是他们都是热点数据,这时候 LRU 将失去优点。

LFU

LRU 记录了「上次访问时间」的维度,而 LFU 这是记录了「访问频率」的维度。