# Go에서의 멀티스레딩: CPU, 캐시, 스케줄러가 성능에 미치는 영향
현대 Go 애플리케이션은 병렬 처리를 광범위하게 활용하지만, 많은 개발자들이 여러 CPU 코어를 다룰 때 내부에서 무슨 일이 벌어지는지 완전히 파악하지 못합니다. 이 글에서는 멀티스레드 프로그램 성능에 영향을 미치는 저수준 메커니즘을 깊숙이 탐구합니다: 컨텍스트 스위칭 세부 사항부터 캐시 일관성까지. 원자 연산과 메모리 배리어가 왜 필요한지, 그리고 실제 상황에서 그 특이점이 어떻게 드러나는지 설명하겠습니다.
멀티태스킹 메커니즘: 협력적 vs. 선점적
싱글 코어 시스템의 핵심 도전 과제는 여러 작업을 동시에 처리하는 것입니다. 해결책은 두 가지 형태로 등장했습니다:
- Cooperative multitasking — 작업들이
sched_yield()같은 시스템 콜을 통해 자발적으로 CPU를 양보합니다. 단점은 뻔합니다: 하나의 걸린 작업이 전체 시스템을 얼어붙게 만듭니다 (Windows 3.1처럼).
- Preemptive multitasking — 작업들이 타이머에 의해 강제로 중단됩니다. 주요 단계:
- 하드웨어 인터럽트 발생
- 현재 작업의 컨텍스트를 RAM에 저장
- 스케줄러가 새 작업 선택
- 컨텍스트 복원
작업 컨텍스트에는 다음이 포함된다는 점을 이해하는 것이 중요합니다:
- 레지스터 값 (RAX, RBX 등)
- 명령 포인터 (RIP)
- 스택 포인터 (RSP)
- 플래그 레지스터 (RFLAGS)
- 스택 데이터
컨텍스트 스위칭은 이 데이터를 RAM에 저장해야 하므로 오버헤드가 발생합니다. 현대 CPU에서 컨텍스트 스위칭은 200~300 사이클이 소요되며, 이는 수십 개 명령어를 실행하는 시간과 맞먹습니다.
멀티 코어 시스템 아키텍처와 캐시 계층
멀티 코어 프로세서로의 전환은 공유 메모리 접근 동기화라는 새로운 문제를 불러일으켰습니다. 이를 해결하는 방식을 이해하려면 캐시 계층을 분해해 보아야 합니다:
L1 Cache
- 크기: 32~64 KB
- 지연: ~4 사이클
- 명령어(L1i)와 데이터(L1d)로 분리
- 물리적 코어에 바인딩
L2 Cache
- 크기: 256 KB ~ 1 MB
- 지연: 10~15 사이클
- 보통 코어에 바인딩
L3 Cache
- 크기: 8~40 MB
- 지연: 30~50 사이클
- 모든 코어 간 공유
핵심: 프로세서는 하드웨어 컨트롤러를 통해 캐시를 관리합니다. OS와 앱은 데이터 배치를 직접 제어할 수 없습니다. 이로 인해 서로 다른 코어가 오래된 데이터 복사본으로 작업하는 상황이 발생합니다.
MESI 프로토콜과 캐시 일관성
캐시 일관성은 MESI 프로토콜(Modified, Exclusive, Shared, Invalid)에 의해 처리됩니다. 각 캐시 라인(일반적으로 64바이트)은 네 가지 상태 중 하나를 갖습니다:
- Modified: 이 캐시에서 데이터가 변경되어 RAM과 다름
- Exclusive: 데이터가 RAM과 일치하며 이 코어만 접근 가능
- Shared: 데이터가 RAM과 일치하며 다른 캐시에 있을 수 있음
- Invalid: 데이터가 오래되어 사용 불가
쓰기 작업 예시:
- 코어 A가 변수 읽기 → Shared 상태
- 코어 B가 같은 변수 읽기 → Shared 상태
- 코어 A가 새 값 쓰기 → 무효화 요청 전송
- 코어 B가 요청을 받고 라인을 Invalid로 설정
이 메커니즘은 숨겨진 지연을 유발합니다: 경쟁 쓰기는 코어들이 버스를 통해 신호를 교환하게 하며, 100~300 사이클이 걸릴 수 있습니다.
Go 개발자를 위한 실전적 함의
이 메커니즘을 이해하는 것은 다음 작업에서 필수입니다:
- Atomic operations:
LOCK XCHG명령어가 버스 락킹을 통해 원자성을 보장 - Memory barriers:
mfence가 명령어 실행 순서를 제어 - CPU affinity:
GOMAXPROCS를 통해 고루틴을 특정 코어에 바인딩
false sharing 예시:
var data [2]int64
// Goroutine 1
for {
data[0]++
}
// Goroutine 2
for {
data[1]++
}
서로 다른 배열 요소에 접근하지만 같은 캐시 라인(64바이트)에 속합니다. data[0] 쓰기가 두 번째 코어의 라인을 무효화시켜 지속적인 동기화를 유발합니다. 해결: 패딩 추가:
var data [2]struct{
value int64
_ [56]byte // Padding to 64 bytes
}
주요 포인트
- 컨텍스트 스위칭은 상태를 RAM에 저장해 ~200 사이클 지연 발생
- MESI를 통한 캐시 일관성이 경쟁 쓰기에 오버헤드 추가
- false sharing이 성능을 2~3 자릿수만큼 떨어뜨릴 수 있음
- 원자 연산은 버스 락킹 또는 캐시 프로토콜 사용 (아키텍처 의존)
- 캐시 계층 이해로 데이터 구조를 캐시 라인 크기에 최적화
Go 개발자들은 고부하 서비스를 구축할 때 이런 문제에 자주 부딪힙니다. 예를 들어, 분산 시스템에서 캐시 처리 미흡은 초당 수천 요청 처리 시 예상치 못한 지연을 초래할 수 있습니다. 핵심 교훈: 효과적인 병렬 처리는 앱 로직뿐 아니라 하드웨어 특성도 고려해야 합니다.
더 깊이 탐구하려면 Go 컴파일러의 어셈블리 출력을 -S 플래그로 확인하고, perf 같은 프로파일러로 캐시 미스와 컨텍스트 스위칭을 측정하세요. 이는 소스 코드 수준에서 보이지 않는 병목을 드러냅니다.
— Editorial Team
아직 댓글이 없습니다.