Go의 동시성 모델은 CSP(Communicating Sequential Processes)를 기반으로 한다. 핵심 철학은 하나다.
“Do not communicate by sharing memory; instead, share memory by communicating.”
공유 메모리에 락을 거는 대신, 채널을 통해 데이터를 전달한다. Goroutine이 실행 단위를, Channel이 통신을, sync/atomic 패키지가 보조 동기화를 담당한다.
Goroutine
Goroutine은 Go의 경량 실행 단위다. OS 스레드가 아니다. Go 런타임이 여러 goroutine을 소수의 OS 스레드에 멀티플렉싱한다.
go func() {
// 이 함수는 새 goroutine에서 실행된다
}()
go 키워드 하나로 생성된다. 초기 스택은 수 KB에 불과하고, 필요에 따라 런타임이 자동으로 늘리고 줄인다. OS 스레드라면 수천 개를 만들기 어렵지만, goroutine은 수십만 개를 같은 주소 공간에서 생성할 수 있다.
GMP 스케줄러
Go 런타임은 M:N 스케줄링 모델을 사용한다. 이를 GMP 모델이라 부른다.
flowchart TB
subgraph Runtime["Go Runtime"]
subgraph P1["P (Processor)"]
LRQ1["로컬 큐: G1, G2, G3"]
end
subgraph P2["P (Processor)"]
LRQ2["로컬 큐: G4, G5"]
end
GRQ["글로벌 큐: G6, G7..."]
end
subgraph OS["OS"]
M1["M (OS Thread)"]
M2["M (OS Thread)"]
M3["M (OS Thread)"]
end
P1 --> M1
P2 --> M2
GRQ -.->|"P의 로컬 큐가 비면 가져감"| P1
G(Goroutine). 실행할 함수와 스택을 가진 경량 실행 단위다.
M(Machine). OS 스레드다. 실제 CPU에서 명령을 실행한다.
P(Processor). 논리적 프로세서다. goroutine을 실행하기 위한 컨텍스트를 제공한다. GOMAXPROCS로 P의 수를 설정하며, 기본값은 CPU 코어 수다.
P는 로컬 큐를 가지고 있다. goroutine이 생성되면 현재 P의 로컬 큐에 들어간다. M은 P에 연결되어 로컬 큐의 goroutine을 하나씩 실행한다. 하나의 goroutine이 시스템 콜로 블로킹되면, 런타임은 같은 P의 다른 goroutine을 다른 M으로 옮겨서 실행을 계속한다.
함수 호출당 평균 세 개의 명령어 정도의 오버헤드만 발생한다.
Channel
Channel은 goroutine 간 데이터를 전달하는 타입이 지정된 통신 수단이다.
Unbuffered Channel
ch := make(chan int)
송신자와 수신자가 동시에 준비되어야 전달이 완료된다. 송신자는 수신자가 값을 가져갈 때까지, 수신자는 송신자가 값을 보낼 때까지 블로킹된다. 통신과 동기화가 동시에 이루어진다.
sequenceDiagram
participant G1 as Goroutine 1
participant Ch as Channel (unbuffered)
participant G2 as Goroutine 2
G1->>Ch: 송신 (블로킹)
Note over G1,Ch: G2가 수신할 때까지 대기
G2->>Ch: 수신
Ch-->>G1: 송신 완료
Ch-->>G2: 값 전달
Buffered Channel
ch := make(chan int, 10) // 버퍼 크기 10
버퍼에 여유가 있으면 송신이 즉시 완료된다. 버퍼가 가득 차면 송신자가 블로킹된다. 동시 실행 수를 제한하는 세마포어로 활용할 수 있다.
방향성
채널에 방향을 지정하면 함수의 의도가 명확해진다.
func producer(out chan<- int) { // 송신 전용
out <- 42
}
func consumer(in <-chan int) { // 수신 전용
val := <-in
}
select
select문은 여러 채널 연산 중 준비된 것을 실행한다. 여러 채널을 동시에 대기하거나, 타임아웃을 처리하거나, 비블로킹 연산을 구현할 때 사용한다.
select {
case msg := <-ch1:
handle(msg)
case ch2 <- response:
// 송신 완료
case <-quit:
return
default:
// 어떤 채널도 준비되지 않았을 때
}
default를 포함하면 어떤 채널도 준비되지 않았을 때 블로킹 없이 넘어간다.
주요 패턴
flowchart LR
subgraph FanOut["Fan-Out"]
IN1["입력"] --> W1["Worker 1"]
IN1 --> W2["Worker 2"]
IN1 --> W3["Worker 3"]
end
subgraph FanIn["Fan-In"]
R1["결과 1"] --> OUT1["출력"]
R2["결과 2"] --> OUT1
R3["결과 3"] --> OUT1
end
Fan-Out. 하나의 채널에서 여러 goroutine이 읽어 작업을 분배한다.
Fan-In. 여러 채널의 결과를 하나의 채널로 합친다.
Pipeline. 각 단계가 채널로 연결된 처리 파이프라인이다. 입력 채널에서 읽고, 처리하고, 출력 채널로 보낸다.
sync 패키지
채널이 항상 최선은 아니다. 공유 상태를 보호하는 단순한 경우에는 sync 패키지가 적합하다.
Mutex. 하나의 goroutine만 임계 영역에 접근하도록 보장한다. Lock()과 Unlock()으로 제어한다.
RWMutex. 읽기는 여러 goroutine이 동시에, 쓰기는 독점적으로 접근한다. 읽기가 쓰기보다 빈번한 경우에 효과적이다.
WaitGroup. 여러 goroutine의 완료를 대기한다. Add()로 카운터를 증가시키고, Done()으로 감소시키고, Wait()로 0이 될 때까지 대기한다.
Once. 함수를 정확히 한 번만 실행한다. 초기화에 사용된다.
atomic 패키지
sync/atomic 패키지는 정수나 포인터에 대한 원자적 연산을 제공한다. 락 없이 단일 변수를 안전하게 읽고 쓸 수 있다.
CompareAndSwap(CAS)은 lock-free 알고리즘의 기초가 되는 연산이다. 현재 값이 기대한 값과 같으면 새 값으로 교체하고 true를 반환한다. 다르면 false를 반환하고 아무 것도 하지 않는다.
var counter int64
// 여러 goroutine에서 안전하게 증가
atomic.AddInt64(&counter, 1)
// CAS: 기대값이 맞을 때만 교체
atomic.CompareAndSwapInt64(&counter, oldVal, newVal)
sync 패키지보다 낮은 수준의 도구다. 단순 카운터나 플래그에는 적합하지만, 복잡한 동기화에는 Mutex나 Channel이 낫다.
선택 기준
| 상황 | 도구 |
|---|---|
| goroutine 간 데이터 전달 | Channel |
| 작업 분배, 결과 수집 | Channel (fan-out/fan-in) |
| 공유 상태 보호 (읽기/쓰기) | sync.RWMutex |
| 동시 실행 수 제한 | Buffered Channel |
| 여러 goroutine 완료 대기 | sync.WaitGroup |
| 단순 카운터/플래그 | sync/atomic |
Go 공식 위키에서는 다음과 같이 정리한다. 채널은 소유권 전달, 작업 분배, 비동기 결과 전달에 적합하다. Mutex는 캐시, 상태 보호처럼 공유 자원의 접근 제어에 적합하다. 둘 다 유효한 도구이며, 상황에 따라 선택한다.