채널 기초
이 챕터에서 다루는 것
7-1에서 go 문에는 반환값이 없다는 것을 봤다. 고루틴의 결과를
받아 오고, 고루틴끼리 순서를 맞추는 장치가 **채널(channel)**이다.
Go의 슬로건은 이것이다.
메모리를 공유해서 통신하지 말고, 통신해서 메모리를 공유하라.
채널은 그 문장의 구현체다.
문제 — 공유 변수로 결과를 받으면
var result int
go func() { result = compute() }()
// 여기서 result를 읽어도 되는가?
두 가지가 동시에 잘못됐다.
- 언제 읽어야 하는지 알 수 없다. 계산이 끝났다는 신호가 없다.
- 읽어도 값이 보인다는 보장이 없다. 동기화 없이 한쪽이 쓰고 한쪽이 읽으면 데이터 경합이고, 컴파일러와 CPU가 재배치할 수 있다. 자세한 것은 7-6에서 본다.
채널은 두 문제를 한 번에 푼다. 값 전달과 동기화가 같은 연산이다.
make(chan T)
ch := make(chan int) // 버퍼 없음
ch := make(chan int, 10) // 버퍼 10칸 — 7-3
채널은 참조 타입이다. make로 만들어야 쓸 수 있고, 선언만 한 var ch chan int는
nil이다.
연산은 세 가지뿐이다.
ch <- v // 송신
v := <-ch // 수신
close(ch) // 닫기
버퍼 없는 채널은 랑데부다
버퍼 없는 채널의 송신은 수신자가 나타날 때까지 블록된다. 반대도 마찬가지다. 그래서 송수신은 "값을 큐에 넣는 일"이 아니라 두 고루틴이 한 지점에서 만나는 일이다. 이 성질 때문에 순서를 정확히 예측할 수 있다.
package main
import "fmt"
func main() {
ping := make(chan struct{})
pong := make(chan struct{})
go func() {
for i := range 3 {
<-ping
fmt.Println(" 받음 → 응답", i)
pong <- struct{}{}
}
}()
for i := range 3 {
fmt.Println("보냄", i)
ping <- struct{}{} // 상대가 받을 때까지 여기서 멈춘다
<-pong
}
// 버퍼 없는 채널의 송수신은 두 고루틴이 만나는 지점이다.
// 그래서 이 프로그램의 출력 순서는 완전히 결정적이다.
fmt.Println("끝")
}
cd examples/07-concurrency
go run ./02-rendezvous
보냄 0
받음 → 응답 0
보냄 1
받음 → 응답 1
보냄 2
받음 → 응답 2
끝
몇 번을 돌려도 같다. 동시성 프로그램이 비결정적인 것은 필연이 아니라 설계의 결과다. 채널로 순서를 강제하면 순서가 지켜진다.
chan struct{}는 "값이 아니라 신호"라는 뜻의 관용구다. struct{}는 크기가 0이라
메모리를 쓰지 않고, 타입 자체가 "이 채널로 오는 값에는 의미가 없다"고 말한다.
방향 타입 — 의도를 타입에 새긴다
chan int // 양방향
chan<- int // 송신 전용
<-chan int // 수신 전용
화살표가 chan 쪽을 향하면 넣는 것, 밖을 향하면 꺼내는 것이다.
package main
import "fmt"
// produce는 송신 전용 채널을 받는다.
// 이 함수 안에서 out으로부터 수신하려 하면 컴파일 에러다.
func produce(out chan<- int, n int) {
for i := 1; i <= n; i++ {
out <- i * i
}
close(out) // 보내는 쪽이 닫는다
}
// consume은 수신 전용 채널을 받는다. close할 수도 없다.
func consume(in <-chan int) int {
sum := 0
for v := range in { // 채널이 닫힐 때까지 돈다
sum += v
}
return sum
}
func main() {
ch := make(chan int)
// make(chan int)의 타입은 chan int다.
// 양방향 채널은 단방향 파라미터에 그대로 넘길 수 있다. 반대는 불가능하다.
go produce(ch, 5)
fmt.Println("제곱의 합:", consume(ch))
// comma-ok 수신은 "값이 진짜인가, 채널이 닫혀서 나온 제로값인가"를 구분한다.
done := make(chan string)
go func() {
done <- "작업 완료"
close(done)
}()
for range 3 {
v, ok := <-done
fmt.Printf("v=%q ok=%v\n", v, ok)
}
}
go run ./02-channel-basics
제곱의 합: 55
v="작업 완료" ok=true
v="" ok=false
v="" ok=false
방향 타입은 문서가 아니라 컴파일러 검사다. consume 안에서 in <- 1을 쓰면
invalid operation: cannot send to receive-only channel in (variable of type <-chan int)으로 막힌다. 함수 시그니처만
보고 "이 함수는 소비자다"를 알 수 있고, 실수로 소비자가 채널을 닫는 사고를 원천
차단한다.
:::tip 파라미터는 되도록 단방향으로 받는다 6-5에서 공개 API를 최소로 유지하라고 했다. 채널 방향도 같은 이야기다. 필요한 방향만 받으면 그 함수가 할 수 있는 일이 줄어들고, 줄어든 만큼 읽기 쉬워진다. :::
close와 range
close(ch)는 **"이 채널로 더 보낼 값이 없다"**는 선언이다. 채널을 파괴하는 것도,
메모리를 반환하는 것도 아니다.
for v := range ch는 채널이 닫힐 때까지 값을 꺼낸다. 닫히면 루프가 끝난다.
닫지 않으면 루프도 끝나지 않는다 — 그리고 그게 다음 절의 데드락이다.
nil 채널과 닫힌 채널의 동작표
이 표를 외우면 채널 관련 패닉과 데드락의 90%가 설명된다.
송신 ch <- v | 수신 <-ch | close(ch) | |
|---|---|---|---|
| nil 채널 | 영원히 블록 | 영원히 블록 | 패닉 |
| 열린 채널 | 수신자 있을 때까지 블록 | 값 올 때까지 블록 | 정상 |
| 닫힌 채널 | 패닉 | 즉시 제로값, ok=false | 패닉 |
package main
import "fmt"
// capturePanic은 f를 실행하고, 패닉이 나면 그 값을 문자열로 돌려준다.
// 패닉 메시지를 프로그램 안에서 보여 주려는 용도이지, 실무에서 권할 패턴은 아니다.
func capturePanic(f func()) (msg string) {
defer func() {
if r := recover(); r != nil {
msg = fmt.Sprintf("panic: %v", r)
}
}()
f()
return "패닉 없음"
}
func main() {
// 1. 닫힌 채널에서의 수신은 블록하지 않고 즉시 제로값을 준다.
closed := make(chan int)
close(closed)
v, ok := <-closed
fmt.Printf("1) 닫힌 채널 수신: v=%d ok=%v\n", v, ok)
// 2. 버퍼에 남아 있던 값은 닫은 뒤에도 전부 꺼낼 수 있다.
// close는 "더 보낼 것이 없다"는 뜻이지 "버리라"는 뜻이 아니다.
buf := make(chan int, 3)
buf <- 10
buf <- 20
close(buf)
for v := range buf {
fmt.Println("2) 닫은 뒤에도 꺼낸 값:", v)
}
// 3. 닫힌 채널에 송신하면 패닉이다.
fmt.Println("3)", capturePanic(func() { closed <- 1 }))
// 4. 두 번 닫아도 패닉이다.
fmt.Println("4)", capturePanic(func() { close(closed) }))
// 5. nil 채널을 닫으면 패닉이다.
var nilCh chan int
fmt.Println("5)", capturePanic(func() { close(nilCh) }))
// 6. nil 채널의 len/cap은 0이다. 송수신은 영원히 블록된다.
// (이 성질을 select에서 케이스를 끄는 데 쓴다 — 7-4)
fmt.Printf("6) nil 채널: len=%d cap=%d\n", len(nilCh), cap(nilCh))
}
go run ./02-nil-and-closed
1) 닫힌 채널 수신: v=0 ok=false
2) 닫은 뒤에도 꺼낸 값: 10
2) 닫은 뒤에도 꺼낸 값: 20
3) panic: send on closed channel
4) panic: close of closed channel
5) panic: close of nil channel
6) nil 채널: len=0 cap=0
두 가지를 읽어 내자.
닫힌 채널의 수신이 블록하지 않는다는 것이 채널을 "브로드캐스트 신호"로 쓸 수
있게 만든다. done 채널을 닫으면 그것을 기다리던 고루틴 100개가 동시에 깨어난다.
값을 100번 보낼 필요가 없다. close 하나로 끝난다.
패닉은 전부 송신 쪽과 close 쪽에서만 난다. 수신은 어떤 상태에서도 패닉하지
않는다. 여기서 7-3의 소유권 규칙이 나온다.
데드락 메시지 읽기
Go 런타임은 모든 고루틴이 잠들면 그것을 감지하고 죽는다.
func main() {
ch := make(chan int)
ch <- 1 // 받을 사람이 없다
fmt.Println(<-ch)
}
fatal error: all goroutines are asleep - deadlock!
goroutine 1 [chan send]:
main.main()
/tmp/dl/main.go:7 +0x38
exit status 2
읽는 순서는 대괄호 안의 상태 → 스택 프레임이다. [chan send]는 "채널 송신에서
멈췄다"는 뜻이고, main.go:7이 그 줄이다.
더 자주 만나는 것은 close를 빼먹은 경우다.
func main() {
ch := make(chan int)
var wg sync.WaitGroup
wg.Go(func() {
for v := range ch {
fmt.Println(v)
}
})
ch <- 1
ch <- 2
wg.Wait() // ch를 닫지 않았다
}
1
2
fatal error: all goroutines are asleep - deadlock!
goroutine 1 [sync.WaitGroup.Wait]:
sync.runtime_SemacquireWaitGroup(0x21dc8a584070?, 0x40?)
/usr/local/go/src/runtime/sema.go:114 +0x38
sync.(*WaitGroup).Wait(0x21dc8a586030)
/usr/local/go/src/sync/waitgroup.go:206 +0xa8
main.main()
/tmp/dl/main.go:18 +0xac
goroutine 19 [chan receive]:
main.main.func1()
/tmp/dl/main.go:12 +0x6c
sync.(*WaitGroup).Go.func1()
/usr/local/go/src/sync/waitgroup.go:258 +0x48
created by sync.(*WaitGroup).Go in goroutine 1
/usr/local/go/src/sync/waitgroup.go:238 +0x70
값 두 개는 정상적으로 찍혔다는 점을 보라. 프로그램이 한참 잘 돌다가 마지막에
멈춘다. 덤프에는 고루틴이 둘 나온다. goroutine 1은 Wait에서, goroutine 19는
[chan receive]에서 잠들었다. 서로를 기다리고 있다. created by ... in goroutine 1
줄이 그 고루틴을 누가 만들었는지 알려 준다 — 누수 추적에서 가장 유용한 줄이다.
:::warning 데드락 감지는 믿을 것이 못 된다 런타임은 모든 고루틴이 잠들었을 때만 이 에러를 낸다. 타이머 하나, HTTP 서버 하나가 살아 있으면 나머지 999개가 영구히 막혀 있어도 아무 일도 일어나지 않는다. 실전에서 데드락은 이 친절한 메시지가 아니라 "응답이 안 온다"로 나타난다. 그래서 7-1의 고루틴 수 모니터링이 필요하다. :::
채널로 하는 세 가지 일
채널을 쓰는 목적을 셋으로 나눠 두면 설계가 쉬워진다.
1. 값 전달. 가장 흔하다. results <- computed.
2. 신호. chan struct{}로 "일어났다"를 알린다. close로 브로드캐스트한다.
done := make(chan struct{})
// ...
close(done) // 기다리던 모두가 동시에 깨어난다
3. 자원 제한. 버퍼 채널을 세마포어로 쓴다. 7-3에서 본다.
흔히 하는 실수
1. 받는 쪽 없이 보낸다
main에서 버퍼 없는 채널에 그냥 송신하는 것. 위의 첫 데드락이다. 버퍼 없는
채널의 송신은 항상 다른 고루틴을 필요로 한다.
2. range를 도는데 아무도 닫지 않는다
두 번째 데드락. for range ch를 쓰기로 했다면 누가 언제 닫는지가 코드에 있어야
한다. 없으면 그 루프는 영원하다.
3. 받는 쪽에서 닫는다
for v := range ch {
if v > 100 {
close(ch) // 위험 — 아직 보내는 중일 수 있다
break
}
}
송신자가 살아 있으면 다음 송신이 send on closed channel로 패닉한다. 닫는 것은
보내는 쪽의 권한이다. 받는 쪽이 "그만"이라고 말하는 법은 7-4의
done 채널이다.
4. 제로값을 종료 신호로 쓴다
for {
v := <-ch
if v == 0 { // 닫혔다는 뜻? 아니면 진짜 0?
break
}
}
구분할 수 없다. comma-ok 아니면 range를 쓴다.
5. 에러를 채널로 흘려보내고 잊는다
errCh := make(chan error) // 버퍼 없음
go func() { errCh <- doWork() }()
// 호출자가 errCh를 안 읽고 return하면 고루틴 누수
전형적인 누수다. doWork()가 nil을 반환해도 송신 자체가 블록된다. 버퍼를 주거나
반드시 읽거나, 둘 중 하나여야 한다.
정리
- 채널은 값 전달과 동기화를 한 연산으로 묶는다. "통신해서 메모리를 공유하라"의 실체다.
- 버퍼 없는 채널의 송수신은 랑데부다. 두 고루틴이 만나는 지점이므로 순서를 결정적으로 만들 수 있다.
- 방향 타입
chan<-/<-chan은 컴파일러가 검사하는 의도 선언이다. 파라미터는 되도록 단방향으로 받는다. - **
close는 "더 보낼 것이 없다"**다.range를 끝내고, 대기 중인 모든 수신자를 동시에 깨운다. - 패닉은 송신과
close에서만 난다. 닫힌 채널 송신, 이중 close, nil close. 수신은 어떤 상태에서도 안전하다. - nil 채널의 송수신은 영원히 블록된다. 버그처럼 보이지만
select에서 쓸모가 있다. - 데드락 감지는 모든 고루틴이 잠들었을 때만 작동한다. 실전 데드락은 조용하다.
연습문제
-
02-channel-basics의produce에서close(out)을 지우고 실행해 보자. 어떤 메시지가 나오는가? 스택 덤프에서[chan receive]상태인 고루틴이 어느 줄에 있는지 찾아보자. 그다음consume을for range대신for i := 0; i < 5; i++로 바꾸면 어떻게 되는가 — 데드락이 사라지는가, 아니면 다른 문제가 생기는가? -
chan struct{}하나를 만들고 고루틴 다섯 개가 그것을 수신 대기하게 한 뒤, (a) 값을 하나 보내는 경우와 (b)close하는 경우를 비교해 보자. 몇 개가 깨어나는가? "브로드캐스트에는close"라는 규칙이 왜 나오는지 설명해 보자. (깨어난 개수를 세는 데는 7-1의 인덱스 슬라이스 기법을 쓰면 출력이 결정적이 된다.) -
02-nil-and-closed에 케이스를 하나 더 추가하자. 버퍼 없는 채널에 값을 보내는 고루틴을 띄워 놓고,main에서 그 채널을close한 뒤 조금 있다가 수신해 보자. 무슨 일이 일어나는가? 이 실험이 "닫는 것은 보내는 쪽의 권한"이라는 규칙을 어떻게 뒷받침하는가?