Я разрабатываю веб-приложение с 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);
}
}
Спасибо за комментарий, в итоге я использовал Hangfire, он очень мощный. Подумайте о том, чтобы написать ответ, чтобы я мог его принять.
Вы по-прежнему можете использовать 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. Мне не удалось найти способ выполнить фоновую задачу в веб-приложении, которое долго работает и не приведет к тайм-ауту пользовательского интерфейса.
Следует разрешить использование временных ресурсов. Лично я использую ContextFactory
, которые создают новый контекст каждый раз, когда он запрашивает.
Спасибо @Fabio. Просто попробовал найти такой пример, но без всякой радости. У вас есть пример?
@TylerDurden, я думаю, вы можете задать вопрос о своей проблеме и почти наверняка получите правильный ответ
Я следил за этой скороговоркой (пытался), и общедоступная асинхронная задача StopAsync (CancellationToken cancellationToken) никогда не вызывается. Поэтому мне приходится вручную останавливать процесс. Что я должен искать? service.AddSingleton должен быть services.AddSingleton, верно? Значит, и синглтон, и размещенная служба зарегистрированы в Startup?
При локальном запуске CTRL + C должен правильно остановить ваш сервер и вызвать метод Stop.
Когда я нажимаю CTRL + C, он начинает отключаться, но StopAsync никогда не вызывается. Довольно странно. Если я не зарегистрирую размещенную службу и синглтон, проблем не будет. Я проверил точки останова и регистрацию вызова StartAsync, но что-то мешает вызову StopAsync.
@Fabio проблема была в том, что Deque блокировал навсегда. Передача токена отмены в TasksToRun.Deque из _tokenSource устраняет проблему. А теперь отключается.
Как реализовать отмену задачи из очереди (выполняющейся)?
Это действительно работает? Я попытался реализовать и быстро заметил, что метод BlockingCollection
Take
в методе StartAsync
размещенной службы препятствует запуску службы. Это также блокирует запуск среды выполнения ASP.NET, поскольку регистрация службы никогда не завершается.
@ Джастин, нет, это не работает. Нам нужно вернуть Task.CompletedTask
в StartAsync
, чтобы веб-приложение запустилось. Фактическая работа может быть обернута в Task
. Но тогда можно было просто использовать задачу без IHostedService
. Мне действительно интересно, почему этот ответ получил столько голосов.
Код в ответе - это псевдокод, суть ответа в том, что в IHostedService.StartAsync
вы запускаете задачу, которая будет «опрашивать» блокирующую коллекцию, не дожидаясь завершения этой задачи, и отменить задачу в IHostedService.StopAsync
. Блокирующая коллекция предоставляется другим частям приложения, где потребитель может добавлять «запросы» для выполнения в фоновом режиме.
@Fabio Можно ли (и как?) Расширить приведенный выше пример так, чтобы было несколько (динамических) потребителей задач из BlockingCollection?
@Fabio, ваш ответ не означает, что это псевдокод. Кроме того, он выглядит как полностью действующий код времени выполнения в том виде, в каком он написан, поэтому он явно вводит в заблуждение. Я предлагаю вам отредактировать свой ответ, чтобы уточнить.
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
), это, скорее всего, сломается: после постановки задачи в очередь метод контроллера возвращается, и запрос завершается. Затем фреймворк очищает внедренные сервисы. Если наша фоновая задача выполняется достаточно медленно или с задержкой, она может попытаться вызвать метод удаленной службы, что приведет к ошибке.
Чтобы избежать этого, наш поставленные в очередь задачи всегда должны создавать свою собственную область обслуживания не должен использовать экземпляры службы из окружающего контроллера.
Используйте сторонний инструмент, такой как Hangifre, но здесь должны быть тысячи подобных вопросов.