티스토리 뷰

들어가며

이전 글에서 CPU가 멀티코어로 전환한 배경과, 게임 엔진이 그에 맞춰 스레딩 모델을 발전시켜 온 흐름을 살펴봤습니다. 멀티코어를 활용하려면 멀티스레딩이 필수라는 결론까지는 자연스럽습니다. 하지만 실무에서 스레드를 늘린 뒤 오히려 성능이 떨어지는 경험을 해본 분이라면, 멀티스레딩에는 반드시 비용이 따른다는 사실을 체감하셨을 것입니다.

 

멀티스레딩의 비용 중 가장 기본적이면서도 자주 간과되는 것이 컨텍스트 스위칭 오버헤드입니다. 스레드가 교체될 때마다 CPU는 현재 작업 상태를 저장하고 새 작업 상태를 복원해야 하며, 이 과정에서 눈에 보이지 않는 비용이 누적됩니다. 그리고 이 비용의 크기는 작업의 성격 - CPU 바운드인지, I/O 바운드인지 - 에 따라 전혀 다른 의미를 가집니다.

 

이 글에서는 컨텍스트 스위칭의 비용 구조를 직접 비용과 간접 비용으로 나눠 구체적으로 살펴보고, CPU 바운드 작업과 I/O 바운드 작업에서 멀티스레딩이 각각 어떤 효과를 내는지를 정리해 보겠습니다.



컨텍스트 스위칭이란

컨텍스트 스위칭은 OS 스케줄러가 현재 실행 중인 스레드를 중단하고 다른 스레드로 CPU 실행 흐름을 전환하는 과정입니다. 선점형(preemptive) 스케줄링을 사용하는 현대 OS에서는 타임 슬라이스가 만료되거나, 높은 우선순위의 스레드가 실행 가능 상태가 되거나, 현재 스레드가 I/O 대기에 들어가면 컨텍스트 스위칭이 발생합니다.

이 전환 과정은 단순히 "다음 스레드를 실행한다"는 것 이상의 작업을 수반합니다. 비용의 구조를 이해하려면 직접 비용과 간접 비용을 구분해서 볼 필요가 있습니다.



직접 비용 - 상태 저장과 복원

컨텍스트 스위칭의 직접 비용은 CPU가 현재 스레드의 실행 상태를 메모리에 저장하고, 다음 스레드의 상태를 메모리에서 복원하는 과정에서 발생합니다. 구체적으로는 범용 레지스터, 프로그램 카운터, 스택 포인터, 부동소수점 레지스터(FPU/SSE/AVX 상태) 등을 TCB(Thread Control Block)에 기록하고, 새 스레드의 TCB에서 이전 상태를 읽어오는 작업입니다.

여기에 사용자 모드에서 커널 모드로의 전환 비용이 더해집니다. 스케줄링은 커널의 영역이기 때문에, 모드 전환에 따른 권한 변경과 스택 교체가 추가됩니다. 이 직접 비용은 일반적으로 수 마이크로초(μs) 수준입니다. 한 번의 전환만 보면 미미해 보이지만, 초당 수만 번의 컨텍스트 스위칭이 발생하는 시스템에서는 누적 비용이 전체 CPU 시간의 상당 부분을 차지할 수 있습니다.



간접 비용 - 캐시 오염과 TLB 플러시

컨텍스트 스위칭의 실제 비용에서 더 큰 비중을 차지하는 것은 간접 비용입니다. 레지스터를 저장하고 복원하는 것은 수백 사이클 수준이지만, 그 이후에 발생하는 캐시 관련 비용은 수천에서 수만 사이클에 달할 수 있습니다.


캐시 오염(Cache Pollution)

현대 CPU는 메인 메모리보다 훨씬 빠른 캐시 계층(L1, L2, L3)을 통해 데이터 접근 속도를 높입니다. 스레드 A가 실행되는 동안 A가 자주 접근하는 데이터가 캐시에 적재되어 있다가, 스레드 B로 전환되면 B는 자신의 작업 데이터가 필요합니다. B의 데이터가 캐시에 없으면 메인 메모리에서 가져와야 하고, 이 과정에서 A의 데이터가 캐시에서 밀려납니다. 이후 A로 다시 전환되면 A 역시 자신의 데이터를 메인 메모리에서 다시 로드해야 합니다.

이 현상을 캐시 워밍(cache warming) 또는 캐시 오염이라고 합니다. Intel의 아키텍처 최적화 레퍼런스에 따르면, L1 캐시 히트는 약 1ns, L3 캐시 히트는 약 10ns, 메인 메모리 접근은 약 100ns가 소요됩니다. 100배의 차이입니다. 컨텍스트 스위칭 이후 워킹 셋(working set) 전체가 캐시에서 밀려난 상태에서 다시 캐시를 채우는 과정은, 레지스터 저장/복원의 직접 비용보다 훨씬 큰 지연을 만들어냅니다.

이 비용은 워킹 셋의 크기에 비례합니다. 작업 데이터가 L1 캐시에 들어갈 만큼 작으면 캐시 오염의 영향이 적지만, 워킹 셋이 L2나 L3까지 차지하는 경우에는 전환 후 캐시가 완전히 "차가운" 상태에서 시작해야 하므로 비용이 크게 증가합니다.

TLB 플러시

TLB(Translation Lookaside Buffer)는 가상 주소에서 물리 주소로의 변환 결과를 캐시하는 하드웨어입니다. 프로세스 간 전환이 발생하면 가상 주소 공간이 바뀌므로 TLB 항목이 무효화됩니다. 이후 메모리 접근마다 페이지 테이블을 다시 참조해야 하며, 이 비용도 무시할 수 없습니다.

다만 같은 프로세스 내의 스레드 간 전환에서는 가상 주소 공간이 공유되므로 TLB 플러시가 발생하지 않습니다. 이것이 프로세스 기반 병렬 처리(멀티프로세스)보다 스레드 기반 병렬 처리(멀티스레드)가 전환 비용 측면에서 유리한 이유 중 하나입니다.



CPU 바운드 작업에서의 멀티스레딩

작업의 성격에 따라 멀티스레딩의 효과가 극명하게 달라집니다. 먼저 CPU 바운드 작업을 살펴보겠습니다.

CPU 바운드 작업은 실행 시간의 대부분을 CPU 연산에 소비하는 작업입니다. 행렬 곱셈, 이미지 필터 적용, 해시 연산, 물리 시뮬레이션 등이 이에 해당합니다. 이런 작업에서 멀티스레딩의 효과는 물리 코어 수에 의해 상한이 결정됩니다.


4코어 CPU에서 CPU 바운드 작업을 4개 스레드로 분할하면, 각 스레드가 하나의 코어를 점유하여 이상적으로는 4배의 성능 향상을 얻을 수 있습니다. 하지만 스레드 수를 8개, 16개로 늘리면 어떻게 될까요. 물리 코어는 4개뿐이므로 추가 스레드들은 기존 스레드와 코어를 시분할해야 합니다. 이때 컨텍스트 스위칭이 발생하고, 앞서 살펴본 캐시 오염 비용이 더해져 오히려 성능이 저하됩니다.


CPU 바운드 작업에서 컨텍스트 스위칭이 특히 해로운 이유는, 이 작업들이 보통 큰 워킹 셋을 가지고 캐시를 적극적으로 활용하기 때문입니다. 행렬 곱셈을 수행하는 스레드가 전환되면 캐시에 적재해 둔 행렬 데이터가 모두 밀려나고, 복귀 후 다시 로드해야 합니다. 연산 자체는 빠르지만 데이터를 가져오는 시간이 병목이 되는 것입니다.


Amdahl의 법칙으로 보는 현실적 한계

CPU 바운드 작업의 병렬화 효과를 이론적으로 추정할 때 Amdahl의 법칙이 유용합니다. 수식은 다음과 같습니다.

S = 1 / ((1 - P) + P/N)


여기서 S는 전체 속도 향상 배율, P는 병렬화 가능한 비율, N은 코어(또는 스레드) 수입니다.


프로그램의 95%가 병렬화 가능하고(P = 0.95), 16개 코어를 사용한다면(N = 16), 이론적 최대 속도 향상은 S = 1 / (0.05 + 0.95/16) ≈ 10.7배입니다. 16코어를 투입해도 16배가 아닌 10.7배에 그치는 이유는, 전체의 5%에 해당하는 순차 실행 구간이 병목으로 남기 때문입니다. 코어를 무한히 늘려도 이 경우의 최대 속도 향상은 1/0.05 = 20배를 넘을 수 없습니다.

이 법칙이 시사하는 바는 명확합니다. 멀티스레딩을 적용하기 전에 전체 작업에서 병렬화 가능한 비율이 얼마나 되는지를 먼저 측정해야 한다는 것입니다. 병렬화 가능 비율이 50%라면 코어를 아무리 늘려도 최대 2배 향상에 그칩니다. 이런 경우에는 멀티스레딩보다 순차 구간의 알고리즘을 최적화하는 것이 더 효과적입니다.

Gustafson의 법칙 - 다른 관점

Amdahl의 법칙은 문제 크기가 고정되어 있다는 전제를 가지고 있습니다. 하지만 현실에서는 코어 수가 늘어나면 더 큰 문제를 풀려는 경향이 있습니다. 1988년 John Gustafson이 제시한 Gustafson의 법칙은 이 관점을 반영합니다.

게임을 예로 들면, 4코어에서 500명의 NPC를 처리하던 게임이 16코어에서는 같은 시간 안에 2,000명의 NPC를 처리하는 것을 목표로 할 수 있습니다. 문제 크기가 코어 수에 비례해 커지는 이 경우에는 Amdahl이 제시한 것보다 훨씬 나은 확장성을 얻을 수 있습니다. 게임에서 월드 크기를 늘리거나 시뮬레이션 정밀도를 높이는 것도 같은 맥락입니다.


두 법칙이 서로 모순되는 것이 아니라, 문제를 바라보는 전제가 다릅니다. 고정된 크기의 작업을 더 빨리 끝내야 하는 상황에서는 Amdahl의 법칙이 현실적 가이드이고, 같은 시간 안에 더 많은 일을 하려는 상황에서는 Gustafson의 법칙이 더 적절한 모델입니다.



I/O 바운드 작업에서의 멀티스레딩

I/O 바운드 작업은 CPU 바운드와 근본적으로 다른 특성을 가지고 있습니다. 디스크 읽기/쓰기, 네트워크 요청, 데이터베이스 쿼리 등 외부 장치의 응답을 기다리는 시간이 전체 실행 시간의 대부분을 차지하는 작업입니다.

핵심적인 차이는 이것입니다. I/O 대기 중에는 CPU가 아무 일도 하지 않고 유휴 상태에 놓인다는 점입니다. CPU 바운드에서는 코어 수를 초과하는 스레드가 해로웠지만, I/O 바운드에서는 오히려 코어 수보다 훨씬 많은 스레드가 유효합니다. 하나의 스레드가 I/O 응답을 기다리는 동안 다른 스레드가 CPU를 사용할 수 있기 때문입니다.


웹 서버에서의 전형적 사례

Java 기반 웹 서버에서 하나의 HTTP 요청을 처리하는 과정을 생각해 보겠습니다. 요청을 파싱하고(0.1ms), DB 쿼리를 실행하고(5ms), 외부 API를 호출하고(50ms), 응답을 조합합니다(0.5ms). 전체 소요 시간은 약 55.6ms이지만, 이 중 CPU가 실제로 연산하는 시간은 0.6ms에 불과합니다. 나머지 55ms는 I/O 대기입니다.

이 상황에서 스레드가 1개라면 초당 약 18개의 요청만 처리할 수 있습니다. 하지만 스레드를 100개로 늘리면, 한 스레드가 DB 응답을 기다리는 55ms 동안 다른 스레드들이 CPU를 사용하여 자신의 요청을 처리할 수 있습니다. CPU는 4코어뿐이지만, 각 스레드가 CPU를 사용하는 시간이 극히 짧기 때문에 100개의 스레드가 코어를 충분히 공유할 수 있습니다.

이 경우의 컨텍스트 스위칭은 CPU 바운드와 달리 "유용한 전환"입니다. 어차피 CPU가 유휴 상태에 놓일 시간을 다른 스레드에게 넘겨주는 것이므로, 전환 비용보다 얻는 이득이 훨씬 큽니다. 캐시 오염 비용도 상대적으로 덜 문제가 됩니다. I/O 바운드 스레드는 보통 워킹 셋이 작고, 캐시에 의존하는 연산량 자체가 적기 때문입니다.



스레드 수를 결정하는 원칙

지금까지의 내용을 종합하면, 적정 스레드 수는 작업의 성격에 따라 완전히 다르게 산정해야 합니다.


CPU 바운드의 경우

CPU 바운드 작업의 적정 스레드 수는 물리 코어 수에 맞추는 것이 기본 원칙입니다.

N_threads ≈ N_cores


하이퍼스레딩(SMT)이 지원되는 환경에서는 논리 코어 수까지 시도해 볼 수 있습니다. 하이퍼스레딩은 하나의 물리 코어에서 두 개의 논리 스레드를 실행하되, 실행 유닛을 공유하는 방식입니다. 워크로드에 따라 10~30% 정도의 추가 성능 향상을 얻을 수 있지만, 두 논리 스레드가 같은 실행 유닛과 캐시를 경쟁하므로 오히려 역효과가 나는 경우도 있습니다. 이 부분은 실측으로 확인하는 것이 확실합니다.


I/O 바운드의 경우

I/O 바운드 작업에서는 Brian Goetz가 "Java Concurrency in Practice"에서 제시한 공식이 실무적으로 많이 참조됩니다.

N_threads = N_cores × (1 + W/C)


여기서 W는 대기 시간(Wait time), C는 연산 시간(Compute time)입니다. 앞의 웹 서버 예시에서 W = 55ms, C = 0.6ms라면 W/C ≈ 92이므로, 4코어 기준으로 N_threads = 4 × (1 + 92) = 372가 됩니다. 물론 이 수치는 이론적 추정이며, 실제로는 메모리 소비, OS의 스레드 관리 비용, 커넥션 풀 크기 등의 제약으로 인해 이보다 작은 값에서 최적점을 찾게 됩니다.


Little's Law(L = λW)도 적정 동시 스레드 수를 추정하는 데 유용합니다. 여기서 L은 시스템 내 평균 요청 수(동시 스레드 수), λ는 요청 도착률(처리량), W는 평균 체류 시간(응답 시간)입니다. 목표 처리량이 초당 1,000건이고 평균 응답 시간이 200ms라면, 필요한 동시 스레드 수는 L = 1000 × 0.2 = 200개입니다.


혼합 워크로드의 경우

현실의 애플리케이션은 순수하게 CPU 바운드이거나 I/O 바운드인 경우가 드뭅니다. 하나의 요청 안에서도 JSON 파싱(CPU)과 DB 쿼리(I/O)가 섞여 있습니다. 이런 혼합 워크로드에서 하나의 스레드 풀로 모든 작업을 처리하면 문제가 생길 수 있습니다.

CPU 바운드 작업이 스레드 풀의 스레드를 장시간 점유하면, I/O 바운드 작업이 스레드를 할당받지 못해 대기하는 상황이 발생합니다. 반대로 I/O 바운드 작업에 맞춰 스레드 풀을 크게 잡으면, CPU 바운드 작업 실행 시 과도한 컨텍스트 스위칭이 발생합니다.


실무적 접근은 스레드 풀을 성격별로 분리하는 것입니다. CPU 바운드 작업 전용 풀은 코어 수에 맞추고, I/O 바운드 작업 전용 풀은 W/C 비율에 따라 더 크게 설정합니다. Java의 ForkJoinPool과 별도의 I/O 전용 ExecutorService를 분리하는 것이 대표적인 패턴입니다. 게임 엔진에서도 물리/AI 연산용 워커 풀과 에셋 로딩용 I/O 풀을 분리하는 것이 일반적입니다.



마치며

멀티스레딩의 비용 구조를 정리하면서 다시 확인하게 된 점이 있습니다. 스레드 수를 늘리는 것 자체는 단순한 작업이지만, 그것이 성능 향상으로 이어지는지는 작업의 성격과 비용 구조에 대한 이해 없이는 판단할 수 없다는 것입니다.


CPU 바운드 작업에서는 물리 코어 수가 상한이고, 이를 넘기면 컨텍스트 스위칭과 캐시 오염으로 인해 역효과가 납니다. I/O 바운드 작업에서는 대기 시간을 활용하기 위해 코어 수를 크게 넘는 스레드가 유효합니다. 그리고 현실의 워크로드는 이 둘이 섞여 있으므로, 성격이 다른 작업을 같은 스레드 풀에서 처리하지 않는 것이 기본적인 원칙입니다.


다음 글에서는 이론에서 구현으로 넘어갑니다. C++과 Java에서 멀티스레딩을 구현하는 방식을 구체적인 코드와 함께 비교하고, 두 언어가 동시성 문제를 서로 다른 철학으로 해결하는 방식을 살펴보겠습니다.



참고 출처

  • Intel 64 and IA-32 Architectures Optimization Reference Manual
  • Amdahl, G. (1967). "Validity of the single processor approach to achieving large scale computing capabilities". AFIPS Conference Proceedings.
  • Gustafson, J. (1988). "Reevaluating Amdahl's Law". Communications of the ACM, 31(5).
  • Goetz, B. et al. (2006). "Java Concurrency in Practice". Addison-Wesley. — 스레드 수 산정 공식(Chapter 8.2)
  • Little, J.D.C. (1961). "A Proof for the Queuing Formula: L = λW". Operations Research, 9(3).