最近專案有個需求,需要比較兩個任意大小檔案的內容是否相同,要求如下:
- 專案是.NET CORE,所以使用C#進行編寫比較方法
- 檔案大小任意,所以不能將檔案內容全部讀入到記憶體中進行比較(更專業點說,需要使用非快取的比較方式)
- 不依賴第三方庫
- 越快越好
為了選出最優的解決方案,我搭建了一個簡單的命令列工程,準備了兩個大小為912MB的檔案,並且這兩個檔案內容完全相同.在本文的最後,你可以看到該工程的Main方法的程式碼.
下面我們開始嘗試各個比較方法,選出最優的解決方案:
比較兩個檔案是否完全相同,首先想到的是用雜湊演算法(如MD5,SHA)算出兩個檔案的雜湊值,然後進行比較.
廢話少說,擼起袖子寫一個MD5比較方法:
/// <summary>
/// MD5
/// </summary>
/// <param name="file1"></param>
/// <param name="file2"></param>
/// <returns></returns>
private static bool CompareByMD5(string file1, string file2)
{
// 使用.NET內建的MD5庫
using (var md5 = MD5.Create())
{
byte[] one, two;
using (var fs1 = File.Open(file1, FileMode.Open))
{
// 以FileStream讀取檔案內容,計算HASH值
one = md5.ComputeHash(fs1);
}
using (var fs2 = File.Open(file2, FileMode.Open))
{
// 以FileStream讀取檔案內容,計算HASH值
two = md5.ComputeHash(fs2);
}
// 將MD5結果(位元組陣列)轉換成字串進行比較
return BitConverter.ToString(one) == BitConverter.ToString(two);
}
}
比較結果:
Method: CompareByMD5, Identical: True. Elapsed: 00:00:05.7933178
耗時5.79秒,感覺還不錯.然而,這是最佳的解決方案嗎?
其實我們仔細想一下,答案應該是否定的.
因為任何雜湊演算法本質上都是對位元組進行一定的計算,而計算過程是要消耗時間的.
很多下載網站上提供了下載檔案的雜湊值,那是因為下載的原始檔本身不會改變,只需要計算一次原始檔的雜湊值,提供給使用者驗證即可.
而我們的需求中,兩個檔案都是不固定的,那麼每次都要計算兩個檔案的雜湊值,就不太合適了.
所以,雜湊比較這個方案被PASS.
這種求演算法最優解的問題,我以往的經驗是: 去stackoverflow查詢 :)
經過我的艱苦努力,找到了一個非常切題的答案: How to compare 2 files fast using .NET?
得贊最多一個答案,將程式碼改造了一下放入工程中:
/// <summary>
/// https://stackoverflow.com/a/1359947
/// </summary>
/// <param name="file1"></param>
/// <param name="file2"></param>
/// <returns></returns>
private static bool CompareByToInt64(string file1, string file2)
{
const int BYTES_TO_READ = sizeof(Int64); // 每次讀取8個位元組
int iterations = (int)Math.Ceiling((double)new FileInfo(file1).Length / BYTES_TO_READ); // 計算讀取次數
using (FileStream fs1 = File.Open(file1, FileMode.Open))
using (FileStream fs2 = File.Open(file2, FileMode.Open))
{
byte[] one = new byte[BYTES_TO_READ];
byte[] two = new byte[BYTES_TO_READ];
for (int i = 0; i < iterations; i++)
{
// 迴圈讀取到位元組陣列中
fs1.Read(one, 0, BYTES_TO_READ);
fs2.Read(two, 0, BYTES_TO_READ);
// 轉換為Int64進行數值比較
if (BitConverter.ToInt64(one, 0) != BitConverter.ToInt64(two, 0))
return false;
}
}
return true;
}
該方法基本的原理是迴圈讀取兩個檔案,每次讀取8個位元組,轉換為Int64,再進行數值比較.那麼效率如何呢?
Method: CompareByToInt64, Identical: True. Elapsed: 00:00:08.0918099
什麼?8秒!竟然比MD5還慢?這不是SO得贊最多的答案嗎,怎麼會這樣?
其實分析一下不難想到原因,因為每次只讀取8個位元組,程式頻繁的進行IO操作,導致效能低下.看來SO上的答案也不能迷信啊!
那麼優化的方向就變為了如何減少IO操作帶來的損耗.
既然每次8個位元組太少了,我們定義一個大一些的位元組陣列,比如1024個位元組.每次讀取1024個位元組到陣列中,然後進行位元組陣列的比較.
但是這樣又帶來一個新問題,就是如何快速比較兩個位元組陣列是否相同?
我首先想到的是在MD5方法中用過的----將位元組陣列轉換成字串進行比較:
/// <summary>
/// 讀入到位元組陣列中比較(轉為String比較)
/// </summary>
/// <param name="file1"></param>
/// <param name="file2"></param>
/// <returns></returns>
private static bool CompareByString(string file1, string file2)
{
const int BYTES_TO_READ = 1024 * 10;
using (FileStream fs1 = File.Open(file1, FileMode.Open))
using (FileStream fs2 = File.Open(file2, FileMode.Open))
{
byte[] one = new byte[BYTES_TO_READ];
byte[] two = new byte[BYTES_TO_READ];
while (true)
{
int len1 = fs1.Read(one, 0, BYTES_TO_READ);
int len2 = fs2.Read(two, 0, BYTES_TO_READ);
if (BitConverter.ToString(one) != BitConverter.ToString(two)) return false;
if (len1 == 0 || len2 == 0) break; // 兩個檔案都讀取到了末尾,退出while迴圈
}
}
return true;
}
結果:
Method: CompareByString, Identical: True. Elapsed: 00:00:07.8088732
耗時也接近8秒,比上一個方法強不了多少.
分析一下原因,在每次迴圈中,字串的轉換是一個非常耗時的操作.那麼有沒有不進行型別轉換的位元組陣列比較方法呢?
我想到了LINQ中有一個比較序列的方法SequenceEqual
,我們嘗試使用該方法比較:
/// <summary>
/// 讀入到位元組陣列中比較(使用LINQ的SequenceEqual比較)
/// </summary>
/// <param name="file1"></param>
/// <param name="file2"></param>
/// <returns></returns>
private static bool CompareBySequenceEqual(string file1, string file2)
{
const int BYTES_TO_READ = 1024 * 10;
using (FileStream fs1 = File.Open(file1, FileMode.Open))
using (FileStream fs2 = File.Open(file2, FileMode.Open))
{
byte[] one = new byte[BYTES_TO_READ];
byte[] two = new byte[BYTES_TO_READ];
while (true)
{
int len1 = fs1.Read(one, 0, BYTES_TO_READ);
int len2 = fs2.Read(two, 0, BYTES_TO_READ);
if (!one.SequenceEqual(two)) return false;
if (len1 == 0 || len2 == 0) break; // 兩個檔案都讀取到了末尾,退出while迴圈
}
}
return true;
}
結果:
Method: CompareBySequenceEqual, Identical: True. Elapsed: 00:00:08.2174360
竟然比前兩個都要慢(實際這也是所有方案中最慢的一個),LINQ的SequenceEqual看來不是為了效率而生.
那麼我們不用那些花哨的功能,迴歸質樸,老實兒的使用while迴圈比較位元組陣列怎麼樣呢?
/// <summary>
/// 讀入到位元組陣列中比較(while迴圈比較位元組陣列)
/// </summary>
/// <param name="file1"></param>
/// <param name="file2"></param>
/// <returns></returns>
private static bool CompareByByteArry(string file1, string file2)
{
const int BYTES_TO_READ = 1024 * 10;
using (FileStream fs1 = File.Open(file1, FileMode.Open))
using (FileStream fs2 = File.Open(file2, FileMode.Open))
{
byte[] one = new byte[BYTES_TO_READ];
byte[] two = new byte[BYTES_TO_READ];
while (true)
{
int len1 = fs1.Read(one, 0, BYTES_TO_READ);
int len2 = fs2.Read(two, 0, BYTES_TO_READ);
int index = 0;
while (index < len1 && index < len2)
{
if (one[index] != two[index]) return false;
index++;
}
if (len1 == 0 || len2 == 0) break;
}
}
return true;
}
結果是....
Method: CompareByByteArry, Identical: True. Elapsed: 00:00:01.5356821
1.53秒!大突破!看來有時候看起來笨拙的方法反而效果更好!
試驗到此,比較兩個900多MB的檔案耗時1.5秒左右,讀者對於該方法是否滿意呢?
No!我不滿意!我相信通過努力,一定會找到更快的方法的!
同樣.NET CORE也在為了編寫高效能程式碼而不斷的優化中.
那麼,我們如何繼續優化我們的程式碼呢?
我突然想到在C# 7.2中加入的一個新的值型別: Span<T>
,它用來代表一段連續的記憶體區域,並提供一系列可操作該區域的方法.
對於我們的需求,因為我們不會更改陣列的值,所以可以使用另外一個只讀的型別ReadOnlySpan<T>
追求更高的效率.
修改程式碼,使用ReadOnlySpan<T>
:
/// <summary>
/// 讀入到位元組陣列中比較(ReadOnlySpan)
/// </summary>
/// <param name="file1"></param>
/// <param name="file2"></param>
/// <returns></returns>
private static bool CompareByReadOnlySpan(string file1, string file2)
{
const int BYTES_TO_READ = 1024 * 10;
using (FileStream fs1 = File.Open(file1, FileMode.Open))
using (FileStream fs2 = File.Open(file2, FileMode.Open))
{
byte[] one = new byte[BYTES_TO_READ];
byte[] two = new byte[BYTES_TO_READ];
while (true)
{
int len1 = fs1.Read(one, 0, BYTES_TO_READ);
int len2 = fs2.Read(two, 0, BYTES_TO_READ);
// 位元組陣列可直接轉換為ReadOnlySpan
if (!((ReadOnlySpan<byte>)one).SequenceEqual((ReadOnlySpan<byte>)two)) return false;
if (len1 == 0 || len2 == 0) break; // 兩個檔案都讀取到了末尾,退出while迴圈
}
}
return true;
}
核心是用來比較的SequenceEqual
方法,該方法是ReadOnlySpan
的一個擴充套件方法,要注意它只是方法名與LINQ中一樣,實現完全不同.
那麼該方法的表現如何呢?
Method: CompareByReadOnlySpan, Identical: True. Elapsed: 00:00:00.9287703
不 到 一 秒!
相對上一個已經不錯的結果,速度提高了差不多40%!
對此結果,我個人覺得已經很滿意了,如果各位有更快的方法,請不吝賜教,我非常歡迎!
關於Span<T>
結構型別,各位讀者如有興趣,可瀏覽該文章,該文有非常詳細的介紹.
後記
文中的程式碼只是出於實驗性質,實際應用中仍可以繼續細節上的優化, 如:
- 如兩個檔案大小不同,直接返回false
- 如果兩個檔案路徑相同,直接返回true
- ...
試驗工程的Main方法原始碼:
static void Main(string[] args) { string file1 = @"C:\Users\WAKU\Desktop\file1.ISO"; string file2 = @"C:\Users\WAKU\Desktop\file2.ISO"; var methods = new Func<string, string, bool>[] { CompareByMD5, CompareByToInt64, CompareByByteArry, CompareByReadOnlySpan }; foreach (var method in methods) { var sw = Stopwatch.StartNew(); bool identical = method(file1, file2); Console.WriteLine("Method: {0}, Identical: {1}. Elapsed: {2}", method.Method.Name, identical, sw.Elapsed); } }
完.