C#异步/等待进度报告顺序不如预期

6

我正在尝试使用async/await和进度报告,因此编写了一个异步文件复制方法,在每个复制的MB后报告进度:

public async Task CopyFileAsync(string sourceFile, string destFile, CancellationToken ct, IProgress<int> progress) {

  var bufferSize = 1024*1024 ;
  byte[] bytes = new byte[bufferSize];
  using(var source = new FileStream(sourceFile, FileMode.Open, FileAccess.Read)){
    using(var dest = new FileStream(destFile, FileMode.Create, FileAccess.Write)){

      var totalBytes = source.Length;
      var copiedBytes = 0;
      var bytesRead = -1;
      while ((bytesRead = await source.ReadAsync(bytes, 0, bufferSize, ct)) > 0)
      {
        await dest.WriteAsync(bytes, 0, bytesRead, ct);
        copiedBytes += bytesRead;
        progress?.Report((int)(copiedBytes * 100 / totalBytes));
      }
    }
  }
}

在控制台应用程序中,我创建了一个包含随机内容的10MB文件,然后使用上述方法进行复制:
private void MainProgram(string[] args)
{
  Console.WriteLine("Create File...");
  var dir = Path.GetDirectoryName(typeof(MainClass).Assembly.Location);
  var file = Path.Combine(dir, "file.txt");
  var dest = Path.Combine(dir, "fileCopy.txt");

  var rnd = new Random();
  const string chars = ("ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890");
  var str = new string(Enumerable
                       .Range(0, 1024*1024*10)
                       .Select(i => letters[rnd.Next(chars.Length -1)])
                       .ToArray());
  File.WriteAllText(file, str);

  var source = new CancellationTokenSource();
  var token = source.Token;

  var progress = new Progress<int>();
  progress.ProgressChanged += (sender, percent) => Console.WriteLine($"Progress: {percent}%");

  var task = CopyFileAsync(file, dest, token, progress);
  Console.WriteLine("Start Copy...");
  Console.ReadLine();
}

应用程序执行后,两个文件是相同的,因此复制过程按照正确的顺序进行。但是控制台输出类似于:

Create File...
Start Copy...
Progress: 10%
Progress: 30%
Progress: 20%
Progress: 60%
Progress: 50%
Progress: 70%
Progress: 80%
Progress: 40%
Progress: 90%
Progress: 100%

每次调用应用程序时,顺序都不同。我不理解这种行为。如果我在事件处理程序中设置断点并检查每个值,则它们的顺序是正确的。有人能向我解释一下吗?
我希望以后在GUI应用程序中使用它,并且不想让它来回跳动。

你需要为IProgress添加同步上下文,并且在CopyFileAsync()方法中等待任务时应该添加ConfigureAwait(false)。 - Sir Rufo
Progress<T> 使用同步上下文来处理事件处理程序的调用,因此无法保证事件处理程序将按照您对 .Report 的调用顺序进行调用,这完全取决于特定同步上下文在使用时的行为。 - Lasse V. Karlsen
顺便提一下:如果您想要一个 FileStream 实际上是异步读写的,您需要使用带有 FileOptions.Asynchronous 参数的构造函数来创建它。否则,它只是使用线程来模拟异步 IO 操作。 - ckuri
文件复制速度太快了,以至于所有由ProgressChanged启动的线程池线程都能按预期顺序获取控制台锁的希望都很渺茫。这是一个非常标准的测试问题,文件系统缓存使磁盘看起来非常快。只是内存到内存的复制。在GUI应用程序中,您不会遇到此问题,更新将严格按调度程序循环排序。 - Hans Passant
谢谢@ckuri,我不知道这个选项。即使在MSDN文档中也没有使用它。它只在构造函数文档中提到。 - Lightbringer
@HansPassant:你说得对。当我将文件大小增加到50 MB并将缓冲区增加到5MB时,顺序是正确的。 - Lightbringer
1个回答

3

Progress<T>在创建时会捕获当前的SynchronizationContext。如果没有SynchronizationContext(比如在控制台应用程序中),进度回调将被安排到线程池线程中。这意味着多个回调甚至可以并行运行,当然顺序不能保证。

在UI应用程序中,发布到同步上下文大致相当于:

  1. 在WPF中:Dispatcher.BeginInvoke()

  2. 在WinForms中:Control.BeginInvoke

我不使用WinForms,但在WPF中,具有相同优先级的多个BeginInvoke(在这种情况下它们具有相同的优先级)被保证按照调用的顺序执行:

如果在同一DispatcherPriority下进行多个BeginInvoke调用,则它们将按照调用的顺序执行。

我不明白为什么在WinForms中Control.BeginInvoke可能会无序执行,但我不知道像上面提供的WPF证明。因此,我认为在WPF和WinForms中,只要您在UI线程上创建了Progress<T>实例本身以便可以捕获上下文,您就可以安全地依赖于按顺序执行进度回调。
附注:不要忘记在您的ReadAsyncWriteAsync调用中添加ConfigureAwait(false),以防止在UI应用程序中每次在这些await之后返回到UI线程。

听起来不错,我会在周一回到办公室时测试它,因为我在家里没有Windows电脑来测试WPF :) - Lightbringer

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