前置き
我が家は妻が写真や動画を撮るのが好きで、だいたい 100GB/年 ぐらいのペースでそれらが増えていきます。今は 4TB の HDD にそれらをぶち込んでいるのですが、私の管理が悪いためファイルがめちゃくちゃ重複している状況です。
次の HDD の買い替えまでに重複したファイルを削除してスッキリさせたい。そうだ、Haskell でツールを作って重複ファイルを洗い出そう!
そんな理由で dupcheck1 という同一ファイル検出ツールを作った際に得られた知見を共有します。
設計方針
ツールの大雑把な設計方針は以下のとおりです。
- コマンドラインツール
- 同一ファイルを検出したいディレクトリを引数で複数指定
- 検出結果は標準出力
詳細設計方針
同一ファイルをどのように検出するか、については以下のようにしました。
- ファイルからハッシュを求めてそのハッシュ同士を比較する
ハッシュを求めるコストはそれなりに高いのですが、例えば 100 個のファイルの比較を行おうとすると、比較は 4950 回行う必要があるわけで、これが 1000 個の場合は 499500 回と増えていき、要するに比較のコストを可能な限り低くすることがこのツールがストレスなく動作するためのポイントであり、そのためには多少のコストは払ってもハッシュを求めてハッシュ同士の比較を行うのが良い、と考えました。
ハッシュライブラリ
ハッシュ関数はいろいろあるのですが、今回のケースでは MD5 が最適です。その理由としては:
- ふたつのファイルが異なることを十分にチェックすることが可能である
- 計算コストが他のハッシュ関数と比べると低い
- 攻撃等が介在しない範囲での同一性チェックには最適なハッシュ関数
などが挙げられます2。
MD5 を導出できる Haskell のライブラリはいくつかあるのですが pureMD5 が md5 :: ByteString -> MD5Digest
という関数を提供していて使い勝手が良いためこれにしました。
動作確認
実装して動作させてみるとうまく重複ファイルを検出してくれるのですが、大量ファイルがあるディレクトリを指定すると openBinaryFile: resource exhausted (Too many open files)
というメッセージを出力してツールが終了してしまいます。
これは OS が許容するファイルディスクリプタの最大数までファイルを開いてしまったことを示すエラーで、要するにファイルを開きすぎです。
問題の実装
このツールでファイルを開いているのは md5sum
という関数だけで、ここが原因のはずです。
md5sum :: FilePath -> IO (MD5Digest, FilePath)
md5sum file = LBI.readFile file >>= \contents -> return (md5 contents, file)
LBI
は Data.ByteString.Lazy
を表しています。Lazy 版を使用しているのは md5
関数が Data.ByteString.Lazy.ByteString
を要求するためです。
カンの良い方はもう気がついたかもしれませんが、以下のように修正しました。
md5sum :: FilePath -> IO (MD5Digest, FilePath)
-md5sum file = LBI.readFile file >>= \contents -> return (md5 contents, file)
+md5sum file = BI.readFile file >>= \contents -> return (md5 (LBI.fromStrict contents), file)
BI
は Data.ByteString
を表しています。Lazy ではない方の readFile
で Lazy ではない方の ByteString
を取得し、md5
には fromStrict
で Lazy な ByteString
に変換して渡す、という修正です。
Haskell は遅延評価戦略に基づき、それが本当に必要になるまで評価はしません。今回のツールでは先にすべてのファイルのハッシュを求めて、それから比較を行っていきますが、比較が始まるまで実際にはファイルのハッシュは計算されないが、readFile
によってファイルディスクリプタは消費される、ということなんだと思います3。
まとめ
-
Data.ByteString.Lazy.readFile
はコンテンツの読み込み自体は遅延されるが、ファイルディスクリプタは消費されるようである - 通常この挙動は気にしなくても良いはずだが、今回のツールのようにファイルを大量に読み出すような場合には問題になる
- そうした場合は
Data.ByteString.readFile
を使うことで問題を回避できる
-
今回の記事のテーマであるリソースリークは回避できたのですが、やっぱり対象ファイルが大量にあるとメモリが足りなくなって途中で落ちます。スペースリークが発生しているだと思いますが、こちらはまだ解決しておらず、これに関してはまた別の機会に解決して記事にしようと思います ↩
-
Hash関数まとめ(md5, sha1, sha2, crc32, PBKDF2) #Pistatium がわかりやすくまとまっています ↩
-
更に
md5
で導出されたハッシュを即座に標準出力に表示して動作検証を行ったところ、Data.ByteString.Lazy.readFile
は得られたコンテンツをすべて読み終えているにもかかわらずファイルディスクリプタは即座には閉じてくれない、という動作のようです ↩