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)。実は公式ドキュメントの注釈にも書かれている。