컨텍스트 스위칭과 스레드 수
운영체제가 실행할 스레드를 바꿀 때 무엇을 저장하고 복원하는지 알아보고, 작은 작업마다 스레드를 만드는 비용을 직접 잰 뒤 작업 성격에 맞게 스레드 수를 정하는 방법을 배웁니다.
앞 글에서 여러 스레드가 함께 실행되는 모습을 보았습니다. 그런데 컴퓨터의 코어 수는 정해져 있고, 이 글의 예제를 실행한 컴퓨터에는 코어가 두 개뿐이었습니다. 반면 컴퓨터에 만들어져 있는 스레드는 거의 언제나 코어보다 훨씬 많습니다. JVM만 해도 main 말고 내부 작업용 스레드를 여러 개 띄우고, 운영체제와 다른 프로그램의 스레드도 있습니다. 그래서 운영체제는 각 코어에서 실행할 스레드를 쉴 새 없이 바꿔 가며 코어를 나눠 씁니다.
실행할 스레드를 바꾸는 이 일을 컨텍스트 스위칭(context switching), 우리말로 문맥 전환이라고 합니다. 문맥 전환은 코어를 나눠 쓰는 데 꼭 필요한 일이지만 공짜는 아닙니다. 이 글에서는 전환할 때 무엇을 저장하고 복원하는지 살펴보고, 작은 작업마다 스레드를 새로 만들면 무엇이 손해인지 시간을 직접 재 봅니다. 마지막으로 작업의 성격에 따라 스레드를 몇 개 두면 좋을지 정하는 방법을 정리합니다. 운영체제가 전환할 때 무엇을 어떤 순서로 저장하고 복원하는지는 PCB와 컨텍스트 스위칭에서도 다룹니다.
스레드를 바꿀 때 저장하고 복원하는 것
스레드 A가 반복문으로 합을 계산하다가 운영체제가 정해 준 시간 조각을 다 썼다고 해 봅시다. 운영체제는 A를 멈추고 기다리던 스레드 B를 실행해야 합니다. 그런데 나중에 A로 돌아왔을 때 A는 처음부터가 아니라 멈췄던 바로 그 자리에서 계산을 이어 가야 합니다. 그러려면 A가 어디까지 실행했고 계산 중이던 값이 무엇이었는지를 어딘가에 적어 두어야 합니다.
이렇게 스레드가 실행을 이어 가는 데 필요한 정보를 실행 문맥(execution context)이라고 합니다. 이 절에서 말하는 전환은 운영체제가 플랫폼 스레드를 바꾸는 경우입니다. 실행 문맥의 핵심은 코어의 레지스터(register)에 들어 있는 값들입니다. 레지스터는 코어 안에 있는 아주 빠른 저장 칸으로, 다음에 실행할 명령의 위치(프로그램 카운터), 스레드가 쓰는 스택의 현재 위치(스택 포인터), 계산 중인 중간값 등이 여기에 들어 있습니다. 스레드 A에서 B로 바꾸는 과정은 다음과 같습니다.
| 단계 | 일어나는 일 | 스레드 A | 스레드 B |
|---|---|---|---|
| 1 | A가 코어에서 명령을 실행하는 중 | 실행 중 | 실행 차례를 기다림 |
| 2 | A의 시간 조각이 끝나 코어가 운영체제 코드로 넘어감 | 멈춤 | 실행 차례를 기다림 |
| 3 | A의 레지스터 값을 A의 몫으로 메모리에 저장 | 문맥 저장됨 | 실행 차례를 기다림 |
| 4 | 실행할 수 있는 스레드 가운데 B를 고름 | 실행 차례를 기다림 | 선택됨 |
| 5 | 전에 저장해 둔 B의 레지스터 값을 코어에 다시 넣음 | 실행 차례를 기다림 | 문맥 복원됨 |
| 6 | B가 지난번에 멈췄던 명령부터 실행을 이어 감 | 실행 차례를 기다림 | 실행 중 |
플랫폼 스레드 전환에서 바꾸는 것은 실행 문맥이다
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 재개 저장 위치에서 계속 실행합니다. |
|
|
전환할 때 바뀌지 않는 것도 중요합니다. A와 B가 같은 프로세스의 스레드라면 두 스레드가 함께 쓰는 힙은 전환 전후에 그대로 있습니다. 스레드를 바꿀 때마다 힙 전체를 복사하는 일은 없고, A가 힙 객체에 써 둔 값도 그 자리에 남아 있습니다. 각 스레드의 스택도 메모리에 그대로 있습니다. 운영체제는 스택 내용을 옮기지 않고, 스택의 어디까지 쓰고 있었는지 가리키는 스택 포인터 값만 저장해 둡니다.
JVM 메모리 구조에서 배운 Java 스택 프레임과 운영체제가 저장하는 문맥은 서로 다른 수준의 이야기입니다. 스택 프레임은 메서드 호출마다 지역 변수와 계산 상태를 담는 JVM의 구조이고, 문맥 전환 때 운영체제가 저장하는 것은 코어의 레지스터 값입니다. 둘 다 '스레드가 어디까지 실행했는가'를 기억한다는 점은 같지만, 문맥 전환이 Java 스택 전체를 복사한다는 뜻은 아닙니다.
스레드가 바뀌는 때
문맥 전환은 크게 두 가지 경우에 일어납니다. 첫째는 스레드가 스스로 더 진행할 수 없게 된 경우입니다. sleep()으로 잠들거나, 파일이나 네트워크 응답을 기다리거나, 락이나 join()으로 다른 스레드를 기다리면 운영체제는 그 스레드를 실행 후보에서 빼고 다른 스레드를 실행합니다. 이를 자발적 문맥 전환(voluntary context switch)이라고 합니다. 기다리는 스레드가 코어를 차지하고 있으면 낭비이므로, 이런 전환은 코어를 잘 쓰기 위해 꼭 필요합니다.
둘째는 스레드가 계속 실행할 수 있는데도 운영체제가 코어를 빼앗는 경우입니다. 시간 조각을 다 썼거나 우선순위가 더 높은 스레드가 실행할 수 있게 되면 이런 일이 생기며, 이를 비자발적 문맥 전환(involuntary context switch)이라고 합니다. 실행할 수 있는 계산 스레드가 코어보다 많으면 비자발적 전환이 시간 조각마다 되풀이됩니다. 각 스레드는 조금씩만 진행하고 전환 비용은 계속 쌓입니다.
어느 순간에 어떤 스레드로 바꿀지는 운영체제가 그때그때 결정합니다. Java 코드는 sleep()처럼 스스로 기다리며 코어를 내놓을 수는 있어도, 그다음에 어느 스레드가 언제 실행될지는 정할 수 없습니다. 앞 글에서 실행할 때마다 출력 순서가 달라졌던 것도 이 때문입니다. 따라서 코드가 특정 전환 순서에 기대서는 안 됩니다. 두 스레드가 일정한 순서로 번갈아 실행된다는 보장도 없고, 먼저 start()한 스레드가 먼저 선택된다는 보장도 없습니다.
문맥 전환에 드는 비용
전환에는 두 종류의 비용이 듭니다. 직접 비용은 전환 작업 자체에 드는 시간입니다. 코어가 운영체제 코드로 넘어가 A의 레지스터를 저장하고, 다음 스레드를 고른 뒤 B의 레지스터를 복원하는 동안에는 어느 프로그램의 일도 진행되지 않습니다.
간접 비용은 캐시(cache)에서 생깁니다. 코어는 최근에 쓴 데이터를 캐시라는 빠른 메모리에 담아 두고 쓰는데, 새로 실행되는 스레드 B가 쓰는 데이터는 대개 캐시에 없습니다. 그래서 B는 한동안 느린 메인 메모리에서 데이터를 가져오며 실행됩니다. B가 실행되는 동안 A의 데이터가 캐시에서 밀려나면, 나중에 A로 돌아왔을 때 A도 같은 일을 겪습니다. 작업에 따라서는 이 간접 비용이 직접 비용보다 클 수도 있습니다.
그렇다고 문맥 전환 자체가 결함은 아닙니다. 코어보다 스레드가 많은 한 전환은 반드시 일어나고, 덕분에 기다리는 스레드가 코어를 붙잡지 않습니다. 문제가 되는 것은 전환이나 스레드 관리에 드는 비용이 실제로 하는 일에 비해 커지는 설계입니다. 작업 하나가 아주 짧은데 그 작업마다 스레드를 새로 만들고 전환까지 한다면, 코어는 계산보다 준비 작업에 더 많은 시간을 쓰게 됩니다.
더 깊이: 프로세스를 바꾸는 전환이 더 비싼 이유
서로 다른 프로세스의 스레드로 전환할 때는 레지스터 외에 주소 공간도 바꿔야 합니다. 프로세스마다 가상 주소를 실제 메모리 위치로 바꾸는 표(페이지 테이블)가 따로 있기 때문입니다. 코어는 이 변환 결과를 TLB(translation lookaside buffer)라는 캐시에 담아 두는데, 주소 공간이 바뀌면 이전 프로세스의 변환 결과는 쓸 수 없습니다. 하드웨어에 따라 TLB 항목에 프로세스 표시를 붙여 통째로 비우는 일을 피하기도 하지만, 주소 공간을 바꾸는 일 자체가 같은 프로세스 안의 전환에는 없는 비용입니다. 같은 프로세스의 스레드끼리 전환할 때는 주소 공간이 그대로이므로 이 과정을 건너뜁니다. 스레드가 프로세스보다 가볍다고 말하는 이유 가운데 하나입니다.
작은 작업마다 스레드를 만들면
다음 프로그램은 정수 하나를 배열에 기록하는 아주 작은 작업 64개를 처리하면서, 작업마다 스레드를 하나씩 모두 64개 만듭니다. 0번 스레드는 results[0]에 0을, 1번 스레드는 results[1]에 1을 쓰는 식입니다. 모든 스레드를 join()한 뒤 배열의 합을 출력합니다.
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));
}
}sum=2016, created=64
design-failure=true0부터 63까지의 합은 63×64/2 = 2016이므로 계산 결과는 맞습니다. 각 스레드가 배열의 서로 다른 칸에 쓰고 main은 모든 join()이 끝난 뒤 읽으므로, 몇 번을 실행해도 이 출력은 같습니다. 두 번째 줄의 design-failure=true는 측정한 값이 아니라, 생성한 스레드 수 64가 이 예제가 기준으로 정해 둔 8보다 크다는 비교 결과일 뿐입니다.
하지만 답이 맞는다는 것과 자원을 알맞게 썼다는 것은 별개의 문제입니다. 스레드 하나를 만들고 시작하고 끝내는 데는 생각보다 많은 일이 듭니다. 운영체제 스레드를 새로 만들고 스택으로 쓸 메모리를 예약해야 하며, JVM도 그 스레드를 관리하는 데 필요한 정보를 준비해야 합니다. 스레드가 끝나면 이것들을 다시 정리해야 합니다. 이 예제의 작업은 배열에 값 하나를 쓰는 것뿐이므로, 일 자체보다 스레드를 준비하고 치우는 일이 훨씬 큽니다.
이 비용은 문맥 전환 비용과는 다른 것입니다. created=64라는 출력만 보고 전환이 64번 일어났다고 계산할 수도 없습니다. 코어가 여러 개면 일부 스레드는 동시에 실행될 수 있고, 아주 짧은 작업은 시간 조각을 다 쓰기 전에 끝나 코어를 빼앗기지 않을 수도 있습니다. 반대로 main이 join()으로 기다릴 때마다 자발적 전환이 일어나기도 합니다. 전환 횟수는 운영체제의 도구로 따로 재야 하며, 그 방법은 글 뒷부분에서 소개합니다.
스레드 하나를 만드는 데 걸리는 시간 재 보기
스레드를 준비하는 비용이 얼마나 큰지 직접 재 보겠습니다. 다음 프로그램은 카운터를 1 늘리는 같은 작업을 만 번 실행하는데, 처음에는 run()을 직접 호출하고 다음에는 작업마다 스레드를 새로 만들어 start()와 join()을 합니다. System.nanoTime()은 시간 간격을 나노초(10억분의 1초) 단위로 재는 데 쓰는 메서드입니다. 두 방식 모두 전체 걸린 시간을 반복 횟수로 나눠 작업 하나당 평균 시간을 출력합니다.
public final class ThreadCreationCost {
public static void main(String[] args) throws InterruptedException {
int count = 10_000;
int[] counter = new int[1];
Runnable task = () -> counter[0]++;
long start = System.nanoTime();
for (int i = 0; i < count; i++) {
task.run();
}
long directNanos = (System.nanoTime() - start) / count;
start = System.nanoTime();
for (int i = 0; i < count; i++) {
Thread worker = new Thread(task);
worker.start();
worker.join();
}
long threadNanos = (System.nanoTime() - start) / count;
System.out.println("direct call: " + directNanos + " ns per task");
System.out.println("new thread: " + threadNanos + " ns per task");
System.out.println("counter=" + counter[0]);
}
}direct call: 76 ns per task
new thread: 296909 ns per task
counter=20000시간을 나타내는 두 숫자는 실행할 때마다 달라집니다. 이 글을 쓴 환경(코어 두 개인 Linux 가상 머신, JDK 21)에서 스무 번 넘게 실행했더니 직접 호출은 작업당 50나노초 안팎에서 500나노초 사이, 스레드를 만든 경우는 135,000나노초에서 330,000나노초 사이였습니다. 같은 컴퓨터에서도 이만큼 흔들리고, 하드웨어와 운영체제가 다르면 숫자는 더 크게 달라집니다. 그래도 두 방식의 차이는 매번 수백 배에서 수천 배였습니다. 작업 자체는 1마이크로초도 걸리지 않는데, 스레드를 만들고 시작하고 끝나기를 기다리는 데는 매번 100마이크로초가 넘게 걸린 것입니다.
마지막 줄 counter=20000은 항상 같습니다. 직접 호출로 만 번, 스레드로 만 번 늘렸기 때문입니다. 스레드 쪽은 하나를 시작하고 끝나기를 기다린 다음에 다음 스레드를 시작하므로, 두 스레드가 동시에 카운터를 바꾸는 일이 없습니다. 앞 글에서 본 것처럼 start() 전에 한 일은 새 스레드에 보이고 스레드가 한 일은 join() 뒤에 보이므로, 각 스레드는 앞 스레드가 늘려 둔 값을 이어서 늘립니다.
이 측정은 엄밀한 벤치마크가 아닙니다. JVM은 자주 실행되는 코드를 실행 중에 기계어로 컴파일(JIT 컴파일)해 점점 빠르게 만드는데, 이 프로그램은 그런 준비 과정을 거치지 않고 바로 쟀습니다. 정확하게 재는 방법은 JIT 예열과 벤치마크에서 다룹니다. 다만 여기서 확인하려는 것은 정확한 배율이 아니라 자릿수의 차이이고, 이 정도의 차이는 측정 방법을 다듬어도 결론을 바꾸지 않습니다.
스레드 수를 정해 두고 일을 나눠 맡기기
작은 작업이 많을 때는 작업 수만큼 스레드를 만들지 말고, 스레드 수를 먼저 정한 뒤 스레드 하나가 작업 여러 개를 처리하게 하면 됩니다. 다음 프로그램은 앞과 같은 64개 값을 스레드 4개로 처리합니다. 각 스레드에 0부터 3까지의 번호(slot)를 주고, 스레드는 자기 번호에서 시작해 4씩 건너뛰며 값을 더합니다.
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");
}
}sum=2016, workers=4, tasks=64각 스레드가 맡는 값과 부분합은 다음과 같습니다. 어떤 수를 4로 나눈 나머지가 그 수를 맡을 스레드의 번호이므로, 0부터 63까지의 모든 수는 정확히 한 스레드에 배정됩니다. 빠지는 수도 없고 두 스레드가 함께 맡는 수도 없습니다.
| slot | 맡는 값 | 개수 | 부분합 |
|---|---|---|---|
| 0 | 0, 4, 8, …, 60 | 16 | 480 |
| 1 | 1, 5, 9, …, 61 | 16 | 496 |
| 2 | 2, 6, 10, …, 62 | 16 | 512 |
| 3 | 3, 7, 11, …, 63 | 16 | 528 |
네 부분합을 더하면 2016으로 앞 프로그램과 같습니다. 작업은 여전히 64개이지만 새로 만든 스레드는 4개뿐이므로, 스레드를 준비하고 치우는 횟수도 16분의 1로 줄었습니다. 작업 수와 스레드 수가 서로 다른 양이 된 것입니다. 이렇게 스레드 수에 상한을 두면 처리할 입력이 갑자기 늘어도 스레드가 그만큼 폭증하지 않습니다.
같은 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()을 빼도 되는 것은 아닙니다. 아직 계산 중인 스레드의 칸을 읽으면 0이 섞인 합이 나오고, 이미 쓴 값이라도 join()이 없으면 main에서 보인다는 보장이 없기 때문입니다. 이 보장은 문맥 전환과는 상관없이 JLS 17.4.5의 join() 규칙에서 나옵니다. sleep()으로 충분히 기다리거나 스레드 수를 줄이는 것으로는 같은 보장을 얻을 수 없습니다.
배열 이름이 pool이지만 이 코드는 진짜 스레드 풀(thread pool)이 아닙니다. 네 스레드는 미리 나눠 받은 몫을 한 번 처리하고 끝나며, 새 작업을 계속 받아 처리하지는 못합니다. 작업을 제출받아 정해진 수의 스레드가 돌아가며 처리하고, 결과와 종료까지 관리해 주는 도구는 22장 실행자와 스레드 풀에서 배웁니다. 실무에서는 이 예제처럼 스레드를 직접 나눠 만들기보다 그런 도구를 씁니다.
더 깊이: 이웃한 배열 칸과 거짓 공유
partial의 네 칸은 메모리에서 바로 붙어 있어서 대개 캐시 줄(cache line) 하나에 함께 들어갑니다. 캐시 줄은 코어가 메모리와 캐시 사이에서 데이터를 옮기는 단위로, 흔히 64바이트입니다. 한 코어가 캐시 줄에 쓰려면 다른 코어들의 캐시에 있는 같은 줄의 사본을 무효로 만들어야 합니다. 그래서 두 스레드가 서로 다른 칸에 쓰더라도 그 칸들이 같은 캐시 줄에 있으면, 쓸 때마다 캐시 줄이 코어 사이를 오가며 느려질 수 있습니다. 데이터를 실제로 공유하지 않는데도 공유하는 것처럼 비용이 드는 이 현상을 거짓 공유(false sharing)라고 합니다.
이 예제는 반복문 안에서는 지역 변수 sum에만 더하고, 다 더한 뒤 배열 칸에 한 번만 씁니다. 지역 변수는 각 스레드의 스택에 있으므로 거짓 공유가 생기지 않습니다. 반복문 안에서 partial[slot] += index처럼 배열 칸을 직접 갱신하면 결과는 같아도, 여러 코어가 같은 캐시 줄에 쓰는 횟수가 반복 횟수만큼 늘어날 수 있습니다. 계산은 지역 변수로 하고 공유 위치에는 마지막에 한 번만 쓰는 습관은 이런 비용도 줄여 줍니다.
작업 성격에 맞게 스레드 수 정하기
앞 예제에서는 스레드를 4개로 정했는데, 실제로는 몇 개가 알맞을까요? 답은 작업이 시간을 어디에 쓰는지에 따라 다릅니다. 계산만 하는 CPU 중심 작업은 실행 시간 대부분을 RUNNABLE 상태로 보내므로, 코어 수보다 스레드가 많으면 남는 스레드는 코어를 기다릴 뿐입니다. 그래서 보통 코어 수 근처에서 시작합니다. JVM이 쓸 수 있는 코어 수는 Runtime.getRuntime().availableProcessors()로 알 수 있고, 컨테이너에 CPU 제한이 걸려 있으면 그 제한이 반영된 값이 나올 수 있습니다.
CPU 중심 작업에서 스레드를 두 배, 네 배로 늘렸는데 처리량은 그대로이고 전환 횟수만 늘었다면 스레드를 줄이는 편이 낫습니다. 스레드가 많을수록 캐시를 서로 밀어내는 일도 잦아지고, 앞에서 본 거짓 공유까지 겹치면 스레드를 나누기 전보다 오히려 느려질 수도 있습니다.
파일이나 네트워크 응답을 기다리는 시간이 긴 I/O 중심 작업은 사정이 다릅니다. 기다리는 동안에는 코어를 쓰지 않으므로, 그 시간에 다른 스레드가 계산하게 하면 코어를 놀리지 않을 수 있습니다. 예를 들어 작업 하나가 계산에 2ms, 응답 대기에 6ms를 쓴다고 해 봅시다. 이 작업은 8ms 가운데 2ms만 코어를 쓰므로, 코어 하나가 이런 작업 4개를 번갈아 맡으면 쉬지 않고 일할 수 있습니다. 코어가 8개라면 스레드 32개가 출발점이 됩니다. 일반적으로 코어 수를 N, 작업 하나의 계산 시간을 C, 대기 시간을 W라고 하면 출발점은 N×(C+W)/C입니다.
이 식에는 몇 가지 가정이 숨어 있습니다. 모든 작업의 계산 시간과 대기 시간이 비슷하고, 기다리는 동안 다른 자원은 부족하지 않으며, 스레드끼리 락을 두고 다투지 않는다는 가정입니다. 실제로는 데이터베이스 연결 수나 외부 API의 호출 한도 같은 외부 자원이 먼저 한계가 되는 경우가 많습니다. 예를 들어 데이터베이스 연결이 12개뿐이라면, 데이터베이스를 쓰는 스레드를 32개 두어도 나머지 20개는 연결을 기다리기만 합니다. 그래서 식으로 구한 값과 외부 한도 가운데 작은 쪽을 첫 시험값으로 고릅니다.
다음 프로그램은 이 계산을 그대로 옮긴 것입니다. trialWorkers는 N×(C+W)/C를 올림한 값과 외부 한도 가운데 작은 쪽을 돌려줍니다. 올림 나눗셈에 쓴 Math.ceilDiv(a, b)는 a를 b로 나눈 몫을 올림한 정수를 돌려주는 메서드입니다. 입력값이 의미 없는 범위이면 trialWorkers는 계산하기 전에 IllegalArgumentException을 던집니다. 특히 계산 시간 C는 0보다 커야 하는데, 0이면 나눗셈을 할 수 없고 계산하지 않는 작업에는 이 식이 애초에 맞지 않기 때문입니다.
public final class WorkerBudgetAdvisor {
public static void main(String[] args) {
print(8, 10, 0, 32);
print(8, 2, 2, 32);
print(8, 2, 6, 32);
print(8, 2, 6, 12);
}
private static int trialWorkers(int cores, int computeMillis, int waitMillis, int externalLimit) {
if (cores <= 0 || computeMillis <= 0 || waitMillis < 0 || externalLimit <= 0) {
throw new IllegalArgumentException("invalid model inputs");
}
int candidate = Math.ceilDiv(cores * (computeMillis + waitMillis), computeMillis);
return Math.min(candidate, externalLimit);
}
private static void print(int cores, int computeMillis, int waitMillis, int externalLimit) {
int workers = trialWorkers(cores, computeMillis, waitMillis, externalLimit);
System.out.println("cores=" + cores + ", compute=" + computeMillis + "ms, wait=" + waitMillis
+ "ms, limit=" + externalLimit + " -> trial-workers=" + workers);
}
}cores=8, compute=10ms, wait=0ms, limit=32 -> trial-workers=8
cores=8, compute=2ms, wait=2ms, limit=32 -> trial-workers=16
cores=8, compute=2ms, wait=6ms, limit=32 -> trial-workers=32
cores=8, compute=2ms, wait=6ms, limit=12 -> trial-workers=12첫 줄은 기다리는 시간이 없는 CPU 중심 작업이라 코어 수 8이 그대로 나왔습니다. 둘째 줄은 계산과 대기가 반반이므로 코어 하나가 작업 두 개를 맡아 16이 되고, 셋째 줄은 앞에서 손으로 계산한 32입니다. 넷째 줄은 식으로는 32이지만 외부 한도 12가 더 작아서 12가 되었습니다.
이 값은 측정을 시작할 첫 시험값일 뿐, 가장 좋은 스레드 수를 보장하지 않습니다. 실제 계산 시간과 대기 시간은 작업마다 다르고 부하에 따라 변하므로, 이 값에서 출발해 다음 절의 방법으로 재 보며 조정해야 합니다. 이 식은 『Java Concurrency in Practice』에 소개된 스레드 풀 크기 공식 N×U×(1+W/C)에서 목표 CPU 사용률 U를 100%로 둔 것과 같습니다.
스레드 수에 상한을 두지 않는 것은 위험합니다. 예를 들어 요청마다 스레드를 만드는 서버에서 데이터베이스가 느려지면, 끝나지 않은 요청의 스레드가 계속 쌓입니다. 처리하지 못한 일이 줄을 서는 장소가 대기열에서 스레드로 바뀌었을 뿐이고, 스레드마다 스택 메모리를 차지하므로 결국 더 이상 스레드를 만들 수 없다는 OutOfMemoryError로 이어질 수 있습니다. 그래서 스레드 수와 함께 대기열의 크기, 일이 넘칠 때 거절할지 기다리게 할지, 얼마나 기다리다 포기할지를 함께 정해야 합니다. 이 정책은 ThreadPoolExecutor의 스레드 수와 작업 큐에서 다룹니다.
기다리는 작업이 아주 많다면 Java 21에서 정식 기능이 된 가상 스레드도 선택지입니다. 가상 스레드는 JVM이 적은 수의 플랫폼 스레드 위에서 번갈아 실행하는 가벼운 스레드라서 수만 개를 만들어도 부담이 작습니다. 하지만 코어 수를 늘려 주지는 않으므로 CPU 중심 작업에는 도움이 되지 않고, 데이터베이스 연결 같은 외부 한도도 그대로 남습니다. 자세한 내용은 가상 스레드와 ScopedValue에서 배웁니다.
스레드 수를 바꿔 가며 측정하기
스레드가 너무 많아 전환에 시간을 낭비하는지는 숫자 하나로 판단할 수 없고, 여러 지표를 같은 구간에서 함께 봐야 합니다. CPU 사용률은 높은데 처리량이 낮고, 실행을 기다리는 스레드 수와 비자발적 전환 횟수가 함께 늘었다면 스레드끼리 코어를 두고 다투는 상황을 의심할 수 있습니다. 반대로 CPU 사용률이 낮고 I/O 대기 시간이 길다면 병목은 계산 스레드 수가 아니라 디스크나 네트워크 쪽일 가능성이 큽니다.
비교 실험을 할 때는 입력과 기대 결과를 고정하고 스레드 수만 1, 2, 4, 8처럼 바꿉니다. 설정마다 JIT 컴파일이 끝나도록 충분히 예열한 뒤 여러 번 재고, 평균보다는 중앙값과 p95 같은 느린 쪽 응답 시간을 봅니다. p95는 응답 시간을 짧은 것부터 늘어놓았을 때 95% 지점의 값으로, 요청 100개 가운데 95개가 그 시간 안에 끝났다는 뜻입니다. 스레드를 늘려도 처리량이 더 늘지 않고 전환 횟수와 CPU 시간만 늘기 시작하는 지점이 있다면, 그 바로 앞의 설정이 합리적인 출발점입니다. 그사이에 힙 사용량이나 외부 서비스의 응답 속도도 함께 변했다면, 스레드 수의 효과와 분리해 다시 재야 합니다.
측정 도구로는 Java 쪽에서 JFR(JDK Flight Recorder)이 스레드 상태와 CPU 사용을 기록해 주며, 사용법은 JFR로 실행 기록 남기고 분석하기에서 다룹니다. Linux에서 vmstat 1을 실행하면 시스템 전체의 상황이 1초마다 한 줄씩 출력되는데, cs 열이 초당 문맥 전환 횟수이고 r 열이 실행 중이거나 실행 차례를 기다리는 스레드 수입니다. 첫 줄은 부팅한 뒤의 평균이므로 둘째 줄부터 읽습니다. 스레드 하나하나의 전환 횟수는 /proc/<pid>/task/<tid>/status 파일의 voluntary_ctxt_switches(자발적)와 nonvoluntary_ctxt_switches(비자발적) 값으로 볼 수 있습니다.
/proc/<pid>/status에도 같은 이름의 값이 있지만, 이것은 번호가 PID와 같은 스레드 하나의 값이지 프로세스 전체의 합계가 아닙니다. 이 글의 환경에서 스레드 네 개가 5밀리초씩 자다 깨기를 되풀이하는 Java 프로그램을 살펴보니, 스레드마다 자발적 전환이 160번을 넘었는데도 이 파일의 값은 1이었습니다. Linux의 java 실행기는 JVM과 main 스레드를 새 스레드에서 실행하고, PID와 번호가 같은 처음 스레드는 그 스레드가 끝나기를 기다리기만 하기 때문입니다.
측정하는 코드 자체가 결과를 흐리지 않게 하는 것도 중요합니다. 작업마다 로그를 한 줄씩 출력하면 출력하는 동안의 대기가 측정 대상에 섞이므로, 측정하는 동안에는 작업 안의 로그를 뺍니다. 앞의 ThreadCreationCost처럼 예열 없이 짧게 잰 시간에는 JIT 컴파일과 가비지 컬렉션의 영향이 섞여 있다는 점도 기억해야 합니다. 그런 측정은 자릿수를 가늠하는 데만 쓰고 세밀한 비교에는 쓰지 않습니다.
정리
- 컨텍스트 스위칭은 운영체제가 코어에서 실행할 스레드를 바꾸는 일입니다. 현재 스레드의 레지스터 값을 저장하고 다음 스레드의 레지스터 값을 복원하며, 같은 프로세스라면 힙과 각 스레드의 스택은 메모리에 그대로 둡니다.
- 전환은 스레드가 기다릴 때(자발적)와 운영체제가 코어를 빼앗을 때(비자발적) 일어나고, 언제 일어날지는 Java 코드가 제어할 수 없습니다.
- 전환에는 저장·복원의 직접 비용과 캐시를 다시 채우는 간접 비용이 듭니다. 스레드를 만들고 끝내는 비용은 이와 별도이며, 이 글의 환경에서는 아주 작은 작업 하나보다 수백 배 이상 컸습니다.
- 작은 작업이 많으면 스레드 수를 먼저 정하고 각 스레드가 여러 작업을 맡게 합니다. 쓰는 칸을 나누고 모든
join()뒤에 결과를 읽어야 합니다. - CPU 중심 작업은 코어 수 근처에서, I/O 중심 작업은 N×(C+W)/C와 외부 한도 가운데 작은 값에서 시작하고, 반드시 측정하며 조정합니다.
연습 문제
1. 다섯 스레드로 제곱합 구하기
1부터 100까지의 제곱합을 계산하되, 새 스레드는 정확히 다섯 개만 만드세요. 각 수는 정확히 한 스레드가 한 번만 처리해야 하고, main은 모든 스레드가 끝난 뒤 결과를 출력해야 합니다.
스레드 0이 1, 6, 11, …, 96을, 스레드 4가 5, 10, 15, …, 100을 맡도록 나누는 것부터 생각해 보세요. 각 스레드가 20개씩 맡게 되고, 양 끝의 1과 100도 빠지지 않아야 합니다. 제곱을 더하는 계산과 부분합을 어느 칸에 쓸지를 따로 생각하면, 여러 스레드가 같은 칸을 고치는 실수를 피할 수 있습니다.
정답과 해설
스레드 번호가 slot이면 slot + 1부터 시작해 5씩 건너뛰며 맡습니다. 어떤 수 n을 맡는 스레드는 (n − 1)을 5로 나눈 나머지로 정해지므로, 1부터 100까지의 모든 수가 정확히 한 스레드에 배정됩니다. 각 스레드는 부분합을 지역 변수에 모았다가 partial의 자기 칸에 한 번만 쓰고, main은 모든 스레드를 join()한 뒤 배열을 읽습니다. 이 범위에서는 int로 계산해도 넘치지 않지만, 범위를 넓혀도 안전하도록 (long) n * n처럼 long으로 계산했습니다.
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);
}
}workers=5, square-sum=3383501부터 n까지의 제곱합 공식 n(n+1)(2n+1)/6에 100을 넣으면 100×101×201/6 = 338350이므로 결과가 맞습니다. 다섯 스레드가 끝나는 순서는 실행마다 다를 수 있지만, 출력하는 것은 모든 join() 뒤의 합 하나뿐이므로 출력은 항상 같습니다.
2. 입력 수가 스레드 수로 나누어떨어지지 않을 때
BoundedTinyTaskRunner에서 스레드는 4개 그대로 두고, 처리할 값만 0부터 9까지 10개로 바꾼다고 해 봅시다. 반복 조건의 index < 64를 index < 10으로 바꾸고, 나머지 코드와 모든 join()은 그대로 둡니다.
- slot 0부터 3까지 각 스레드가 맡는 값과 부분합, 전체 합을 구하세요. 스레드마다 맡는 값의 개수가 다른데, 이 분할은 올바른가요?
- 쓰는 칸이 서로 다르니
join()을 모두 빼고 바로 합쳐도 된다는 주장을 평가하세요.
정답과 해설
- slot 0은 0, 4, 8을 맡아 12, slot 1은 1, 5, 9를 맡아 15, slot 2는 2, 6을 맡아 8, slot 3은 3, 7을 맡아 10입니다. 전체 합은 45로 0부터 9까지의 합 9×10/2와 같습니다. 맡는 개수는 3, 3, 2, 2로 다르지만 모든 값이 정확히 한 스레드에 한 번씩 배정되므로 분할은 올바릅니다. 분할이 올바른지는 개수가 같은지가 아니라 빠지거나 겹친 값이 없는지로 판단합니다. 스레드를 시작한 순서도 어떤 값을 누가 맡는지와는 상관이 없습니다.
- 틀린 주장입니다. 서로 다른 칸에 쓰는 것은 두 스레드가 같은 칸을 고치는 충돌을 막아 줄 뿐입니다.
join()없이 읽으면 아직 계산 중인 스레드의 칸은 초깃값 0인 채로 더해질 수 있고, 이미 쓴 값이라도main에서 보인다는 보장이 없습니다. 충분히 오래sleep()한 뒤 읽었더니 맞았다는 실행 결과도join()을 대신하지 못합니다.
3. 첫 시험값 계산하기
코어 4개인 서버에서 작업 하나가 계산에 3ms, 외부 서비스 응답 대기에 7ms를 씁니다. 외부 서비스는 동시 요청을 20개까지만 받습니다. WorkerBudgetAdvisor의 방식으로 첫 시험 스레드 수를 구하세요. 이 수로 설정하면 최대 처리량이 나온다고 말할 수 있을까요?
정답과 해설
작업 하나가 10ms 가운데 3ms만 코어를 쓰므로, 코어 하나가 맡을 수 있는 작업은 10/3, 약 3.33개입니다. 코어가 4개이므로 4×10/3 = 13.33…이고, 올림하면 14입니다. Math.ceilDiv(4 * 10, 3)도 14를 돌려줍니다. 외부 한도 20보다 작으므로 첫 시험값은 14입니다.
이 값으로 최대 처리량이 나온다고 말할 수는 없습니다. 식은 모든 작업이 비슷하고 락 경쟁이나 다른 자원 부족이 없다는 가정에서 나온 출발점일 뿐입니다. 실제로는 14 근처에서 스레드 수를 바꿔 가며 처리량과 p95 응답 시간, 외부 서비스의 부하를 재 보고 정해야 합니다.
다음 글
Thread 생성과 start에서는 Thread를 상속해 스레드를 만드는 방법을 살펴보고, start()와 run()을 호출했을 때 각각 무슨 일이 일어나는지 자세히 비교합니다.