Go에서의 저수준 동기화: 원자 연산, 배리어, 그리고 명령어 재배치
프로세서 수준의 동기화 메커니즘을 이해하는 것은 학문적 사치품이 아닙니다—멀티스레드 Go 애플리케이션을 만드는 개발자들에게 실질적인 필수 사항입니다. 이 글에서는 CPU가 코드 실행을 어떻게 최적화하는지, 이로 인해 동시성 프로그램의 예상 로직이 왜 깨지는지, 그리고 순서를 복원해주는 하드웨어 프리미티브는 무엇인지 자세히 살펴보겠습니다.
프로세서가 프로그래머를 현혹하는 방법: 재배치와 순서 외 실행
x = 1; ready = true를 작성하면 엄격한 순차적 순서를 기대하죠. 하지만 프로세서는? 전혀 그렇지 않습니다. 파이프라인 공백을 메우기 위해 독립적인 명령어들을 재배치합니다. 이것을 명령어 재배치라고 합니다. 목표는 실행 유닛의 활용률을 극대화하고 메모리 지연을 숨기는 것입니다.
CPU는 순서 외 실행을 사용합니다: 명령어는 프로그램 순서대로가 아니라 피연산자가 준비되는 대로 실행됩니다. 예를 들어:
MOVQ A(SB), AX ; load from memory
MOVQ B(SB), BX ; load from memory
ADDQ DX, CX ; add registers
프로세서는 피연산자가 이미 레지스터에 있어서 ADDQ를 먼저 시작할 수 있고, 로드는 백그라운드로 보내—they'll take hundreds of cycles. 현재 스레드의 논리적 결과는 동일하지만 다른 스레드는 다르게 볼 수 있습니다.
혼란이 발생하는 파이프라인 단계:
- Fetch → Decode → Register Rename → Dispatch → Execute → Commit
핵심 단계: Dispatch. 여기서 스케줄러는 원래 순서를 무시하고 준비된 피연산자를 가진 명령어를 선택합니다. Reorder Buffer (ROB)는 명령어가 아키텍처 상태에 올바른 순서로 커밋되도록 보장하지만—현재 코어에만 해당합니다.
메모리 재배치: 스토어 버퍼가 등을 돌릴 때
문제는 메모리 수준에서 더 심각해집니다. RAM에 대한 쓰기는 즉시 일어나지 않습니다—스토어 버퍼에 임시 저장된 후 캐시로 비동기적으로 플러시됩니다. 한 코어에서는:
x = 1
ready = true
다른 코어에서는 이렇게 보일 수 있습니다:
ready = true
x = 0
ready=true 쓰기가 스토어 버퍼를 더 일찍 떠날 수 있기 때문입니다. 이것이 메모리 재배치—다른 스레드에 보이는 메모리 작업의 재배치입니다. 전형적인 데이터 레이스 예제:
G1:
x = 1
ready = true
G2:
if ready {
print(x) // might print 0!
}
메모리 배리어: MFENCE와 순서 복원을 위한 다른 도구들
해결책: 메모리 펜스(메모리 배리어). 이 명령어들은 코어 간 작업 가시성을 강제 동기화합니다. x86에서는 주요 배리어가 MFENCE입니다:
MOVQ $1, x(SB)
MFENCE ; barrier
MOVQ $1, ready(SB)
MFENCE는 이전의 모든 스토어가 이후 작업 실행 전에 모든 코어에 보이도록 보장합니다. 네 가지 재배치 유형을 금지합니다:
- Store → Store
- Load → Load
- Load → Store
- Store → Load
중요: x86은 상대적으로 강력한 메모리 모델을 가지며 많은 재배치가 이미 하드웨어에서 금지되어 있습니다. 하지만 Store→Load는 여전히 가능하므로 엄격한 동기화를 위해 MFENCE가 필수입니다.
다양한 아키텍처에서의 배리어 유형:
- x86: MFENCE (full), SFENCE (store→store), LFENCE (load→load)
- ARM: DMB (Data Memory Barrier), ISH (inner shareable domain)
- RISC-V: FENCE (flexible, with direction specifiers)
원자 연산: LOCK 위의 고수준 추상화
Go는 sync/atomic 패키지의 원자 연산 뒤에 배리어를 숨깁니다. 내부적으로는 모든 코어에서 읽기-수정-쓰기 작업을 분할 불가능하게 만드는 LOCK 접두어를 사용합니다. XADDQ 예제:
TEXT ·Xadd64(SB), NOSPLIT, $0-24
MOVQ ptr+0(FP), BX
MOVQ delta+8(FP), AX
MOVQ AX, CX
LOCK
XADDQ AX, 0(BX) ; atomically: tmp=*ptr; *ptr+=AX; AX=tmp
ADDQ CX, AX
MOVQ AX, ret+16(FP)
RET
LOCK은 MESI 프로토콜에서 캐시 라인을 잠그는 방식으로 작동합니다:
- 코어가 캐시 라인에 대한 배타적 권한을 획득합니다
- 작업을 수행합니다
- 라인을 해제합니다—변경 사항이 다른 코어에 보이게 됩니다
원자성 ≠ 순서 보장! 원자 연산은 분할 불가능성을 보장하지만 반드시 메모리 배리어를 만들지 않습니다. x86에서 간단한 로드/스토어는 원자적이지만 순서를 제공하지 않습니다.
Go 어셈블러로 직접 만드는 원자 AND: 실습
Go 표준 라이브러리에는 atomic.And가 없습니다. 직접 구현해 보죠. Xand8_x86.s 파일을 만듭니다:
#include "textflag.h"
TEXT ·And8(SB), NOSPLIT, $0-9
MOVQ ptr+0(FP), BX // BX = pointer
MOVB mask+8(FP), AX // AX = mask (8 bits)
LOCK
ANDB AX, 0(BX) // *ptr &= mask
RET
그리고 해당 main.go:
package main
import "C"
func And8(ptr *uint8, mask uint8)
func main() {
var smth uint8 = 3
And8(&smth, uint8(2))
println("Result:", smth) // outputs 2
}
go run .으로 빌드하고 실행하세요—ASM 파일이 자동으로 인식됩니다. 주의: x86에 XAND 명령어가 없음에도 LOCK 접두어와 ANDB를 사용합니다. 작동합니다—LOCK은 기본 논리 연산과 호환됩니다.
주요 요점
- 명령어 재배치 — 성능 향상을 위해 독립적인 명령어의 실행 순서를 바꾸는 CPU 최적화.
- 메모리 재배치 — 스토어 버퍼로 인한 코어 간 쓰기 작업 가시성의 비동기화.
- 메모리 배리어 (펜스) — 스레드 간 메모리 작업 순서를 강제 동기화하는 명령어.
- 원자 연산 — LOCK을 통해 구현된 분할 불가능한 읽기-수정-쓰기 작업으로 배리어를 캡슐화.
- LOCK + ANDB — Go 표준 라이브러리에 없는 비트wise 원자 연산을 구현하는 효과적인 방법.
— Editorial Team
아직 댓글이 없습니다.