Hangfire design
Изображение с hangfire.io

Hangfire — многопоточный и масштабируемый планировщик задач, построенный по клиент-серверной архитектуре на стеке технологий .NET (в первую очередь Task Parallel Library и Reflection), с промежуточным хранением задач в БД. Полностью функционален в бесплатной (LGPL v3) версии с открытым исходным кодом. В статье рассказывается, как пользоваться Hangfire.

План статьи:


Принципы работы


В чем суть? Как вы можете видеть на КДПВ, которую я честно скопировал из официальной документации, процесс-клиент добавляет задачу в БД, процесс-сервер периодически опрашивает БД и выполняет задачи. Важные моменты:
  • Всё, что связывает клиента и сервера — это доступ к общей БД и общим сборкам, в которых объявлены классы-задачи.
  • Масштабирование нагрузки (увеличение количества серверов) — есть!
  • Без БД (хранилища задач) Hangfire не работает и работать не может. По-умолчанию поддерживается SQL Server, есть расширения для ряда популярных СУБД. В платной версии добавляется поддержка Redis.
  • В качестве хоста для Hangfire может выступать что угодно: ASP.NET-приложение, Windows Service, консольное приложение и т.д. вплоть до Azure Worker Role.

С точки зрения клиента, работа с задачей происходит по принципу «fire-and-forget», а если точнее — «добавил в очередь и забыл» — на клиенте не происходит ничего, помимо сохранения задачи в БД. К примеру, мы хотим выполнить метод MethodToRun в отдельном процессе:
BackgroundJob.Enqueue(() => MethodToRun(42, "foo"));

Эта задача будет сериализована вместе со значениями входных параметров и сохранена в БД:
{
    "Type": "HangClient.BackgroundJobClient_Tests, HangClient, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null",
    "Method": "MethodToRun",
    "ParameterTypes": "(\"System.Int32, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089\",\"System.String, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089\")",
    "Arguments": "(\"42\",\"\\\"foo\\\"\")"
}

Данной информации достаточно, чтобы вызвать метод MethodToRun в отдельном процессе через Reflection, при условии доступа к сборке HangClient, в которой он объявлен. Естественно, совершенно необязательно держать код для фонового выполнения в одной сборке с клиентом, в общем случае схема зависимостей такая:
module dependency
Клиент и сервер должны иметь доступ к общей сборке, при этом для встроенного веб-интерфейса (о нем чуть ниже) доступ необязателен. При необходимости возможно заменить реализацию уже сохраненной в БД задачи — путем замены сборки, на которую ссылается приложение-сервер. Это удобно для повторяемых по расписанию задач, но, конечно же, работает при условии полного совпадения контракта MethodToRun в старой и новой сборках. Единственное ограничение на метод — наличие public модификатора.
Необходимо создать объект и вызвать его метод? Hangfire сделает это за нас:
 BackgroundJob.Enqueue<EmailSender>(x => x.Send(13, "Hello!"));

И даже получит экземпляр EmailSender через DI-контейнер при необходимости.

Развернуть сервер (например в отдельном Windows Service) проще некуда:
public partial class Service1 : ServiceBase
{
    private BackgroundJobServer _server;

    public Service1()
    {
        InitializeComponent();
        GlobalConfiguration.Configuration.UseSqlServerStorage("connection_string");
    }

    protected override void OnStart(string() args)
    {
        _server = new BackgroundJobServer();
    }

    protected override void OnStop()
    {
        _server.Dispose();
    }
}

После старта сервиса наш Hangfire-сервер начнет подтягивать задачи из БД и выполнять их.

Необязательным для использования, но полезным и очень приятным является встроенный web dashboard, позволяющий управлять обработкой задач:

dashboard

Внутренности и возможности Hangfire-сервера


Прежде всего, сервер содержит свой пул потоков, реализованный через Task Parallel Library. А в основе лежит всем известный Task.WaitAll (см. класс BackgroundProcessingServer).

Горизонтальное масштабирование? Web Farm? Web Garden? Поддерживается:
You don’t want to consume additional Thread Pool threads with background processing – Hangfire Server uses custom, separate and limited thread pool.
You are using Web Farm or Web Garden and don’t want to face with synchronization issues – Hangfire Server is Web Garden/Web Farm friendly by default.

Мы можем создать произвольное количество Hangfire-серверов и не думать об их синхронизации — Hangfire гарантирует, что одна задача будет выполнена одним и только одним сервером. Пример реализации — использование sp_getapplock (см. класс SqlServerDistributedLock).
Как уже отмечалось, Hangfire-сервер не требователен к процессу-хосту и может быть развернут где угодно от Console App до Azure Web Site. Однако, он не всемогущ, поэтому при хостинге в ASP.NET следует учитывать ряд общих особенностей IIS, таких как process recycling, авто-старт (startMode=«AlwaysRunning» ) и т.п. Впрочем, документация планировщика предоставляет исчерпывающую информацию и на этот случай.
Кстати! Не могу не отметить качество документации — оно выше всяких похвал и находится где-то в районе идеального. Исходный код Hangfire окрыт и качественно оформлен, нет никаких препятствий к тому, чтобы поднять локальный сервер и походить по коду отладчиком.

Повторяемые и отложенные задачи


Hangfire позволяет создавать повторяемые задачи с минимальным интервалом в минуту:
RecurringJob.AddOrUpdate(() => MethodToRun(42, "foo"), Cron.Minutely);

Запустить задачу вручную или удалить:
RecurringJob.Trigger("task-id");
RecurringJob.RemoveIfExists("task-id");

Отложить выполнение задачи:
BackgroundJob.Schedule(() => MethodToRun(42, "foo"), TimeSpan.FromDays(7));

Создание повторяющейся И отложенной задачи возможно при помощи CRON expressions (поддержка реализована через проект NCrontab). К примеру, следующая задача будет выполняться каждый день в 2:15 ночи:
RecurringJob.AddOrUpdate("task-id", () => MethodToRun(42, "foo"), "15 2 * * *");


Микрообзор Quartz.NET


Рассказ о конкретном планировщике задач был бы неполон без упоминания достойных альтернатив. На платформе .NET таковой альтернативой является Quartz.NET — порт планировщика Quartz из мира Java. Quartz.NET решает схожие задачи, как и Hangfire — поддерживает произвольное количество «клиентов» (добавление задачи) и «серверов» (выполнение задачи), использующих общую БД. Но исполнение разное.
Мое первое знакомство с Quartz.NET нельзя было назвать удачным — взятый из официально GitHub-репозитория исходный код просто не компилировался, пока я вручную не поправил ссылки на несколько отсутствующих файлов и сборок (disclaimer: просто рассказываю, как было). Разделения на клиентскую и серверную часть в проекте нет — Quartz.NET распространяется в виде единственной DLL. Для того, чтобы конкретный экземляр приложения позволял только добавлять задачи, а не исполнять их — необходимо его настроить.
Quartz.NET полностью бесплатен, «из коробки» предлагает хранение задач как in-memory, так и с использованием многих популярных СУБД (SQL Server, Oracle, MySQL, SQLite и т.п.). Хранение in-memory представляет собой по-сути обычный словарь в памяти одного единственного процесса-сервера, выполняющего задачи. Реализовать несколько процессов-серверов становится возможным только при сохранении задач в БД. Для синхронизации, Quartz.NET не полагается на специфичные особенности реализации конкретной СУБД (те же Application Lock в SQL Server), а использует один обобщенный алгоритм. К примеру, путем регистрации в таблице QRTZ_LOCKS гарантируется единовременная работа не более чем одного процесса-планировщика с конкретным уникальным id, выдача задачи «на исполнение» осуществляется простым изменением статуса в таблице QRTZ_TRIGGERS.

Класс-задача в Quartz.NET должен реализовывать интерфейс IJob:
public interface IJob
{
    void Execute(IJobExecutionContext context);
}

С подобным ограничением, очень просто сериализовать задачу: в БД хранится полное имя класса, что достаточно для последующего получения типа класса-задачи через Type.GetType(name). Для передачи параметров в задачу используется класс JobDataMap, при этом допускается изменение параметров уже сохраненной задачи.
Что касается многопоточности, то Quartz.NET использует классы из пространства имен System.Threading: new Thread() (см. класс QuartzThread), свои пулы потоков, синхронизация через Monitor.Wait/Monitor.PulseAll.
Немалой ложкой дегтя является качество официальной документации. К примеру, вот материал по кластеризации: Lesson 11: Advanced (Enterprise) Features. Да-да, это всё, что есть на официальном сайте по данной теме. Где-то на просторах SO встречался фееричный совет просматривать также гайды по оригинальному Quartz, там тема раскрыта подробнее. Желание разработчиков поддерживать похожее API в обоих мирах — Java и .NET — не может не сказываться на скорости разработки. Релизы и обновления у Quartz.NET нечасто.
Пример клиентского API: регистрация повторяемой задачи HelloJob.
IScheduler scheduler = GetSqlServerScheduler();
scheduler.Start();

IJobDetail job = JobBuilder.Create<HelloJob>()
    .Build();

ITrigger trigger = TriggerBuilder.Create()
    .StartNow()
    .WithSimpleSchedule(x => x
    .WithIntervalInSeconds(10)
    .RepeatForever())
    .Build();

scheduler.ScheduleJob(job, trigger);

Основные характеристики двух рассмотренных планировщиков сведены в таблицу:
Характеристика Hangfire Quartz.NET
Неограниченное количество клиентов и серверов Да Да
Исходный код github.com/HangfireIO github.com/quartznet/quartznet
NuGet-пакет Hangfire Quartz
Лицензия LGPL v3 Apache License 2.0
Где хостим Web, Windows, Azure Web, Windows, Azure
Хранилище задач SQL Server (по-умолчанию), ряд СУБД через расширения, Redis (в платной версии) In-memory, ряд БД (SQL Server, MySQL, Oracle...)
Реализация многопоточности TPL Thread, Monitor
Web-интерфейс Да Нет. Планируется в будущих версиях.
Отложенные задачи Да Да
Повторяемые задачи Да (минимальный интервал 1 минута) Да (минимальный интервал 1 миллисекунда)
Cron Expressions Да Да

UPDATE: Как справедливо заметил ShurikEv в комментариях, web-interface для Quartz.NET существует: github.com/guryanovev/CrystalQuartz

Про (не)нагрузочное тестирование


Необходимо было проверить, как справится Hangfire с большим количеством задач. Сказано-сделано, и я написал простейшего клиента, добавляющего задачи с интервалом в 0,2 с. Каждая задача записывает строку с отладочной информацией в БД. Поставив на клиенте ограничение в 100К задач, я запустил 2 экземпляра клиента и один сервер, причем сервер — с профайлером (dotMemory). Спустя 6 часов, меня уже ожидало 200К успешно выполненных задач в Hangfire и 200К добавленных строк в БД. На скриншоте приведены результаты профилирования — 2 снимка состояния памяти «до» и «после» выполнения:
snapshots
На следующих этапах работало уже 20 процессов-клиентов и 20 процессов-серверов, а время выполнения задачи было увеличено и стало случайной величиной. Вот только на Hangfire это не отражалось вообще никак:
dashboard-2kk

Выводы. Опрос.


Лично мне понравился Hangfire. Бесплатный, открытый продукт, сокращает расходы на разработку и поддержку распределенных систем. Используете ли вы что-нибудь подобное? Приглашаю принять участие в опросе и рассказать свою точку зрения в комментариях.
Какие планировщики задач вы используете при разработке на .NET?

Проголосовало 209 человек. Воздержалось 54 человека.

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

Комментарии (39)


  1. King_Lamer
    06.04.2016 13:21
    +4

    Было бы интересно узнать о практическом применение таких менеджеров? Можете поделиться, в каких ситуациях вам приходилось их использовать?


    1. ilkar
      06.04.2016 15:00

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


    1. Zashikivarashi
      06.04.2016 16:04

      Использовал Hangfire для управления задачами клиентских парсеров инстаграмма и для запуска всяческих процессов. Приложение на asp mvc.


    1. Adash
      06.04.2016 16:04

      (EN) -> Such as calculation schedulers, whereby one calculation depends on the completion of another calculation.

      Such calculations could be for inserting aggregated data into analytics database etc. We discovered Quartz.NET and resolved this problem, we can pool all the calculations in our calc engine and they are ordered with a 'manual' inheritance tree via `Quartz.NET`.

      Problem exists with thread failing, but that's another story.

      Sorry for no RU.


    1. chumakov-ilya
      06.04.2016 16:12

      Например, выполнение задач по-расписанию (загрузка новых данных, тяжелые скрипты по ночам). Или перенос с клиента в отдельный какой-либо ресурсоемкой задачи.


    1. QtRoS
      06.04.2016 17:30

      В бизнес-процессах повсеместно — раз в сутки скачать/отправить данные, проверить наличие данных в смежной системе, наличие информации от клиентов и так далее.


    1. netaholic
      06.04.2016 20:02

      Есть сервис электронного документооборота DocuSign
      У них есть систему нотификации о произошедших событиях, которая рассылает оповещения по заранее сконфигурированным url
      Однако эта функция доступна только для пользователей с самой дорогой лицензией.
      Если вы создаёте сервис, который интегрируется с DocuSign, то для пользователей с отключённой функцией оповещения вам придётся запрашивать какие данные изменились каждые 15 минут.
      Таким образом после того, как пользователь в вашем сервисе авторизовался в DocuSign — вы с помощью HangFire можете поставить повторяющуюся каждые 15 минут задачу на опрос изменений


    1. odinserj
      08.04.2016 10:28

      В веб-приложениях постоянно приходится отдавать длительные задачи на выполнение в фоне, чтобы не заставлять пользователя ждать. Различные рассылки, импорт из больших XML, CSV файлов, создание архивов, отчетов и прочие вещи – в общем все то, что выполняется от одной секунды до нескольких часов (или имеет к этому склонность).


  1. ilkar
    06.04.2016 15:00

    Мы у себя используем RabbitMQ — в Nugget есть отличный клиент для .Net, разворачивается под linux за 3 клика, в win за… 4. Думаю, его можно добавить в опрос в качестве варианта ответа.


    1. NYMEZIDE
      06.04.2016 15:12
      +1

      RabbitMQ это не планировщик. А средство доставки.


      1. ilkar
        06.04.2016 15:23

        Да, прошу прощения, глаза зацепились за построение очереди. Тогда присоединяюсь к вопросу выше — было бы интересно узнать для каких задач нужен именно планировщик, а не система обмена сообщениями?


        1. NYMEZIDE
          06.04.2016 15:28

          У нас на работе планировщик используется для периодического (от каждые 15 минут, до ежедневного или ежемесячного) сбора информации с различных источников (почта, ftp, api сайтов и т.д.).
          Фактически куча ботов, которые умеют собираться информацию, затем боты которые обрабатывают и сохраняют.
          Тоже самое работает и в обратном направлении, т.е. отправка.
          Автоматизация повторяющихся действий, облегчение работы людей-менеджеров.


        1. odinserj
          08.04.2016 11:59

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

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


        1. De-Korzh
          10.04.2016 15:24

          Еще одним пример — организация ETL процесса, если нужно что-то более кастомное чем SSIS.


  1. kekekeks
    06.04.2016 15:09
    +2

    Хардкод конкретного метода конкретного класса с конкретным списком аргументов в конкретном порядке — лёгкий путь получить проблемы при обновлении системы. Почему-то разработчики подобных решений об этом не думают.


    1. chumakov-ilya
      06.04.2016 16:25

      Возможный workaround: для повторяемых/отложенных задач хранить их id, далее при обновлении системы заменять задачи в БД новой версией.
      Безусловно, проблема есть, но она решаема в разумные сроки.


      1. chumakov-ilya
        06.04.2016 16:59

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


        1. MonkAlex
          06.04.2016 20:02

          Пока не столкнетесь, не оцените. У меня так в продуктиве однажды сдохли задачи, пусть и с другой системой хранений. Конвертировать такие сериализованные значения неудобно и даже не всегда можно, иногда старые записи должны работать по старому.
          Просто имейте это в виду.


    1. odinserj
      08.04.2016 16:07
      +1

      Можно, и даже нужно, использовать интерфейсы и создавать на их основе фоновые задачи: BackgroundJob.Enqueue<IMyInterface>(x => x.SomeMethod()), и делать с реализацией что угодно. Главное интерфейсы не трогать, они любят когда нежно. А все изменения можно вносить через создание нового интерфейса (IMyInterfaceV2 и пр.).

      Сериализация всегда порождает проблемы с обратной совместимостью, тут никуда не денешься. Даже с NuGet-пакетами та же проблема, когда вносятся breaking changes, и ломаются все зависимые пакеты.


  1. mird
    06.04.2016 15:45

    Вообще-то hangfire не такой уж и бесплатный. У него есть Pro модификация в которой сейчас собственно и идет разработка, а бесплатный вариант не развивается.

    Мы можем создать произвольное количество Hangfire-серверов и не думать об их синхронизации — Hangfire гарантирует, что одна задача будет выполнена одним и только одним сервером. Пример реализации — использование sp_getapplock (см. класс SqlServerDistributedLock).

    Не гарантирует. Он (при организации расписания в sql) скрывает задачу из очереди на выполнение на 15 минут (по умолчанию, настраивается) а потом она появляется снова в очереди и может быть подхвачена другим сервером (или даже тем же самым). В результате хангфаер можно использовать только как планировщик задач, а управлять транзакционностью и т.п. приходится все равно снаружи. Вот и получается, что профит относительно quartz.net невелик.

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


    1. dotnetdonik
      06.04.2016 23:13

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


      Можно здесь подробней? Не совсем понятно какой тип задач в рамках ASP.NET сервера можно эффективно решать scheduler'om который привязан к таймеру, а не веб-запросу в качестве тригера.
      К тому же Hangfire не имеет поддержки асинхронности, как я понимаю и на каждую задачу создает блокирующий long running background поток.
      Зачем все это в хост веб приложения встраивать?


      1. mird
        07.04.2016 08:49

        Можно здесь подробней? Не совсем понятно какой тип задач в рамках ASP.NET сервера можно эффективно решать scheduler'om который привязан к таймеру, а не веб-запросу в качестве тригера.

        Если делать отдельный сервис для бекграунд задач, то появляется лишняя единица развертывания. Во многих случаях это не оправданно. А типовые бекграунд задачи — посылать уведомления по расписанию или что-то подобное.
        Зачем все это в хост веб приложения встраивать?

        Я, честно говоря, последнее время считаю, что не нужно эти задачи встраивать в веб приложение и закладываю в архитектуру сразу отдельный сервис для бекграунд задач. Но это архитектуру усложняет и многие предпочитают хостить бекграунд задачи прямо в ASP.net приложении.


        1. dotnetdonik
          07.04.2016 12:25

          Простой планировщик не усложняющий архитектуру как-то слабо вяжется с распределенной системой имеющей persistance независимый от applicaton pool с очередью в базе в виде сериализованных .NET абстракций.


          1. mird
            07.04.2016 13:23

            Ну так отдельный модуль, сепарированный, не требующий поддержки, легко встраиваемый. Если все это правда — он почти не усложняет архитектуру приложения, потому что его внутренняя архитектура никак не влияет на архитектуру приложения.


    1. chumakov-ilya
      07.04.2016 09:07

      Вы хотите сказать, что длительная однократная задача (например, добавленная через BackgroundJob.Enqueue) может быть выполнена многократно? Как это воспроизвести? В моем случае, долговременные (2-4 часа) задачи выполняются строго 1 раз, иначе Hangfire было бы сложно воспринимать всерьез =)


      1. dotnetdonik
        07.04.2016 12:17

        Речь не совсем об этом. Не уверен что использовать в веб-сервере сторонние workers, хорошая идея. Даже несмотря на то, что там нету использования asp.net тред пула.


  1. ShurikEv
    06.04.2016 16:05
    +1

    Вы пишите, что Web-интерфейс у Quartz.NET отсутствует. Не соглашусь. Он есть. Например github.com/guryanovev/CrystalQuartz


    1. chumakov-ilya
      06.04.2016 16:33

      Спасибо, добавил ссылку в статью. Это проект от разработчиков Quartz.NET, или сторонний?


      1. ShurikEv
        06.04.2016 16:54

        Сторонний


      1. ShurikEv
        06.04.2016 16:58
        +1

        Есть еще:
        1. QuartzNetWebConsole — bugsquash.blogspot.ru/2010/06/embeddable-quartznet-web-consoles.html и его исходники github.com/mausch/QuartzNetWebConsole
        2. quartznet-admin — code.google.com/archive/p/quartznet-admin
        Но я в своих проектах использовал только CrystalQuartz


  1. Yustos
    06.04.2016 16:51

    Без БД (хранилища задач) Hangfire не работает и работать не может.

    Может: MemoryStorage.
    События можно создавать и обрабатывать в одном и том же процессе без каких-либо коннектов:
    GlobalConfiguration.Configuration.UseMemoryStorage();
    

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


    1. chumakov-ilya
      06.04.2016 17:00

      «И все-таки это хранилище!»


      1. Yustos
        06.04.2016 17:08

        Безусловно.
        Я просто добавил, что Hangfire можно использовать в очень простом виде (без SQL, Redis и отдельных процессов обработчиков). Даже дашборд работает.


  1. alhel
    06.04.2016 20:04

    chumakov-ilya, а с Microsoft HPC Pack technet.microsoft.com/ru-ru/library/jj899572.aspx можно использовать для таких целей, возможности не сравнивали?


    1. chumakov-ilya
      06.04.2016 20:05

      С этой технологией не знаком, ничего сказать не могу.


  1. KYKYH
    08.04.2016 10:28
    +1

    Пользуюсь этим планировщиком около года, но только для мелких процессов которые нужно выполнять непосредственно на сервере приложения. Для реальных вычислительных процессов пользуюсь Azure Batch с автоматическим масштабированием (вычислительная мощность непосредственно на веб-сервере дорого обходится).

    Одна неприятная особенность hangfire — каждый heartbeat это запись в базу данных. Если ведётся аудит базы, то он разрастётся очень быстро и может стать почти неподъёмным. Ещё трудней, если приложение развёрнуто в десятке разных окружений и соответственно для каждого окружения своя база данных. Поэтому я сделал отдельную базу данных для hangfire и подключил к ней все приложения с уникальными идентификаторами. В руководствах это описывается нечасто, но отделяйте зёрна от плевел, впоследствии будет легче.


  1. dobriykot
    09.04.2016 01:11

    Hangfire умеет работать с распределенными транзакциями, обрабатывать один таск сутки или более? Ожидать сигнала извне на каком-то этапе? Есть условные переходы по шагам тасков? Можно ли вообще делать последовательность или иерархию шагов, которые надо выполнить?


    1. mird
      09.04.2016 20:54

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


      1. dobriykot
        10.04.2016 00:19

        Понятно, спасибо.