(2019/02/09 更新)
C# 8.0
を試してみましたが上手くいきませんでした(´・ω・`)
(2019/02/10 更新)
tangoさんよりアドバイス頂き、async/await で非同期的にCドラ以下のファイルを取得にボタンがマウスホバーでない場合にボタンが非ブロッキング状態になるように教えて頂きましたので追記しました。ありがとうございます!
(2019/02/14 更新)
パフォーマンス改善を追記しました。
#背景
(自称)最強のファイル検索ソフトであるEveryThing
を愛用しているわけですが、このソフトだとネットワークドライブのファイルまで見に行きたいいけない!!
そうだ!ネットワークドライブまで見に行けるファイル検索をつくろう!!!
んでもって、画面が固まらないように!!そして途中キャンセルできるようにしよう!!!!
そう考えて作ってみると思ったよりも格段に難しかったのでメモとして残しておきます。マサカリ大歓迎!
そしてツール完成間近、EveryThing がネットワークドライブのファイルまで見に行けることを知る事となるとはこの時、知る由もなかった。。。
こういうことがやりたい
書いてみたら意外と長くなってしまったので、最初に結論というか最終成果物。
- Cドライブ以下にあるファイルを全取得出来るようにする
- 画面が固まらないように非同期で処理する
- ファイル数が膨大なのでキャンセル可能にする
同期処理での記述から始めて、最終的にこれらの条件を満たしていくようにしていきます。
Directory.EnumerateFiles がCドラ直下だと役に立たない件
C#
ではDirectoryクラス
にファイル列挙メソッドが用意されています。
var folderPath = @"C:\";
var files = Directory.EnumerateFiles(folderPath, "*", SearchOption.AllDirectories).ToList();
え、これだけでネットワークパスまでファイル取りに行ける!楽勝じゃん!
**「C:$Recycle.Bin\S-1-5-18」**てなんぞ。
なんか知らんけど、存在しないフォルダにアクセスしようとしてみただけなのか?
ほう?
ほうほう…。
なにやら、Directory.EnumerateFilesメソッド
でアクセス権限のないシステムフォルダなどを覗き見ようとした際にこのようになってしまうようだ。どうしたものか。
try catch で全てを握りつぶしてCドラ以下のファイルを取得する
キータ内でそれっぽい記事があったので拝借します。
【C#】ドライブ直下からのファイルリスト取得について
検索元のディレクトリの指定「C:\」や「D:\」といったドライブ直下にした時、プログラムがコケるケースがあった。
原因は隠しフォルダの「RECYCLE.BIN」で、要は操作しているユーザのアクセス権が無いゴミ箱(他のユーザのゴミ箱だったりとか)を読み取ることができずに例外を吐いて死んでしまうことに気がついた。
これを回避するためには、再帰処理に読み取れなかった場合を考慮してちゃんとtry~catchしてあげればいいという単純なもの。
ふむふむ、なるほどなるほど。
確かに再帰的にアクセスして例外が発生したものだけを無視すればよさそう。実際に測ってみた。
また、計測にあたって明らかに不要かつどのPCにもありそうなフォルダに関しては検索段階で弾くようにする。
具体的には、以下のフィールド内のどれかにフォルダパスの接頭辞が該当した場合には検索対象外とした。
/// <summary>
/// 検索フォルダから除外する
/// </summary>
private readonly HashSet<string> _exceptFolder = new HashSet<string>
{
@"C:\Windows", //システムファイルが多すぎる
@"C:\Users\All Users",
@"C:\$Recycle.Bin", //ゴミ箱
@"C:\Recovery",
@"C:\Config.Msi", //起動して最初に実行されるらしい
@"C:\Documents and Settings", //デスクトップとかマイドキュメントなど
@"C:\System Volume Information",
@"C:\Program Files\windows nt\アクセサリ",
@"C:\ProgramData\Application Data", //よくある隠しフォルダ
};
ファイル件数 | 掛かった時間 | プロセスメモリ |
---|---|---|
648,721件 | 46秒 | 214MB |
IEnumerable型でCドラ以下のファイルを取得する
Cドラ以下のファイルが無事に取得できる事は確認できました。
ただ私が作りたいのは、画面が固まらず、キャンセル可能なファイル取得です。
つまりこういういめーじ。
private CancellationTokenSource _cancellationTokenSource;
private CancellationToken _cancellationToken;
private bool _isExecute = false;
private async void Button_Click(object sender, RoutedEventArgs e)
{
//実行中にもう一度押されたらタスクのキャンセルを行う
if (_isExecute)
{
_isExecute = false;
_cancellationTokenSource.Cancel();
return;
}
_isExecute = true;
_cancellationTokenSource = new CancellationTokenSource();
_cancellationToken = _cancellationTokenSource.Token;
try
{
//スレッドプールさん、処理をお願いします何でもしますから
await Task.Run(async () =>
{
//ファイル取得
var filePaths = await GetAllFilesAsync(@"C:\");
//一回でList型で取得するのではなく
//1ファイル読み込み毎に何らかの処理をしたいからIEnumerable型で取得したい
foreach (var filePath in filePaths)
{
//タスクのキャンセルがされていたら例外を投げる
_cancellationToken.ThrowIfCancellationRequested();
//1ファイル読み込み毎に何らかの処理
}
}, _cancellationToken);
}
catch
{
//ignore
}
finally
{
_isExecute = false;
_cancellationTokenSource.Dispose();
}
}
上記のソースコードでいう、「1ファイル毎の~~」の部分を実現する為にIEnumerable型
で取得してみました。
/// <summary>
/// ファイルパスの全取得(同期処理でならこれでよい)
/// </summary>
/// <param name="folderPath"></param>
/// <returns></returns>
private IEnumerable<string> GetAllFiles(string folderPath)
{
var directories = Enumerable.Empty<string>();
try
{
directories = Directory.EnumerateDirectories(folderPath)
.Where(x => _exceptFolder.All(y => !x.StartsWith(y, StringComparison.CurrentCultureIgnoreCase)))
.SelectMany(GetAllFiles);
}
catch
{
return directories;
}
//同階層のファイル取得をして再帰的に同階層のフォルダを検索しに行く
return Directory.EnumerateFiles(folderPath).Concat(directories);
}
また、計測するにあたって、以下の2パターンで計測してみます。
//パターン1
//IEnumerable型で取得した後にList化して初めからListで返すパターンとの比較を行う
var files = GetAllFiles(folderPath).ToList();
//パターン2
//IEnumerableで取得したものをforeachで展開させる
foreach (var file in GetAllFiles(folderPath))
{
//ignore
}
ファイル件数 | 掛かった時間 | プロセスメモリ | |
---|---|---|---|
パターン1 | 648,772件 | 46秒 | 208MB |
パターン2 | 648,772件 | 52秒 | 38MB |
まぁこんなもんだよね。
完全に蛇足だとは思いますが、念の為このメソッドを使ってUIを更新してみます。
private void Button_Click(object sender, RoutedEventArgs e)
{
var folderPath = @"C:\";
foreach (var file in GetAllFiles(folderPath).Select((value, index) => new { value, index }))
{
//Dispatcher.InvokeでUIスレッドに処理をさせる
this.Dispatcher.Invoke(() =>
{
this.txtFileCount.Text = file.index + Environment.NewLine + file.value;
});
}
}
はい、まあそらそうですね、、って感じの結果です。
全ての処理をシングルスレッドで行っているので画面は固まったまんまです。
Cドラ以下のファイルを全取得し終わるまでボタンも押せない。
ふつーに書いてたらまあこうなるよね。
async/await で非同期的にCドラ以下のファイルを取得
ここからようやく本題、非同期処理で画面を更新させていきます。
サクッと非同期にする。async void
はイベントハンドラにだけ許された特権( ˘ω˘)
private async void Button_Click(object sender, RoutedEventArgs e)
{
var folderPath = @"C:\";
//スレッドプールに処理を任せといた
await Task.Run(() =>
{
foreach (var file in GetAllFiles(folderPath).Select((value, index) => new { value, index }))
{
//UIスレッドに戻ってきてUIを更新させる
this.Dispatcher.Invoke(() =>
{
this.txtFileCount.Text = file.index + Environment.NewLine + file.value;
});
}
});
}
やったー\(^o^)/
これで非同期的にCドラ以下のファイルが取得出来たミッションコンプリート\(^o^)/
…………
……………………
………………………
お気付きだろうか。
マウスカーソルがボタンから離れていてもマウスオーバー状態と変わらない事に……!
Task.Run
内の処理は確かにスレッドプールに処理が投げられて晴れて非ブロッキング状態となりましたが、なぜかボタンがおせねぇ。
ちょっと正直なところ、なんでこうなるのかは詳しくわかってないんですが非同期処理内で同期処理をしてしまっているので
- メインの画面(ボタン以外)は非ブロッキング状態
- ボタン内では同期処理が走っている為ブロッキング状態
になっているのだと考えています。
Dispatcher.Priorityを使用して非ブロッキングにできる
Dispatcher.Invoke
のオーバーロードにDispatcher.Priority
を受け取るものがありまして、これ、省略した場合はDispatcherPriority.Send(最高優先度)
で実行されるんですよ。
なので、引数を明示的に指定してあげて
MainWindow.xaml.csthis.Dispatcher.Invoke(() => { this.txtFileCount.Text = file.index + Environment.NewLine + file.value; },System.Windows.Threading.DispatcherPriority.Background);
テキストボックスのレンダリングの優先度をBackground(4)にしてボタン入力(Input:5)よりも優先度を下げてやるとボタンが押せるようになるかも。
というコメントを頂きました。
実際にこの通りでしたので、これ以降の非同期的にforeach
をしてやらなくても大丈夫そうですね( ˘ω˘)
ボタンを非ブロッキング状態にする
非同期処理内で同期処理をしてしまっていることが原因だとすれば、どうすれば非ブロッキング状態にできるのか明白ですね。
非同期処理内で非同期処理をしてやればいいじゃん!!
ということで、ファイル取得メソッドを非同期にしてみました。
private async Task<IEnumerable<string>> GetAllFilesAsync(string folderPath)
{
var directories = Enumerable.Empty<string>();
try
{
directories = Directory.EnumerateDirectories(folderPath)
.AsParallel()
.Where(x => _exceptFolder.All(y => !x.StartsWith(y, StringComparison.CurrentCultureIgnoreCase)));
//同階層にフォルダが存在しなければ同階層のファイルを取得するタスクを返す
if (!directories.Any())
{
return await Task.FromResult(Directory.EnumerateFiles(folderPath)).ConfigureAwait(false);
}
//再帰的にフォルダを探し続ける
var filePaths = await Task.WhenAll(directories.Select(async x => await GetAllFilesAsync(x)))
.ConfigureAwait(false);
directories = filePaths.SelectMany(x => x);
}
catch
{
return directories;
}
//タスクを作成する
var tcs = new TaskCompletionSource<IEnumerable<string>>();
tcs.SetResult(Directory.EnumerateFiles(folderPath).Concat(directories));
return await tcs.Task.ConfigureAwait(false);
}
非同期処理内で非同期処理をさせる事でこれで思い通りの処理になるはずだぜヒャッハー\(^o^)/
なんてこったい/(^o^)\
確かにボタンは非ブロッキング状態になったけど、ボタンを押しても何もおきねぇ/(^o^)\
~ 2分後 ~
動き出したけどこんどは画面全体がブロッキング状態になってる/(^o^)\
これ、非同期処理内で同期処理させた時よりも状況がひどくなってる/(^o^)\
新ステージ、ForeachAsyncで更なる高みへ
これまでの流れを整理する。
- ファイル取得を同期処理で行う。ただこれだと画面が固まってしまう。
- スレッドプールでファイル取得を同期処理で行う。ただこれだとボタンがブロッキング状態になってしまう。
- スレッドプールでファイル取得を非同期処理で行う。ただこれだと
foreach
で展開された後に全体がブロッキング状態に。
このことから考えてもうこれしかないと思った。
Foreachは非同期処理を同期的にしか展開してくれねぇ説!!!!!!!
じゃあやってやろうじゃないの、ForeachAsync
ってやつをよ。
いつもお世話になってます、neueさん、拝借させて頂きます。
ForEachAsync - 非同期の列挙の方法 Part2
さて、ここで書かれてある拡張メソッドをコピっとペッで作ります。
作ったうえで、このForeachAsync
を使えるようにファイル取得メソッドを作り変えます。それがコチラ。
private IEnumerable<ConfiguredTaskAwaitable<IEnumerable<string>>> GetAllFilesAsync(string folderPath)
{
var directories = Enumerable.Empty<string>();
try
{
directories = Directory.EnumerateDirectories(folderPath)
.AsParallel()
.Where(x => _exceptFolder.All(y => !x.StartsWith(y, StringComparison.CurrentCultureIgnoreCase)));
}
catch
{
//ディレクトリにアクセスできないならファイルはない
yield break;
}
//同階層にフォルダが存在しなければ同階層のファイルを取得するタスクを返す
if (!directories.Any())
{
yield return Task.FromResult(Directory.EnumerateFiles(folderPath)).ConfigureAwait(false);
yield break;
}
//再帰的にフォルダを探し続ける
foreach (var task in directories.Select(GetAllFilesAsync).SelectMany(t => t))
{
yield return task;
}
//タスクを作成
var tcs = new TaskCompletionSource<IEnumerable<string>>();
tcs.SetResult(Directory.EnumerateFiles(folderPath));
yield return tcs.Task.ConfigureAwait(false);
}
ForeachAsync
はIEnumerable<T>
の拡張メソッドなので、元々Task<IEnumerable<string>>
だったところを
更にIEnumerable
で包んであげて IEnumerable<ConfiguredTaskAwaitable<IEnumerable<string>>>型
へと進化しました!!!!
はい、じゃあ後は実行するだけ。
ちゃんとタスクにCancellationToken
を渡してThrowIfCancellationRequestedメソッド
使って例外投げるだけでおk。おけまる。
private async void Button_Click(object sender, RoutedEventArgs e)
{
ThreadPool.SetMinThreads(200, 200);
//実行中にもう一度押されたらタスクをキャンセル
if (_isExecute)
{
_isExecute = false;
_cancellationTokenSource.Cancel();
return;
}
btnGetFile.Content = "実行中";
_isExecute = true;
_cancellationTokenSource = new CancellationTokenSource();
_cancellationToken = _cancellationTokenSource.Token;
var count = 0;
try
{
//スレッドプールさん、処理をお願いします何でもしますから
await Task.Run(async () =>
{
var enumerateFilesCollection = GetAllFilesAsync(@"C:\");
await enumerateFilesCollection.ForEachAsync(async enumerateFiles =>
{
foreach (var file in await enumerateFiles)
{
//キャンセルされれば例外で止める
_cancellationToken.ThrowIfCancellationRequested();
this.Dispatcher.Invoke(() =>
{
++count;
this.txtFileCount.Text = count + Environment.NewLine + file;
});
}
}, 200, _cancellationToken);
}, _cancellationToken);
}
catch
{
//ignore
}
finally
{
_cancellationTokenSource.Dispose();
_isExecute = false;
btnGetFile.Content = "ファイル取得";
}
}
これが……これが、やりたかった!!!!!
ということで、予想していたForeach
は非同期処理を同期的にしか展開してくれねぇ説!はまあ大方合っていたのかな?
多分。
パフォーマンス改善
さてさてさーて、欲しいものはできた。が、実際に上記のコードを動かしてみた方は分かると思いますが、パフォーマンスが死ぬ程わるい!!!!
比較してみた。
パフォーマンスの比較
比較条件
-
IEnumerable<T>
でファイル取得をするだけ - Cドライブ以下を対象とする
//パターン1に関しては省略
//パターン2の計測の仕方
await Task.Run(async () =>
{
var enumerateFilesCollection = GetAllFilesAsync(@"C:\");
await enumerateFilesCollection.ForEachAsync(async enumerateFiles =>
{
foreach (var file in await enumerateFiles)
{
//キャンセルされれば例外で止める
_cancellationToken.ThrowIfCancellationRequested();
//念の為スレッドセーフにインクリメントを行う
Interlocked.Increment(ref count);
if (count >= 50000)
{
_cancellationTokenSource.Cancel();
}
}
}, 200, _cancellationToken);
}, _cancellationToken);
ファイル件数 | 掛かった時間 | プロセスメモリ | |
---|---|---|---|
パターン1 GetAllFilesメソッド |
50,000件 | 6.64秒 | 57MB |
パターン2 GetAllFilesAsyncメソッド |
50,000件 | 1分10秒 | 60MB |
ね、遅いよね。
この遅くなり方は何か致命的なミスをしているはず…。
並列クエリを付けたりなくしたりしてみる
色々試していたら一個気付いた。
AsParallel
邪魔なんじゃね!!!!!?????
ファイル件数 | 掛かった時間 | プロセスメモリ | |
---|---|---|---|
パターン1 GetAllFilesメソッド |
50,000件 | 6.64秒 | 57MB |
パターン2 GetAllFilesAsyncメソッド |
50,000件 | 1分10秒 | 60MB |
パターン3 GetAllFilesメソッドにAsParallelを追加して並列処理 |
50,000件 | 終わらない… | - |
パターン4 GetAllFilesAsyncメソッドからAsParallelを削除 |
50,000件 | 7.06秒 | 60MB |
AsParallelメソッド
を使用した場合がどちらも遅い。というか絶望的に遅い。
もしかして、並列処理実行時に例外が発生した場合ってかなりコストがかかってる感じですか…?
とりあえず一つはAsParallelメソッドを使用しない
事で大幅に上がりそう。(前に計測した時は倍速だった気が…。)
TaskCreationOptions.LongRunning を設定してみる
幾つかのサイトを流し見していると何回かチラ見するこのTaskCreationOptions.LongRunning
というやつ。
なんか明らかに長い時間タスクを実行するときに付けたほうがよさそうな名前。
TaskCreationOptions
タスクの作成および実行に関するオプションの動作を制御するフラグ
ふむ。
フラグ | 説明 |
---|---|
None | 既定の動作を適用します |
PreferFairness | TaskScheduler に対し、できる限り公平にタスクをスケジュールするように指示します |
LongRunning | 粒度の細かいシステムではなく、タスクが長時間実行され、少量の大きなコンポーネントを含む粒度の粗い操作とすることを指定します |
AttachedToParent | タスクがタスク階層の親にアタッチされることを指定します |
DenyChildAttach | アタッチされた子タスクとして実行を試みるすべての子タスクが、親タスクにアタッチされるのではなく、デタッチされた子タスクとして実行されるように指定します |
HideScheduler | アンビエント スケジューラが作成されたタスクの現在のスケジューラと見なされることを防止します |
RunContinuationsAsynchronously | 現在のタスクに追加される継続処理を強制的に非同期実行します |
詳しくはTaskCreationOptions Enum を参照してください。 |
とりあえずやってみた。
//Task.RunではTaskCreationOptionsを設定できないのでTask.Factoryを使用する
await Task.Factory.StartNew(async () =>
{
var enumerateFilesCollection = GetAllFilesAsync(@"C:\");
await enumerateFilesCollection.ForEachAsync(async enumerateFiles =>
{
foreach (var file in await enumerateFiles)
{
//キャンセルされれば例外で止める
_cancellationToken.ThrowIfCancellationRequested();
//念の為スレッドセーフにインクリメントを行う
Interlocked.Increment(ref count);
if (count >= 50000)
{
_cancellationTokenSource.Cancel();
}
}
}, 200, _cancellationToken);
}, _cancellationToken, TaskCreationOptions.LongRunning, TaskScheduler.Default);
ファイル件数 | 掛かった時間 | プロセスメモリ | |
---|---|---|---|
GetAllFilesメソッド | 50,000件 | 6.64秒 | 57MB |
GetAllFilesAsyncメソッドからAsParallelを削除 | 50,000件 | 7.06秒 | 60MB |
GetAllFilesAsyncメソッドからAsParallelを削除 + TaskCreationOptions.LongRunning を設定 |
50,000件 | 5.97秒 | 59MB |
ほぼ誤差の範囲っぽい。このサイトに少し詳しく書いてあります。
タスク並列ライブラリ入門記-006 (TaskCreationOptions.LongRunning, 長時間実行されるタスクであることを示すオプション, オーバーサブスクリプション)
スレッドの切り替えにかかる時間が若干軽減する、かも!?ぐらいな感じでしょうか。
ForeachAsync の排他制御を外す
新ステージ、ForeachAsyncで更なる高みへで非同期なforeach
を作った訳ですが、中身をちゃんと見るとSemaphoreSlim
が使用されています。SemaphoreSlim
については以下の記事がとてもわかりやすいです。
たがわ製作所ブログ - 非同期処理におけるセマフォを用いた排他制御
まあ要は、Task
の最大同時実行数に制限を掛けているわけなんですが、今回はCドライブ以下のファイルパスを根こそぎ持ってくるだけなので特に処理順序なんて必要ないですし求められているのはメモリをぶん殴ってでも速度が欲しい訳です。じゃあどうするか?
public static async Task ForEachAsyncNoLock<T>(this IEnumerable<T> source, Func<T, Task> action, CancellationToken cancellationToken = default(CancellationToken), bool configureAwait = false)
{
if (source == null) throw new ArgumentNullException("source");
if (action == null) throw new ArgumentNullException("action");
var tasks = new List<Task>();
foreach (var item in source)
{
cancellationToken.ThrowIfCancellationRequested();
var task = action(item);
tasks.Add(task);
}
await Task.WhenAll(tasks).ConfigureAwait(configureAwait);
}
SemaphoreSlim
を消し去ってやればいいじゃない。
ファイル件数 | 掛かった時間 | プロセスメモリ | |
---|---|---|---|
GetAllFilesメソッド | 50,000件 | 6.64秒 | 57MB |
GetAllFilesAsyncメソッドからAsParallelを削除 | 50,000件 | 7.06秒 | 60MB |
GetAllFilesAsyncメソッドからAsParallelを削除 + ForEachAsyncNoLock |
50,000件 | 5.97秒 | 59MB |
1割程度は改善した感じ?
ちな、ForEachAsyncNoLock
は後述するUI描画時のパフォーマンスにめちゃ役に立ちます。
ファイルパス取得 + UI描画のパフォーマンス比較
ロジック面で思いつくことは粗方やれたかな、と思います。
なので次はファイルパス取得 + UIへの描画
に掛かる時間を図ります。
んで今更なんですが、非同期処理実行時にはDispatcherPriority.Background
を指定してあげないとフォームの移動とかさせられないですね。。
this.Dispatcher.Invoke(() =>
{
this.txtFileCount.Text = count + Environment.NewLine + file;
//}, _cancellationToken); こうじゃない
}, DispatcherPriority.Background, _cancellationToken); //こっち
さっそく計測してみる。
ファイル件数 | 掛かった時間 | プロセスメモリ | |
---|---|---|---|
ForEachAsync | 50,000件 | 1分58秒 | 57MB |
ForEachAsyncNoLock | 50,000件 | 2分02秒 | 60MB |
えっ
実行上限数が設定されてないForEachAsyncNoLock
の方が遅い。
非同期的にUIを更新する
この結果を受けて考えました。なぜ、ForEachAsyncNoLock
の方が遅いのか、と。
そこで一つの可能性として、UIスレッド
の実行権限取得時に競合的な何かが起きているのでは?と考えました。
どういうことかというとForEachAsync
に対してForEachAsyncNoLock
の方が同時実行タスク数が多くなる可能性があり、UI
を更新できるのはUIスレッド
からのみ、という前提から考えればUIスレッド
を奪い合う頻度の高いForEachAsyncNoLock
の方が遅くなって当然なのかなと一旦理解しました。
そこでふと、InvokeAsyncメソッド
があることを思い出し、これを使えば競合なくきちんと待ってからUIスレッド
を効率的にタスクスケジュールしてくれるのでは?と考えて計測してみました。
await Task.Run(async () =>
{
var enumerateFilesCollection = GetAllFilesAsync(@"C:\");
await enumerateFilesCollection.ForEachAsyncNoLock(async enumerateFiles =>
{
foreach (var file in await enumerateFiles)
{
//キャンセルされれば例外で止める
_cancellationToken.ThrowIfCancellationRequested();
//念の為スレッドセーフにインクリメントを行う
Interlocked.Increment(ref count);
//意図的にawaitを付けない
this.Dispatcher.InvokeAsync(() =>
{
this.txtFileCount.Text = count + Environment.NewLine + file;
}, DispatcherPriority.Background, _cancellationToken);
//50000件の計測用
if (count >= 50000)
{
_cancellationTokenSource.Cancel();
}
}
}, _cancellationToken);
}, _cancellationToken);
ファイル件数 | 掛かった時間 | プロセスメモリ | |
---|---|---|---|
ForEachAsync | 50,000件 | 1分58秒 | 83MB |
ForEachAsyncNoLock | 50,000件 | 2分02秒 | 83MB |
ForEachAsync + InvokeAsync |
50,000件 | 8.42秒 | 123MB |
ForEachAsyncNoLock + InvokeAsync |
50,000件 | 7.07秒 | 121MB |
正直、草生えました。
結局、非同期処理をやりたいのって「ちゃんと処理してるんだぜなう」みたいなアピールが出来てればよくて、進捗状況まで律義に待ってやる必要なんてないですね。そういう理由でInvokeAsync
を意図的に同期処理させています。
パフォーマンス改善後
実行時間に若干のムラはありますが、思ったより納得いく所までパフォーマンスを上げられました。
ファイル取得 + UIの更新までの時間 | ファイル件数 | 掛かった時間 | プロセスメモリ |
---|---|---|---|
パフォーマンス改善前 | 50,000件 | 2分29秒 | 83MB |
パフォーマンス改善後 | 50,000件 | 7秒 | 121MB |
暫定版。
private async Task<IEnumerable<string>> GetAllFilesAsync(string folderPath)
{
var directories = Enumerable.Empty<string>();
try
{
directories = Directory.EnumerateDirectories(folderPath)
//.AsParallel() 悪夢の元凶
.Where(x => _exceptFolder.All(y => !x.StartsWith(y, StringComparison.CurrentCultureIgnoreCase)));
//同階層にフォルダが存在しなければ同階層のファイルを取得するタスクを返す
if (!directories.Any())
{
return await Task.FromResult(Directory.EnumerateFiles(folderPath)).ConfigureAwait(false);
}
//再帰的にフォルダを探し続ける
var filePaths = await Task.WhenAll(directories.Select(async x => await GetAllFilesAsync(x)))
.ConfigureAwait(false);
directories = filePaths.SelectMany(x => x);
}
catch
{
return directories;
}
//タスクを作成する
var tcs = new TaskCompletionSource<IEnumerable<string>>();
tcs.SetResult(Directory.EnumerateFiles(folderPath).Concat(directories));
return await tcs.Task.ConfigureAwait(false);
}
private async void BtnGetFile_Click(object sender, RoutedEventArgs e)
{
//省略
try
{
await Task.Run(async () =>
{
var enumerateFilesCollection = GetAllFilesAsync(@"C:\");
await enumerateFilesCollection.ForEachAsyncNoLock(async enumerateFiles =>
{
foreach (var file in await enumerateFiles)
{
//キャンセルされれば例外で止める
_cancellationToken.ThrowIfCancellationRequested();
//念の為スレッドセーフにインクリメントを行う
Interlocked.Increment(ref count);
//意図的にawaitを付けない
this.Dispatcher.InvokeAsync(() =>
{
this.txtFileCount.Text = count + Environment.NewLine + file;
}, DispatcherPriority.Background, _cancellationToken);
}
}, _cancellationToken);
}, _cancellationToken);
}
//省略
}
C# 8.0で実装予定の非同期 foreach
C# 8.0 Async streams
実装されるみたいです!!
気が向けば、VS2019をインストールして、追記しますね( ˘ω˘)
追記 (2019/02/09)
非同期foreachを試してみましたが…。
- VS2019で実行
- プロジェクトの詳細設定から
C# 8.0
を選択 - C# 8.0 Async streamsからコピペ
うん、動かん。( ˘ω˘)
正式リリースを待つとします…。
まとめ
Cドラ以下のファイルパスを取得したかったというよりも、その過程での非同期処理の勉強の為にこういうことをやっていました。
なんとなくで非同期処理を書けてしまう、、それはそれでよい事ですがちゃんと理解してコーディングしたいですよね。
今回の記事を書くにあたって、非常に参考にさせて頂いたサイトを紹介して〆たいと思います。