前回の 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.Scoperef 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 ステートメント

付随して、同じく「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.EnterMonitor.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 を書けるようにする」みたいなことはしません。)