Go 스케줄러 내부 동작 원리 - GMP 모델 깊이 파헤치기
포스트
취소

Go 스케줄러 내부 동작 원리 - GMP 모델 깊이 파헤치기

최근 회사에서 Golang 을 활발하게 사용하고 있는데, Go 언어의 가장 강력한 특징 중 하나는 고루틴(goroutine)을 통한 경량 동시성이다. go 키워드 하나로 수천, 수만 개의 동시 작업을 생성할 수 있는 것은 Go 런타임의 스케줄러 덕분이다. 공부 차원에서 이 글에서는 Go 스케줄러의 핵심인 GMP 모델의 내부 동작 원리를 살펴보고 정리한다.

자체 스케쥴러? 왜 필요한데

OS 스레드를 직접 사용하면 되지 않을까? 몇 가지 이유로 Go는 자체 스케줄러를 구현했다.

  1. OS 스레드는 비싸다. 스레드 하나당 기본 스택 크기가 1~8MB인 반면, 고루틴은 초기 스택이 2KB에 불과하다. 필요에 따라 동적으로 늘어난다.
  2. 컨텍스트 스위칭 비용도 문제다. OS 스레드 간 전환은 커널 모드 진입이 필요하지만, 고루틴 전환은 유저 스페이스에서 일어난다.
  3. 그리고 Go의 채널이나 select 같은 언어 수준 동시성 프리미티브를 OS 스케줄러가 이해할 수 없다는 근본적인 한계가 있다.

초기 Go(1.0)는 단일 OS 스레드에서 모든 고루틴을 멀티플렉싱하는 단순한 모델을 사용했다고 한다. Go 1.1에서 Dmitry Vyukov가 설계한 현재의 GMP 모델이 도입되면서 멀티코어 활용이 가능해졌다.

GMP 모델의 세 가지 구성 요소

Go 스케줄러는 세 가지 핵심 엔티티로 구성된다.

G (Goroutine)

실행할 작업 단위다. 각 G는 자체 스택, instruction pointer, 그리고 스케줄링에 필요한 메타데이터를 가진다.

1
2
3
4
5
6
7
8
9
// runtime/runtime2.go (simplified)
type g struct {
    stack       stack   // 스택 메모리 범위
    stackguard0 uintptr // 스택 오버플로우 검사용
    m           *m      // 현재 실행 중인 M
    sched       gobuf   // 스케줄링 컨텍스트 (SP, PC 등)
    atomicstatus uint32 // 고루틴 상태
    goid         int64  // 고루틴 ID
}

고루틴은 다음과 같은 상태를 가진다:

상태설명
_Gidle방금 할당됨, 아직 초기화되지 않음
_Grunnable실행 큐에 있음, 아직 실행되지 않음
_GrunningM에서 실행 중
_Gsyscall시스템 콜 수행 중
_Gwaiting채널, mutex 등에서 대기 중
_Gdead실행 완료

M (Machine = OS Thread)

실제 OS 스레드를 나타낸다. M은 Go 코드를 실행하려면 반드시 P를 할당받아야 한다.

1
2
3
4
5
6
7
8
// runtime/runtime2.go (simplified)
type m struct {
    g0      *g     // 스케줄링 코드를 실행하는 특수 고루틴
    curg    *g     // 현재 실행 중인 고루틴
    p       puintptr // 연결된 P
    spinning bool  // work stealing 중인지 여부
    park    note   // M을 재울 때 사용
}

g0는 특별한 고루틴으로, 스케줄링 결정이나 시스템 콜 처리 등 런타임 관리 코드를 실행할 때 사용된다. 모든 M은 자신만의 g0를 가진다.

P (Processor = 논리 프로세서)

P는 Go 코드를 실행하기 위한 리소스 컨텍스트다. 로컬 실행 큐(Local Run Queue)를 보유하며, M과 G를 연결하는 중재자 역할을 한다.

1
2
3
4
5
6
7
8
9
10
11
12
// runtime/runtime2.go (simplified)
type p struct {
    m           muintptr   // 연결된 M (없을 수 있음)
    runqhead    uint32
    runqtail    uint32
    runq        [256]guintptr // 로컬 실행 큐 (고정 크기 256)
    runnext     guintptr   // 다음에 실행할 고루틴 (우선)
    gFree       struct {   // 재사용 가능한 dead 고루틴 풀
        gList
        n int32
    }
}

P의 개수는 GOMAXPROCS로 설정되며, 기본값은 사용 가능한 CPU 코어 수다.

스케줄링 루프

Go 스케줄러의 핵심은 runtime.schedule() 함수다. M이 다음에 실행할 G를 찾는 과정을 살펴보자.

1
2
3
4
5
6
7
8
9
// runtime/proc.go (simplified pseudocode)
func schedule() {
    // 1. GC STW가 필요하면 대기
    // 2. 61번째 스케줄링마다 글로벌 큐 확인 (starvation 방지)
    // 3. 로컬 큐에서 G를 가져옴
    // 4. 없으면 findrunnable()로 다른 곳에서 찾음
    // 5. 찾은 G를 실행
    execute(gp)
}

고루틴을 찾는 순서

findrunnable() 함수는 다음 순서로 실행할 고루틴을 탐색한다:

  1. 현재 P의 로컬 큐에서 G를 꺼낸다
  2. 로컬 큐가 비었으면 글로벌 큐를 확인한다
  3. epoll/kqueue 네트워크 폴러에서 준비된 G가 있는지 본다
  4. 그래도 없으면 다른 P의 로컬 큐에서 절반을 훔쳐온다 (Work Stealing)

특히 61번째 스케줄링 사이클마다 글로벌 큐를 먼저 확인하는 것이 중요하다. 이는 글로벌 큐에 있는 고루틴이 무한정 기다리는 기아(starvation) 현상을 방지한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// runtime/proc.go
func schedule() {
    _g_ := getg()

    if _g_.m.p.ptr().schedtick%61 == 0 && sched.runqsize > 0 {
        lock(&sched.lock)
        gp = globrunqget(_g_.m.p.ptr(), 1)
        unlock(&sched.lock)
    }
    if gp == nil {
        gp, inheritTime = runqget(_g_.m.p.ptr())
    }
    if gp == nil {
        gp, inheritTime = findrunnable() // blocks until work is available
    }

    execute(gp, inheritTime)
}

Work Stealing

Work Stealing은 Go 스케줄러의 부하 분산 핵심 메커니즘이다. 한 P의 로컬 큐가 비었을 때, 다른 P의 큐에서 고루틴을 가져온다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// runtime/proc.go (simplified)
func stealWork(pp *p) (gp *g, inheritTime bool) {
    for i := 0; i < 4; i++ {
        // 랜덤한 P를 선택하여 큐의 절반을 훔침
        p2 := allp[fastrand() % gomaxprocs]
        if p2 == pp {
            continue
        }
        if gp := runqsteal(pp, p2); gp != nil {
            return gp, false
        }
    }
    return nil, false
}

훔쳐올 때는 대상 P 로컬 큐의 절반을 가져온다. 하나만 가져오면 금방 다시 훔치러 가야 하고, 전부 가져오면 원래 P가 굶주리기 때문이다.

Spinning Thread

M이 실행할 고루틴을 찾지 못했을 때, 바로 잠들지 않고 spinning 상태로 잠시 대기한다. 이는 새로운 고루틴이 곧 생길 수 있기 때문에 M을 깨우는 비용을 줄이기 위함이다.

1
2
3
4
// spinning 상태의 M은 적극적으로 work를 찾는다
// 하지만 spinning M의 수는 제한된다:
// - 최대 GOMAXPROCS개의 spinning M만 허용
// - 실제로는 보통 idle P의 수만큼만 spinning

Spinning M이 없고 새로운 고루틴이 생성되면, 파킹된 M을 깨우거나 새 M을 생성해야 한다. Spinning M이 있으면 이 비용을 피할 수 있다.

선점(Preemption)

Go 1.14 이전에는 함수 호출 시 컴파일러가 삽입하는 선점 체크포인트에서만 고루틴 전환이 가능했다. 이 방식에는 치명적인 문제가 있었다.

1
2
3
4
5
6
7
8
// 이 코드는 Go 1.13 이하에서 다른 고루틴을 영원히 블록할 수 있었다
func tightLoop() {
    for {
        // 함수 호출이 없는 무한 루프
        // 선점 포인트가 없어서 스케줄러가 개입 불가
        i++
    }
}

비동기 선점 (Go 1.14+)

Go 1.14에서 시그널 기반 비동기 선점이 도입되었다. SIGURG 시그널을 사용하여 실행 중인 고루틴을 강제로 중단할 수 있다.

동작 과정:

  1. sysmon 고루틴이 10ms 이상 실행된 고루틴을 감지
  2. 해당 고루틴이 실행 중인 M에 SIGURG 시그널 전송
  3. 시그널 핸들러가 현재 고루틴의 컨텍스트를 저장하고 스케줄러 호출
  4. 스케줄러가 다른 고루틴으로 전환
1
2
3
4
5
6
// runtime/signal_unix.go (simplified)
func doSigPreempt(gp *g, ctxt *sigctxt) {
    // 현재 PC/SP를 저장하고
    // asyncPreempt로 점프하도록 설정
    ctxt.pushCall(funcPC(asyncPreempt))
}

SIGURG를 선택한 이유가 재미있다. 이 시그널은 일반 애플리케이션에서 거의 사용되지 않고, libc에서 내부적으로 사용하지 않으며, 디버거가 중단점으로 사용하지 않기 때문이다.

Sysmon - 시스템 모니터

sysmon은 독립적으로 실행되는 특별한 M으로, P 없이 동작한다. 주기적으로 시스템 상태를 점검한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
// runtime/proc.go (simplified)
func sysmon() {
    for {
        // 초기에는 20μs마다, 최대 10ms까지 점진적으로 증가
        usleep(delay)

        // 1. 네트워크 폴링 (epoll/kqueue)
        // 2. 10ms 이상 실행 중인 고루틴 선점
        // 3. 오래된 syscall에서 P 회수
        // 4. GC가 필요한지 확인
        retake(now)
    }
}

Syscall 시 P 회수 (Handoff)

고루틴이 시스템 콜에 진입하면, 해당 M은 P를 놓아줄 수 있다. 시스템 콜이 오래 걸리면 sysmon이 P를 강제로 회수하여 다른 M에 할당한다.

1
2
3
4
5
6
7
8
9
10
11
func entersyscall() {
    // P를 현재 M에 연결은 유지하되
    // 상태를 _Psyscall로 변경
    // 다른 M이 이 P를 가져갈 수 있게 됨
}

func exitsyscall() {
    // 원래 P를 되찾으려 시도
    // 실패하면 다른 idle P를 찾음
    // 그것도 없으면 글로벌 큐에 G를 넣고 M은 파킹
}

실전에서의 스케줄러 관찰

GODEBUG=schedtrace

스케줄러의 동작을 실시간으로 확인할 수 있다.

1
2
3
4
5
$ GODEBUG=schedtrace=1000 ./myapp
SCHED 0ms: gomaxprocs=8 idleprocs=5 threads=5 spinningthreads=1
  idlethreads=0 runqueue=0 [0 0 0 0 0 0 0 0]
SCHED 1000ms: gomaxprocs=8 idleprocs=0 threads=10 spinningthreads=0
  idlethreads=2 runqueue=15 [3 4 2 1 0 5 2 3]

각 필드의 의미:

  • gomaxprocs: P의 수
  • idleprocs: 유휴 P의 수
  • threads: 생성된 총 M의 수
  • spinningthreads: work를 찾고 있는 M의 수
  • runqueue: 글로벌 큐에 있는 G의 수
  • [0 0 ...]: 각 P의 로컬 큐에 있는 G의 수

scheddetail=1을 추가하면 각 고루틴과 스레드의 상세 상태도 볼 수 있다.

Go Execution Tracer

더 정밀한 분석에는 runtime/trace 패키지를 사용한다.

1
2
3
4
5
6
7
8
9
10
import "runtime/trace"

func main() {
    f, _ := os.Create("trace.out")
    defer f.Close()
    trace.Start(f)
    defer trace.Stop()

    // ... 분석할 코드 ...
}
1
$ go tool trace trace.out

브라우저에서 각 P에서 어떤 고루틴이 언제 실행되었는지, 언제 블록되었는지 시각적으로 확인할 수 있다.

스케줄러를 고려한 코드 작성

스케줄러의 동작 원리를 이해하면 더 나은 동시성 코드를 작성할 수 있다.

1. 고루틴 수와 GOMAXPROCS

CPU 바운드 작업이라면 GOMAXPROCS 이상의 고루틴을 만들어도 실질적인 병렬성은 증가하지 않는다. 오히려 스케줄링 오버헤드만 늘어난다.

1
2
3
4
5
// CPU 바운드 작업의 경우
numWorkers := runtime.GOMAXPROCS(0)
for i := 0; i < numWorkers; i++ {
    go cpuIntensiveWork(tasks)
}

2. 채널 vs 뮤텍스

채널을 통한 통신에서 고루틴이 블록되면 _Gwaiting 상태로 전환되어 P의 실행 큐에서 빠진다. 이는 스케줄러가 효율적으로 다른 고루틴을 실행할 수 있게 한다. 반면 sync.Mutex에서 짧은 시간 경합이 발생하면 스핀락으로 동작하여 컨텍스트 스위칭을 피한다.

1
2
3
4
5
6
7
8
9
10
11
12
// 채널: 고루틴이 _Gwaiting으로 전환 → 다른 G 실행 가능
ch := make(chan int)
go func() {
    val := <-ch  // 블록 → 스케줄러가 다른 G로 전환
    process(val)
}()

// Mutex: 짧은 경합 시 스핀 → 컨텍스트 스위칭 없음
var mu sync.Mutex
mu.Lock()
// critical section
mu.Unlock()

3. runtime.Gosched()

명시적으로 스케줄러에 양보할 수 있지만, 대부분의 경우 필요하지 않다. 비동기 선점이 도입된 이후로는 더욱 그렇다.

1
2
3
4
5
6
7
// 거의 필요 없지만, CPU 집약적 루프에서 다른 고루틴에 기회를 주고 싶을 때
for i := 0; i < 1e9; i++ {
    if i%1e6 == 0 {
        runtime.Gosched()
    }
    heavyComputation(i)
}

정리

Go 스케줄러의 GMP 모델은 다음과 같은 설계 원칙을 따른다:

  • 로컬 큐를 우선 사용해서 캐시 지역성을 살리고 락 경합을 줄인다
  • Work Stealing으로 P 간 부하를 균등하게 분배한다
  • Spinning Thread로 M을 깨우는 비용을 아낀다
  • 비동기 선점으로 특정 고루틴이 CPU를 독점하지 못하게 한다
  • Handoff로 syscall이 다른 고루틴까지 블록하는 걸 방지한다

go 키워드 뒤에서 이루어지는 이러한 메커니즘 덕분에, 실제 개발자는 수만 개의 고루틴을 별다른 고민 없이 생성하고 효율적으로 활용할 수 있다.

References

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.

무료 사이드 프로젝트에서 Scalable 하게 프로덕션까지 fly.io 활용하기 A to Z

-