메모리를 공유해 통신하지 말고, 통신해서 공유하라
— 파이프 하나가 어떻게 동기화까지 대신하는가
// 공유 메모리 — 잠금 순서를 사람이 지킨다 mu.Lock() result = 42 mu.Unlock() // channel — 건네주면 소유권도 넘어간다 ch := make(chan int) go func() { ch <- 42 }() v := <-ch
만드는 법과 값을 넣고 빼는 법. 문법은 십 분이면 끝나지만, 여기서 정확히 읽는 훈련을 해두지 않으면 뒤의 blocking·close가 전부 헷갈린다.
make(chan T) — T 타입만 흐른다. 다른 타입은 컴파일 에러nil이다. nil channel은 send·receive 모두 영원히 멈춘다ch := make(chan int) // unbuffered ch := make(chan string, 5) // buffered (5칸) ch := make(chan Result) // 구조체도 된다 ch := make(chan chan int) // channel의 channel도 var ch chan int // nil — 절대 진행되지 않는다
make(chan int)과 make(chan int, 1)은 타입이 같다. 그런데 동기화 성질은 완전히 다르다.
ch <- vv := <-ch<-ch만 쓰면 "올 때까지 기다린다"는 뜻v, ok := <-ch의 ok는 channel이 살아 있는지를 알려준다func TestChannelSendReceive(t *testing.T) { ch := make(chan int) go func() { ch <- 42 // send }() value := <-ch // send될 때까지 blocking assert.Equal(t, 42, value) }
value := <-ch 줄이 "42가 올 때까지 여기서 멈춘다"는 뜻이라는 걸 짚는 게 이 장의 전부다.func TestChannelStructType(t *testing.T) { type Result struct { Value int Err error } ch := make(chan Result) go func() { ch <- Result{Value: 100, Err: nil} }() result := <-ch assert.Equal(t, 100, result.Value) assert.NoError(t, result.Err) }
channel의 본질은 "값을 옮긴다"가 아니라 "기다린다"에 있다. 언제 멈추는지를 알면 unbuffered와 buffered의 차이는 저절로 따라온다.
send와 receive가 동시에 준비되어야 값이 건너간다.
먼저 도착한 쪽이 상대를 기다린다. 만나는 그 순간이 두 goroutine의 동기화 지점이 된다.
버퍼에 빈칸이 있으면 send는 즉시 끝난다.
가득 차면 그때부터 sender가 기다리고, 비면 receiver가 기다린다. 큐가 하나 끼어든 셈이다.
가운데가 만나는 지점이다. 어느 쪽이 먼저 도착하든 상대가 올 때까지 멈춰 서 있는다.
선은 값이 흐르는 방향이다. 버퍼는 FIFO — 넣은 순서 그대로 나온다.
func TestUnbufferedChannel(t *testing.T) { ch := make(chan int) // 버퍼 크기 0 go func() { ch <- 42 // receiver가 준비될 때까지 여기서 blocking }() time.Sleep(100 * time.Millisecond) // sender는 이미 대기 중 value := <-ch // 받는 순간 sender도 풀린다 assert.Equal(t, 42, value) }
go를 지우면?
main goroutine이 자기 자신을 기다리게 된다 — fatal error: all goroutines are asleep - deadlock!
func TestBufferedChannel(t *testing.T) { ch := make(chan int, 3) // receiver가 없어도 3개까지는 send가 즉시 끝난다 ch <- 1 ch <- 2 ch <- 3 // ch <- 4 → 여기서 blocking (버퍼 가득 참) assert.Equal(t, 1, <-ch) assert.Equal(t, 2, <-ch) assert.Equal(t, 3, <-ch) }
ch <- 1에서 바로 deadlock이다.
cap(ch) — 만들 때 정한 버퍼 용량. 끝까지 변하지 않는다len(ch) — 지금 버퍼에 쌓여 있는 값의 개수cap 0, len 0ch := make(chan string, 5) assert.Equal(t, 5, cap(ch)) // 용량 assert.Equal(t, 0, len(ch)) // 대기 중인 값 ch <- "a" ch <- "b" assert.Equal(t, 5, cap(ch)) assert.Equal(t, 2, len(ch))
if len(ch) < cap(ch) { ch <- v } — 확인과 send 사이에 다른 goroutine이 채워 넣을 수 있다. 그 자리는 select의 default가 맡는다(편 3).
| 상황 | 선택 | 이유 |
|---|---|---|
| goroutine 간 핸드셰이크 | Unbuffered | 만나는 순간이 곧 동기화 지점 |
| 완료 · 종료 신호 (done, quit) | Unbuffered | 신호는 쌓일 이유가 없다 |
| 생산 · 소비 속도 차 완충 | Buffered | 짧게 몰리는 구간을 흡수 |
| Producer / Consumer | Buffered | 서로를 매번 기다리지 않는다 |
| 대량 데이터 전달 | Buffered | 컨텍스트 전환 횟수가 줄어든다 |
타입에 방향을 새겨 넣으면, 잘못된 사용이 컴파일 단계에서 걸린다. 런타임 비용은 0이고 문법은 화살표 하나 옮기는 게 전부다.
chan<- int — send 전용. 받으려 하면 컴파일 에러<-chan int — receive 전용. 보내거나 닫으면 컴파일 에러chan int // 양방향 chan<- int // send-only — 넣기만 <-chan int // receive-only — 꺼내기만 func produce(ch chan<- int) { ch <- 1 } // OK func consume(ch <-chan int) { <-ch } // OK func bad(ch <-chan int) { close(ch) } // 컴파일 에러
ch <- v·<-ch 두 표현을 떠올리면 된다고 알려준다.// producer — send-only. 여기서는 받을 수 없다 func produce(ch chan<- int, values []int) { for _, v := range values { ch <- v } close(ch) // 보낸 쪽이 닫는다 } // consumer — receive-only. 여기서는 닫을 수 없다 func consume(ch <-chan int) []int { var out []int for v := range ch { out = append(out, v) } return out }
range로 끝까지 읽는다. 오늘 나머지 예제는 전부 이 모양의 변주다.
ch := make(chan int, 5)를 그냥 넘기면 된다는 점을 말해준다 — 변환이 자동이라 호출하는 쪽 코드는 하나도 안 바뀐다. 그 얘기가 다음 장이다.chan int → chan<- int
chan int → <-chan int
대입도, 함수 인자 전달도 그냥 된다. 캐스팅을 따로 쓸 필요가 없다.
chan<- int ⇸ chan int
<-chan int ⇸ chan int
한 번 좁힌 권한은 되돌릴 수 없다. 넓히려면 원래의 양방향 channel을 직접 들고 있어야 한다.
close는 채널을 없애는 게 아니라 "더 이상 보내지 않겠다"고 알리는 일이다. 규칙 셋을 어기면 컴파일이 아니라 런타임 panic으로 터진다.
range로 받는 쪽이 있을 때 필요하다ch := make(chan int, 2) ch <- 1 ch <- 2 close(ch) // 더 보내지 않겠다 fmt.Println(<-ch) // 1 — 남은 값은 그대로 fmt.Println(<-ch) // 2 fmt.Println(<-ch) // 0 — 다 비었다
func TestReceiveFromClosedChannel(t *testing.T) { ch := make(chan int, 1) ch <- 42 close(ch) // 버퍼에 값이 남아 있으면 정상 반환 val, ok := <-ch assert.Equal(t, 42, val) assert.True(t, ok) // 유효한 값 // 버퍼가 비고 닫혔으면 → zero value + false val, ok = <-ch assert.Equal(t, 0, val) // int의 zero value assert.False(t, ok) // 닫힘 표시 }
0인지 sender가 진짜 보낸 0인지는 오직 ok만 안다. 그리고 blocking도 하지 않는다 — 즉시 돌아온다.
ok 검사나 range가 필요하다. 다음 장으로 자연스럽게 연결된다.| 규칙 | 어기면 |
|---|---|
| sender가 close한다 | receiver가 닫으면 sender가 닫힌 channel에 send → panic |
| 두 번 close하지 않는다 | panic: close of closed channel |
| 닫힌 channel에 send하지 않는다 | panic: send on closed channel |
| 닫힌 channel에서 receive는 안전하다 | 허용 — zero value + false를 즉시 반환 |
func generator() <-chan int { ch := make(chan int) go func() { defer close(ch) // 보낸 쪽이 닫는다 for i := range 5 { ch <- i } }() return ch // receive-only — 밖에서는 닫을 수 없다 } for v := range generator() { fmt.Println(v) // 0 1 2 3 4 }
<-chan으로 좁혔으니 호출자는 close도 send도 못 한다. 22번 규칙 셋이 전부 컴파일 에러로 바뀐 셈이다.
defer close(ch)가 goroutine 안 첫 줄에 있다는 점 — panic이 나도 닫힌다. 파이프라인(3편)의 기본 벽돌이기도 하다.받는 쪽의 관용구인 range, 그리고 값을 담지 않는 채널 chan struct{}.
둘 다 앞에서 배운 close 위에 얹혀 있다.
for v := range ch — 값이 올 때까지 기다렸다가 하나씩 꺼낸다v, ok := <-ch를 직접 쓰는 것과 같지만 ok 검사를 대신해준다ch := make(chan int, 5) go func() { for i := 1; i <= 5; i++ { ch <- i } close(ch) // 없으면 range가 끝나지 않는다 }() for v := range ch { // 1 2 3 4 5 out = append(out, v) }
range로 읽는 코드를 쓰는 순간, 그 channel을 누가 언제 닫는지도 함께 정해야 한다. 둘은 한 세트다.
struct{}는 필드가 없어 0바이트다 — 값을 담지 않겠다는 의도가 타입에 드러난다chan bool을 쓰면 "true는 뭐고 false는 뭐지?"라는 질문이 따라온다done, quit, readydone := make(chan struct{}) go func() { // 작업 수행... close(done) // 완료 신호 }() <-done // 완료될 때까지 대기
unsafe.Sizeof(struct{}{})는 0이다 — 오가는 값에 드는 비용이 없다. (channel 자체의 비용은 별개다)
기다리는 receiver 한 명만 깨운다.
셋이 기다리면 세 번 보내야 한다. 즉 sender가 수신자 수를 알고 있어야 한다.
기다리는 전원이 동시에 깨어난다.
몇 명이든 상관없고, 닫힌 뒤에 도착한 receiver도 즉시 통과한다. 신호를 놓칠 수 없다.
context.Done()이 정확히 이 방식이다 — 편 5에서 다시 만난다.
| 표현 | 의미 | 비유 |
|---|---|---|
int | int 타입 | 설계도 |
42 | int 값 | 실물 |
struct{} | 빈 구조체 타입 | 설계도 — 필드가 없는 |
struct{}{} | 빈 구조체 값 | 실물 — 내용이 없는 |
struct{} + {} — 앞이 타입, 뒤가 빈 리터럴이다. Person{}이 Person + {}인 것과 똑같다.
그래서 make(chan struct{})에는 타입이, done <- struct{}{}에는 값이 온다.
지금까지 배운 것을 한 코드에 모은다. producer가 여럿이 되는 순간 "누가 닫는가"라는 질문이 실제 설계 문제로 바뀐다.
func TestProducerConsumer(t *testing.T) { ch := make(chan int, 10) var results []int go func() { // Producer — 제곱수를 만든다 for i := 1; i <= 10; i++ { ch <- i * i } close(ch) // 다 보냈다는 선언 }() for val := range ch { // Consumer — 닫힐 때까지 모은다 results = append(results, val) } assert.Len(t, results, 10) }
ch := make(chan int, 20) var wg sync.WaitGroup for p := range 3 { // Producer 3개 wg.Add(1) go func() { defer wg.Done() for i := range 5 { ch <- p*100 + i } }() } go func() { // 감시자 — 전원이 끝나야 닫는다 wg.Wait() close(ch) }()
wg.Wait()을 main에서 부르면, 버퍼가 차는 순간 producer가 멈추고 main은 Wait에서 멈춘다 — 서로를 기다리다 죽는다.
make의 숫자 하나가 성질을 바꾼다chan<- · <-chan으로 방향을 좁히면 컴파일러가 잘못된 사용을 막는다close는 "더 안 보낸다"는 선언이고, 반드시 보내는 쪽이 한다 — 두 번 닫으면 panicrange는 그 신호를 보고 끝난다chan struct{} — 전원에게 알려야 하면 보내지 말고 닫는다all goroutines are asleep - deadlock!로 죽인다. → 슬라이드 09 · 11select + default로 해야 한다. → 슬라이드 13panic: send on closed channel이 난다. 끝을 아는 쪽은 sender뿐이라 close도 sender의 책임이다. → 슬라이드 22 · 23v, ok := <-ch의 ok로만 구분된다. 닫힌 channel은 blocking 없이 zero value와 false를 즉시 돌려준다. → 슬라이드 21close(ch)다. range의 종료 조건은 close 하나뿐이고, 값을 몇 개 보냈는지는 아무 상관이 없다. → 슬라이드 25wg.Wait()을 기다리는 별도 goroutine이 전원 종료 후에 닫는다. → 슬라이드 31 · 32오늘은 channel 하나를 다뤘다. 그런데 실무에서는 값 채널과 에러 채널, 거기에 타임아웃과 취소까지 여러 개를 동시에 기다려야 한다. 다음 편의 주제가 그것이다.
default), 무한 대기를 끊는 타임아웃, nil channel로 케이스 끄기 —
오늘 스쳐 간 것들이 전부 select 한 문법으로 풀린다. fan-in / fan-out도 함께 다룬다.
참고 자료
github.com/kenshin579/tutorials-go /golang/concurrencyadvenoh.pe.kr