Java프로그래밍Java 개발자

Thread.interrupt()가 Selector.select()에서 블록된 스레드에 호출될 때 어떤 구조적 모호성이 발생하며, 이는 진정한 I/O 준비 상태와 인터럽트에 의해 발생한 허위 깨우기를 구별하기 위한 명시적 상태 확인이 필요한 이유는 무엇인가요?

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

질문에 대한 답변

Thread.interrupt()가 Selector.select()에서 블록된 스레드를 대상으로 할 때, 선택기는 스레드의 인터럽트 플래그를 설정하면서 즉시 빈 선택된 키 집합으로 반환합니다. 이는 호출 코드가 반환 값만으로는 채널이 I/O 준비 상태인지, 아니면 반환이 단순히 인터럽트 신호를 반영하는 것인지 결정할 수 없기 때문에 구조적 모호성을 만듭니다. Selector.wakeup()처럼 인터럽트 상태에 부작용 없이 선택기를 차단 해제하는 경우와는 달리, 인터럽트는 종료 신호와 I/O 이벤트를 혼합합니다. 따라서 강력한 구현은 Thread.interrupted()를 명시적으로 확인하거나 공유되는 volatile 상태 변수에 문의하여 진정한 준비 상태와 허위 깨우기를 구별해야 하며, 이를 통해 CPU 집약적인 스핀 루프를 방지합니다.

실생활의 사례

시장 데이터 피드를 처리하는 고처리량 Java NIO 게이트웨이를 고려해 보세요. 전용 스레드는 SelectionKey 이벤트를 작업자 스레드에 전송하기 위해 Selector.select()에서 블록됩니다. 제로 다운타임 배포 동안 오케스트레이션 계층은 이 선택기 스레드에 비행 중인 거래를 완료한 후 작업을 정상적으로 중지하도록 신호를 보내야 합니다.

초기 구현은 종료 신호를 보내기 위해 Thread.interrupt()를 사용했습니다. 이는 select()를 성공적으로 차단 해제했지만 중요 라이브락을 초래했습니다: select()가 제로 키를 반환하여 이벤트 루프가 CPU를 완전히 사용하여 지속적으로 반복하도록 했습니다. 스레드는 I/O 활동이 존재한다고 가정하고 모든 등록된 채널에서 논블로킹 읽기를 시도했지만, 준비된 채널을 찾지 못하고 즉시 select()를 다시 호출했습니다. 그러면 지속적인 인터럽트 플래그로 인해 즉시 반환되었습니다.

제안된 대안은 select(100)와 함께 volatile 불리언 종료 플래그로 무제한 차단을 대체했습니다. 이 전략은 차단 지속 시간을 제한하여 CPU 포화를 방지했으며, Thread.interrupt()에 의존하지 않고 종료 신호에 대한 간단한 폴링 메커니즘을 제공했습니다. 그러나 이는 종료 탐지에서 타임아웃 기간까지 결정론적 지연을 초래하고, 최대 부하에서 문맥 전환 오버헤드를 20% 증가시켜 고빈도 작업의 처리량 저하를 초래했습니다.

다른 후보 솔루션은 인터럽트 의미를 완전히 피하기 위해 종료 후크에 의해 유발된 Selector.wakeup()을 사용했습니다. 이는 빈 키 집합의 모호성 없이 즉시 차단 해제를 제공하고, 진정한 긴급 종료 시나리오를 위해 인터럽트 플래그를 유지했습니다. 그러나 이는 선택기 스레드가 키를 처리하고 있을 때 wakeup()이 실행되면 "잃어버린 깨우기" 경합 조건의 위험이 있었고, 이를 통해 I/O 이벤트가 도착할 때까지 select()가 무한히 블록될 수 있습니다.

최종 설계는 신중한 happens-before 의미론을 사용하여 volatile AtomicBoolean 종료 플래그와 Selector.wakeup()을 동기화했습니다. 종료 시퀀스는 플래그를 원자적으로 설정한 후 wakeup()을 호출하고, 이벤트 루프는 select() 반환 시 즉시 플래그를 확인하여 키의 가용성에 관계없이 종료 요청이 있을 경우에 원활하게 종료했습니다. 이를 통해 CPU 스핀을 제거하고 종료 시작까지 전체 I/O 처리량을 유지했으며, 인터럽트 상태 확인에 의존하지 않고 50ms 이하의 종료 지연을 달성했습니다.

게이트웨이는 롤링 배포 중 10,000개 이상의 동시 연결을 실패 없는 요청으로 성공적으로 처리했습니다. 종료 시퀀스 전반에 걸쳐 CPU 사용률은 기본 수준을 유지하며, 아키텍처는 I/O 이벤트 처리와 생명주기 관리 신호 사이에 명확한 구분을 제공했습니다.

후보들이 자주 놓치는 것

Thread.interrupted()가 Thread.isInterrupted()와 어떻게 다른지, 그리고 플래그를 지우는 것이 중첩 정리 루틴에서 왜 위험을 초래하는가?

Thread.interrupted()는 현재 스레드의 인터럽트 상태를 확인하고 지우는 반면, Thread.isInterrupted()는 플래그를 수정 없이 검사합니다. 선택기 루프에서 개발자들은 종료 신호를 감지하기 위해 Thread.interrupted()를 호출하는 경우가 많으며, 루프를 종료할 의도로 사용합니다. 그러나 이후 정리 코드가 channel.close()와 같은 블로킹 I/O 작업을 수행하거나 CountDownLatch 종료를 기다리면, 이러한 작업은 이전에 지워진 인터럽트 상태를 보지 못하고 원래의 종료 요청에 응답하지 않고 무한히 블록될 수 있습니다.

왜 Selector.select()가 인터럽트 발생 시 제로 키로 정상적으로 반환되며 InterruptedException을 던지지 않는가? 그리고 이는 어떤 제어 흐름 모호성을 초래하는가?

Object.wait() 또는 Thread.sleep()과 같은 블로킹 메서드와는 달리, Selector.select()는 InterruptedException을 선언하지 않고 Thread.interrupt()가 호출될 때 즉시 제로 선택된 키로 반환합니다. 이 설계 선택은 실제 I/O 준비 상태, 즉 우연히 제로 키를 반환할 수 있는 상태와 인터럽트 신호를 혼합하여 응용프로그램이 "준비된 채널 없음"과 "종료 요청됨"을 구별하기 위해 명시적 상태 확인을 구현하도록 강요합니다. 후보들은 종종 이 구별을 놓치고, 제로 키가 라이브락을 의미하거나 즉시 재시도를 가정하는 루프를 작성하여 선택기가 단순히 인터럽트 플래그에 응답할 때 CPU 포화 상황을 초래합니다.

Selector.wakeup()이 공유 변수에 대한 메모리 가시성 보장을 제공하지 않으며, 이것이 종료 플래그에 대한 volatile 또는 synchronized 의미가 왜 필요한지?

Selector.wakeup()은 선택기 스레드를 원자적으로 차단 해제하지만, 깨우기 호출과 이후 블록이 해제된 스레드가 공유 종료 변수에 접근하기 사이에 happens-before 관계를 설정하지 않습니다. 따라서 종료 플래그를 volatile로 선언하지 않거나 synchronized 블록 내에서 접근하지 않으면 선택기 스레드는 wakeup()이 실행된 후에도 오래된 캐시된 값(거짓)을 관찰할 수 있어, 이를 통해 select()에 다시 진입하고 실제 종료가 시작되었음에도 불구하고 영원히 블록될 수 있습니다. 이러한 미묘한 Java 메모리 모델 상호작용은 단독으로 wakeup()이 신뢰할 수 있는 교차 스레드 통신에 불충분하다는 것을 의미하며, 상태 변화를 제대로 보장하기 위해 적절한 동기화와 쌍으로 사용해야 합니다.