Недавно меня озадачили хлипкие тесты в Мятеже.
Проблема
Время от времени у нас возникали сбои сборки в исполнителях GitHub Action, и, конечно же, мы не могли воспроизвести их локально. Даже повторение теста тысячу раз не воспроизведет неудачу, наблюдаемую у бегунов. И, конечно же, не было большого детерминизма, при котором тест мог провалиться.
Тем не менее, журналы намекали на то, что задачи были отклонены завершенными исполнителями Java, поэтому я начал копать. Я проверил использование исполнителей в тестах, но, за исключением нескольких тривиальных исправлений, все исполнители использовались так, как должны быть, например:
// Get an executor
var executor = Executors.newFixedThreadPool(4);
// Do stuff
doThingsWith(executor);
// Shut it down
executor.shutdownNow();
Затем я начал отслеживать вызовы shutdown()
и shutdownNow()
, чтобы посмотреть, нет ли у нас где-нибудь кода, который отключил бы исполнителя. Ничего в тестах, но в конце концов я нашел вызов shutdown
в этом классе из JDK:
static class FinalizableDelegatedExecutorService
extends DelegatedExecutorService {
FinalizableDelegatedExecutorService(ExecutorService executor) {
super(executor);
}
protected void finalize() {
super.shutdown();
}
}
Угадай, что?
Этот класс используется… Executors.newSingleThreadExecutor()
!
public static ExecutorService newSingleThreadExecutor() {
return new FinalizableDelegatedExecutorService
(new ThreadPoolExecutor(1, 1,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<Runnable>()));
}
Таким образом, если вы создаете исполняющую программу с помощью newSingleThreadExecutor()
, то фактическая исполняющая часть помещается в класс, единственной целью которого является вызов shutdown()
в финализаторе.
См. первый абзац документации метода (ныне устаревшего!) Object.finalize()
:
Вызывается сборщиком мусора для объекта, когда сборщик мусора определяет, что больше нет ссылок на объект. Подкласс переопределяет метод finalize для удаления системных ресурсов или выполнения другой очистки.
Следует ожидать, что в ограниченной среде, такой как среда выполнения CI/CD, сборщик мусора должен запускаться чаще, чем на ноутбуке с 32 ГБ ОЗУ. В зависимости от того, как написан ваш код, вы можете столкнуться с тем, что исполнитель будет финализирован до того, как получит все задачи, и они будут отклонены.
Исправление и немного осторожности
В случае тестов Mutiny ненадёжность значительно уменьшилась за счёт замены вызовов:
var executor = Executors.newSingleThreadExecutor();
с:
var executor = Executors.newFixedThreadPool(1);
Действительно, newSingleThreadExecutor()
— единственный метод, который оборачивает исполнителей в FinalizableDelegatedExecutorService
.
Теперь вы должны сделать то же самое с вашей кодовой базой и отказаться от newSingleThreadExecutor()
?
Я так не думаю!
- Убедитесь, что вы правильно управляете исполнителями и особенно не забываете их закрывать. Это особенно важно в наборах тестов, потому что вы можете создать их множество.
- В качестве общей практики для нового кода я считаю хорошей идеей вызывать
Executors.newFixedThreadPool(1)
, даже если для этой цели есть метод с правильным именем. В какой-то момент в будущих выпусках Java финализаторы исчезнут, иExecutors.newSingleThreadExecutor()
будет иметь такое же поведение во время выполнения, но тем временем вы сможете избежать некоторых потенциальных головных болей. - Если вы или ваши библиотеки используете
Executors.newSingleThreadExecutor()
и видите странные отклонения задач, есть большая вероятность, что вы столкнулись с той же проблемой!
Первоначально опубликовано на https://julien.ponge.org 1 декабря 2021 г.