Запуск фоновой задачи из действия контроллера в ASP.NET Core

Я разрабатываю веб-приложение с REST API, используя C# с ASP.NET Core 2.0.

Я хочу добиться, чтобы когда клиент отправлял запрос в конечную точку, я запускал фоновую задачу, отделенную от контекста клиентского запроса, которая будет завершена, если задача будет запущена успешно.

Я знаю, что есть HostedService, но проблема в том, что HostedService запускается при запуске сервера, и, насколько мне известно, нет возможности запустить HostedService вручную с контроллера.

Вот простой код, демонстрирующий вопрос.

[Authorize(AuthenticationSchemes = "UsersScheme")]
public class UsersController : Controller
{
    [HttpPost]
    public async Task<JsonResult> StartJob([FromForm] string UserId, [FromServices] IBackgroundJobService backgroundService)
    {
        // check user account
        (bool isStarted, string data) result = backgroundService.Start();

        return JsonResult(result);
    }
}

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

DavidG 13.04.2018 11:31

Спасибо за комментарий, в итоге я использовал Hangfire, он очень мощный. Подумайте о том, чтобы написать ответ, чтобы я мог его принять.

Waxren 06.06.2018 20:14
Стоит ли изучать PHP в 2023-2024 годах?
Стоит ли изучать PHP в 2023-2024 годах?
Привет всем, сегодня я хочу высказать свои соображения по поводу вопроса, который я уже много раз получал в своем сообществе: "Стоит ли изучать PHP в...
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
В JavaScript одним из самых запутанных понятий является поведение ключевого слова "this" в стрелочной и обычной функциях.
Приемы CSS-макетирования - floats и Flexbox
Приемы CSS-макетирования - floats и Flexbox
Здравствуйте, друзья-студенты! Готовы совершенствовать свои навыки веб-дизайна? Сегодня в нашем путешествии мы рассмотрим приемы CSS-верстки - в...
Тестирование функциональных ngrx-эффектов в Angular 16 с помощью Jest
В системе управления состояниями ngrx, совместимой с Angular 16, появились функциональные эффекты. Это здорово и делает код определенно легче для...
Концепция локализации и ее применение в приложениях React ⚡️
Концепция локализации и ее применение в приложениях React ⚡️
Локализация - это процесс адаптации приложения к различным языкам и культурным требованиям. Это позволяет пользователям получить опыт, соответствующий...
Пользовательский скаляр GraphQL
Пользовательский скаляр GraphQL
Листовые узлы системы типов GraphQL называются скалярами. Достигнув скалярного типа, невозможно спуститься дальше по иерархии типов. Скалярный тип...
41
2
29 519
3

Ответы 3

Вы по-прежнему можете использовать IHostedService в качестве основы для фоновых задач в сочетании с BlockingCollection.

Создайте оболочку для BlockingCollection, чтобы вы могли внедрить ее как синглтон.

public class TasksToRun
{
    private readonly BlockingCollection<TaskSettings> _tasks;

    public TasksToRun() => _tasks = new BlockingCollection<TaskSettings>();

    public void Enqueue(TaskSettings settings) => _tasks.Add(settings);

    public TaskSettings Dequeue(CancellationToken token) => _tasks.Take(token);
}

Затем в реализации IHostedService «слушайте» задачи и, когда задачи «приходят», выполняйте ее. BlockingCollection остановит выполнение, если коллекция пуста, поэтому цикл while не будет потреблять процессорное время. Метод .Take принимает cancellationToken в качестве аргумента. С помощью токена вы можете отменить «ожидание» следующей задачи при остановке приложения.

public class BackgroundService : IHostedService
{
    private readonly TasksToRun _tasks;

    private CancellationTokenSource _tokenSource;

    private Task _currentTask;

    public BackgroundService(TasksToRun tasks) => _tasks = tasks;

    public async Task StartAsync(CancellationToken cancellationToken)
    {
        _tokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
        while (cancellationToken.IsCancellationRequested == false)
        {
            try
            {
                var taskToRun = _tasks.Dequeue(_tokenSource.Token);

                // We need to save executable task, 
                // so we can gratefully wait for it's completion in Stop method
                _currentTask = ExecuteTask(taskToRun);               
                await _currentTask;
            }
            catch (OperationCanceledException)
            {
                // execution cancelled
            }
        }
    }

    public async Task StopAsync(CancellationToken cancellationToken)
    {
        _tokenSource.Cancel(); // cancel "waiting" for task in blocking collection

        if (_currentTask == null) return;

        // wait when _currentTask is complete
        await Task.WhenAny(_currentTask, Task.Delay(-1, cancellationToken));
    }
}

А в контроллере вы просто добавляете задачу, которую хотите запустить, в нашу коллекцию.

public class JobController : Controller
{
    private readonly TasksToRun _tasks;

    public JobController(TasksToRun tasks) => _tasks = tasks;

    public IActionResult PostJob()
    {
        var settings = CreateTaskSettings();

        _tasks.Enqueue(settings);

        return Ok();
    }
}

Обертка для блокировки сбора должна быть зарегистрирована для внедрения зависимостей как singleton

services.AddSingleton<TasksToRun, TasksToRun>();

Зарегистрировать фоновую службу

services.AddHostedService<BackgroundService>();

Как вы можете использовать IHostedService для запуска заданий в базах данных и т. д.? Singleton блокирует использование ресурсов Scoped или Transient. Мне не удалось найти способ выполнить фоновую задачу в веб-приложении, которое долго работает и не приведет к тайм-ауту пользовательского интерфейса.

Tyler Durden 23.05.2018 09:51

Следует разрешить использование временных ресурсов. Лично я использую ContextFactory, которые создают новый контекст каждый раз, когда он запрашивает.

Fabio 23.05.2018 10:04

Спасибо @Fabio. Просто попробовал найти такой пример, но без всякой радости. У вас есть пример?

Tyler Durden 23.05.2018 10:36

@TylerDurden, я думаю, вы можете задать вопрос о своей проблеме и почти наверняка получите правильный ответ

Fabio 23.05.2018 10:48

Я следил за этой скороговоркой (пытался), и общедоступная асинхронная задача StopAsync (CancellationToken cancellationToken) никогда не вызывается. Поэтому мне приходится вручную останавливать процесс. Что я должен искать? service.AddSingleton должен быть services.AddSingleton, верно? Значит, и синглтон, и размещенная служба зарегистрированы в Startup?

Timothy John Laird 06.03.2019 18:37

При локальном запуске CTRL + C должен правильно остановить ваш сервер и вызвать метод Stop.

Fabio 06.03.2019 18:40

Когда я нажимаю CTRL + C, он начинает отключаться, но StopAsync никогда не вызывается. Довольно странно. Если я не зарегистрирую размещенную службу и синглтон, проблем не будет. Я проверил точки останова и регистрацию вызова StartAsync, но что-то мешает вызову StopAsync.

Timothy John Laird 06.03.2019 20:18

@Fabio проблема была в том, что Deque блокировал навсегда. Передача токена отмены в TasksToRun.Deque из _tokenSource устраняет проблему. А теперь отключается.

Timothy John Laird 06.03.2019 21:00

Как реализовать отмену задачи из очереди (выполняющейся)?

Adam Cox 11.09.2019 03:34

Это действительно работает? Я попытался реализовать и быстро заметил, что метод BlockingCollectionTake в методе StartAsync размещенной службы препятствует запуску службы. Это также блокирует запуск среды выполнения ASP.NET, поскольку регистрация службы никогда не завершается.

Justin Skiles 23.06.2020 15:13

@ Джастин, нет, это не работает. Нам нужно вернуть Task.CompletedTask в StartAsync, чтобы веб-приложение запустилось. Фактическая работа может быть обернута в Task. Но тогда можно было просто использовать задачу без IHostedService. Мне действительно интересно, почему этот ответ получил столько голосов.

Jack Miller 17.12.2020 16:48

Код в ответе - это псевдокод, суть ответа в том, что в IHostedService.StartAsync вы запускаете задачу, которая будет «опрашивать» блокирующую коллекцию, не дожидаясь завершения этой задачи, и отменить задачу в IHostedService.StopAsync. Блокирующая коллекция предоставляется другим частям приложения, где потребитель может добавлять «запросы» для выполнения в фоновом режиме.

Fabio 18.12.2020 01:47

@Fabio Можно ли (и как?) Расширить приведенный выше пример так, чтобы было несколько (динамических) потребителей задач из BlockingCollection?

Maciej Pszczolinski 26.12.2020 11:13

@Fabio, ваш ответ не означает, что это псевдокод. Кроме того, он выглядит как полностью действующий код времени выполнения в том виде, в каком он написан, поэтому он явно вводит в заблуждение. Я предлагаю вам отредактировать свой ответ, чтобы уточнить.

Justin Skiles 28.12.2020 19:47

Microsoft задокументировала то же самое на https://docs.microsoft.com/en-us/aspnet/core/fundamentals/host/hosted-services?view=aspnetcore-2.1.

Для этого используется BackgroundTaskQueue, работа которого назначается контроллером, а работа выполняется QueueHostedService, производным от BackgroundService.

Это в значительной степени основано на документация, связанном с ответ Скьягини, с некоторыми улучшениями.

Я подумал, что может помочь повторить здесь весь пример, если в какой-то момент ссылка оборвется. Я внес некоторые коррективы; в частности, я ввожу IServiceScopeFactory, чтобы позволить фоновым процессам безопасно запрашивать услуги сами. Я объясняю свои рассуждения в конце этого ответа.


Основная идея заключается в создании очередь задач, который пользователь может внедрить в свой контроллер, а затем назначить ему задачи. Такая же очередь задач присутствует в долгосрочная размещенная служба, который выводит из очереди одну задачу за раз и выполняет ее.

Очередь задач:

public interface IBackgroundTaskQueue
{
    // Enqueues the given task.
    void EnqueueTask(Func<IServiceScopeFactory, CancellationToken, Task> task);

    // Dequeues and returns one task. This method blocks until a task becomes available.
    Task<Func<IServiceScopeFactory, CancellationToken, Task>> DequeueAsync(CancellationToken cancellationToken);
}

public class BackgroundTaskQueue : IBackgroundTaskQueue
{
    private readonly ConcurrentQueue<Func<IServiceScopeFactory, CancellationToken, Task>> _items = new();

    // Holds the current count of tasks in the queue.
    private readonly SemaphoreSlim _signal = new SemaphoreSlim(0);

    public void EnqueueTask(Func<IServiceScopeFactory, CancellationToken, Task> task)
    {
        if (task == null)
            throw new ArgumentNullException(nameof(task));

        _items.Enqueue(task);
        _signal.Release();
    }

    public async Task<Func<IServiceScopeFactory, CancellationToken, Task>> DequeueAsync(CancellationToken cancellationToken)
    {
        // Wait for task to become available
        await _signal.WaitAsync(cancellationToken);

        _items.TryDequeue(out var task);
        return task;
    }
}

В основе очереди задач лежит потокобезопасный ConcurrentQueue<>. Поскольку мы не хотим опрашивать очередь, пока не станет доступной новая задача, мы используем объект SemaphoreSlim, чтобы отслеживать текущее количество задач в очереди. Каждый раз, когда мы вызываем Release, внутренний счетчик увеличивается. Метод WaitAsync блокируется до тех пор, пока внутренний счетчик не станет больше 0, а затем уменьшает его.

Для вывода из очереди и выполнения задач мы создаем фоновую службу:

public class BackgroundQueueHostedService : BackgroundService
{
    private readonly IBackgroundTaskQueue _taskQueue;
    private readonly IServiceScopeFactory _serviceScopeFactory;
    private readonly ILogger<BackgroundQueueHostedService> _logger;

    public BackgroundQueueHostedService(IBackgroundTaskQueue taskQueue, IServiceScopeFactory serviceScopeFactory, ILogger<BackgroundQueueHostedService> logger)
    {
        _taskQueue = taskQueue ?? throw new ArgumentNullException(nameof(taskQueue));
        _serviceScopeFactory = serviceScopeFactory ?? throw new ArgumentNullException(nameof(serviceScopeFactory));
        _logger = logger ?? throw new ArgumentNullException(nameof(logger));
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        // Dequeue and execute tasks until the application is stopped
        while(!stoppingToken.IsCancellationRequested)
        {
            // Get next task
            // This blocks until a task becomes available
            var task = await _taskQueue.DequeueAsync(stoppingToken);

            try
            {
                // Run task
                await task(_serviceScopeFactory, stoppingToken);
            }
            catch(Exception ex)
            {
                _logger.LogError(ex, "An error occured during execution of a background task");
            }
        }
    }
}

Наконец, нам нужно сделать нашу очередь задач доступной для внедрения зависимостей и запустить нашу фоновую службу:

public void ConfigureServices(IServiceCollection services)
{
    // ...
    
    services.AddSingleton<IBackgroundTaskQueue, BackgroundTaskQueue>();
    services.AddHostedService<BackgroundQueueHostedService>();
    
    // ...
}

Теперь мы можем внедрить очередь фоновых задач в наш контроллер и поставить задачи в очередь:

public class ExampleController : Controller
{
    private readonly IBackgroundTaskQueue _backgroundTaskQueue;

    public ExampleController(IBackgroundTaskQueue backgroundTaskQueue)
    {
        _backgroundTaskQueue = backgroundTaskQueue ?? throw new ArgumentNullException(nameof(backgroundTaskQueue));
    }

    public IActionResult Index()
    {
        _backgroundTaskQueue.EnqueueTask(async (serviceScopeFactory, cancellationToken) =>
        {
            // Get services
            using var scope = serviceScopeFactory.CreateScope();
            var myService = scope.ServiceProvider.GetRequiredService<IMyService>();
            var logger = scope.ServiceProvider.GetRequiredService<ILogger<ExampleController>>();
            
            try
            {
                // Do something expensive
                await myService.DoSomethingAsync(cancellationToken);
            }
            catch(Exception ex)
            {
                logger.LogError(ex, "Could not do something expensive");
            }
        });

        return Ok();
    }
}

Зачем использовать IServiceScopeFactory?

Теоретически мы могли бы напрямую использовать служебные объекты, которые мы внедрили в наш контроллер. Это, вероятно, будет хорошо работать с одноэлементными службами, а также с наиболее ограниченными службами.

Однако для служб с ограниченной областью действия, которые реализуют IDisposable (например, DbContext), это, скорее всего, сломается: после постановки задачи в очередь метод контроллера возвращается, и запрос завершается. Затем фреймворк очищает внедренные сервисы. Если наша фоновая задача выполняется достаточно медленно или с задержкой, она может попытаться вызвать метод удаленной службы, что приведет к ошибке.

Чтобы избежать этого, наш поставленные в очередь задачи всегда должны создавать свою собственную область обслуживания не должен использовать экземпляры службы из окружающего контроллера.

Другие вопросы по теме