【问题标题】:How to synchronize a shared IProgress<int>如何同步共享的 IProgress<int>
【发布时间】:2020-07-30 17:23:15
【问题描述】:

我有一个异步方法DoStuffAsync,它使用Task.Run 生成两个任务,两个任务都使用一个IProgress&lt;int&gt; 对象报告它们的进度。从用户的角度来看,只有一个操作,因此显示两个进度条(每个Task 一个)没有任何意义。这就是共享IProgress&lt;int&gt; 的原因。问题是有时 UI 以错误的顺序接收进度通知。这是我的代码:

private async void Button1_Click(object sender, EventArgs e)
{
    TextBox1.Clear();
    var progress = new Progress<int>(x => TextBox1.AppendText($"Progress: {x}\r\n"));
    await DoStuffAsync(progress);
}

async Task DoStuffAsync(IProgress<int> progress)
{
    int totalPercentDone = 0;
    Task[] tasks = Enumerable.Range(1, 2).Select(n => Task.Run(async () =>
    {
        for (int i = 0; i < 5; i++)
        {
            await Task.Delay(100); // Simulate an I/O operation
            var localPercentDone = Interlocked.Add(ref totalPercentDone, 10);
            progress.Report(localPercentDone);
        }
    })).ToArray();
    await Task.WhenAll(tasks);
}

大多数时候通知的顺序是正确的,但有时它们不是:

这会导致ProgressBar 控件(上面的屏幕截图中未显示)笨拙地来回跳跃。

作为临时解决方案,我在DoStuffAsync 方法中添加了一个lock,其中包括对IProgress.Report 方法的调用:

async Task DoStuffAsync(IProgress<int> progress)
{
    int totalPercentDone = 0;
    object locker = new object();
    Task[] tasks = Enumerable.Range(1, 2).Select(n => Task.Run(async () =>
    {
        for (int i = 0; i < 5; i++)
        {
            await Task.Delay(100); // Simulate an I/O operation
            lock (locker)
            {
                totalPercentDone += 10;
                progress.Report(totalPercentDone);
            };
        }
    })).ToArray();
    await Task.WhenAll(tasks);
}

虽然这解决了问题,但它让我感到焦虑,因为我在持有lock 的同时调用任意代码。 DoStuffAsync 方法实际上是库的一部分,可以使用任何 IProgress&lt;int&gt; 实现作为参数来调用。这打开了死锁场景的可能性。有没有更好的方法来实现DoStuffAsync 方法,而不使用lock,但具有关于通知排序的所需行为?

【问题讨论】:

  • 其实有多种选择。您有 2 个任务和 100% 条 - 您可以只将 Max percent 传递到 Report 中(如果它们本质上相同,这将是最简单的方法),或者将它们分成 50% 用于一项任务,50% 用于其他任务,并且每个其中一个为它的单独实例做出贡献,您可以听听这两者的组合。
  • 我不认为锁是个麻烦。甚至涉及到外部操作,锁定的对象是在本地声明的,因此没有自定义 IProgress 实现可以访问它。只有在自定义 IProgress 实现挂起的情况下才可能出现死锁,这可能会导致同样的问题(任务不工作)。
  • 如果您将totalPercentDone 声明为字段,并且在DoStuffAsync 中,您只需:await Task.WhenAll(Enumerable.Range(1, 2).Select(n =&gt; DoWorkAsync(progress)));(在按钮中重置totalPercentDone。单击)。 DoWorkAsync() 当然是worker方法。在那里你可以拥有:progress.Report(Interlocked.Add(ref totalPercentDone, 10));。如果您反复按下按钮,它也应该可以工作(但结果会累积。需要禁用该按钮)。
  • 我在 UWP 应用程序(类似于 WinForm 应用程序)中处理此问题的方式只是忽略了 ProgressChanged 中的较小值。对我来说,保持/显示过去取得的进展是没有意义的。而且我可以在锁定的上下文中使用更少的代码。也许你这里有有效的案例,你得到了很好的答案。
  • @weichch 这绝对是一个可靠的选择。我想避免将订单保留的负担转移到客户端。理想情况下,客户端代码(DoStuffAsync 方法的调用者)应该只订阅Progress.ProgressChanged 事件,然后一切都应该按预期工作。

标签: c# winforms async-await task-parallel-library iprogress


【解决方案1】:

您的问题是您需要 totalPercentDone 的增量和对 Report 的调用是原子的。

在这里使用lock 没有任何问题。毕竟,您需要一些方法来使这两个操作具有原子性。如果你真的不想使用lock,那么你可以使用SemaphoireSlim:

async Task DoStuffAsync(IProgress<int> progress)
{
    int totalPercentDone = 0;
    var semaphore =  new SemaphoreSlim(1,1);

    Task[] tasks = Enumerable.Range(1, 2).Select(n => Task.Run(async () =>
    {
        for (int i = 0; i < 5; i++)
        {
            await Task.Delay(100); // Simulate an I/O operation
            await semaphore.WaitAsync();

            try
            {
                totalPercentDone += 10;
                progress.Report(totalPercentDone);
            }
            finally
            {
                semaphore.Release();
            }
        }
    })).ToArray();

    await Task.WhenAll(tasks);
}

【讨论】:

  • 感谢肖恩的回答。这个效果很好,似乎比我的解决方案更好!如果与内部有自己的同步或异步同步的IProgress 实现结合使用,您认为可能发生死锁吗?
  • 我看不出使用SemaphoreSlim 或lock 会导致死锁,除非IProgress 实现有问题。
  • @TheodorZoulias - 尝试了这个答案,我的和我的都没有通过这个测试 - dotnetfiddle.net/kXPEm1 - 我做错了什么吗?
  • 也许你是对的,我的担心是没有根据的。无论如何,您的解决方案显然比我的要好,因为它不会阻塞任何线程,所以我会接受它。 :-)
  • Progress.IProgress.Report 不会等到处理程序被调用;它仅将进度报告排队到创建Progress 时捕获的SyncCtx。 WinForms SyncCtx 按顺序处理它们。如果没有 FIFO 排队 SyncCtx(即,在一般情况下),报告仍然可能出现故障。
【解决方案2】:

您可以使用两个单独的 int,并取其中最小的一个,而不是对两个任务都使用一个 int。每个任务需要报告到 100,而不是 50。

async Task DoStuffAsync(IProgress<int> progress)
{
    int[] totalPercentDone = new int[2];
    Task[] tasks = Enumerable.Range(1, 2).Select(n => Task.Run(async () =>
    {
        for (int i = 0; i < 5; i++)
        {
            await Task.Delay(100); // Simulate an I/O operation
            totalPercentDone[n - 1] += 10;

            progress.Report(totalPercentDone.Min());
        }
    })).ToArray();
    await Task.WhenAll(tasks);
}

【讨论】:

  • 感谢 Random 的回答。我测试了你的解决方案。不幸的是,它有时会以错误的顺序生成通知,例如我自己的第一个实现 (screenshot)。
  • @TheodorZoulias - 对其进行了测试,.Min() 似乎对我有用
  • 我刚刚测试了新的.Min() 版本。仍然有不正常的通知 (screenshot with .Min)。
【解决方案3】:

您可以只报告增量并让处理人员处理它们:

private async void Button1_Click(object sender, EventArgs e)
{
    TextBox1.Clear();
    var totalPercentDone = 0;
    var progress = new Progress<int>(x =>
        {
            totalPercentDone += x;
            TextBox1.AppendText($"Progress: {totalPercentDone}\r\n"));
        }
    await DoStuffAsync(progress);
}

async Task DoStuffAsync(IProgress<int> progress)
{
    await Task.WhenAll(Enumerable.Range(1, 2).Select(n => Task.Run(async () =>
    {
        for (int i = 0; i < 5; i++)
        {
            await Task.Delay(100); // Simulate an I/O operation
            progress.Report(10);
        }
    })));
}

【讨论】:

  • 进度有某种内部锁定吗?因为如果不是这样,您的代码很容易丢失更新并且永远不会达到 100%
  • 如果Progress 实例是在存在SynchronizationContext 的情况下创建的,链接到Windows 窗体控件的事件处理程序中,则可以保证将其发布到该同步上下文。如果没有,您可以使用“ActionBlock”。
  • 像这样使用Progress 是违反直觉的,但我赞成它,因为它是一种无锁解决方案。 :-)
【解决方案4】:

这是为了扩展我的 cmets 问题

基本上,progress 通常是一个只进的值。关于报告进度,您可能永远不需要报告过去取得的进展。即使您这样做了,在大多数情况下,客户端/事件处理程序端仍会丢弃收到的此类值。

这里的问题/为什么需要同步报告主要是因为您正在报告值类型的进度,其值在调用Report(T)时被复制。

您可以通过报告具有最新进展的引用类型实例来简单地避免锁定:

public class DoStuffProgress
{
    private volatile int _percentage;

    public int Percentage => _percentage;

    internal void IncrementBy(int increment)
    {
        Interlocked.Add(ref _percentage, increment);
    }
}

现在您的代码如下所示:

async Task DoStuffAsync(IProgress<DoStuffProgress> progress)
{
    DoStuffProgress totalPercentDone = new DoStuffProgress();

    Task[] tasks = Enumerable.Range(1, 2).Select(n => Task.Run(async () =>
    {
        for (int i = 0; i < 5; i++)
        {
            await Task.Delay(100); // Simulate an I/O operation

            totalPercentDone.IncrementBy(10);

            // Report reference type object
            progress.Report(totalPercentDone);
        }
    })).ToArray();
    await Task.WhenAll(tasks);
}

但是,客户端可能会收到重复值的通知:

Progress: 20
Progress: 20
Progress: 40
Progress: 40
Progress: 60
Progress: 60
Progress: 80
Progress: 80
Progress: 90
Progress: 100

但是,值永远不应该乱序。

【讨论】:

  • 感谢weichch 的回答。这是一个非常有趣的想法!本质上,您一遍又一遍地报告同一个对象。我认为您可以通过使用 IProgress 对象本身作为最近的值持有者(而不是 DoStuffProgress 实例)来使您的想法更具吸引力。您只需要在IProgress 接口中再添加一个成员。属性public T CurrentValue。
  • 其实没那么简单。仅添加属性 T CurrentValue { get; } 是不够的。该接口还需要void IncrementBy(T value);和void Report();这两个方法,所以变得更复杂,更不吸引人。
  • IProgress 接口用于报告进度。您可以拥有自己的 IProgressValue&lt;T&gt; 接口,其中包含 T 的值,并且实现包含内部集。
  • 我试图实现这一点,并发现除非接口具有原子增加其内部值的方法,否则我们又回到了乱序报告的初始问题。我目前正在试验这个界面:interface IProgressIncremental&lt;T&gt; {T CurrentValue { get; } void Initialize(T initialValue, Func&lt;T, T, T&gt; increment); void IncrementBy(T value);},它可以工作,但它远非可爱。
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 2012-12-31
  • 2022-01-15
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2012-04-27
  • 2016-03-21
相关资源
最近更新 更多