Третий очерк из цикла приключений в мире сусликов
Это третья статья серии рассказов о подводных камнях, которые можно встретить в начале разработки на Go. Напоминаю, что в статьях есть примеры кода, будьте с ними аккуратнее - не все из них будут компилироваться и работать, читайте внимательно комментарии, везде указано на какой строке происходит ошибка. Также в блоках кода табуляция везде заменена на пробелы - это сделано намеренно, чтобы статьи выглядели у всех одинаково.
Статьи серии:
Инструменты выполнения фоновых задач в Go
...
Как писал ранее, я всю жизнь занимаюсь разработкой программного обеспечения, в основном в сфере WEB. Успел познакомиться со многими языками программирования и поработать в разных крупных компаниях. Сейчас руковожу разработкой в компании NUT.Tech, где мы делаем классные и интересные вещи. В данный момент бо́льшая часть разработки в отделе построена вокруг Go, поэтому о нём я и продолжаю рассказывать.
Данная статья будет немного выбиваться из привычного формата. Тут не будет никаких подводных камней. Но здесь я постараюсь помочь с выбором инструмента для фонового запуска кода. Иногда замечаю, что у разработчиков, пришедших из других языков, бывают проблемы с пониманием, какие лучше использовать для этого инструменты в Go. Тут я постараюсь перечислить основные подходы и библиотеки для помощи с выбором.
Статья рассчитана на разработчиков любого уровня, пришедших из других языков. Тут не будет подробного руководства по каждому из инструментов, но я постараюсь помочь получить общее представление и понять основные свойства каждого. Этот текст призван помочь проще и быстрее вникнуть в основные методы фонового запуска бизнес-логики и выбрать наиболее подходящий для своей задачи.
Для удобства в каждом пункте есть сводная информация и вывод, по которым можно примерно понять общую информацию об инструменте. А в конце будет общая таблица с основными свойствами для перечисленных в статье инструментов.
Расшифровка понятий
Сложность - моё оценочное суждение о сложности применения инструмента.
Надёжность - гарантии инструмента по выполнению фоновых операций.
Отказоустойчивость - устойчивость системы к выходу из строя отдельных её элементов.
Распределённый запуск - возможность запустить систему на нескольких хостах.
Привязка к ОС - система работает только на определенных ОС.
Персистентность - возможность восстановить запущенные/запланированные задачи после падения системы.
Автоматические повторы - повторы задач в случае ошибки их выполнения.
Привязка ко времени - возможность настроить запуск задач в определённые часы, дни, недели.
Внешние зависимости - зависимость от сторонних программ или внешних хранилищ данных.
Cron
Краткая информация
Сложность: Низкая
Надёжность: Низкая
Отказоустойчивость: Низкая
Распределённый запуск: Нет
Привязка к ОС: Да
Персистентность: Нет
Автоматические повторы: Нет
Привязка ко времени: Да
Внешние зависимости: Да (сам cron)
Описание
Начнём с классического и привычного многим подхода - запуск задач через внешние планировщики задач. Для простоты возьмём классический cron, но общая логика не изменится и для systemd и для прочих отдельных планировщиков/менеджеров процессов.
Использование крона очень распространено, многие с ним встречались или наверняка ещё встретятся. Такой метод запуска задач простой, и обычно мало у кого возникают проблемы с тем, чтобы разобраться с ним.
Из минусов данного способа можно отметить, что он обычно не предполагает какой-либо надёжности выполнения. Если не городить велосипедов, понять, что что-то пошло не так, можно будет по логам и пойти перезапустить всё руками. Есть, конечно, более продвинутые системы, например rundeck, которые позволяют централизованно управлять фоновыми задачами, но общий принцип для приложения будет аналогичным.
Также, воспользовавшись этим инструментом, необходимо понимать, что появляется зависимость от внешней сторонней утилиты, которой может не быть на любой системе, где будет запускаться приложение.
Главное, что нам понадобится - это возможность вызывать исполняемый файл таким образом, чтобы внутри запустился необходимый для задачи код/функция. Для этого может понадобиться сделать консольный интерфейс (cli), чтобы запускать бинарный файл с некоторыми аргументами и вызывать то, что нужно. Для простого построения cli приложения я рекомендую urfave/cli, наверное, сейчас самый распространённый и удобный фреймворк для построения интерфейсов командной строки.
Но можно обойтись и без cli, просто собрав отдельный бинарник, в котором при запуске сразу будет выполнен необходимый код. Для упрощения примера этим способом я и воспользуюсь.
Пример
-
Пишем наш код в
main.go
package main func task() { println("Hello cron!") } func main() { task() }
-
Компилируем
go build -o app main.go
-
Проверяем, что файл запускается и делает то, что нам нужно
./app Hello cron!
Настраиваем cron (инструкция тут) (также есть удобный инструмент для правильного конфигурирования времени).
Вывод
Крон лучше всего подходит для фоновых периодических задач, строгая выполняемость которых не нужна. Например, задачи фоновой чистки ненужных данных, актуализация кешей/индексов. Система сама не будет выполнять автоматические перезапуски упавших задач, и, если упадёт нода, на которой живёт планировщик, то задачи тоже не будут выполняться.
Gocron
Краткая информация
Сложность: Низкая
Надёжность: Низкая
Отказоустойчивость: Низкая
Распределённый запуск: Нет
Привязка к ОС: Нет
Персистентность: Нет
Автоматические повторы: Нет
Привязка ко времени: Да
Внешние зависимости: Нет
Описание
Gocron - это библиотека, которая может заменить cron в Go. Удобство заключается в том, что сам cron не нужен, как внешняя утилита в системе, нет необходимости что-то отдельно компилировать или писать cli. Библиотека используется внутри кода приложения и просто в отдельных потоках (горутинах) вызывает необходимые функции с нужной периодичностью.
Достаточно удобно для небольшого приложения, но имеет такие же минусы, что и классический подход с cron, кроме привязки к операционной системе. В отличие от cron, gocron выполняется вместе с кодом и будет выполняться везде, где будет выполняться приложение.
Пример
package main
import "github.com/go-co-op/gocron"
func task() {
println("Hello gocron!")
}
func main() {
// инициализируем объект планировщика
s := gocron.NewScheduler(time.UTC)
// добавляем одну задачу на каждую минуту
s.Cron("* * * * *").Do(task)
// запускаем планировщик с блокировкой текущего потока
s.StartBlocking()
}
Вывод
Gocron очень похож по принципу работы на сам cron. Но является более удобным, так как убирает зависимость от окружения и необходимость создания отдельного вызываемого файла. Если есть большое желание сделать что-то через классический cron, советую сначала посмотреть на gocron. Главное, нельзя забывать о том, что gocron будет работать на каждом хосте, на котором развёрнуто приложение. Если это нежелательно, следует выносить gocron в отдельный сервис и запускать в единственном экземпляре.
Goroutines
Краткая информация
Сложность: Высокая
Надёжность: Низкая
Отказоустойчивость: Низкая
Распределённый запуск: Нет
Привязка к ОС: Нет
Персистентность: Нет
Автоматические повторы: Нет
Привязка ко времени: Нет
Внешние зависимости: Нет
Описание
Горутины - это краеугольный камень в мире Go. Это очень мощный и элегантный инструмент запуска асинхронного кода. На самом деле предыдущий рассмотренный инструмент gocron под капотом полностью построен именно на горутинах.
Используя горутины, можно создавать очень гибкие системы фонового выполнения, ограниченные только фантазией и одной машиной выполнения. Тут я расскажу лишь об одном из способов их использования и очень советую почитать про них ещё отдельно, например, на сайте языка go.dev/doc/effective_go#goroutines.
Первый раз столкнувшись с этим крутым инструментом, вы можете поддаться соблазну сделать просто так:
go task()
Это будет работать, но при таком подходе существуют некоторые проблемы:
Количество горутин никак не будет контролироваться. И может произойти так называемая утечка горутин. Это ситуация, когда запускаемые функции в фоне по какой-то причине стали залипать (например, если они дёргают какую-то API и подолгу ждут ответа, а основной поток продолжает запускать всё новые и новые горутины). Хоть в Go и может быть по-настоящему много горутин, каждая отнимает у сервера для себя ресурсы.
В тестах вы никак не сможете проверить результат работы функции, которая запускает такую горутину. Конечно, можно будет написать юнит тест, но иногда нужно проверить более сложные сценарии.
Запущенную таким образом горутину будет невозможно остановить, только если вместе с основным потоком.
Если при исполнении горутины произойдёт паника, упадёт и основной поток.
Для решения этих проблем есть разные инструменты, одни из них это: каналы и пакет context. Каналы нужны для обмена информацией между разными потоками, а контекст для остановки горутины, когда это необходимо, например, по времени или вызовом специальной функции завершения.
Частым (но далеко не единственным) шаблоном использования горутин, в контексте запуска фоновых задач, является запуск в отдельной функции-горутине бесконечного цикла. В этом цикле выполняется необходимая задача, и поток засыпает на некоторое время, чтобы после проснуться и сделать ещё одну итерацию цикла.
Данный шаблон лучше всего подходит для задач, которые выполнять нужно бесконечно и часто - каждую секунду или чаще.
Пример будет не очень жизненным и не самым красивым, но он такой для понимания, как этот способ вообще можно применять.
Пример
package main
import (
"context"
"time"
)
func task(ctx context.Context) {
// запускаем бесконечный цикл
for {
select {
// проверяем не завершён ли ещё контекст и выходим, если завершён
case <-ctx.Done():
return
// выполняем нужный нам код
default:
println("Hello gophers!")
}
// делаем паузу перед следующей итерацией
time.Sleep(time.Minute)
}
}
func main() {
// создаём контекст с функцией завершения
ctx, cancel := context.WithCancel(context.Background())
// запускаем нашу горутину
go task(ctx)
// делаем паузу, чтобы дать горутине поработать
time.Sleep(10 * time.Minute)
// завершаем контекст, чтобы завершить горутину
cancel()
}
Вывод
Часто разработчики Go при вопросе, как запустить что-то в фоне, скажут вам "Горутины!". И это правильно, это настоящий go way, очень мощный, гибкий и удобный инструмент. На мой личный взгляд, горутины - одно из основных преимуществ языка.
Это очень универсальный и нативный способ работы с фоновым кодом и не только, на горутинах, каналах и контекстах обычно строится вся асинхронность в го. И для понимания и применения этой асинхронности нужно обязательно понимать, как работать с горутинами.
Pond
Краткая информация
Сложность: Средняя
Надёжность: Низкая
Отказоустойчивость: Низкая
Распределённый запуск: Нет
Привязка к ОС: Нет
Персистентность: Нет
Автоматические повторы: Нет
Привязка ко времени: Нет
Внешние зависимости: Нет
Описание
Следующий инструмент запуска фоновых функций - это ещё один способ решить проблемы контроля запускаемых горутин, перечисленные в предыдущем разделе, но по-своему. Фактически он является обёрткой над горутинами, занимающейся их контролем и защищающей от утечек.
Суть данного подхода заключается в том, что мы не запускаем горутину самостоятельно, а вместо этого передаём ответственность на запуск своей функции-таски некоторому менеджеру (пулу задач). Пул выполнит нашу задачу, когда будет к этому готов.
Его хорошо использовать, когда нужно просто выполнить что-то в фоне, а результат выполнения положить, например, в базу или просто в логи.
Библиотек, реализующих такие пулы, очень много. Если, например, заглянуть в репозиторий awesome-go, можно найти их огромное количество. Я покажу тут одну из них, которая показалась мне очень простой в использовании и с достаточным функционалом - pond.
Пример
package main
import "github.com/alitto/pond"
func task() {
println("Hello pond!")
}
func main() {
// Создаём объект пула
pool := pond.New(7, 42)
// Расскажу тут немного о магических числах 7 и 42.
// Конкретно в этих числах нет особого смысла, они просто взяты для примера.
// 7 - это количество тасок, которые могут работать одновременно.
// А 42 - это размер очереди, в которую становятся задачи, ожидающие выполнения.
// Если очередь уже заполнена, а мы хотим туда ещё что-то докинуть функцией
// Submit, она заблокирует поток, пока в очереди не появится место.
// Это всё нужно, чтобы предотвратить утечку горутин, рассмотренную выше.
// Отправляем в пул функцию, которая будет запущена в фоне
pool.Submit(task)
// Закрываем возможность добавлять новые задачи в пул и ждём завершения поставленных
pool.StopAndWait()
}
Вывод
Использование менеджера-пула фоновых задач - удобная альтернатива запуску фоновых задач в качестве горутин. Такие библиотеки как pond позволяют сохранить контроль над функциями, выполняемыми в фоне, в том случае, если нет необходимости их как-то контролировать вручную через каналы или контекст.
Bell
Краткая информация
Сложность: Низкая
Надёжность: Низкая
Отказоустойчивость: Низкая
Распределённый запуск: Нет
Привязка к ОС: Нет
Персистентность: Нет
Автоматические повторы: Нет
Привязка ко времени: Нет
Внешние зависимости: Нет
Описание
Рассказывая про фоновое выполнение, не могу не поделиться ещё одной библиотекой - bell, которая призвана в первую очередь упростить запуск фонового кода при наступлении определённых событий.
Данный пакет также полностью построен на горутинах, не имеет каких-либо специфических зависимостей от окружения выполнения и использовать его очень просто.
Пример
package main
import "github.com/nuttech/bell"
func handler(bell.Message) {
println("Hello bell!")
}
func main() {
// определяем имя события, которое необходимо отслеживать
eventName := "event-hello"
// привязываем к имени события функцию-обработчик
bell.Listen(eventName, handler)
// оповещаем bell о том, что произошло событие и необходимо выполнить его обработчик
_ = bell.Ring(eventName, nil)
}
Вывод
У пакета очень простое предназначение - запускать фоновый код, когда в основном потоке кто-то сгенерировал определённое событие. Аналогично всем предыдущим, данный инструмент не гарантирует выполнение события при сбоях и не имеет автоматических повторов. Подходит в первую очередь для простых задач, строгое выполнение которых не нужно. Например, для записи информации в лог при авторизации пользователя.
Machinery
Краткая информация
Сложность: Высокая
Надёжность: Высокая
Отказоустойчивость: Высокая
Распределённый запуск: Да
Привязка к ОС: Нет
Персистентность: Да
Автоматические повторы: Да
Привязка ко времени: Да (при необходимости)
Внешние зависимости: Да (брокер сообщений)
Описание
Крайним инструментом в этой статье будет machinery. Этот инструмент очень отличается от всего рассмотренного ранее.
Если кто-то пришёл в мир Go из мира Python, он узнает в машинерии известный питонистам инструмент celery. Машинерия проще по функционалу, но, скорее всего, это не будет помехой, большинство к чему привыкли в celery есть и в машинерии.
Если уже знакомы с архитектурой обмена задачами через брокера, можете пролистывать сразу до следующего раздела, потому что тут я немного расскажу про сам подход используемый в машинерии и подобных инструментах.
Работая с машинерией и вообще подобной архитектурой, нужно строго различать три понятия: планировщик (producer) - тот, кто ставит новые задачи, брокер (broker) - общее хранилище информации о поставленных задачах (это может быть как простая база данных, так и специализированные брокеры вроде rabbitmq), воркер (worker) - тот, кто выполняет поставленные задачи.
Главное отличие данного подхода - это наличие брокера задач. Машинерия поддерживает разных брокеров, от выбора брокера и его настроек будет зависеть надёжность - то есть будут ли все ваши поставленные задачи в итоге выполнены.
Машинерия, в отличие от всех ранее рассмотренных инструментов, не будет вызывать код напрямую. Вместо этого она будет сохранять задачу в брокер сообщений, а подключенные воркеры возьмут её в работу, когда будут готовы.
Это позволяет убрать жёсткую связь между кодом, который ставит задачи, и кодом, который их выполняет. Появляется горизонтальная масштабируемость: можно развернуть много воркеров на разных хостах, собрать кластер из брокеров и можно сделать даже несколько планировщиков, если это позволяет бизнес логика.
Что касается брокеров, они бывают разные. Машинерия поддерживает как чисто облачные решения, так и брокеры, которые можно развернуть самостоятельно. Я не буду подробно рассказывать про каждый брокер - это выходит за рамки статьи. Дам только совет: если не знаете какой брокер выбрать и не используете облака, у вас остаётся выбор между редисом и кроликом (rabbitmq). Редис проще в поднятии и настройке, но в случае сбоев не гарантирует доставку задач до воркеров. А у кролика намного больше гарантий, их можно даже настраивать, но он сложнее в поднятии и эксплуатации.
Пример
РАЗЫСКИВАЮТСЯ три суслика и белка, за похищение примера.
К сожалению, в отличие от предыдущих инструментов, на машинерию сложно написать короткий пример. Но в официальном репозитории есть несколько отличных примеров кода, их достаточно для общего понимания, как работать с этим инструментом.
Вывод
Машинерия - мощный эффективный инструмент для надёжного распределённого выполнения фоновых задач. Если вы пришли из питона и скучаете по celery, обязательно посмотрите на machinery. Но только если вам нужно гарантированное или распределённое выполнение. Не стоит тащить в проект большие сложные фреймворки, если с вашей задачей могут прекрасно справится нативные инструменты в Go, например, горутины.
Итоговое сравнение
cron |
gocron |
pond |
goroutines |
bell |
machinery |
|
---|---|---|---|---|---|---|
Сложность |
Низкая |
Низкая |
Средняя |
Высокая |
Низкая |
Высокая |
Надёжность |
Низкая |
Низкая |
Низкая |
Низкая |
Низкая |
Высокая |
Отказоустойчивость |
Низкая |
Низкая |
Низкая |
Низкая |
Низкая |
Высокая |
Распределённый запуск |
Нет |
Нет |
Нет |
Нет |
Нет |
Да |
Персистентность |
Нет |
Нет |
Нет |
Нет |
Нет |
Да |
Привязка к ОС |
Да |
Нет |
Нет |
Нет |
Нет |
Нет |
Автоматические повторы |
Нет |
Нет |
Нет |
Нет |
Нет |
Да |
Привязка ко времени |
Да |
Да |
Нет |
Нет |
Нет |
Да |
Внешние зависимости |
Да |
Нет |
Нет |
Нет |
Нет |
Да |
Как же выбрать?
Мой совет такой:
Если вам нужен запуск задач на разных хостах, вам нужна Machinery или что-то подобное.
Если вам нужно что-то максимально простое для запуска периодических задач, берите gocron.
Если нужно что-то делать в фоне, при событиях в основном потоке, скорее всего хорошо подойдёт bell или pond.
Для всего остального есть горутины :) Как минимум всегда стоит попробовать с них начать, если остальные варианты не подошли. Более гибкий инструмент в мире Go будет сложно найти.
Комментарии (5)
hogstaberg
21.04.2022 15:55+1Сравнивать внешние системные шедулеры, какие-то либы и встроенный в язык механизм конкуррентного выполнения это сильно, не могу не снять шляпу.
lowitea Автор
21.04.2022 18:06-1Благодарю.
Понимаю о чём Вы. Постараюсь объяснить свою идею этого сравнения. В статье я старался не сравнивать абстрактный механизм конкурентности в языке со всем остальным, я старался сравнить скорее плюсы и минусы применения конкретных инструментов к поставленной задаче.
GoodGod
21.04.2022 19:18+1Cron задачи и брокер сообщений успешно дополняют друг друга. Например у нас подключена внешняя CRM в облаке (нет доступов к исходникам) и мы каждую минуту проверяем не пришли ли туда новые заказы (CRM не умеет ставить задачи в очередь). При этом свои заказы в CRM экспортируем через задачи в брокере.
lowitea Автор
21.04.2022 19:29На самом деле, тут все инструменты могут прекрасно дополнять друг друга. Никто не запрещает в одном проекте использовать их все вместе. Но если брать конкретные задачи (как например у Вас, если я правильно понял, экспорт и импорт), то какие-то инструменты будут для одной задачи подходить лучше, какие-то хуже.
Спасибо за ваше дополнение и пример)
saipr
Интересное суждение.