MYDB 2. 参照カウントキャッシュフレームワークと共有メモリアレイ
本章で扱うコードはすべて backend/common にあります。
はじめに
この章から、MYDB の最も低層のモジュールであるデータマネージャ(Data Manager、以下 DM)について議論を始めます。
DM はデータベースの DB ファイルとログファイルを直接管理します。DM の主な役割は、1) DB ファイルのページ管理とキャッシュ、2) ログファイルの管理とエラー発生時のログに基づく復旧の保証、3) DB ファイルを DataItem として抽象化し上位モジュールに提供し、かつキャッシュ機能を備えることです。
DM の機能は大きく二つにまとめられます。ひとつは上位モジュールとファイルシステムの間の抽象層として、下位ではファイルの直接読み書きを行い、上位にはデータのラッピングを提供すること。もうひとつはログ機能です。
注目すべきは、上位にも下位にも DM はキャッシュ機能を提供しており、メモリ操作によって効率を確保している点です。
参照カウントキャッシュフレームワーク
なぜ LRU ではないのか?
ページ管理やデータ項目(DataItem)管理はキャッシュを伴うため、より汎用的なキャッシュフレームワークを設計しました。
ここで、多くの方は疑問に思うでしょう。なぜ「非常に先進的な」LRU 戦略ではなく、参照カウント戦略を採用したのか?
まずキャッシュのインターフェース設計から説明します。もし LRU キャッシュを使うなら、get(key)
インターフェースだけで十分で、キャッシュの解放は満杯になった際に自動的に行われます。例えば以下のような状況を想像してください。ある時点でキャッシュが満杯となり、あるリソースが追い出されました。その時、上位モジュールがそのリソースを強制的にデータソースへフラッシュしようとしたとします。そのリソースはちょうど追い出されたものでした。すると上位モジュールは、そのデータがキャッシュから消えたことに気づき、次のような困った状況に陥ります。果たしてデータソースへ戻す操作は必要か?
- 戻さない。キャッシュが追い出されたタイミングが不明で、追い出し後にデータ項目が変更されたかも分からず非常に危険。
- 戻す。もし追い出された時のデータと現在が同じなら無駄な戻しになる。
- キャッシュに戻し、次回追い出し時に戻す。問題が解決したように見えるが、既にキャッシュは満杯なので、再度リソースを追い出さなければならず、キャッシュの揺れ(スラッシング)を引き起こす可能性がある。
もちろんリソースの最終更新時刻を記録し、キャッシュの追い出し時刻も記録することは可能ですが……
不必要なら実体を増やすな。――オッカムの剃刀
問題の根本は、LRU 戦略ではリソースの追い出しが制御不能であり、上位モジュールがそれを感知できないことにあります。参照カウント戦略はこれを解決します。上位モジュールが能動的に参照を解放しなければ、キャッシュはリソースを追い出しません。
これが参照カウント法です。release(key)
というメソッドを追加し、上位モジュールがリソースを使わなくなった際に参照を解放します。参照がゼロになると、キャッシュはそのリソースを追い出します。
同様に、キャッシュが満杯になった場合、参照カウント法は自動的にキャッシュを解放できないため、直接エラーを返すべきです(JVM のように、直接 OOM になるイメージです)。
実装
AbstractCache<T>
は抽象クラスで、内部に二つの抽象メソッドがあり、実装クラスが具体的な操作を実装します。
/**
* リソースがキャッシュに存在しない場合の取得動作
*/
protected abstract T getForCache(long key) throws Exception;
/**
* リソースが追い出される際の書き戻し動作
*/
protected abstract void releaseForCache(T obj);
参照カウントなので、通常のキャッシュ機能に加え、参照数を管理する必要があります。さらにマルチスレッド環境に対応するため、どのリソースが現在データソースから取得中かも記録します(データソースからの取得は比較的時間がかかる操作です)。そのため、以下の三つの Map を用意しています。
private HashMap<Long, T> cache; // 実際のキャッシュデータ
private HashMap<Long, Integer> references; // リソースの参照カウント
private HashMap<Long, Boolean> getting; // 取得中のリソース
get()
メソッドでリソースを取得する際、まず無限ループに入り、キャッシュからの取得を試みます。まず他のスレッドがそのリソースをデータソースから取得中かどうかをチェックし、取得中なら少し待って再試行します。
while(true) {
lock.lock();
if(getting.containsKey(key)) {
// リクエストされたリソースは他スレッドが取得中
lock.unlock();
try {
Thread.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
continue;
}
continue;
}
...
}
もちろんリソースがキャッシュにあれば、直接取得して返します。その際、参照数を +1 します。そうでなければ、キャッシュが満杯でなければ、getting
に登録してそのスレッドがデータソースから取得を開始します。
while(true) {
if(cache.containsKey(key)) {
// キャッシュにリソースがあるので直接返す
T obj = cache.get(key);
references.put(key, references.get(key) + 1);
lock.unlock();
return obj;
}
// リソース取得を試みる
if(maxResource > 0 && count == maxResource) {
lock.unlock();
throw Error.CacheFullException;
}
count ++;
getting.put(key, true);
lock.unlock();
break;
}
データソースからの取得は簡単で、抽象メソッドを呼び出すだけです。取得完了後は getting
からキーを削除します。
T obj = null;
try {
obj = getForCache(key);
} catch(Exception e) {
lock.lock();
count --;
getting.remove(key);
lock.unlock();
throw e;
}
lock.lock();
getting.remove(key);
cache.put(key, obj);
references.put(key, 1);
lock.unlock();
キャッシュの解放はもっと簡単で、references
の参照数を 1 減らし、0 になればデータソースへ書き戻し、キャッシュから関連データを削除します。
/**
* 強制的にキャッシュを解放する
*/
protected void release(long key) {
lock.lock();
try {
int ref = references.get(key)-1;
if(ref == 0) {
T obj = cache.get(key);
releaseForCache(obj);
references.remove(key);
cache.remove(key);
count --;
} else {
references.put(key, ref);
}
} finally {
lock.unlock();
}
}
キャッシュには安全にシャットダウンする機能も必要で、シャットダウン時にはキャッシュ内のすべてのリソースを強制的にデータソースへ戻します。
lock.lock();
try {
Set<Long> keys = cache.keySet();
for (long key : keys) {
release(key);
references.remove(key);
cache.remove(key);
}
} finally {
lock.unlock();
}
これでシンプルなキャッシュフレームワークが完成しました。その他のキャッシュはこのクラスを継承し、二つの抽象メソッドを実装するだけで済みます。
共有メモリアレイ
ここで Java の非常に厄介な点について触れます。
Java では配列はオブジェクトとして扱われ、メモリ上でもオブジェクトの形で格納されます。一方、C や C++、Go などの言語では配列はポインタで実装されています。これが「Java だけが真の配列を持つ」と言われる理由です。
しかし、このプロジェクトにとっては必ずしも良いニュースではありません。例えば Go 言語では次のようなコードが書けます。
var array1 [10]int64
array2 := array1[5:]
この場合、array2 は array1 の 5 番目の要素から最後までの範囲を共有し、同じメモリ領域を参照します。配列の長さが異なっても同じメモリを共有するのです。
しかし Java ではこれが実現できません(これが高級言語なのか……)。
Java で subArray
のような操作をすると、内部でコピーが行われ、同一メモリを共有することはできません。
そこで、私は SubArray
クラスを作り、この配列の使用範囲を(緩やかに)規定することにしました。
public class SubArray {
public byte[] raw;
public int start;
public int end;
public SubArray(byte[] raw, int start, int end) {
this.raw = raw;
this.start = start;
this.end = end;
}
}
正直言って、これはあまり美しい解決策ではありませんが、現状ではこれしかありません。もし他に良い解決策を知っている方がいれば、ぜひコメント欄で教えてください。こんな醜いコードは書きたくありません/(ㄒo ㄒ)/~~