Quick Flow
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
jobs := make(chan int)
results := make(chan int)
go func() {
defer close(jobs)
jobs <- 21
}()
go func() {
worker(ctx, jobs, results)
close(results) // 이 예제에서는 worker가 하나라 이 goroutine이 닫습니다.
}()
for result := range results {
fmt.Println(result)
}Go 동시성은 "goroutine을 만든다"보다 어디서 멈추고, 어떻게 값을 주고받고, 누가 종료를 책임지는가를 먼저 같이 봐야 합니다.
기본 흐름
goroutine은 가벼운 실행 단위다
go doWork()go 키워드를 붙이면 새 goroutine에서 함수가 실행됩니다. 하지만 이 한 줄만으로는 충분하지 않습니다. 결과를 어디로 보낼지, 언제 끝낼지, 호출자가 기다릴지까지 같이 설계해야 합니다.
channel은 goroutine 사이 값 전달 경계다
jobs := make(chan int)
results := make(chan int)channel은 공유 메모리보다 값 전달 경계로 읽는 편이 좋습니다.
func worker(jobs <-chan int, results chan<- int) {
for n := range jobs {
results <- n * 2
}
}<-chan T: 받기 전용chan<- T: 보내기 전용
방향을 시그니처에 넣으면 함수 책임이 더 선명해집니다.
context는 취소와 기한 전파를 맡는다
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()context는 결과를 담는 용도가 아니라, 취소 신호와 deadline을 전달하는 표준 경계입니다.
func worker(ctx context.Context, jobs <-chan int, results chan<- int) {
for {
select {
case <-ctx.Done():
return
case job, ok := <-jobs:
if !ok {
return
}
select {
case results <- job * 2:
case <-ctx.Done():
return
}
}
}
}닫힌 channel에서 단일 receive를 계속하면 zero value를 반복해서 받을 수 있습니다. job, ok := <-jobs 또는 for job := range jobs로 입력 종료를 분리합니다. 결과 send도 ctx.Done()과 경쟁시켜야 소비자가 먼저 끝났을 때 goroutine이 멈추지 않습니다.
goroutine만 만들고 취소를 안 두면 누수가 생기기 쉽다
Go 동시성에서 흔한 실패는 goroutine을 쉽게 만드는 대신, 종료 조건을 늦게 설계하는 것입니다. 채널을 닫는 주체, context 취소 주체, 결과를 소비하지 못할 때의 처리까지 초반에 같이 두는 편이 좋습니다.
선택 기준
| 상황 | 먼저 떠올릴 선택 |
|---|---|
| 작업 하나를 병렬로 돌린다 | goroutine |
| goroutine 사이 값을 주고받는다 | channel |
| 취소/timeout을 전파한다 | context.Context |
| 함수 책임을 시그니처로 드러낸다 | 방향 있는 channel |
| 작업 종료를 호출자가 책임진다 | cancel() 또는 채널 종료 주체 명시 |
주의할 점
Go에서 goroutine은 만들기 쉽지만, 자동으로 정리되지는 않습니다. 결과를 읽지 않는 채널, 닫히지 않는 작업 채널, 호출되지 않는 cancel()은 goroutine 누수로 바로 이어질 수 있습니다. go 키워드를 붙이는 순간, 종료 조건과 소유권도 같이 적는 습관이 필요합니다.
참고 링크
3 sources