最近项目有个需求, 需要比较两个任意大小文件的内容是否相同, 要求如下:
项目是. 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.
这种求算法最优解的问题, 我以往的经验是: 去 Stack Overflow 查找 :)
经过我的艰苦努力, 找到了一个非常切题的答案: 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);
- }
- }
完.
来源: https://www.cnblogs.com/waku/p/11069214.html