C#性能比较:通过Read\WriteAllLines和Read\WriteAllLinesAsync进行同步和异步文本文件IO操作的对比。

4

在处理相对较大的文本文件时,我注意到了一些奇怪的现象。异步读写实际上比非异步读取更慢:

例如,执行以下虚拟代码:

 var res1 = File.WriteAllLinesAsync(string.Format(@"C:\Projects\DelMee\file{0}.txt", i), lines);
 var res2 = File.WriteAllLinesAsync(string.Format(@"C:\Projects\DelMee\file{0}_bck.txt", i), lines);

 await res1;
 await res2;

实际上比...要慢得多。

  File.WriteAllLines(string.Format(@"C:\Projects\DelMee\file{0}.txt", i), lines);
  File.WriteAllLines(string.Format(@"C:\Projects\DelMee\file{0}_bck.txt", i), lines);

理论上第一种方法应该更快,因为在第一个写入完成之前应该已经开始了第二个写入。对于15~25MB的文件(10秒 vs 20秒),性能差异约为100%。
我注意到ReadAllLines和ReadAllLinesAsync也有相同的行为。
更新:0主要思路是在TestFileWriteXXX函数完成后处理所有文件。
Task.WhenAll(allTasks1); // Without await is not a valid option

更新:1 我添加了使用线程进行读写操作,效果提升了50%。以下是完整示例:

更新:2 我更新了代码,以消除缓冲区生成的开销。

        const int MaxAttempts = 5;
        static void Main(string[] args)
        {
            TestFileWrite();
            TestFileWriteViaThread();
            TestFileWriteAsync();
            Console.ReadLine();
        }

        private static void TestFileWrite()
        {
            Clear();
            Stopwatch stopWatch = new Stopwatch();
            stopWatch.Start();

            Console.WriteLine( "Begin TestFileWrite");

            for (int i = 0; i < MaxAttempts; ++i)
            {
                TestFileWriteInt(i);
            }

            TimeSpan ts = stopWatch.Elapsed;
            string elapsedTime = String.Format("{0:00}:{1:00}:{2:00}.{3:00}", ts.Hours, ts.Minutes, ts.Seconds, ts.Milliseconds / 10);
            Console.WriteLine("TestFileWrite took: " + elapsedTime);
        }

        private static void TestFileWriteViaThread()
        {
            Clear();
            Stopwatch stopWatch = new Stopwatch();
            stopWatch.Start();

            Console.WriteLine("Begin TestFileWriteViaThread");

            List<Thread> _threads = new List<Thread>();

            for (int i = 0; i < MaxAttempts; ++i)
            {
                var t = new Thread(TestFileWriteInt);
                t.Start(i);
                _threads.Add(t);
            }

            _threads.ForEach(T => T.Join());

            TimeSpan ts = stopWatch.Elapsed;
            string elapsedTime = String.Format("{0:00}:{1:00}:{2:00}.{3:00}", ts.Hours, ts.Minutes, ts.Seconds, ts.Milliseconds / 10);
            Console.WriteLine("TestFileWriteViaThread took: " + elapsedTime);
        }

        private static void TestFileWriteInt(object oIndex)
        {
            int index = (int)oIndex;
            List<string> lines = GenerateLines(index);

            File.WriteAllLines(string.Format(@"C:\Projects\DelMee\file{0}.txt", index), lines);
            File.WriteAllLines(string.Format(@"F:\Projects\DelMee\file{0}_bck.txt", index), lines);

            var text = File.ReadAllLines(string.Format(@"C:\Projects\DelMee\file{0}.txt", index));
            var text1 = File.ReadAllLines(string.Format(@"C:\Projects\DelMee\file{0}.txt", index));

            //File.WriteAllLines(string.Format(@"C:\Projects\DelMee\file_test{0}.txt", index), text1);
        }

        private static  async void TestFileWriteAsync()
        {
            Clear();

            Console.WriteLine("Begin TestFileWriteAsync ");
            Stopwatch stopWatch = new Stopwatch();
            stopWatch.Start();

            for (int i = 0; i < MaxAttempts; ++i)
            {
                List<string> lines = GenerateLines(i);
                var allTasks = new List<Task>();

                allTasks.Add(File.WriteAllLinesAsync(string.Format(@"C:\Projects\DelMee\file{0}.txt", i), lines));
                allTasks.Add(File.WriteAllLinesAsync(string.Format(@"F:\Projects\DelMee\file{0}_bck.txt", i), lines));

                await Task.WhenAll(allTasks);

                var allTasks1 = new List<Task<string[]>>();
                allTasks1.Add(File.ReadAllLinesAsync(string.Format(@"C:\Projects\DelMee\file{0}.txt", i)));
                allTasks1.Add(File.ReadAllLinesAsync(string.Format(@"C:\Projects\DelMee\file{0}.txt", i)));

                await Task.WhenAll(allTasks1);

//                await File.WriteAllLinesAsync(string.Format(@"C:\Projects\DelMee\file_test{0}.txt", i), allTasks1[0].Result);
            }

            stopWatch.Stop();

            TimeSpan ts = stopWatch.Elapsed;
            string elapsedTime = String.Format("{0:00}:{1:00}:{2:00}.{3:00}", ts.Hours, ts.Minutes, ts.Seconds, ts.Milliseconds / 10);
            Console.WriteLine("TestFileWriteAsync took: " + elapsedTime);
        }

        private static void Clear()
        {
            for (int i = 0; i < 15; ++i)
            {
                System.IO.File.Delete(string.Format(@"C:\Projects\DelMee\file{0}.txt", i));
                System.IO.File.Delete(string.Format(@"F:\Projects\DelMee\file{0}_bck.txt", i));
            }
        }


        static string buffer = new string('a', 25 * 1024 * 1024);
        private static List<string> GenerateLines(int i)
        {
            return new List<string>() { buffer };
        }

结果如下:

TestFileWrite 耗时: 00:00:03.50

TestFileWriteViaThread 耗时: 00:00:01.63

TestFileWriteAsync 耗时: 00:00:06.78

8个核心CPU,C盘和F盘分别使用两个不同的SATA连接850 EVO固态硬盘。

更新3 - 结论 看起来 File.WriteAllLinesAsync 在处理大量数据刷新的场景时表现良好。正如下面的回答所指出的那样,最好直接使用 FileStream。但是异步操作仍然比顺序访问慢。

但目前最快的方法仍然是使用多线程。


6
理论上,第一种方法应该更快。但这是错误的假设。使用"async"并不一定会使事情更快,这取决于实现方式。参考链接:https://stackoverflow.com/questions/54753339/async-file-reading-40-times-slower-than-synchronous-or-manual-threads - mjwills
2
@Kiko 我认为问题在于你正在将数据写入单个物理驱动器。如果你使用SATA驱动器,那么它无法同时写入数据。因此,你的代码中的写操作本身可能是顺序执行的。 - Iliar Turdushev
1
你的问题缺乏细节,任何人都无法确定为什么你看到了相对性能差异。之前的两个评论提供了非常合理的解释,但是坦白地说,如果没有更多细节和至少一个良好的 [mcve],你的问题将无法得到任何好的答案。即使有了这些,这个问题可能也不是很新颖或有用。 - Peter Duniho
1
@IliarTurdushev 很好的观点。但我在两个不同的驱动器上尝试了,结果是相同的。 - Kiko
2
@Kiko,你不能这样测试代码性能。你的代码执行了大量的字符串操作,这会对性能产生比同步和异步之间的任何差异都更大的影响。磁盘IO也受到所有因素的影响,所以只进行5次迭代是没有意义的——每个运行程序都会完全不同。使用BenchmarkDotNet,并检查分配开销。重写你的测试,使它们全部写入相同的静态字符串。 - Panagiotis Kanavos
显示剩余9条评论
2个回答

3
我认为这是一个已知的问题。如果您搜索一下,您会看到很多类似的帖子。
例如https://github.com/dotnet/runtime/issues/23196 如果单个IO操作需要快速响应,则应始终使用同步IO以及同步方法。 Write*Async 方法在内部以异步IO模式打开文件流,与同步IO相比具有额外的开销成本。

https://learn.microsoft.com/en-us/windows/win32/fileio/synchronous-and-asynchronous-i-o

然而,对于相对较快的I/O操作,处理内核I/O请求和内核信号的开销可能使异步I/O不那么有益,特别是如果需要进行许多快速的I/O操作。在这种情况下,同步I/O会更好。
此外,FileStreamStreamWriter中的异步方法可能存在小缓冲区的问题。写入文件流的默认缓冲区大小为4KB,远小于文件大小(25MB至50MB)。尽管缓冲区大小似乎对同步方法有效,但它夸大了异步方法产生的开销成本。
参见line,每当缓冲区满时,该方法都会产生线程。如果使用默认的4096字节缓冲区编写25MB文件,则会发生6400次。
为了优化这一点,如果整个文件都在内存中,则可以将缓冲区大小设置为文件大小,以减少每次写入和刷新之间的上下文切换和同步。
如果您在代码中使用不同的缓冲区大小打开FileStreamStreamWriter,并运行WriteWriteAsync测试,您将看到差异。如果缓冲区大小与文件大小相同,则同步和异步方法之间的差异非常小。
例如:
// 4KB buffer sync stream
using (var stream = new FileStream(
    path, FileMode.Create, FileAccess.Write, FileShare.Read, 
    4096, FileOptions.SequentialScan))
{
    using (var writer = new StreamWriter(stream, Encoding.UTF8))
    {
        writer.Write(str25mb);
    }
}

// 25MB buffer sync stream
using (var stream = new FileStream(
    path, FileMode.Create, FileAccess.Write, FileShare.Read, 
    25 * 1024 * 1024, FileOptions.SequentialScan))
{
    using (var writer = new StreamWriter(stream, Encoding.UTF8))
    {
        writer.Write(str25mb);
    }
}

// 4KB buffer async stream
using (var stream = new FileStream(
    path,
    FileMode.Create, FileAccess.Write, FileShare.Read,
    4096, FileOptions.Asynchronous | FileOptions.SequentialScan))
using (var writer = new StreamWriter(stream, Encoding.UTF8))
{
    await writer.WriteAsync(str25mb);
}

// 25MB buffer async stream
using (var stream = new FileStream(
    path,
    FileMode.Create, FileAccess.Write, FileShare.Read,
    25 * 1024 * 1024, FileOptions.Asynchronous | FileOptions.SequentialScan))
using (var writer = new StreamWriter(stream, Encoding.UTF8))
{
    await writer.WriteAsync(str25mb);
}

结果(我每个测试运行了10次)是:

TestFileWriteWithLargeBuffer took: 00:00:00.9291647
TestFileWriteWithLargeBufferAsync took: 00:00:01.1950127
TestFileWrite took: 00:00:01.5251026
TestFileWriteAsync took: 00:00:03.6913877

你是对的,但是异步仍然比非异步慢2倍。与WriteAllLines相同的比例。 - Kiko
1
WriteAllLinesWriteAllLinesAsync内部使用带有小缓冲区的FileStreamStreamWriter,因此异步方法比同步方法慢两倍。但仅在写入大文件且需要多次刷新缓冲区时才会发生这种情况。在这种情况下,您可以通过使用具有大尺寸缓冲区的FileStream来优化写操作。 - weichch
1
就“异步比同步慢”而言,根据MSDN文档的说法,对于任何单个IO操作都是正确的。 - weichch

1
我的第一个回答是错误的,因为在尝试从TestFileWriteAsync中删除async时没有使用wait for Task.WhenAll。

我已经修复了测试,结果显示File.Write*Async确实更慢。
Begin TestFileWriteAsync
TestFileWriteAsync took: 00:00:13.7128699
Begin TestFileWrite
TestFileWrite took: 00:00:01.5734895
Begin TestFileWriteViaThread
TestFileWriteViaThread took: 00:00:00.8322218

请原谅我。
PS,我已经查看了异步方法的源代码。
看起来,File.WriteAllLinesAsync和File.WriteAllTextAsync使用相同的InternalWriteAllTextAsync,该方法会多次复制原始缓冲区的一部分。
buffer = ArrayPool<char>.Shared.Rent(DefaultBufferSize);
int count = contents.Length;
int index = 0;
while (index < count)
{
 int batchSize = Math.Min(DefaultBufferSize, count - index);
 contents.CopyTo(index, buffer, 0, batchSize);
#if MS_IO_REDIST
 await sw.WriteAsync(buffer, 0, batchSize).ConfigureAwait(false);
#else
 await sw.WriteAsync(new ReadOnlyMemory<char>(buffer, 0, batchSize), cancellationToken).ConfigureAwait(false);
#endif

contents.CopyTo(index, buffer, 0, batchSize);这行代码是将原始数据缓冲区的一部分复制到缓冲区。

您可以尝试使用File.WriteAllBytesAsync,它采用原始数据缓冲区“原样”并且不执行额外的复制操作:

Begin TestFileWriteAsync
TestFileWriteAsync took: 00:00:00.7741439
Begin TestFileWrite
TestFileWrite took: 00:00:00.5772008
Begin TestFileWriteViaThread
TestFileWriteViaThread took: 00:00:00.4457552

WriteAllBytesAsync 测试源代码



@Kiko 请尝试使用 WriteAllBytesAsync - 它与 WriteAllBytes 方法一样快。我已经更新了我的答案,其中包含了一部分 .Net Core 源代码,可以解释为什么 WriteAllLinesAsync 如此缓慢。这里是带有字节数组源代码的测试 v3 - oleksa
嗨@oleksa,感谢您的分析。我仍在努力理解我所看到的:)。我发现这个函数实现得非常简单。微软的目标是按块编写,但他们可以简单地使用**WriteAsync(buffer, offset, count)**并使用适当的偏移量,而不是复制缓冲区+使用ReadOnlyMemory。 - Kiko
@weichch 我刚刚发现 RuntimeHelpers.IsReferenceOrContainsReferences<string>() 的结果是 true。这意味着 InternalWriteAllTextAsync 导致 void Memmove<T> 使用 BulkMoveWithWriteBarrier。这是 InternalWriteAllTextAsyncInternalWriteAllLines 之间的实际区别。InternalWriteAllLines 使用 Stream.Write(),该方法调用 Buffer.Memmove - oleksa
有趣的是,如果使用OP的数据和测试,“WriteAllTextAsync”确实比“WriteAllText”慢,但我的4096字节数据不会给我相同的结果。 - weichch
@weichch 我已经尝试使用100KB缓冲区进行测试,结果为TestFileWriteAsync 00.2640504TestFileWrite 00.0281505。对于1MB缓冲区,TestFileWriteAsync took: 00:00:01.0431329,而TestFileWrite took: 00:00:00.1100321 - oleksa
显示剩余4条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接