スレッドセーフな実装について

前回
blog1.mammb.com

について書いたのでついでにスレッドセーフについても書いとこ。



レースコンディションとは

Javaではスレッドを比較的簡単に扱うことができますが、その利用にはいろいろと注意しなければならないことがあります。スレッドは簡単に扱えますが、スレッドの安全性を確保しつつ実行性能を得るのは難しいトピックの一つです。

以下のクラスはスレッドセーフではありません。

public class NotThreadSafe {
    private long nextLong;
    public long getNextLong() {
        return ++nextLong;
    }
}

getNextLong()の呼び出しは、複数スレッドからの呼び出しタイミングにより、正しい結果が得られなくなる可能性があります。タイミングが悪いと、複数のスレッドが同じ値を受け取ることがあります。
このような複数スレッドの実行タイミングにより正しい結果が得られない状態をレースコンディション(Race Condition)と呼びます。

レースコンディションとなる状況には read-modify-write と呼ばれる、ある共有のステート変数を読み込んで、変更して、書き戻すといった操作や、check-then-act と呼ばれる、ある共有のステート変数をチェックしてから、その結果に基づいて処理を行う操作などがあります。
このような操作を複数スレッド下で行う場合には適切な同期処理を行い、スレッドセーフな実装を行う必要があります。


スレッドセーフ化

前述のクラスは単純な例なので、スレッドセーフにするのは簡単です。getNextLong()メソッドを同期メソッドにするだけです。

public class NotThreadSafe {
    private long nextLong;
    public synchronized long getNextLong() {
        return ++nextLong;
    }
}

またJDKのライブラリとして提供されているアトミック変数を使用することでスレッドセーフにすることができます。

public class NotThreadSafe {
    private final AtomicLong nextLong = new AtomicLong(0);
    public long getNextLong() {
        return nextLong.incrementAndGet();
    }
}

オブジェクト型には AtomicReference というクラスが提供されています。


複合アクション

前述の例ではアトミック変数を用いてスレッドセーフを確保しましたが、複合アクションとなる場合は上手くいきません。
以下の例は、引数の素数を計算して返却し、その時最後に計算したものをキャッシュしようとしています。キャッシュにはアトミック変数を使用してはいますが、スレッドセーフではありません。

private final AtomicReference<BigInteger> lastNumber = new AtomicReference<BigInteger>();
private final AtomicReference<BigInteger[]> cache = new AtomicReference<BigInteger[]>();

public BigInteger[] getPrimeNumbers(BigInteger i) {
    if (i.equals(lastNumber.get()))
        return cache.get();
    else {
        BigInteger[] primes = makePrimeNumbers(i);
        lastNumber.set(i);
        cache.set(primes);
    }
}

それぞれのアトミック変数の操作はスレッドセーフではありますが、複数スレッドにより2つの変数の更新タイミングが入れ替わることがあります。


では以下のように同期メソッドにすれば解決でしょうか?

private BigInteger lastNumber;
private BigInteger[] cache;

public synchronized BigInteger[] getPrimeNumbers(BigInteger i) {
    if (i.equals(lastNumber))
        return cache;
    else {
        BigInteger[] primes = makePrimeNumbers(i);
        lastNumber = i;
        cache = primes;
    }
}

getPrimeNumbersの素数計算に時間が掛かると、他の全てのスレッドが待ち状態となり実行性能が著しく低下する原因になります。これでは計算結果をキャッシュしている意味がありませんね。


複合アクションをスレッドセーフにする

複合アクションを実効性能を維持したままにスレッドセーフにすると以下のようになります。

private BigInteger lastNumber;
private BigInteger[] cache;

public BigInteger[] getPrimeNumbers(BigInteger i) {
    BigInteger[] ret = null;
    synchronized (this) {
        if (i.equals(lastNumber))
            ret = cache.clone();
    }
    if (ret == null ) {
        ret = makePrimeNumbers(i);
        synchronized (this) {
            lastNumber = i;
            cache = ret.clone();
        }
    }
    return ret;
}

時間のかかる素数処理の makePrimeNumbers メソッド(このメソッドは単なる関数でインスタンス変数の操作は行わない前提)は同期ブロックから外し、その他のインスタンス変数に対する処理を同期ブロックに収めています。
同期ブロック外で操作するのはスタック変数のみとなります。また、返却値は配列(オブジェクト型)なので、キャッシュとの代入処理ではクローンによりコピーをやり取りしています。


その他スレッド関連ではもう少し重要な点があるので次回、

blog1.mammb.com