等待从 UI 线程取消的异步方法?

huangapple go评论58阅读模式
英文:

Waiting for cancelled async method from UI thread?

问题

我有一个异步向文本框中追加内容的WPF程序
如果用户通过组合框选择了一个新的提供商,追加操作应该被取消,然后用新的提供商重新调用。问题是updateMoviesTask?.Wait()会造成死锁。
我猜这是因为UpdateMovies()在等待UI线程,而ProviderId属性在等待任务。我该如何解决这个问题?

CancellationTokenSource source;
Task updateMoviesTask;

async Task UpdateMovies()
{
    source?.Dispose();
    source = new CancellationTokenSource();

    CancellationToken cancel = source.Token;

    var request = new GetPopularRequest(Country.Germany);

    for (int i = 1; i <= TotalPages; i++)
    {
        request.Page = i;
        request.ProviderId = providerId;

        var getPopularResponse = await justWatchClient.GetPopularAsync(request);

        // 在这里进行UI操作        

        if (cancel.IsCancellationRequested)
            return;
    }
}

public GetProviderResponse ProviderId
{
    get { return providerId; }
    set
    {
        this.providerId = value; 
        this.source?.Cancel();
        updateMoviesTask?.Wait();
        updateMoviesTask = this.UpdateMovies();
        this.OnPropertyChanged("");
    }
}
英文:

I have a WPF program appending to a textbox asynchronously.
If the user selects a new provider via combobox, the appending should be cancelled and called again with the new provider. problem is the updateMoviesTask?.Wait() deadlocks.
I guess it is because UpdateMovies() waits for ui thread and ProviderId property waits for the task. How can I solve this?

CancellationTokenSource source;
Task updateMoviesTask;

async Task UpdateMovies()
{
    source?.Dispose();
    source = new CancellationTokenSource();

    CancellationToken cancel = source.Token;

    var request = new GetPopularRequest(Country.Germany);

    for (int i = 1; i &lt;= TotalPages; i++)
    {
        request.Page = i;
        request.ProviderId = providerId;

        var getPopularResponse = await justWatchClient.GetPopularAsync(request);

        // do ui stuff here        

        if (cancel.IsCancellationRequested)
            return;
    }
}

public GetProviderResponse ProviderId
{
    get { return providerId; }
    set
    {
        this.providerId = value; 
        this.source?.Cancel();
        updateMoviesTask?.Wait();
        updateMoviesTask = this.UpdateMovies();
        this.OnPropertyChanged(&quot;&quot;);
    }
}

答案1

得分: 1

如果我理解正确,那么你需要 - 查看此版本的代码(不考虑可能的异常处理):

private CancellationTokenSource source = new();
private readonly object locker = new();

private void UpdateMovies() => Task.Run(() =>
{
    source.Dispose();
    source = new CancellationTokenSource();
    CancellationToken cancel = source.Token;

    lock (locker)
    {
        var request = new GetPopularRequest(Country.Germany);

        for (int i = 1; i <= TotalPages; i++)
        {
            request.Page = i;
            request.ProviderId = providerId;

            // 需要该方法的同步版本
            // var getPopularResponse = await justWatchClient.GetPopularAsync(request);
            var getPopularResponse = justWatchClient.GetPopular(request);

            // 在此执行UI操作

            if (cancel.IsCancellationRequested)
                return;
        }
    }
});

public GetProviderResponse ProviderId
{
    get { return providerId; }
    set
    {
        this.providerId = value;
        UpdateMovies();
        this.OnPropertyChanged(string.Empty);
    }
}
英文:

If I understand correctly, then you need to - Look at this version of the code (without taking into account the handling of possible exceptions):

    private CancellationTokenSource source = new();
    private readonly object locker = new();

    private void UpdateMovies() =&gt; Task.Run(() =&gt;
    {
        source.Dispose();
        source = new CancellationTokenSource();
        CancellationToken cancel = source.Token;

        lock (locker)
        {
            var request = new GetPopularRequest(Country.Germany);

            for (int i = 1; i &lt;= TotalPages; i++)
            {
                request.Page = i;
                request.ProviderId = providerId;

                // Need a synchronous version of the method
                // var getPopularResponse = await justWatchClient.GetPopularAsync(request);
                var getPopularResponse = justWatchClient.GetPopular(request);

                // do ui stuff here        

                if (cancel.IsCancellationRequested)
                    return;
            }
        }
    });

    public GetProviderResponse ProviderId
    {
        get { return providerId; }
        set
        {
            this.providerId = value;
            UpdateMovies();
            this.OnPropertyChanged(string.Empty);
        }
    }

答案2

得分: 1

看起来你的流程如下:用户修改了 ProviderId 属性。该属性然后会取消任何正在运行的 UpdatMovies 操作并启动一个新的。

良好的用户体验需要用户明确地启动这样长时间运行的操作。从一个无害的 ComboBox 中选择一个值不应该导致资源密集型的意外。相反,让用户配置操作(例如选择 ProviderId 并通过点击例如“更新”按钮来启动它)。现在一切对用户来说都是透明的。你甚至可以允许用户决定是要等待完成还是中止正在运行的操作。

&lt;Window&gt;
  &lt;StackPanel&gt;
    &lt;CombBox SelectedItem={Binding ProviderId}&quot; /&gt;
    &lt;Button Content=&quot;开始更新&quot;
            Command=&quot;{Binding UpdateMoviesCommand}&quot; /&gt;
  &lt;/StackPanel&gt;
&lt;/Window&gt;

请参阅 Microsoft Docs: 传递命令逻辑,了解示例中使用的 RelayCommand 实现。

public object ProviderId { get; set; }
public ICommand UpdateMoviesCommand =&gt; new RelayCommand(ExecuteUpdateMoviesCommand, CanExecuteUpdateMoviesCommand);
private CancellationTokenSource CancellationTokenSource { get; set; }  
  = new CancellationTokenSource();
private SemaphoreSlim Semaphore { get; }
  = new SemaphoreSlim(1, 1);

private bool CanExecuteUpdateMoviesCommand(object commandParameter)
  =&gt; this.ProviderId is not null;

private async void ExecuteUpdateMoviesCommand(object commandParameter)
{
  this.CancellationTokenSource.Cancel();

  // 异步等待直到 UpdateMoviesAsync 返回并且 finally 块被执行,以允许当前上下文继续。
  await this.Semaphore.WaitAsnyc();
  
  try
  {  
    await UpdateMoviesAsync(this.CancellationTokenSource.Token);
  }
  catch (OperationCanceledException)
  {
  }   
  finally 
  {    
    this.CancellationTokenSource.Dispose();
    this.CancellationTokenSource = new CancellationTokenSource();

    // 允许等待上下文调用 UpdateMoviesAsync
    this.Semaphore.Release();
  }
}

private async Task UpdateMoviesAsync(CancellationToken cancellationToken)
{
  var request = new GetPopularRequest(Country.Germany);

  for (int i = 1; i &lt;= TotalPages; i++)
  {
    request.Page = i;
    request.ProviderId = providerId;

    // 考虑使 justWatchClient.GetPopularAsync 也可以取消以提高性能
    var getPopularResponse = await justWatchClient.GetPopularAsync(request);

    // 在这里进行 UI 操作

    cancellationToken.ThrowIfCancellationRequested();
  }
}

或者,可以使用 ComboBox.SelectionChanged 事件来触发异步操作。

英文:

It looks like your flow is as follows: user modifies the ProviderId property. The property then cancels any running UpdatMovies operations and start a new.

Good UX would require the user to start such long running operations explicitly. Selecting an value from an innocent ComboBox shouldn't result in a resource intensive surprise. Instead let the user configure the opeartion (e.g. select the ProviderId and start it by clicking in a e.g. "Update" button. Now everything is transparent to the user. You could even allow the user to decide whether he wants to wait for completion or to abort the running operation.

&lt;Window&gt;
  &lt;StackPanel&gt;
    &lt;CombBox SelectedItem={Binding ProviderId}&quot; /&gt;
    &lt;Button Content=&quot;Start update&quot;
            Command=&quot;{Binding UpdateMoviesCommand}&quot; /&gt;
  &lt;/StackPanel&gt;
&lt;/Window&gt;

See Microsoft Docs: Relaying Command Logic for the RelayCommand implementation used in the example.

public object ProviderId { get; set; }
public ICommand UpdateMoviesCommand =&gt; new RelayCommand(ExecuteUpdateMoviesCommand, CanExecuteUpdateMoviesCommand);
private CancellationTokenSource CancellationTokenSource { get; set; }  
  = new CancellationTokenSource();
private SemaphoreSlim Semaphore { get; }
  = new SemaphoreSlim(1, 1);

private bool CanExecuteUpdateMoviesCommand(object commandParameter)
  =&gt; this.ProviderId is not null;

private async void ExecuteUpdateMoviesCommand(object commandParameter)
{
  this.CancellationTokenSource.Cancel();

  // Asynchronously wait until UpdateMoviesAsync has returned 
  // and the finally block was executed to allow the current context to continue.
  await this.Semaphore.WaitAsnyc();
  
  try
  {  
    await UpdateMoviesAsync(this.CancellationTokenSource.Token);
  }
  catch (OperationCanceledException)
  {
  }   
  finally 
  {    
    this.CancellationTokenSource.Dispose();
    this.CancellationTokenSource = new CancellationTokenSource();

    // Allow the waiting context to call UpdateMoviesAsync
    this.Semaphore.Release();
  }
}

private async Task UpdateMoviesAsync(CancellationToken cancellationToken)
{
  var request = new GetPopularRequest(Country.Germany);

  for (int i = 1; i &lt;= TotalPages; i++)
  {
    request.Page = i;
    request.ProviderId = providerId;

    // Consider to make justWatchClient.GetPopularAsync 
    // cancellable too to improve performance
    var getPopularResponse = await justWatchClient.GetPopularAsync(request);

    // do ui stuff here        

    cancellationToken.ThrowIfCancellationRequested();
  }
}

Alternatively, use the ComboBox.SelectionChanged event to trigger the async operation.

答案3

得分: 0

如果用户通过下拉框选择了新的提供商,则应取消附加操作并使用新的提供商重新调用。问题在于 updateMoviesTask?.Wait() 造成了死锁。我猜这是因为 UpdateMovies() 在等待 UI 线程,而 ProviderId 属性则在等待任务。我该如何解决这个问题?

是的,这是我在我的博客中描述的一个经典死锁情况。

对于这类“替换更新UI操作”的问题,我通常更喜欢基于令牌的方法。每个操作都会分配一个令牌,只有当其令牌与当前令牌匹配时才会更新UI。然后,在启动新操作之前,在将其传递给新操作之前重新生成令牌。

在老式的代码中,我通常只是使用一个简单的 object 作为令牌,但如今 CancellationToken 也同样有效。实际上,你的代码已经有一个;只是在更新UI之前随时观察它

旁注:有时你必须等待前一个操作完成,例如,如果它正在更新诸如数据库或缓存之类的外部资源,而你不希望它干扰当前操作。但如果操作只是进行一些查询并更新UI,那么等待前一个操作完成并非必要;你可以只需重新生成令牌并立即开始替换操作。

CancellationTokenSource source;

async Task UpdateMoviesAsync(CancellationToken cancel)
{
  var request = new GetPopularRequest(Country.Germany);

  for (int i = 1; i <= TotalPages; i++)
  {
    request.Page = i;
    request.ProviderId = providerId;

    var getPopularResponse = await justWatchClient.GetPopularAsync(request);

    if (cancel.IsCancellationRequested)
      return;

    // 在此处进行UI操作
  }
}

public GetProviderResponse ProviderId
{
  get { return providerId; }
  set
  {
    providerId = value; 
    source?.Cancel();
    source?.Dispose();
    source = new CancellationTokenSource();
    _ = this.UpdateMoviesAsync(source.Token); // 参见下面的注释
    this.OnPropertyChanged("");
  }
}

丢弃任务 _ = ... 是有问题的。实际上,它忽略了来自 UpdateMoviesAsync 的任何错误。将 UpdateMoviesAsyncasync Task 改为 async void 更糟糕 - 你会在出现错误时崩溃。你需要比这两种方法都更好地处理这个问题。我推荐的模式是“通知任务”模式,它接受一个 Task 并使其可观察(包括异常)。通知任务模式在我的这篇文章中有描述。值得注意的是,如今大多数MVVM库都内置了某种名为 NotifyTask<T> 的功能,尽管有时它可能有不同的名称。

英文:

> If the user selects a new provider via combobox, the appending should be cancelled and called again with the new provider. problem is the updateMoviesTask?.Wait() deadlocks. I guess it is because UpdateMovies() waits for ui thread and ProviderId property waits for the task. How can I solve this?

Yes, that's a classic deadlock I describe on my blog.

With these kinds of "replace the operation updating the UI" problems, I generally prefer a token-based approach. Every operation is handed a token, and only updates the UI if its token matches the current token. Then, when starting a new operation, regenerate the token before passing it to the new operation.

In old-school code, I've just used something as simple as object as the token, but these days a CancellationToken is just as good. And, in fact, your code already has one; it just has to observe it anytime before it updates the UI.

Side note: sometimes you have to wait for the previous operation to complete, e.g., if it's updating some external resource like a database or cache and you don't want it interfering with the current operation. But if the operations only do some queries and update the UI, then waiting for the previous operation to complete is not necessary; you can just regenerate the token and start the replacement operation immediately.

CancellationTokenSource source;

async Task UpdateMoviesAsync(CancellationToken cancel)
{
  var request = new GetPopularRequest(Country.Germany);

  for (int i = 1; i &lt;= TotalPages; i++)
  {
    request.Page = i;
    request.ProviderId = providerId;

    var getPopularResponse = await justWatchClient.GetPopularAsync(request);

    if (cancel.IsCancellationRequested)
      return;

    // do ui stuff here
  }
}

public GetProviderResponse ProviderId
{
  get { return providerId; }
  set
  {
    providerId = value; 
    source?.Cancel();
    source?.Dispose();
    source = new CancellationTokenSource();
    _ = this.UpdateMoviesAsync(source.Token); // see note below
    this.OnPropertyChanged(&quot;&quot;);
  }
}

Discarding the task _ = ... is problematic. It's actually ignoring any errors that can come from UpdateMoviesAsync. And making UpdateMoviesAsync async void instead of async Task would be even worse - you'd crash on errors. You'll want to handle this better than either of these approaches. My recommended pattern is the "notify task" pattern, which takes a Task and makes it observable (including exceptions). The Notify Task pattern is described in this article of mine. Note that these days most MVVM libraries have some kind of NotifyTask&lt;T&gt; built-in, though it sometimes goes by different names.

huangapple
  • 本文由 发表于 2023年6月19日 03:04:55
  • 转载请务必保留本文链接:https://go.coder-hub.com/76502130.html
匿名

发表评论

匿名网友

:?: :razz: :sad: :evil: :!: :smile: :oops: :grin: :eek: :shock: :???: :cool: :lol: :mad: :twisted: :roll: :wink: :idea: :arrow: :neutral: :cry: :mrgreen:

确定