Как да изключите многонишкови приложения с Spring Boot

Наскоро имах това прозрение за това как една нишка е прекъсната в Java. Никога не съм го чел и просто получих частична информация от stackoverflow и др. когато търсите как да се справите с ужасяващото InterruptedException да бъде изхвърлено от Thread.sleep().

Как се прекратяват нишките на Java

В Java една нишка прекратява, когато методът от най-високо ниво, т.е. този, който имплементира Runnable.run() или Callable.call() естествено излезе.

Има Thread.stop(), но той просто убива нишката и може да остави приложението ви в непоследователно състояние. С други думи, вие искате да го извикате само когато така или иначе искате да убиете приложението си и това може да стане по-лесно само с извикване на System.exit().

Има случаи, когато трябва да прекратите дадена нишка по-рано, отколкото тя е приключила естествено. Един пример е, когато вашата нишка е проектирана да работи вечно, защото обработва някакъв вид събития, анкетира базата данни или наблюдава файлове. Например:

public void run() {
    while(true) {
        Event e = getEvent();

        processEvent(e);
    }
}

Друг случай са дълготрайни нишки, при които просто не можете да си позволите да чакате, докато приключат. На работния плот потребителят може да е щракнал върху бутон „изход“ или „отказ“ и очаква операцията да приключи в разумен срок. От страна на сървъра, операционните системи предоставят на процесите само ограничено време за грациозно изключване, преди да ги убият принудително.

Прекъсване на нишки

Начинът на Java за спиране на нишките е методът Thread.interrupt(). Имайте предвид, че JVM по никакъв начин не прекъсва автоматично нишките. Трябва сами да внедрите извикването Thread.interrupt() в зависимост от вашия случай на употреба. Обикновено правите това в „кука за изключване“. Spring Boot идва с кука за изключване по подразбиране, която търси методи, отбелязани с @PreDestroy, където можете да поставите логиката за прекъсване.

Сам по себе си Thread.interrupt() не прави много, защото Java няма представа как да принуди една нишка да прекрати. Отговорност на разработчика е да провери със статичния метод Thread.interrupted() дали текущата нишка е била прекъсната. Ако този метод върне true, трябва да прекратите нишката възможно най-бързо и да изпълните само минимално необходимия код за почистване.

За примера с безкраен цикъл ще работи така:

public void run() {
    while(true) {
        if (Thread.interrupted()) {
            break;
        }

        Event e = getEvent();

        processEvent(e);
    }

    doSomeCleanUp();
}

Thread.interrupted() нулира състоянието на нишката, което означава, че вече не е в прекъснато състояние. Следователно код като по-долу няма да работи:

public void run() {
    if (!Thread.interrupted()) {
        processEventPart1(e);
    }

    if (!Thread.interrupted()) {
        processEventPart2(e);
    }
    doSomeCleanUp();
}

Ако първата проверка върне true, втората ще върне false и processEventPart2() се изпълнява. Ето защо обикновено се препоръчва текущата нишка да се прекъсне отново след извикване на Thread.interrupted():

if (!Thread.interrupted()) {
 	processEventPart1(e);
    Thread.currentThread().interrupt();
 }

Това гарантира, че следващите проверки все още виждат прекъсната нишка.

Проблемът с Thread.interrupt()

Thread.interrupt() прекъсва не само вашия код, където го очаквате, но и всичко останало, което използва Thread.interrupted() или методи като Thread.sleep() или Thread.wait(). В моите тестове както драйверите на Oracle JDBC, така и на MongoDB реагираха гневно на прекъсване с поредица от типове изключения „случи се нещо ужасно“. Нищо чудно, защото от гледна точка на драйвер на база данни прекъсванията са подобни на издърпване на мрежовия кабел от компютъра.

Още по-лошо, тъй като Thread.interrupt() попада на прекъсната нишка е произволно, може да видите или да не видите изключенията. Виждах ги редовно, когато използвах бази данни в центрове за данни в половината страна. Ако тествате с локални бази данни, шансовете са, че всичко работи перфектно през повечето време. Поради тази причина Oracle изрично казва: „Не използвайте метода Thread.interrupt“ с техния JDBC драйвер.

В крайна сметка, използвайте Thread.interrupt() само ако имате пълен контрол върху всички части на кода, което е много рядко в днешно време.

По-добре се занимавайте сами

Когато не можете да използвате Thread.interrupt(), трябва да приложите собствената си логика. Ето пример за спиране на безкраен цикъл:

public class Process extends Runnable {
    private AtomicBoolean active = new AtomicBoolean(true);

    public void run() {
        while (active.get()) {
            doProcessing();
        }
    }

    public void stop() {
        active.set(false);
    }
}

AtomicBoolean е JDK клас, който имплементира безопасна за нишка булева променлива. Методът stop() просто го настройва на false, което съществува в цикъла при следващото изпълнение на while. Губите способността да спирате отделни нишки, но нямате нужда от това за грациозно изключване.

Спрете да спите и да чакате

Без Thread.interrupt() sleep() и wait() ще продължат да блокират до указаното време за изчакване. Има три начина да се справите с това:

  • Ако времето за сън или чакане е достатъчно кратко, можете просто да ги изчакате. Например, ако имате 30 секунди, за да затворите процеса си, сън за няколко секунди няма голямо значение.
  • Ако времето ви за сън е много по-дълго, тогава планирането може да е по-доброто решение. Например, ако искате да използвате API на всеки пет минути, можете да планирате нишката да се изпълнява на всеки пет минути, вместо да спи.
  • Можете да разделите вашите дълги заспивания на цикъл от по-малки заспивания, като между тях проверявате флага си за спиране на нишка.

Грациозно изключване в Spring Boot

Едно от многото предимства при използването на Spring Boot е, че неговите компоненти са конфигурирани за елегантно изключване. Ако обаче стартирате свои собствени нишки, трябва да се погрижите за тях и по време на изключване. Добър начин е да използвате ThreadPoolTaskExceutor, дори ако не се нуждаете от функционалността за обединяване на нишки. Просто се уверете, че сте оразмерили пула правилно, за да избегнете нишките, които се натрупват на опашка или загуба на ресурси.

Spring Boot извиква shutdown() на ThreadPooolTaskExceutor, когато приложението се прекрати. По подразбиране след това прекъсва нишките. Ако не искате това поради изброените по-горе причини, тогава трябва да зададете setWaitForTasksToCompleteOnShutdown() на true. Друга полезна функция е задаване на забавяне след прекратяване с setAwaitTerminationSeconds() или setAwaitTerminationMillis(). Това кара Spring Boot да спре своята последователност на изключване, докато всички нишки не бъдат прекратени или не настъпи определеното време за изчакване. Без това забавяне определени ресурси като пулове от бази данни може да се затварят, докато нишка, която ги използва, все още работи.

Ако използвате персонализиран метод за прекратяване на нишка като този в предишния пример, можете да подкласирате ThreadPooolTaskExceutor и да замените shutdown() във вашата Spring Boot конфигурация по следния начин:

@Bean
public AsyncTaskExecutor asyncTaskExecutor(Process process) {
    ThreadPoolTaskExecutor te = new ThreadPoolTaskExecutor() {
        @Override
        public void shutdown() {
            process.stop();
            super.shutdown();
        }
    };
    te.setCorePoolSize(NUMBER_OF_THREADS);
    te.setMaxPoolSize(NUMBER_OF_THREADS);
    te.setWaitForTasksToCompleteOnShutdown(true);
    te.setAwaitTerminationSeconds(SHUTDOWN_DELAY_SECONDS);
    return te;
}

Още нещо

Това не е специфично за Java, но нещо, което е лесно да се пренебрегне и ще наруши грациозното изключване, когато не е направено правилно

Ако използвате UNIX shell скриптове за стартиране на вашата JVM, уверете се, че използвате exec по този начин

exec java -jar myapp.jar

Това трябва да е последният ред в shell скрипт, защото всичко след exec така или иначе ще бъде игнорирано.

Ако JVM се стартира без exec, той работи като дъщерен процес на обвивката и няма да получи сигнала за изключване SIGTERM, но ще бъде унищожен, когато обвивката приключи. С exec JVM замества обвивката и получава директно OS сигнали. Забележете, че ако използвате няколко скрипта на обвивката, които се извикват един друг, трябва да използвате exec във всеки от тях.

Заключение

Грациозното изключване на многопоточно приложение Spring Boot е по-скоро изкуство, отколкото би трябвало да бъде. Това, което го прави толкова сложно е, че наистина трябва да обърнете внимание какво се случва по време на изключване. По подразбиране нишките са твърдо прекратени заедно с JVM и нямат възможност да регистрират грешки. Така че е лесно да го направите погрешно, без изобщо да го забележите.