【问题标题】:How to enforce C# Task to exist only once at a time for a given ID如何强制 C# 任务对于给定的 ID 一次只存在一次
【发布时间】:2023-04-09 23:24:01
【问题描述】:

在不同的应用程序中,我多次需要使用 C# Task 完成以下行为,并且我以某种方式完成了它,并且希望了解它是否是实现预期效果的最佳方式,或者是否存在是其他更好的方法。

问题在于,在某些情况下,我希望特定任务仅存在于一个实例中。例如,如果有人请求,假设一个产品列表通过执行类似Task GetProductsAsync() 的方法,而其他人尝试请求相同的东西,它不会触发另一个任务,而是返回已经存在的任务。当GetProductsAsync 结束时,之前请求结果的所有调用者都将收到相同的结果。因此,在给定时间点应该只有一次 GetProductsAsync 执行。

在尝试找到类似且众所周知的设计模式来解决此问题后,我想出了自己的实现。在这里

public class TaskManager : ITaskManager
    {
        private readonly object _taskLocker = new object();
        private readonly Dictionary<string, Task> _tasks = new Dictionary<string, Task>();
        private readonly Dictionary<string, Task> _continuations = new Dictionary<string, Task>();

        public Task<T> ExecuteOnceAsync<T>(string taskId, Func<Task<T>> taskFactory)
        {
            lock(_taskLocker)
            {

                if(_tasks.TryGetValue(taskId, out Task task))
                {
                    if(!(task is Task<T> concreteTask))
                    {
                        throw new TaskManagerException($"Task with id {taskId} already exists but it has a different type {task.GetType()}. {typeof(Task<T>)} was expected");
                    }
                    else
                    {
                        return concreteTask;
                    }
                }
                else
                {
                    Task<T> concreteTask = taskFactory();
                    _tasks.Add(taskId, concreteTask);
                    _continuations.Add(taskId, concreteTask.ContinueWith(_ => RemoveTask(taskId)));
                    return concreteTask;
                }
            }
        }

        private void RemoveTask(string taskId)
        {
            lock(_taskLocker)
            {
                if(_tasks.ContainsKey(taskId))
                {
                    _tasks.Remove(taskId);
                }

                if(_continuations.ContainsKey(taskId))
                {
                    _continuations.Remove(taskId);
                }
            }
        }
    }

我们的想法是在整个应用程序生命周期中我们将拥有一个 TaskManager 实例。任何应在给定时间点仅执行一次的异步任务请求将调用ExecuteOnceAsync,提供创建任务本身的工厂方法,以及所需的应用程序范围的唯一 ID。任何其他具有相同 ID 的任务,任务管理器都会回复之前创建的相同任务实例。只有当没有其他具有该 ID 的任务时,管理器才会调用工厂方法并启动任务。我在代码任务创建和删除周围添加了锁,以确保线程安全。此外,为了在任务完成后从存储的字典中删除任务,我使用ContinueWith 方法添加了一个延续任务。因此,在任务完成后,任务本身及其延续都将被删除。

在我看来,这似乎是一个很常见的情况。我会假设有一个完善的设计模式,或者可能是 C# API 可以完成同样的事情。因此,任何见解或建议将不胜感激。

【问题讨论】:

  • 难道不能使用 Autofac 等 IoC 容器来实现吗?使用单个实例生命周期范围注册特定的 Task?然后,您只需通过构造函数请求所需的任务,无需了解或担心其生命周期管理。
  • 感谢@olitee 的回复。这实际上可能是可能的。但我认为它需要几乎与我相同的代码 - 比如识别任务,通过 ID 解决它,然后当它完成处理它时。所以,我需要让这件作品继续,才能删除任务。然后,我需要锁定继续和任务创建部分,因为这些操作应该是原子的。你怎么看?
  • 现有API可以考虑使用Lazy&lt;T&gt;。不利的一面是,您必须在 Lazy&lt;T&gt; 变量中为您只想执行一次的每个操作保留引用。
  • @DmytroMukalov 好点。可能使用 Lazy 任务只能解决工厂部分,不是吗?我仍然需要跟踪引用并将它们从列表中删除吗?
  • 尝试在任务级别解决这个问题似乎真的很容易出错和竞争条件。如果您需要结果缓存,只需在中间放置一个实际缓存并让(仍然不同的)任务从那里获取结果。我想说,发生应该合并(而不是序列化)的实际并发请求的情况根本不常见——这表明在更高级别上存在一个问题,即发出了应该合并的不必要的请求在其他地方,控制并行度更容易。

标签: c# async-await task


【解决方案1】:

如果您想允许具有相同taskId 但数据类型不同的多个任务同时运行,那么您可以将泛型类型&lt;T&gt; 从方法移动到类。这样每个类型&lt;T&gt; 都有自己的字典。它还允许您使字典存储Task&lt;T&gt; 并避免类型转换。

您说过您将只有一个 TaskManager 类的实例,但如果您可以将其更改为每个调用 TaskManager 的类只有一个实例,您就可以避免需要字典 (并因此散列任务的名称)每个类都有一个

的实例
public class TaskManager<T>
{
    private Task<T> _currentTask;
    private object _lock = new object();

    public Task<T> ExecuteOnceAsync(string taskId, Func<Task<T>> taskFactory)
    {
        if (_currentTask == null)
        {
            lock (_lock)
            {
                if (_currentTask == null)
                {
                    Task<T> concreteTask = taskFactory();
                    concreteTask.ContinueWith(RemoveTask);
                    _currentTask = concreteTask;
                }
            }
        }

        return _currentTask;
    }

    private void RemoveTask()
    {
        _currentTask = null;
    }
}

如果你想超级正确,那么你可以使用Interlocked.Exchange而不是直接给_currentTask赋值,这在传递给ContinueWith的匿名函数中更重要,因为它在锁之外运行。您可能想考虑使用一些Interlocked 方法来检查该值是否为空。顺便说一句,如果您知道对 ExecuteOnceAsync 的调用不会并行发生,则不需要锁定,例如,如果它被 WPF 或 WinForms 应用程序中的按钮单击事件处理程序调用(即使用户单击多个次,每个都应该按顺序发生)。但是,Web 应用程序无法做出这样的保证。

你可以看到我做了两次if (_currentTask == null) 检查,一次在锁内,一次在锁外,这允许任务已经存在的情况,以避免需要获取锁的性能命中。我不相信您可以对问题中的代码使用相同的技巧,因为如果 RemoveTaskTryGetValue 同时被调用,您可能会陷入糟糕的状态。您可以切换到使用 ConcurrentDictionary,这可能会让您避免使用 RemoveTask 内部的锁,并且仅在 TryGetValue 返回 false 时锁定 ExecuteOnceAsync。但这是否会提高性能需要衡量。

您和我的代码的另一个问题是,当taskFactory 很慢时,它会长时间保持锁定(请记住,在调用异步方法时,it runs synchronously until the first await where the task it awaits is not already complete)。这可以通过使用Task&lt;T&gt; concreteTask = Task.Run(()=&gt; taskFactory()); 来缓解,但是Task.Run 有它自己的开销,所以如果taskFactory 确实非常快地执行await,那么保持原样可能会更好的性能。同样,如果性能很重要,您需要衡量。

虽然我绝不是性能专家,但根据您的性能要求,我的建议有几个改进:

  • 虽然Dictionary 的查找时间为 O(1),Thread.Sleep(1000) 的查找时间也是如此,换句话说,它可能总是比替代方案慢,即使替代方案是 O(n)(对于较小的 n 值)。由于您的字典键是一个字符串,string.GetHashCode 是 O(n),具体取决于字符串的长度,并且字典性能可能会受到哈希冲突的影响。我的建议只有一个任务对象,所以它也是 O(1),它不需要散列任何东西或搜索数组,或者实现 Dictionary
  • 它非常小,但由于我的 RemoveTask 不需要捕获任何变量,我可以将它作为委托直接传递给 ContinueWith(因为类型匹配),而您使用匿名函数,这意味着编译器必须创建一个匿名类来捕获taskId变量,并在每次代码运行时在堆上创建该类的实例,这会增加内存压力。这本身不会产生任何显着的性能影响(除非在非常紧密的循环中运行),但如果您有许多其他代码在堆上创建小对象,它可能会导致“1000 次削减”。
  • 如上所述,当任务已经存在时,我的代码是无锁的。
  • 您的代码有效地使用了全局应用程序锁。 这是最重要的性能杀手。如果每次发出数据库请求时都使用您的TaskManager,那么您的实现一次只能启动一个数据库查询,即使具有不同taskId 的两个任务永远不会返回相同的任务。而RemoveTask,无论哪个任务完成,都会阻止对ExecuteOnceAsync 的所有调用。通过对每个查询类型使用不同的 TaskManager 实例,您可以避免 GetProductsAsync 阻止 GetCustomerInformation

写完所有这些,如果您的目标不是每个应用程序使用一个TaskManager,而是每个数据库查询方法使用一个TaskManager,这意味着taskId实际上是@987654361的查询参数的表示@,那么您应该忽略我写的所有内容:)好吧,几乎所有内容。您仍然可以将 &lt;T&gt; 移动到类和字典定义中,并避免在您的方法中进行类型转换。

【讨论】:

  • 感谢您的精彩回答 :) 我得到了你们所有的分数!他们是有道理的。虽然我们不打算使用这个 TaskManager 进行查询,但它很少被调用,我们需要确保它是否被调用两次,那么两个请求应该返回相同的结果。在数据库情况下完全同意,这可能是一种矫枉过正。让我评估答案并进行一些试验。我会回到这里。再次非常感谢如此详细的回答
  • 关于代码的一个问题。在您执行 ContinueWith 的部分中,您不存储返回的值 - 一个任务,它完成后不会被垃圾收集吗?另外,是的,我确实认为我们需要在 Remove 和 ExecuteOnce 中都进行锁定,因为这种方法不会很少被调用,我不介意锁定几毫秒 :)
  • 我不认为存储延续任务是必要的。首先,the examples in the docs don't do it。其次,正如 Damien_The_Unbeliever 作为对您的问题 the continuation task is registered 的评论所写的那样,一旦第一个任务完成,它就会引用要执行的延续任务(它本身可能有延续,因此仅存储委托是不够的)
  • 以防万一读到这里的人还不知道,.NET 中的垃圾收集使用标记来查找仍然具有来自应用程序根目录的引用的对象。顺便说一句,应用程序根并不意味着 Program.Main。运行时可以保留对您自己的代码中没有的对象的引用。超出范围的对象不会自动符合垃圾回收条件。
【解决方案2】:

我认为您可以使用一个有用的并发类来做到这一点。

只有在无法获得任何现有工作时才开始工作,然后等待它完成。如果之前已经运行过,它将获取已经完成(或正在进行)的任务并等待它完成。

// Add this static dictionary to your class

static readonly ConcurrentDictionary<string, Task> tasks = new();

// Add this to your doing something once method in that class

var work = this.tasks.GetOrAdd(taskId, _ =>
{
    return client.DoSomethingAsync();
});

await work;

重要

请参阅下面 Theodor 关于在锁外运行的委托的评论。这很不幸,这可以追溯到我 10 多年前设计的并发字典(并被微软认为是一种更好的方法来做 ConcurrentDictionary)。

我有自己的使用这种方法的线程安全字典。

bool TryAdd(K key, T value, out T contains);

这种简单的设计返回新添加的或现有的值,并提供了一些很棒的模式,特别是对于缓存/请求重复数据删除;添加任务以获得某些东西与等待现有任务获得它。

我注意到ConcurrentDictionary 有一个TryAdd,但它不会在失败时返回现有值,这很遗憾,但这样的事情可能会起作用:

static readonly ConcurrentDictionary<string, Task<Task>> tasks = new();

//

var newTask = new Task<Task>(() => DoSomethingAsync());

if (this.tasks.TryAdd(taskId, newTask))
{
    newTask.Start();
}

var somethingTask = await this.tasks[taskId];
await somethingTask;

它假定任务永远不会被删除。

基本上有一个外部任务,只有比赛获胜者才能开始。如果正在完成的工作是异步的,例如创建文件或网络资源,则其中需要有另一个 Task。

这个很好。

【讨论】:

  • 来自documentation"但是,valueFactory委托在锁之外被调用以避免在锁下执行未知代码可能出现的问题。因此,GetOrAddConcurrentDictionary&lt;TKey,TValue&gt; class 上的所有其他操作而言,它不是原子的。” 因此,不能保证两个线程不会同时调用client.DoSomethingAsync 方法。
  • @TheodorZoulias 保存得很好,先生!
  • 卢克你的最新编辑与这个问题有些相关:ConcurrentDictionary GetOrAdd async
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2021-09-21
  • 1970-01-01
  • 1970-01-01
  • 2020-07-25
  • 1970-01-01
  • 2011-09-21
相关资源
最近更新 更多