避免冗余异步计算

本文关键字:计算 异步 冗余 | 更新日期: 2023-09-27 18:03:51

我有一些UI代码,其中我有一个方法,看起来像这样:

    private async Task UpdateStatusAsync()
    {
        //Do stuff on UI thread...
        var result = await Task.Run(DoBunchOfStuffInBackground);
        //Update UI based on result of background processing...
    }

目标是让UI在任何影响其状态的属性更改时更新相对复杂的计算状态。这里有几个问题:

    如果我只是从每个更新状态的地方直接调用这个方法,最终更新的状态可能是不正确的。假设性质A改变,然后性质B也改变。即使B在A之后调用UpdateStatusAsync,有时回调代码(最终的UI更新)以相反的顺序发生。所以:(A ->更新)-> (B ->更新)-> (B更新)-> (A更新)。这意味着最终的UI显示陈旧的状态(反映a,但不反映B)。
  1. 如果我总是等待先前的UpdateStatusAsync先完成(我目前正在做的),我可能会多次执行昂贵的状态计算。理想情况下,我应该只做一系列更新的"最后"计算。

我要找的是一个干净的模式,完成以下任务:

  1. 最终状态不应该超过一段时间(即我不希望UI与底层状态不同步)
  2. 如果多个更新调用在短时间内发生(一个常见的用例),我宁愿避免重复的工作,而总是计算"最新"更新。
  3. 由于多个更新可能发生在非常接近的情况下(即在毫秒内),如果有一种方法可以避免在短时间内启动处理,以防止其他更新请求进入,这将是很方便的。

似乎这应该是一个相当普遍的问题,所以我想我应该问一下,如果有人知道一个特别干净的方法来做到这一点。

避免冗余异步计算

嗯,最直接的方法是使用CancellationToken取消旧的状态更新,使用Task.Delay延迟状态更新:

private CancellationTokenSource cancelCurrentUpdate;
private Task currentUpdate;
private async Task UpdateStatusAsync()
{
  //Do stuff on UI thread...
  // Cancel any running update
  if (cancelCurrentUpdate != null)
  {
    cancelCurrentUpdate.Cancel();
    try { await currentUpdate; } catch (OperationCanceledException) { }
    // or "await Task.WhenAny(currentUpdate);" to avoid the try/catch but have less clear code
  }
  try
  {
    cancelCurrentUpdate = new CancellationTokenSource();
    var token = cancelCurrentUpdate.Token;
    currentUpdate = Task.Run(async () =>
    {
      await Task.Delay(TimeSpan.FromMilliseconds(100), token);
      DoBunchOfStuffInBackground(token);
    }, token);
    var result = await currentUpdate;
    //Update UI based on result of background processing...
  }
  catch (OperationCanceledException) { }
}

如果你更新真的很快,但是,这种方法会为GC创造(甚至)更多的垃圾这种简单的方法总是会取消旧的状态更新,所以如果事件中没有"中断",UI可能会落后。

这种复杂程度是async开始达到极限的地方。如果你需要更复杂的东西(比如处理"中断",这样你至少每隔一段时间就会得到一个UI更新),响应式扩展将是一个更好的选择。Rx特别擅长处理时间。

您应该能够在不使用计时器的情况下做到这一点。总的来说:

private async Task UpdateStatusAsync()
{
    //Do stuff on UI thread...
    set update pending flag
    if currently doing background processing
    {
        return
    }
    while update pending
    {
        clear update pending flag
        set background processing flag
        result = await Task.Run(DoBunchOfStuffInBackground);
        //Update UI based on result of background processing...
    }
    clear background processing flag
}

我必须考虑如何在async/await的上下文中准确地完成所有这些。我过去对BackgroundWorker做过类似的事情,所以我知道这是可能的。

防止它丢失更新应该很容易,但是它可能会不时地做不必要的后台处理。但是,当在短时间内发布10个更新时,它肯定会消除9个不必要的更新(可能,它只会做第一个和最后一个)。

如果你愿意,你可以把UI更新移出循环。这取决于你是否介意看到中间更新

既然我的思路似乎是对的,我将提交我的建议。在非常基本的伪代码中,看起来可以这样做:

int counter = 0;
if (update received && counter < MAX_ITERATIONS)
{
     store info;
     reset N_MILLISECOND timer;
}
if (timer expires)
{
    counter = 0;
    do calculation;
}

这将让你跳过尽可能多的调用,因为你想要的,而计数器将确保你仍然保持最新的UI

我最终采用了Jim Mischel推荐的方法,添加了一个计时器来聚合快速进入的触发器:

public sealed class ThrottledTask
    {
        private readonly object _runLock = new object();
        private readonly Func<Task> _runTask;
        private Task _loopTask;
        private int _updatePending;
        public ThrottledTask(Func<Task> runTask)
        {
            _runTask = runTask;
            AggregationPeriod = TimeSpan.FromMilliseconds(10);
        }
        public TimeSpan AggregationPeriod { get; private set; }
        public Task Run()
        {
            _updatePending = 1;
            lock (_runLock)
            {
                if (_loopTask == null)
                    _loopTask = RunLoop();
                return _loopTask;
            }
        }
        private async Task RunLoop()
        {
            //Allow some time before we start processing, in case many requests pile up
            await Task.Delay(AggregationPeriod);
            //Continue to process as long as update is still pending
            //This clears flag on each cycle in a thread-safe way
            while (Interlocked.CompareExchange(ref _updatePending, 0, 1) == 1)
            {
                await _runTask();
            }
            lock (_runLock)
            {
                _loopTask = null;
            }
        }
    }

一旦聚合周期过去,只要仍然有传入的触发器,就会尽可能快地运行更新。关键是,如果触发器发生的速度比计算快,这不会堆积多余的更新,它总是确保"最后"触发器得到更新。