Очереди и фоновые задачи на C#: как ускорить приложение без боли

Онлайн-тренажер Python для начинающих

Изучайте Python легко и без перегрузки теорией. Решайте практические задачи с автоматической проверкой, получайте подсказки на русском языке и пишите код прямо в браузере — без необходимости что-либо устанавливать.

Начать курс

Почему вашему C# приложению нужны очереди и фоновые задачи?

Представьте: вы запускаете веб-приложение на C#, пользователь нажимает кнопку «Отправить отчёт», и… сервер зависает на 30 секунд, пока генерируется PDF. Знакомо? В 2024 году пользователи не прощают тормозов. Решение — очереди и фоновые задачи. Они превращают синхронный ад в асинхронный рай.

В этой статье мы разберём, как правильно организовывать фоновую обработку в C#: от встроенных Channel<T> до промышленного RabbitMQ. Вы узнаете, как вынести тяжёлую работу из основного потока, не сломав архитектуру. Поехали!

Что такое очередь задач и зачем она в C#?

Очередь (queue) — это структура данных FIFO (First In, First Out). В контексте C# это механизм, который позволяет отложить выполнение задачи, чтобы не блокировать основной поток. Фоновая задача — это код, который выполняется «за кулисами», пока пользователь продолжает кликать по сайту.

Типичные сценарии:

  • Отправка email-уведомлений (100 писем — 100 запросов к SMTP)
  • Генерация отчётов и Excel-файлов
  • Обработка загруженных изображений (ресайз, watermark)
  • Интеграция с внешними API (медленные ответы)
  • Логирование в базу данных (чтобы не тормозить UI)

Без очередей C# приложение будет «залипать» на каждом тяжёлом запросе. С очередями — вы просто кладёте задачу в буфер и отвечаете пользователю: «Готово, мы обрабатываем».

Вариант 1: Фоновые задачи через BackgroundService (встроенный .NET)

Самый простой способ — использовать BackgroundService из Microsoft.Extensions.Hosting. Это базовый класс для создания фоновых сервисов в ASP.NET Core. Он идеально подходит для простых сценариев, когда не нужна сложная маршрутизация сообщений.

Пример: фоновый обработчик с Channel<T>

Давайте напишем простой Producer-Consumer. Producer (контроллер) кладёт задачу в канал, Consumer (BackgroundService) забирает и выполняет.

// Шаг 1. Создаём канал (потокобезопасную очередь)
public class TaskChannel
{
    private readonly Channel<string> _channel = 
        Channel.CreateBounded<string>(new BoundedChannelOptions(100)
        {
            FullMode = BoundedChannelFullMode.Wait
        });

    public ChannelWriter<string> Writer => _channel.Writer;
    public ChannelReader<string> Reader => _channel.Reader;
}

// Шаг 2. BackgroundService, который читает из канала
public class EmailBackgroundService : BackgroundService
{
    private readonly TaskChannel _taskChannel;

    public EmailBackgroundService(TaskChannel taskChannel)
    {
        _taskChannel = taskChannel;
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        await foreach (var emailData in _taskChannel.Reader.ReadAllAsync(stoppingToken))
        {
            // Имитация отправки письма (2 секунды)
            await Task.Delay(2000, stoppingToken);
            Console.WriteLine($"Письмо отправлено: {emailData}");
        }
    }
}

// Шаг 3. Контроллер, который кладёт задачи
[ApiController]
[Route("api/email")]
public class EmailController : ControllerBase
{
    private readonly TaskChannel _taskChannel;

    public EmailController(TaskChannel taskChannel)
    {
        _taskChannel = taskChannel;
    }

    [HttpPost("send")]
    public IActionResult SendEmail([FromBody] string emailData)
    {
        _taskChannel.Writer.TryWrite(emailData);
        return Accepted(new { message = "Письмо поставлено в очередь" });
    }
}

// Шаг 4. Регистрация в Program.cs
builder.Services.AddSingleton<TaskChannel>();
builder.Services.AddHostedService<EmailBackgroundService>();

Что здесь происходит? Пользователь вызывает POST /api/email/send — данные попадают в канал. EmailBackgroundService в фоне забирает их и обрабатывает. Ответ пользователю приходит мгновенно (HTTP 202 Accepted).

Плюсы и минусы Channel<T>

  • Плюсы: встроено в .NET, не требует внешних зависимостей, потокобезопасно, есть ограничение буфера.
  • Минусы: данные живут только в памяти (потеряются при перезапуске), не подходит для распределённых систем.

Вариант 2: RabbitMQ — промышленная очередь для C#

Когда одного сервера мало, а данные нужно сохранять между перезапусками, на сцену выходит RabbitMQ. Это брокер сообщений, который работает по протоколу AMQP. В C# для работы с ним используется библиотека RabbitMQ.Client.

RabbitMQ решает проблемы: гарантия доставки, маршрутизация по routing key, работа в кластере. Идеально для микросервисов.

Пример: Producer и Consumer на C#

// Producer — отправляем сообщение в очередь
public class RabbitMqProducer
{
    private readonly IConnection _connection;
    private readonly IModel _channel;

    public RabbitMqProducer(string hostName = "localhost")
    {
        var factory = new ConnectionFactory() { HostName = hostName };
        _connection = factory.CreateConnection();
        _channel = _connection.CreateModel();
        _channel.QueueDeclare(queue: "task_queue",
                             durable: true,
                             exclusive: false,
                             autoDelete: false,
                             arguments: null);
    }

    public void Publish(string message)
    {
        var body = Encoding.UTF8.GetBytes(message);
        _channel.BasicPublish(exchange: "",
                             routingKey: "task_queue",
                             basicProperties: null,
                             body: body);
        Console.WriteLine($"Отправлено: {message}");
    }

    public void Dispose()
    {
        _channel?.Close();
        _connection?.Close();
    }
}

// Consumer — фоновый сервис, который слушает очередь
public class RabbitMqConsumerService : BackgroundService
{
    private readonly IConnection _connection;
    private readonly IModel _channel;

    public RabbitMqConsumerService()
    {
        var factory = new ConnectionFactory() { HostName = "localhost" };
        _connection = factory.CreateConnection();
        _channel = _connection.CreateModel();
        _channel.QueueDeclare(queue: "task_queue",
                             durable: true,
                             exclusive: false,
                             autoDelete: false,
                             arguments: null);
        _channel.BasicQos(prefetchSize: 0, prefetchCount: 1, global: false);
    }

    protected override Task ExecuteAsync(CancellationToken stoppingToken)
    {
        var consumer = new EventingBasicConsumer(_channel);
        consumer.Received += (model, ea) =>
        {
            var body = ea.Body.ToArray();
            var message = Encoding.UTF8.GetString(body);
            Console.WriteLine($"Обрабатываю: {message}");

            // Имитация работы
            Thread.Sleep(2000);

            _channel.BasicAck(deliveryTag: ea.DeliveryTag, multiple: false);
            Console.WriteLine($"Завершено: {message}");
        };

        _channel.BasicConsume(queue: "task_queue",
                             autoAck: false,
                             consumer: consumer);

        return Task.CompletedTask;
    }

    public override void Dispose()
    {
        _channel?.Close();
        _connection?.Close();
        base.Dispose();
    }
}

Обратите внимание: BasicQos(prefetchCount: 1) гарантирует, что consumer не получит новое сообщение, пока не подтвердит текущее.

Блоги

Рекомендаций по книгам