컨텍스트 스위칭
실행 문맥을 저장하고 복원하는 과정과 작은 작업마다 스레드를 만드는 설계의 비용을 구분합니다.
스케줄러가 실행 대상을 바꾸려면 지금 스레드가 어디까지 계산했는지 보관하고 다음 스레드의 상태를 복원해야 합니다.
이 전환이 컨텍스트 스위칭입니다.
멀티스레드가 일을 빠르게 만드는 조건을 이해하려면 스레드 개수보다 유용한 작업 시간과 전환·대기 비용의 비율을 먼저 봐야 합니다.
작은 작업의 플랫폼 스레드 과다 생성
아래 프로그램은 정수 하나를 배열에 기록하는 작업 64개에 스레드 64개를 할당합니다.
합계 2016은 맞지만 design-failure=true가 실제로 출력됩니다.
이 출력은 생성한 스레드 수 64가 예제의 기준값 8을 넘었다는 뜻입니다. 실제 비용 차이를 측정한 결과는 아닙니다.
생성 비용과 문맥 전환 비용도 구분합니다. new Thread·start·종료에는 스레드의 자원과 생명 주기를 관리하는 비용이 있고, 이미 실행 중인 스레드를 교체할 때는 문맥 전환 비용이 듭니다. created=64만으로 전환 횟수가 64였다고 계산할 수는 없습니다. 여러 코어에서는 작업자가 동시에 실행될 수도 있고, 짧은 작업이 끝나는 시점과 스케줄러의 교체 시점도 같지 않습니다.
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 재개에서 공유 힙은 유지됩니다. 실행 위치 전환을 따라가되 힙 전체 복사 아님을 고정 관계로 남깁니다.
같은 프로세스의 플랫폼 스레드를 바꿀 때 현재 문맥 보존, 다음 실행 대상 선택, 문맥 복원과 공유 힙의 지속을 구분합니다.
플랫폼 스레드 · 문맥 전환
플랫폼 스레드 전환에서 바꾸는 것은 실행 문맥이다
실행 위치·레지스터를 보존하고 다음 문맥을 복원합니다. 같은 프로세스의 공유 힙은 계속 사용합니다.
현재 실행 문맥
- 전환에서 하는 일
- 실행 위치·레지스터 등을 보존
- 잘못 읽지 않을 점
- 작업을 처음부터 다시 실행하지 않음
다음 실행 대상
- 전환에서 하는 일
- 실행 가능한 스레드 중 하나를 선택
- 잘못 읽지 않을 점
start호출 순서가 선택 순서를 예약하지 않음
다음 스레드의 문맥
- 전환에서 하는 일
- 보존한 문맥을 복원하고 실행 재개
- 잘못 읽지 않을 점
- 두 스레드의 고정 교대 순서는 아님
공유 힙
- 전환에서 하는 일
- 같은 프로세스의 객체를 계속 사용
- 잘못 읽지 않을 점
- 전환마다 힙 전체를 복사하지 않음
같은 프로세스의 플랫폼 스레드 사이 전환을 개념적으로 정리했습니다. OS가 보존하는 실행 문맥과 JVM의 Java 스택 프레임은 서로 다른 설명 수준입니다. 아래 Java 출력으로 내부 스케줄링이나 문맥 전환 횟수를 관찰한 것은 아닙니다.
이 예의 조건
- 개념 문맥 전환이며 Java 출력으로 횟수를 측정하지 않는다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 전체 읽기 정보 | 플랫폼 스레드 전환에서 바꾸는 것은 실행 문맥이다 플랫폼 스레드 전환에서 바꾸는 것은 실행 문맥이다 같은 프로세스의 플랫폼 스레드를 바꿀 때 현재 문맥 보존, 다음 실행 대상 선택, 문맥 복원과 공유 힙의 지속을 구분합니다. 플랫폼 스레드 · 문맥 전환 플랫폼 스레드 전환에서 바꾸는 것은 실행 문맥이다 실행 위치·레지스터를 보존하고 다음 문맥을 복원합니다. 같은 프로세스의 공유 힙은 계속 사용합니다. 현재 실행 문맥 전환에서 하는 일 실행 위치·레지스터 등을 보존 잘못 읽지 않을 점 작업을 처음부터 다시 실행하지 않음 다음 실행 대상 전환에서 하는 일 실행 가능한 스레드 중 하나를 선택 잘못 읽지 않을 점 start 호출 순서가 선택 순서를 보장하지 않음 다음 스레드의 문맥 전환에서 하는 일 보존한 문맥을 복원하고 실행 재개 잘못 읽지 않을 점 두 스레드의 고정 교대 순서는 아님 공유 힙 전환에서 하는 일 같은 프로세스의 객체를 계속 사용 잘못 읽지 않을 점 전환마다 힙 전체를 복사하지 않음 같은 프로세스의 플랫폼 스레드 사이 전환을 개념적으로 정리했습니다. OS가 보존하는 실행 문맥과 JVM의 Java 스택 프레임은 서로 다른 설명 수준입니다. 아래 Java 출력으로 내부 스케줄링이나 문맥 전환 횟수를 관찰한 것은 아닙니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| A 실행 위치 현재 문맥을 보존합니다. |
|
|
| 다음 선택 B의 보존 문맥을 복원합니다. |
|
|
| B 재개 저장 위치에서 계속 실행합니다. |
|
|
플랫폼 스레드 전환에서 바꾸는 것은 실행 문맥이다
A 문맥 보존→B 문맥 복원→B 재개에서 공유 힙은 유지됩니다. 실행 위치 전환을 따라가되 힙 전체 복사 아님을 고정 관계로 남깁니다.
같은 프로세스의 플랫폼 스레드를 바꿀 때 현재 문맥 보존, 다음 실행 대상 선택, 문맥 복원과 공유 힙의 지속을 구분합니다.
플랫폼 스레드 · 문맥 전환
플랫폼 스레드 전환에서 바꾸는 것은 실행 문맥이다
실행 위치·레지스터를 보존하고 다음 문맥을 복원합니다. 같은 프로세스의 공유 힙은 계속 사용합니다.
현재 실행 문맥
- 전환에서 하는 일
- 실행 위치·레지스터 등을 보존
- 잘못 읽지 않을 점
- 작업을 처음부터 다시 실행하지 않음
다음 실행 대상
- 전환에서 하는 일
- 실행 가능한 스레드 중 하나를 선택
- 잘못 읽지 않을 점
start호출 순서가 선택 순서를 예약하지 않음
다음 스레드의 문맥
- 전환에서 하는 일
- 보존한 문맥을 복원하고 실행 재개
- 잘못 읽지 않을 점
- 두 스레드의 고정 교대 순서는 아님
공유 힙
- 전환에서 하는 일
- 같은 프로세스의 객체를 계속 사용
- 잘못 읽지 않을 점
- 전환마다 힙 전체를 복사하지 않음
같은 프로세스의 플랫폼 스레드 사이 전환을 개념적으로 정리했습니다. OS가 보존하는 실행 문맥과 JVM의 Java 스택 프레임은 서로 다른 설명 수준입니다. 아래 Java 출력으로 내부 스케줄링이나 문맥 전환 횟수를 관찰한 것은 아닙니다.
이 예의 조건
- 개념 문맥 전환이며 Java 출력으로 횟수를 측정하지 않는다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 전체 읽기 정보 | 플랫폼 스레드 전환에서 바꾸는 것은 실행 문맥이다 플랫폼 스레드 전환에서 바꾸는 것은 실행 문맥이다 같은 프로세스의 플랫폼 스레드를 바꿀 때 현재 문맥 보존, 다음 실행 대상 선택, 문맥 복원과 공유 힙의 지속을 구분합니다. 플랫폼 스레드 · 문맥 전환 플랫폼 스레드 전환에서 바꾸는 것은 실행 문맥이다 실행 위치·레지스터를 보존하고 다음 문맥을 복원합니다. 같은 프로세스의 공유 힙은 계속 사용합니다. 현재 실행 문맥 전환에서 하는 일 실행 위치·레지스터 등을 보존 잘못 읽지 않을 점 작업을 처음부터 다시 실행하지 않음 다음 실행 대상 전환에서 하는 일 실행 가능한 스레드 중 하나를 선택 잘못 읽지 않을 점 start 호출 순서가 선택 순서를 보장하지 않음 다음 스레드의 문맥 전환에서 하는 일 보존한 문맥을 복원하고 실행 재개 잘못 읽지 않을 점 두 스레드의 고정 교대 순서는 아님 공유 힙 전환에서 하는 일 같은 프로세스의 객체를 계속 사용 잘못 읽지 않을 점 전환마다 힙 전체를 복사하지 않음 같은 프로세스의 플랫폼 스레드 사이 전환을 개념적으로 정리했습니다. OS가 보존하는 실행 문맥과 JVM의 Java 스택 프레임은 서로 다른 설명 수준입니다. 아래 Java 출력으로 내부 스케줄링이나 문맥 전환 횟수를 관찰한 것은 아닙니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| A 실행 위치 현재 문맥을 보존합니다. |
|
|
| 다음 선택 B의 보존 문맥을 복원합니다. |
|
|
| B 재개 저장 위치에서 계속 실행합니다. |
|
|
운영체제가 보존하는 명령 위치·레지스터·스택 포인터 등의 문맥으로 중단했던 실행을 이어갑니다.
Java 스택 프레임은 메서드 호출의 지역 변수와 계산 상태를 유지하는 JVM의 논리적 구조입니다. 플랫폼 스레드 전환 때 Java 스택 전체를 복사한다는 뜻은 아닙니다.
애플리케이션 코드는 이 물리 과정을 직접 제어하지 않으므로 실행 순서를 추측해서는 안 됩니다.
제한된 작업자의 작업 재사용
작은 작업이 많다면 작업자 수를 제한하고 각 작업자가 여러 항목을 처리하게 만들 수 있습니다.
다음 구현은 네 스레드가 0, 4, 8처럼 서로 겹치지 않는 번호를 맡아 64개 값을 모두 합칩니다.
생성 스레드는 4개이고 작업과 실행 자원의 수가 분리됩니다.
여기서 pool은 네 참조를 담은 배열 이름입니다. 각 스레드는 한 번의 반복 작업을 끝내면 종료하므로, 새 작업을 계속 제출받는 재사용 가능한 스레드 풀을 구현한 것은 아닙니다. 이 코드는 64개 입력을 네 번의 스레드 실행에 묶는 것을 보여 줍니다.
이처럼 작업자 수에 상한을 두면 입력 폭증이 곧 플랫폼 스레드 폭증으로 이어지는 것을 막을 수 있습니다.
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 합산합니다. 배분/병렬기록/완료 뒤 집계 단계가 실제 수명 경계를 설명합니다.
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보다 크다는 비교 결과입니다. 네 작업자는 번호를 일정 간격으로 나누며 큐나 지속적인 작업 제출 루프를 사용하지 않습니다. 같은 합과 생성 수를 비교하며, 실행 시간·문맥 전환 감소를 측정한 표는 아닙니다.
이 예의 조건
- 소스 계산이며 성능 비교 측정이 아니다.
- 각 결과 슬롯의 기록자는 한 명이다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 1 | 같은 64작업을 나누는 두 기록 구조 |
| 2 | 64개 작업자의 서로 다른 results 칸과 4개 작업자의 partial 칸을 각각 합산 관계로 연결합니다. 0..63 합 2016이며 네 부분합 480·496·512·528은 소스 계산값입니다. |
| 10 | 새 Thread 64 |
| 12 | slot 0~3에서 4씩 증가 |
| 13 | 작업자당 16개 번호 |
| 15 | 합 480 |
| 16 | 496 |
| 17 | 512 |
| 18 | 528 |
| 21 | 새 Thread 4 |
| 23 | 64개 작업은 그대로 |
| 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 작업자로 나눕니다. |
|
|
| 자기 슬롯 기록 각 작업자가 results 또는 partial에 기록합니다. |
|
|
| join 후 합 모든 종료를 회수해 각각 합산합니다. |
|
|
같은 64개 값은 64개 또는 4개 작업자로 처리한다
64작업을 64/4작업자에 배분하고 각 전용 슬롯 기록 뒤 join 합산합니다. 배분/병렬기록/완료 뒤 집계 단계가 실제 수명 경계를 설명합니다.
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보다 크다는 비교 결과입니다. 네 작업자는 번호를 일정 간격으로 나누며 큐나 지속적인 작업 제출 루프를 사용하지 않습니다. 같은 합과 생성 수를 비교하며, 실행 시간·문맥 전환 감소를 측정한 표는 아닙니다.
이 예의 조건
- 소스 계산이며 성능 비교 측정이 아니다.
- 각 결과 슬롯의 기록자는 한 명이다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 1 | 같은 64작업을 나누는 두 기록 구조 |
| 2 | 64개 작업자의 서로 다른 results 칸과 4개 작업자의 partial 칸을 각각 합산 관계로 연결합니다. 0..63 합 2016이며 네 부분합 480·496·512·528은 소스 계산값입니다. |
| 10 | 새 Thread 64 |
| 12 | slot 0~3에서 4씩 증가 |
| 13 | 작업자당 16개 번호 |
| 15 | 합 480 |
| 16 | 496 |
| 17 | 512 |
| 18 | 528 |
| 21 | 새 Thread 4 |
| 23 | 64개 작업은 그대로 |
| 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 작업자로 나눕니다. |
|
|
| 자기 슬롯 기록 각 작업자가 results 또는 partial에 기록합니다. |
|
|
| join 후 합 모든 종료를 회수해 각각 합산합니다. |
|
|
각 작업자는 자기 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를 시험값으로 고릅니다. 작업마다 비용이 비슷하고 대기를 다른 작업의 계산으로 겹칠 수 있다는 단순화이며, 락 경쟁이나 외부 서비스 포화까지 해결하는 성능 예측은 아닙니다.
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 미만 비율을 먼저 확인합니다.
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한 뒤 배열을 읽기 때문에 추가 동기화 없이 완료 결과를 볼 수 있습니다.
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은 유지합니다.
- slot 0~3이 맡는 번호와 부분합을 쓰고 전체 합을 구하세요. 각 작업자가 같은 수의 번호를 맡아야만 분할이 올바른가요?
- 계산 2ms·대기 6ms, 8코어, 외부 제한 12의 모델 시험값을 구하세요. 이것이 실제 스레드 12개에서 최대 처리량을 보장하나요?
- 배열 칸을 분리했으니 모든
join을 빼고 바로 합쳐도 된다는 주장을 평가하세요.
불균등 분할과 시험값 풀이
- 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로 다르지만 모든 번호가 한 번씩 배정되므로 올바릅니다. 스레드를 먼저 시작한 순서가 번호 소유권을 결정하지는 않습니다.
- 대기 비율은 0.75, 제한 전 후보는 32, 제한 적용 후 시험값은 12입니다. 입력 모델의 후보일 뿐이며 실제 처리량·상위 지연·외부 자원 사용량을 측정해 바꿔야 합니다.
- 서로 다른 칸에 쓰는 것은 쓰기 충돌을 피하는 조건입니다. 완료 전에 읽으면 아직 초깃값인 칸을 포함할 수 있고, 완료 쓰기와 읽기의 순서 보장도 사라집니다. 충분히 오래
sleep했다는 관찰로join을 대체할 수 없습니다.