11
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

【C#】ラムダ式の変数束縛で罠にハマる 〜ILを調べて実態を調査〜

Posted at

本記事は、私が最近ハマった罠について調べたものです。
クロージャについて面白いことが分かったので、ちょっと記事にしてみました。

TL;DR

ラムダ式の変数束縛に注意しよう!

example.cs
for (var i = 1; i <= 10; i++)
{
    var ii = i; // いったんローカル変数に代入しないと大変なことに
    yield return Task.Run(() => ii);
}

本記事のために作成したコード

以下のGitHubに置いてあります。

罠の概要

以下のように、for文でTaskを生成したところ、結果がおかしくなります。

example.cs
using System;
using System.Collections.Generic;
using System.Threading.Tasks;

class Program
{
    static async Task Main(string[] args)
    {
        foreach (var i in await Task.WhenAll(A.F()))
        {
            Console.WriteLine(i);
        }
    }
}

static class A
{
    public static IEnumerable<Task<int>> F()
    {
        for (var i = 1; i <= 10; i++)
        {
            yield return Task.Run(() => i);
        }
    }
}
output
3
3
7
11
7
7
7
11
11
11

本来なら1 〜 10が表示されてほしかったのですが、うまくいきませんでした。

おかしくなった理由

理由は単純なことで、for文のカウンターをラムダ式が直接束縛しているからです。

example.cs
for (var i = 1; i <= 10; i++)
{
    yield return Task.Run(() => i); // iを直接束縛している
}

このため、Taskが生成されたタイミングではなく、Taskのスレッドが走ったタイミングにおけるiの値が返されてしまっています。

なるほど、iの変数が複数のスレッドで使いまわされているのね。

ちょっと待て

iint型なので、「値型」です。

値型なので、異なるコンテキストから値を変更することは基本的にはできません。ラムダ式の束縛って、どうやって実現されているんでしょうか?

調べてみる

まず、以下のようなコードを書いてみました。

ClosureSample.Sample1_Simple_1/Program.cs
using System;

namespace ClosureSample.Sample1_Simple_1
{
    class Program
    {
        static void Main(string[] args)
        {
            Console.WriteLine(A.F());
        }
    }

    static class A
    {
        public static int F()
        {
            var i = 0;
            Action act = () => i++; // ラムダ式の内部でiをインクリメント
            act(); // 処理実行
            return i;
        }
    }
}
output
1

束縛されたiが、ちゃんとインクリメントされていますね。

このコードのIL(中間言語)を解析してみました。すると、以下のコードと大体等価であることが分かりました。

ClosureSample.Sample1_Simple_2/Program.cs
using System;

namespace ClosureSample.Sample1_Simple_2
{
    class Program
    {
        static void Main(string[] args)
        {
            Console.WriteLine(A.F());
        }
    }

    static class A
    {
        public static int F()
        {
            var i = 0;
            var c = new C(); // クロージャのインスタンスを生成
            c.i = i; // 束縛
            c.B(); // 実行
            i = c.i; // 束縛した変数の書き戻し
            return i;
        }

        // クロージャ用の内部クラスが生成されている
        class C
        {
            public int i; // 束縛する変数

            public void B() // ラムダ式本体
            {
                i++;
            }
        }
    }
}

ふむふむ、束縛って変数のポインタを使っているのかと思っていましたが、単純にフィールドへの代入と書き戻しによって実現されていたんですね。

for + yieldは?

最初の例で使用していたyieldは、どのようなコードに変換されるか見てみましょう。

まずは元のコードです。

ClosureSample.Sample3_Yield_1/Program.cs
using System;
using System.Collections.Generic;

namespace ClosureSample.Sample3_Yield_1
{
    class Program
    {
        static void Main(string[] args)
        {
            foreach (var i in A.F())
            {
                Console.WriteLine(i);
            }
        }
    }

    static class A
    {
        public static IEnumerable<int> F()
        {
            for (var i = 1; i <= 10; i++)
            {
                yield return i;
            }
        }
    }
}
output
1
2
3
4
5
6
7
8
9
10

これは、次のコードと大体等価です。

ClosureSample.Sample3_Yield_2/Program.cs
using System;
using System.Collections;
using System.Collections.Generic;

namespace ClosureSample.Sample3_Yield_2
{
    class Program
    {
        static void Main(string[] args)
        {
            foreach (var i in A.F())
            {
                Console.WriteLine(i);
            }
        }
    }

    static class A
    {
        public static IEnumerable<int> F()
        {
            return new D(-2); // 最初は無効な-2がstateに設定される
        }

        // yield用の内部クラスが生成されている
        class D : IEnumerable<int>, IEnumerator<int>, IDisposable
        {
            private int state;
            private int current;
            private int initialThreadId;
            private int i;

            public D(int st)
            {
                state = st;
                initialThreadId = Environment.CurrentManagedThreadId;
            }

            public void Dispose()
            {
            }

            public int Current => current;

            object IEnumerator.Current => current;

            public bool MoveNext()
            {
                switch (state)
                {
                    case 0: // 1回目
                        state = -1;
                        i = 1;
                        break;
                    case 1: // 2回目以降
                        state = -1;
                        i++;
                        break;
                    default:
                        return false;
                }

                // for文の中身
                if (i <= 10)
                {
                    current = i;
                    state = 1;
                    return true;
                }
                else
                {
                    return false;
                }
            }

            public void Reset()
            {
                throw new NotSupportedException();
            }

            public IEnumerator<int> GetEnumerator()
            {
                if (state == -2 && initialThreadId == Environment.CurrentManagedThreadId)
                {
                    state = 0;
                    return this;
                }
                else
                {
                    return new D(0);
                }
            }

            IEnumerator IEnumerable.GetEnumerator()
            {
                return GetEnumerator();
            }
        }
    }
}

以上のように、MoveNextをステートマシンでうまく作っていますね。

最初の例に戻る

ClosureSample.Sample4_Task_1/Program.cs
using System;
using System.Collections.Generic;
using System.Threading.Tasks;

namespace ClosureSample.Sample4_Task_1
{
    class Program
    {
        static async Task Main(string[] args)
        {
            foreach (var i in await Task.WhenAll(A.F()))
            {
                Console.WriteLine(i);
            }
        }
    }

    static class A
    {
        public static IEnumerable<Task<int>> F()
        {
            for (var i = 1; i <= 10; i++)
            {
                yield return Task.Run(() => i);
            }
        }
    }
}

これは、次のコードと大体等価です。

ClosureSample.Sample4_Task_2/Program.cs
using System;
using System.Collections;
using System.Collections.Generic;
using System.Threading.Tasks;

namespace ClosureSample.Sample4_Task_2
{
    class Program
    {
        static async Task Main(string[] args)
        {
            foreach (var i in await Task.WhenAll(A.F()))
            {
                Console.WriteLine(i);
            }
        }
    }

    static class A
    {
        public static IEnumerable<Task<int>> F()
        {
            return new D(-2);
        }

        // クロージャ用の内部クラス
        class C
        {
            public int i;

            public int B()
            {
                return i;
            }
        }

        // yield用の内部クラス
        class D : IEnumerable<Task<int>>, IEnumerator<Task<int>>, IDisposable
        {
            private int state;
            private Task<int> current;
            private int initialThreadId;
            private C c;

            public D(int st)
            {
                state = st;
                initialThreadId = Environment.CurrentManagedThreadId;
            }

            public void Dispose()
            {
            }

            public Task<int> Current => current;

            object IEnumerator.Current => current;

            public bool MoveNext()
            {
                switch (state)
                {
                    case 0: // 1回目
                        state = -1;
                        c = new C(); // クロージャが最初にしか作られていない!
                        c.i = 1;
                        break;
                    case 1: // 2回目以降
                        state = -1;
                        c.i++; // 単一のインスタンスのiを更新している!
                        break;
                    default:
                        return false;
                }

                // for文の中身
                if (c.i <= 10)
                {
                    current = Task.Run(c.B); // 各タスクに同じインスタンスを渡している!
                    state = 1;
                    return true;
                }
                else
                {
                    c = null;
                    return false;
                }
            }

            public void Reset()
            {
                throw new NotSupportedException();
            }

            public IEnumerator<Task<int>> GetEnumerator()
            {
                if (state == -2 && initialThreadId == Environment.CurrentManagedThreadId)
                {
                    state = 0;
                    return this;
                }
                else
                {
                    return new D(0);
                }
            }

            IEnumerator IEnumerable.GetEnumerator()
            {
                return GetEnumerator();
            }
        }
    }
}

これは、おかしくなって当然ですね。

改修する

ClosureSample.Sample5_Local_1/Program.cs
using System;
using System.Collections.Generic;
using System.Threading.Tasks;

namespace ClosureSample.Sample5_Local_1
{
    class Program
    {
        static async Task Main(string[] args)
        {
            foreach (var i in await Task.WhenAll(A.F()))
            {
                Console.WriteLine(i);
            }
        }
    }

    static class A
    {
        public static IEnumerable<Task<int>> F()
        {
            for (var i = 1; i <= 10; i++)
            {
                var ii = i; // いったんローカル変数に代入する
                yield return Task.Run(() => ii); // ローカル変数を渡す
            }
        }
    }
}
output
1
2
3
4
5
6
7
8
9
10

これは、次のコードと大体等価です。

ClosureSample.Sample5_Local_2/Program.cs
using System;
using System.Collections;
using System.Collections.Generic;
using System.Threading.Tasks;

namespace ClosureSample.Sample5_Local_2
{
    class Program
    {
        static async Task Main(string[] args)
        {
            foreach (var i in await Task.WhenAll(A.F()))
            {
                Console.WriteLine(i);
            }
        }
    }

    static class A
    {
        public static IEnumerable<Task<int>> F()
        {
            return new D(-2);
        }

        // クロージャ用の内部クラス
        class C
        {
            public int ii;

            public int B()
            {
                return ii;
            }
        }

        // yield用の内部クラス
        class D : IEnumerable<Task<int>>, IEnumerator<Task<int>>, IDisposable
        {
            private int state;
            private Task<int> current;
            private int initialThreadId;
            private C c;
            private int i; // カウンターがフィールドとして含まれている

            public D(int st)
            {
                state = st;
                initialThreadId = Environment.CurrentManagedThreadId;
            }

            public void Dispose()
            {
            }

            public Task<int> Current => current;

            object IEnumerator.Current => current;

            public bool MoveNext()
            {
                switch (state)
                {
                    case 0: // 1回目
                        state = -1;
                        i = 1; // カウンターを初期化
                        break;
                    case 1: // 2回目以降
                        state = -1;
                        c = null;
                        i++; // カウンターを更新
                        break;
                    default:
                        return false;
                }

                // for文の中身
                if (i <= 10)
                {
                    c = new C(); // クロージャを毎回初期化!
                    c.ii = i; // クロージャにiを束縛
                    current = Task.Run(c.B);
                    state = 1;
                    return true;
                }
                else
                {
                    return false;
                }
            }

            public void Reset()
            {
                throw new NotSupportedException();
            }

            public IEnumerator<Task<int>> GetEnumerator()
            {
                if (state == -2 && initialThreadId == Environment.CurrentManagedThreadId)
                {
                    state = 0;
                    return this;
                }
                else
                {
                    return new D(0);
                }
            }

            IEnumerator IEnumerable.GetEnumerator()
            {
                return GetEnumerator();
            }
        }
    }
}

何故…?

ローカル変数を導入した途端、クロージャの初期化位置が変化しました。

これは私の予想でしかないのですが、おそらく、変数のコンテキストがfor文に対してグローバルかローカルかによって、クロージャを使いまわせるかどうかが変わってくるためでしょう。

まとめ

クロージャの内部の動きを見てみました。

ラムダ式は、束縛変数のポインタを保持しているわけではなく、クロージャのクラスのフィールド変数に書き込み/書き戻しをしていました。これは、私の直感とはだいぶ異なる動作だと感じました。

以上、ありがとうございました。

11
2
4

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
11
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?