Как заставить C # Task существовать только один раз за один и тот же идентификатор - PullRequest
0 голосов
/ 07 февраля 2019

Было несколько раз в разных приложениях, в которых мне нужно было выполнить следующее поведение с помощью 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, предоставляющий фабричный метод для создания самой Задачи и требуемый уникальный идентификатор для всего приложения.Любая другая задача, которая поступит с тем же идентификатором, диспетчер задач с ответом с тем же экземпляром задачи, созданным ранее.Только если нет других задач с таким идентификатором, менеджер вызывает фабричный метод и запускает задачу.Я добавил блокировки вокруг создания и удаления задач кода, чтобы обеспечить безопасность потоков.Кроме того, чтобы удалить задачу из сохраненного словаря после ее завершения, я добавил задачу продолжения, используя метод ContinueWith.Итак, после того, как задача будет выполнена, и сама задача, и ее продолжение будут удалены.

С моей стороны это довольно распространенный сценарий.Я хотел бы предположить, что существует хорошо разработанный шаблон проектирования, или, возможно, C # API, который выполняет ту же самую вещь.Таким образом, любые идеи или предложения будут очень благодарны.

1 Ответ

0 голосов
/ 07 февраля 2019

Если вы хотите разрешить одновременное выполнение нескольких задач с одним и тем же taskId, но с другим типом данных, вы можете переместить универсальный тип <T> из метода в класс.Таким образом, каждый тип <T> имеет свой собственный словарь.Это также позволяет вам хранить словарь Task<T> и избегать приведения типов.

Вы сказали, что у вас будет только один экземпляр класса 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 (даже если пользователь нажимает несколькораз, каждый должен происходить последовательно).Веб-приложение не может дать такие гарантии, хотя.

Вы можете видеть, что я делаю проверку if (_currentTask == null) дважды, один раз внутри замка и один раз за пределами замка, что позволяет в случае уже существующей задачи избежать попадания в ловушку необходимости получить замок.Я не верю, что вы можете использовать тот же трюк с кодом в вашем вопросе, потому что если RemoveTask вызывается одновременно с TryGetValue, вы можете попасть в плохое состояние.Вы можете переключиться на использование ConcurrentDictionary, и это, вероятно, позволит вам избежать использования блокировки внутри RemoveTask и блокировать ExecuteOnceAsync только тогда, когда TryGetValue возвращает false.Но нужно ли это измерять для повышения производительности или нет.

Другая проблема как с вашим кодом, так и с моим, заключается в том, что когда taskFactory медленный, он долго удерживает блокировку (помните, когда вызывается асинхронный вызов).метод выполняется синхронно до тех пор, пока первое ожидание, когда задача, которую он ожидает, еще не завершено ).Это может быть уменьшено с помощью Task<T> concreteTask = Task.Run(()=> taskFactory());, но Task.Run имеет свои собственные издержки, поэтому, если taskFactory действительно очень быстро набирает await, вероятно, лучше сохранить производительность, как есть.Опять же, если перфоманс имеет значение, вам нужно измерить.

И хотя я ни в коем случае не эксперт по перфекту, в зависимости от ваших перфектных требований мое предложение имеет несколько улучшений:

  • Хотя Dictionary поисков - это O (1), равно как и Thread.Sleep(1000), другими словами, он может быть постоянно медленнее, чем альтернатива, даже если альтернативой является O (n) (для малых значений n).Поскольку ваш ключ словаря является строкой, string.GetHashCode - это O (n) в зависимости от длины строки, и словарь perf может быть поврежден коллизиями хешей.Мое предложение имеет только один объект задачи, так что это также O (1), и он не требует хеширования чего-либо или поиска в массиве, или, тем не менее, Dictionary.
  • Это супер минор, но так как мойRemoveTask не нужно захватывать какие-либо переменные, я могу передать его непосредственно в ContinueWith как делегат (так как типы совпадают), тогда как вы используете анонимную функцию, которая означает, что компилятор должен создать анонимный класс для захвата переменной taskId,и создайте экземпляр этого класса в куче при каждом запуске кода, что увеличивает нагрузку на память.Это само по себе не окажет значительного перфоментного эффекта (если только не будет выполняться в очень узком цикле), но оно может способствовать «смерти от 1000 сокращений», если у вас есть много других битов кода, которые создают небольшие объекты в куче.
  • Как уже упоминалось выше, мой код не блокируется, когда задача уже существует.
  • Ваш код эффективно использует глобальную блокировку приложения. Это самый важный убийца перфектов .Если ваш TaskManager используется каждый раз, когда делается запрос к базе данных, ваша реализация может запускать только один запрос к базе данных за раз, даже если две задачи с разными taskId никогда не смогут вернуть одну и ту же задачу.И RemoveTask, независимо от того, какая задача завершена, блокирует все вызовы на ExecuteOnceAsync.Используя другой экземпляр TaskManager для каждого типа запроса, вы можете избежать GetProductsAsync блокирования GetCustomerInformation.

Написав все это, если ваша цель - не использовать один TaskManagerдля каждого приложения, но вместо этого используйте один TaskManager для каждого метода запроса к базе данных, что означает, что taskId на самом деле представляет параметры запроса GetProductsAsync, тогда вам следует игнорировать все, что я написал :) Ну, почти все.Вы по-прежнему можете перемещать <T> в определения классов и словарей и избегать выполнения типов в своем методе.

...