为啥 File.ReadAllLinesAsync() 会阻塞 UI 线程?

Posted

技术标签:

【中文标题】为啥 File.ReadAllLinesAsync() 会阻塞 UI 线程?【英文标题】:Why File.ReadAllLinesAsync() blocks the UI thread?为什么 File.ReadAllLinesAsync() 会阻塞 UI 线程? 【发布时间】:2021-08-29 18:35:27 【问题描述】:

这是我的代码。用于读取文件行的​​ WPF 按钮的事件处理程序:

private async void Button_OnClick(object sender, RoutedEventArgs e)

    Button.Content = "Loading...";
    var lines = await File.ReadAllLinesAsync(@"D:\temp.txt"); //Why blocking UI Thread???
    Button.Content = "Show"; //Reset Button text

我在 .NET Core 3.1 WPF App 中使用了异步版本的 File.ReadAllLines() 方法。

但它阻塞了 UI 线程!为什么?


更新:和@Theodor Zoulias一样,我做了一个测试:

private async void Button_OnClick(object sender, RoutedEventArgs e)
    
        Button.Content = "Loading...";
        TextBox.Text = "";

        var stopwatch = Stopwatch.StartNew();
        var task = File.ReadAllLinesAsync(@"D:\temp.txt"); //Problem
        var duration1 = stopwatch.ElapsedMilliseconds;
        var isCompleted = task.IsCompleted;
        stopwatch.Restart();
        var lines = await task;
        var duration2 = stopwatch.ElapsedMilliseconds;

        Debug.WriteLine($"Create: duration1:#,0 msec, Task.IsCompleted: isCompleted");
        Debug.WriteLine($"Await:  duration2:#,0 msec, Lines: lines.Length:#,0");


        Button.Content = "Show";
    

结果是:

Create: 652 msec msec, Task.IsCompleted: False | Await:   15 msec, Lines: 480,001

.NET Core 3.1、C# 8、WPF、调试版本 | 7.32 Mb 文件 (.txt) |硬盘 5400 SATA

【问题讨论】:

这能回答你的问题吗? How to Async Files.ReadAllLines and await for results? 你对正在阅读的文本文件做了什么,它有多大?例如,如果您在读取文件内容后立即将文本添加到文本框,则会在填充文本框时阻塞 UI 线程。 调用本身不会阻塞。通话结束后将所有内容注释掉,然后自己查看。 我的建议 - 按照答案中的建议使用 var lines = await Task.Run(() => File.ReadAllLines(@"D:\temp.txt"));,它不仅可以保持 UI 响应,而且 7x faster 比 ReadAllLinesAsync 更好! @aepot 您可以尝试稍微改进一下问题,例如修复大小写并正确格式化代码。这将增加该问题被重新投票的可能性(需要再投票一次)。 【参考方案1】:

遗憾的是,目前 (.NET 5) 用于访问文件系统的内置异步 API 并未根据 Microsoft 的 own recommendations 关于异步方法预期行为的一致实现。

基于 TAP 的异步方法可以在返回结果任务之前同步执行少量工作,例如验证参数和启动异步操作。应将同步工作保持在最低限度,以便异步方法可以快速返回。

StreamReader.ReadToEndAsync 之类的方法不会以这种方式运行,而是在返回不完整的Task 之前将当前线程阻塞相当长的时间。例如,在我的older experiment 从我的 SSD 读取 6MB 文件时,此方法将调用线程阻塞 120 毫秒,返回一个 Task,然后仅在 20 毫秒后完成。我的建议是避免使用来自 GUI 应用程序的异步文件系统 API,而是使用包装在 Task.Run 中的同步 API。

var lines = await Task.Run(() => File.ReadAllLines(@"D:\temp.txt"));

更新:以下是File.ReadAllLinesAsync的一些实验结果:

var stopwatch = Stopwatch.StartNew();
var task = File.ReadAllLinesAsync(@"C:\6MBfile.txt");
var duration1 = stopwatch.ElapsedMilliseconds;
bool isCompleted = task.IsCompleted;
stopwatch.Restart();
var lines = await task;
var duration2 = stopwatch.ElapsedMilliseconds;
Console.WriteLine($"Create: duration1:#,0 msec, Task.IsCompleted: isCompleted");
Console.WriteLine($"Await:  duration2:#,0 msec, Lines: lines.Length:#,0");

输出:

Create: 450 msec, Task.IsCompleted: False
Await:  5 msec, Lines: 204,000

方法File.ReadAllLinesAsync阻塞当前线程450毫秒,5毫秒后返回任务完成。这些测量值在多次运行后是一致的。

.NET Core 3.1.3、C# 8、控制台应用程序、发布版本(未附加调试器)、Windows 10、SSD Toshiba OCZ Arc 100 240GB


.NET 6 更新。使用 .NET 6 在相同硬件上进行相同测试:

Create: 19 msec, Task.IsCompleted: False
Await:  366 msec, Lines: 204,000

异步文件系统 API 的实现在 .NET 6 上得到了改进,但它们仍然远远落后于同步 API(它们大约 慢 2 倍,而且不是完全异步的)。所以我的建议是 使用包装在Task.Run 中的同步 API 仍然有效。

【讨论】:

OP 使用 .NET Core 3.1 API File.ReadAllLinesAsync。它是真正的异步。 Task.Run() 在这里是不好的做法,就像在苍蝇读取时浪费池线程一样,不建议用于基于 I/O 的操作。似乎 OP 的问题不在显示的代码范围内。 @aepot 我用File.ReadAllLinesAsync 的实验结果更新了我的答案。请在您的 PC 中尝试此代码 sn-p 并报告您的结果。关于Task.Run 是不好的做法,这对于 ASP.NET 应用程序绝对正确,对于 WinForms/WPF 应用程序几乎不重要。保持 UI 完全响应的价值使任何关于将ThreadPool 的大小增加一两个线程的考虑都相形见绌。 我已经完成了调查并且:是的! ReadAllLinesAsync 完全是 BROKEN API 方法。我有相当快的 SSD 并用 12MB 的文本文件(里面有简单的 json)进行了测试。结果让我的大脑崩溃了。太棒了:ReadAllLinesAsync = 350 毫秒,UI 冻结,Task.Run => ReadAllLines(同步调用 Task) - 55 毫秒。 Async 慢了 7 倍!使困惑。 最后: here's a Bug。我认为这是使用此信息和此reference 附加答案的原因。 ReadAllTextAsyncWriteAllLinesAsyncWriteAllTextAsync 的相同错误。没想到来自.NET。所有测试均在 .NET Core 3.1 上进行。 @aepot 是的,很遗憾异步文件系统 API 被破坏了。前段时间我写了my arguments,赞成在GUI 应用程序的事件处理程序中使用Task.Run,不包括内置的异步API,因为它们“由专家实现”。我几乎不知道...【参考方案2】:

感谢 Theodor Zoulias 的回答,它是正确且有效的。

在等待异步方法时,当前线程会等待异步方法的结果。在这种情况下,当前线程是主线程,所以它等待读取过程的结果,从而冻结 UI。 (UI由主线程处理)

为了与其他用户分享更多信息,我创建了一个 Visual Studio 解决方案来实际提供这些想法。

问题:异步读取一个大文件并处理它而不冻结 UI。

案例1:如果很少发生,我的建议是创建一个线程并读取文件的内容,处理文件然后杀死线程。使用按钮的点击事件中的以下代码行。

OpenFileDialog fileDialog = new OpenFileDialog()

    Multiselect = false,
    Filter = "All files (*.*)|*.*"
;
var b = fileDialog.ShowDialog();
if (string.IsNullOrEmpty(fileDialog.FileName))
    return;

Task.Run(async () =>

    var fileContent = await File.ReadAllLinesAsync(fileDialog.FileName, Encoding.UTF8);

    // Process the file content
    label1.Invoke((MethodInvoker)delegate
    
        label1.Text = fileContent.Length.ToString();
    );
);

案例2:如果连续发生,我的建议是创建一个频道并在后台线程中订阅它。每当发布新文件名时,消费者都会异步读取并处理它。

架构:

在您的构造函数中调用下面的方法 (InitializeChannelReader) 以订阅频道。

private async Task InitializeChannelReader(CancellationToken cancellationToken)

    do
    
        var newFileName = await _newFilesChannel.Reader.ReadAsync(cancellationToken);
        var fileContent = await File.ReadAllLinesAsync(newFileName, Encoding.UTF8);

        // Process the file content
        label1.Invoke((MethodInvoker)delegate
        
            label1.Text = fileContent.Length.ToString();
        );
     while (!cancellationToken.IsCancellationRequested);

调用方法方法,以便将文件名发布到将由消费者消费的频道。使用按钮的点击事件中的以下代码行。

OpenFileDialog fileDialog = new OpenFileDialog()

    Multiselect = false,
    Filter = "All files (*.*)|*.*"
;
var b = fileDialog.ShowDialog();
if (string.IsNullOrEmpty(fileDialog.FileName))
    return;

await _newFilesChannel.Writer.WriteAsync(fileDialog.FileName);

【讨论】:

以上是关于为啥 File.ReadAllLinesAsync() 会阻塞 UI 线程?的主要内容,如果未能解决你的问题,请参考以下文章

为啥使用 glTranslatef?为啥不直接更改渲染坐标?

为啥 DataGridView 上的 DoubleBuffered 属性默认为 false,为啥它受到保护?

为啥需要softmax函数?为啥不简单归一化?

为啥 g++ 需要 libstdc++.a?为啥不是默认值?

为啥或为啥不在 C++ 中使用 memset? [关闭]

为啥临时变量需要更改数组元素以及为啥需要在最后取消设置?