Lru在Rust中的实现, 源码解析

lru,rust · 浏览次数 : 0

小编点评

LRU算法(Least Recently Used)是一种常用的页面置换算法,它的核心思想是选择最近最久未使用的页面予以淘汰。这个算法基于一个假设:如果一个数据在最近一段时间没有被访问到,那么在将来它被访问的可能性也很低。LRU算法通过维护一个队列或链表来实现,每次访问一个页面时,如果该页面已经在队列中,则将其移动到队列的头部;如果该页面不在队列中,则将其添加到队列的头部。当缓存空间不足时,算法会选择最久未使用的数据进行淘汰。 LRU算法的优点包括: 1. 能够利用时间局部性原理,保留最近使用过的页面,提高缓存命中率。 2. 算法简单,易于实现。 然而,LRU算法也有一些缺点: 1. 需要维护一个队列或数组,占用额外的空间。 2. 当页面访问模式具有循环周期时,LRU算法可能会淘汰掉正在使用的页面,导致缓存命中率下降。 3. 对于随机访问的页面输入序列,LRU算法的表现可能不如其他算法。 在实际应用中,LRU算法可以通过各种方式优化和改进,例如结合其他页面替换算法,如LRU-k和LFU(Least Frequently Used),以提高缓存的性能和命中率。

正文

LRU(Least Recently Used)是一种常用的页面置换算法,其核心思想是选择最近最久未使用的页面予以淘汰。

LRU算法原理

  • 基本思想:LRU算法基于一个假设,即如果一个数据在最近一段时间没有被访问到,那么在将来它被访问的可能性也很低。因此,当缓存空间不足时,算法会选择最久未使用的数据进行淘汰。

  • 实现方式:LRU算法通常通过维护一个队列或链表来实现。当访问一个页面时,如果该页面已经在队列中,则将其移动到队列的头部(最近使用);如果该页面不在队列中,则将其添加到队列的头部,并检查队列长度是否超过预设的阈值。如果队列长度超过阈值,则淘汰队列尾部的页面(最久未使用)。

LRU算法的优缺点

  • 优点
    • LRU算法能够利用时间局部性原理,保留最近使用过的页面,提高缓存命中率。
    • 算法简单,易于实现。
  • 缺点
    • 需要维护一个队列或数组,占用额外的空间。
    • 当页面访问模式具有循环周期时,LRU算法可能会淘汰掉正在使用的页面,导致缓存命中率下降。
    • 对于随机访问的页面输入序列,LRU算法的表现可能不如其他算法。

结构设计

在Lru的结构中,我们要避免key或者val的拷贝。
因为key此时需要在双向列表中保存也需要在HashMap中保存,所以我们要以下方案:

  • Rc<K>引用计数

通过引用计数来控制生命周期
优点:不用处理不安全的代码
缺点:因为Val可能在遍历中被更改,所以不能存储在双向列表里,取得值的时候需要进行一次Hash

  • *mut K 裸指针

通过unsafe编码来实现
优点:在双向列表及HashMap中均存储一份数值,遍历或者根据key取值均只需一次操作
缺点:需要引入ptr,即用指针的方式来进行生命周期管理

节点设计

此时我们用的是裸指针的方式,让我们先来定义节点数据,数据将存储在该节点里面,key及val的生命周期随节点管理,在删除的时候需同时在列表及在HashMap中进行删除

/// Lru节点数据
struct LruEntry<K, V> {
    /// 头部节点及尾部结点均未初始化值
    pub key: mem::MaybeUninit<K>,
    /// 头部节点及尾部结点均未初始化值
    pub val: mem::MaybeUninit<V>,
    pub prev: *mut LruEntry<K, V>,
    pub next: *mut LruEntry<K, V>,
}

类设计

接下来需要设计LruCache结构,将由一个map存储数据结构,一个双向链表存储访问优先级,cap表示缓存的容量。

pub struct LruCache<K, V, S> {
    /// 存储数据结构
    map: HashMap<KeyRef<K>, NonNull<LruEntry<K, V>>, S>,
    /// 缓存的总容量
    cap: usize,
    /// 双向列表的头
    head: *mut LruEntry<K, V>,
    /// 双向列表的尾
    tail: *mut LruEntry<K, V>,
}

其中KeyRef仅仅只是表示裸指针的一层包装,方便重新实现Hash,Eq等trait

#[derive(Clone)]
struct KeyRef<K> {
    pub k: *const K,
}

首先初始化对象,初始化map及空的双向链表:

impl<K, V, S> LruCache<K, V, S> {
    /// 提供hash函数
    pub fn with_hasher(cap: usize, hash_builder: S) -> LruCache<K, V, S> {
        let cap = cap.max(1);
        let map = HashMap::with_capacity_and_hasher(cap, hash_builder);
        let head = Box::into_raw(Box::new(LruEntry::new_empty()));
        let tail = Box::into_raw(Box::new(LruEntry::new_empty()));
        unsafe {
            (*head).next = tail;
            (*tail).prev = head;
        }
        Self {
            map,
            cap,
            head,
            tail,
        }
    }
}

元素插入

插入对象,分已在缓存内和不在缓存内:

pub fn capture_insert(&mut self, k: K, mut v: V) -> Option<(K, V)> {
    let key = KeyRef::new(&k);
    match self.map.get_mut(&key) {
        Some(entry) => {
            let entry_ptr = entry.as_ptr();
            unsafe {
                mem::swap(&mut *(*entry_ptr).val.as_mut_ptr(), &mut v);
            }
            self.detach(entry_ptr);
            self.attach(entry_ptr);

            Some((k, v))
        }
        None => {
            let (_, entry) = self.replace_or_create_node(k, v);
            let entry_ptr = entry.as_ptr();
            self.attach(entry_ptr);
            unsafe {
                self.map
                    .insert(KeyRef::new((*entry_ptr).key.as_ptr()), entry);
            }
            None
        }
    }
}

存在该元素时,将进行替换

unsafe {
    mem::swap(&mut *(*entry_ptr).val.as_mut_ptr(), &mut v);
}

并且重新维护访问队列,需要detach然后重新attach使其在队列的最前面,保证最近访问最晚淘汰,从而实现Lru。
如果元素不存在,那么将判断是否缓存队列为满,如果满了将要淘汰的数据进行替换,如果未满创建新的元素,即replace_or_create_node

元素删除

在将元素删除时,需要维护好我们的队列,防止出现队列错误及野指针等

pub fn remove<Q>(&mut self, k: &Q) -> Option<(K, V)>
    where
        K: Borrow<Q>,
        Q: Hash + Eq + ?Sized,
{
    match self.map.remove(KeyWrapper::from_ref(k)) {
        Some(l) => unsafe {
            self.detach(l.as_ptr());
            let node = *Box::from_raw(l.as_ptr());
            Some((node.key.assume_init(), node.val.assume_init()))
        },
        None => None,
    }
}

这里因为移除时,仅仅需要一个可以转化成K的引用即可以,并不需要严格的K,利于编程。例如:

let mut map = LruCache::new(2);
map.insert("aaaa".to_string(), "bbb");
map.remove("aaaa");
assert!(map.len() == 0);

在此处我们就不需要严格的构建String对象。由于map中的key我们用的是KeyRef,在这里,我们构建一个KeyWrapper进行转化。

// 确保新类型与其内部类型的内存布局完全相同
#[repr(transparent)]
struct KeyWrapper<Q: ?Sized>(Q);

impl<K, Q> Borrow<KeyWrapper<Q>> for KeyRef<K>
where
    K: Borrow<Q>,
    Q: ?Sized,
{
    fn borrow(&self) -> &KeyWrapper<Q> {
        let key = unsafe { &*self.k }.borrow();
        KeyWrapper::from_ref(key)
    }
}

如果移除成功,那么将从双向链表中同步移除,并且将指针中的值重新变成Rust中的对象,以便可以及时被drop,避免内存泄漏。

self.detach(l.as_ptr());
let node = *Box::from_raw(l.as_ptr());
Some((node.key.assume_init(), node.val.assume_init()))

其它操作

  • pop移除栈顶上的数据,最近使用的
  • pop_last移除栈尾上的数据,最久未被使用的
  • contains_key判断是否包含key值
  • raw_get直接获取key的值,不会触发双向链表的维护
  • get获取key的值,并维护双向链表
  • get_mut获取key的值,并可以根据需要改变val的值
  • retain 根据函数保留符合条件的元素

如何使用

在cargo.toml中添加

[dependencies]
algorithm = "0.1"
示例
use algorithm::LruCache;
fn main() {
    let mut lru = LruCache::new(3);
    lru.insert("now", "ok");
    lru.insert("hello", "algorithm");
    lru.insert("this", "lru");
    lru.insert("auth", "tickbh");
    assert!(lru.len() == 3);
    assert_eq!(lru.get("hello"), Some(&"algorithm"));
    assert_eq!(lru.get("this"), Some(&"lru"));
    assert_eq!(lru.get("now"), None);
}

完整项目地址

https://github.com/tickbh/algorithm-rs

结语

Lru在缓存场景中也是极其重要的一环,但是普通的Lru容易将热点数据进行移除,如果短时间内大量的数据进入则会将需要缓存的数据全部清空,后续将介绍改进算法Lru-kLfu算法。

与Lru在Rust中的实现, 源码解析相似的内容:

Lru在Rust中的实现, 源码解析

源码剖析-LRU(Least Recently Used)是一种常用的页面置换算法,其核心思想是选择最近最久未使用的页面予以淘汰。

带有ttl的Lru在Rust中的实现及源码解析

TTL是Time To Live的缩写,通常意味着元素的生存时间是多长。 应用场景 数据库:在redis中我们最常见的就是缓存我们的数据元素,但是我们又不想其保留太长的时间,因为数据时间越长污染的可能性就越大,我们又不想在后续的程序中设置删除,所以我们此时需要设置过期时间来让数据自动淘汰。 sete

Lru-k在Rust中的实现及源码解析

Lru-k与lru的区别在于多维护一个队列,及每个元素多维护一个次数选项,对于性能的影响不大,仅仅多耗一点cpu,但是可以相应的提高命中率,下一章将介绍LFU按频次的淘汰机制。

Lfu缓存在Rust中的实现及源码解析

综上所述,LFU算法通过跟踪数据项的访问频次来决定淘汰对象,适用于数据访问频率差异较大的场景。与LRU相比,LFU更能抵御偶发性的大量访问请求对缓存的冲击。然而,LFU的实现较为复杂,需要综合考虑效率和公平性。在实际应用中,应当根据具体的数据访问模式和系统需求,灵活选择和调整缓存算法,以达到最优的性...

如何使用 LinkedHashMap 实现 LRU 缓存?

本文已收录到 AndroidFamily,技术和职场问题,请关注公众号 [彭旭锐] 提问。 大家好,我是小彭。 在上一篇文章里,我们聊到了 HashMap 的实现原理和源码分析,在源码分析的过程中,我们发现一些 LinkedHashMap 相关的源码,当时没有展开,现在它来了。 那么,LinkedH

S3-FIFO

S3-FIFO 本文作为下一篇缓存文章的预备知识。 背景 基于LRU和FIFO的驱逐 FIFO和LRU都是经典的缓存驱逐算法,在过去几十年中也出现了很多追求更高效率的驱逐算法,如ARC, 2Q, LIRS, TinyLFU。传统观点认为,基于LRU的缓冲未命中率要低于基于FIFO的算法,如CLOCK

Android 内存缓存框架 LruCache 的实现原理,手写试试?

本文已收录到 AndroidFamily,技术和职场问题,请关注公众号 [彭旭锐] 提问。 前言 大家好,我是小彭。 在之前的文章里,我们聊到了 LRU 缓存淘汰算法,并且分析 Java 标准库中支持 LUR 算法的数据结构 LinkedHashMap。当时,我们使用 LinkedHashMap 实

LRU缓存替换策略及C#实现

LRU缓存替换策略 缓存是一种非常常见的设计,通过将数据缓存到访问速度更快的存储设备中,来提高数据的访问速度,如内存、CPU缓存、硬盘缓存等。 但与缓存的高速相对的是,缓存的成本较高,因此容量往往是有限的,当缓存满了之后,就需要一种策略来决定将哪些数据移除出缓存,以腾出空间来存储新的数据。 这样的策

LRU算法

1、什么事LRU 单从代码层面来说,我认为lru算法很容易实现,重点是我们要知道什么是lru算法。 LRU 英文全称是 Least Recently Used,英译过来就是”最近最少使用“的意思,假如我们有一块内存,专门用来缓存我们最近发访问的网页,访问一个新网页,我们就会往内存中添加一个网页地址,

Rust性能分析之测试及火焰图,附(lru,lfu,arc)测试

好的测试用例及性能测试是对一个库的稳定及优秀的重要标准,尽量的覆盖全的单元测试,能及早的发现bug,使程序更稳定。