首页 > 解决方案 > 使用 Dispacher.BeginInvoke 从并行循环调用 UI 线程

问题描述

我在下面有一个简单的异步代码:这是一个带有一个按钮和一个文本框的 WPF。我使用了一些包含五个整数的列表来模拟 5 个不同的任务。我的目的是实现当我并行和异步运行所有五个任务时,我可以观察到数字被一一添加到文本框中。而我做到了。方法“DoSomething”并行运行所有五个任务,每个任务都有不同的执行时间(由 Task.Delay 模拟),因此所有结果都会导致文本框中的数字一一出现。我无法弄清楚的唯一问题是:为什么在文本框中我首先显示字符串文本“这是结束文本”?!如果我等待方法 DoSomething 那么它应该首先完成,然后应该执行其余的代码。即使在我的情况下是重新绘制 GUI。

我猜这可能是由使用 Dispacher.BeginInvoke 引起的,这可能对异步/等待机制“造成一些干扰”。但我会很感激小线索以及如何避免这种行为。我知道我可以使用 Progress 事件来实现类似的效果,但是有没有其他方法可以使用 Parallel 循环并在 WPF 中逐步更新结果,避免我描述的这种意外行为?

 private async void Button_Click(object sender, RoutedEventArgs e)
    {
        await DoSomething();

        tbResults.Text += "This is end text";
    }


    private async Task DoSomething()
    {
        List<int> numbers = new List<int>(Enumerable.Range(1, 5));

      await Task.Run(()=> Parallel.ForEach(numbers,async  i =>
        {
          await Task.Delay(i * 300);
          await Dispatcher.BeginInvoke(() => tbResults.Text += i.ToString() + Environment.NewLine);
        }));
    }
// output is:
//This is end text 1 2 3 4 5 (all in separatę lines).

我的问题:

  1. 为什么在方法 DoSomething 之前显示文本。
  2. 如何解决它/避免它,解决它的任何替代方法(使用 Progress 事件除外)。任何信息将不胜感激。

标签: c#wpfasynchronousparallel-processing

解决方案


的线程Parallel.Foreach是“真正的”后台线程。它们被创建并且应用程序继续执行。关键是这Parallel.Foreach是不可等待的,因此在Parallel.Foreach使用 挂起的线程时继续执行await

private async Task DoSomething()
{
    List<int> numbers = new List<int>(Enumerable.Range(1, 5));

    // Create the threads of Parallel.Foreach
    await Task.Run(() => 
      { 
        // Create the threads of Parallel.Foreach and continue
        Parallel.ForEach(numbers,async  i =>
        {
          // await suspends the thread and forces to return.
          // Because Parallel.ForEach is not awaitable, 
          // execution leaves the scope of the Parallel.Foreach to continue.
          await Task.Delay(i * 300);

          await Dispatcher.BeginInvoke(() => tbResults.Text += i.ToString() + Environment.NewLine);
        });

        // After the threads are created the internal await of the Parallel.Foreach suspends background threads and
        // forces to the execution to return from the Parallel.Foreach.
        // The Task.Run thread continues.

        Dispatcher.InvokeAsync(() => tbResults.Text += "Text while Parallel.Foreach threads are suspended");

        // Since the background threads of the Parallel.Foreach are not attached 
        // to the parent Task.Run, the Task.Run completes now and returns
        // i.e. Task.run does not wait for child background threads to complete.
        // ==> Leave Task.Run as there is no work. 
      });

      // Leave DoSomething() and continue to execute the remaining code in the Button_Click(). 
      // Parallel.Foreach threads is still suspended until the await chain, in this case Button_Click(), is completed.
}

解决方案是实现 Clemens 的评论建议的模式或使用 eg 的生产者消费者模式的异步实现,BlockingCollection或者Channel在分配“无限”数量的作业时获得对固定数量的线程的更多控制。

private async Task DoSomething(int number)
{
  await Task.Delay(number * 300);
  Dispatcher.Invoke(() => tbResults.Text += number + Environment.NewLine);
}

private async void ButtonBase_OnClick(object sender, RoutedEventArgs e)
{
  List<int> numbers = new List<int>(Enumerable.Range(1, 5));
  List<Task> tasks = new List<Task>();

  // Alternatively use LINQ Select
  foreach (int number in numbers)
  {
    Task task = DoSomething(number);
    tasks.Add(task);
  }

  await Task.WhenAll(tasks);

  tbResults.Text += "This is end text" + Environment.NewLine;
}

讨论评论

“我的意图是并行运行任务并在完成后“报告”,即最短的任务将首先“报告”,依此类推。”

这正是上述解决方案中发生的事情。具有Task最短延迟的将文本附加到第TextBox一个。

“但是实施你建议的await Task.WhenAll(tasks)原因,我们需要等待所有任务完成,然后一次性报告。”

要按Task完成顺序处理对象,您将替换Task.WhenAllTask.WhenAny. 如果您不仅对第一个 completed 感兴趣,则Task必须以Task.WhenAny迭代方式使用,直到所有Task实例都完成:

按完成顺序处理所有任务对象

private async Task DoSomething(int number)
{
  await Task.Delay(number * 300);
  Dispatcher.Invoke(() => tbResults.Text += number + Environment.NewLine);
}

private async void ButtonBase_OnClick(object sender, RoutedEventArgs e)
{
  List<int> numbers = new List<int>(Enumerable.Range(1, 5));
  List<Task> tasks = new List<Task>();

  // Alternatively use LINQ Select
  foreach (int number in numbers)
  {
    Task task = DoSomething(number);
    tasks.Add(task);
  }

  // Until all Tasks have completed
  while (tasks.Any())
  {
    Task<int> nextCompletedTask = await Task.WhenAny(tasks);

    // Remove the completed Task so that
    // we can await the next uncompleted Task that completes first
    tasks.Remove(nextCompletedTask);

    // Get the result of the completed Task
    int taskId = await nextCompletedTask;
    tbResults.Text += $"Task {taskId} has completed." + Environment.NewLine;
  }

  tbResults.Text += "This is end text" + Environment.NewLine;
}

"Parallel.ForEach是不可等待的,所以我认为将它包装起来 Task.Run可以让我等待它,但这是因为正如你所说的"因为后台线程Parallel.Foreach没有附加到父级Task.Run""

不,这不是我所说的。关键是我回答的第三句话:“关键Parallel.Foreach是不可等待,因此在使用等待暂停Parallel.Foreach线程的同时继续执行。” .
这意味着: 通常Parallel.Foreach同步执行:当所有线程Parallel.Foreach都完成时,调用上下文继续执行。await但是由于您在这些线程内部调用,因此您以异步/等待方式挂起它们。
由于Parallel.Foreach不可等待,它无法处理await调用,并且就像挂起的线程自然完成一样。Parallel.Foreach不明白线程只是被暂停await并将在稍后继续。换句话说,等待链被破坏,因为Parallel.Foreach无法将等待的上下文返回Task到父等待Task.Run上下文以表示其暂停。
这就是我说的线程Parallel.Foreach不附加到Task.Run. 它们与async/await基础设施完全隔离运行。

“异步 lambda 应该是“仅用于事件””

不,这是不正确的。当您将异步 lambda 传递给void委托时,Action<T>您是正确的:在这种情况下无法等待异步 lambda。但是,当将异步 lambda 传递给type的Func<T>委托时,可以等待您的 lamda:TTask

private void NoAsyncDelegateSupportedMethod(Action lambda)
{
  // Since Action does not return a Task (return type is always void),
  // the async lambda can't be awaited
  lambda.Invoke();
}

private async Task AsyncDelegateSupportedMethod(Func<Task> asyncLambda)
{
  // Since Func returns a Task, the async lambda can be awaited
  await asyncLambda.Invoke();
}

public voi DoSoemthing()
{
  // Not a good idea as NoAsyncDelegateSupportedMethod can't handle async lamdas: it defines a void delegate
  NoAsyncDelegateSupportedMethod(async () => await Task.Delay(1));

  // A good idea as AsyncDelegateSupportedMethod can handle async lamdas: it defines a Func<Task> delegate
  AsyncDelegateSupportedMethod(async () => await Task.Delay(1));
}

如您所见,您的陈述不正确。您必须始终检查被调用方法的签名及其重载。如果它接受Func<Task>类型委托,你就可以走了。
这就是添加异步支持的方式Parallel.ForeachAsync:API 支持Func<ValueTask>类型委托。例如Task.Run接受 a Func<Task>,因此以下调用非常好:

Task.Run(async () => await Task.Delay(1));

“我猜你承认 .Net 6.0 带来了最好的解决方案:Parallel.ForEachASYNC![...] 我们可以产生几个线程并行处理我们的任务,我们可以等待整个循环,我们做到了不需要等待所有任务完成——他们在完成时“报告”“

那是错误的。支持使用async/awaitParallel.ForeachAsync的线程,这是真的。实际上,您的原始示例将不再破坏预期的流程:因为在其线程中支持,它可以处理挂起的线程并将对象从其线程正确传播到调用者上下文,例如,到 wrapping 。 它现在知道如何等待和恢复挂起的线程。 重要提示:在其所有线程完成仍会完成。您假设“他们在完成时“报告””是错误的。这是最直观的并发实现。枚举完所有项目后也完成。Parallel.ForeachAsyncawaitTaskawait Task.Run

Parallel.ForeachAsync foreachforeach
在对象完成时处理Task对象的解决方案仍然使用Task.WhenAny上面的模式。

一般来说,如果您不需要 and 的分区等额外功能Parallel.ForeachParallel.ForeachAsync您可以随时使用Task.WhenAllTask.WhenAll尤其Parallel.ForeachAsync是等效的,除了Parallel.ForeachAsync默认提供更大的自定义:它支持节流和分区等技术,而无需额外的代码。


推荐阅读