1. 程式人生 > 其它 >NodeJS 中的 LRU 快取(CLOCK-2-hand)實現

NodeJS 中的 LRU 快取(CLOCK-2-hand)實現

程式碼展示

首先構建一個用來構造LRU物件模組的檔案:

'use strict';
let Lru = function(cacheSize,callbackBackingStoreLoad,elementLifeTimeMs=1000){
    let me = this;
    let maxWait = elementLifeTimeMs;
    let size = parseInt(cacheSize,10);
    let mapping = {};
    let mappingInFlightMiss = {};
    let buf = [];
    for(let i=0;i<size;i++)
    {
        let rnd = Math.random();
        mapping[rnd] = i;
        buf.push({data:"",visited:false, key:rnd, time:0, locked:false});
    }
    let ctr = 0;
    let ctrEvict = parseInt(cacheSize/2,10);
    let loadData = callbackBackingStoreLoad;
    this.get = function(key,callbackPrm){
       
        let callback = callbackPrm;
        if(key in mappingInFlightMiss)
        {
            setTimeout(function(){
                me.get(key,function(newData){
                    callback(newData);
                });
            },0);
            return;
        }

        if(key in mapping)
        {            
            // RAM speed data
            if((Date.now() - buf[mapping[key]].time) > maxWait)
            {                
                if(buf[mapping[key]].locked)
                {                                        
                    setTimeout(function(){
                        me.get(key,function(newData){
                            callback(newData);
                        });
                    },0);                    
                }
                else
                {
                    delete mapping[key];
                    
                    me.get(key,function(newData){
                        callback(newData);
                    });                    
                }                
            }
            else
            {
                buf[mapping[key]].visited=true;
                buf[mapping[key]].time = Date.now();
                callback(buf[mapping[key]].data);
            }
        }
        else
        {
            // datastore loading + cache eviction
            let ctrFound = -1;
            while(ctrFound===-1)
            {
                if(!buf[ctr].locked && buf[ctr].visited)
                {
                    buf[ctr].visited=false;
                }
                ctr++;
                if(ctr >= size)
                {
                    ctr=0;
                }

                if(!buf[ctrEvict].locked && !buf[ctrEvict].visited)
                {
                    // evict
                    buf[ctrEvict].locked = true;
                    ctrFound = ctrEvict;
                }

                ctrEvict++;
                if(ctrEvict >= size)
                {
                    ctrEvict=0;
                }
            }
            
            mappingInFlightMiss[key]=true;
            let f = function(res){
                delete mapping[buf[ctrFound].key];
                buf[ctrFound] = 
                {data: res, visited:false, key:key, time:Date.now(), locked:false};
                mapping[key] = ctrFound;
                callback(buf[ctrFound].data);
                delete mappingInFlightMiss[key];        
            };
            loadData(key,f);
        }
    };
};

exports.Lru = Lru;

檔案快取示例:

let Lru = require("./lrucache.js").Lru;
let fs = require("fs");
let path = require("path");

let fileCache = new Lru(500, async function(key,callback){
  // cache-miss data-load algorithm
    fs.readFile(path.join(__dirname,key),function(err,data){
      if(err) {                                 
        callback({stat:404, data:JSON.stringify(err)});
      }
      else
      {                                
        callback({stat:200, data:data});
      }                                                        
    });
},1000 /* cache element lifetime */);

使用LRU建構函式獲取引數(快取記憶體大小、快取記憶體未命中的關鍵字和回撥、快取記憶體要素生命週期)來構造CLOCK快取記憶體。

非同步快取未命中回撥的工作方式如下:
1.一些get()在快取中找不到金鑰
2.演算法找到對應插槽
3.執行此回撥:
在回撥中,重要計算非同步完成
回撥結束時,將回調函式的回撥返回到LRU快取中

再次訪問同一金鑰的資料來自RAM
該依賴的唯一實現方法get():


fileCache.get("./test.js",function(dat){
     httpResponse.writeHead(dat.stat);
     httpResponse.end(dat.data);
});

結果資料還有另一個回撥,因此可以非同步執行

工作原理

現在大多LRU的工作過程始終存在從鍵到快取槽的“對映”物件,就快取槽的數量而言實現O(1)鍵搜尋時間複雜度。但是用JavaScript就簡單多了:
對映物件:

let mapping= {};

在對映中找到一個(字串/整數)鍵:

if(key in mapping)
{
   // key found, get data from RAM
}

高效且簡單

只要對映對應一個快取插槽,就可以直接從其中獲取資料:

buf[mapping[key]].visited=true; 
buf[mapping[key]].time = Date.now(); 
callback(buf[mapping[key]].data);

visited用來通知CLOCK指標(ctr和ctrEvict)儲存該插槽,避免它被驅逐。time欄位用來管理插槽的生命週期。只要訪問到快取記憶體命中都會更新time欄位,把它保留在快取記憶體中。

使用者使用callback函式給get()函式提供用於檢索快取記憶體插槽的資料。

想要直接從對映插槽獲取資料之前,需要先檢視它的生命週期,如果生命週期已經結束,需要刪除對映並用相同鍵重試使快取記憶體丟失:

if((Date.now() - buf[mapping[key]].time) > maxWait)
{
    delete mapping[key];
    me.get(key,function(newData){
        callback(newData);
    });
}

刪除對映後其他非同步訪問不會再影響其內部狀態

如果在對映物件中沒找到金鑰,就執行LRU逐出邏輯尋找目標:


let ctrFound = -1;
while(ctrFound===-1)
{
    if(!buf[ctr].locked && buf[ctr].visited)
    {
        buf[ctr].visited=false;
    }
    ctr++;
    if(ctr >= size)
    {
        ctr=0;
    }

    if(!buf[ctrEvict].locked && !buf[ctrEvict].visited)
    {
        // evict
        buf[ctrEvict].locked = true;
        ctrFound = ctrEvict;
    }

    ctrEvict++;
    if(ctrEvict >= size)
    {
        ctrEvict=0;
    }
}

第一個“ if”塊檢查“第二次機會”指標(ctr)指向的插槽狀態,如果是未鎖定並已訪問會將其標記為未訪問,而不是驅逐它。

第三“If”塊檢查由ctrEvict指標指向的插槽狀態,如果是未鎖定且未被訪問,則將該插槽標記為“ locked”,防止非同步訪問get() 方法,並找到逐出插槽,然後迴圈結束。

對比可以發現ctr和ctrEvict的初始相位差為50%:

let ctr = 0;
let ctrEvict = parseInt(cacheSize/2,10);

並且在“ while”迴圈中二者均等遞增。這意味著,這二者迴圈跟隨另一方,互相檢查。快取記憶體插槽越多,對目標插槽搜尋越有利。對每個鍵而言,每個鍵至少停留超過N / 2個時針運動才從從逐出中儲存。

找到目標插槽後,刪除對映防止非同步衝突的發生,並在載入資料儲存區後重新建立對映:

mappingInFlightMiss[key]=true; 
let f = function(res){ 
    delete mapping[buf[ctrFound].key]; 
    buf[ctrFound] = {data: res, visited:false, key:key, time:Date.now(), locked:false}; 
    mapping[key] = ctrFound; 
    callback(buf[ctrFound].data); 
    delete mappingInFlightMiss[key]; 
}; 

loadData(key,f);

由於使用者提供的快取缺失資料儲存載入功能(loadData)可以非同步進行,所以該快取在執行中最多可以包含N個快取缺失,最多可以隱藏N個快取未命中延遲。隱藏延遲是影響吞吐量高低的重要因素,這一點在web應用中尤為明顯。一旦應用中出現了超過N個非同步快取未命中/訪問就會導致死鎖,因此具有100個插槽的快取可以非同步服務多達100個使用者,甚至可以將其限制為比N更低的值(M),並在多次(K)遍中進行計算(其中M x K =總訪問次數)。

我們都知道快取記憶體命中就是RAM的速度,但因為快取記憶體未命中可以隱藏,所以對於命中和未命中而言,總體效能看起來的時間複雜度都是O(1)。當插槽很少時,每個訪問可能有多個時鐘指標迭代,但如果增加插槽數時,它接近O(1)。

在此loadData回撥中,將新插槽資料的locked欄位設定為false,可以使該插槽用於其他非同步訪問。

如果存在命中,並且找到的插槽生命週期結束且已鎖定,則訪問操作setTimeout將0 time引數延遲到JavaScript訊息佇列的末尾。鎖定操作(cache-miss)在setTimeout之前結束的概率為100%,就時間複雜度而言,仍算作具有較大的延遲的O(1),但它隱藏在鎖定操作延遲的延遲的之後。

if(buf[mapping[key]].locked) 
{ 
    setTimeout(function(){ 
        me.get(key,function(newData){ 
            callback(newData); 
        }); 
    },0); 
}

最後,如果某個鍵處於進行中的快取記憶體未命中對映中,則通過setTimeout將其推遲到訊息佇列的末尾:

if(key in mappingInFlightMiss)
{

  setTimeout(function(){
     me.get(key,function(newData){
              callback(newData);
     });
  },0);
  return;
}

這樣,就可以避免資料的重複。

標杆管理

非同步快取記憶體未命中基準

"use strict";
// number of asynchronous accessors(1000 here) need to be equal to or less than 
// cache size(1000 here) or it makes dead-lock
let Lru = require("./lrucache.js").Lru;

let cache = new Lru(1000, async function(key,callback){
    // cache-miss data-load algorithm
    setTimeout(function(){
        callback(key+" processed");
    },1000);
},1000 /* cache element lifetime */);

let ctr = 0;
let t1 = Date.now();
for(let i=0;i<1000;i++)
{
    cache.get(i,function(data){
        console.log("data:"+data+" key:"+i);
        if(i.toString()+" processed" !== data)
        {
            console.log("error: wrong key-data mapping.");
        }
        if(++ctr === 1000)
        {
            console.log("benchmark: "+(Date.now()-t1)+" miliseconds");
        }
    });
}

為了避免死鎖的出現,可以將LRU大小選擇為1000,或者for只允許迴圈迭代1000次。

輸出:


benchmark: 1127 miliseconds

由於每個快取記憶體未命中都有1000毫秒的延遲,因此同步載入1000個元素將花費15分鐘,但是重疊的快取記憶體未命中會更快。這在I / O繁重的工作負載(例如來自HDD或網路的流資料)中特別有用。

快取命中率基準

10%的命中率:
金鑰生成:隨機,可能有10000個不同的值
1000個插槽


"use strict";
// number of asynchronous accessors(1000 here) need to be equal to or less than 
// cache size(1000 here) or it makes dead-lock
let Lru = require("./lrucache.js").Lru;

let cacheMiss = 0;
let cache = new Lru(1000, async function(key,callback){
    cacheMiss++;
    // cache-miss data-load algorithm
    setTimeout(function(){
        callback(key+" processed");
    },100);
},100000000 /* cache element lifetime */);

let ctr = 0;
let t1 = Date.now();
let asynchronity = 500;
let benchRepeat = 100;
let access = 0;

function test()
{
    ctr = 0;
    for(let i=0;i<asynchronity;i++)
    {
        let key = parseInt(Math.random()*10000,10); // 10% hit ratio
        cache.get(key.toString(),function(data){     
            access++;
            if(key.toString()+" processed" !== data)
            {
                console.log("error: wrong key-data mapping.");
            }
            if(++ctr === asynchronity)
            {
                console.log("benchmark: "+(Date.now()-t1)+" miliseconds");
                console.log("cache hit: "+(access - cacheMiss));
                console.log("cache miss: "+(cacheMiss));
                console.log("cache hit ratio: "+((access - cacheMiss)/access));
                if(benchRepeat>0)
                {
                    benchRepeat--;
                    test();
                }
            }
        });
    }
}

test();

http://www.bijianshuo.com 軟文發稿平臺

結果:

benchmark: 10498 miliseconds
cache hit: 6151
cache miss: 44349
cache hit ratio: 0.1218019801980198

由於基準測試是按100個步驟進行的,每個快取丟失的延遲時間為100毫秒,因此產生了10秒的時間(接近100 x 100毫秒)。命中率接近預期值10%。

50%命中率測試

let key = parseInt(Math.random()*2000,10); // 50% hit ratio

Result:

benchmark: 10418 miliseconds
cache hit: 27541
cache miss: 22959
cache hit ratio: 0.5453663366336634

命中率測試

let key = parseInt(Math.random()*1010,10); // 99% hit ratio

Result:

benchmark: 10199 miliseconds
cache hit: 49156
cache miss: 1344
cache hit ratio: 0.9733861386138614

結果產生了0.9733比率的鍵的隨機性

%命中率測試

letkey =parseInt(Math.random()*999,10);// 100% hit ratio

結果:

benchmark: 1463 miliseconds
cache hit: 49501
cache miss: 999
cache hit ratio: 0.9802178217821782

基準測試的第一步(無法逃避快取未命中)之後,所有內容都來自RAM,並大大減少了總延遲。