본문으로 건너뛰기
안동민 개발노트 아이콘

안동민 개발노트

본문 시작
18장 : 스레드 생성·제어·생명 주기

컨텍스트 스위칭

실행 문맥을 저장하고 복원하는 과정과 작은 작업마다 스레드를 만드는 설계의 비용을 구분합니다.

스케줄러가 실행 대상을 바꾸려면 지금 스레드가 어디까지 계산했는지 보관하고 다음 스레드의 상태를 복원해야 합니다. 이 전환이 컨텍스트 스위칭입니다. 멀티스레드가 일을 빠르게 만드는 조건을 이해하려면 스레드 개수보다 유용한 작업 시간과 전환·대기 비용의 비율을 먼저 봐야 합니다.

작은 작업의 플랫폼 스레드 과다 생성

아래 프로그램은 정수 하나를 더하는 작업 64개에 스레드 64개를 할당합니다. 합계 2016은 맞지만 design-failure=true가 실제로 출력됩니다. 계산 한 번보다 스레드 객체, 스택, 시작·종료, 스케줄러 진입 비용이 훨씬 큰 구조라는 뜻입니다.

lab/TinyTaskThreadExplosion.java
public final class TinyTaskThreadExplosion {
    public static void main(String[] args) throws InterruptedException {
        Thread[] workers = new Thread[64];
        int[] results = new int[64];
        for (int i = 0; i < workers.length; i++) {
            int value = i;
            workers[i] = new Thread(() -> results[value] = value, "one-value-" + value);
            workers[i].start();
        }
        for (Thread worker : workers) {
            worker.join();
        }
        int sum = 0;
        for (int value : results) sum += value;
        System.out.println("sum=" + sum + ", created=" + workers.length);
        System.out.println("design-failure=" + (workers.length > 8));
    }
}

정답이 맞는 것과 자원 사용이 타당한 것은 별개입니다. 실무에서는 처리량, p95 응답 시간, 활성 스레드 수, 문맥 전환 횟수, 할당량을 같이 측정합니다. 작업이 수 마이크로초인데 전환이 비슷한 규모라면 코어가 계산보다 준비 작업에 시간을 씁니다.

전환할 때 보존하는 실행 문맥

스레드의 논리적 실행 위치는 프로그램 카운터, CPU 레지스터 값, Java 스택 프레임으로 표현됩니다. 운영체제와 JVM은 실행 중인 문맥을 저장하고 다른 실행 가능 스레드의 문맥을 불러옵니다. 힙 객체는 프로세스 안에서 공유되므로 스레드 교체 때 힙 전체를 복사하지 않습니다.

전환 계기는 시간 할당량 만료, 더 높은 우선순위 작업의 준비, sleep·I/O·락 대기처럼 다양합니다. 현재 스레드가 BLOCKED나 WAITING으로 들어가면 CPU를 계속 점유할 이유가 없으므로 다른 실행 가능 스레드가 선택됩니다. 반대로 코어 수보다 실행 가능 계산 스레드가 많으면 시간 조각이 끝날 때마다 경쟁이 반복됩니다.

프로그램 카운터는 다음에 실행할 명령 위치를, 스택 프레임은 호출 중인 메서드의 지역 변수와 반환 위치를 보존합니다. 레지스터에는 계산 중간값이 있을 수 있습니다. 이 셋을 복원해야 이전 코드가 중단된 다음 지점부터 이어집니다. 애플리케이션 코드는 이 물리 과정을 직접 제어하지 않으므로 실행 순서를 추측해서는 안 됩니다.

제한된 작업자의 작업 재사용

작은 작업이 많다면 작업자 수를 제한하고 각 작업자가 여러 항목을 처리하게 만들 수 있습니다. 다음 구현은 네 스레드가 0, 4, 8처럼 서로 겹치지 않는 번호를 맡아 64개 값을 모두 합칩니다. 생성 스레드는 4개이고 작업과 실행 자원의 수가 분리됩니다.

이처럼 작업자 수에 상한을 두면 입력 폭증이 곧 플랫폼 스레드 폭증으로 이어지는 것을 막을 수 있습니다.

src/BoundedTinyTaskRunner.java
public final class BoundedTinyTaskRunner {
    public static void main(String[] args) throws InterruptedException {
        Thread[] pool = new Thread[4];
        int[] partial = new int[4];
        for (int i = 0; i < pool.length; i++) {
            int slot = i;
            pool[i] =
                    new Thread(
                            () -> {
                                int sum = 0;
                                for (int index = slot; index < 64; index += pool.length) {
                                    sum += index;
                                }
                                partial[slot] = sum;
                            },
                            "batch-worker-" + i);
            pool[i].start();
        }
        for (Thread worker : pool) {
            worker.join();
        }
        int total = 0;
        for (int value : partial) total += value;
        System.out.println("sum=" + total + ", workers=" + pool.length + ", tasks=64");
    }
}

각 작업자는 자기 partial 칸 하나만 쓰고 main은 모든 join이 끝난 뒤 합칩니다. 아직 배우지 않은 원자 변수 없이도 공유 쓰기를 피할 수 있습니다. 작업 제출·결과·종료를 관리하는 실행기는 ch22, I/O 대기 작업을 많이 표현하는 가상 스레드는 ch36에서 정식으로 배웁니다.

CPU·대기 작업별 판단

CPU 중심 작업은 대부분 RUNNABLE이므로 보통 코어 수 부근에서 시작해 측정합니다. 작업자를 두 배, 네 배로 늘렸는데 처리량이 늘지 않고 전환만 늘면 줄이는 편이 낫습니다. 캐시 지역성이 깨지고 여러 스레드가 같은 캐시 줄을 갱신하는 거짓 공유까지 생기면 병렬화 전보다 느려질 수 있습니다.

I/O 중심 작업은 응답을 기다리는 동안 CPU를 내놓습니다. 동시 요청 수를 늘려 빈 시간을 활용할 수 있지만 소켓, DB 연결 풀, 외부 API 할당량이 상한입니다. 무제한 스레드 생성은 장애 시 대기열을 메모리로 옮길 뿐입니다. 큐 용량과 거절·시간 제한·취소 정책을 함께 정합니다.

아래 모델은 측정값을 받아 계산 작업의 작업자 후보를 제한합니다. 이 계산식이 보편 공식은 아니며 첫 실험값을 만드는 도구입니다.

app/WorkerBudgetAdvisor.java
public final class WorkerBudgetAdvisor {
    public static void main(String[] args) {
        int cores = args.length > 0 ? Integer.parseInt(args[0]) : 8;
        double waitRatio = args.length > 1 ? Double.parseDouble(args[1]) : 0.0;
        int externalLimit = args.length > 2 ? Integer.parseInt(args[2]) : 32;
        int candidate = (int) Math.ceil(cores / (1.0 - Math.min(waitRatio, 0.90)));
        int workers = Math.max(1, Math.min(candidate, externalLimit));
        System.out.println(
                "cores=" + cores + ", wait-ratio=" + waitRatio + ", trial-workers=" + workers);
    }
}

대기 비율 0이면 핵심 수를, 비율이 높으면 더 큰 후보를 내지만 외부 제한을 넘지 않습니다. 실제 선택은 프로파일러와 운영 지표로 되돌려 확인해야 합니다.

무엇을 측정해야 전환 과다를 구별할 수 있는가

CPU 사용률이 높은데 처리량이 낮고 실행 가능 큐와 자발적·비자발적 문맥 전환이 함께 늘면 경쟁 과다를 의심합니다. 반대로 CPU 사용률이 낮고 I/O 대기가 길면 병목은 계산 스레드 수가 아닐 수 있습니다. 단일 숫자만 보고 결론 내리지 않습니다.

비교 실험에서는 입력과 결과를 고정한 뒤 작업자 수만 1, 2, 4, 8처럼 바꿉니다. 각 설정을 충분히 예열하고 여러 번 측정해 중앙값과 상위 지연을 봅니다. 처리량 증가가 멈춘 지점 이후 문맥 전환과 CPU 시간만 늘면 그 앞 설정이 합리적인 시작점입니다. 힙 압력이나 외부 서비스의 처리율 제한이 같이 변했다면 스레드 효과와 분리해 다시 측정합니다.

JFR의 스레드 상태·CPU 표본, 운영체제의 프로세스/스레드 지표, 애플리케이션 큐 체류 시간을 같은 구간에서 봅니다. 로그 한 줄마다 동기식 출력하면 측정 대상 자체가 I/O 대기로 변하므로 벤치마크 내부 로깅은 제거합니다. 예열 없는 짧은 벽시계 비교도 JIT 컴파일과 GC 영향을 섞습니다.

연습 문제

1부터 100까지 제곱합을 계산하되 새 스레드는 정확히 다섯 개만 만드세요. 각 번호를 한 번만 처리하고 main은 모든 작업자 종료 뒤 결과를 출력해야 합니다.

정답과 검증 방법

각 작업자는 시작 번호가 다른 등차수열을 맡고 부분합은 자기 배열 칸에 기록합니다. mainjoin한 뒤 배열을 읽기 때문에 추가 동기화 없이 완료 결과를 볼 수 있습니다.

exercise/FiveWorkerSquareSumSolution.java
public final class FiveWorkerSquareSumSolution {
    public static void main(String[] args) throws InterruptedException {
        long[] partial = new long[5];
        Thread[] workers = new Thread[5];
        for (int i = 0; i < workers.length; i++) {
            int slot = i;
            workers[i] =
                    new Thread(
                            () -> {
                                long sum = 0;
                                for (int n = slot + 1; n <= 100; n += workers.length)
                                    sum += (long) n * n;
                                partial[slot] = sum;
                            },
                            "square-" + i);
            workers[i].start();
        }
        for (Thread worker : workers) {
            worker.join();
        }
        long total = 0;
        for (long value : partial) {
            total += value;
        }
        System.out.println("workers=" + workers.length + ", square-sum=" + total);
    }
}

결과는 338350입니다. 다섯 등차수열이 1부터 100까지를 겹치지 않고 정확히 한 번 덮는지 먼저 확인합니다.

컨텍스트 스위칭은 동시성의 결함이 아니라 실행 자원을 나누는 기본 비용입니다. 문제는 작업 크기보다 전환이 커지는 설계입니다. 작업을 묶고 작업자 상한을 두며 CPU·대기 특성에 맞춘 지표로 조정해야 합니다.