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


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

    LRU算法原理

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

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

    LRU算法的优缺点

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

    结构设计

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

    • Rc引用计数

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

    • *mut K 裸指针

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

    节点设计

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

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

    类设计

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

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

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

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

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

    impl LruCache {
    /// 提供hash函数
    pub fn with_hasher(cap: usize, hash_builder: S) -> LruCache {
    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(&mut self, k: &Q) -> Option<(K, V)>
    where
    K: Borrow,
    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 KeyWrapperSized>(Q);
    impl Borrow> for KeyRef
    where
    K: Borrow,
    Q: ?Sized,
    {
    fn borrow(&self) -> &KeyWrapper {
    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算法。

  • 相关阅读:
    【畅购商城】购物车模块之修改购物车以及结算
    IDEA稀奇古怪问题的解决方案
    python ⾯向对象
    GeForce RTX 2080显卡驱动安装(linux系统)
    [Java反序列化]jdk原生链分析
    vue调用post方法并且后端代码需要接收ids
    7-8 循环日程安排问题
    Linux6.1中为什么用Radix树替换位图(bitmap)来管理进程pid
    Activity 的启动模式
    MySQL之数据库编程(创建存储过程)
  • 原文地址:https://www.cnblogs.com/wmproxy/p/18236668