前回の Lock
クラスの話を見てから、とりあえず以下のコードを見てほしい。
using System.Runtime.Versioning; [module: RequiresPreviewFeatures] class MultiThreadCode { private static readonly object _syncObj = new(); private static readonly Lock _syncLock = new(); public static IEnumerable<object?> MIterator() { lock (_syncObj) { } // 旧来 lock。 lock (_syncLock) { } // 新しい lock (VS 17.10p2 以降)。 yield return null; } public static async ValueTask MAsync() { lock (_syncObj) { } lock (_syncLock) { } // これだけダメ(VS 17.10p2 以降)。 await Task.Yield(); } }
おそらく C# 13 正式リリースまでには直ると思うんですが、 どうしてこうなるのかと、どう対処する予定なのかという話になります。
ちなみに、単に Lock
クラスに対して特殊処理をするという話ではなく、
もう少し汎用に「非同期メソッド中で ref ローカル変数を使えるようにする」という対処になります。
lock の展開
[前回の話]で、今回関係するのは、Lock
インスタンスに対する lock
ステートメントが using (x.EnterScope())
み化けるという点。
で、さらにいうと、using
は以下のように展開されます。
class MultiThreadCode { private static readonly Lock _syncLock = new(); // 元コード。 public static void A() { lock (_syncLock) { } } // lock → using。 public static void B() { using (_syncLock.EnterScope()) { } } // using → try-finally。 public static void C() { Lock.Scope scope = _syncLock.EnterScope(); try { } finally { scope.Dispose(); } } }
ここで、Lock.Scope
は ref struct になっています。
これが先ほどのコードで非同期メソッド中の lock (_syncLock)
がエラーになる原因です。
問題の本質としては以下のようなコードと同じ。
class A { public static IEnumerable<object?> MIterator() { // イテレーター中では ref strcut を使える。 // (ただし、yield をまたがない場合のみ。) Span<int> span = stackalloc int[1]; yield return null; } public static async ValueTask MAsync() { // こちらはダメ。 Span<int> span = stackalloc int[1]; await Task.Yield(); } }
イテレーターと非同期メソッドって、仕組みがかなり似ていて、「イテレーターでできて非同期メソッドでできない」ということは原理的にはあまりないんですが。 実際、上記の挙動は単に実装都合で、コストさえかければ「非同期メソッド中でも ref struct のローカル変数を書けるようにする」というのは可能です。
イテレーターの中断と再開
「イテレーターのコンパイル結果」辺りで書いてるんですが、 イテレーターは「中断と再開」をするようなコードが生成されます。
例えば以下のようなコードを書いたとき、
foreach (var x in M()) { Console.WriteLine(x); } IEnumerable<int> M() { var x = 1; yield return x * x; // 式は適当。 // ここで重要なのは、y は yield をまたがないということ。 var y = ++x * x; y *= y; yield return y; // 同、z は yield をまたがない。 var z = ++x; z *= (2 * x + 1); yield return z; }
おおむね、以下のようなクラスが生成されます。 (簡単化のためちょこっとさぼっています。要点のみ。)
var e = new MImpl(); while (e.MoveNext()) { Console.WriteLine(e.Current); } class MImpl { private int _i = 0; private int _x = 1; public int Current { get; private set; } public bool MoveNext() { if (_i == 0) { Current = _x * _x; } else if (_i == 1) { var y = ++_x * _x; y *= y; Current = y; } else if (_i == 2) { var z = ++_x; z *= (2 * _x + 1); Current = z; } else { return false; } _i++; return true; } }
ここで重要なのは以下の点。
yield
をまたいで使う変数はフィールドに昇格する- そうでないものはローカル変数のまま
つまり、「yield
さえまたがなければ、ローカル変数に制限を掛ける必要はない」ということになります。
ここではイテレーターで話しましたが、非同期メソッドもほぼ同様で、
「await
さえまたがなければ、ローカル変数に制限を掛ける必要はない」といえたりします。
ただまあ、これはあくまで「原理的には」という話であって、じゃあ、現在の実装がどうなっているかというと… C# 12 時点では以下のような感じ。
class A { public static void M() { RefStruct rs = new(); using (rs) { } foreach (var _ in rs) ; int x = 1; ref int r = ref x; } public static IEnumerable<object?> MIterator() { RefStruct rs = new(); using (rs) { } foreach (var _ in rs) ; // ダメ。 int x = 1; ref int r = ref x; // ダメ。 yield return null; } public static async ValueTask MAsync() { RefStruct rs = new(); // 非同期メソッドだとこの時点でダメ。 using (rs) { } // ダメ。 foreach (var _ in rs) ; // ダメ。 int x = 1; ref int r = ref x; // ダメ。 await Task.Yield(); } } ref struct RefStruct { public void Dispose() { } public RefStruct GetEnumerator() => this; public int Current => 0; public bool MoveNext() => false; }
どれも、「ref struct のローカル変数が認められるのであれば書けてもいいはずのコード」になります。 ところが、大丈夫なものとコンパイル エラーになるものがまちまち。
ref/ref struct 変数を非同期メソッド中で使えるように
まあ既知の問題ではあったんですが。
これまで、需要がそこまでないからか、ずっと放置されていました。
ところが、今回「Lock
クラスに対する lock
ステートメント」問題が出たからか、急に対処することになったみたいです。
先ほどの、以下のようなコード、すべて「yield
/await
さえまたがなければ認める」ということになりそうです。
RefStruct rs = new(); using (rs) { } foreach (var _ in rs) ; int x = 1; ref int r = ref x;
- ref ローカル変数
-
ref struct のローカル変数
- ref struct に対する
using
ステートメント - ref struct に対する
foreach
ステートメント
- ref struct に対する
付随して、同じく「yield
/await
さえまたがなければ認める」という条件で、
unsafe
ブロックも認めるそうです。
lock 中の yield
逆に、「これまで書けちゃっていたけども、実はまずかった」というものに警告を出そうという話もあります。
それが「lock
ステートメント中の yield
」です。
class MultiThreadCode { private static readonly object _syncObj = new(); public static IEnumerable<object?> MIterator() { lock (_syncObj) { // これが書けちゃう。使い方によってはまずい。 yield return null; } } public static async ValueTask MAsync() { lock (_syncObj) { // 非同期メソッドの場合、コンパイル エラーになるので大丈夫。 await Task.Yield(); } } }
.NET の実装では、
「ロックの開始と終了(内部的には Monitor.Enter
と Monitor.Exit
)は同じスレッドでやらないといけない」という制限がありまして。
非同期メソッドの方はわかりやすく「await
をまたぐと別スレッド」感があるのでコンパイルの時点でエラーにしています。
で、イテレーターの方も使い方によっては「yield
をまたぐと別スレッドになることがある」という意味では危険で、
例えば、以下のようなコードを書くと実行時に SynchronizationLockException
例外が出ます。
object syncObj = new(); IEnumerable<object?> M() { lock (syncObj) { // これが書けちゃう。使い方によってはまずい。 yield return null; } } foreach (var _ in M()) { // M 内に非同期コードがなくても、利用側が非同期だった時点でアウト。 await Task.Yield(); }
ということで、この「lock
中での yield
」も警告を足すことになりそうです。
(いきなりエラーにすると破壊的変更になるのでとりあえず警告。
何バージョンかかけてエラーに変更する可能性はあり。)
(※ 「スレッドをまたいだ lock
を書けるようにする」みたいなことはしません。)