Java프로그래밍시니어 자바 개발자

**ThreadPoolExecutor**가 한정된 **BlockingQueue**와 함께 **CallerRunsPolicy**를 사용할 때 발생하는 원형 의존성 교착 상태는 무엇이며, 제출 스레드가 완료가 후속 작업에 의존하는 작업에 대해 **Future.get()**을 호출하면 어떻게 됩니까?

Hintsage AI 어시스턴트로 면접 통과

질문에 대한 답변

ThreadPoolExecutor가 코어 스레드와 한정된 큐를 초과할 경우, CallerRunsPolicy는 거부된 작업을 제출자 스레드에 즉시 실행하도록 위임합니다. 만약 제출자 스레드가 방금 제출한 작업의 결과를 동기적으로 기다리기 위해 **Future.get()**을 호출하고, 제출된 작업의 로직이 내부적으로 추가 작업을 같은 실행자에 제출하고 그 완료를 기다리면, 원형 대기가 발생합니다.

제출자 스레드는 작업이 완료될 때까지 **get()**에서 반환할 수 없지만, 작업은 그것보다 뒤에 있는 하위 작업을 기다리기 때문에 완료될 수 없습니다. 모든 작업자 스레드는 다른 작업으로 바쁘므로 큐를 비울 수 있는 스레드는 없습니다. 이로 인해 제출자는 교착 상태에 빠지게 됩니다. 왜냐하면 그가 큐에 있는 하위 작업을 실행할 수 있는 유일한 스레드이면서 동시에 그 하위 작업이 완료되기를 기다리며 차단되어 있기 때문입니다.

실제 상황

우리는 PDF 렌더링 작업을 처리하는 CallerRunsPolicy가 있는 ThreadPoolExecutor를 사용하는 분산 문서 처리 파이프라인에서 이를 겪었습니다. 각 문서 작업은 메타데이터를 파싱하고 이미지 추출을 위한 하위 작업을 생성한 다음, 최종 결과를 조립하기 위해 즉시 그 하위 작업들에 대해 **Future.get()**을 호출했습니다.

부하가 심해지면 큐가 포화 상태가 되어 CallerRunsPolicy가 웹 요청 처리 스레드에서 문서 작업을 실행하게 했습니다. 그 스레드는 이미지 추출 작업을 제출하고 **get()**에서 차단되었지만, 모든 작업자 스레드는 다른 문서와 바쁘게 작업하고 있었습니다. 새로운 하위 작업들은 할당되지 않고 큐의 뒤쪽에 대기하게 되었습니다.

처리 스레드는 하위 작업을 기다리느라 차단되어 있기 때문에 하위 작업을 실행할 수 없고, 하위 작업들은 실행할 수 있는 스레드가 없으므로 실행될 수 없었습니다. 이로 인해 서비스가 마비되었고, 수동 Intervention으로 JVM을 다시 시작할 때까지 멈췄습니다.

다음 코드는 위험한 패턴을 나타냅니다:

ExecutorService executor = new ThreadPoolExecutor( 2, 2, 0L, TimeUnit.MILLISECONDS, new ArrayBlockingQueue<>(2), new ThreadPoolExecutor.CallerRunsPolicy() ); // 메인 요청 처리 스레드에서 제출 Future<?> parent = executor.submit(() -> { // 풀에 포화 상태일 때은 처리 스레드에서 실행됨 (CallerRunsPolicy) Future<?> child = executor.submit(() -> "extracted image"); // 처리 스레드 여기서 차단, 하위 작업을 기다림 // 그러나 하위 작업은 큐에 있고 작업자 스레드는 비어있지 않음 // 처리 스레드는 차단되어 있기 때문에 하위 작업을 실행할 수 없음 return child.get(); }); parent.get(); // 교착 상태: 처리 스레드는 영원히 대기

우리는 네 가지 뚜렷한 아키텍처 해결책을 평가했습니다. 첫 번째 접근법은 CallerRunsPolicyAbortPolicy로 바꾸고 클라이언트에서 지수 백오프 재시도 루프를 구현했습니다. 이는 호출자 스레드의 가용성을 보존했지만 일시적인 실패와 복잡한 재시도 로직을 도입하여 아이도포턴트 보증을 복잡하게 만들었습니다.

두 번째 해결책은 한정된 큐의 포화를 완전히 방지하기 위해 무한 LinkedBlockingQueue로 확장했습니다. 이는 거부를 없애긴 했지만 교통 급증 시 OutOfMemoryError의 위험을 초래하고 백프레셔 신호를 가리며 과도한 대기 시간을 초래했습니다.

세 번째 옵션은 한정된 큐를 유지하면서 maximumPoolSizecorePoolSize보다 상당히 높였습니다. 이는 Load를 흡수하기 위해 스레드의 증가에 의존했습니다. 이는 과도한 컨텍스트 전환과 메모리 소비의 대가로 처리량을 개선했지만, 궁극적으로 CPU 캐시의 쓰레기 수집으로 인해 성능이 저하되었습니다.

네 번째 접근법은 ExecutorCompletionService와 비동기 콜백을 사용하여 워크플로를 재구성하고 동기 **Future.get()**을 대체했습니다. 이는 원래의 문서 작업이 하위 작업이 제출될 때 작업자 스레드를 해제하고 CompletionService가 완료를 알릴 때만 재개하도록 했습니다.

우리는 네 번째 솔루션을 선택했습니다. 왜냐하면 그것은 기본적으로 제출을 완료와 분리했기 때문입니다. 이는 한정된 큐의 백프레셔를 보존하면서 원형 대기 조건을 제거하여 원래 작업이 가벼운 상태 변수에서 알림을 기다리는 동안 작업자 스레드가 하위 작업을 처리할 수 있도록 했습니다.

이 변경으로 교착 상태가 해결되고 평균 대기 시간이 40% 감소했으며, 한정된 큐의 실패 의미를 희생하지 않고 피크 부하에서 안정적인 메모리 제약을 유지했습니다.

후보자들이 자주 놓치는 점

ThreadPoolExecutor는 무제한 BlockingQueue로 구성될 때 corePoolSize를 넘는 스레드를 인스턴스화하지 않습니까?

수행자는 **execute()**가 대기 중인 작업자 스레드에 즉시 작업을 전달할 수 없거나 큐에 삽입할 수 없을 때만 새로운 스레드를 생성하려고 시도합니다. 무한 큐의 offer() 메서드는 결코 false를 반환하지 않으므로 수행자는 포화 상태를 인식하지 못하고 따라서 코어 수치를 초과하는 스레드를 할당하지 않습니다. 이러한 설계는 큐잉이 리소스 관리에 대해 스레드 생성보다 우선이라는 가정을 하고 있지만, 이는 큐가 있는 동안 풀의 활용이 저조한 것처럼 보이게 하여 맹점을 만듭니다. 후보자들은 maximumPoolSize가 큐 용량에 관계없이 하드 천장으로 작용한다고 잘못 가정하는 경우가 많습니다. 큐의 한정성이 스레드 확장을 위한 게이트키퍼로 작용한다는 것을 인식하지 못합니다.

어떻게 CallerRunsPolicy가 단순한 거부 핸들러가 아니라 암묵적인 흐름 제어 메커니즘으로 작용합니까?

제출자 스레드에서 작업을 실행함으로써 정책은 이 스레드가 제출 속도를 일시 중지하고 작업을 수행하도록 강요하여, 자연스럽게 풀의 처리 용량에 맞게 유입되는 흐름을 제한합니다. 이러한 백프레셔는 원래 생산자에게 호출 스택을 따라 전파되어, 명시적인 비율 제한 코드 없이도 속도를 늦춥니다. 많은 후보자들은 이 정책을 단순히 누락된 작업에 대한 실패 방지 장치로만 보고, 자원을 고갈되지 않도록 하기 위해 생산자를 차단하는 정책의 의도를 놓치고 있습니다. 이러한 의미적 구분을 이해하는 것은 부하 급증 시 완전한 거부보다 지연을 선호하는 시스템을 설계하는 데 중요합니다.

**어떤 미세한 상호작용이 **shutdown()CallerRunsPolicy 간의 조화로운 저하를 방지합니까?

**shutdown()**이 호출되면, 수행자는 새로운 제출을 RejectedExecutionException으로 거부되는 상태로 전환되며, 설정된 거부 정책을 완전히 우회합니다. 후보자들은 종종 CallerRunsPolicy가 종료 시 제출자에서 작업을 계속 실행한다고 가정하지만, 수행자는 정책을 확인하기 전에 종료 상태를 검사합니다. 이는 조화로운 종료 단계에서 제출된 작업이 즉시 실패하고 호출자에 의해 실행되지 않기 때문에 클라이언트가 예외를 처리하지 않으면 진행 중인 작업이 손실될 수 있음을 의미합니다. 적절한 종료 순서에는 **awaitTermination()**을 통한 큐 비우기 또는 실패 구조에 거부된 작업을 포착하는 것이 필요합니다. 정책 메커니즘은 종료 플래그가 설정되면 비활성화됩니다.