线程安全的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);
}
总结
方案 | 适用场景 | 实现复杂度 | 内存控制及时性 |
---|---|---|---|
后台线程 | 内存敏感,允许短暂写阻塞 | 高 | 高 |
批量惰性删除 | 写操作频繁,容忍一定内存膨胀 | 低 | 中 |
分层策略 | 负载波动大,需平衡性能与内存 | 中 | 中高 |
根据实际需求选择方案:
- 严格内存限制:方案一(后台线程) + 方案二(批量清理)。
- 高吞吐优先:方案二(批量清理) + 动态调整。