本記事は、私が最近ハマった罠について調べたものです。
クロージャについて面白いことが分かったので、ちょっと記事にしてみました。
TL;DR
ラムダ式の変数束縛に注意しよう!
for (var i = 1; i <= 10; i++)
{
var ii = i; // いったんローカル変数に代入しないと大変なことに
yield return Task.Run(() => ii);
}
本記事のために作成したコード
以下のGitHubに置いてあります。
罠の概要
以下のように、for
文でTask
を生成したところ、結果がおかしくなります。
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);
}
}
}
3
3
7
11
7
7
7
11
11
11
本来なら1 〜 10
が表示されてほしかったのですが、うまくいきませんでした。
おかしくなった理由
理由は単純なことで、for
文のカウンターをラムダ式が直接束縛しているからです。
for (var i = 1; i <= 10; i++)
{
yield return Task.Run(() => i); // iを直接束縛している
}
このため、Task
が生成されたタイミングではなく、Task
のスレッドが走ったタイミングにおけるi
の値が返されてしまっています。
なるほど、i
の変数が複数のスレッドで使いまわされているのね。
ちょっと待て
i
はint
型なので、「値型」です。
値型なので、異なるコンテキストから値を変更することは基本的にはできません。ラムダ式の束縛って、どうやって実現されているんでしょうか?
調べてみる
まず、以下のようなコードを書いてみました。
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;
}
}
}
1
束縛されたi
が、ちゃんとインクリメントされていますね。
このコードのIL(中間言語)を解析してみました。すると、以下のコードと大体等価であることが分かりました。
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
は、どのようなコードに変換されるか見てみましょう。
まずは元のコードです。
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;
}
}
}
}
1
2
3
4
5
6
7
8
9
10
これは、次のコードと大体等価です。
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
をステートマシンでうまく作っていますね。
最初の例に戻る
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);
}
}
}
}
これは、次のコードと大体等価です。
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();
}
}
}
}
これは、おかしくなって当然ですね。
改修する
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); // ローカル変数を渡す
}
}
}
}
1
2
3
4
5
6
7
8
9
10
これは、次のコードと大体等価です。
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
文に対してグローバルかローカルかによって、クロージャを使いまわせるかどうかが変わってくるためでしょう。
まとめ
クロージャの内部の動きを見てみました。
ラムダ式は、束縛変数のポインタを保持しているわけではなく、クロージャのクラスのフィールド変数に書き込み/書き戻しをしていました。これは、私の直感とはだいぶ異なる動作だと感じました。
以上、ありがとうございました。