【C#】ネットワーク越し多数ファイルコピー高速化

数千とか数万とかの比較的小さなファイルをネットワーク越しにコピーするのには思いの外時間がかかります。うそー、そんなにかかるのー、ぐらい。なんとか出来ないものかと思い、高速化をいろいろ試してみました。

nkojimaさんに意見いただきまして、Zipファイル圧縮を使った場合も追加しました。


環境など

この記事はC#で書いてます。Windows7です。VisualStudio 2017を使っています。

プログラムの速度評価には1個あたり4kB未満のファイル13,000個のセットを使っています。

ローカルからリモートフォルダへのコピーを想定しています。


試したこと


1. 普通にコピーしてみる


normal_copy

static void CopyFiles( string srcPath, string dstPath )

{
// コピー元ファイルの一覧(FileInfoの配列)を作る
System.IO.DirectoryInfo dir = new System.IO.DirectoryInfo( srcPath );
System.IO.FileInfo[] files =
dir.GetFiles( "*", System.IO.SearchOption.AllDirectories );

// コピー
foreach( var file in files )
{
string dst = dstPath+"\\"+file.Name;
System.IO.File.Copy( file.FullName, dst, true );
}
}


srcPathで指定したディレクトリ以下の全ファイルをdstPathにコピーします。サブフォルダは作りません。

結果は141秒。遅い。これが問題。


2. 並列処理を使ってコピーしてみる

高速化といえばまず思いつくのは並列処理です。複数のスレッドを使って並列処理でコピーしてみます。

プログラムではforeachをParallel.ForEachに変えるだけです。スレッドの数などシステム側で最適に決めてくれるはず。C#のこういうお手軽さがいいですね。


Parallel_copy

static void CopyFilesParallel( string srcPath, string dstPath )

{
// コピー元ファイルの一覧(FileInfoの配列)を作る
System.IO.DirectoryInfo dir = new System.IO.DirectoryInfo( srcPath );
System.IO.FileInfo[] files =
dir.GetFiles( "*", System.IO.SearchOption.AllDirectories );

// マルチスレッドでコピー
Parallel.ForEach( files, file =>
{
string dst = dstPath+"\\"+file.Name;
System.IO.File.Copy( file.FullName, dst, true );
});
}


結果、30秒でした。普通コピーの約5倍。


3. 転送するファイルを連結してコピーしてみる

多数のファイルを転送するため時間がかかるのだったら、1個のファイルにまとめてしまおう、ということで試してみました。

ただし、連結してしまうと本来のファイルの切れ目がわからなくなるので、ファイルの名前と位置を示す目次ファイルが別途に必要になります。

準備としてファイル名とバイトデータを組で記録するクラスを定義します。これを使って目次を作ります。


Index_class

class Index

{
public byte[] body; // ファイルのバイトデータを保存
public string name; // ファイル名を保存
}

次に連結してコピーする関数本体です。


Combine_copy

static void CombineCopy( string srcPath, string dstPath )

{
// コピーする全てのファイルのデータと名前を記録するリスト
List<Index> indexList = new List<Index>();
// コピー元ファイルの一覧(FileInfoの配列)を作る
System.IO.DirectoryInfo dir = new System.IO.DirectoryInfo( srcPath );
System.IO.FileInfo[] files =
dir.GetFiles( "*", System.IO.SearchOption.AllDirectories );

// 全ファイルを一度に読み込む
foreach( var file in files )
{
using( var reader = new System.IO.FileStream( file.FullName, System.IO.FileMode.Open ) )
{
var indexItem = new Index();
indexItem.body = new byte[reader.Length];
// ファイルのデータを記録
reader.Read( indexItem.body, 0, indexItem.body.Length );
// ファイルの名前を記録
indexItem.name = file.Name;
indexList.Add( indexItem );
}
}

// コピー先に連結したファイルを作る
using( var writer = new System.IO.FileStream( dstPath+"\\CombineCopy.bin", System.IO.FileMode.Create ) )
using( var indexWriter = new System.IO.StreamWriter( dstPath+"\\index.txt", false ) )
{
foreach( var indexItem in indexList )
{
// 全ファイルを連結する
writer.Write( indexItem.body, 0, indexItem.body.Length );
// 目次ファイルを書き出す
indexWriter.WriteLine( $"{indexItem.name} {indexItem.body.Length}" );
}
}
}


コピー対象ファイル全部をメモリに読み込んで、コピー先フォルダに全部を連結して書き出します。

連結したファイルを書き出す際にファイル名とファイル容量を目次ファイルに書き出します。

連結したファイルはコピー先で分解してもとに戻さないといけませんが、今回はコピーできたところで終わりにします。

結果は30秒でした。

内訳は、読み込みに30秒、転送に0.4秒。

コピー自体は圧倒的に速いですが、読み込みに時間がかかっています。


4. 連結するファイルを読み込むときに並列処理を使ってみる

読み込みが遅いのならば、並列処理で読み込みめばどうなるか、試してみました。

先ほどの並列処理を使ったコピーと同様に読み込むところでParallel.ForEachを使うだけです・・・と思ったのでしたが、そううまく行きませんでした。単純にforeachをParallel.ForEachに置き換えたコードは動作しません。


Combine_copy_parallel(抜粋)


// マルチスレッドで全ファイルを一度に読み込むつもり・・・
Parallel.ForEach( var file in files )
{
using( var reader = new System.IO.FileStream( file.FullName, System.IO.FileMode.Open ) )
{
var indexItem = new Index();
indexItem.body = new byte[reader.Length];
// ファイルのデータを記録
reader.Read( indexItem.body, 0, indexItem.body.Length );
// ファイルの名前を記録
indexItem.name = file.Name;
indexList.Add( indexItem );// これがダメ
}
}

Parallel.ForEach の中に indexList.Add( indexItem ); が書かれており、マルチスレッドでList<Index>へのList要素のAddが行われています。Listコレクションはマルチスレッドに対応していないため、ここで動作不良が起こってしまいます。


5. 連結するファイルを読み込むときに並列処理を使ってみる改

マルチスレッドで使えるコレクション、つまりスレッドセーフなコレクションとしてConcurrentBagがあります。これを使えばParallelの中でも動作不具合は起こりません。Listと同様にAddしてforeachでアクセスできます。ただし、どうも要素の順序が定まっていないみたいです。Microsoft Docsを見ると"unordered collection of objects":順序付けられていないオブジェクトのコレクション、とあります。

今回は目次ファイルを作るので、順不同でも特に問題はありません。


Combine_copy_parallel_fix

static void CombineCopyParallelFix( string srcPath, string dstPath )

{
// Lisをスレッドセーフなコレクションに変える
ConcurrentBag<Index> indexList = new ConcurrentBag<Index>();
// コピー元ファイルの一覧(FileInfoの配列)を作る
System.IO.DirectoryInfo dir = new System.IO.DirectoryInfo( srcPath );
System.IO.FileInfo[] files =
dir.GetFiles( "*", System.IO.SearchOption.AllDirectories );

// 並列処理でファイルを読み込む
Parallel.ForEach( files, file =>
{
using( var reader = new System.IO.FileStream( file.FullName, System.IO.FileMode.Open ) )
{
var indexItem = new Index();
indexItem.body = new byte[reader.Length];
// ファイル本体を読み込む
reader.Read( indexItem.body, 0, indexItem.body.Length );
// ファイル名
indexItem.name = file.Name;
indexList.Add( indexItem );
}
} );

// 連結ファイル書き出し。ここからは普通のと同じ。
using( var writer = new System.IO.FileStream( dstPath+"\\CombineCopy.bin", System.IO.FileMode.Create ) )
using( var indexWriter = new System.IO.StreamWriter( dstPath+"\\index.txt", false ) )
{
foreach( var index in indexList )
{
// ファイルのデータを連結
writer.Write( index.body, 0, index.body.Length );
// 目次ファイルを書き出す
indexWriter.WriteLine( $"{index.name} {index.body.Length}" );
}
}
}


結果は12秒。

内訳は読み込み11.5秒、目次ファイルを含めたコピーが0.5秒。


6. Zip圧縮して転送してみる

Zipを使ってファイルを1個にまとめて転送してみました。Zipを使うと目次ファイルがいらなくなるし、ファイルの結合・分解を自前でやる必要がありません。

圧縮する・しないでファイル容量に大きな差がなかったので圧縮はしません。


Zip_copy

static void ZipCopy( string srcPath, string zipFile )

{
System.IO.Compression.ZipFile.CreateFromDirectory( srcPath, zipFile,
System.IO.Compression.CompressionLevel.NoCompression, false );
}

なんと1行で書けます。

実行時間は55秒。


7. Zip圧縮とファイル読み込み並列化を組み合わせてみる

速度向上の効果が大きかった「ファイルを読み込むときに並列処理・・・」とZip転送を組み合わせてみます。ファイルを並列処理で読み込んでから、メモリ上でZip圧縮書庫作成を行い転送します。

ziplib(SharpZipLib)を使っています。


Zip_copy_parallel

static void ZipCopyParallel( string srcPath, string zipPath )

{
//書き込むZIP書庫のStream
var writer = new System.IO.FileStream( zipPath, System.IO.FileMode.Create, System.IO.FileAccess.Write );
//ZipOutputStreamを作成
var zipStream = new ICSharpCode.SharpZipLib.Zip.ZipOutputStream( writer );

//圧縮レベルを設定する。0は圧縮しない。9は最高圧縮。
zipStream.SetLevel( 0 );

// コピー元ファイルのリストを作る
System.IO.DirectoryInfo dir = new System.IO.DirectoryInfo( srcPath );
System.IO.FileInfo[] files =
dir.GetFiles( "*", System.IO.SearchOption.AllDirectories );

//srcPathの相対パスをエントリ名にする
var nameTrans = new ICSharpCode.SharpZipLib.Zip.ZipNameTransform( srcPath );

// 並列処理でファイルを読み込む
var fList = new ConcurrentBag<Index>();
Parallel.ForEach( files, file =>
{
using( System.IO.FileStream fs = new System.IO.FileStream( file.FullName, System.IO.FileMode.Open, System.IO.FileAccess.Read ) )
{
var idx = new Index();
idx.body = new byte[file.Length];
// ファイルを読み込む
fs.Read( idx.body, 0, idx.body.Length );
// ファイル名を記録
idx.name = file.FullName;
// コレクションに追加
fList.Add( idx );
}
} );

// コレクションに記録したファイルをZipにまとめる
foreach( var idx in fList )
{
// ZIP内のエントリの名前を決定する
string entryName = nameTrans.TransformFile( idx.name );

// ZipEntryを作成
var zipEntry = new ICSharpCode.SharpZipLib.Zip.ZipEntry( entryName );

// サイズを設定する。サイズを設定したほうが何故か高速。
zipEntry.Size = idx.body.Length;

// 新しいエントリの追加
zipStream.PutNextEntry( zipEntry );

// 書庫に書き込む
zipStream.Write( idx.body, 0, idx.body.Length );
}

// 閉じる
zipStream.Finish();
zipStream.Close();
writer.Close();
}


実行時間は28秒でした。


まとめ

コピー方法
転送時間(秒)
備考

普通にコピー
144

並列処理を使ってコピー
30
目次ファイル必要

転送するファイルを連結してコピー
30
転送先での分割はしない

連結するファイルを読み込むときに並列処理を使ってみる改
12
転送先での分割はしない

Zip圧縮して転送してみる
55
目次ファイル不要

Zip圧縮とファイル読み込み並列化を組み合わせてみる
28
目次ファイル不要

私の思いつく方法はこんなところです。頑張って10倍強達成です。

並列処理で単純結合が一番速いですが、目次ファイル必要、分割処理必要と手間がかかります。Zipは幾分遅いですが手間が省けて実用的かなとも思います。

数値はネットワーク環境によってかなり左右されると思います。


番外. IEnumerableを使ってみる?

あくまで気がするレベルなのですが、IEnumerableで速くなるみたいな気がするので試してみました。しかし、IEnumerableは、Parallelとは相性がよろしくないようです。ラムダ式の内部からyield returnは使えません、とコンパイラに怒られました。残念。

仕方ないので並列処理を使わない「3. 転送するファイルを連結してコピーしてみる」でIEnumerableを使ってみるとどうなるか試してみました。

IEnumerableを使うと、コピーするファイルのデータを保存するListが不要になります。読み込んだデータをすぐに書き込みに行くような処理になります。


Combine_copy_enumerable

//ファイルを読んで読み込んだデータの列挙を返す

static IEnumerable<Index> CombineCopyParallelFix2( string srcPath )
{
// コピー元ファイルの一覧(FileInfoの配列)を作る
System.IO.DirectoryInfo dir = new System.IO.DirectoryInfo( srcPath );
var files = dir.EnumerateFiles( "*", System.IO.SearchOption.AllDirectories );

// ファイルを読み込む
foreach( var file in files )
{
using( var reader = new System.IO.FileStream( file.FullName, System.IO.FileMode.Open ) )
{
var indexItem = new Index();
indexItem.body = new byte[reader.Length];
reader.Read( indexItem.body, 0, indexItem.body.Length );
indexItem.name = file.Name;
yield return indexItem;// 列挙します
}
}
}

// データの列挙を受け取って連結したファイルと目次を書き出す
static void WriteCombineFile( IEnumerable<Index> indexList, string dstPath )
{
using( var writer = new System.IO.FileStream( dstPath+"\\CombineCopy.bin", System.IO.FileMode.Create ) )
using( var indexWriter = new System.IO.StreamWriter( dstPath+"\\index.txt", false ) )
{
foreach( var indexItem in indexList )
{
writer.Write( indexItem.body, 0, indexItem.body.Length );
indexWriter.WriteLine( $"{indexItem.name} {indexItem.body.Length}" );
}
}
}

static void Main( string[] args )
{
string src_path = @"転送元ローカルパス";
string dst_path = @"転送先リモートパス";
var item = CombineCopyEnumerable( src_path ); // 列挙を作成して
WriteCombineFile( item, dst_path ); // それを書き込む
}


結果、30秒。変わりませんでした。ちゃんちゃん。


参考URL