线程安全的LRU实现

线程安全的LRU实现

无清理线程


实现方案

根据需求,我们需要在原有LRU缓存基础上增加线程安全过期时间,并确保写入不阻塞读取。以下是分步骤的代码实现和解释:


一、增加线程安全(读写锁)

使用 std::shared_mutex(读写锁)保护共享资源,确保:

  • 读操作(get:允许多个线程同时读取。
  • 写操作(put、节点移动、过期删除):需要独占访问。
#include <shared_mutex>
#include <chrono>

class LRUCache {
private:
    mutable std::shared_mutex mutex_;  // 读写锁
    // ... 其他原有成员
};

二、添加过期时间

1. 修改节点结构

每个节点记录过期时间(当前时间 + TTL)。

struct DLinkedNode {
    int key, value;
    DLinkedNode* prev;
    DLinkedNode* next;
    std::chrono::steady_clock::time_point expire_time;  // 过期时间
    DLinkedNode(): key(0), value(0), prev(nullptr), next(nullptr) {}
    DLinkedNode(int _key, int _value, std::chrono::steady_clock::time_point _expire_time)
        : key(_key), value(_value), prev(nullptr), next(nullptr), expire_time(_expire_time) {}
};

2. 检查是否过期

bool isExpired(DLinkedNode* node) const {
    return std::chrono::steady_clock::now() > node->expire_time;
}

三、写入操作不阻塞读取

1. get 方法逻辑

  • 读锁:查找节点。
  • 惰性删除:若节点过期,升级为写锁并删除。
  • 移动节点:若未过期,升级为写锁并移动到头部。
int get(int key) {
    // 1. 读锁查找节点
    std::shared_lock<std::shared_mutex> read_lock(mutex_);
    auto it = cache.find(key);
    if (it == cache.end()) return -1;
    DLinkedNode* node = it->second;

    // 2. 检查是否过期
    if (isExpired(node)) {
        read_lock.unlock();  // 释放读锁,准备加写锁
        std::unique_lock<std::shared_mutex> write_lock(mutex_);
        // 双重检查防止竞态条件
        if (cache.count(key) && isExpired(cache[key])) {
            removeNode(cache[key]);
            cache.erase(key);
            delete node;
            size--;
        }
        return -1;
    }

    // 3. 未过期则移动节点到头部(需写锁)
    read_lock.unlock();
    std::unique_lock<std::shared_mutex> write_lock(mutex_);
    // 再次检查节点状态
    if (!cache.count(key) || isExpired(cache[key])) return -1;
    moveToHead(cache[key]);
    return cache[key]->value;
}

2. put 方法逻辑

  • 写锁:插入新节点或更新已有节点。
  • 设置过期时间:例如TTL=1秒。
void put(int key, int value) {
    auto expire_time = std::chrono::steady_clock::now() + std::chrono::seconds(1);
    std::unique_lock<std::shared_mutex> write_lock(mutex_);
    // ... 原有逻辑修改为使用带过期时间的节点
    if (!cache.count(key)) {
        DLinkedNode* node = new DLinkedNode(key, value, expire_time);
        // ... 其他逻辑不变
    } else {
        cache[key]->value = value;
        cache[key]->expire_time = expire_time;
        moveToHead(cache[key]);
    }
}

四、关键设计点

1. 读写锁的精细控制

  • 读锁(std::shared_lock:用于只读操作(哈希表查找、节点访问)。
  • 写锁(std::unique_lock:用于修改数据结构(插入、删除、移动节点)。

2. 惰性过期删除

  • 优点:无需后台线程,仅在访问时清理过期数据。
  • 缺点:内存释放不够及时,但适合低频率过期场景。

3. 避免阻塞的关键

  • 锁升级策略:在get中先释放读锁再加写锁,确保写入操作不长时间阻塞读操作。
  • 双重检查(Double-Check):防止在释放读锁后,数据状态被其他线程修改。

五、完整代码示例

#include <unordered_map>
#include <memory>
#include <shared_mutex>
#include <chrono>

struct DLinkedNode {
    int key, value;
    DLinkedNode* prev;
    DLinkedNode* next;
    std::chrono::steady_clock::time_point expire_time;
    DLinkedNode(int _key, int _value, std::chrono::steady_clock::time_point _expire_time)
        : key(_key), value(_value), prev(nullptr), next(nullptr), expire_time(_expire_time) {}
};

class LRUCache {
private:
    std::unordered_map<int, DLinkedNode*> cache;
    DLinkedNode* head;
    DLinkedNode* tail;
    int size;
    int capacity;
    mutable std::shared_mutex mutex_;

    bool isExpired(DLinkedNode* node) const {
        return std::chrono::steady_clock::now() > node->expire_time;
    }

    void addToHead(DLinkedNode* node) {
        node->prev = head;
        node->next = head->next;
        head->next->prev = node;
        head->next = node;
    }

    void removeNode(DLinkedNode* node) {
        node->prev->next = node->next;
        node->next->prev = node->prev;
    }

    void moveToHead(DLinkedNode* node) {
        removeNode(node);
        addToHead(node);
    }

    DLinkedNode* removeTail() {
        DLinkedNode* node = tail->prev;
        removeNode(node);
        return node;
    }

public:
    LRUCache(int _capacity) : capacity(_capacity), size(0) {
        head = new DLinkedNode(-1, -1, std::chrono::steady_clock::now());
        tail = new DLinkedNode(-1, -1, std::chrono::steady_clock::now());
        head->next = tail;
        tail->prev = head;
    }

    ~LRUCache() {
        // 清理内存(示例代码省略)
    }

    int get(int key) {
        std::shared_lock<std::shared_mutex> read_lock(mutex_);
        auto it = cache.find(key);
        if (it == cache.end()) return -1;

        DLinkedNode* node = it->second;
        if (isExpired(node)) {
            read_lock.unlock();
            std::unique_lock<std::shared_mutex> write_lock(mutex_);
            if (cache.count(key) && isExpired(cache[key])) {
                removeNode(cache[key]);
                cache.erase(key);
                delete node;
                size--;
            }
            return -1;
        }

        read_lock.unlock();
        std::unique_lock<std::shared_mutex> write_lock(mutex_);
        if (!cache.count(key) || isExpired(cache[key])) return -1;
        moveToHead(cache[key]);
        return cache[key]->value;
    }

    void put(int key, int value) {
        auto expire_time = std::chrono::steady_clock::now() + std::chrono::seconds(1);
        std::unique_lock<std::shared_mutex> write_lock(mutex_);
        if (!cache.count(key)) {
            DLinkedNode* node = new DLinkedNode(key, value, expire_time);
            cache[key] = node;
            addToHead(node);
            size++;
            if (size > capacity) {
                DLinkedNode* removed = removeTail();
                cache.erase(removed->key);
                delete removed;
                size--;
            }
        } else {
            cache[key]->value = value;
            cache[key]->expire_time = expire_time;
            moveToHead(cache[key]);
        }
    }
};

六、总结

  • 线程安全:通过读写锁(std::shared_mutex)实现高效并发访问。
  • 过期时间:节点记录过期时间,访问时惰性删除。
  • 无阻塞读取:读操作仅在必要时升级为写锁,且锁持有时间极短。

加入清理线程


问题分析

当使用惰性删除(仅在访问时检查过期)时,若大量节点长时间未被访问但已过期,会导致缓存体积膨胀。我们需要增加主动删除机制,定期或按策略清理过期数据。


解决方案

以下是两种主要方法,可根据场景选择或结合使用:


方案一:后台清理线程

启动一个独立线程定期扫描并删除过期节点,确保内存及时释放。

1. 代码修改

class LRUCache {
private:
    // ... 原有成员
    std::thread cleanup_thread;        // 后台清理线程
    std::atomic<bool> running{true};   // 控制线程退出
    std::condition_variable cv;        // 用于定时或条件触发
    std::mutex cleanup_mutex;          // 条件变量专用锁

    void backgroundCleanup() {
        while (running) {
            {
                std::unique_lock<std::mutex> lock(cleanup_mutex);
                // 每隔N秒触发一次清理(例如5秒)
                cv.wait_for(lock, std::chrono::seconds(5), [this] { return !running; });
                if (!running) break;
            }

            // 执行清理
            performExpirationCheck();
        }
    }

    void performExpirationCheck() {
        std::unique_lock<std::shared_mutex> write_lock(mutex_);
        auto now = std::chrono::steady_clock::now();
        DLinkedNode* node = tail->prev;
        while (node != head) {  // 遍历双向链表
            DLinkedNode* prev_node = node->prev;
            if (now > node->expire_time) {
                removeNode(node);
                cache.erase(node->key);
                delete node;
                size--;
            }
            node = prev_node;
        }
    }

public:
    LRUCache(int _capacity) : capacity(_capacity), size(0) {
        // ... 原有初始化
        cleanup_thread = std::thread(&LRUCache::backgroundCleanup, this);
    }

    ~LRUCache() {
        running = false;
        cv.notify_all();  // 唤醒线程以便退出
        if (cleanup_thread.joinable()) {
            cleanup_thread.join();
        }
        // ... 原有析构逻辑
    }
};

2. 关键设计点

  • 定时清理:后台线程每隔固定时间(如5秒)遍历链表删除过期节点。
  • 线程协调
    • 使用 std::condition_variable 实现定时或条件触发。
    • running 原子变量控制线程优雅退出。
  • 锁的粒度
    • 清理时持有写锁unique_lock),阻塞其他写操作,但读操作仍可并发。
    • 遍历方向:从链表尾部(LRU端)开始,因过期概率更高。

3. 优缺点

  • 优点:内存释放及时。
  • 缺点
    • 遍历整个链表可能耗时,尤其当容量大时。
    • 持有写锁期间会阻塞所有写操作(但读操作仍可用共享锁)。

方案二:批量惰性删除

写操作(put中附加清理逻辑,每次删除固定数量(如5个)过期节点,逐步释放内存。

1. 代码修改

void put(int key, int value) {
    auto expire_time = std::chrono::steady_clock::now() + std::chrono::seconds(1);
    std::unique_lock<std::shared_mutex> write_lock(mutex_);
  
    // 附加清理逻辑:每次put时清理最多5个过期节点
    int cleanup_count = 0;
    DLinkedNode* node = tail->prev;
    auto now = std::chrono::steady_clock::now();
    while (node != head && cleanup_count < 5) {
        DLinkedNode* prev_node = node->prev;
        if (now > node->expire_time) {
            removeNode(node);
            cache.erase(node->key);
            delete node;
            size--;
            cleanup_count++;
        }
        node = prev_node;
    }

    // ... 原有put逻辑
}

2. 关键设计点

  • 增量清理:每次put时从尾部向前清理最多5个过期节点。
  • 锁复用:写操作已持有写锁,清理无需额外加锁。
  • 清理方向:从尾部(LRU端)开始,过期概率更高。

3. 优缺点

  • 优点
    • 无额外线程开销。
    • 清理操作分散到每次put中,避免集中式耗时。
  • 缺点
    • 清理不完全,极端情况下仍可能膨胀。
    • 写操作可能因附加清理逻辑略微变慢。

方案三:分层过期检查

结合上述两种方案,根据系统负载动态调整清理频率。

1. 动态调整策略

  • 低负载时:使用后台线程定期清理。
  • 高负载时:增加put中的批量清理数量(例如每次清理10个节点)。

2. 代码示例

// 在LRUCache类中增加负载状态跟踪
std::atomic<int> put_counter{0};
void put(int key, int value) {
    // ... 原有逻辑
    put_counter++;
  
    // 动态调整清理数量
    int cleanup_limit = (put_counter.load() > 1000) ? 10 : 5;
    int cleanup_count = 0;
    DLinkedNode* node = tail->prev;
    auto now = std::chrono::steady_clock::now();
    while (node != head && cleanup_count < cleanup_limit) {
        // ... 清理逻辑
    }
    put_counter.store(0);
}

总结

方案 适用场景 实现复杂度 内存控制及时性
后台线程 内存敏感,允许短暂写阻塞
批量惰性删除 写操作频繁,容忍一定内存膨胀
分层策略 负载波动大,需平衡性能与内存 中高

根据实际需求选择方案:

  • 严格内存限制:方案一(后台线程) + 方案二(批量清理)。
  • 高吞吐优先:方案二(批量清理) + 动态调整。
posted @ 2025-04-01 15:31  Gold_stein  阅读(166)  评论(0)    收藏  举报