等待和异步阻塞UI

5
我写了一个小的Winforms应用程序,可以搜索磁盘上的文件(对于问题来说,文件类型并不重要)。问题在于,可能会有100,000个或更多的文件,因此该操作需要时间。
我想要实现的是将搜索操作作为异步操作进行,并且不要阻塞UI线程,以便窗体不会被卡住。
我可以使用backgroundWorker来做到这一点,但出于某些原因,我无法使用async\await机制。
以下是我的代码:
private async void button_FindFiles_Click(object sender, EventArgs e)
{
    await SearchFilesUtil.SearchPnrFilesAsync(this.textBox_mainDirectory.Text);
    MessageBox.Show("After SearchPnrFilesAsync");
}

public async static Task SearchPnrFilesAsync(string mainDir)
{
    foreach (string file in Directory.EnumerateFiles(mainDir, ".xml", SearchOption.AllDirectories))
    {
        var fileContenet = File.ReadAllText(file);
        var path = Path.Combine(@"C:\CopyFileHere", Path.GetFileName(file));
        using (StreamWriter sw = new StreamWriter(path))
        {
            await sw.WriteAsync(fileContenet);
        }
    }
}

为什么UI线程会卡住,无法立即显示MessageBox?我漏掉了什么吗?

mainDir在哪里,里面有多少个文件? - Hamid Pourjam
主目录位于C盘,目录树中可能有20-30万个文件。 - Dardar
3个回答

4

在方法 SearchPnrFilesAsync 上标记 async 关键字本身并不能神奇地在单独的任务中异步启动此方法的执行。

事实上,SearchPnrFilesAsync 中除了 sw.WriteAsync 之外的所有代码都在 UI 线程中执行,从而阻塞它。

如果您需要在单独的任务中执行整个方法,可以像这样进行包装:

public async static Task SearchPnrFilesAsync(string mainDir)
{
   await Task.Run(() => your_code_here);
}

2
@dotctor 为什么不行呢?如果 OP 需要这个方法在另一个任务中完全运行,那么只有你提到的 ReadAllText 是同步运行的,而 Directory.EnumerateFiles 也是如此。 - Andrey Korneyev
@AndyKorneyev 我同意你的观点。这只是一个例子。 - Jannik
2
我应该为同步方法暴露异步包装器吗? - Hamid Pourjam
1
为什么你不应该使用Task.Run()创建异步包装器 - Hamid Pourjam
1
Directory.EnumerateFiles没有太高的开销。UI冻结的主要原因仅仅是ReadAllText - Hamid Pourjam
@dotctor 感谢您提供的链接,我发现这些文章很有趣。是的,看起来在这种情况下使用 Task.Run 只会实现任务的转移而不是可扩展性,可能违反了某些指南规定。因此,在 OP 的情况下最好不要使用 async/await,而是处理手动创建的 Task。如果他想要实现高 UI 响应能力,这仍然很重要,因为在目录/子目录中存在大量文件时,Directory.EnumerateFiles 可能 成为瓶颈。事实上,如果文件数量约为几千个,则在典型 PC 上需要几秒钟。 - Andrey Korneyev

4
为什么UI线程会卡住?
可能是因为你在UI线程上执行了一些阻塞操作,例如读取文件。
为什么我的MessageBox不能立即显示?
因为这不是async的工作方式。当你await一个方法时,它会异步地将控制权交给调用者,直到操作完成。当第一个await被触发时,你开始从磁盘读取文件并复制它们。await并不意味着“在不同的线程上执行这个任务并继续执行”。
你可能想要做的是使用异步读取机制而不是阻塞的File.ReadAllText。你可以使用StreamReader来实现:
public static async Task SearchPnrFilesAsync(string mainDir)
{
    foreach (string file in Directory.EnumerateFiles(mainDir, ".xml",
                                                        SearchOption.AllDirectories))
    {
        var path = Path.Combine(@"C:\CopyFileHere", Path.GetFileName(file));
        using (var reader = File.OpenRead(file))
        using (var writer = File.OpenWrite(path))
        {
            await reader.CopyToAsync(writer);
        }   
    }   
}

@dotctor,你不必这样做,这只是一种方法。如果文件很大,你不想将其全部加载到内存中。这取决于OP的需求。 - Yuval Itzchakov
@Paulo 是的,也许那样会更好。 - Yuval Itzchakov
至少它更短,易于阅读。而且它不会为整个文件内容分配空间,如果正在读/写大文件,则这是不好的。这种方式,每个文件将被分配一个81920字节的缓冲区(Stream.CopyToAsyncInternal)。假设有100000个文件,将会有100000次81920的分配。我会使用一个单一的本地缓冲区来复制内容。我假设这是一个学术问题,因为在现实世界中,我会使用File.Copy - Paulo Morgado
@YuvalItzchakov,这是框架和/或操作系统的缺陷。但是,总有Task.Run! :) - Paulo Morgado
1
@Paulo 是的,但我不喜欢在缺乏异步对应方法的情况下给出使用正确异步方法然后推迟到 Task.Run 的示例。但仍然可能有用。 - Yuval Itzchakov
显示剩余3条评论

0
为什么UI线程会卡住?
我已经测试了您发布的代码,它不会阻塞UI。当SearchPnrFilesAsync运行时,我可以调整窗口大小并单击按钮。如果有什么问题,请告诉我。
不立即显示MessageBox
button_FindFiles_Click函数中的await关键字异步等待SearchFilesUtil.SearchPnrFilesAsync函数完成。这就是为什么“After SearchPnrFilesAsync”消息不会在单击按钮后立即弹出的原因。如果您想要在单击按钮后立即执行SearchFilesUtil.SearchPnrFilesAsync函数并立即检查消息,则可以在方法调用时不使用await关键字。
SearchFilesUtil.SearchPnrFilesAsync(this.textBox_mainDirectory.Text);

当您在返回Task的函数上不使用await时,会收到一个“警告”。 在这种情况下,使用discards功能,警告将消失。(使用C# 7)

_ = SearchFilesUtil.SearchPnrFilesAsync(this.textBox_mainDirectory.Text);

如果您想了解更多信息,请查看此链接(https://learn.microsoft.com/en-us/dotnet/csharp/fundamentals/functional/discards)。


1
感谢您的回答......6年后 :) - Dardar

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