c++ 实现 key-value缓存数据结构
概述
最近在阅读Memcached的源代码,今天借鉴部分设计思想简单的实现了一个keyvalue缓存。
哈希表部分使用了unordered_map,用于实现LRU算法的双向链表嵌套在缓存类中实现。
LRU 算法
LRU算法又称为last least used 算法,是用于在缓冲区内存不足的情况下进行内存替换调度的算法,出于局部性原理,我们会将缓存中上一次使用时间最久远的元素删除,在这里我的实现算法如下:
将hash表中存储的数据地址(实现形式为存储数据类型的指针)用双向链表的形式存储,在一个元素被更新或者插入的时候会将该元素从链表中取出重新添加到链表头部,在LRU调度时,只需要将链表尾部的元素删除即可。
存储元素
对存储元素类的数据结构设计如下: Data作为粒度最小的数据单位存储,然而由于
template<typename K, typename V>
struct Data {
explicit Data(const K& k, const V& v) :key(k), val(v) {
}
K key;
V val;
};
数据结构设计
- 数据的存取基于哈希表来实现
为了照顾代码可读性,在这里使用了unordered_map。
链表节点实现粒度的考虑
- 双向链表
首先链表是通过包装Data形成一个双向链表节点实现。- 为什么不能使用std::list?
在使用的粒度上std::list和此处的应用场景不同,考虑如下场合:通过get来查询哈希表中一个元素,此时由于这个元素被使用到了,应该从LRU链表中取出然后添加到链表头,如果使用std::list是难以实现的。因为它将list_node封装起来调用,我们无法通过哈希表中元素快速定位到链表中的迭代器位置。- 具体实现方式
实现一个类似list_node节点来进行存储,链表在缓存中以头节点的形式存储。
双线链表的实现 可参考:
http://www.cnblogs.com/joeylee97/p/8549835.html
链表节点结构
- 为什么通过指向const Data类型的shrared_ptr来存储数据?
- Reason 1:
Item之间的拷贝应该是轻量级的,这样能够提高存取性能- Reason 2:
在高并发情况下,const Data的智能指针便于内存管理,而且可以减小锁的粒度。
详细场景分析:在高并发情况下,此缓冲数据结构作为服务器端存储来使用,一个缓存区中数据应该怎样在读取时加锁?
如果仅仅在取数据时期加锁,那么要做大量拷贝(从数据结构中拷贝到栈或者其他变量中),然后调用socket进行发送。
如果我们在发送期间全程加锁,不仅效率极低,而且容易死锁。
在这里我给出的方案是通过shared_ptr类型在读取时加锁,在发送时直接通过指针来读取数据内容(使用const Data)来避免线程之间读写冲突。
template<typename K, typename V>
struct Item {
typedef Item<K, V>* Itemptr;
typedef shared_ptr<const Data<K, V>> MData;
//for head node
Item(){}
explicit Item(const K& k, const V& v) : nxt(nullptr), pre(nullptr) {
data = make_shared<Data<K, V>>(k, v);
}
//this should be a light weighted copy method since all its elems are ptr_type
Item(const Item& rhs) = default;
Item& operator=(const Item& rhs) = default;
//删除该元素
void detachFromList() {
Itemptr this_pre = pre, this_nxt = nxt;
this_pre->nxt = this_nxt;
this_nxt->pre = this_pre;
pre = nxt = nullptr; //In case this Item is reused
}
//加到该节点后面
void appendAftHead(Itemptr head) {
head->nxt->pre = this;
nxt = head->nxt;
head->nxt = this;
pre = head;
}
//for light copy and concurency
shared_ptr<const Data<K, V>> data;
Itemptr nxt;
Itemptr pre;
};
源码分析
哈希表接口
使用类应该通过模板偏特化来实现这两个接口
template<class T>
struct Hash {
size_t operator()(const T&) const;
};
template<class T>
struct Equal {
bool operator()(const T& lhs, const T& rhs) const;
};
Cache
template<class K, class V>
class Cache {
public:
typedef Item<K, V> MItem;
typedef shared_ptr<const Data<K, V>> MData;
typedef shared_ptr<const Data<K, V>> Dataptr;
typedef unordered_map<K, MItem, Hash<K>, Equal<K>> Table;
//对头节点初始化
explicit Cache(size_t capacity) :table(), head(), siz(0),cap(capacity) {
head.nxt = &head, head.pre = &head;
}
//禁止拷贝
Cache(const Cache&) = delete;
Cache& operator=(const Cache&) = delete;
std::pair<bool, Dataptr> get(const K& key) {
auto it = table.find(key);
if (it != table.end()) {
auto val = it->second.data->val;
it->second.detachFromList();
it->second.appendAftHead(&head);//调整到LRU首端
return { true, it->second.data };
}
else {
return { false, Dataptr() };
}
}
void put(const K& key, const V& val) {
auto it = table.find(key);
if (it != table.end()) {
it->second.detachFromList();
table.erase(it);
auto p = table.insert({ key, MItem(key, val) });
p.first->second.appendAftHead(&head);
}
else {
if (siz == cap) {
deleteLru();
}
auto p = table.insert({ key, MItem(key, val) }); //insert
p.first->second.appendAftHead(&head);
siz++;
}
}
bool del(const K& key) {
auto it = table.find(key);
if (it == table.end()) {
return false;
}
else {
it->second.detachFromList();
table.erase(it);
siz--;
return true;
}
}
private:
//delete least recently used item
void deleteLru() {
MItem* lru = head.pre;
if (lru != &head) {
del(lru->data->key);
}
}
size_t cap;
size_t siz;
Table table;
MItem head;
};
设计的缺陷以及优化方向
首先Memcached 的数据结构是C语言定制的,所以在哈希表上性能会更突出,举个例子
void deleteLru() {
MItem* lru = head.pre;
if (lru != &head) {
del(lru->data->key);
}
}
在这个删除LRU链表尾部元素的操作过程中,我们由于不能从链表直接定位到哈希表,所以要有一个 o nlogn的查询操作,在定制化的数据结构中这个是O 1 的
set/map?
细心的读者会注意到,在hash_map中我们的key被存储了两次(一次在map_pair节点,一次在Item中),可以使用unordered_set 来存储Item,不过这样每次使用key都要进行一次类型组装(从key到Item),在时间上性能会下降,但是会节省空间。