- Published on
Go 런타임 스케줄러 Deep Dive — GMP 모델, Work-Stealing, 비동기 Preemption, netpoller 완전 정복 (2025)
- Authors

- Name
- Youngju Kim
- @fjvbn20031
TL;DR
- GMP 모델: G(Goroutine) = 실행 단위, M(Machine) = OS 스레드, P(Processor) = 로컬 런큐를 가진 스케줄링 컨텍스트.
GOMAXPROCS = P의 개수. - Work-Stealing: 각 P는 로컬 런큐(256개)를 가지며, 비면 전역 큐 → netpoller → 다른 P에서 절반을 훔쳐온다. 로컬 경합 없이 멀티코어에 선형 확장.
- 고루틴은 가볍다: 초기 스택 2KB, 필요하면 8KB → 16KB로 복사하며 증가(Copy-on-Grow). OS 스레드(2MB)보다 1000배 작다.
- Preemption 진화: Go 1.13까지는 함수 진입점에서만 협력적 preemption → Go 1.14부터 SIGURG 기반 비동기 preemption으로 타이트 루프도 중단 가능.
- Syscall Handoff: 고루틴이 blocking syscall에 들어가면 M과 P가 분리(handoff)되어, P는 다른 M에 붙어 계속 일한다. "1만 개 동시 연결"이 가능한 핵심 메커니즘.
- Netpoller: epoll/kqueue/IOCP 추상화. blocking I/O를 non-blocking으로 바꾸고, 준비된 고루틴만 런큐로 올린다.
- 실무 튜닝:
GODEBUG=schedtrace=1000,gctrace=1,go tool trace,runtime/pprof로 스케줄러 병목 가시화. 컨테이너에서는GOMAXPROCS를 cgroup CPU quota에 맞춰 설정(Uber automaxprocs).
1. 왜 Go의 동시성은 "특별한가"
1.1 OS 스레드의 한계
OS 스레드는 동시성의 기본 단위지만 비싸다.
| 항목 | OS 스레드 (pthread) | 고루틴 |
|---|---|---|
| 초기 스택 | 2MB (고정) | 2KB (동적 증가) |
| 컨텍스트 스위치 | ~1-2μs (커널 진입) | ~200ns (유저 공간) |
| 생성 시간 | ~10μs | ~200ns |
| 최대 개수 | 수천 ~ 수만 | 수십만 ~ 수백만 |
| 스케줄러 | 커널 (CFS) | Go 런타임 (GMP) |
"C10K" 문제를 기억하는가? 동시 1만 연결을 처리하려면 스레드 1만 개가 필요했고, 40GB 메모리가 증발했다. Node.js는 단일 스레드 + 이벤트 루프로 해결했지만 CPU 집약 작업에 약했다. Go는 경량 스레드(고루틴) + 멀티코어 스케줄러로 둘 다 해결했다.
1.2 M:N 스케줄링
Go는 M:N 스케줄링을 쓴다. M개의 OS 스레드 위에 N개의 고루틴을 매핑하며, N >> M.
유저 공간: G1 G2 G3 G4 G5 G6 G7 G8 G9 ... (수십만)
| | | | | | | | |
+---+---+ +---+---+ +---+---+---+
| | |
Go 런타임: P0 P1 P2 (GOMAXPROCS=3)
| | |
OS: M0 M1 M2 (OS 스레드)
| | |
커널: CPU0 CPU1 CPU2
각 P는 자체 런큐를 가지므로 락 없이 고루틴을 스케줄한다. 이것이 Go 성능의 핵심이다.
1.3 1:1 vs N:1 vs M:N
- 1:1 (Java, C/pthread): 스레드 하나당 커널 스레드 하나. 단순하지만 스케일 X.
- N:1 (초기 Erlang, Node.js 유사): 여러 고루틴이 한 스레드 위에서 돌아감. 경량이지만 멀티코어 활용 X.
- M:N (Go, Erlang BEAM): 둘의 장점을 합친 형태. 구현이 복잡하지만 성능/스케일 최고.
Go가 "Erlang 수준의 동시성을 C 수준의 성능으로" 제공하는 이유다.
2. GMP 모델 해부
2.1 G (Goroutine)
runtime/runtime2.go의 g 구조체:
type g struct {
stack stack // 스택 [lo, hi)
stackguard0 uintptr // 스택 오버플로우 체크용
m *m // 현재 실행 중인 M (없으면 nil)
sched gobuf // 저장된 PC, SP, BP 등
atomicstatus atomic.Uint32 // _Grunnable, _Grunning, _Gwaiting, ...
goid uint64 // 고유 ID
waitreason waitReason // "chan receive", "select", "GC assist wait" 등
// 스케줄러 힌트
preempt bool // true면 다음 preemption check에서 양보
preemptStop bool // 비동기 preemption 표식
preemptShrink bool // 스택 축소 허용
// 트레이싱
traceseq uint64
tracelastp puintptr
}
중요한 필드:
stack: 고루틴의 스택 메모리. 초기 2KB, 필요하면 2배씩 증가.sched: 고루틴이 중단될 때 레지스터 저장 공간 (PC, SP, BP 등).atomicstatus: 상태 머신._Grunnable(대기) →_Grunning(실행) →_Gwaiting(블록) →_Grunnable.preempt: 스케줄러가 "양보해달라"고 표시하는 플래그.
2.2 M (Machine = OS 스레드)
type m struct {
g0 *g // 스케줄링/GC용 특수 고루틴 (각 M마다 1개)
curg *g // 현재 실행 중인 유저 고루틴
p puintptr // 현재 붙어 있는 P (없으면 0)
nextp puintptr // 다음에 붙을 P
oldp puintptr // syscall 전의 P
id int64
mallocing int32
throwing throwType
preemptoff string // "gc", "locks" 등 preempt 금지 이유
locks int32
dying int32
profilehz int32
// park/unpark
park note
alllink *m
schedlink muintptr
// cgo / signal
mOS // OS별 정보 (스레드 ID, TLS 등)
}
M은 OS 스레드 1개에 1:1 대응된다. m.g0는 "스케줄러 고루틴"으로, 유저 고루틴을 실행할 M을 선택하는 일을 한다. 유저 고루틴 ↔ g0 사이를 오가며 스케줄링한다.
2.3 P (Processor)
P는 GMP 모델에서 Go 1.1에 추가된 핵심 개념이다.
type p struct {
id int32
status uint32 // _Pidle, _Prunning, _Psyscall, _Pgcstop, _Pdead
m muintptr // 붙어 있는 M
// 로컬 런큐 (락 없이 접근 가능, 크기 256)
runqhead uint32
runqtail uint32
runq [256]guintptr
// 다음에 실행할 G (우선권)
runnext guintptr
// GC 관련
gcAssistTime int64
gcFractionalMarkTime int64
gcMarkWorkerMode gcMarkWorkerMode
// sudog 캐시 (채널 대기용)
sudogcache []*sudog
sudogbuf [128]*sudog
// mcache (malloc 로컬 캐시)
mcache *mcache
// 타이머 힙
timers []*timer
timerModifiedEarliest atomic.Int64
}
핵심:
runq: 길이 256의 순환 큐. 락 없이 소유 P의 M만 접근. 다른 P가 훔칠 때만 CAS 사용.runnext: "LIFO"처럼 동작하는 1개 슬롯. 최근 ready된 고루틴 우선 실행 → 캐시 지역성 ↑.mcache: 각 P가 전용 할당자 캐시를 가진다. malloc도 락 없이 작동.
GOMAXPROCS는 P의 개수를 결정한다. 기본값은 runtime.NumCPU()지만, 컨테이너에서는 cgroup CPU quota를 자동으로 반영하지 않으므로 주의.
2.4 전체 구조 요약
┌─────────────────────────────────────────────┐
│ Global Run Queue (락 필요) │
└─────────────────────────────────────────────┘
↑ (overflow/steal)
┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐
│ P0 │ │ P1 │ │ P2 │ │ P3 │ GOMAXPROCS=4
│ runq │ │ runq │ │ runq │ │ runq │ local run queues (256)
└──────┘ └──────┘ └──────┘ └──────┘
│ │ │ │
M0 M1 M2 M3 OS 스레드
│ │ │ │
CPU0 CPU1 CPU2 CPU3
한 줄 요약: P는 "스케줄링 권한"이고, M은 "실행 자원"이다. G는 P의 큐에서 대기했다가 M 위에서 돈다.
3. Work-Stealing 알고리즘
Go 스케줄러의 심장. runtime/proc.go의 findRunnable() 함수:
// 의사 코드 (실제 구현은 더 복잡)
func findRunnable() *g {
top:
// 1. 로컬 런큐 체크
if gp, inheritTime := runqget(_p_); gp != nil {
return gp, inheritTime
}
// 2. 글로벌 런큐 체크
if sched.runqsize != 0 {
lock(&sched.lock)
gp := globrunqget(_p_, 0)
unlock(&sched.lock)
if gp != nil { return gp, false }
}
// 3. Netpoller 체크 (non-blocking)
if list := netpoll(0); !list.empty() {
gp := list.pop()
return gp, false
}
// 4. 다른 P에서 훔치기 (steal from other P)
procs := gomaxprocs
if sched.nmspinning.Add(1) < procs/2 {
// 최대 4번 시도
for i := 0; i < 4; i++ {
for _, p2 := range randomOrder.randomProcs(procs) {
if gp := runqsteal(_p_, p2, stealTimersOrRunNextG); gp != nil {
return gp, false
}
}
}
}
// 5. 아무것도 없으면 M을 park (잠재움)
stopm()
goto top
}
3.1 로컬 런큐 접근
runqget()은 락 없다:
func runqget(_p_ *p) (gp *g, inheritTime bool) {
// runnext 우선
next := _p_.runnext
if next != 0 && _p_.runnext.cas(next, 0) {
return next.ptr(), true
}
for {
h := atomic.LoadAcq(&_p_.runqhead)
t := _p_.runqtail
if t == h {
return nil, false // 비었음
}
gp := _p_.runq[h%uint32(len(_p_.runq))].ptr()
if atomic.CasRel(&_p_.runqhead, h, h+1) {
return gp, false
}
}
}
runnext: CAS로 한 번에 획득.runq: 링 버퍼. 소유 P는 head/tail을 자유롭게 조작, 다른 P는 head만 CAS로 읽을 수 있다(steal용).
3.2 Work-Stealing: runqsteal
func runqsteal(_p_, p2 *p, stealRunNextG bool) *g {
t := _p_.runqtail
// p2의 런큐에서 절반을 훔쳐온다
n := runqgrab(p2, &_p_.runq, t, stealRunNextG)
if n == 0 { return nil }
n--
gp := _p_.runq[(t+n)%uint32(len(_p_.runq))].ptr()
if n == 0 { return gp }
_ = atomic.LoadAcq(&_p_.runqhead)
atomic.StoreRel(&_p_.runqtail, t+n)
return gp
}
핵심 아이디어:
- 절반을 훔친다 (Chase-Lev deque 변형). 전부 가져오면 스케줄링 공평성이 깨지고, 하나만 가져오면 오버헤드가 크다.
- 헤드 쪽에서 훔친다. 원본 P는 tail에서 push/pop하므로 경합 최소.
- 랜덤 순서로 P 선택. 특정 P에 몰려서 경합이 생기는 것을 방지.
3.3 왜 Work-Stealing인가
대안과 비교:
- Global Queue Only: 모든 M이 하나의 큐를 락으로 보호 → 코어 수만큼 경합 증가. 리눅스 커널 초기 CFS와 유사한 문제.
- Work-Sharing (Push): 분산 시점에 부하가 많은 P가 부하가 적은 P에게 밀어준다. 스케줄러가 전체 상태를 알아야 해서 오버헤드 큼.
- Work-Stealing (Pull): 할 일 없는 P가 스스로 훔친다. 비동기, 분산, 락 프리. 이게 승자.
Java ForkJoinPool, Rust Rayon, Cilk, Intel TBB도 모두 work-stealing이다.
4. 고루틴 스택: Copy-on-Grow
4.1 왜 스택이 동적이어야 하는가
OS 스레드는 기본 2MB 스택을 고정 할당한다. 고루틴을 10만 개 만들면 200GB가 필요 → 불가능.
Go는 초기 2KB만 할당하고, 필요에 따라 증가시킨다.
4.2 스택 체크 & 증가
모든 함수 진입점에 컴파일러가 stack guard check를 삽입한다.
; 함수 프롤로그
CMPQ SP, runtime.stackguard0(R14) ; SP가 guard보다 작으면?
JLS morestack ; 스택 모자람 → morestack 호출
morestack은:
- 현재 스택 크기의 2배로 새 스택 할당.
- 기존 스택 내용을 새 스택으로 복사.
- 스택 내 포인터들을 리라이트(포인터가 스택 내부를 가리키는 경우).
- 함수 재실행.
4.3 포인터 리라이트
이것이 핵심이다. Go는 GC가 있으므로 모든 포인터의 위치를 알고 있다(포인터 맵). 스택 복사 후:
// 원래 스택 [0x1000, 0x1800), 새 스택 [0x2000, 0x2800)
// delta = 0x1000
for _, ptr := range stackMap {
if ptr >= oldLo && ptr < oldHi {
*ptr += delta // 새 위치로 조정
}
}
C/C++에서는 이게 불가능하다(포인터 맵이 없고, 포인터의 정체를 알 수 없음). 이것이 Go가 경량 스택을 구현할 수 있는 이유다.
4.4 Segmented Stack → Continuous Stack
초기 Go는 segmented stack을 썼다: 스택이 모자라면 새 세그먼트를 링크드 리스트로 추가. 하지만 함수 호출 경계마다 segment 체크/전환이 필요 → hot split 문제(같은 함수가 반복 호출되면 스택 할당/해제가 반복됨).
Go 1.3부터 **continuous stack (copying stack)**으로 바뀌었다. 단순 재할당 + 복사 방식. 한 번 커지면 줄어들 때까지 그대로 유지.
4.5 스택 축소
너무 커진 스택은 GC 중에 줄인다:
func shrinkstack(gp *g) {
oldsize := gp.stack.hi - gp.stack.lo
newsize := oldsize / 2
if newsize < _StackMin {
return
}
// 사용량이 1/4 미만이면 절반으로 축소
avail := gp.stack.hi - gp.sched.sp
if avail*4 > oldsize {
return
}
copystack(gp, newsize)
}
5. Preemption: 협력적에서 비동기로
5.1 협력적 Preemption (Go 1.13까지)
초기 Go는 함수 진입점에서만 preemption 체크를 했다.
; 함수 프롤로그
CMPQ SP, runtime.stackguard0(R14)
JLS morestack_or_preempt ; stackguard를 MAX_UINT로 설정해서 강제 진입
스케줄러가 "이 G 양보해라"고 표시하면 stackguard0을 stackPreempt 상수로 바꾼다. 다음 함수 호출에서 체크가 실패하고 스케줄러로 진입.
문제: 함수 호출이 없는 타이트 루프는 영원히 preempt되지 않는다!
// Go 1.13에서 이 코드는 치명적
go func() {
for i := 0; i < 1e18; i++ {
// 함수 호출 없음 → preempt 불가
}
}()
runtime.GC() // 영원히 멈춤! GC 시작을 위해 모든 G가 stop해야 하는데...
실제로 Go 1.13 전까지 이것이 GC 지연의 주된 원인이었다.
5.2 비동기 Preemption (Go 1.14+)
Go 1.14에 Asynchronous Preemption이 도입됐다. 아이디어:
- 스케줄러가 preempt하고 싶은 M에게 **시그널(SIGURG)**을 보낸다.
- 시그널 핸들러가 현재 PC/SP를 저장하고 가짜 프레임을 만들어
asyncPreempt로 점프시킨다. asyncPreempt는gopreempt_m을 호출해서 G를 양보.
// runtime/preempt.go
func preemptone(_p_ *p) bool {
mp := _p_.m.ptr()
gp := mp.curg
gp.preempt = true
gp.stackguard0 = stackPreempt // 기존 협력적 방식도 유지
// 비동기 preemption
if preemptMSupported && debug.asyncpreemptoff == 0 {
_p_.preempt = true
preemptM(mp) // SIGURG 발송
}
return true
}
이제 타이트 루프도 10ms 안에 preempt된다 — sysmon(아래 설명)이 주기적으로 검사.
5.3 Safe Points
비동기 preemption의 함정: 어느 지점에서 멈춰도 안전한가?
// 이 상태에서 멈추면 위험
x := &someStruct{} // 1. x 할당 중 (writeBarrier 이전)
x.field = ptr // 2. GC가 x.field를 못 보고 ptr 해제 가능
Go는 컴파일 타임에 safe point metadata를 생성한다. 런타임은 PC를 보고 "이 위치에서 멈춰도 되는가"를 판단. 불가능하면 잠시 더 돌리고 다시 시도.
Intel/AMD만 지원(ARM은 명령어 경계가 더 명확해서 쉬움). RISC-V는 작업 중.
5.4 sysmon: 백그라운드 감시자
sysmon은 Go 런타임의 감시 고루틴이다. 별도 M 위에서 P 없이 돌며, 주기적으로:
// runtime/proc.go
func sysmon() {
for {
// 1. netpoll 체크 (blocking된 I/O 깨우기)
list := netpoll(10 * time.Microsecond)
injectglist(&list)
// 2. syscall blocked P 회수
retake(now)
// 3. GC 강제 트리거 (2분마다)
if gcTrigger.test() {
injectglist(startGCM)
}
// 4. 스캐벤저 (메모리 반환)
scavenger.wake()
}
}
retake()가 preemption의 트리거다:
func retake(now int64) uint32 {
n := 0
for i := 0; i < len(allp); i++ {
_p_ := allp[i]
pd := &_p_.sysmontick
// Case 1: syscall에서 너무 오래 있는 P
if _p_.status == _Psyscall && pd.syscalltick == now {
handoffp(_p_) // P를 다른 M에 넘기기
n++
}
// Case 2: 같은 G가 10ms 이상 실행 중
if _p_.status == _Prunning && pd.schedtick == now {
preemptone(_p_) // preempt 요청
}
}
return uint32(n)
}
Go 스케줄링 단위 ≈ 10ms. Linux CFS quantum(~6ms)과 비슷한 수준.
6. Syscall Handoff
고루틴이 blocking syscall을 호출하면? Go의 해법은 P와 M을 분리하는 것이다.
6.1 Syscall 진입
// syscall.Syscall에서 호출
func entersyscall() {
_g_ := getg()
_g_.m.mcache = nil
_g_.m.p.ptr().status = _Psyscall
_g_.m.p.ptr().m = 0 // P에서 M 떼어내기
_g_.m.locks++
save(pc, sp)
_g_.atomicstatus.Store(_Gsyscall)
}
중요한 부분:
_g_.m.p = 0: M에서 P를 떼어낸다. M만 커널 syscall로 들어가고, P는 "대기" 상태.status = _Psyscall: P가 syscall 블록 상태임을 표시.
6.2 P 핸드오프
sysmon이 주기적으로 retake()를 호출, syscall이 10μs 이상이면:
func handoffp(_p_ *p) {
// P에 새 M 붙이기 (idle M 깨우거나 새로 생성)
if atomic.Load(&sched.nmspinning) == 0 && atomic.Cas(&sched.nmspinning, 0, 1) {
startm(_p_, true)
return
}
// ...
}
이제:
- 기존 M은 여전히 syscall에서 블록된 상태.
- P는 새 M과 짝을 이루어 다른 고루틴들을 실행.
- 수만 개 연결을 동시 처리해도 P의 수(CPU 코어 수)만큼만 "진짜 실행" 중.
6.3 Syscall 복귀
Syscall이 끝나면 M은 P를 다시 얻으려 한다:
func exitsyscall() {
_g_ := getg()
if exitsyscallfast(oldp) {
// 빠른 경로: 원래 P가 아직 비어 있으면 재획득
...
return
}
// 느린 경로: P를 찾지 못하면 G를 글로벌 큐에 넣고 M park
mcall(exitsyscall0)
}
빠른 경로로 돌아가지 못하면:
- 현재 G를 글로벌 런큐에 넣는다.
- M을 park(잠재움). 나중에 스케줄러가 필요하면 깨운다.
6.4 Non-blocking I/O: netpoller
Network I/O는 특별하다. Go는 모든 네트워크 소켓을 non-blocking으로 만들고, epoll/kqueue/IOCP로 관리한다.
// net 패키지 내부
func (fd *netFD) Read(p []byte) (n int, err error) {
n, err = fd.pfd.Read(p)
// ...
}
// internal/poll
func (fd *FD) Read(p []byte) (int, error) {
for {
n, err := syscall.Read(fd.Sysfd, p)
if err != nil {
if err == syscall.EAGAIN && fd.pd.pollable() {
if err = fd.pd.waitRead(fd.isFile); err == nil {
continue // 재시도
}
}
}
return n, err
}
}
waitRead()는:
- 현재 G를
_Gwaiting상태로 만든다. - 소켓 fd를 epoll에 등록(EPOLL_CTL_ADD, EPOLLIN).
- 스케줄러로 돌아가서 다른 G 실행.
나중에 데이터가 도착하면:
sysmon또는findRunnable이netpoll(0)호출.- epoll이 준비된 fd 리스트 반환.
- 해당 fd에 묶인 G들을 런큐에
injectglist.
결과: 수십만 고루틴이 네트워크에서 블록돼 있어도, 실제 OS 스레드는 수십 개뿐. C10M이 가능한 이유.
7. 스케줄링 정책 상세
7.1 고루틴 생성
go f()
컴파일러가 runtime.newproc로 치환한다.
func newproc(fn *funcval) {
gp := getg()
pc := getcallerpc()
systemstack(func() {
newg := newproc1(fn, gp, pc)
_p_ := getg().m.p.ptr()
// runnext에 우선 배치 (LIFO)
runqput(_p_, newg, true)
if mainStarted {
wakep()
}
})
}
runnext = newg: 새 G를 우선권 슬롯에 놓는다. 캐시 지역성(방금 만든 클로저/데이터를 바로 실행).wakep(): 유휴 P가 있으면 깨워서 훔쳐갈 수 있게 한다(일이 생겼음을 알림).
7.2 Schedule Loop
각 M의 g0는 무한 루프를 돈다:
func schedule() {
_g_ := getg()
top:
// GC 담당 중이면 잠시 대기
if sched.gcwaiting.Load() {
gcstopm()
goto top
}
// 타이머 체크
checkTimers(pp, 0)
// 61번에 한 번은 글로벌 큐에서 (기아 방지)
var gp *g
if _p_.schedtick%61 == 0 && sched.runqsize > 0 {
lock(&sched.lock)
gp = globrunqget(_p_, 1)
unlock(&sched.lock)
}
// 로컬 큐에서
if gp == nil {
gp, _ = runqget(_p_)
}
// 로컬도 비면 work-stealing
if gp == nil {
gp, _, _ = findRunnable()
}
execute(gp, false)
}
눈여겨볼 점:
- 61번에 한 번 글로벌 큐 체크: 로컬만 보면 글로벌 큐의 G가 starvation 당할 수 있다. Go는 마법 숫자 61을 써서(소수) 주기적으로 글로벌을 섞어준다.
- Timer 체크:
time.Sleep,time.After등이 만료되면 G를 ready 상태로 만든다. - GC 협조: GC가 stop-the-world를 요청하면 잠시 대기.
7.3 Spinning M
M이 할 일을 찾는 동안 spinning 상태가 된다.
// M은 네 가지 상태
// 1. Executing G
// 2. Executing runtime code (GC, sysmon)
// 3. Idle (parked, waiting for work)
// 4. Spinning (adtively searching for work)
Spinning M은:
- CPU를 "돌려가며" 다른 P에서 훔치려 시도.
- 목적: 새 G가 ready되었을 때 바로 잡아채기 (즉시 실행).
- 단점: CPU 낭비. 그래서 최대 GOMAXPROCS/2개까지만 허용.
sched.nmspinning 카운터로 관리하며, 일이 나타나면 이 값이 0이 되기 전에 wakep()가 호출돼서 다른 M을 깨운다.
7.4 Starvation 방지
Go 스케줄러는 공평하지 않다(fair scheduler 아님). 대신 여러 hack으로 starvation을 막는다:
- 61-tick 글로벌 큐 체크: 위 설명.
- Steal 시 절반: 한 P가 모든 일을 독점하지 못하게.
- sysmon의 10ms preempt: 한 G가 무한 실행 못하게.
- Timer-based injection: netpoller/timer에서 온 G는 글로벌 큐에 섞임.
8. Channel과 Sync
8.1 Unbuffered Channel
ch := make(chan int)
go func() { ch <- 42 }()
x := <-ch
내부적으로:
- 리시버가 먼저 도착:
ch.recvq에 G 추가,gopark()로_Gwaiting. - 센더 도착:
recvq에서 대기 G 꺼냄 → 값을 직접 스택에 복사 →goready()로 런큐에 넣음. - 직접 전달: 버퍼 없이 센더의 스택 → 리시버의 스택으로 복사. 메모리 복사 1번.
핵심은 데이터 패싱이 아니라 동기화다. "Do not communicate by sharing memory; share memory by communicating." — Rob Pike.
8.2 Buffered Channel
ch := make(chan int, 10)
내부 구조:
type hchan struct {
qcount uint // 현재 개수
dataqsiz uint // 버퍼 크기
buf unsafe.Pointer // 순환 버퍼
elemsize uint16
closed uint32
elemtype *_type
sendx uint // 다음 쓸 위치
recvx uint // 다음 읽을 위치
recvq waitq // 대기 중인 리시버
sendq waitq // 대기 중인 센더
lock mutex
}
- 버퍼 비어있지 않음 + 버퍼 풀 아님: 락만 잡고 데이터 복사.
- 버퍼 풀 + 리시버 없음: 센더를
sendq에 추가, gopark. - 버퍼 비어있음 + 센더 없음: 리시버를
recvq에 추가, gopark.
8.3 Select
select {
case v := <-ch1:
case ch2 <- v:
case <-time.After(1 * time.Second):
default:
}
컴파일러가 selectgo()로 변환. 알고리즘:
- 케이스 shuffle: 공평성을 위해 랜덤 순서로 검사.
- 1차 스캔: 즉시 진행 가능한 케이스가 있으면 실행.
- 등록: 모든 채널의 sendq/recvq에 G 등록.
- Park: 아무도 깨우기 전까지 대기.
- Wake: 한 채널이 깨우면 나머지 등록 해제 후 해당 케이스 실행.
O(N*M) 복잡도(N=케이스 수, M=채널 경합 수준). 케이스가 많으면 느리다.
8.4 Mutex
Go의 sync.Mutex는 hybrid다:
type Mutex struct {
state int32
sema uint32
}
// state bits:
// 0: locked
// 1: woken (깨워진 상태)
// 2: starving (기아 모드)
// 3-: waiter count
정상 모드 (Normal Mode):
- 대기 큐는 FIFO지만, 새로 도착한 G가 먼저 획득 시도(spinning).
- 빠른 언락→재락 경로에 좋다.
기아 모드 (Starvation Mode):
- 한 G가 1ms 이상 대기하면 전환.
- 언락 시 대기 G에게 직접 소유권 이전.
- 새 G는 spin 없이 큐 뒤로.
- 모든 대기 G가 처리되거나 대기 시간 < 1ms가 되면 정상 모드 복귀.
이 dual-mode는 Linux futex의 아이디어와 유사하다.
9. GC와 스케줄러의 상호작용
Go의 GC는 동시형 마크 앤 스윕이다. 스케줄러와 깊이 엮여 있다.
9.1 STW 구간 최소화
Go GC는 두 번의 짧은 STW 구간을 가진다:
[앱 실행] → [STW 1: Mark 시작] → [동시 Mark] → [STW 2: Mark 종료] → [동시 Sweep] → [앱 실행]
~수 ms 수십 ms ~수 ms (비차단)
- STW 1: GC 시작 시 Write Barrier 활성화, 루트 스캔 준비. 모든 G를 멈춰야 한다 → 여기서 비동기 preemption이 중요.
- STW 2: 마크 단계 종료 확인. 스택 리스캔 등.
Go 1.14의 async preempt 전에는 STW 1이 수십~수백 ms 걸리는 경우가 있었다(타이트 루프 G가 멈추지 않아서). 1.14 이후 대부분 1ms 미만.
9.2 GC Assist
Mark 단계에서 G가 메모리를 할당하면, 할당한 만큼 mark 작업을 돕는다(GC Assist).
// runtime/mgcmark.go
func gcAssistAlloc(gp *g) {
assistWorkPerByte := float64(gcController.assistWorkPerByte.Load())
debtBytes := gp.gcAssistBytes * int64(assistWorkPerByte)
if debtBytes > 0 {
gcDrainN(gcw, debtBytes) // 마크 작업 수행
}
}
이 설계로 "할당 많은 G가 GC 비용을 치른다"는 공평성을 달성. 악성 할당자가 GC를 느리게 하면 스스로 느려진다.
9.3 Fractional GC Worker
GOMAXPROCS/4 만큼의 P는 전용 GC worker가 된다. 나머지 1/4은 fractional worker로 25% 시간만 GC.
type gcControllerState struct {
dedicatedMarkWorkersNeeded atomic.Int64
fractionalUtilizationGoal float64 // 보통 0.25
}
이 설계로 **"GC 중에도 CPU의 75%는 앱이 쓴다"**를 보장한다. Java G1GC의 concurrent mark와 유사하지만, Go는 더 극단적으로 앱 응답성을 우선한다.
10. 실무 튜닝
10.1 GOMAXPROCS
기본값 = runtime.NumCPU() = 호스트 CPU 수. 컨테이너에서 문제:
호스트: 32 CPU
컨테이너 limit: 2 CPU (cgroup)
→ GOMAXPROCS = 32 (잘못됨!)
→ 32개 P가 2개 CPU 위에서 요리 경쟁 → throttling + 컨텍스트 스위칭 폭증
해결: Uber의 automaxprocs:
import _ "go.uber.org/automaxprocs"
import만 하면 init 시점에 cgroup을 읽어 GOMAXPROCS를 조정. K8s 환경에서 필수.
10.2 GODEBUG 환경변수
schedtrace: 스케줄러 상태 로그
GODEBUG=schedtrace=1000 ./myapp
SCHED 1000ms: gomaxprocs=4 idleprocs=0 threads=8 spinningthreads=0 idlethreads=1 runqueue=3 [12 8 6 10]
읽는 법:
gomaxprocs=4: P 개수.idleprocs=0: 유휴 P 없음 → 스케줄러가 꽉 참 (좋음).runqueue=3: 글로벌 큐 크기. 항상 0에 가까워야 정상.[12 8 6 10]: 각 P의 로컬 큐 크기. 불균형이 심하면 work-stealing이 따라가지 못함.
gctrace: GC 로그
gc 12 @2.345s 3%: 0.021+1.2+0.015 ms clock, 0.084+0.52/2.1/1.8+0.060 ms cpu
3%: 전체 시간 중 GC에 쓴 비율.- STW 1 (0.021ms) + 동시 마크 (1.2ms) + STW 2 (0.015ms).
3%초과면 튜닝 필요.
asyncpreemptoff=1: async preempt 끄기(디버깅용).
10.3 go tool trace
고수준 프로파일러. 실행 중 이벤트 전부 기록:
import "runtime/trace"
f, _ := os.Create("trace.out")
trace.Start(f)
defer trace.Stop()
// ... 애플리케이션 코드 ...
go tool trace trace.out
브라우저가 열리며:
- Goroutine timeline: 각 G가 언제 뛰고 언제 블록됐는지.
- Network blocking profile: I/O 대기 시간.
- Scheduler latency: G가 ready 상태에서 실행까지 걸린 시간.
- GC timeline: 각 GC 사이클의 상세.
"왜 이 요청이 느리지?"를 P99 수준까지 파고들 때 유일한 도구.
10.4 pprof
import _ "net/http/pprof"
go func() {
http.ListenAndServe("localhost:6060", nil)
}()
그 다음:
# CPU 프로파일 (30초)
go tool pprof http://localhost:6060/debug/pprof/profile?seconds=30
# 메모리
go tool pprof http://localhost:6060/debug/pprof/heap
# 고루틴 스택
curl http://localhost:6060/debug/pprof/goroutine?debug=2
# 블록/뮤텍스
go tool pprof http://localhost:6060/debug/pprof/block
go tool pprof http://localhost:6060/debug/pprof/mutex
- block profile: 채널/select/뮤텍스/cond에서 대기한 시간.
- mutex profile: 컨텐션으로 대기한 시간.
두 프로파일의 차이가 핵심 힌트다: block은 크지만 mutex는 작다 → I/O나 채널 설계 문제, mutex가 크다 → 실제 락 경합.
10.5 흔한 안티패턴
1. 고루틴 leak
// 나쁜 예
func fetchAll(urls []string) []string {
ch := make(chan string)
for _, u := range urls {
go func(u string) {
ch <- fetch(u)
}(u)
}
var results []string
for i := 0; i < 2; i++ { // 버그: 2개만 받음
results = append(results, <-ch)
}
return results
// 나머지 N-2개 고루틴은 영원히 ch에 블록됨 → 리크
}
2. Unbuffered channel 오용
// 센더가 더 빠르면 매번 park/unpark → 2x 이상 오버헤드
ch := make(chan int)
go producer(ch)
consumer(ch)
// 버퍼 추가로 batching
ch := make(chan int, 1024)
3. 과도한 고루틴
// 100만 개 고루틴 → 메모리 2GB + 스케줄링 오버헤드
for i := 0; i < 1_000_000; i++ {
go process(i)
}
// Worker pool 패턴
pool := make(chan struct{}, 100) // 세마포어
for i := 0; i < 1_000_000; i++ {
pool <- struct{}{}
go func(i int) {
defer func() { <-pool }()
process(i)
}(i)
}
11. Go vs Other Languages
11.1 Go vs Java
| 항목 | Go | Java |
|---|---|---|
| 스레드 모델 | M:N (GMP) | 1:1 (JVM → OS) + Virtual Threads (Loom, J21) |
| 스택 | 2KB 동적 | 1MB 고정 (Virtual Thread는 2KB) |
| 스케줄러 | Work-Stealing (P마다) | Work-Stealing (ForkJoinPool, Loom) |
| I/O | Netpoller (epoll) | NIO (Selector), Virtual Thread는 내장 |
| Preemption | Async (SIGURG) | Safepoint polling (JVM) |
| GC Pause | ~1ms | G1 ~100ms, ZGC ~1ms |
Java Project Loom (JDK 21+)의 Virtual Threads는 Go의 GMP와 매우 유사하다. "Go의 아이디어가 15년 뒤 Java에 도착"한 셈.
11.2 Go vs Rust (tokio)
| 항목 | Go | Rust tokio |
|---|---|---|
| 실행 단위 | Goroutine (stackful) | Task (stackless, Future) |
| 스택 | 2KB 동적 | 없음 (state machine) |
| 타입 | 런타임 포함 | zero-cost abstraction |
| async/await | 없음 (내장됨) | 명시적 (Function coloring) |
| 성능 | 런타임 오버헤드 있음 | 더 적은 오버헤드 |
| 복잡도 | 낮음 | 높음 (Pin, Send, Sync) |
Rust의 async는 stackless coroutine이다. async fn이 state machine으로 컴파일돼서 스택이 없다 → 매우 경량. 그러나 "function coloring" 문제(async 함수와 동기 함수가 섞이면 복잡)가 있다. Go는 이 타협 없이 단순성을 선택했다.
11.3 Go vs Erlang
| 항목 | Go | Erlang/BEAM |
|---|---|---|
| 경량 프로세스 | Goroutine | Process (BEAM) |
| 초기 스택 | 2KB | 233 words (~1KB) |
| 메모리 공유 | 공유 (포인터) | 격리 (메시지 복사) |
| Preemption | 10ms 단위 | Reduction count (더 정교) |
| 장애 격리 | 부분적 | 완전 (Let it crash) |
| 분산 | 직접 구현 필요 | 내장 (distributed Erlang) |
Erlang은 **99.9999999% 가용성(Nine nines)**을 달성한 반면, Go는 단순성과 성능을 우선한다. Discord(Elixir→Go 이전), WhatsApp(Erlang) 같은 서비스가 각자의 선택 이유를 보여준다.
12. 고급 토픽
12.1 LockOSThread
func main() {
runtime.LockOSThread()
defer runtime.UnlockOSThread()
// 이제 이 G는 특정 OS 스레드에 묶임
// cgo callback, OpenGL (OpenGL은 thread-local state 사용), 시그널 처리 등에 필요
}
주의: LockOSThread된 G가 종료되면 M도 종료한다. runtime.UnlockOSThread()로 해제하지 않으면 M이 낭비됨.
12.2 cgo의 스케줄링 영향
// #include <stdio.h>
import "C"
func foo() {
C.printf(...) // cgo 호출
}
cgo는 syscall과 유사하게 취급된다:
- G가 C 함수로 진입 →
entersyscall유사 처리. - P가 분리되고 다른 M이 P를 이어받음.
- C 함수가 돌아오면 P 재획득 시도.
하지만 C 코드가 느리면 스케줄러가 멀리서 preempt할 수 없다. C 코드는 Go 스택을 쓰지 않기 때문. cgo 남발은 스케줄링 오버헤드 폭증의 주범.
12.3 Goroutine Local Storage (없음!)
Go는 의도적으로 GLS를 제공하지 않는다. 이유:
- 스레드 로컬 스토리지는 "암묵적 컨텍스트" → 버그 원인.
- Go 팀은
context.Context를 명시적으로 전달하라고 권장.
하지만 성능 최적화 목적으로 runtime.Getg() 우회로 구현된 라이브러리가 있다(github.com/jtolio/gls). 권장하지 않음.
12.4 Scheduler Affinity
Go 스케줄러는 NUMA 어웨어하지 않다. M이 어떤 CPU에 있든 신경 쓰지 않는다. 고성능 NUMA 시스템(서버급)에서는 다음이 도움될 수 있다:
# taskset으로 CPU 고정
taskset -c 0-15 ./myapp
# GOMAXPROCS도 맞추기
GOMAXPROCS=16 ./myapp
또는 cgo로 pthread affinity API 호출. 2025년 시점, Go 런타임의 NUMA 지원은 여전히 "TODO".
13. 실전 시나리오 튜닝
13.1 "내 서비스의 p99 레이턴시가 튄다"
증상: p50 5ms, p99 200ms.
의심 1: GC pause
GODEBUG=gctrace=1 ./myapp 2>&1 | grep "gc "
3%+ 또는 +2ms 이상 pause가 보이면:
- GOGC 튜닝: 기본 100% → 200%로 늘리면 GC 빈도 반으로 줄어듦(메모리 2배 증가).
- GOMEMLIMIT(Go 1.19+): 메모리 상한을 명시하면 GC가 그에 맞춰 동작.
- 할당 줄이기:
sync.Pool,[]byte재사용.
의심 2: Scheduler latency
GODEBUG=schedtrace=100 ./myapp
idleprocs=0이 지속되고 runqueue가 쌓이면 CPU 포화. 해결:
GOMAXPROCS늘리기(가능하면).- CPU-heavy 작업 고루틴 분리 (blocking I/O와 섞지 말 것).
의심 3: Starvation
go tool trace로 특정 G의 대기 시간 확인. 10ms 이상 ready 상태로 대기하면 starvation.
13.2 "고루틴 leak 의심"
curl http://localhost:6060/debug/pprof/goroutine?debug=1 | head -100
출력 예시:
10000 @ 0x7a3e00 0x7a3cd0 0x6b2a20
# 0x7a3dff runtime.gopark+0xff runtime/proc.go:363
# 0x7a3ccf runtime.chanrecv+0x37f runtime/chan.go:584
# 0x6b2a1f main.worker+0x2f main.go:24
"10000개의 G가 main.go:24의 채널 recv에서 블록됨" → leak 발견. 채널 클로즈나 context cancel 누락일 가능성.
13.3 "CPU는 남는데 왜 느리지?"
프로파일에서 system time 비율이 높으면 syscall 오버헤드 의심:
go tool pprof -top cpu.prof
runtime.futex, runtime.usleep, runtime.unlockextra가 상위에 보이면 sleeping/waking 경합. 채널/뮤텍스 과다 사용.
14. Go 1.22+ 최신 변경사항
14.1 Loop variable per-iteration (Go 1.22)
// Go 1.21까지: 고전적 함정
for i := 0; i < 10; i++ {
go func() {
fmt.Println(i) // 전부 10 출력!
}()
}
Go 1.22부터 루프 변수가 iteration별로 새로 생성 → 함정 해결. 스케줄러와 직접 관련은 없지만, 동시성 코드에 영향이 크다.
14.2 Timer 내부 재작성 (Go 1.23)
Go 1.23에서 타이머 힙이 per-P에서 shared wheel로 재설계됐다. 수만 개 타이머 사용 시 스케줄링 오버헤드 감소.
14.3 PGO (Profile-Guided Optimization, Go 1.21+)
go test -cpuprofile cpu.prof
cp cpu.prof default.pgo
go build -pgo=auto
컴파일러가 프로파일을 읽어 hot path를 인라인한다. 2-14% 성능 향상. 스케줄러/GC 경로도 PGO 혜택을 받는다.
14.4 sync/atomic.Pointer[T] (Go 1.19+)
제네릭 atomic pointer. 락 없는 자료구조를 타입 안전하게 구현 가능.
15. 흔한 질문 FAQ
Q1. 고루틴 개수의 상한은?
A. 이론상 maxgomaxprocs=1024 (Go 런타임 제약), 실무적으론 메모리에 따라 수십만~수백만. 각 고루틴이 평균 4KB 쓴다고 치면, 1M goroutines = 4GB. 스택만 보면 그렇고, 실제론 채널/뮤텍스 등으로 더 큼.
Q2. GOMAXPROCS=1로 설정하면?
A. 단일 P만 사용 → 모든 고루틴이 순차 실행(동시성은 있지만 병렬성 없음). 디버깅/결정적 테스트용. 실무 배포에는 거의 안 씀.
Q3. goroutine과 thread의 1:1 보장이 있나?
A. 없다. 같은 M 위에서 여러 G가 번갈아 돈다. 특정 G를 특정 M에 고정하려면 LockOSThread.
Q4. channel select는 O(n)?
A. 그렇다. 매 select마다 모든 케이스를 등록/해제. 케이스가 10개 이상이면 성능 저하. 대안: 커스텀 디스패처, reflect.Select (더 느림).
Q5. for { go doSomething() }가 문제인가?
A. 네, 폭주할 가능성 있음. Worker pool 또는 golang.org/x/sync/errgroup의 SetLimit으로 제한.
Q6. Go는 realtime?
A. No. GC pause(1-10ms)와 스케줄러 지연(~10ms)이 예측 불가. 하드 리얼타임(us 단위 보장)은 C/C++, Rust, Ada가 적합.
16. 디버깅 체크리스트
문제 발생 시 순서:
1. 현상 확인
- p50/p99 레이턴시
- 처리량 (rps)
- 에러 비율
- CPU/메모리 사용률
2. 빠른 스냅샷
GODEBUG=schedtrace=1000,gctrace=1 ./myapp
3. Goroutine 개수 확인
curl http://.../debug/pprof/goroutine?debug=1
4. CPU 프로파일
go tool pprof http://.../debug/pprof/profile?seconds=30
- top 명령: hot path 확인
- runtime.* 함수가 상위? → 런타임 오버헤드 의심
5. 블록 프로파일
go tool pprof http://.../debug/pprof/block
- 채널 recv/send? → 설계 점검
- Mutex? → 경합 축소 (락 세분화, sync.Pool, sync.Map)
6. Trace (정밀 분석)
trace.Start(f); ...; trace.Stop()
go tool trace trace.out
- Goroutine Analysis → 특정 G의 실행/블록 패턴
- Scheduler Latency → starvation 여부
7. 메모리 프로파일
go tool pprof http://.../debug/pprof/heap
- top -cum → 누적 할당 많은 지점
- 할당 줄이기: sync.Pool, 슬라이스 재사용
8. 힙 오브젝트 (gc 분석)
GOGC=off ./myapp → 수동으로 GC 제어
GOMEMLIMIT=8GiB → 메모리 상한 설정
17. 학습 리소스
공식 문서:
내부 문서 (Go 소스 코드):
src/runtime/HACKING.md: 런타임 기여자 가이드.src/runtime/proc.go: 스케줄러 본체. 주석이 교과서.src/runtime/mgc.go: GC 본체.src/runtime/chan.go: 채널 구현.
서적:
- "The Go Programming Language" — Donovan & Kernighan
- "Concurrency in Go" — Katherine Cox-Buday
- "100 Go Mistakes and How to Avoid Them" — Teiva Harsanyi
블로그/영상:
- Dave Cheney — goroutine lifecycle
- Ardan Labs blogs (Bill Kennedy)
- dotGo 컨퍼런스 영상들 (2015-2019 GMP 관련 토크)
18. 요약 — 한 장 정리
┌─────────────────────────────────────────────────────┐
│ Go GMP Scheduler Cheat Sheet │
├─────────────────────────────────────────────────────┤
│ G = Goroutine: 2KB stack, grows on demand │
│ M = Machine: OS thread │
│ P = Processor: local runq (256), mcache │
│ │
│ GOMAXPROCS = len(P) = logical CPUs │
│ │
│ Schedule Loop (on g0): │
│ 1. runqget(P.local) [lock-free] │
│ 2. globrunqget (61-tick) [lock] │
│ 3. netpoll(0) [epoll/kqueue] │
│ 4. runqsteal(random P) [work-stealing] │
│ 5. stopm() → park │
│ │
│ Preemption: │
│ - sysmon: 10ms 단위 체크 │
│ - Async: SIGURG → asyncPreempt │
│ - Cooperative: stackguard0 flag │
│ │
│ Syscall: │
│ - entersyscall: P 분리 │
│ - sysmon retake: 10μs+ → handoffp │
│ - exitsyscall: P 재획득 or global queue + park │
│ │
│ Netpoller: │
│ - 모든 socket non-blocking │
│ - gopark → epoll_wait → goready │
│ │
│ GC: │
│ - Concurrent mark + STW boundaries (~1ms) │
│ - Async preempt가 STW 짧게 유지 │
│ - GC assist: 할당한 G가 mark 도움 │
│ │
│ 튜닝: │
│ - GOMAXPROCS (+ automaxprocs for k8s) │
│ - GODEBUG=schedtrace=1000,gctrace=1 │
│ - go tool trace, pprof (cpu/heap/block/mutex) │
└─────────────────────────────────────────────────────┘
19. 퀴즈
Q1. Go 스케줄러의 P는 무엇을 의미하는가?
A. Processor — 로컬 런큐, mcache, 타이머 힙 등을 가진 스케줄링 컨텍스트다. GOMAXPROCS 개만큼 존재하며, M이 G를 실행하기 위해 반드시 P를 획득해야 한다. P가 있는 이유는 락 없는 로컬 큐로 work-stealing을 구현하기 위해서. Go 1.1에 도입됐고, 이전에는 글로벌 큐 하나라서 확장성이 매우 나빴다.
Q2. 고루틴의 초기 스택 크기는 몇 KB이며, 어떻게 커지는가?
A. 2KB로 시작한다. 함수 진입점의 stack guard check가 실패하면 morestack이 호출되어 기존 크기의 2배 새 스택을 할당하고, 기존 내용을 복사한 뒤(포인터 리라이트 포함) 함수를 재실행한다. 이것이 copy-on-grow이며, C와 달리 GC의 포인터 맵이 있기 때문에 가능하다.
Q3. Go 1.14 이전의 cooperative preemption은 어떤 문제를 만들었는가?
A. 함수 호출이 없는 타이트 루프는 영원히 preempt되지 않았다. 이로 인해 GC STW가 시작되려면 모든 G가 멈춰야 하는데, 타이트 루프 G 하나 때문에 STW가 수십~수백 ms 지연됐다. Go 1.14의 asynchronous preemption은 SIGURG 시그널로 M을 강제 인터럽트해서 이 문제를 해결했다.
Q4. 고루틴이 blocking syscall에 들어가면 어떻게 되는가?
A. entersyscall이 호출되어 M에서 P가 분리된다. M은 syscall로 커널에 들어가고, P는 P-syscall 상태로 대기. sysmon이 주기적으로 검사하다가 10μs 이상 지연되면 handoffp로 P를 다른 M에 붙여 다른 G들을 계속 실행하게 한다. Syscall이 끝나면 M은 원래 P를 다시 얻으려 시도하고, 실패하면 G를 글로벌 큐에 넣고 park된다.
Q5. Work-Stealing에서 왜 "절반"을 훔치는가?
A. 공평성과 효율의 균형 때문. 하나만 훔치면 다음 스틸이 곧 필요해져 오버헤드가 크고, 전부 훔치면 원래 P가 다시 굶는 일이 생긴다. 절반을 가져오면 훔친 P도 당분간 일이 있고, 원본 P도 여전히 일이 있어 다음 스틸 필요성이 줄어든다. 이는 Chase-Lev deque 연구의 표준 결론이다.
Q6. 컨테이너에서 GOMAXPROCS를 조정해야 하는 이유는?
A. Go 런타임은 기본적으로 runtime.NumCPU() — 즉 호스트 전체 CPU 수를 본다. cgroup CPU limit은 무시한다. 호스트 32 CPU, 컨테이너 limit 2 CPU라면, P가 32개 생성되어 2개 CPU 위에서 서로 싸우고 컨텍스트 스위칭과 CFS throttling이 폭증한다. Uber automaxprocs가 이를 자동 교정한다.
Q7. Channel과 Mutex 중 어느 쪽이 더 빠른가?
A. 일반적으로 Mutex가 더 빠르다. Channel은 park/unpark 오버헤드(수백 ns)가 있고 Mutex는 빠른 경로(CAS 성공)가 수십 ns. 하지만 채널은 동기화 + 데이터 전달 + 타임아웃(select) + 취소까지 한 번에 해주므로, 코드의 단순성이 훨씬 높다. **"Don't communicate by sharing memory; share memory by communicating"**은 성능이 아닌 설계 철학의 문제다.
이 글이 도움이 됐다면 다음 포스트도 확인해 보세요:
- "JVM GC Deep Dive — G1, ZGC, Shenandoah 튜닝" — 저수준 가비지 컬렉터 비교.
- "Python GIL과 CPython 내부" — Go와 정반대 접근. 무엇을 포기했는가.
- "io_uring과 비동기 I/O 모델" — Linux의 최신 비동기 I/O, netpoller의 미래 방향.
- "Rust tokio 런타임 내부" — stackless coroutine의 대안 접근.