ページ

2010年9月10日金曜日

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

昨日の 「[C#] yield return はスレッドセーフなのか?」 に匿名さんからコメントを頂きました。
昨日の記事の最後のところにまとめとして 「lock { ~ } の中で yield return を使うのはやめておいた方がよさげ」 と書きましたが、ちょっと乱暴なまとめだと思ったので追記します。
(最初は昨日の記事に追記するつもりだったんですが、長くなったので別記事にしました)

えーと、もともと 「lock { ~ } の中で yield return を使うのはやめておいた方がよさげ」 と私が思ったのは 「ロックが解除されるタイミングが呼び出し元に依存するから」 というのが理由です。
お仕事で lock { ~ } の中で yield return を使うコードを書いていたときに、ふと、「これってちゃんとロックかかるの?」 と疑問に思ったのが発端で今回のことを調べたんです。このとき書いていたコードは 「なるべく早くロックを解除して他のスレッドが動けるようにしたい」 というものだったので、自然とロック解除のタイミングが呼び出し元に依存するのは避けたいと思ってました。
そういう前提があったので 「lock { ~ } の中で yield return を使うのはやめておいた方がよさげ」 と、まとめのところに書きました。けど、そういう前提であることをあまり明確にせずにこうまとめちゃうのはちょっとまずかったですね。

-- 9/10 13時追記 ここから
前の記事にもちょこっと追記しましたが、指摘を頂いたのでこちらにも追記しておきます。
すっかりロックが解除されるのは Enumerator の Dispose() が呼び出されたときだけかのように書いちゃってますが、実際には MoveNext() が false を返すときにも Dispose() と同じ処理が行われるようになっています。なので、Enumerator の Dispose() メソッドを呼び出したときか、MoveNext() が false を返して列挙が終わるときにロックが解除されることになります。
もちろん、以下に書いたファイルの例でも同じです。
-- ここまで追記

ということで、また他の例を。

コメントで頂いたように using { ~ } や try ~ finally でも lock { ~ } と同じことが起こります。
たとえば、以下のようなコード。

private IEnumerable<string> GetLines()
{
    using (var stream = new FileStream("test.txt", FileMode.Open, FileAccess.Read, FileShare.None))
    using (var reader = new StreamReader(stream))
    {
        var text = reader.ReadToEnd();
        var lines = text.Split('\n');
        foreach (var line in lines)
        {
            yield return line;
        }
    }
}

これはファイルをオープンするときに FileShare.None を指定してファイルをアクセス禁止にしています。
yield return が無ければ using のスコープを抜けるときに stream.Dispose() と reader.Dispose() が呼び出されてファイルがクローズされます。もちろん、このときにファイルのアクセス禁止も解除されます。
しかし、yield return によって出来上がるコードは lock { ~ } のときに見たのと同じようなものですから、stream.Dispose() と reader.Dispose() が呼び出されるのは Enumerator の Dispose() メソッドからになります。
呼び出し元が foreach を使っているときは、以下のような感じになるわけです。

foreach (var line in GetLines())
{

    ... line を使って何か処理 ...

}
// foreach を抜けたときに Enumerator の Dispose() が呼ばれて test.txt のアクセス禁止が解除される

このように GetLines() から返ってきた Enumerator での列挙が終わったあとに、foreach によって作られた Enumerator.Dispose() の呼び出しが行われて、ファイルクローズおよびアクセス禁止解除ということになります。

では、yield return を使わない場合はどうなるでしょうか?
以下のようなコードです。

private IEnumerable<string> GetLines()
{
    using (var stream = new FileStream("test.txt", FileMode.Open, FileAccess.Read, FileShare.None))
    using (var reader = new StreamReader(stream))
    {
        var text = reader.ReadToEnd();
        var lines = text.Split('\n');
        return lines;
    }
}

この場合は、GetLines() メソッドから return する時点で using のスコープを抜け、stream.Dispose() と reader.Dispose() が呼び出されてファイルがクローズされ、アクセス禁止が解除されます。
ですから、呼び出し元が上記と同じ foreach を使ったものであっても、foreach の列挙が始まるときにはすでにファイルがクローズされている状態になっています。

で、これはどちらが正しいとか言えるようなたぐいのものではありません。
前者は 「列挙が終わって Enumerator の Dispose() メソッドを呼び出すまでファイルがアクセス禁止になる」、後者は 「Enumerator を作成する間だけファイルがアクセス禁止になる」 ということになるわけですが、どちらにしたいかなんてことはケースバイケースです。
ただ、今回取り上げたような yield return の動作を知らないと 「yield return のところで using のスコープを抜けてファイルはクローズされるんじゃないの?」 と勘違いしやすいとは言えると思います。

というわけで、結局のところ、lock { ~ }、using { ~ }、try ~ finally などスコープに重要な意味があるものの中に yield return を書くときはちょっと注意が必要ではあると思うけど、yield return でいいかどうかなんてことはケースバイケース、という感じですね。

それにしても、yield return についてちゃんと考えたのなんて今回が初めてなんですが、なかなか興味深かったです。

0 件のコメント:

コメントを投稿