二重化されたオブジェクト(2)

BufferedObject をリファクタリングしてみました。ポイントは以下の通り。

  • commit と rollback を追放
  • ポインタのかわりに get/set でインタフェースする
  • 変数名をよりわかりやすいものに変更

名前も BufferedValue にしてみました。ではコードを見てみましょう。

BufferedValue version 1.0
template <typename T>
class BufferedValue {
    enum { OPEN, LOCK, CLOSE };
    int primary_;
    int bufferState_;
    T value_[2];
    Mutex mutex_;

public:
    BufferedValue() : primary_(0), bufferState_(OPEN) {}
    T& get() {
        Guard on(mutex_);
        if(bufferState_ == CLOSE) {
            bufferState_ = OPEN;
            primary_ = !primary_;
        }
        return value_[primary_];
    }

    bool set(const T& newValue) {
        {
            Guard on(mutex_);
            if(bufferState_ != OPEN) {
                return false;
            }
            bufferState_ = LOCK;
        }
        value_[!primary_] = newValue;
        bufferState_ = CLOSE;
        return true;
    }
};
get メソッド

primary な『値』へのリファレンスを返します。もしバッファが変更されていれば、primary の指し先を secondary へと変更します。添字の変数名を idx_ → primary_ にしたことで、

return value_[primary_];

という一文の意図が、より明確に伝わるようになったと思います。まあ、primary の意味を知らなければ伝わりませんが、それは仕方のないことです。

set メソッド

T のリファレンスを受け取り、更新可能であれば secondary な値に代入します。このことから、T には operator= を実装する責任が発生しています。内容的には、以前の BufferedObject における secondary() と commit() を一つのメソッドにまとめたものと思えばいいですね。また、今回の実装では以前のように secondary の値を取り出してゴニョゴニョ... ということができなくなっています。つまり、 secondary は書き込み専用ということです。自由度が低下して不便になった印象があるかもしれませんが、使い方はシンプルで迷うこともないでしょう。個人的には、BufferedObject よりずっと安全な設計だと思います。


排他の位置に注目すると、bufferState_ を読み書きする瞬間だけに限定しているのがわかります。これは、get の応答時間が operator= の処理時間に引きずられて低下するのを避けたかったからです。例えば、

  1. スレッド A:foo.set(newValue)
  2. スレッド B:foo.get()

というシナリオを考えてみます。仮に set 全体で Guard(mutex_) していたら、スレッド B の get は set が完了するまで待たされることになります。set 中の operator= が一瞬で済めば良いですが、やっていることはオブジェクトの代入です。L 辞書のような巨大なオブジェクトをコピーすることを考えると、ちょっと不安になりますよね。そこで、パフォーマンスの低下を未然に防ぐためにも、Guard(mutex_) のスコープを限定しているわけです。


一方で、secondary の値を更新する時に Mutex で排他しなくても大丈夫なんでしょうか。コードを見ればわかるように、bufferState_ の値が OPEN かどうかで更新可能かどうかを判断しています。仮に複数のスレッドが同時に set を呼び出しても、secondary を更新できるのはそのうちの一つのスレッドに限定されます。その他のスレッドは bufferState_ が LOCK か CLOSE になっているので失敗するわけです。これで、set 全体をロックしなくても良いことがわかりました。


さて、大分良くなった気はするのですが、set が失敗するのはやっぱり不自然だし、何か納得がいきません。そこで、さらにリファクタリングを進めてみます。

BufferedValue version 2.0
template <typename T>
class BufferedValue {
    int primary_;
    bool changed_;
    T value_[2];
    Mutex mutex_;

public:
    BufferedValue() : primary_(0), changed_(false) {}
    T& get() {
        Guard on(mutex_);
        if(changed_) {
            primary_ = !primary_;
            changed_ = false;
        }
        return value_[primary_];
    }

    void set(const T& newValue) {
        Guard on(mutex_);
        value_[!primary_] = newValue;
        changed_ = true;
    }
};

bufferState_ のかわりに、より単純な changed_ 変数を導入しました。secondary な値に変更があれば true になります。set はいつでも呼び出し可能になり、かつ、失敗しなくなりました。


なぜ今までの実装では set が失敗するようになっていたかと言うと、『secondary への変更は必ず primary に反映されるべき』という個人的なポリシーを持っていたからです。つまり、set が連続して呼ばれることで、secondary の値が上書きされることを良しとしていなかったわけです。ですが、今回はそのポリシーを曲げて妥協しています。


ところで、先程と今回の set を見比べて、「あれ、それでいいの?」と思っている人がいるかもしれませんね。その通り、今回の実装では Guard(mutex_) が set 全体を排他しています。パフォーマンスがどうのこうのとさんざん能書きを垂れていたのは一体なんだったのかと疑問に思うことでしょう。全く同感です。ほんとにいい加減でけしからん輩ですね、t_suwa は。

急な心変わりの理由は単純です。BufferedValue version 1.0 を書いた後で、operator= のパフォーマンスを実際に測定してみたのです。測定に用いたのは

typedef std::pair<std::string, std::string> SKKPair;
typedef std::vector<SKKPair> EntryContainer;
EntryContainer okuriAri_;
EntryContainer okuriNasi_;
EntryContainer okuriAriCopy_;
EntryContainer okuriNasiCopy_;

というコンテナです。で、まずは L 辞書のエントリを okuriAri_ と okuriNasi_ それぞれにロードします。次に、

okuriAriCopy_ = okuriAri_;
okuriNasiCopy_ = okuriNasi_;

に要する時間を計測します。その結果、わずか 0 秒 〜 0.1 秒で完了することがわかりました。計測には、以前紹介した StopWatch クラスを使っています。とにもかくにも、パフォーマンス低下に関する懸念は払拭されたわけです。それで、よりシンプルに set 全体をロックするようにしています。


さて、BufferedValue version 2.0 はこのままでもかなり良いと思うのですが、もう少しなんとかなりそうな予感があります。プログラマとしての勘というか、どことなく匂うというか。そこで、さらにしつこく粘っこくネチネチとリファクタリングしてみます。

BufferedValue version 3.0
template <typename T>
class BufferedValue {
    T value_;
    Mutex mutex_;

public:
    T get() {
        Guard on(mutex_);
        return value_;
    }

    void set(const T& newValue) {
        Guard on(mutex_);
        value_ = newValue;
    }
};

驚きです。添字も配列も状態もなくなってしまいました。もうこのクラスをくどくど説明する必要はありませんね。get では T のコピーが返されて、set では T を代入しているだけです。どちらもしっかりと排他制御されています。


しかし、get で毎回コピーが発生するのはいかがなものでしょうか。いくら先程の計測でコピーが低コストだとわかっていたとしても、なんだか背中がムズムズします。なにか、とても無駄遣いをしている気になってしまうのは、根が貧乏性だからかもしれません。でも、この違和感をほうっておくわけにもいきません。


ということで、次回(まだ続くのか!)は最終的な BufferedValue クラスの実装を紹介したいと思います。