동시성은 구조고, 병렬성은 실행이다
— go 한 줄이 실제로 무슨 일을 하는지
// 순차 — 기다림이 쌓인다 a := fetch(u1) // 100ms b := fetch(u2) // 100ms c := fetch(u3) // 100ms // 합계 300ms // 동시 — 기다림이 겹친다 for i, u := range urls { go func() { out[i] = fetch(u) }() } wg.Wait() // 합계 ~100ms
go 키워드와 channel을 언어 차원에서 제공한다. 라이브러리를 고르는 단계가 없다.
| 편 | 제목 | 다루는 영역 |
|---|---|---|
| 편 1 | 개요와 Goroutine 기초 | 실행 단위 |
| 편 2 · 3 | Channel 완전 정복 / Select와 Channel 심화 패턴 | 통신 |
| 편 4 · 5 | sync 패키지 완벽 가이드 / Context 완벽 가이드 | 동기화 · 생명주기 |
| 편 6 · 7 | 동시성 패턴 실전 / 에러 처리 전략 | 설계 패턴 |
| 편 8 · 9 | Go Memory Model과 Atomic / Debugging과 Race Detector | 저수준 · 디버깅 |
| 편 10 · 11 | 실전 프로젝트와 Best Practices / go tool trace 시각화 | 실전 · 도구 |
오늘은 첫 편이다. 나머지 열 편이 전부 이 위에 얹히기 때문에, goroutine이 무엇이고 어떻게 스케줄되는지를 여기서 확실히 깔고 간다.
go 키워드 · OS thread와의 차이 · 실행 순서와 생명주기GOMAXPROCScontext로 막는 법 · 종료 경로라는 원칙동시성과 병렬성은 다른 말이다. 이 구분에서 출발해 Go가 왜 CSP를 골랐는지, 그리고 언제 쓰면 안 되는지까지 본다.
| 구분 | Concurrency (동시성) | Parallelism (병렬성) |
|---|---|---|
| 정의 | 여러 작업을 동시에 다루는 구조 | 여러 작업을 동시에 실행하는 것 |
| 핵심 | 작업의 구성 — composition | 작업의 실행 — execution |
| CPU | 1개의 CPU에서도 성립한다 | 여러 CPU가 필요하다 |
| 비유 | 한 사람이 여러 일을 번갈아 처리 | 여러 사람이 각자 일을 동시에 처리 |
“Concurrency is about dealing with lots of things at once.
Parallelism is about doing lots of things at once.”
— Rob Pike
Dealing with — 다루기
코드를 독립적으로 실행 가능한 단위로 쪼개는 일. 프로그램의 구조에 관한 이야기다.
Doing — 하기
쪼개진 단위를 실제로 여러 CPU에서 동시에 굴리는 일. 이건 runtime과 하드웨어의 몫이다.
Communicating Sequential Processes. 1978년 Tony Hoare가 제안했다. 핵심은 하나다 — 독립적인 프로세스들이 메시지 전달로 통신한다.
| CSP 개념 | Go의 구현 |
|---|---|
| 독립적인 실행 단위 | goroutine |
| 메시지 전달 수단 | channel |
전통적 방식 — Shared Memory + Lock
보호를 사람이 걸어야 한다. deadlock, race condition이 여기서 나온다.
Go 방식 — Message Passing
소유권이 넘어가므로 동시에 두 곳에서 만질 일이 없다. lock이 필요 없어진다.
sync.Mutex는 있고 필요할 때 쓴다. 다만 기본 선택지가 channel이라는 것이 Go의 태도다.
goroutine을 뿌린다고 빨라지지 않는다. 복잡도라는 비용을 먼저 지불한다.
쓸 만한 경우
오버엔지니어링인 경우
키워드 하나로 만들어지는 실행 단위. 얼마나 가볍고, 무엇을 보장하지 않는지를 정확히 알아야 사고가 안 난다.
// goroutine 생성 — go 키워드 사용 go func() { fmt.Println("goroutine 실행됨") }() // 이름 있는 함수도 가능 go sayHello("World")
async/await도 없다| 구분 | Goroutine | OS Thread |
|---|---|---|
| 초기 스택 크기 | ~2KB — 필요하면 동적으로 증가 | ~1MB 고정 |
| 생성 비용 | 매우 저렴 | 상대적으로 비쌈 |
| 스케줄링 | Go runtime — 사용자 공간 | OS 커널 |
| 동시 실행 수 | 수십만 개 가능 | 수천 개 수준 |
| 컨텍스트 스위칭 | 빠름 — 레지스터 3개 | 느림 — 전체 레지스터 |
그래서 goroutine은 수십만 개, OS thread는 수천 개가 현실적인 한계선이 된다. 게다가 goroutine 스택은 고정이 아니라 필요할 때 늘어난다.
아래 막대가 선 한 줄로 보이는 게 오류가 아니다. 실제 비율이 저렇다.
P의 큐에 넣고 언제 꺼낼지는 매 실행마다 다르다sync로 직접 만들어야 한다. 기본값으로 주어지지 않는다.
const numGoroutines = 10 wg.Add(numGoroutines) for i := range numGoroutines { go func() { defer wg.Done() mu.Lock() order = append(order, i) mu.Unlock() }() } wg.Wait() t.Logf("실행 순서: %v", order) // 실행 순서: [1 4 2 3 5 9 8 0 6 7]
main()은 main goroutine에서 실행된다. 이게 반환되면 다른 goroutine의 완료 여부와 상관없이 프로세스가 종료된다.
func TestMainExitKillsGoroutines(t *testing.T) { var completed atomic.Bool go func() { time.Sleep(100 * time.Millisecond) completed.Store(true) }() // 기다리지 않으면 완료되지 않는다 assert.False(t, completed.Load()) }
defer도 실행되지 않는다sync.WaitGroupfunc TestWaitGroupSolution(t *testing.T) { var completed atomic.Bool var wg sync.WaitGroup wg.Add(1) go func() { defer wg.Done() time.Sleep(50 * time.Millisecond) completed.Store(true) }() wg.Wait() // 완료될 때까지 대기 assert.True(t, completed.Load()) }
channel로도 같은 일을 할 수 있다.
time.Sleep으로 때우는 코드는 대기가 아니라 도박이다.
| 1만 개를 만들면 | 초기 스택 합계 |
|---|---|
| Goroutine (~2KB) | 약 20 MB |
| OS Thread (~1MB) | 약 10 GB |
앞 슬라이드 스택 크기로 계산한 단순 산술값이다.
const numGoroutines = 10000 var counter atomic.Int64 var wg sync.WaitGroup wg.Add(numGoroutines) for range numGoroutines { go func() { defer wg.Done() counter.Add(1) }() } wg.Wait() assert.Equal(t, int64(numGoroutines), counter.Load())
OS가 goroutine을 모르는데 어떻게 돌아가는가. Go runtime이 사용자 공간에서 직접 스케줄링하는 구조를 본다.
| 구성 요소 | 역할 |
|---|---|
| G — Goroutine | 실행할 함수와 스택 정보를 담은 경량 실행 단위 |
| M — Machine | OS thread. 실제로 CPU에서 코드를 실행한다 |
| P — Processor | 논리 프로세서. goroutine의 실행 큐(run queue)를 관리한다 |
G가 생성되면 현재 P의 로컬 run queue에 들어간다P는 바인딩된 M에서 큐의 goroutine을 하나씩 꺼내 실행한다time.Sleep으로 막히면 즉시 다음 G로 전환한다P와 노는 P가 생기지 않도록 부하를 스스로 고르게 만든다.
Go 1.14부터는 선점형(preemptive)이라, CPU만 오래 붙잡고 있는 goroutine도 runtime이 강제로 끌어내린다.
GOMAXPROCS — 동시에 실행될 수 있는 개수goroutine을 몇 개 만들 수 있는지가 아니라, P를 몇 개 둘지를 정하는 값이다. 기본값은 CPU 코어 수.
func TestGOMAXPROCS(t *testing.T) { // 0 을 주면 값을 바꾸지 않고 현재 값만 반환 currentProcs := runtime.GOMAXPROCS(0) numCPU := runtime.NumCPU() t.Logf("CPU 수: %d", numCPU) // 12 t.Logf("현재 GOMAXPROCS: %d", currentProcs) // 12 runtime.GOMAXPROCS(1) // P 를 1개로 }
GOMAXPROCS = 1
goroutine은 여전히 얼마든지 만들어진다. 다만 한 번에 하나만 실행된다 — concurrency는 있고 parallelism은 없다. race 재현·디버깅에 유용하다.
GOMAXPROCS = N
최대 N개가 물리적으로 동시에 실행된다. 기본값을 그대로 두는 것이 권장된다. 컨테이너 환경이라면 CPU limit과 맞는지만 확인한다.
Kotlin Coroutine, Java Thread와 나란히 놓고 보면 goroutine이 무엇을 포기하고 무엇을 얻었는지가 선명해진다.
| 구분 | Go Goroutine | Kotlin Coroutine | Java Platform Thread | Java Virtual Thread (21+) |
|---|---|---|---|---|
| 스택 크기 | ~2KB · 동적 증가 | stackless · 힙 객체 | ~1MB 고정 | ~수KB · 동적 |
| 스케줄링 | Go runtime · preemptive | 협력적 · suspend/resume | OS 커널 | JVM · cooperative |
| 생성 비용 | 매우 저렴 | 매우 저렴 | 비쌈 | 저렴 |
| 동시 실행 수 | 수십만 개 | 수십만 개 | 수천 개 | 수백만 개 |
| 통신 방식 | Channel — CSP | Flow, Channel | synchronized, Lock | synchronized, Lock |
Kotlin — 협력적 (cooperative)
// suspend 지점에서만 중단된다 suspend fun fetchData() { delay(1000) // 여기서 양보 // suspend 없는 CPU 작업은 // 양보하지 않는다 }
CPU만 붙잡고 도는 코드는 스레드를 점유한 채 놓지 않는다.
Go — 선점형 (preemptive)
// 별도 키워드 없이 runtime이 전환 func fetchData() { time.Sleep(time.Second) // CPU-bound 작업도 // runtime이 강제 전환 (Go 1.14+) }
goroutine이 CPU를 오래 점유하면 runtime이 끌어내린다.
Kotlin — 색이 전파된다
suspend fun a() = b() suspend fun b() = c() suspend fun c() = delay(100) // c 를 suspend 로 바꾸는 순간 // 호출 체인 전체에 suspend 가 번진다
suspend 함수는 suspend 함수나 coroutine 안에서만 호출된다.
Go — 전부 같은 함수다
func a() { b() } func b() { c() } func c() { time.Sleep(t) } go a() // 아무 함수나 goroutine 으로 a() // 그냥 불러도 된다
async · await · suspend 같은 구분 자체가 없다.
CoroutineExceptionHandler로 에러 전파가 체계적이다.
// Platform Thread — OS thread 1:1 매핑 new Thread(() -> doWork()).start(); // ~1MB 스택 할당 // Virtual Thread (Java 21+) — goroutine 유사 Thread.startVirtualThread(() -> doWork());
Platform Thread는 수천 개만 넘어가도 메모리와 컨텍스트 스위칭 비용이 급증한다. Virtual Thread는 그 문제를 개념적으로 goroutine과 같은 방식으로 푼다.
BlockingQueue나 CompletableFuture 같은 별도 도구를 골라 써야 한다.
강점
go + chan이 키워드로 제공되어 별도 라이브러리가 필요 없다async/await/suspend 구분 없이 모든 함수가 동일하다약점
Context와 WaitGroup으로 손수 관리해야 한다panic이 나면 프로그램 전체가 종료될 수 있다만들기 쉬운 만큼 정리하기를 잊기 쉽다. 새는 goroutine은 GC도 회수하지 못한다.
더 이상 필요 없는데 끝나지 않는 상태다. 메모리를 점유한 채 GC 대상이 되지도 않으므로, 시간이 지날수록 사용량이 계속 올라간다.
| 대표적인 원인 | 어떤 코드에서 |
|---|---|
| Channel 대기 | 아무도 receive/send하지 않는 channel에서 영원히 blocking |
| 무한 루프 | 종료 조건이 없는 goroutine |
| Context 미사용 | 취소 신호를 받을 방법 없이 실행되는 goroutine |
// 현재 goroutine 수를 세어보면 보인다 runtime.NumGoroutine()
leakyFunc := func() <-chan int { ch := make(chan int) go func() { ch <- 42 // 아무도 receive하지 않으면 영원히 blocking }() return ch } _ = leakyFunc() // 반환은 받지만 쓰지 않는다 → leak! time.Sleep(50 * time.Millisecond) runtime.NumGoroutine() // 초기 2 → leak 후 3
safeFunc := func(ctx context.Context) <-chan int { ch := make(chan int, 1) // buffered → send가 안 막힌다 go func() { defer close(ch) select { case ch <- 42: // 정상적으로 값 전달 case <-ctx.Done(): // context 취소 시 종료 return } }() return ch } ctx, cancel := context.WithCancel(context.Background()) ch := safeFunc(ctx) cancel() // 취소하면 goroutine이 정리된다
ch <- 42에서 영원히 멈춘다select + ctx.Done() — 취소되면 return으로 빠져나간다defer close(ch) — 종료 시 channel도 함께 정리된다context
ctx.Done()을 select에 끼워 넣는다. 취소·타임아웃·데드라인이 전부 여기로 온다.
done channel
context를 쓰기 어려운 자리에서 종료 신호만 따로 보낼 때. 닫히면 모든 수신자가 깨어난다.
close
보내는 쪽이 다 보냈음을 알린다. defer close(ch)로 짝을 맞춘다.
Context의 전체 그림은 편 5에서, 새는 goroutine을 실제로 잡아내는 방법은 편 9에서 다룬다.
go 한 줄로 만드는 ~2KB 실행 단위다. 수만 개가 정상이다GOMAXPROCS(기본 = CPU 코어 수)가 정한다sync로 직접 만들어야 한다. → 슬라이드 16defer조차 실행되지 않는다. WaitGroup이나 channel로 기다려야 한다. → 슬라이드 17 · 18P가 1개일 뿐이라 한 번에 하나만 실행된다. goroutine 자체는 얼마든지 만들어진다. concurrency는 있고 parallelism이 없는 상태다. → 슬라이드 23suspend 같은 구분이 없다. → 슬라이드 26 · 27select/ctx.Done()으로 종료 경로를 만들어야 한다. → 슬라이드 32오늘은 실행 단위만 다뤘다. goroutine 여러 개를 띄울 줄 알아도, 그들 사이에 데이터를 주고받지 못하면 할 수 있는 일이 없다. 다음 편에서는 CSP의 나머지 절반인 channel을 처음부터 끝까지 본다.
close의 정확한 의미, 방향이 있는 channel —
오늘 leak 슬라이드에서 스쳐 간 것들이 전부 다음 편의 주제다.
참고 자료
github.com/kenshin579/tutorials-go /golang/concurrencyadvenoh.pe.kr