본문 바로가기

컨텍스트 스위칭

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

스케줄러가 실행 대상을 바꾸려면 지금 스레드가 어디까지 계산했는지 보관하고 다음 스레드의 상태를 복원해야 합니다.

이 전환이 컨텍스트 스위칭입니다.

멀티스레드가 일을 빠르게 만드는 조건을 이해하려면 스레드 개수보다 유용한 작업 시간과 전환·대기 비용의 비율을 먼저 봐야 합니다.


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

아래 프로그램은 정수 하나를 배열에 기록하는 작업 64개에 스레드 64개를 할당합니다.

합계 2016은 맞지만 design-failure=true가 실제로 출력됩니다.

이 출력은 생성한 스레드 수 64가 예제의 기준값 8을 넘었다는 뜻입니다. 실제 비용 차이를 측정한 결과는 아닙니다.

생성 비용과 문맥 전환 비용도 구분합니다. new Thread·start·종료에는 스레드의 자원과 생명 주기를 관리하는 비용이 있고, 이미 실행 중인 스레드를 교체할 때는 문맥 전환 비용이 듭니다. created=64만으로 전환 횟수가 64였다고 계산할 수는 없습니다. 여러 코어에서는 작업자가 동시에 실행될 수도 있고, 짧은 작업이 끝나는 시점과 스케줄러의 교체 시점도 같지 않습니다.

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 응답 시간, 활성 스레드 수, 문맥 전환 횟수, 할당량을 같이 측정합니다.

작업이 수 마이크로초인데 전환이 비슷한 규모라면 코어가 계산보다 준비 작업에 시간을 씁니다.


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

플랫폼 스레드의 전환에서는 실행 위치와 레지스터 등 실행 문맥을 보존합니다. Java 스택 프레임은 각 메서드 호출의 지역 변수와 계산 상태를 유지합니다.

운영체제는 플랫폼 스레드의 문맥을 저장하고 다른 실행 가능 스레드의 문맥을 불러옵니다.

힙 객체는 프로세스 안에서 공유되므로 스레드 교체 때 힙 전체를 복사하지 않습니다.

전환 계기는 시간 할당량 만료, 더 높은 우선순위 작업의 준비, sleep·I/O·락 대기처럼 다양합니다.

대기 중인 플랫폼 스레드는 운영체제의 실행 대상에서 빠질 수 있습니다. Java의 Thread.State와 운영체제 스레드 상태는 같은 분류가 아닙니다.

반대로 코어 수보다 실행 가능 계산 스레드가 많으면 시간 조각이 끝날 때마다 경쟁이 반복됩니다.

플랫폼 스레드 전환에서 바꾸는 것은 실행 문맥이다

A 문맥 보존→B 문맥 복원→B 재개에서 공유 힙은 유지됩니다. 실행 위치 전환을 따라가되 힙 전체 복사 아님을 고정 관계로 남깁니다.

플랫폼 스레드 전환에서 바꾸는 것은 실행 문맥이다

같은 프로세스의 플랫폼 스레드를 바꿀 때 현재 문맥 보존, 다음 실행 대상 선택, 문맥 복원과 공유 힙의 지속을 구분합니다.

플랫폼 스레드 · 문맥 전환

플랫폼 스레드 전환에서 바꾸는 것은 실행 문맥이다

실행 위치·레지스터를 보존하고 다음 문맥을 복원합니다. 같은 프로세스의 공유 힙은 계속 사용합니다.

바뀌는 실행 문맥과 지속되는 공유 대상 한 영역은 문맥 보존·선택·복원의 개념 단계이며 다른 영역은 공유 힙이 지속되는 관계입니다. Java 출력으로 내부 문맥 전환 횟수를 측정하지 않습니다. 문맥을 바꾸는 과정 스레드 A 실행 위치 위치·레지스터 보존 다음 스레드 선택 B의 보존한 문맥 복원 스레드 B 실행 재개 처음부터 다시 실행 안 함 그동안 유지되는 대상 같은 프로세스의 힙 공유 객체는 그대로 A와 B가 접근 가능 힙 전체를 복사하지 않음 실선: 왼쪽은 전환의 단계 오른쪽은 공유 대상의 지속 Java 출력은 전환 횟수 증거 아님
바뀌는 실행 문맥과 지속되는 공유 대상 한 영역은 문맥 보존·선택·복원의 개념 단계이며 다른 영역은 공유 힙이 지속되는 관계입니다. Java 출력으로 내부 문맥 전환 횟수를 측정하지 않습니다. 문맥을 바꾸는 과정 스레드 A 실행 위치 위치·레지스터 보존 다음 스레드 선택 B의 보존한 문맥 복원 스레드 B 실행 재개 처음부터 다시 실행 안 함 그동안 유지되는 대상 같은 프로세스의 힙 공유 객체는 그대로 A와 B가 접근 가능 힙 전체를 복사하지 않음 실선: 왼쪽은 전환의 단계 오른쪽은 공유 대상의 지속 Java 출력은 전환 횟수 증거 아님

현재 실행 문맥

전환에서 하는 일
실행 위치·레지스터 등을 보존
잘못 읽지 않을 점
작업을 처음부터 다시 실행하지 않음

다음 실행 대상

전환에서 하는 일
실행 가능한 스레드 중 하나를 선택
잘못 읽지 않을 점
start 호출 순서가 선택 순서를 예약하지 않음

다음 스레드의 문맥

전환에서 하는 일
보존한 문맥을 복원하고 실행 재개
잘못 읽지 않을 점
두 스레드의 고정 교대 순서는 아님

공유 힙

전환에서 하는 일
같은 프로세스의 객체를 계속 사용
잘못 읽지 않을 점
전환마다 힙 전체를 복사하지 않음

같은 프로세스의 플랫폼 스레드 사이 전환을 개념적으로 정리했습니다. OS가 보존하는 실행 문맥과 JVM의 Java 스택 프레임은 서로 다른 설명 수준입니다. 아래 Java 출력으로 내부 스케줄링이나 문맥 전환 횟수를 관찰한 것은 아닙니다.

이 예의 조건

  • 개념 문맥 전환이며 Java 출력으로 횟수를 측정하지 않는다.
관계도의 보충 설명
원 도표의 설명과 주석
구분내용
전체 읽기 정보플랫폼 스레드 전환에서 바꾸는 것은 실행 문맥이다 플랫폼 스레드 전환에서 바꾸는 것은 실행 문맥이다 같은 프로세스의 플랫폼 스레드를 바꿀 때 현재 문맥 보존, 다음 실행 대상 선택, 문맥 복원과 공유 힙의 지속을 구분합니다. 플랫폼 스레드 · 문맥 전환 플랫폼 스레드 전환에서 바꾸는 것은 실행 문맥이다 실행 위치·레지스터를 보존하고 다음 문맥을 복원합니다. 같은 프로세스의 공유 힙은 계속 사용합니다. 현재 실행 문맥 전환에서 하는 일 실행 위치·레지스터 등을 보존 잘못 읽지 않을 점 작업을 처음부터 다시 실행하지 않음 다음 실행 대상 전환에서 하는 일 실행 가능한 스레드 중 하나를 선택 잘못 읽지 않을 점 start 호출 순서가 선택 순서를 보장하지 않음 다음 스레드의 문맥 전환에서 하는 일 보존한 문맥을 복원하고 실행 재개 잘못 읽지 않을 점 두 스레드의 고정 교대 순서는 아님 공유 힙 전환에서 하는 일 같은 프로세스의 객체를 계속 사용 잘못 읽지 않을 점 전환마다 힙 전체를 복사하지 않음 같은 프로세스의 플랫폼 스레드 사이 전환을 개념적으로 정리했습니다. OS가 보존하는 실행 문맥과 JVM의 Java 스택 프레임은 서로 다른 설명 수준입니다. 아래 Java 출력으로 내부 스케줄링이나 문맥 전환 횟수를 관찰한 것은 아닙니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
A 실행 위치

현재 문맥을 보존합니다.

  • 현재: 스레드 A
  • 보존: 실행 위치·레지스터
  • 힙: 같은 공유 객체
  • 중단 지점의 실행 문맥을 재개할 수 있도록 남긴다.
다음 선택

B의 보존 문맥을 복원합니다.

  • 선택: 스레드 B
  • 힙: 복사하지 않음
  • 실행 문맥 선택이 프로세스 힙 교체와 다르다.
B 재개

저장 위치에서 계속 실행합니다.

  • 실행: B의 이어진 위치
  • 공유 대상: A/B 접근 가능
  • 처음부터 프로그램을 다시 시작하지 않는다.

플랫폼 스레드 전환에서 바꾸는 것은 실행 문맥이다

A 문맥 보존→B 문맥 복원→B 재개에서 공유 힙은 유지됩니다. 실행 위치 전환을 따라가되 힙 전체 복사 아님을 고정 관계로 남깁니다.

플랫폼 스레드 전환에서 바꾸는 것은 실행 문맥이다

같은 프로세스의 플랫폼 스레드를 바꿀 때 현재 문맥 보존, 다음 실행 대상 선택, 문맥 복원과 공유 힙의 지속을 구분합니다.

플랫폼 스레드 · 문맥 전환

플랫폼 스레드 전환에서 바꾸는 것은 실행 문맥이다

실행 위치·레지스터를 보존하고 다음 문맥을 복원합니다. 같은 프로세스의 공유 힙은 계속 사용합니다.

바뀌는 실행 문맥과 지속되는 공유 대상 한 영역은 문맥 보존·선택·복원의 개념 단계이며 다른 영역은 공유 힙이 지속되는 관계입니다. Java 출력으로 내부 문맥 전환 횟수를 측정하지 않습니다. 문맥을 바꾸는 과정 스레드 A 실행 위치 위치·레지스터 보존 다음 스레드 선택 B의 보존한 문맥 복원 스레드 B 실행 재개 처음부터 다시 실행 안 함 그동안 유지되는 대상 같은 프로세스의 힙 공유 객체는 그대로 A와 B가 접근 가능 힙 전체를 복사하지 않음 실선: 왼쪽은 전환의 단계 오른쪽은 공유 대상의 지속 Java 출력은 전환 횟수 증거 아님
바뀌는 실행 문맥과 지속되는 공유 대상 한 영역은 문맥 보존·선택·복원의 개념 단계이며 다른 영역은 공유 힙이 지속되는 관계입니다. Java 출력으로 내부 문맥 전환 횟수를 측정하지 않습니다. 문맥을 바꾸는 과정 스레드 A 실행 위치 위치·레지스터 보존 다음 스레드 선택 B의 보존한 문맥 복원 스레드 B 실행 재개 처음부터 다시 실행 안 함 그동안 유지되는 대상 같은 프로세스의 힙 공유 객체는 그대로 A와 B가 접근 가능 힙 전체를 복사하지 않음 실선: 왼쪽은 전환의 단계 오른쪽은 공유 대상의 지속 Java 출력은 전환 횟수 증거 아님

현재 실행 문맥

전환에서 하는 일
실행 위치·레지스터 등을 보존
잘못 읽지 않을 점
작업을 처음부터 다시 실행하지 않음

다음 실행 대상

전환에서 하는 일
실행 가능한 스레드 중 하나를 선택
잘못 읽지 않을 점
start 호출 순서가 선택 순서를 예약하지 않음

다음 스레드의 문맥

전환에서 하는 일
보존한 문맥을 복원하고 실행 재개
잘못 읽지 않을 점
두 스레드의 고정 교대 순서는 아님

공유 힙

전환에서 하는 일
같은 프로세스의 객체를 계속 사용
잘못 읽지 않을 점
전환마다 힙 전체를 복사하지 않음

같은 프로세스의 플랫폼 스레드 사이 전환을 개념적으로 정리했습니다. OS가 보존하는 실행 문맥과 JVM의 Java 스택 프레임은 서로 다른 설명 수준입니다. 아래 Java 출력으로 내부 스케줄링이나 문맥 전환 횟수를 관찰한 것은 아닙니다.

이 예의 조건

  • 개념 문맥 전환이며 Java 출력으로 횟수를 측정하지 않는다.

관계도의 보충 설명

원 도표의 설명과 주석
구분내용
전체 읽기 정보플랫폼 스레드 전환에서 바꾸는 것은 실행 문맥이다 플랫폼 스레드 전환에서 바꾸는 것은 실행 문맥이다 같은 프로세스의 플랫폼 스레드를 바꿀 때 현재 문맥 보존, 다음 실행 대상 선택, 문맥 복원과 공유 힙의 지속을 구분합니다. 플랫폼 스레드 · 문맥 전환 플랫폼 스레드 전환에서 바꾸는 것은 실행 문맥이다 실행 위치·레지스터를 보존하고 다음 문맥을 복원합니다. 같은 프로세스의 공유 힙은 계속 사용합니다. 현재 실행 문맥 전환에서 하는 일 실행 위치·레지스터 등을 보존 잘못 읽지 않을 점 작업을 처음부터 다시 실행하지 않음 다음 실행 대상 전환에서 하는 일 실행 가능한 스레드 중 하나를 선택 잘못 읽지 않을 점 start 호출 순서가 선택 순서를 보장하지 않음 다음 스레드의 문맥 전환에서 하는 일 보존한 문맥을 복원하고 실행 재개 잘못 읽지 않을 점 두 스레드의 고정 교대 순서는 아님 공유 힙 전환에서 하는 일 같은 프로세스의 객체를 계속 사용 잘못 읽지 않을 점 전환마다 힙 전체를 복사하지 않음 같은 프로세스의 플랫폼 스레드 사이 전환을 개념적으로 정리했습니다. OS가 보존하는 실행 문맥과 JVM의 Java 스택 프레임은 서로 다른 설명 수준입니다. 아래 Java 출력으로 내부 스케줄링이나 문맥 전환 횟수를 관찰한 것은 아닙니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
A 실행 위치

현재 문맥을 보존합니다.

  • 현재: 스레드 A
  • 보존: 실행 위치·레지스터
  • 힙: 같은 공유 객체
  • 중단 지점의 실행 문맥을 재개할 수 있도록 남긴다.
다음 선택

B의 보존 문맥을 복원합니다.

  • 선택: 스레드 B
  • 힙: 복사하지 않음
  • 실행 문맥 선택이 프로세스 힙 교체와 다르다.
B 재개

저장 위치에서 계속 실행합니다.

  • 실행: B의 이어진 위치
  • 공유 대상: A/B 접근 가능
  • 처음부터 프로그램을 다시 시작하지 않는다.

운영체제가 보존하는 명령 위치·레지스터·스택 포인터 등의 문맥으로 중단했던 실행을 이어갑니다.

Java 스택 프레임은 메서드 호출의 지역 변수와 계산 상태를 유지하는 JVM의 논리적 구조입니다. 플랫폼 스레드 전환 때 Java 스택 전체를 복사한다는 뜻은 아닙니다.

애플리케이션 코드는 이 물리 과정을 직접 제어하지 않으므로 실행 순서를 추측해서는 안 됩니다.


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

작은 작업이 많다면 작업자 수를 제한하고 각 작업자가 여러 항목을 처리하게 만들 수 있습니다.

다음 구현은 네 스레드가 0, 4, 8처럼 서로 겹치지 않는 번호를 맡아 64개 값을 모두 합칩니다.

생성 스레드는 4개이고 작업과 실행 자원의 수가 분리됩니다.

여기서 pool은 네 참조를 담은 배열 이름입니다. 각 스레드는 한 번의 반복 작업을 끝내면 종료하므로, 새 작업을 계속 제출받는 재사용 가능한 스레드 풀을 구현한 것은 아닙니다. 이 코드는 64개 입력을 네 번의 스레드 실행에 묶는 것을 보여 줍니다.

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

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");
    }
}

같은 64개 값은 64개 또는 4개 작업자로 처리한다

64작업을 64/4작업자에 배분하고 각 전용 슬롯 기록 뒤 join 합산합니다. 배분/병렬기록/완료 뒤 집계 단계가 실제 수명 경계를 설명합니다.

같은 64개 값은 64개 또는 4개 작업자로 처리한다

TinyTaskThreadExplosion과 BoundedTinyTaskRunner의 실제 새 스레드 수, 번호 배분, 쓰는 배열 칸과 join 뒤 합계를 비교합니다. 실행시간 비교는 아닙니다.

작업 배분 · 64개 값

같은 64개 값은 64개 또는 4개 작업자로 처리한다

작업 수 64는 그대로 두고, 생성하는 스레드 수를 64개에서 4개로 줄인 설계를 비교합니다.

같은64작업을 나누는 두 기록 구조 64개 작업자의 서로 다른 results칸과4개 작업자의 partial칸을 각각 합산 관계로 연결합니다. 0..63합2016이며 네 부분합480·496·512·528은 소스 계산값입니다. 64개의 새 작업자 작업 번호 0~63 한 작업자당 한 값 각 results 칸에 기록 각 칸 기록자 한 명 모든 join 뒤 합산 2016 / 새 Thread64 4개의 새 작업자 slot0~3에서 4씩 증가 작업자당16개 번호 각 partial 칸에 기록 합480 / 496 / 512 / 528 모든 join 뒤 합산 2016 / 새 Thread4 실선: 각 방식의 입력·기록·합산 64개 작업은 그대로 / 속도실험 아님
같은64작업을 나누는 두 기록 구조 64개 작업자의 서로 다른 results칸과4개 작업자의 partial칸을 각각 합산 관계로 연결합니다. 0..63합2016이며 네 부분합480·496·512·528은 소스 계산값입니다. 64개의 새 작업자 작업 번호 0~63 한 작업자당 한 값 각 results 칸에 기록 각 칸 기록자 한 명 모든 join 뒤 합산 2016 / 새 Thread64 4개의 새 작업자 slot0~3에서 4씩 증가 작업자당16개 번호 각 partial 칸에 기록 합480 / 496 / 512 / 528 모든 join 뒤 합산 2016 / 새 Thread4 실선: 각 방식의 입력·기록·합산 64개 작업은 그대로 / 속도실험 아님

TinyTaskThreadExplosion

새 작업자 수
64
한 작업자의 작업
자기 번호 한 개를 배열에 기록
결과를 쓰는 위치
results의 서로 다른 64칸
main의 최종 합
모두 join 뒤
2016

BoundedTinyTaskRunner

새 작업자 수
4
한 작업자의 작업
slot부터 4씩 증가 · 16개 합산
결과를 쓰는 위치
partial의 서로 다른 4칸
main의 최종 합
모두 join 뒤
2016

design-failure=true는 64가 예제 기준 8보다 크다는 비교 결과입니다. 네 작업자는 번호를 일정 간격으로 나누며 큐나 지속적인 작업 제출 루프를 사용하지 않습니다. 같은 합과 생성 수를 비교하며, 실행 시간·문맥 전환 감소를 측정한 표는 아닙니다.

이 예의 조건

  • 소스 계산이며 성능 비교 측정이 아니다.
  • 각 결과 슬롯의 기록자는 한 명이다.
관계도의 보충 설명
원 도표의 전체 관계·라벨·조건
구분내용
1같은 64작업을 나누는 두 기록 구조
264개 작업자의 서로 다른 results 칸과 4개 작업자의 partial 칸을 각각 합산 관계로 연결합니다. 0..63 합 2016이며 네 부분합 480·496·512·528은 소스 계산값입니다.
10새 Thread 64
12slot 0~3에서 4씩 증가
13작업자당 16개 번호
15합 480
16496
17512
18528
21새 Thread 4
2364개 작업은 그대로
24속도 실험 아님
원 도표의 설명과 주석
구분내용
설명64개 작업자의 서로 다른 results 칸과 4개 작업자의 partial 칸을 각각 합산 관계로 연결합니다. 0..63 합 2016이며 네 부분합 480·496·512·528은 소스 계산값입니다.
전체 읽기 정보같은 64개 값은 64개 또는 4개 작업자로 처리한다 같은 64개 값은 64개 또는 4개 작업자로 처리한다 TinyTaskThreadExplosion과 BoundedTinyTaskRunner의 실제 새 스레드 수, 번호 배분, 쓰는 배열 칸과 join 뒤 합계를 비교합니다. 실행 시간 비교는 아닙니다. 작업 배분 · 64개 값 같은 64개 값은 64개 또는 4개 작업자로 처리한다 작업 수 64는 그대로 두고, 생성하는 스레드 수를 64개에서 4개로 줄인 설계를 비교합니다. TinyTaskThreadExplosion 새 작업자 수 64 한 작업자의 작업 자기 번호 한 개를 배열에 기록 결과를 쓰는 위치 results 의 서로 다른 64칸 main 의 최종 합 모두 join 뒤 2016 BoundedTinyTaskRunner 새 작업자 수 4 한 작업자의 작업 slot 부터 4씩 증가 · 16개 합산 결과를 쓰는 위치 partial 의 서로 다른 4칸 main 의 최종 합 모두 join 뒤 2016 design-failure=true 는 64가 예제 기준 8보다 크다는 비교 결과입니다. 네 작업자는 번호를 일정 간격으로 나누며 큐나 지속적인 작업 제출 루프를 사용하지 않습니다. 같은 합과 생성 수를 비교하며, 실행 시간·문맥 전환 감소를 측정한 표는 아닙니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
동일 작업 분배

64개 번호를 64 또는 4 작업자로 나눕니다.

  • 작업: 0..63
  • 방법 A: Thread 64 / 각 1값
  • 방법 B: Thread 4 / 각 16값
  • 작업 개수와 스레드 개수는 서로 다른 양이다.
자기 슬롯 기록

각 작업자가 results 또는 partial에 기록합니다.

  • 4 부분합: 480,496,512,528
  • 64 slots: 한 작업자 한 칸
  • 공유 한 누산기 대신 기록 위치를 분리한다.
join 후 합

모든 종료를 회수해 각각 합산합니다.

  • 두 total: 2016
  • 속도 측정: 없음
  • 같은 계산 결과가 같은 실행 비용의 증거는 아니다.

같은 64개 값은 64개 또는 4개 작업자로 처리한다

64작업을 64/4작업자에 배분하고 각 전용 슬롯 기록 뒤 join 합산합니다. 배분/병렬기록/완료 뒤 집계 단계가 실제 수명 경계를 설명합니다.

같은 64개 값은 64개 또는 4개 작업자로 처리한다

TinyTaskThreadExplosion과 BoundedTinyTaskRunner의 실제 새 스레드 수, 번호 배분, 쓰는 배열 칸과 join 뒤 합계를 비교합니다. 실행시간 비교는 아닙니다.

작업 배분 · 64개 값

같은 64개 값은 64개 또는 4개 작업자로 처리한다

작업 수 64는 그대로 두고, 생성하는 스레드 수를 64개에서 4개로 줄인 설계를 비교합니다.

같은64작업을 나누는 두 기록 구조 64개 작업자의 서로 다른 results칸과4개 작업자의 partial칸을 각각 합산 관계로 연결합니다. 0..63합2016이며 네 부분합480·496·512·528은 소스 계산값입니다. 64개의 새 작업자 작업 번호 0~63 한 작업자당 한 값 각 results 칸에 기록 각 칸 기록자 한 명 모든 join 뒤 합산 2016 / 새 Thread64 4개의 새 작업자 slot0~3에서 4씩 증가 작업자당16개 번호 각 partial 칸에 기록 합480 / 496 / 512 / 528 모든 join 뒤 합산 2016 / 새 Thread4 실선: 각 방식의 입력·기록·합산 64개 작업은 그대로 / 속도실험 아님
같은64작업을 나누는 두 기록 구조 64개 작업자의 서로 다른 results칸과4개 작업자의 partial칸을 각각 합산 관계로 연결합니다. 0..63합2016이며 네 부분합480·496·512·528은 소스 계산값입니다. 64개의 새 작업자 작업 번호 0~63 한 작업자당 한 값 각 results 칸에 기록 각 칸 기록자 한 명 모든 join 뒤 합산 2016 / 새 Thread64 4개의 새 작업자 slot0~3에서 4씩 증가 작업자당16개 번호 각 partial 칸에 기록 합480 / 496 / 512 / 528 모든 join 뒤 합산 2016 / 새 Thread4 실선: 각 방식의 입력·기록·합산 64개 작업은 그대로 / 속도실험 아님

TinyTaskThreadExplosion

새 작업자 수
64
한 작업자의 작업
자기 번호 한 개를 배열에 기록
결과를 쓰는 위치
results의 서로 다른 64칸
main의 최종 합
모두 join 뒤
2016

BoundedTinyTaskRunner

새 작업자 수
4
한 작업자의 작업
slot부터 4씩 증가 · 16개 합산
결과를 쓰는 위치
partial의 서로 다른 4칸
main의 최종 합
모두 join 뒤
2016

design-failure=true는 64가 예제 기준 8보다 크다는 비교 결과입니다. 네 작업자는 번호를 일정 간격으로 나누며 큐나 지속적인 작업 제출 루프를 사용하지 않습니다. 같은 합과 생성 수를 비교하며, 실행 시간·문맥 전환 감소를 측정한 표는 아닙니다.

이 예의 조건

  • 소스 계산이며 성능 비교 측정이 아니다.
  • 각 결과 슬롯의 기록자는 한 명이다.

관계도의 보충 설명

원 도표의 전체 관계·라벨·조건
구분내용
1같은 64작업을 나누는 두 기록 구조
264개 작업자의 서로 다른 results 칸과 4개 작업자의 partial 칸을 각각 합산 관계로 연결합니다. 0..63 합 2016이며 네 부분합 480·496·512·528은 소스 계산값입니다.
10새 Thread 64
12slot 0~3에서 4씩 증가
13작업자당 16개 번호
15합 480
16496
17512
18528
21새 Thread 4
2364개 작업은 그대로
24속도 실험 아님
원 도표의 설명과 주석
구분내용
설명64개 작업자의 서로 다른 results 칸과 4개 작업자의 partial 칸을 각각 합산 관계로 연결합니다. 0..63 합 2016이며 네 부분합 480·496·512·528은 소스 계산값입니다.
전체 읽기 정보같은 64개 값은 64개 또는 4개 작업자로 처리한다 같은 64개 값은 64개 또는 4개 작업자로 처리한다 TinyTaskThreadExplosion과 BoundedTinyTaskRunner의 실제 새 스레드 수, 번호 배분, 쓰는 배열 칸과 join 뒤 합계를 비교합니다. 실행 시간 비교는 아닙니다. 작업 배분 · 64개 값 같은 64개 값은 64개 또는 4개 작업자로 처리한다 작업 수 64는 그대로 두고, 생성하는 스레드 수를 64개에서 4개로 줄인 설계를 비교합니다. TinyTaskThreadExplosion 새 작업자 수 64 한 작업자의 작업 자기 번호 한 개를 배열에 기록 결과를 쓰는 위치 results 의 서로 다른 64칸 main 의 최종 합 모두 join 뒤 2016 BoundedTinyTaskRunner 새 작업자 수 4 한 작업자의 작업 slot 부터 4씩 증가 · 16개 합산 결과를 쓰는 위치 partial 의 서로 다른 4칸 main 의 최종 합 모두 join 뒤 2016 design-failure=true 는 64가 예제 기준 8보다 크다는 비교 결과입니다. 네 작업자는 번호를 일정 간격으로 나누며 큐나 지속적인 작업 제출 루프를 사용하지 않습니다. 같은 합과 생성 수를 비교하며, 실행 시간·문맥 전환 감소를 측정한 표는 아닙니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
동일 작업 분배

64개 번호를 64 또는 4 작업자로 나눕니다.

  • 작업: 0..63
  • 방법 A: Thread 64 / 각 1값
  • 방법 B: Thread 4 / 각 16값
  • 작업 개수와 스레드 개수는 서로 다른 양이다.
자기 슬롯 기록

각 작업자가 results 또는 partial에 기록합니다.

  • 4 부분합: 480,496,512,528
  • 64 slots: 한 작업자 한 칸
  • 공유 한 누산기 대신 기록 위치를 분리한다.
join 후 합

모든 종료를 회수해 각각 합산합니다.

  • 두 total: 2016
  • 속도 측정: 없음
  • 같은 계산 결과가 같은 실행 비용의 증거는 아니다.

각 작업자는 자기 partial 칸 하나만 쓰고 main은 모든 join이 끝난 뒤 합칩니다.

원자 변수 없이도 여러 작업자가 같은 배열 원소에 쓰는 충돌을 피할 수 있습니다.

완료 결과가 보이는 근거는 문맥 전환이 아니라 join입니다. 작업자가 자기 칸에 쓰고 종료한 뒤 main의 해당 join()이 정상 반환하면 그 쓰기를 이후 읽기에서 볼 수 있습니다. 이 실행 순서 보장을 happens-before라고 합니다. sleep으로 시간을 벌거나 스레드 수를 줄이는 것은 같은 보장을 만들지 않습니다. JLS의 시작·종료 순서 규칙을 기준으로 결과를 읽는 지점을 확인하세요.

slot 0은 0·4·8·…·60, slot 1은 1·5·9·…·61을 맡습니다. 한 번호를 4로 나눈 나머지가 담당 slot이고 서로 다른 나머지는 겹치지 않습니다. 네 부분합은 각각 480·496·512·528이며 합이 2016입니다. 같은 배열에 쓴다는 사실과 같은 배열 칸에 쓴다는 사실을 구별하세요. 쓰는 칸이 분리돼도 main이 너무 일찍 읽는 문제는 남으므로 마지막 join이 필요합니다.

작업 제출·결과·종료를 관리하는 실행기는 ch22, I/O 대기 작업을 많이 표현하는 가상 스레드는 ch36에서 정식으로 배웁니다.


CPU·대기 작업별 판단

CPU 중심 작업은 대부분 RUNNABLE이므로 보통 코어 수 부근에서 시작해 측정합니다.

작업자를 두 배, 네 배로 늘렸는데 처리량이 늘지 않고 전환만 늘면 줄이는 편이 낫습니다.

캐시 지역성이 깨지고 여러 스레드가 같은 캐시 줄을 갱신하는 거짓 공유까지 생기면 병렬화 전보다 느려질 수 있습니다.

I/O 중심 작업은 응답을 기다리는 동안 CPU를 내놓습니다.

동시 요청 수를 늘려 빈 시간을 활용할 수 있지만 소켓, DB 연결 풀, 외부 API 할당량이 상한입니다.

무제한 스레드 생성은 장애 시 대기열을 메모리로 옮길 뿐입니다.

큐 용량과 거절·시간 제한·취소 정책을 함께 정합니다.

아래 모델은 측정값을 받아 계산 작업의 작업자 후보를 제한합니다.

이 계산식이 보편 공식은 아니며 첫 실험값을 만드는 도구입니다.

waitRatio는 한 작업의 계산 시간과 대기 시간을 합친 시간 중 대기의 비율로 읽습니다. 예를 들어 계산 2ms·대기 6ms이면 6/(2+6)=0.75이고, 8코어 모델의 제한 전 후보는 ceil(8/(1−0.75))=32입니다. 외부 제한이 12이면 코드는 12를 시험값으로 고릅니다. 작업마다 비용이 비슷하고 대기를 다른 작업의 계산으로 겹칠 수 있다는 단순화이며, 락 경쟁이나 외부 서비스 포화까지 해결하는 성능 예측은 아닙니다.

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 이상 1 미만의 대기 비율을 가정한 교육용 계산입니다. 인자를 생략하면 실제 장비 측정값이 아닌 기본값 8, 0.0, 32를 사용합니다.

실제 선택은 프로파일러와 운영 지표로 되돌려 확인해야 합니다.

예시

cores=8, waitRatio=0.5, externalLimit=10을 대입하면 후보는 ceil(8/(1−0.5))=16이고 외부 제한을 적용한 시험 작업자 수는 10입니다. 앞 코드가 인자 형식과 의미를 모두 검증하는 것은 아닙니다. 측정값을 입력받는 도구로 확장할 때는 양수 코어 수·제한과 유한한 0 이상 1 미만 비율을 먼저 확인합니다.

src/GuardedWorkerBudget.java
public final class GuardedWorkerBudget {
    public static void main(String[] args) {
        if (args.length != 3) throw new IllegalArgumentException("three inputs required");
        int cores = Integer.parseInt(args[0]);
        double waitRatio = Double.parseDouble(args[1]);
        int limit = Integer.parseInt(args[2]);
        if (cores <= 0 || limit <= 0 || !Double.isFinite(waitRatio)
                || waitRatio < 0 || waitRatio >= 1) {
            throw new IllegalArgumentException("invalid model inputs");
        }
        int candidate = (int) Math.ceil(cores / (1.0 - Math.min(waitRatio, 0.90)));
        int workers = Math.max(1, Math.min(candidate, limit));
        System.out.println("trial-workers=" + workers);
    }
}

세 인자 8 0.5 10의 출력은 trial-workers=10입니다. 비율 NaN·Infinity 또는 코어 수 0은 계산 전에 거부됩니다. 형식 파싱 오류도 정상 후보로 바꾸지 않습니다. 0.90 상한은 원 모델의 정책이며 실제 대기 비율이나 하드웨어 성능을 새로 측정한 값이 아닙니다.


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

CPU 사용률이 높은데 처리량이 낮고 실행 가능 큐와 자발적·비자발적 문맥 전환이 함께 늘면 경쟁 과다를 의심합니다.

반대로 CPU 사용률이 낮고 I/O 대기가 길면 병목은 계산 스레드 수가 아닐 수 있습니다.

단일 숫자만 보고 결론 내리지 않습니다.

비교 실험에서는 입력과 결과를 고정한 뒤 작업자 수만 1, 2, 4, 8처럼 바꿉니다.

각 설정을 충분히 예열하고 여러 번 측정해 중앙값과 상위 지연을 봅니다.

처리량 증가가 멈춘 지점 이후 문맥 전환과 CPU 시간만 늘면 그 앞 설정이 합리적인 시작점입니다.

힙 압력이나 외부 서비스의 처리율 제한이 같이 변했다면 스레드 효과와 분리해 다시 측정합니다.

JFR의 스레드 상태·CPU 표본, 운영체제의 프로세스/스레드 지표, 애플리케이션 큐 체류 시간을 같은 구간에서 봅니다.

로그 한 줄마다 동기식 출력하면 측정 대상 자체가 I/O 대기로 변하므로 벤치마크 내부 로깅은 제거합니다.

예열 없는 짧은 벽시계 비교도 JIT 컴파일과 GC 영향을 섞습니다.


연습 문제

1부터 100까지 제곱합을 계산하되 새 스레드는 정확히 다섯 개만 만드세요.

각 번호를 한 번만 처리하고 main은 모든 작업자 종료 뒤 결과를 출력해야 합니다.

작업자 0이 1·6·11·…·96을, 작업자 4가 5·10·15·…·100을 맡는 분할부터 만들어 보세요. 각 작업자가 20개씩 맡고, 양 끝 번호도 빠지지 않아야 합니다. 제곱을 더하는 계산과 어느 부분합 칸에 쓰는지를 따로 적으면 다섯 작업자가 같은 칸을 갱신하는 실수를 줄일 수 있습니다.

정답과 검증 방법

각 작업자는 시작 번호가 다른 등차수열을 맡고 부분합은 자기 배열 칸에 기록합니다.

main이 join한 뒤 배열을 읽기 때문에 추가 동기화 없이 완료 결과를 볼 수 있습니다.

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·대기 특성에 맞춘 지표로 조정해야 합니다.

연습: 입력 수와 작업자 수가 나누어떨어지지 않을 때

BoundedTinyTaskRunner의 작업자는 네 개로 유지하고 입력 번호만 0부터 9까지로 바꾼다고 가정합니다. 반복 조건의 index < 64는 index < 10, 마지막 표시의 tasks=64는 tasks=10으로 바꾸며 나머지 분할과 모든 join은 유지합니다.

  1. slot 0~3이 맡는 번호와 부분합을 쓰고 전체 합을 구하세요. 각 작업자가 같은 수의 번호를 맡아야만 분할이 올바른가요?
  2. 계산 2ms·대기 6ms, 8코어, 외부 제한 12의 모델 시험값을 구하세요. 이것이 실제 스레드 12개에서 최대 처리량을 보장하나요?
  3. 배열 칸을 분리했으니 모든 join을 빼고 바로 합쳐도 된다는 주장을 평가하세요.
불균등 분할과 시험값 풀이
  1. slot 0은 0·4·8 → 12, slot 1은 1·5·9 → 15, slot 2는 2·6 → 8, slot 3은 3·7 → 10입니다. 전체 합은 45입니다. 작업 수는 3·3·2·2로 다르지만 모든 번호가 한 번씩 배정되므로 올바릅니다. 스레드를 먼저 시작한 순서가 번호 소유권을 결정하지는 않습니다.
  2. 대기 비율은 0.75, 제한 전 후보는 32, 제한 적용 후 시험값은 12입니다. 입력 모델의 후보일 뿐이며 실제 처리량·상위 지연·외부 자원 사용량을 측정해 바꿔야 합니다.
  3. 서로 다른 칸에 쓰는 것은 쓰기 충돌을 피하는 조건입니다. 완료 전에 읽으면 아직 초깃값인 칸을 포함할 수 있고, 완료 쓰기와 읽기의 순서 보장도 사라집니다. 충분히 오래 sleep했다는 관찰로 join을 대체할 수 없습니다.