C#
WPF
task
async
await

【C#】Cドライブ以下にある全てのファイルパスを非同期かつIEnumerableに取得してみた

(2019/02/09 更新)

C# 8.0を試してみましたが上手くいきませんでした(´・ω・`)

(2019/02/10 更新)

tangoさんよりアドバイス頂き、async/await で非同期的にCドラ以下のファイルを取得にボタンがマウスホバーでない場合にボタンが非ブロッキング状態になるように教えて頂きましたので追記しました。ありがとうございます!

(2019/02/14 更新)

パフォーマンス改善を追記しました。


背景

(自称)最強のファイル検索ソフトであるEveryThingを愛用しているわけですが、このソフトだとネットワークドライブのファイルまで見に行きたいいけない!!

そうだ!ネットワークドライブまで見に行けるファイル検索をつくろう!!!

んでもって、画面が固まらないように!!そして途中キャンセルできるようにしよう!!!!

そう考えて作ってみると思ったよりも格段に難しかったのでメモとして残しておきます。マサカリ大歓迎!

そしてツール完成間近、EveryThing がネットワークドライブのファイルまで見に行けることを知る事となるとはこの時、知る由もなかった。。。


こういうことがやりたい

書いてみたら意外と長くなってしまったので、最初に結論というか最終成果物。

非同期7.gif


  • Cドライブ以下にあるファイルを全取得出来るようにする

  • 画面が固まらないように非同期で処理する

  • ファイル数が膨大なのでキャンセル可能にする

同期処理での記述から始めて、最終的にこれらの条件を満たしていくようにしていきます。


Directory.EnumerateFiles がCドラ直下だと役に立たない件

C#ではDirectoryクラスにファイル列挙メソッドが用意されています。


MainWindow.xaml.cs

var folderPath = @"C:\";

var files = Directory.EnumerateFiles(folderPath, "*", SearchOption.AllDirectories).ToList();

え、これだけでネットワークパスまでファイル取りに行ける!楽勝じゃん!

maxresdefault.jpg

実際に上記を実行してみると

キャプチャ.JPG

「C:\$Recycle.Bin\S-1-5-18」てなんぞ。

なんか知らんけど、存在しないフォルダにアクセスしようとしてみただけなのか?

キャプチャ.JPG

ほう?

キャプチャ.JPG

ほうほう…。

なにやら、Directory.EnumerateFilesメソッドでアクセス権限のないシステムフォルダなどを覗き見ようとした際にこのようになってしまうようだ。どうしたものか。


try catch で全てを握りつぶしてCドラ以下のファイルを取得する

キータ内でそれっぽい記事があったので拝借します。

【C#】ドライブ直下からのファイルリスト取得について


検索元のディレクトリの指定「C:\」や「D:\」といったドライブ直下にした時、プログラムがコケるケースがあった。

原因は隠しフォルダの「RECYCLE.BIN」で、要は操作しているユーザのアクセス権が無いゴミ箱(他のユーザのゴミ箱だったりとか)を読み取ることができずに例外を吐いて死んでしまうことに気がついた。

これを回避するためには、再帰処理に読み取れなかった場合を考慮してちゃんとtry~catchしてあげればいいという単純なもの。


ふむふむ、なるほどなるほど。

確かに再帰的にアクセスして例外が発生したものだけを無視すればよさそう。実際に測ってみた。

また、計測にあたって明らかに不要かつどのPCにもありそうなフォルダに関しては検索段階で弾くようにする。

具体的には、以下のフィールド内のどれかにフォルダパスの接頭辞が該当した場合には検索対象外とした。


MainWindow.xaml.cs

/// <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ドラ以下のファイルが無事に取得できる事は確認できました。

ただ私が作りたいのは、画面が固まらず、キャンセル可能なファイル取得です。

つまりこういういめーじ。


MainWindow.xaml.cs

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型で取得してみました。


MainWindow.xaml.cs

/// <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パターンで計測してみます。


MainWindow.xaml.cs

//パターン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を更新してみます。


MainWindow.xaml.cs

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;
});
}
}

同期処理.gif

はい、まあそらそうですね、、って感じの結果です。

全ての処理をシングルスレッドで行っているので画面は固まったまんまです。

Cドラ以下のファイルを全取得し終わるまでボタンも押せない。

ふつーに書いてたらまあこうなるよね。


async/await で非同期的にCドラ以下のファイルを取得

ここからようやく本題、非同期処理で画面を更新させていきます。

サクッと非同期にする。async voidはイベントハンドラにだけ許された特権( ˘ω˘)


MainWindow.xaml.cs

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;
});
}
});
}


非同期.gif

やったー\(^o^)/

これで非同期的にCドラ以下のファイルが取得出来たミッションコンプリート\(^o^)/

…………

……………………

………………………

ざわざわ.jpg

お気付きだろうか。

マウスカーソルがボタンから離れていてもマウスオーバー状態と変わらない事に……!

Task.Run内の処理は確かにスレッドプールに処理が投げられて晴れて非ブロッキング状態となりましたが、なぜかボタンがおせねぇ。

ちょっと正直なところ、なんでこうなるのかは詳しくわかってないんですが非同期処理内で同期処理をしてしまっているので


  • メインの画面(ボタン以外)は非ブロッキング状態

  • ボタン内では同期処理が走っている為ブロッキング状態

になっているのだと考えています。


Dispatcher.Priorityを使用して非ブロッキングにできる


Dispatcher.InvokeのオーバーロードにDispatcher.Priorityを受け取るものがありまして、これ、省略した場合はDispatcherPriority.Send(最高優先度)で実行されるんですよ。

なので、引数を明示的に指定してあげて


MainWindow.xaml.cs

this.Dispatcher.Invoke(() => {

this.txtFileCount.Text = file.index + Environment.NewLine + file.value;
},System.Windows.Threading.DispatcherPriority.Background);

テキストボックスのレンダリングの優先度をBackground(4)にしてボタン入力(Input:5)よりも優先度を下げてやるとボタンが押せるようになるかも。


というコメントを頂きました。

実際にこの通りでしたので、これ以降の非同期的にforeachをしてやらなくても大丈夫そうですね( ˘ω˘)


ボタンを非ブロッキング状態にする

非同期処理内で同期処理をしてしまっていることが原因だとすれば、どうすれば非ブロッキング状態にできるのか明白ですね。

非同期処理内で非同期処理をしてやればいいじゃん!!

ということで、ファイル取得メソッドを非同期にしてみました。


MainWindow.xaml.cs

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^)/

非同期2.gif

なんてこったい/(^o^)\

確かにボタンは非ブロッキング状態になったけど、ボタンを押しても何もおきねぇ/(^o^)\

~ 2分後 ~

非同期3.gif

動き出したけどこんどは画面全体がブロッキング状態になってる/(^o^)\

これ、非同期処理内で同期処理させた時よりも状況がひどくなってる/(^o^)\


新ステージ、ForeachAsyncで更なる高みへ

これまでの流れを整理する。


  • ファイル取得を同期処理で行う。ただこれだと画面が固まってしまう。

  • スレッドプールでファイル取得を同期処理で行う。ただこれだとボタンがブロッキング状態になってしまう。

  • スレッドプールでファイル取得を非同期処理で行う。ただこれだとforeachで展開された後に全体がブロッキング状態に。

このことから考えてもうこれしかないと思った。

Foreachは非同期処理を同期的にしか展開してくれねぇ説!!!!!!!

じゃあやってやろうじゃないの、ForeachAsyncってやつをよ。

いつもお世話になってます、neueさん、拝借させて頂きます。

ForEachAsync - 非同期の列挙の方法 Part2

さて、ここで書かれてある拡張メソッドをコピっとペッで作ります。

作ったうえで、このForeachAsyncを使えるようにファイル取得メソッドを作り変えます。それがコチラ。


MainWindow.xaml.cs

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);
}


ForeachAsyncIEnumerable<T>の拡張メソッドなので、元々Task<IEnumerable<string>>だったところを

更にIEnumerableで包んであげて IEnumerable<ConfiguredTaskAwaitable<IEnumerable<string>>>型へと進化しました!!!!

はい、じゃあ後は実行するだけ。

ちゃんとタスクにCancellationTokenを渡してThrowIfCancellationRequestedメソッド使って例外投げるだけでおk。おけまる。


MainWindow.xaml.cs

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 = "ファイル取得";
}
}


非同期4.gif

圧倒的感謝.png

これが……これが、やりたかった!!!!!

ということで、予想していたForeachは非同期処理を同期的にしか展開してくれねぇ説!はまあ大方合っていたのかな?

多分。


パフォーマンス改善

さてさてさーて、欲しいものはできた。が、実際に上記のコードを動かしてみた方は分かると思いますが、パフォーマンスが死ぬ程わるい!!!!

比較してみた。


パフォーマンスの比較

比較条件



  • IEnumerable<T>でファイル取得をするだけ

  • Cドライブ以下を対象とする


MainWindow.xaml.cs

//パターン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 を参照してください。

とりあえずやってみた。


MainWindow.xaml.cs

//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ドライブ以下のファイルパスを根こそぎ持ってくるだけなので特に処理順序なんて必要ないですし求められているのはメモリをぶん殴ってでも速度が欲しい訳です。じゃあどうするか?


MainWindow.xaml.cs

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を指定してあげないとフォームの移動とかさせられないですね。。


MainWindow.xaml.cs

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スレッドを効率的にタスクスケジュールしてくれるのでは?と考えて計測してみました。


MainWindow.xaml.cs

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

正直、草生えました。

非同期6.gif

結局、非同期処理をやりたいのって「ちゃんと処理してるんだぜなう」みたいなアピールが出来てればよくて、進捗状況まで律義に待ってやる必要なんてないですね。そういう理由でInvokeAsyncを意図的に同期処理させています。


パフォーマンス改善後

実行時間に若干のムラはありますが、思ったより納得いく所までパフォーマンスを上げられました。

ファイル取得 + UIの更新までの時間
ファイル件数
掛かった時間
プロセスメモリ

パフォーマンス改善前
50,000件
2分29秒
83MB

パフォーマンス改善後
50,000件
7秒
121MB

暫定版。


MainWindow.xaml.cs

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を試してみましたが…。

image.png


  • VS2019で実行

  • プロジェクトの詳細設定からC# 8.0を選択


  • C# 8.0 Async streamsからコピペ

うん、動かん。( ˘ω˘)

正式リリースを待つとします…。


まとめ

Cドラ以下のファイルパスを取得したかったというよりも、その過程での非同期処理の勉強の為にこういうことをやっていました。

なんとなくで非同期処理を書けてしまう、、それはそれでよい事ですがちゃんと理解してコーディングしたいですよね。

今回の記事を書くにあたって、非常に参考にさせて頂いたサイトを紹介して〆たいと思います。