【问题标题】:How can I run an async Task in C# only once (even if called multiple times in parallel)?如何在 C# 中只运行一次异步任务(即使并行调用多次)?
【发布时间】:2022-10-06 19:10:31
【问题描述】:

问题可能出现在多种常见场景中:您按下一个按钮并想确定按钮处理程序逻辑是否只执行一次 - 即使用户像疯子一样反复按下同一个按钮。

另一种情况是在后端系统中采用“第一次调用获胜”策略:无论一次调用执行多少次,我们都只想运行一次。所有并行调用不应该在锁前排队,而是被取消。

我的第一次尝试如下所示:我正在使用 SemaphoreSlim,为此我创建了一个扩展方法 \"ExecuteOnceAsync\",它有一个参数 \"action\"(它本质上是我们只想执行的任务的任务创建者工厂运行一次)。

public static class SemaphoreSlimExtensions
{
    /// <summary>
    /// Executes a task within the context of a a SemaphoreSlim.
    /// The task is started only if no <paramref name=\"action\"/> is currently running.
    /// </summary>
    /// <param name=\"semaphoreSlim\">The semaphore instance.</param>
    /// <param name=\"action\">The function to execute as a task.</param>
    public static async Task ExecuteOnceAsync(this SemaphoreSlim semaphoreSlim, Func<Task> action)
    {
        if (semaphoreSlim.CurrentCount == 0)
        {
            return;
        }

        try
        {
            await semaphoreSlim.WaitAsync();
            await action();
        }
        finally
        {
            try
            {
                semaphoreSlim.Release();
            }
            catch (SemaphoreFullException)
            {
                // Ignored
            }
        }
    }
}

下面的单元测试应该显示一个示例用法,其中对具有共享资源的任务的调用并行完成 100 次,但我们要确保它只执行一次:

[Fact]
public async Task ShouldExecuteOnceAsync()
{
    // Arrange
    var counter = 0;
    var parallelTasks = 100;
    var semaphoreSlim = new SemaphoreSlim(1, 1);
    Func<Task> action = () => Task.Run(() =>
    {
        counter++;
        this.testOutputHelper.WriteLine($\"Run: counter={counter}\");
        return counter;
    });

    // Act
    var tasks = Enumerable.Range(1, parallelTasks).Select(i => semaphoreSlim.ExecuteOnceAsync(action));
    await Task.WhenAll(tasks);

    // Assert
    counter.Should().Be(1);
}

现在问题是:这段代码是否有意义,或者是否有更好或更可靠的方法来实现我的目标?这种扩展方法是线程安全的吗?

感谢您分享您的体验。

  • 这已经由 ActionBlock 或由单个任务处理的 Channel 提供。
  • @PanagiotisKanavos,我阅读了 ActionBlock<T> 的文档并尝试了一些示例。它要么不能解决所描述的问题,要么我只是不明白如何使用 ActionBlock<T> 来解决问题。 var actionBlock = new ActionBlock<string>(async url => { await Task.Delay(1000); this.testOutputHelper.WriteLine($\"url={url}\"); }, new ExecutionDataflowBlockOptions { MaxDegreeOfParallelism = 1 }) ; var result1 = actionBlock.Post(\"website.com/path/to/images\"); var result2 = actionBlock.Post(\"another-website.com/path/to/images\");
  • ActionBlock 允许您按顺序处理项目。这是解决问题的第一步。信号量对此一点帮助都没有。除了only-once 不是穿线问题,这是一个信息问题。您希望确保仅在需要将消息存储在已处理该消息的某个位置时才处理该消息。在这种情况下,ConcurrentDictionary 有助于存储您每次处理的 ID 或整个消息。当然,如果您使用 DOP=1 的 ActionBlock,即使 Dictionary 也可以
  • 如果您想消除按钮点击的抖动,Reactive Extensions\' Throttle 方法可以工作。有界通道配置为降低满时的新消息也会有所帮助。
  • 另一种方法是在处理第一次单击时禁用按钮。无论如何,您都必须这样做,才能为用户提供视觉提示。

标签: c# async-await synchronization task-parallel-library semaphore


【解决方案1】:

上面的代码有一个竞争条件,在检查信号量的CurrentCount 和调用WaitAsync 之间。

更好的方法是使用semaphoreSlim.WaitAsync(0)

public static async Task ExecuteOnceAsync(this SemaphoreSlim semaphoreSlim, 
  Func<Task> action)
{
    try
    {
        if (! await semaphoreSlim.WaitAsync(0)) return;
        await action();
    }
    finally
    {
        try
        {
            semaphoreSlim.Release();
        }
        catch (SemaphoreFullException)
        {
            // Ignored
        }
    }
}

您的测试可能没有帮助。没有可靠的方法来测试并发代码,因为任何事情都可能发生,因此测试不是幂等的。

【讨论】:

  • WaitAsync(0) 的文档说:“等待的毫秒数(...),或者为零来测试等待句柄的状态并立即返回。”每当我运行此代码时,无论是在单元测试中还是在实际场景中,WaitAsync(0) 都会立即返回 true 并运行 action()。难道我做错了什么?你运行这段代码了吗?
  • 是的,我做到了。我的代码是正确的。但是,您的测试并不是刚刚看到它的问题。如果你在return counter之前加上counter--,你会看到所有的输出都是1,计数器的最终值是0。
猜你喜欢
  • 1970-01-01
  • 2014-02-11
  • 1970-01-01
  • 2019-08-13
  • 1970-01-01
  • 2020-07-25
  • 1970-01-01
  • 2017-08-21
  • 1970-01-01
相关资源
最近更新 更多