로컬에서 CompletableFuture.runAsync()로 A, B, C 세 작업을 동시에 제출했을 때,
전체 시간이 max(A, B, C)가 아니라 A + B + C에 가깝게 나온다면
코드 버그가 아니라 OS 스케줄러의 실행 순서 결정 때문일 가능성이 높다.
개발 머신은 IDE, 브라우저, 시스템 데몬 등 수십~수백 개의 스레드가 경쟁하고 있다. 내 워커 스레드는 그 경쟁에서 언제 CPU를 받을지 보장받지 못한다.
A + B + C에 근접 (병렬이면 max(A, B, C)여야 함)join(), get() 불필요한 호출 없음
runAsync()를 호출하면 JVM은 작업을 ForkJoinPool(공통 풀)의 큐에 넣는다.
이 시점은 제출(submission)이지, 실행(execution)이 아니다.
실제 CPU를 점유하려면 OS 스케줄러가 해당 워커 스레드를 선택해야 한다.
왼쪽(기대)에서는 A와 B가 거의 동시에 시작한다. 오른쪽(실제)에서는 OS가 Worker-2에 CPU를 늦게 할당하여 B의 시작이 50ms 이상 밀린다. 결과적으로 겹치는 구간이 줄어 전체 시간이 늘어난다.
OS 스케줄러는 CPU라는 희소 자원을 수백 개의 스레드에게 공정하게 나눠주는 교통 정리사다. Linux의 CFS(Completely Fair Scheduler), macOS/Windows의 멀티레벨 피드백 큐 등 구체적인 구현은 다르지만 핵심 개념은 같다.
| 개념 | 설명 | 영향 |
|---|---|---|
| 선점형 스케줄링 | 실행 중인 스레드라도 타임슬라이스가 끝나면 CPU를 빼앗김 | 예측 불가 |
| 우선순위 | IDE, 시스템 프로세스가 워커 스레드보다 높은 우선순위일 수 있음 | 지연 원인 |
| Time Slice | 한 스레드가 연속으로 CPU를 점유할 수 있는 시간 (보통 1~10ms) | 짧을수록 전환 잦음 |
| Context Switching | 스레드 교체 시 레지스터·스택 상태 저장/복원 — 수 마이크로초 오버헤드 | 누적 시 유의미 |
| Run Queue 경쟁 | 새로 생성된 스레드는 런큐 끝에 줄 서서 순서를 기다림 | 시작 지연 원인 |
runAsync(task) 호출 → 태스크가 ForkJoinPool 큐에 등록된다. 스레드 생성이나 실행이 아님.RUNNABLE 상태로 전환되어 OS 런큐(run queue)에 진입한다.프로덕션 서버는 애플리케이션 외 프로세스가 거의 없다. 반면 개발 머신은 IntelliJ/Eclipse, Chrome, 슬랙, Docker Desktop, 터미널 등 수백 개의 스레드가 같은 CPU를 놓고 경쟁한다. 워커 스레드가 런큐에서 기다리는 시간이 서버 환경보다 훨씬 길다.
도커 컨테이너가 호스트와 분리되어 있다는 인식에서 나온 발상이다. 결론적으로 도커는 이 문제를 해결하지 못한다.
| 도커가 격리하는 것 | 도커가 격리하지 않는 것 |
|---|---|
| 파일시스템 (레이어) | CPU 스케줄러 |
| 네트워크 네임스페이스 | OS 커널 (호스트 커널 공유) |
| PID 네임스페이스 | 스레드 실행 우선순위 경쟁 |
| CPU/메모리 사용량 (cgroup) | Context Switching 오버헤드 |
이 문제는 "해결"보다 "이해와 수용"이 맞는 접근이다.
Executors.newFixedThreadPool(n)으로 우선순위 조절 가능// 커스텀 ThreadFactory로 우선순위 높이기
ThreadFactory highPriority = r -> {
Thread t = new Thread(r);
t.setPriority(Thread.MAX_PRIORITY);
return t;
};
ExecutorService exec = Executors.newFixedThreadPool(4, highPriority);
CompletableFuture<Void> a = CompletableFuture.runAsync(taskA, exec);
CompletableFuture<Void> b = CompletableFuture.runAsync(taskB, exec);
CompletableFuture<Void> c = CompletableFuture.runAsync(taskC, exec);
CompletableFuture.allOf(a, b, c).join();
4단계 "OS Run Queue 대기"가 핵심 병목이다.
코드에서는 runAsync() 한 줄이지만, 내부적으로는 위 5단계를 모두 거친다.
로컬 환경에서 이 대기 시간이 길어지면 A, B, C의 실제 시작 시점이 분산되어 직렬처럼 보인다.