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

lru,rust · 浏览次数 : 0

小编点评

LRU-K 算法是一种改进的缓存淘汰算法,旨在解决传统 LRU 算法中的缓存污染问题。通过引入“最近使用过 K 次”的判断标准,LRU-K 可以降低缓存污染,提高缓存的命中率。然而,它需要额外的内存和 CPU 资源来维护历史队列和进行排序操作。 1. **结构设计与实现**:LRU-K 的结构设计与 LRU 类似,使用指针或数组坐标模拟指针来保存节点。节点中除了保存键值对外,还需要额外存储访问次数数据。 2. **类设计**:LRU-K 类需要维护两个队列:历史队列和缓存队列。普通队列用于保存每次访问的页面,而 K 次队列用于保存已经访问 K 次的页面。此外,还需要维护访问次数的维护字段。 3. **元素插入及删除**:LRU-K 的插入和删除操作主要关注将新元素加入到队列以及从队列中移除元素。当元素被访问 K 次时,将其从历史队列移除并添加到 K 次队列。 4. **淘汰数据**:LRU-K 在淘汰数据时会优先淘汰普通队列中的数据。如果普通队列为空,将考虑淘汰 K 次队列中的页面。淘汰过程涉及到多次比较和调整,但不需要遍历整个队列。 5. **其他操作**:LRU-K 还提供了 get、get_mut、pop、pop_last、contains_key 和 get_or_insert 默认/可变参数等操作方法。 在实际应用中,LRU-2 通常被认为是 LRU-K 的最优选择,因为它在命中率和内存消耗之间取得了平衡。

正文

LRU-K 是一种缓存淘汰算法,旨在改进传统的LRU(Least Recently Used,最近最少使用)算法的性能。将其中高频的数据达到K次访问移入到另一个队列进行保护。

算法思想

  • LRU-K中的“K”代表最近使用的次数。因此,LRU可以认为是LRU-1的特例。
  • LRU-K的主要目的是为了解决LRU算法“缓存污染”的问题。其核心思想是将“最近使用过1次”的判断标准扩展为“最近使用过K次”。

工作原理

  • LRU-K需要维护两个队列:历史队列和缓存队列。
    1. 普通队列:保存着每次访问的页面。当页面访问次数达到K次时,该页面从历史队列中移除,并添加到K次队列中。
    2. K次队列:保存已经访问K次的页面。当缓存队列满了之后,需要淘汰页面时,会淘汰最后一个页面,即“倒数第K次访问离现在最久”的那个页面。
  • 详细说明:
    1. 页面第一次被访问时,添加到普通队列中。
    2. 普通队列中的页面满了,根据一定的缓存策略(如FIFO、LRU、LFU)进行淘汰。
    3. 当历史队列中的某个页面第K次访问时,该页面从历史队列中移除,并添加到K次队列中。
    4. K次队列中的页面再次被访问时,会重新排序。

优缺点

  • 优点
    1. LRU-K降低了“缓存污染”带来的问题,因为只有当页面被访问K次后才会被加入缓存队列。
    2. LRU-K的命中率通常比LRU要高。
  • 缺点
    1. LRU-K需要维护一个普通队列,因此内存消耗会比LRU多。
    2. LRU-K需要基于次数进行排序(可以需要淘汰时再排序,也可以即时排序),因此CPU消耗比LRU要高。
    3. 当K值较大时(如LRU-3或更大的K值),虽然命中率会更高,但适应性较差,需要大量的数据访问才能将历史访问记录清除掉。

实际应用

  • 在实际应用中,LRU-2通常被认为是综合各种因素后最优的选择。

综上所述,LRU-K通过引入“最近使用过K次”的判断标准,有效地解决了LRU算法中的“缓存污染”问题,提高了缓存的命中率。然而,它也需要更多的内存和CPU资源来维护历史队列和进行排序操作。

结构设计

与Lru的结构类似,K与V均用指针方式保存,避免在使用过程中出现Copy或者Clone的可能,提高性能。
注:该方法用了指针会相应的出现许多unsafe的代码,因为在Rsut中,访问指针都被认为是unsafe。我们也可以使用数组坐标模拟指针的方式来模拟。

节点设计

相对普通的Lru节点,我们需要额外存储次数数据。

/// LruK节点数据
struct LruKEntry<K, V> {
    pub key: mem::MaybeUninit<K>,
    pub val: mem::MaybeUninit<V>,
    pub times: usize,
    pub prev: *mut LruKEntry<K, V>,
    pub next: *mut LruKEntry<K, V>,
}

类设计

由于有K次的列表,所以需要维护两个列表,在空间占用上会比Lru多一些,主要多一个字段访问次数的维护

pub struct LruKCache<K, V, S> {
    map: HashMap<KeyRef<K>, NonNull<LruKEntry<K, V>>, S>,
    cap: usize,
    /// 触发K次数,默认为2
    times: usize,
    /// K次的队列
    head_times: *mut LruKEntry<K, V>,
    tail_times: *mut LruKEntry<K, V>,
    /// 普通队列
    head: *mut LruKEntry<K, V>,
    tail: *mut LruKEntry<K, V>,
    /// 普通队列的长度
    lru_count: usize,
}

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

impl<K, V, S> LruCache<K, V, S> {
    /// 提供hash函数
    pub fn with_hasher(cap: usize, times: usize, hash_builder: S) -> LruKCache<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(LruKEntry::new_empty()));
        let tail = Box::into_raw(Box::new(LruKEntry::new_empty()));
        unsafe {
            (*head).next = tail;
            (*tail).prev = head;
        }
        let head_times = Box::into_raw(Box::new(LruKEntry::new_empty()));
        let tail_times = Box::into_raw(Box::new(LruKEntry::new_empty()));
        unsafe {
            (*head_times).next = tail_times;
            (*tail_times).prev = head_times;
        }
        Self {
            map,
            cap,
            times,
            head_times,
            tail_times,
            head,
            tail,
            lru_count: 0,
        }
    }
}

元素插入及删除

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

pub fn capture_insert(&mut self, k: K, mut v: V) -> Option<(K, V, bool)> {
    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, true))
        }
        None => {
            let (val, 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);
            }
            val.map(|(k, v)| (k, v, false))
        }
    }
}

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,
        }
    }

与Lru的操作方式类似,但是主要集中在attachdetach因为有两个队列,需要正确的附着在正确的队列之上。

attach
/// 加到队列中
fn attach(&mut self, entry: *mut LruKEntry<K, V>) {
    unsafe {
        (*entry).times = (*entry).times.saturating_add(1);
        if (*entry).times < self.times {
            self.lru_count += 1;
            (*entry).next = (*self.head).next;
            (*(*entry).next).prev = entry;
            (*entry).prev = self.head;
            (*self.head).next = entry;
        } else {
            (*entry).next = (*self.head_times).next;
            (*(*entry).next).prev = entry;
            (*entry).prev = self.head_times;
            (*self.head_times).next = entry;
        }
    }
}

在加入到队列的时候,需将访问次数+1,然后判断是否达到K次的次数,如果达到将其加入到head_times队列中。
其中使用了saturating_add,这里说个Rust与其它语言的差别。
因为在Rust中不像c语言,如果在c语言中,定义一个uchar类型

uchar times = 255;
times += 1; //此时times为0,不会有任何异常

但是在Rust中

let mut times: u8 = 255;
times = times.overflowing_add(1); // 此时times为0,因为上溢出了
times = times.saturating_add(1); // 此时times为255,因为达到了最大值
times += 1; // 此时将会发生panic

此时这函数的效率基本上等同于Lru的,相对仅仅是多维护times字段lru_count字段

detach
fn detach(&mut self, entry: *mut LruKEntry<K, V>) {
    unsafe {
        (*(*entry).prev).next = (*entry).next;
        (*(*entry).next).prev = (*entry).prev;

        if (*entry).times < self.times {
            self.lru_count -= 1;
        }
    }
}

与Lru中的类似,仅仅如果次数在k次以下的时候维护lru_count,效率基本一致。

replace_or_create_node
fn replace_or_create_node(&mut self, k: K, v: V) -> (Option<(K, V)>, NonNull<LruKEntry<K, V>>) {
    if self.len() == self.cap {
        let old_key = if self.lru_count > 0 {
            KeyRef {
                k: unsafe { &(*(*(*self.tail).prev).key.as_ptr()) },
            }
        } else {
            KeyRef {
                k: unsafe { &(*(*(*self.tail_times).prev).key.as_ptr()) },
            }
        };
        let old_node = self.map.remove(&old_key).unwrap();
        let node_ptr: *mut LruKEntry<K, V> = old_node.as_ptr();
        unsafe  {
            (*node_ptr).times = 0;
        }
        let replaced = unsafe {
            (
                mem::replace(&mut (*node_ptr).key, mem::MaybeUninit::new(k)).assume_init(),
                mem::replace(&mut (*node_ptr).val, mem::MaybeUninit::new(v)).assume_init(),
            )
        };

        self.detach(node_ptr);

        (Some(replaced), old_node)
    } else {
        (None, unsafe {
            NonNull::new_unchecked(Box::into_raw(Box::new(LruKEntry::new(k, v))))
        })
    }

淘汰数据,优先淘汰普通队列的数据,如果普通队列为空,将进入淘汰K次队列。区别就是在于淘汰时多选择一次数据。效率上也基本上可以忽略不计。

其它操作

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

如何使用

在cargo.toml中添加

[dependencies]
algorithm = "0.1"
示例
use algorithm::LruKCache;
fn main() {
    let mut lru = LruKCache::with_times(3, 3);
    lru.insert("this", "lru");
    for _ in 0..3 {
        let _ = lru.get("this");
    }
    lru.insert("hello", "algorithm");
    lru.insert("auth", "tickbh");
    assert!(lru.len() == 3);
    lru.insert("auth1", "tickbh");
    assert_eq!(lru.get("this"), Some(&"lru"));
    assert_eq!(lru.get("hello"), None);
    assert!(lru.len() == 3);
}

完整项目地址

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

结语

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

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

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

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

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

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

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

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

LRU算法

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

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

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

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

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

如何使用 LinkedHashMap 实现 LRU 缓存?

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

Redis系列19:LRU内存淘汰算法分析

[Redis系列1:深刻理解高性能Redis的本质](https://www.cnblogs.com/wzh2010/p/15886787.html "Redis系列1:深刻理解高性能Redis的本质") [Redis系列2:数据持久化提高可用性](https://www.cnblogs.com/w

深入解析Redis的LRU与LFU算法实现

重点介绍了Redis的LRU与LFU算法实现,并分析总结了两种算法的实现效果以及存在的问题。

.NET周报 【4月第2期 2023-04-08】

国内文章 LRU缓存替换策略及C#实现 https://www.cnblogs.com/eventhorizon/p/17290125.html 这篇文章讲述了缓存替换策略,特别是LRU算法。LRU算法基于这样一个假设:如果数据最近被访问过,那么将来被访问的几率也更高。通常我们会用双向链表来实现这个