Golang Concurrency · 편 1 / 11

개요와
Goroutine 기초

동시성은 구조고, 병렬성은 실행이다
go 한 줄이 실제로 무슨 일을 하는지

Concurrency Goroutine GMP 스케줄러 Leak 방지
goroutine 스케줄 타임라인 GOMAXPROCS = 2
go func() { … }() ×8 M = OS thread
인사 + 오늘은 시리즈 11편 중 1편. 오른쪽 그림이 오늘의 결론이라고 미리 말해둔다 — goroutine 8개가 OS thread 2개 위에서 번갈아 실행되는 그림. 이 한 장을 이해하는 게 오늘 목표다.
문제why concurrency

기다리는 시간이 그대로 벽시계 시간이 된다

  • 외부 API 세 곳을 호출해 결과를 합쳐야 한다
  • 각각 100ms — 그런데 그 100ms는 대부분 응답을 기다리는 시간이다
  • 순차로 부르면 300ms. 그동안 CPU는 놀고 있다
  • 세 호출은 서로 독립적이다. 겹쳐서 기다리지 못할 이유가 없다
// 순차 — 기다림이 쌓인다
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의 답 go 키워드와 channel언어 차원에서 제공한다. 라이브러리를 고르는 단계가 없다.
"세 API를 순서대로 부르는 코드 짜본 적 있는 분?"으로 시작하면 공감이 빠르다. 핵심은 CPU가 바쁜 게 아니라 기다리고 있다는 점. 뒤의 3장(언제 쓰나)에서 반대 경우 — CPU-bound — 도 다룬다고 예고.
시리즈11 parts

전체 시리즈 안에서 오늘의 위치

제목다루는 영역
편 1개요와 Goroutine 기초실행 단위
편 2 · 3Channel 완전 정복 / Select와 Channel 심화 패턴통신
편 4 · 5sync 패키지 완벽 가이드 / Context 완벽 가이드동기화 · 생명주기
편 6 · 7동시성 패턴 실전 / 에러 처리 전략설계 패턴
편 8 · 9Go Memory Model과 Atomic / Debugging과 Race Detector저수준 · 디버깅
편 10 · 11실전 프로젝트와 Best Practices / go tool trace 시각화실전 · 도구

오늘은 첫 편이다. 나머지 열 편이 전부 이 위에 얹히기 때문에, goroutine이 무엇이고 어떻게 스케줄되는지를 여기서 확실히 깔고 간다.

시리즈 전체를 다 소개하려 하지 말고 "오늘은 바닥을 까는 편"이라는 위치만 짚는다. Channel은 다음 편이라고 선을 그어두면 질문이 앞서가지 않는다.
목차agenda

오늘 다루는 것

01
Concurrency란 무엇인가
Concurrency vs Parallelism · CSP 모델 · 언제 쓰고 언제 쓰지 않나
02
Goroutine 기초
go 키워드 · OS thread와의 차이 · 실행 순서와 생명주기
03
GMP 스케줄러
G · M · P의 역할 · work stealing · GOMAXPROCS
04
다른 언어와의 비교
Kotlin Coroutine · Java Platform / Virtual Thread · 함수 색칠 문제
05
Goroutine Leak
왜 새는가 · context로 막는 법 · 종료 경로라는 원칙
1·2장이 개념과 기초, 3장이 원리, 4장은 배경지식 있는 청중용, 5장이 실무 함정. 시간이 모자라면 4장(다른 언어 비교)을 통째로 줄이고 5장을 살린다.
01

Concurrency란 무엇인가

동시성과 병렬성은 다른 말이다. 이 구분에서 출발해 Go가 왜 CSP를 골랐는지, 그리고 언제 쓰면 안 되는지까지 본다.

이 장은 개념이라 지루해지기 쉽다. 표 한 장 → 그림 한 장 → 인용 한 장으로 리듬을 바꿔가며 빠르게 넘긴다.
01 · 개념concurrency vs parallelism

자주 섞어 쓰지만, 다른 말이다

구분Concurrency (동시성)Parallelism (병렬성)
정의여러 작업을 동시에 다루는 구조여러 작업을 동시에 실행하는
핵심작업의 구성 — composition작업의 실행 — execution
CPU1개의 CPU에서도 성립한다여러 CPU가 필요하다
비유한 사람이 여러 일을 번갈아 처리여러 사람이 각자 일을 동시에 처리
여기만 기억하면 된다 싱글 코어 머신에서도 concurrency는 성립한다. 동시에 다루는 것동시에 실행되는 것은 별개다.
"CPU 1개에서도 동시성이 가능한가?"를 청중에게 먼저 물어보면 대부분 아니라고 답한다. 여기서 한 번 뒤집어주면 나머지가 쉬워진다. 퀴즈 Q1과 직결.
01 · 개념timeline

같은 두 작업, 다른 두 그림

Concurrency — CPU 1개 · 번갈아 처리
A B A B A B A
Parallelism — CPU 2개 · 진짜로 동시에
Task A
Task B
시작time →
Go의 입장 프로그램을 concurrent하게 설계하는 것이 개발자의 몫이고, 그걸 몇 개의 CPU에 얹을지는 runtime이 알아서 한다.
같은 작업량인데 아래 두 레인이 더 일찍 끝난다는 점(가로 길이)을 손으로 짚어준다. 다만 concurrency의 목적이 항상 속도는 아니라는 것도 한 마디 — 구조가 먼저다. CPU가 1개뿐이면 위 그림이 되고, 그래도 concurrency는 성립한다.
01 · 개념rob pike

Go 창시자의 한 문장

“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과 하드웨어의 몫이다.

이 문장은 Go Blog "Concurrency is not parallelism" 강연에서 나온 것. 원문 영상 링크는 마지막 참고 슬라이드에 있다. 여기서 잠깐 멈춰 읽을 시간을 준다.
01 · 개념csp

Go가 고른 모델 — CSP

Communicating Sequential Processes. 1978년 Tony Hoare가 제안했다. 핵심은 하나다 — 독립적인 프로세스들이 메시지 전달로 통신한다.

CSP 개념Go의 구현
독립적인 실행 단위goroutine
메시지 전달 수단channel
Goroutine A데이터 소유
Goroutine B데이터 수신
왜 이게 안전한가 데이터의 소유권이 channel을 통해 넘어가므로, 한 시점에 하나의 goroutine만 그 데이터에 접근한다.
CSP라는 이름 자체는 중요하지 않다. "메시지로 주고받는다"는 한 줄만 남기면 된다. Erlang/Akka의 actor 모델과 비교 질문이 나오면 "메일박스 대신 channel이 1급 객체"라고만 짚고 넘어간다.
01 · 개념go proverb

메모리를 공유해서 통신하지 말고, 통신해서 메모리를 공유하라

전통적 방식 — Shared Memory + Lock

보호를 사람이 걸어야 한다. deadlock, race condition이 여기서 나온다.

Go 방식 — Message Passing

Goroutine A
Goroutine B

소유권이 넘어가므로 동시에 두 곳에서 만질 일이 없다. lock이 필요 없어진다.

단, 절대 규칙은 아니다 Go에도 sync.Mutex는 있고 필요할 때 쓴다. 다만 기본 선택지가 channel이라는 것이 Go의 태도다.
"그럼 mutex는 쓰면 안 되나요?" 질문이 반드시 나온다. 마지막 콜아웃이 그 답이다. 상태를 공유해야만 하는 카운터 같은 경우는 4편(sync 패키지)에서 다룬다고 넘긴다.
01 · 개념when to use

동시성은 공짜가 아니다

goroutine을 뿌린다고 빨라지지 않는다. 복잡도라는 비용을 먼저 지불한다.

쓸 만한 경우

  • I/O 대기가 많은 작업 — HTTP 요청, DB 쿼리, 파일 읽기/쓰기
  • 독립적인 작업의 병렬 처리 — 여러 API를 동시에 호출
  • 이벤트 기반 처리 — 웹 서버의 요청 처리
  • 파이프라인 처리 — 데이터 변환 스테이지 체이닝

오버엔지니어링인 경우

  • 단순 순차 처리로 충분한 경우 — 간단한 데이터 변환
  • CPU-bound 작업에서 goroutine을 과도하게 생성하는 경우
  • 공유 상태가 많아 lock이 복잡해지는 경우 — 설계를 다시 본다
  • 디버깅이 어려워질 정도로 복잡한 경우
오른쪽 카드가 더 중요하다. 특히 CPU-bound에 goroutine을 1만 개 뿌려도 코어 수 이상으로는 빨라지지 않는다는 점 — 3장 GOMAXPROCS에서 다시 연결된다.
02

Goroutine 기초

키워드 하나로 만들어지는 실행 단위. 얼마나 가볍고, 무엇을 보장하지 않는지를 정확히 알아야 사고가 안 난다.

이 장의 핵심은 "가볍다"가 아니라 "순서를 보장하지 않는다"와 "main이 끝나면 다 끝난다" 두 가지다. 실습 중 가장 많이 밟는 지뢰가 여기 있다.
02 · Goroutinego keyword

함수 호출 앞에 go 를 붙인다. 그게 전부다

// goroutine 생성 — go 키워드 사용
go func() {
    fmt.Println("goroutine 실행됨")
}()

// 이름 있는 함수도 가능
go sayHello("World")
  • Go runtime이 관리하는 경량 실행 단위다 — OS thread가 아니다
  • 별도 라이브러리도, 스레드 풀 설정도, async/await도 없다
  • 호출은 즉시 반환된다. 함수는 다른 데서 돌기 시작한다
  • 표준 라이브러리 전체가 이 전제 위에 설계되어 있다
대신 따라오는 것 만들기가 쉬운 만큼 정리하기를 잊기도 쉽다. 5장에서 이 대가를 치른다.
여기서 데모를 하나 돌려도 좋다. go 붙인 Println이 출력되지 않는 걸 보여주면 17번(main goroutine) 슬라이드로 자연스럽게 이어진다.
02 · Goroutinevs os thread

OS Thread와 무엇이 다른가

구분GoroutineOS Thread
초기 스택 크기~2KB — 필요하면 동적으로 증가~1MB 고정
생성 비용매우 저렴상대적으로 비쌈
스케줄링Go runtime — 사용자 공간OS 커널
동시 실행 수수십만 개 가능수천 개 수준
컨텍스트 스위칭빠름 — 레지스터 3개느림 — 전체 레지스터
핵심 문장 goroutine은 OS thread 위에서 멀티플렉싱된다. 수천~수만 개가 소수의 OS thread에서 돌아간다.
"사용자 공간에서 스케줄링한다"가 왜 빠른가 — 커널 모드 전환이 없기 때문. 여기서 자세히 들어가지 말고 3장 GMP에서 그림으로 풀겠다고 넘긴다.
02 · Goroutinestack size

2KB와 1MB를 같은 축에 그리면

1 MB÷2 KB=
0 배 — 같은 메모리로 그만큼 더 만든다

그래서 goroutine은 수십만 개, OS thread는 수천 개가 현실적인 한계선이 된다. 게다가 goroutine 스택은 고정이 아니라 필요할 때 늘어난다.

OS Thread 1 MB
Goroutine 2 KB

아래 막대가 선 한 줄로 보이는 게 오류가 아니다. 실제 비율이 저렇다.

숫자가 올라가는 동안 잠깐 멈춘다. 512라는 수가 체감되면 "goroutine 1만 개"가 왜 놀랄 일이 아닌지 뒤(19번)에서 바로 이해된다.
02 · Goroutinenon-deterministic

실행 순서는 보장되지 않는다

  • 10개를 순서대로 띄웠다고 순서대로 실행되지 않는다
  • 스케줄러가 어떤 P의 큐에 넣고 언제 꺼낼지는 매 실행마다 다르다
  • 테스트가 어쩌다 통과한다면 그건 이다
순서가 필요하다면 channel이나 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]
데모로 두세 번 돌려서 매번 다른 결과가 나오는 걸 보여주면 설명이 필요 없다. 참고로 Go 1.22부터 for 루프 변수가 반복마다 새로 생기므로 예전처럼 i를 인자로 넘길 필요가 없다.
02 · Goroutinelifecycle

main이 끝나면 전부 끝난다

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())
}
  • goroutine은 정리 기회를 못 받고 그냥 사라진다 — defer도 실행되지 않는다
  • 로그 flush, 커넥션 close 같은 마무리도 날아간다
  • “왜 아무것도 출력이 안 되죠?”의 90%가 이것이다
규칙 goroutine을 만들었으면 누가 기다릴지를 같이 정한다.
13번에서 go Println 데모를 했다면 여기서 답을 준다. time.Sleep으로 때우는 코드를 본 적 있을 텐데, 그게 왜 나쁜지는 다음 장에서 WaitGroup으로 대체하며 설명.
02 · Goroutinewaitgroup

기다리는 방법 — sync.WaitGroup

func 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())
}
01
wg.Add(n)
기다릴 개수를 goroutine을 띄우기 전에 올린다
02
defer wg.Done()
goroutine 안 첫 줄에 defer로 둔다 — panic이 나도 카운터가 내려간다
03
wg.Wait()
0이 될 때까지 블로킹한다

channel로도 같은 일을 할 수 있다. time.Sleep으로 때우는 코드는 대기가 아니라 도박이다.

Add를 goroutine 안에서 호출하면 경쟁이 생긴다는 함정을 한 줄 짚어준다. WaitGroup의 나머지 세부는 4편에서 다룬다.
02 · Goroutinelightweight

1만 개를 만들어도 아무 일도 일어나지 않는다

0 개 goroutine — 전부 정상 완료
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())
"1만 개"가 감탄 포인트가 아니라 기본값이라는 톤으로 말한다. 다만 무한정 만들어도 된다는 뜻은 아니고, 개수 제한이 필요하면 semaphore 패턴(6편)을 쓴다고 한 줄 예고.
03

GMP 스케줄러

OS가 goroutine을 모르는데 어떻게 돌아가는가. Go runtime이 사용자 공간에서 직접 스케줄링하는 구조를 본다.

내부 구조라 깊게 들어가면 끝이 없다. G·M·P 세 글자와 work stealing, GOMAXPROCS 세 가지만 남기는 것이 목표.
03 · 스케줄러gmp model

세 글자로 된 구조 — G · M · P

G1실행 중
G2대기 — run queue
G3대기 — run queue
P1논리 프로세서 · 실행 큐 관리
M1OS Thread — 실제 CPU 실행
CPU Corehardware
구성 요소역할
G — Goroutine실행할 함수와 스택 정보를 담은 경량 실행 단위
M — MachineOS thread. 실제로 CPU에서 코드를 실행한다
P — Processor논리 프로세서. goroutine의 실행 큐(run queue)를 관리한다
P가 왜 필요한지가 매번 나오는 질문. "M(스레드)은 blocking될 수 있으니, 실행할 일감 목록(P)을 스레드에서 떼어낸 것"이라고 설명하면 다음 슬라이드 3단계와 맞물린다.
03 · 스케줄러scheduling flow

한 바퀴 도는 순서

01
go f()
G가 생성되면 현재 P로컬 run queue에 들어간다
02
P → M
P는 바인딩된 M에서 큐의 goroutine을 하나씩 꺼내 실행한다
03
block → switch
I/O · channel 대기 · time.Sleep으로 막히면 즉시 다음 G로 전환한다
04
work stealing
로컬 큐가 비면 다른 P의 큐에서 훔쳐 온다
이 전환이 싼 이유 커널을 거치지 않는다. 사용자 공간에서 레지스터 몇 개만 바꿔 끼우면 끝이다.
work stealing이 하는 일 일이 몰린 P와 노는 P가 생기지 않도록 부하를 스스로 고르게 만든다.

Go 1.14부터는 선점형(preemptive)이라, CPU만 오래 붙잡고 있는 goroutine도 runtime이 강제로 끌어내린다.

3번이 오늘 슬라이드 2번(I/O 대기)의 답이다. 기다리는 동안 M이 노는 게 아니라 다음 G를 잡는다는 그림을 연결해준다.
03 · 스케줄러gomaxprocs

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과 맞는지만 확인한다.

"goroutine 개수 제한값 아니냐"는 오해가 가장 흔하다. 퀴즈 Q4가 이걸 묻는다. 컨테이너에서 GOMAXPROCS가 호스트 코어 수로 잡히는 이슈는 깊게 들어가지 말고 한 줄만.
04

다른 언어와의 비교

Kotlin Coroutine, Java Thread와 나란히 놓고 보면 goroutine이 무엇을 포기하고 무엇을 얻었는지가 선명해진다.

청중 배경에 따라 비중을 조절한다. JVM 경험자가 많으면 여기가 제일 잘 통하는 장이고, 아니면 표 한 장만 보고 넘어가도 된다.
04 · 비교side by side

넷을 나란히 놓으면

구분Go GoroutineKotlin CoroutineJava Platform ThreadJava Virtual Thread (21+)
스택 크기~2KB · 동적 증가stackless · 힙 객체~1MB 고정~수KB · 동적
스케줄링Go runtime · preemptive협력적 · suspend/resumeOS 커널JVM · cooperative
생성 비용매우 저렴매우 저렴비쌈저렴
동시 실행 수수십만 개수십만 개수천 개수백만 개
통신 방식Channel — CSPFlow, Channelsynchronized, Locksynchronized, Lock
표에서 눈여겨볼 줄 숫자가 아니라 스케줄링통신 방식 두 줄이다. 나머지 슬라이드는 이 둘을 풀어 쓴 것이다.
표를 한 칸씩 읽지 말 것. 두 줄만 짚고 다음으로 넘어간다. Virtual Thread의 "수백만 개"에 반응이 오면, 대신 channel이 없다는 점을 28번에서 다룬다고 예고.
04 · 비교preemptive vs cooperative

가장 큰 차이는 언제 양보하는가

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이 끌어내린다.

실무에서 체감되는 지점 Go에서는 “양보 지점을 넣는 것”을 개발자가 신경 쓸 필요가 없다.
Go 1.14 이전에는 tight loop이 스케줄러를 굶기는 문제가 실제로 있었다는 역사도 한 줄. 지금은 signal 기반 비동기 선점이 들어가 있다.
04 · 비교function coloring

함수 색칠 문제 — Go에는 색이 없다

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 같은 구분 자체가 없다.

반대로 Kotlin이 나은 점 Structured Concurrency가 언어에 내장돼 부모가 취소되면 자식도 자동 취소되고, CoroutineExceptionHandler에러 전파가 체계적이다.
균형을 잡아주는 슬라이드. Go가 이겼다는 톤으로 가면 JVM 진영 청중이 닫힌다. Go는 structured concurrency가 없어 Context/WaitGroup을 손으로 관리해야 한다는 점을 다음 슬라이드에서 명시한다.
04 · 비교java

Java — Virtual Thread로 가까워졌지만

// Platform Thread — OS thread 1:1 매핑
new Thread(() -> doWork()).start();
// ~1MB 스택 할당

// Virtual Thread (Java 21+) — goroutine 유사
Thread.startVirtualThread(() -> doWork());

Platform Thread는 수천 개만 넘어가도 메모리와 컨텍스트 스위칭 비용이 급증한다. Virtual Thread는 그 문제를 개념적으로 goroutine과 같은 방식으로 푼다.

남는 차이 Java에는 Channel 같은 통신 메커니즘이 언어에 내장돼 있지 않다. BlockingQueueCompletableFuture 같은 별도 도구를 골라 써야 한다.
그래서 결론 “가벼운 실행 단위”는 이제 Go만의 것이 아니다. Go가 여전히 다른 지점은 실행 단위와 통신 수단이 한 세트로 언어에 들어있다는 점이다.
Loom/Virtual Thread를 아는 청중이 있으면 여기서 대화가 길어질 수 있다. "언어 내장 통신 수단"이라는 축으로 돌려놓고 다음으로 넘긴다.
04 · 비교summary

정리 — goroutine이 얻은 것과 못 얻은 것

강점

01
언어 내장
go + chan이 키워드로 제공되어 별도 라이브러리가 필요 없다
02
색칠 문제 없음
async/await/suspend 구분 없이 모든 함수가 동일하다
03
preemptive
CPU-bound goroutine도 runtime이 자동 전환한다
04
일관된 생태계
표준 라이브러리 전체가 goroutine 기반으로 설계되어 있다

약점

01
structured concurrency 부재
부모–자식 관계가 언어에 없다. ContextWaitGroup으로 손수 관리해야 한다
02
panic 전파
goroutine에서 panic이 나면 프로그램 전체가 종료될 수 있다
다음 장 예고 “손수 관리해야 한다”를 게을리하면 어떻게 되는지 — 그게 Goroutine Leak이다.
약점 카드가 다음 챕터로 넘어가는 다리 역할이다. 여기서 바로 5장으로 넘긴다.
05

Goroutine Leak

만들기 쉬운 만큼 정리하기를 잊기 쉽다. 새는 goroutine은 GC도 회수하지 못한다.

오늘 발표에서 실무에 가장 직접 닿는 장. 시간이 부족해도 여기는 살린다.
05 · Leakwhat & why

종료되지 않고 계속 살아있는 goroutine

더 이상 필요 없는데 끝나지 않는 상태다. 메모리를 점유한 채 GC 대상이 되지도 않으므로, 시간이 지날수록 사용량이 계속 올라간다.

대표적인 원인어떤 코드에서
Channel 대기아무도 receive/send하지 않는 channel에서 영원히 blocking
무한 루프종료 조건이 없는 goroutine
Context 미사용취소 신호를 받을 방법 없이 실행되는 goroutine
왜 GC가 못 잡는가 실행 중인 goroutine은 살아있는 루트다. 블로킹돼 있어도 “실행 중”이라 회수 대상이 아니다.
// 현재 goroutine 수를 세어보면 보인다
runtime.NumGoroutine()
"메모리 누수는 들어봤는데 goroutine 누수는 처음"이라는 반응이 흔하다. 장시간 뜨는 서버에서 NumGoroutine이 우상향하면 그게 신호라고 알려준다. 9편에서 pprof로 잡는 법을 다룬다.
05 · Leakbefore → after

새는 코드와, 막은 코드

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이 정리된다
  • unbuffered channel이라 받는 쪽이 붙어야 send가 끝난다
  • 호출한 쪽이 channel을 안 읽으면 goroutine은 ch <- 42에서 영원히 멈춘다
  • 이 패턴이 요청마다 반복되면 goroutine이 계속 쌓인다
징후 메모리 사용량이 천천히, 꾸준히 오른다. 재시작하면 잠깐 괜찮아진다.
  • buffered channel — 수신자가 없어도 send가 blocking되지 않는다
  • select + ctx.Done() — 취소되면 return으로 빠져나간다
  • defer close(ch) — 종료 시 channel도 함께 정리된다
달라진 점 goroutine에게 바깥에서 끝내는 방법이 생겼다.
두 버튼을 번갈아 눌러 비교한다. 먼저 "이 코드가 왜 새는지 보이시나요?"를 묻고, 답이 안 나오면 unbuffered channel의 send가 블로킹이라는 점만 힌트로 준다. 퀴즈 Q6과 연결.
05 · Leakthe rule

goroutine을 만들 때는 종료 경로를 함께 만든다

context

ctx.Done()select에 끼워 넣는다. 취소·타임아웃·데드라인이 전부 여기로 온다.

done channel

context를 쓰기 어려운 자리에서 종료 신호만 따로 보낼 때. 닫히면 모든 수신자가 깨어난다.

close

보내는 쪽이 다 보냈음을 알린다. defer close(ch)로 짝을 맞춘다.

코드 리뷰에서 던질 질문 하나 “이 goroutine은 언제, 누가 끝내나요?” — 답이 안 나오면 그건 leak이다.

Context의 전체 그림은 편 5에서, 새는 goroutine을 실제로 잡아내는 방법은 편 9에서 다룬다.

오늘 발표에서 딱 한 줄만 가져간다면 이 콜아웃이다. 실제 코드 리뷰에서 써먹을 수 있는 형태로 줬다는 점을 강조.
마무리recap

오늘 가져갈 것

  • Concurrency는 구조, Parallelism은 실행이다 — 구조를 짜면 실행은 runtime이 붙인다
  • Go는 CSP를 골랐다 — 메모리를 공유해 통신하지 말고, 통신해서 공유하라
  • goroutine은 go 한 줄로 만드는 ~2KB 실행 단위다. 수만 개가 정상이다
  • 실행 순서는 보장되지 않고, main이 끝나면 전부 끝난다 — 필요하면 직접 만든다
  • 스케줄링은 G · M · P 세 조각. 동시 실행 수는 GOMAXPROCS(기본 = CPU 코어 수)가 정한다
  • goroutine을 만들 때는 반드시 종료 경로를 함께 만든다 — 안 그러면 샌다
여섯 줄을 소리 내어 읽는 것만으로 정리가 된다. 여기서부터 질문을 받아도 좋다.
확인클릭하면 답이 열립니다

스스로 답해보기

성립한다. concurrency는 여러 작업을 동시에 다루는 구조이고, 여러 CPU가 필요한 건 parallelism이다. 코어 1개에서는 번갈아 실행될 뿐이다. → 슬라이드 06
아니다. 실행 순서는 보장되지 않는다. 순서가 필요하면 channel이나 sync로 직접 만들어야 한다. → 슬라이드 16
프로세스가 종료되면서 goroutine은 완료되지 않는다. defer조차 실행되지 않는다. WaitGroup이나 channel로 기다려야 한다. → 슬라이드 17 · 18
아니다. P가 1개일 뿐이라 한 번에 하나만 실행된다. goroutine 자체는 얼마든지 만들어진다. concurrency는 있고 parallelism이 없는 상태다. → 슬라이드 23
① 스케줄링 — Kotlin은 협력적이라 suspend 지점에서만 양보하고, Go는 선점형이라 runtime이 강제 전환한다. ② 함수 색칠 문제 — Go에는 suspend 같은 구분이 없다. → 슬라이드 26 · 27
send에서 영원히 blocking되고 GC도 회수하지 못한다 — goroutine leak이다. buffered channel + select/ctx.Done()으로 종료 경로를 만들어야 한다. → 슬라이드 32
청중에게 먼저 답하게 한 뒤 클릭해서 연다. 시간이 없으면 Q4와 Q6 두 개만 골라도 충분하다.
다음part 2

다음 편 — Channel 완전 정복

오늘은 실행 단위만 다뤘다. goroutine 여러 개를 띄울 줄 알아도, 그들 사이에 데이터를 주고받지 못하면 할 수 있는 일이 없다. 다음 편에서는 CSP의 나머지 절반인 channel을 처음부터 끝까지 본다.

오늘 미뤄둔 것들 unbuffered와 buffered의 차이, close의 정확한 의미, 방향이 있는 channel — 오늘 leak 슬라이드에서 스쳐 간 것들이 전부 다음 편의 주제다.

참고 자료

  • 전체 코드 — github.com/kenshin579/tutorials-go /golang/concurrency
  • Effective Go — Concurrency go.dev/doc/effective_go#concurrency
  • Concurrency is not parallelism go.dev/blog/waza-talk
  • Share Memory By Communicating go.dev/blog/codelab-share
  • Go Proverbs go-proverbs.github.io
감사합니다 질문 환영합니다 — advenoh.pe.kr
GitHub 저장소 주소를 띄워둔 채로 Q&A를 받는다.
발표자 노트
← 글 보기 Goroutine 기초 · Golang Concurrency 1
01 / 36

단축키

→ · Space · PgDn
다음 슬라이드
← · PgUp
이전 슬라이드
Home · End
처음 · 마지막
O
전체 슬라이드 개요
N
발표자 노트
T
밝은 테마 / 어두운 테마
F
전체 화면
Esc
닫기