Недавно отвечал на вопрос почему аннотации @Scheduled
и @Async
иногда используют вместе, данный вопрос попался человеку на собеседовании.
Многие начинающие разработчики на java не до конца понимают в каких потоках происходит выполнение программы в таком случае.
В данном материале постараюсь объяснить зачем аннотации @Scheduled
и @Async
ставят вместе, какая проблема при этом решается, в каких потоках происходит работа программы и как делать правильно.
Подписывайтесь на мой блог в телеграм, где я раньше всего публикую материалы.
Проблема
Допустим вам необходимо выполнять какое-то действие раз в минуту, например ходить в базу и смотреть есть ли там записи по которым не выполнилась отправка во внешнюю систему и если такие есть то выполнить отправку повторно (доотправка).
И еще одно действие, которое надо выполнять раз в день, например сформировать аналитический отчет, и формироваться он будет очень долго, целых 5 минут.
Действия которые нужно выполнять по времени я буду называть джобами (от слова job).
Это вполне реальная ситуация на коммерческом проекте.
И мы начали замечать, что во время формирования отчета (а мы помним что это длится 5 минут), первый джоб доотправки не выполняется, почему так присходит?
Представим эту ситуацию в коде.
Пометим два метода аннотацией @Scheduled
:
первый будет выполняться раз в секунду и выводить в лог текущее время и имя потока;
второй будет запускаться раз в 5 секунд и выводить в лог текущее время, имя потока и вдобавок зависать на 3 секунды, имитируя тяжеловесную операцию.
@Scheduled(cron = "*/1 * * * * *")
public void everySecond() {
log.info("EverySecond. Время: {}. Поток: {}",
LocalTime.now(), Thread.currentThread().getName());
}
@Scheduled(cron = "*/5 * * * * *")
public void everyFiveSeconds() throws InterruptedException {
log.info("EveryFiveSeconds. Время: {}. Поток: {}",
LocalTime.now(), Thread.currentThread().getName());
Thread.sleep(3000);
}
Вывод в консоль:
EverySecond. Время: 10:56:12.003384. Поток: scheduling-1
EverySecond. Время: 10:56:13.002846. Поток: scheduling-1
EverySecond. Время: 10:56:14.002538. Поток: scheduling-1
EverySecond. Время: 10:56:15.003404. Поток: scheduling-1
EveryFiveSeconds. Время: 10:56:15.004427. Поток: scheduling-1
EverySecond. Время: 10:56:18.005196. Поток: scheduling-1
EverySecond. Время: 10:56:19.002534. Поток: scheduling-1
EveryFiveSeconds. Время: 10:56:20.002549. Поток: scheduling-1
EverySecond. Время: 10:56:23.003966. Поток: scheduling-1
EverySecond. Время: 10:56:24.002588. Поток: scheduling-1
EverySecond. Время: 10:56:25.002563. Поток: scheduling-1
EveryFiveSeconds. Время: 10:56:25.003424. Поток: scheduling-1
Разберем что мы увидели в логе.
Оба метода выполняются в одном и том же потоке.
При этом пока 3 секунды выполняется тяжеловсный джоб, второй джоб не выполняется вовсе, потому что единственный поток занят.
На коммерческом проекте это может стать проблемой, и у вас уйдет много времени чтобы разобраться почему ваш @Scheduled
метод не отрабатывает в заданное время.
Решение проблемы
Решение - передать выполнение джобов выделенному пулу потоков.
И есть несколько вариантов передачи выполнения пулу:
настроить аннотацию
@Scheduled
чтобы метод помеченный данной аннотацией всегда выполнялся в отдельном потоке;явно передавать выполнение кода в отдельный поток, например с помощью аннотации
@Async
;
Рассмотрим оба варианта.
Настроить пул потоков для аннотации @Scheduled
Если обратиться к javadoc аннотации @EnableScheduling
то найдем пример конфигурации:
@Configuration
public class SchedulerConfig implements SchedulingConfigurer {
@Override
public void configureTasks(ScheduledTaskRegistrar taskRegistrar) {
taskRegistrar.setScheduler(taskExecutor());
}
@Bean(destroyMethod = "shutdown")
public Executor taskExecutor() {
return Executors.newScheduledThreadPool(100);
}
}
Добавим её в наше приложение и перезапустим.
Вывод в лог:
EverySecond. Время: 11:41:00.003843. Поток: pool-2-thread-2
EverySecond. Время: 11:41:01.001589. Поток: pool-2-thread-3
EverySecond. Время: 11:41:02.000566. Поток: pool-2-thread-2
EverySecond. Время: 11:41:03.002542. Поток: pool-2-thread-4
EverySecond. Время: 11:41:04.002607. Поток: pool-2-thread-3
EveryFiveSeconds. Время: 11:41:05.005270. Поток: pool-2-thread-5
EverySecond. Время: 11:41:05.005244. Поток: pool-2-thread-2
EverySecond. Время: 11:41:06.003887. Поток: pool-2-thread-4
EverySecond. Время: 11:41:07.003836. Поток: pool-2-thread-7
EverySecond. Время: 11:41:08.001952. Поток: pool-2-thread-1
EverySecond. Время: 11:41:09.005323. Поток: pool-2-thread-8
EverySecond. Время: 11:41:10.001659. Поток: pool-2-thread-9
EveryFiveSeconds. Время: 11:41:10.001661. Поток: pool-2-thread-3
Видим, что запуск каждого джоба выполняется в отдельном потоке, при этом тяжеловесный джоб не мешает запуску легковесного.
Проблема решена.
Решение через аннотацию @Async
Поставим аннотацию @Async
над @Scheduled
методами:
@Async
@Scheduled(cron = "*/1 * * * * *")
public void everySecond() {
log.info("EverySecond. Время: {}. Поток: {}",
LocalTime.now(), Thread.currentThread().getName());
}
@Async
@Scheduled(cron = "*/5 * * * * *")
public void everyFiveSeconds() throws InterruptedException {
log.info("EveryFiveSeconds. Время: {}. Поток: {}",
LocalTime.now(), Thread.currentThread().getName());
Thread.sleep(3000);
}
Вывод в лог будет аналогичен предыдущему примеру, т.е. джобы будут выполняться в отдельных потоках и не мешать друг другу.
На что стоит обратить внимание
При использовании аннотации @Async
по умолчанию Spring создает Executor, у которого нет предела по количеству создаваемых потоков.
Тем самым, если ваш джоб будет запускаться чаще чем завершаться, то возникнет утечка памяти из-за постоянно создаваемых потоков.
Решить проблему можно с помощью настройки Executor для @Async
, пример настройки можно посмотреть в javadoc аннотации @EnableAsync
.
Правильная настройка Executor'ов это тема для отдельной статьи.
Какой вариант выбрать
Если у вас один джоб на всё приложение и он выполняется быстрее чем запускается, то можно ничего не делать и пользоваться аннотацией @Scheduled
в том виде как она работает из коробки.
Если у вас несколько джобов то лучше всегда настраивать явный планировщик задач, и для удобства пользоваться аннотацией @Async
.
Стоит заметить, что при использовании @Async
лучше всегда указывать Executor явно, пример:
@Bean
public Executor jobExecutor() {
return Executors.newCachedThreadPool();
}
@Async("jobExecutor")
@Scheduled(cron = "*/1 * * * * *")
public void everySecond() {
...
}
тогда вы гарантируете, что потоки Executor'а не будут заняты другими задачами кроме ваших джобов.
Комментарии (4)
breninsul
31.10.2023 21:00Пришел к выводу, что стандартный Timer использовать в большинстве случаев проще + не возникает эта не очевидная проблема с выполнением расписания в одном потоке/пуле.
hddn
31.10.2023 21:00Как-то оно всё неочевидно и выглядит не очень надёжным. Кто его знает - треды кончатся у приложения и джобы перестанут отрабатывать, а мы об этом даже не узнаем (только из логов разве что, т.е. по их отсутствию).
Для MVP-приложения сойдёт, но для прода, пожалуй, продолжу делать на Quartz. Как-то понадёжнее ИМХО.
n7nexus
Лучше сделать бин с Executor на несколько потоков, тогда скедулер будет использовать его и будет сохраняться интервал между завершением таски и началом новой.
На сколько помню, при использовании Async, интервал получается между началом старой и началом новой.
breninsul
если использовать 1 пул потоков он рано или поздно забьется и приведет к очень неприятному поведению, когда таски не выполнятся/выполнятся поздно. Обычно создаю пул из 1 потока шедулера. Если используются шедулеры.
Можно и как в статье, регистрируя пулы и прописывая в @Async, но это много бойлерплейта. Если единственная задача пула обработать задачу по расписанию - нет смысла плодить 5 строк в разных классах вместо 1й