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形成一個雙向鏈表節點實現。
- 為什么不能使用stdlist? 在使用的粒度上stdlist和此處的應用場景不同,考慮如下場合:通過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),在時間上性能會下降,但是會節省空間。