ページ

2010年9月9日木曜日

[C#] yield return はスレッドセーフなのか?

スレッドセーフと言ってもいろいろ意味がありますが、ここでは以下のようなコードのことを指してます。

class YieldTest
{
    private List<Hoge> list = new List<Hoge>();

    public IEnumerable<Hoge> GetHoges1(string s)
    {
        lock (this.list)
        {
            var result = new List<Hoge>();
            foreach (var x in this.list)
            {
                if (x.Value == s)
                {
                    result.Add(x);
                }
            }
            return result;
        }
    }

    以下略

見てもらったまんまですが、何かの List があって、この List は別のスレッドからも操作されるからアクセス時には必ず lock すること、というような場合です。
で、上記の GetHoges1() メソッドは以下のように yield return を使って書いてもほとんど同じ意味です。

    public IEnumerable<Hoge> GetHoges2(string s)
    {
        lock (this.list)
        {
            foreach (var x in this.list)
            {
                if (x.Value == s)
                {
                    yield return x;
                }
            }
        }
    }

さて、この yield return を使った GetHoges2() メソッドはスレッドセーフなんでしょうか?

yield return っていうのは、魔法の力で動いているわけではなく、C# コンパイラが自動的に IEnumerator を実装したクラスを作成してくれて動いてるわけです。どんなコードが作成されるのかを言葉で説明するのは難しいですが、yield return や yield break のところでバラバラに分割してうまいこと MoveNext() に収め直すというような感じです。ですから、普通に考えると lock のスコープを抜けてしまいきちんと排他されないんじゃないか?と思えるわけです。
C# 言語仕様」 の 「8.14 yield ステートメント」 を見ると try ~ catch の中に yield は書けない、というようなことが書いてあります。しかし lock に関することは何も書いてありません。

というわけで、実際に生成されたコードを Reflector で見てみました。
すると以下のようになってました。

private class Enamerator : IEnumerator<Hoge>, IDisposable
{
    private List<Hoge> list = (this.list がセットされる)
    private IEnumerator<Hoge> enumertor;
    private bool lockToken = false;

    public Hoge Current { get; set; }

    public bool MoveNext()
    {
        if (最初の実行)
        {
            Monitor.Enter(this.list, ref this.lockToken);
            this.enumertor = this.list.GetEnumerator();
        }
        while (this.enumertor.MoveNext())
        {
            if (条件判定)
            {
                this.Current = this.enumertor.Current;
                return true;
            }
        }

        // 9/10 13時追記
        // コードは省略しますが、ここで下の Dispose() メソッドと同じ処理(Monitor.Exit の呼び出し)が行われます。

        return false;
    }

    public void Dispose()
    {
        if (this.lockToken)
        {
             Monitor.Exit(this.list);
        }
    }
}

上記のコードは意味的にはこんな感じになっているという例であって、実際の自動生成されたコードとはかなり違うんですが、まぁ、こんな感じです。
どうでしょう?ちゃんと、初めて MoveNext() メソッドが呼ばれたときに Monitor.Enter で list に対してロックをかけ、Dispose() メソッドで Monitor.Exit でロックを解除しています。

-- 9/10 13時追記 ここから
上にも書き足しましたが、MoveNext() が false を返すときにも Dispose() と同じ処理が行われるようになっています。なので、Enumerator の Dispose() メソッドを呼び出したときか、MoveNext() が false を返して列挙が終わるときにロックが解除されることになります。
-- ここまで追記

すげぇ!C# コンパイラは lock のことまで考慮して yield return の処理をしてるのか!

。。。と、思いましたが、実は違いますね。
まず、lock ステートメントは C# のシンタックスシュガーで、コンパイルすると Monitor を使ったコードが出来上がります。yield return と違ってそんなにややこしくなく、以下のような try ~ finally で Monitor.Enter と Monitor.Exit を囲んだコードに変換されるだけです。全体を try { ~ } で囲み、finally で Monitor.Exit() を呼び出すことによって途中で例外が発生しようが何しようがスコープを抜けるときには確実にロックが解除されるようになっているわけですね。

lock (this.list)
{
    ...
}

---- 上記をコンパイルすると下記のコードができあがる ----

bool lockToken = false;
try
{
    Monitor.Enter(this.list, ref lockToken);

    // ここに lock { ... } の ... 部分が入る
}
finally
{
    if (lockToken)
    {
        Monitor.Exit(this.list);
    }
}

また、「C# 言語仕様」 の 「8.14 yield ステートメント」 に try ~ catch の中に yield は書けないとありますが、try ~ finally の中には書けるとあります。
で、try ~ finally の中に yield が書いてある場合は、finally の部分が自動生成された Enumerator の Dispose() メソッドの中で実行されるわけですね。
だから、C# コンパイラはいつもどおりに lock ステートメントを解釈し、いつもどおりに yield return を解釈すると、自動的に正しく lock が働くコードが生成されるわけです。

というわけで、まとめ。

lock { ~ } の中で yield return を使ってもきちんとロックされる。

ただし、重要な注意点があります。
上記の自動生成されるコードを見れば明らかですが

lock { ~ } の中で yield return を使った場合は、返された Enumerator の Dispose() メソッドを呼ぶまでロックが解除されない。

なお、多くの場合、yield return で返された Enumerator は foreach で使われるんじゃないかと思います。
foreach ステートメントは Enumerator が IDisposable を実装している場合は自動的に Dispose() メソッドを呼び出すようになっています。(「C# 言語仕様」 の 「8.8.4 foreach ステートメント」。これは C# の最初のバージョンからそういう仕様だったと思います) だから foreach で使う分にはきちんと Dispose() が呼ばれますから何も問題ありません。
というか、Enumerator は多くの場合に IDisposable を実装していますから、それをチェックしてきちんと Dispose() メソッドを呼び出してやらないとまずいことになる可能性大です。foreach を使わずに Enumerator を受け取るときは using を使うなりしてちゃんと Dispose() を呼び出してやりましょう。

そして、ロックが解除されるタイミングが違うというのも大きな違いですね。
最初の例の GetHoges1() では lock のスコープを外れた時点でロックが解除されます。
それに対して GetHoges2() では Enumerator を受け取った側で Dispose() を呼び出した時点でロックが解除されます。ということは、ロックが解除されるタイミングが呼び出し元の処理に依存してしまうわけですね。
なるべく早くにロック解除してやって他のスレッドが動けるようにしてやりたいというのが普通でしょうから、呼び出し元に依存しちゃうのは避けたい場合が多いんじゃないかと思います。

というわけで、本当のまとめ。

lock { ~ } の中で yield return を使ってもきちんとロックされる。しかし、Enumerator の Dispose() が呼ばれるまでロックが解除されないし、いつ Dispose() が呼ばれるかも呼び出し元に依存することになる。
だから lock { ~ } の中で yield return を使うのはやめておいた方がよさげ。

9/10 追記を書きました。
[C#] yield return はスレッドセーフなのか? 追記

2 件のコメント:

  1. こんなまとめだと、同じ理由で using { } も try-finally も yield return を使えないってことですね。

    返信削除
  2. コメントどうもです。
    追記をコメントしようと思いましたが、長くなったので本文に追記、、、と思ったらずいぶん長くなったので別記事にしましたw
    http://shinichiaoyagiblog.divakk.co.jp/2010/09/c-yield-return_10.html

    返信削除