LINQのSelect関数は、配列などのIEnumerable<T>
オブジェクトの要素一つ一つについて、指定した処理を施した結果を別のIEnumerable<T>
として返してくれる。
using System.Collections.Generic;
using System.Linq;
void Func() {
var list = new List<int>{1, 2, 3, 4, 5};
foreach (var item in list.Select(i => i * i)) {
System.Console.WriteLine(item); // 1, 4, 9, 16, 25 が1行ずつで表示される
}
}
見落としがちなのが、このSelectで施そうとする処理は、実際に結果となるイテレート可能なオブジェクトが利用されるまで、実行が遅れるということである。
void Func() {
var list = new List<int>{1, 2, 3, 4, 5};
var sqList = list.Select(i => i * i); // この時点で、i * i は「計算されていない」
}
この例だと、Selectで作ったものはただの数値なのでSelectの実行が遅れてもさほど問題にはならないだろう。
問題になるのは、要素からクラスを作ろうとする時だ。
バグにつながる使い方
この例はUnity3D (UniTask) での利用をあげているが、そのほかのasyncのやり方でも同じことが考えられる。
あるクラスをnew
するときに重い処理が必要なので、コンストラクタからasync
コンテクストでその重い処理を行わせる指示だけをしておいて、準備ができたらメンバ変数のフラグをtrue
にすることを考えよう。そして、そのクラスを別の配列の値からSelectを経由して作り、全てのクラスインスタンスから「準備完了」が返ってくるのを待つ処理を考える。
インターネット経由で指定したURLにある大きなデータを取ってくるという目的で、上記のような実装を考えるとこういったものになるだろう:
public class FetchBigData {
private bool _isReadyToRead = false;
public bool IsReadyToRead { get {
return _isReadyToRead;
}
};
private object _data;
public object Data { get {
return _data;
}
};
public FetchBigData(string url) {
FetchURL(url).Forget();
}
private async UniTask FetchURL(string url) {
_data = await ...... // ダウンロード処理
_isReadyToRead = true;
}
}
そして、このクラスを複数のURLで作ってこのように処理完了を待つことを考える。
void LoadManyGoneWrong() {
var urls = new List<string>{"url1", "url2", "url3"};
var fetchBigDataList = urls.Select(url => new FetchBigData(url)); // Uh oh
// NG!!!
await UniTask.WaitUntil(() => fetchBigDataList.All(i => i.IsReadyToLoad)); // 条件がfalseの間await
foreach (var data in fetchBigDataList.Select(i => i.Data)) {
// Do something about data
}
}
残念ながら、この実装は await
でソフトロックする。そればかりか、おそらくサーバーに大量のリクエストが飛んで怒られる羽目になる。
その理由は、Select
の実行が遅れることにある。fetchBigDataList
を作ったSelect
では、URLをFetchBigData
のコンストラクタに流してFetchBigData
の配列を作ることになっている。この処理の実行が遅れているのだ。つまり、この時点でFetchBigDataは作成されていない。
遅れた処理は、それによって出来上がる要素が必要とされるまで実行されない。WaitUntil
を評価しようとしてAll
を実行して初めてSelect
が作る配列の中身が必要になるため、Selectの中身が実行されてFetchBigData
が作成され、コンストラクタで呼んだダウンロード処理が発生する。
ちなみに、UniTask.WaitUntilはそれに与えた条件がtrueになるまで、その条件を毎フレーム評価し続ける。ということは、All
がtrue
を返すまで、All
は呼ばれ続ける。
都合の悪いことに、これは**Select
の中身まで毎フレーム呼ばれることを意味する**。つまりawaitに実行が進んだ時点で、大量のFetchBigData
が作られ続け、その時点でのダウンロード完了状態 == false
が確認され、いつまで経っても実行が進まなくなる ばかりか FetchBigData
からのリクエストが飛び続ける事故が発生する。
回避方法
ではどうすればいいのかというと、All
を打つ前にSelect
が作る配列の中身が必要な処理を実行してやれば良いのである。例えばTolist
だ。
void CorrectlyLoadMany() {
var urls = new List<string>{"url1", "url2", "url3"};
var fetchBigDataList = urls.Select(url => new FetchBigData(url)).ToList(); // ToList()を打ち、強制的にSelectを実行させる
await UniTask.WaitUntil(() => fetchBigDataList.All(i => i.IsReadyToLoad)); // 条件がfalseの間await
foreach (var data in fetchBigDataList.Select(i => i.Data)) {
// Do something about data
}
}
このようにすれば、最初のSelectで生成したFetchBigData
がその後も使い続けられ、無事に正しい数のリクエストが飛ぶこととなる。
Select以外も遅延する
今回はSelect
を使ってハマったが、コメントでの指摘の通り、Select
に限らずIEnumerable<T>
が戻り値の型になっているLINQメソッドには同じような遅延実行が起こる(e.g. Where
GroupBy
Take
Skip
)。実は公式ドキュメントの注釈にも書かれている。