ForkJoinTask 취소 메커니즘은 강제 스레드 중단 대신 협력 플래그에 의존합니다. 즉, **cancel()**은 내부의 변동 상태를 설정할 뿐이며, 작업은 종료 요청을 감지하기 위해 명시적으로 폴링해야 합니다. 따라서 이 설계는 FileChannel 읽기 또는 소켓 InputStream 작업과 같은 단일 I/O 작업을 기다리는 스레드를 차단 해제하지 못합니다. 이러한 블로킹 호출은 취소 플래그를 확인하지 않으며, 표준 스레드 중단 메커니즘에 의해 중단될 수 없습니다.
작업자가 차단될 때 풀의 기아를 방지하기 위해, ForkJoinPool.managedBlock API는 개발자가 ForkJoinPool.ManagedBlocker 인스턴스를 등록할 수 있도록 허용합니다. 이 차단기는 풀이 차단 작업에도 불구하고 목표 병렬성 수준을 유지하기 위해 보상 작업 스레드를 생성하도록 신호를 보냅니다. 차단기의 isReleasable 메소드는 취소 상태를 확인하거나 차단된 작업을 프로그래밍 방식으로 중단할 수 있는 후크를 제공합니다. 이를 통해 풀은 응답하지 않는 I/O에 대한 스레드 예산을 소모하는 대신 우아하게 저하됩니다.
우리는 커스텀 RecursiveTask 내에서 **Files.lines()**를 사용한 병렬 로그 프로세서를 구축할 때 이 제한을 만났습니다. 이 작업은 네트워크 장착 스토리지 장치에서 테라바이트 규모의 로그 파일을 파싱했습니다. 사용자가 장기 실행 분석 작업의 취소를 요청했을 때, ForkJoinPool 스레드는 몇 분 동안 블로킹 read() 시스템 호출에 갇히게 되었습니다. 그들은 취소 플래그를 전혀 무시하여 새로운 작업이 시작되는 것을 방지하고 심각한 스레드 기아를 초래했습니다.
우리는 교착 상태를 해결하기 위해 세 가지 접근 방식을 고려했습니다. 첫 번째 접근 방식은 ForkJoinPool을 완전히 포기하고 캐시된 ThreadPoolExecutor로 전환하는 것이었습니다. 이는 간단한 중단 의미론과 즉각적인 스레드 교체를 제공했지만, CPU 집약적인 파싱 단계에 필수적인 작업 절도 효율성을 희생하게 되었습니다.
두 번째 접근 방식은 모든 I/O 호출을 Thread.interrupt() 로직으로 랩핑하고 SocketChannel과 같은 중단 가능 채널로 전환하는 것이었습니다. 이는 즉각적인 취소를 지원했지만, 표준 블로킹 스트림과 타사 파서를 의존하는 구형 라이브러리 코드와 호환되지 않게 되어 침해적이었습니다.
세 번째 접근 방식은 파일 읽기 루프를 랩핑하는 커스텀 ManagedBlocker를 구현하여 ForkJoinPool.managedBlock을 활용했습니다. 이 차단기는 주기적으로 **isCancelled()**를 확인하면서 차단 프로토콜을 통해 풀에 보상 스레드를 생성하도록 허용했습니다. 우리는 기존 병렬 스트림 아키텍처를 유지하면서 차단 작업을 풀에 명시적으로 알리는 세 번째 솔루션을 선택했습니다. 이를 통해 취소 응답성과 처리량의 균형을 유지하면서 전체 I/O 레이어를 재작성하지 않게 되었습니다.
그 결과는 취소 요청이 몇 분이 아닌 몇 초 내에 전파되는 시스템이었습니다. 풀은 수동 구성 없이도 I/O 스파이크 동안 최대 50개의 스레드로 동적으로 확장되었습니다. 작업량 동안 CPU 포화율은 높게 유지되었고, 심각한 네트워크 혼잡 상황에서도 작업 종료가 신뢰할 수 있게 되었습니다.
어떻게 ForkJoinPool은 명시적인 managedBlock 호출 없이 스레드 차단을 감지하고 보상 스레드를 생성하는 임계값은 무엇입니까?
풀은 내부적으로 활성 및 주차된 수를 나타내는 64비트 ctl 필드를 통해 작업자 스레드 상태를 추적합니다. 작업자가 작업을 수행할 때 "활성"으로 간주하지만, 차단 I/O와 CPU 집약적 작업을 구별할 수는 없습니다. 작업자가 managedBlock을 사용하지 않고 동기화 모니터 또는 I/O에서 차단되는 경우, 풀은 절도 가능한 작업 및 사용 가능한 작업자 수의 감소만 관찰합니다. 병렬성 수준에 도달하고 진전 신호가 도착하지 않으면 결국 정체될 수 있습니다. 보상 스레드는 managedBlock이 호출되거나 Unsafe.park 카운터를 통해 내부 JVM 차단이 감지될 때만 신뢰성 있게 생성되지만, 기본 임계값은 사용자 정의 차단 코드에 대해 불투명하고 신뢰할 수 없습니다.
**왜 **ForkJoinTask.join()**이 작업이 취소되었을 때 즉시 반환되지 않으며, **Future.get()와 시간 초과가 있는 경우의 차이점은 무엇입니까?
**join()**은 내부적으로 **doJoin()**을 호출하며, 여기서 호출 스레드는 대상 작업이 완료될 때까지 다른 작업을 실행하거나 절도를 수행하는 "도움 요청" 메커니즘을 구현합니다. 이는 취소 상태와 무관하게 발생하며, 취소는 새 하위 작업이 포킹되는 것을 방지하고 완료 플래그를 설정하는 것만 합니다. 이 메소드는 대기하기 전에 취소 플래그를 폴링하지 않으며, 진입 시 CancellationException을 던지지도 않습니다. 대조적으로, **Future.get()**은 ForkJoinTask에서 Future를 구현하여 즉시 취소 상태를 확인하고 대기하지 않고 CancellationException을 던질 수 있습니다. 이러한 구분은 **join()**이 풀 내 협력을 위해 설계되었고, **get()**은 표준 Future 의미론을 기대하는 외부 클라이언트를 위한 것이라는 점에서 중요합니다.
Blocking 작업의 처리량을 개선하기 위해 ForkJoinPool의 병렬성 수준과 Runtime.availableProcessors() 간의 상호작용은 무엇이며, 병렬성을 사용 가능한 프로세서보다 높게 설정하는 이유는 무엇입니까?
기본 공통 풀은 애플리케이션 스레드 또는 가비지 수집을 위해 한 개의 코어를 예약하기 위해 availableProcessors() - 1로 초기화됩니다. 병렬성은 활성 스레드의 목표 수를 정의하지만, 엄격한 최대치는 아닙니다. 풀은 managedBlock이 차단 작업을 나타내면 더 많은 스레드를 생성할 수 있지만, 실제로 활성 상태인 스레드는 병렬성 수만큼 유지하려고 합니다. 블로킹 작업의 경우, 병렬성을 코어 수보다 높게 설정하면 (예: 2x 또는 3x 코어) 스케줄러가 다른 스레드가 I/O를 기다리는 동안 CPU를 바쁘게 유지할 수 있습니다. 이는 차단이 발생하더라도 각 코어에 대해 실행 가능한 작업이 존재하도록 하여 "코어당 스레드" 제한을 제거하는 모델입니다. 그러나 이는 차단 비율을 잘못 추정할 경우 과도한 컨텍스트 전환 오버헤드를 방지하기 위해 신중한 조정이 필요합니다.