버퍼 채널과 소유권
이 챕터에서 다루는 것
7-2에서 버퍼 없는 채널이 랑데부라는 것을 봤다. 버퍼를 주면 그 성질이 사라진다. 무엇을 얻고 무엇을 잃는지가 앞의 절반이다.
뒤의 절반은 6-8이 남겨 둔 질문의 답이다. 파트 6은 "이 상태를 누가 소유하는가"로 패키지 경계를 그었다. 채널에서는 그 질문이 정확히 한 문장으로 좁혀진다.
누가 이 채널을 닫는가?
버퍼가 바꾸는 것
ch := make(chan int, 3)
용량 3짜리 채널은 수신자가 없어도 값 세 개를 받아 준다. 네 번째 송신부터 블록된다.
package main
import "fmt"
func main() {
ch := make(chan int, 3)
fmt.Printf("만든 직후: len=%d cap=%d\n", len(ch), cap(ch))
// 버퍼가 비어 있으면 수신자가 없어도 송신이 성공한다.
ch <- 1
ch <- 2
ch <- 3
fmt.Printf("3개 넣은 뒤: len=%d cap=%d\n", len(ch), cap(ch))
// 여기서 ch <- 4를 하면 블록된다. 이 고루틴뿐이므로 데드락이다.
fmt.Println("꺼냄:", <-ch)
fmt.Printf("하나 꺼낸 뒤: len=%d cap=%d\n", len(ch), cap(ch))
// 남은 것을 닫고 전부 비운다.
close(ch)
for v := range ch {
fmt.Println("마저 꺼냄:", v)
}
// 버퍼 없는 채널의 cap은 0이다. len도 항상 0이다 — 값이 머무르지 않는다.
un := make(chan int)
fmt.Printf("버퍼 없는 채널: len=%d cap=%d\n", len(un), cap(un))
}
cd examples/07-concurrency
go run ./03-buffered
만든 직후: len=0 cap=3
3개 넣은 뒤: len=3 cap=3
꺼냄: 1
하나 꺼낸 뒤: len=2 cap=3
마저 꺼냄: 2
마저 꺼냄: 3
버퍼 없는 채널: len=0 cap=0
len은 지금 버퍼에 들어 있는 개수, cap은 용량이다. 버퍼 없는 채널은 값이
머무르는 곳이 없으므로 둘 다 항상 0이다.
잃는 것: 동기화 보장
버퍼 없는 채널에서 ch <- v가 반환됐다는 것은 누군가 그 값을 받았다는 뜻이다.
버퍼가 있으면 그 보장이 사라진다. 반환은 "버퍼에 넣었다"는 뜻일 뿐, 아무도 안
받았을 수 있다.
이것이 실무에서 미묘한 버그가 되는 지점이다.
done := make(chan struct{}, 1)
go worker(done)
done <- struct{}{} // 워커가 이 신호를 봤다는 보장이 없다
os.Exit(0) // 워커는 아직 시작도 안 했을 수 있다
"상대가 받았는지"가 중요하면 버퍼를 주면 안 된다.
얻는 것: 속도 차이를 흡수한다
생산자가 순간적으로 빠르고 소비자가 느릴 때, 버퍼는 생산자를 세우지 않는다. 평균 처리량이 같다면 버퍼가 전체 처리량을 올린다. 평균 처리량이 다르면 버퍼는 아무것도 못 고친다 — 잠깐 늦게 터질 뿐이다.
용량은 어떻게 고르는가
기본값은 **0(버퍼 없음)**이다. 그리고 다음 중 하나에 해당할 때만 숫자를 넣는다.
| 상황 | 용량 |
|---|---|
| 동기화가 목적 | 0 |
| 결과를 정확히 N개 모을 것을 안다 | N — 송신자가 절대 안 막힌다 |
| 세마포어로 동시 실행을 제한한다 | 한도값 |
| 생산·소비 속도의 순간 편차를 흡수한다 | 측정해서 정한다 |
| "혹시 모르니까" | 금지 |
마지막 줄이 중요하다. make(chan T, 100)의 100에 근거가 없다면 그 숫자는 버그를
100건 늦게 발견하게 만드는 장치다. 버퍼가 꽉 차서 막히는 것은 정상 동작이고,
그 시점이 빨리 올수록 문제를 빨리 안다.
:::tip "결과를 N개 모을 것을 안다"가 가장 유용한 경우
results := make(chan Result, len(jobs))
이렇게 해 두면 결과를 아무도 안 읽어도 송신 고루틴이 막히지 않는다.
7-2의 "에러를 채널로 흘려보내고 잊는다" 누수를 구조적으로
없앤다. 호출자가 중간에 return해도 워커들은 값을 넣고 정상 종료한다.
:::
소유권 — 누가 닫는가
7-2의 동작표에서 이미 답이 나와 있다. 패닉은 전부 송신 쪽과
close 쪽에서 난다. 수신은 어떤 상태에서도 안전하다. 따라서:
채널을 만든 쪽이 채널에 쓰고, 채널을 닫는다. 그 셋은 항상 같은 코드 안에 있어야 한다.
이것을 코드로 강제하는 방법이 생성자 패턴이다.
// generate는 채널을 만들고, 채우고, 닫는 일을 전부 한 함수 안에서 한다.
// 반환 타입이 <-chan int이므로 호출자는 close할 수 없다.
// 채널의 소유자가 코드상 명백해진다.
func generate(nums ...int) <-chan int {
out := make(chan int)
go func() {
defer close(out) // 어떤 경로로 끝나든 닫힌다
for _, n := range nums {
out <- n
}
}()
return out
}
세 가지가 동시에 일어난다.
make, 송신,close가 한 함수 안에 있다. 읽는 사람이 스크롤할 필요가 없다.- 반환 타입이
<-chan int다. 호출자가close하면 컴파일 에러다. (invalid operation: cannot close receive-only channel out (variable of type <-chan int)) defer close(out)이다. 중간에return하거나 패닉해도 닫힌다.
같은 규칙이 중간 단계에도 그대로 적용된다.
// square는 입력 채널을 소비하고 새 출력 채널을 소유한다.
// 자기가 만든 것만 닫는다는 규칙이 그대로 지켜진다.
func square(in <-chan int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for v := range in {
out <- v * v
}
}()
return out
}
square는 in을 읽기만 하고 닫지 않는다. in은 남의 것이다. in이 닫히면
range가 끝나고, 그 결과로 out이 닫힌다. 닫힘이 파이프라인을 따라 전파된다.
소유자가 여럿일 때 — fan-in
여러 채널을 하나로 합칠 때가 이 규칙이 시험받는 지점이다. 출력 채널은 하나인데 쓰는 고루틴이 여럿이다. 아무나 닫으면 나머지가 패닉한다.
// merge는 여러 채널을 하나로 합친다.
// 입력 채널들은 남의 것이므로 닫지 않는다. 자기가 만든 out만 닫는다.
// "모두 끝났을 때 닫는다"를 WaitGroup으로 판단한다.
func merge(ins ...<-chan int) <-chan int {
out := make(chan int)
var wg sync.WaitGroup
for _, in := range ins {
wg.Go(func() {
for v := range in {
out <- v
}
})
}
go func() {
wg.Wait()
close(out)
}()
return out
}
wg.Wait()를 별도 고루틴에 넣은 것이 핵심이다. merge 안에서 그냥
wg.Wait()를 부르면 merge가 반환하지 않고, 호출자가 out을 읽을 수 없어
데드락이다. merge는 즉시 반환해야 하고, "언제 닫을지 감시하는 일"은 또 하나의
고루틴이 맡는다.
전체 프로그램은 이렇게 붙는다.
func main() {
a := square(generate(1, 2, 3))
b := square(generate(4, 5))
var got []int
for v := range merge(a, b) {
got = append(got, v)
}
// merge의 도착 순서는 비결정적이다. 정렬해서 출력을 고정한다.
slices.Sort(got)
fmt.Println("합쳐진 결과(정렬 후):", got)
}
go run ./03-ownership
합쳐진 결과(정렬 후): [1 4 9 16 25]
merge가 값을 내보내는 순서는 실행할 때마다 다르다. 그것이 fan-in의 정의다.
slices.Sort로 정렬해서 출력을 고정했다. 순서가 의미 있는 결과라면 애초에 fan-in을
쓰면 안 되거나, 값에 인덱스를 붙여 나중에 복원해야 한다
(7-8에서 다룬다).
소유권을 정할 수 없다면
닫을 주체를 못 정하겠다면, 보통은 애초에 닫을 필요가 없는 경우다.
- 수신자가 "몇 개 받을지" 아는 경우:
for i := 0; i < n; i++ { <-ch }로 충분하다. - 종료 신호가 다른 경로로 오는 경우:
done채널이나context(7-7)가 담당하고, 데이터 채널은 그냥 GC에 맡긴다.
닫히지 않은 채널은 누수가 아니다. 그 채널을 붙들고 블록된 고루틴이 누수다. 채널 자체는 참조가 사라지면 GC가 회수한다.
버퍼 채널을 세마포어로
버퍼 채널의 가장 실용적인 용도다. 용량이 곧 동시 실행 한도가 된다.
sem := make(chan struct{}, 3)
sem <- struct{}{} // 자리 확보 (꽉 차 있으면 대기)
defer func() { <-sem }() // 반납
동시 실행 개수가 한도를 정말 넘지 않는지 세어 보자. 세는 방법 자체가 이 파트의 주제와 맞닿아 있다 — 카운터를 뮤텍스로 감싸는 대신, 카운터를 고루틴 하나에게 독점시키고 델타를 채널로 보낸다.
// tracker는 델타(+1/-1)를 받아 동시 실행 개수의 최대값을 기록한다.
// 상태를 고루틴 하나가 독점하므로 뮤텍스가 필요 없다 —
// "통신해서 메모리를 공유하라"의 가장 작은 예다.
type tracker struct {
deltas chan int
summary chan [2]int // [최대 동시 실행 수, 한도 초과 횟수]
}
func newTracker(limit int) *tracker {
t := &tracker{
deltas: make(chan int),
summary: make(chan [2]int),
}
go func() {
cur, max, over := 0, 0, 0
for d := range t.deltas {
cur += d
if cur > max {
max = cur
}
if cur > limit {
over++
}
}
t.summary <- [2]int{max, over}
}()
return t
}
func (t *tracker) enter() { t.deltas <- 1 }
func (t *tracker) exit() { t.deltas <- -1 }
func (t *tracker) close() (max, over int) {
close(t.deltas)
s := <-t.summary
return s[0], s[1]
}
deltas를 닫으면 range가 끝나고, 그때 집계를 summary로 내보낸다. 닫는 것이
"집계를 확정하라"는 신호로 쓰였다.
func main() {
// 버퍼 채널을 세마포어로 쓴다. 용량이 곧 동시 실행 한도다.
sem := make(chan struct{}, limit)
tr := newTracker(limit)
var wg sync.WaitGroup
for range tasks {
wg.Go(func() {
sem <- struct{}{} // 자리 하나 확보. 꽉 차 있으면 여기서 대기한다.
defer func() { <-sem }() // 반드시 반납한다
tr.enter()
time.Sleep(20 * time.Millisecond) // 일하는 척
tr.exit()
})
}
wg.Wait()
max, over := tr.close()
fmt.Println("완료한 작업:", tasks)
fmt.Println("동시에 실행된 최대 개수:", max)
fmt.Printf("한도(%d)를 넘은 횟수: %d\n", limit, over)
}
go run ./03-semaphore
완료한 작업: 9
동시에 실행된 최대 개수: 3
한도(3)를 넘은 횟수: 0
"한도를 넘은 횟수: 0"은 보장된다. 세마포어가 그것을 강제한다. "최대 개수: 3"은 보장되지 않는다. 세마포어가 약속하는 것은 "3을 넘지 않는다"이지 "반드시 3에 도달한다"가 아니다. 20ms 작업 9개라면 실질적으로 항상 3이 나오지만 (다섯 번 돌려 전부 3이었다), 극단적으로 느린 기계에서는 2가 나올 수도 있다. 동시성 코드에서 "보장되는 것"과 "관찰되는 것"을 구분하는 습관이 여기서 시작된다.
:::note 세마포어 대신 워커 풀 세마포어는 작업마다 고루틴을 만들고 실행만 제한한다. 작업이 100만 개면 고루틴도 100만 개다. 워커 풀은 고루틴 개수 자체를 제한한다. 7-8에서 둘을 비교한다. 작업 수가 작고 알려져 있으면 세마포어가 훨씬 짧다. :::
흔히 하는 실수
1. 버퍼로 데드락을 "고친다"
ch := make(chan int) // 데드락 남
ch := make(chan int, 1) // 안 남 — 고쳐진 게 아니다
용량 1이 우연히 맞았을 뿐, 값이 두 개가 되면 다시 막힌다. 버퍼를 늘려서 데드락이 사라졌다면 구조를 고친 것이 아니라 재현 조건을 바꾼 것이다.
2. len(ch)를 보고 판단한다
if len(ch) > 0 {
v := <-ch // 여기 도달했을 때 이미 비었을 수 있다
}
len을 읽은 순간과 쓰는 순간 사이에 다른 고루틴이 끼어든다. 논블로킹 수신이
필요하면 7-4의 select + default를 쓴다. len은 모니터링과
디버깅 출력용이지 제어 흐름용이 아니다.
3. 수신 쪽에서 닫는다
7-2에서도 봤다. 소유권 규칙이 정확히 이것을 막는다.
<-chan T로 받으면 애초에 컴파일이 안 된다.
4. fan-in에서 각 고루틴이 out을 닫는다
for _, in := range ins {
wg.Go(func() {
for v := range in {
out <- v
}
close(out) // 두 번째 고루틴이 도달하면 패닉
})
}
쓰는 사람이 여럿이면 닫는 사람은 "모두가 끝났음을 아는 하나"여야 한다.
5. defer로 세마포어를 반납하지 않는다
sem <- struct{}{}
doWork() // 패닉하면 자리가 영영 반납되지 않는다
<-sem
한 번 새면 동시 실행 한도가 영구히 줄어든다. defer func() { <-sem }()을
자리 확보 바로 다음 줄에 붙인다.
6. 버퍼 크기를 성능 튜닝 손잡이로 쓴다
버퍼는 지연을 숨기지 처리량을 만들어 내지 못한다. 생산자가 소비자보다 지속적으로 빠르면 어떤 용량도 소용없다. 소비자를 늘리거나 생산자를 늦춰야 한다.
정리
- 버퍼는 "받았다"는 보장을 없애는 대가로 속도 편차를 흡수한다. 동기화가 목적이면 용량 0이다.
- 기본값은 버퍼 없음. 근거가 있을 때만 숫자를 넣는다. "혹시 몰라서"는 근거가
아니다.
len(jobs)처럼 정확히 아는 개수는 좋은 근거다. - 소유권 규칙: 만든 쪽이 쓰고, 만든 쪽이 닫는다. 셋을 한 함수에 두고
<-chan T로 반환하면 컴파일러가 규칙을 강제해 준다. defer close(out)으로 닫는다. 어떤 경로로 끝나도 닫힌다.- fan-in에서는
WaitGroup을 감시하는 별도 고루틴이 닫는다.wg.Wait()를 본 함수 안에서 부르면 데드락이다. - 닫히지 않은 채널은 누수가 아니다. 그 채널에 매달린 고루틴이 누수다.
- 버퍼 채널은 세마포어다. 용량이 한도이고, 반납은 반드시
defer로 한다.
연습문제
-
03-buffered에서ch <- 3다음에ch <- 4를 추가하고 실행해 보자. 어떤 메시지가 나오는가? 그다음cap을 4로 늘리면 통과한다 — 이것이 "고친 것"이 아닌 이유를 7-2의 데드락 감지 한계와 엮어 설명해 보자. -
03-ownership의merge에서wg.Wait()를 감싸고 있는go func()를 없애고merge본문에서 직접 부르도록 바꿔 보자. 어떤 상태로 멈추는가? 스택 덤프에서main과 워커 고루틴이 각각 어디에 있는지 확인해 보자. -
03-semaphore의limit를 1로 바꾸면 최대 동시 실행 수는 얼마가 되어야 하는가? 확인해 보자. 그다음sem반납의defer를 지우고<-sem을tr.exit()뒤에 그냥 두면 어떤 차이가 생기는가 — 이 프로그램에서는 잘 도는가? 잘 돈다면, 그럼에도defer를 써야 하는 이유는 무엇인가? -
generate가 반환하는 채널에 버퍼를 주면(make(chan int, len(nums))) 무엇이 달라지는가? 출력은 같은가? 고루틴이 끝나는 시점은 어떻게 달라지는가?