Недавно меня озадачили хлипкие тесты в Мятеже.

Проблема

Время от времени у нас возникали сбои сборки в исполнителях 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()?

Я так не думаю!

  1. Убедитесь, что вы правильно управляете исполнителями и особенно не забываете их закрывать. Это особенно важно в наборах тестов, потому что вы можете создать их множество.
  2. В качестве общей практики для нового кода я считаю хорошей идеей вызывать Executors.newFixedThreadPool(1), даже если для этой цели есть метод с правильным именем. В какой-то момент в будущих выпусках Java финализаторы исчезнут, и Executors.newSingleThreadExecutor() будет иметь такое же поведение во время выполнения, но тем временем вы сможете избежать некоторых потенциальных головных болей.
  3. Если вы или ваши библиотеки используете Executors.newSingleThreadExecutor() и видите странные отклонения задач, есть большая вероятность, что вы столкнулись с той же проблемой!

Первоначально опубликовано на https://julien.ponge.org 1 декабря 2021 г.