ScheduledThreadPoolExecutor został wprowadzony w Javie 5 jako solidny, bezpieczny dla wątków zamiennik dla java.util.Timer, który cierpiał na katastrofalne zakończenie wątku, gdy wystąpił wyjątek, którego nie przechwycono. Nietypowe zjawisko pojawia się z implementacją wewnętrznego ScheduledFutureTask, która przechowuje okres jako long, gdzie wartości dodatnie wskazują na semantykę stałego czasu (harmonogramowanie w czasie absolutnym), a wartości ujemne wskazują na semantykę stałej opóźnienia (harmonogramowanie w czasie względnym). Kiedy czas wykonania zadania okresowego przekracza jego interwał, stały czas stara się utrzymać harmonogram, wykonując zadania jedno po drugim bez przerwy, co powoduje dryft i potencjalne wyczerpanie zasobów, podczas gdy stałe opóźnienie wprowadza obowiązkową pauzę po każdym zakończeniu, akceptując przesunięcia czasowe w celu zapewnienia stabilności systemu.
Obsługiwaliśmy rozproszoną platformę monitorowania zdrowia, która zbierała dane o serwerach co pięć sekund za pomocą ScheduledThreadPoolExecutor skonfigurowanego z scheduleAtFixedRate. W trakcie krytycznego pogorszenia stanu bazy danych, zapytania o metryki zaczęły wygasać po trzydziestu sekundach, ale executor kontynuował uruchamianie nowych zadań co pięć sekund zgodnie z jego absolutnym harmonogramem, nie zważając na zaległości, co powodowało nieograniczony wzrost kolejki roboczej i groziło OutOfMemoryError.
Oceniano kilka rozwiązań architektonicznych, aby zapobiec nadchodzącemu załamaniu systemu przy jednoczesnym zachowaniu możliwości obserwacji. Zwiększenie rozmiaru głównej puli wątków, aby pomieścić nagromadzenie zaległości, zostało natychmiast odrzucone, ponieważ zwiększyłoby to presję na już zawodzącą bazę danych, tworząc problem tysięcy wątków podczas odzyskiwania przyspieszając zużycie pamięci poprzez nieograniczony wzrost kolejki i proliferację wątków. Wdrożenie wyłącznika obwodu wewnątrz zadania do pominięcia wykonania, gdy baza danych była niena zdrowa, uznano za operacyjnie wykonalne, ale wprowadziło to znaczną złożoność do logiki biznesowej i wymagało współdzielonego stanu mutowalnego, co wprowadzało subtelne zagrożenia synchronizacyjne i trudności w testowaniu w wielu wątkach. Ostatecznie wybrano przejście na scheduleWithFixedDelay, ponieważ zapewniało to wrodzone ciśnienie wsteczne bez dodatkowej złożoności kodu: gdy zadania trwały trzydzieści sekund, następne wykonanie czekało dodatkowe pięć sekund po zakończeniu, naturalnie rozciągając zapytania i pozwalając bazie danych na regenerację, jednocześnie zapobiegając wyczerpaniu zasobów. System ustabilizował się podczas incydentu bez awarii, chociaż pulpity monitorujące ujawniły niejednorodne odstępy czasowe w danych historycznych, co skomplikowało analizę trendów, co uznano za dopuszczalne w porównaniu z alternatywą upadku systemu i całkowitej utraty danych.
Jak wewnętrzna DelayedWorkQueue utrzymuje porządek, gdy dwa zadania mają identyczne znaczniki czasu wykonania, i dlaczego może to powodować pozorną niesprawiedliwość harmonogramu w scenariuszach wysokiej przepustowości?
DelayedWorkQueue to kopiec binarny, który głównie porządkuje zadania według ich pola time reprezentującego następny znacznik czasu wykonania. Gdy znaczniki czasu kolidują, korzysta z monotonicznie rosnącego pola sequenceNumber przypisanego w momencie przesyłania, co oznacza, że wcześniejsze zadania mają pierwszeństwo. To rozstrzyganie FIFO może prowadzić do głodzenia długoterminowych zadań okresowych, jeśli pula jest zbyt mała, ponieważ executor nieustannie wybiera zadanie z najkrótszym czasem oczekiwania z kopca, podczas gdy opóźnione zadanie pozostaje zakopane w kolejce, naruszając intuicyjne oczekiwania round-robin.
Dlaczego ScheduledThreadPoolExecutor kontynuuje przetwarzanie innych zaplanowanych zadań po tym, jak jedno runnable zgłasza nieprzechwycony wyjątek, w przeciwieństwie do java.util.Timer, który kończy cały wątek harmonogramujący?
Podczas gdy Timer używa jednego wątku w tle, który kończy się po wystąpieniu jakiegokolwiek nieprzechwyconego wyjątku, ScheduledThreadPoolExecutor korzysta z architektury puli wątków, w której każde wykonanie zadania odbywa się za pomocą FutureTask.run(). Wyjątki są przechwytywane i przechowywane jako wynik ScheduledFuture, ale co ważne, wątek roboczy wraca do puli nietknięty, aby przetwarzać kolejne zadania z DelayedWorkQueue. W przypadku zadań okresowych, jeśli runAndReset() zwraca fałsz z powodu wyjątku, zadanie nie jest ponownie harmonogramowane, ale wątek kontynuuje wykonywanie innych oczekujących harmonogramów, zapewniając izolację i odporność.
Dlaczego, wywołując remove(Runnable), executor może kontynuować wykonywanie zadania, nawet po tym, jak metoda zwraca prawdę, i jakie specyficzne zachowanie dopasowywania tożsamości komplikuje dynamiczne anulowanie?
Metoda remove() próbuje anulować powiązany ScheduledFuture i usunąć go z DelayedWorkQueue, ale nie może przerwać zadania, które już przeszło do aktywnego stanu wykonania. Ponadto executor opakowuje przesyłane runnable w obiekty ScheduledFutureTask, więc remove() dokonuje porównania tożsamości w odniesieniu do tych instancji opakowujących, a nie surowego Runnable przekazywanego przez wywołującego. Programiści muszą zachować ScheduledFuture zwracane przez metodę harmonogramującą, aby wiarygodnie anulować zadania, ponieważ przekazanie oryginalnego runnable do usunięcia zazwyczaj nie udaje się z powodu nierówności odniesienia z wewnętrznym opakowaniem.