홈으로 돌아가기

Go의 멀티스레딩: CPU, 캐시 및 성능 | 분석

Go의 저수준 멀티스레딩 메커니즘 분석: CPU와 캐시 계층 구조 세부 사항부터 MESI 프로토콜까지. 성능 최적화 및 false sharing 방지를 위한 실용적인 권장 사항.

Go의 멀티스레딩 심층 분석: CPU부터 캐시까지
Advertisement 728x90

# Go에서의 멀티스레딩: CPU, 캐시, 스케줄러가 성능에 미치는 영향

현대 Go 애플리케이션은 병렬 처리를 광범위하게 활용하지만, 많은 개발자들이 여러 CPU 코어를 다룰 때 내부에서 무슨 일이 벌어지는지 완전히 파악하지 못합니다. 이 글에서는 멀티스레드 프로그램 성능에 영향을 미치는 저수준 메커니즘을 깊숙이 탐구합니다: 컨텍스트 스위칭 세부 사항부터 캐시 일관성까지. 원자 연산과 메모리 배리어가 왜 필요한지, 그리고 실제 상황에서 그 특이점이 어떻게 드러나는지 설명하겠습니다.

멀티태스킹 메커니즘: 협력적 vs. 선점적

싱글 코어 시스템의 핵심 도전 과제는 여러 작업을 동시에 처리하는 것입니다. 해결책은 두 가지 형태로 등장했습니다:

  • Cooperative multitasking — 작업들이 sched_yield() 같은 시스템 콜을 통해 자발적으로 CPU를 양보합니다. 단점은 뻔합니다: 하나의 걸린 작업이 전체 시스템을 얼어붙게 만듭니다 (Windows 3.1처럼).
  • Preemptive multitasking — 작업들이 타이머에 의해 강제로 중단됩니다. 주요 단계:

- 하드웨어 인터럽트 발생

Google AdInline article slot

- 현재 작업의 컨텍스트를 RAM에 저장

- 스케줄러가 새 작업 선택

- 컨텍스트 복원

Google AdInline article slot

작업 컨텍스트에는 다음이 포함된다는 점을 이해하는 것이 중요합니다:

  • 레지스터 값 (RAX, RBX 등)
  • 명령 포인터 (RIP)
  • 스택 포인터 (RSP)
  • 플래그 레지스터 (RFLAGS)
  • 스택 데이터

컨텍스트 스위칭은 이 데이터를 RAM에 저장해야 하므로 오버헤드가 발생합니다. 현대 CPU에서 컨텍스트 스위칭은 200~300 사이클이 소요되며, 이는 수십 개 명령어를 실행하는 시간과 맞먹습니다.

멀티 코어 시스템 아키텍처와 캐시 계층

멀티 코어 프로세서로의 전환은 공유 메모리 접근 동기화라는 새로운 문제를 불러일으켰습니다. 이를 해결하는 방식을 이해하려면 캐시 계층을 분해해 보아야 합니다:

Google AdInline article slot

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

Advertisement 728x90

다음 읽기