- Published on
Go 채널 select 공정성·기아 현상 재현·해결
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버를 Go로 만들다 보면 select 한 번이면 여러 채널을 “공정하게” 처리할 수 있을 것 같지만, 운영에서 종종 이런 증상을 만납니다.
- 특정 채널의 이벤트가 계속 밀려 처리 지연이 커진다
- 타임아웃/컨텍스트 취소가 늦게 반영된다
- “분명
select인데 왜 한쪽만 계속 타지?” 같은 로그가 나온다
이 글은 Go 채널 select의 공정성(fairness) 기대치가 어디까지인지, 어떤 코드에서 기아(starvation)처럼 보이는 현상이 재현되는지, 그리고 실전에서 어떻게 고치는지까지 한 번에 정리합니다.
관련해서 타임아웃/데드라인을 설계할 때의 실수 패턴은 Go gRPC DEADLINE_EXCEEDED 원인별 해결 7가지에서도 자주 겹치니 함께 보면 좋습니다.
select 공정성: 무엇이 보장되고 무엇이 아닌가
Go 스펙의 요지는 다음과 같습니다.
select에서 준비(ready)된 case가 여러 개면, 그중 하나를 **의사난수(pseudo-random)**로 선택한다- 특정 case가 “항상 먼저 평가된다” 같은 순서 보장은 없다
- 하지만 이 “랜덤 선택”은 기아가 절대 발생하지 않음을 수학적으로 보장하는 수준의 공정성은 아니다
즉, “동시에 준비된 case들 사이에서 편향을 줄이려는” 성격이지, 운영체제 스케줄러 같은 강한 공정성을 제공하는 계약이 아닙니다.
또한 많은 개발자가 놓치는 포인트가 하나 더 있습니다.
select는 현재 시점에 ready인 case들만 후보로 둔다- 어떤 채널이 “거의 항상 ready”면, 다른 채널은 “ready가 되는 순간이 드물어져” 결과적으로 계속 밀릴 수 있다
이게 흔히 말하는 select 기아 현상(혹은 기아처럼 보이는 현상)의 출발점입니다.
기아 현상 재현 1: default가 있는 폴링 루프
default를 넣으면 블로킹을 피할 수 있어 보이지만, 실제로는 CPU를 태우면서 다른 이벤트를 사실상 무시하는 루프가 되기 쉽습니다.
아래 코드는 hi 채널이 자주 ready인 상황에서 lo가 거의 처리되지 않는 패턴을 재현합니다.
package main
import (
"fmt"
"runtime"
"sync/atomic"
"time"
)
func main() {
runtime.GOMAXPROCS(1)
hi := make(chan struct{}, 1024)
lo := make(chan struct{}, 1)
var hiCnt uint64
var loCnt uint64
// hi: 매우 자주 발생
go func() {
for {
select {
case hi <- struct{}{}:
default:
}
}
}()
// lo: 가끔 발생
go func() {
for {
time.Sleep(1 * time.Millisecond)
lo <- struct{}{}
}
}()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
select {
case <-hi:
atomic.AddUint64(&hiCnt, 1)
case <-lo:
atomic.AddUint64(&loCnt, 1)
default:
// 폴링: 이 default가 문제를 증폭시킨다
}
}
fmt.Printf("hi=%d lo=%d\n", hiCnt, loCnt)
}
왜 이런 일이 생길까요?
default가 있으면select는 ready case가 없을 때도 즉시 반환합니다.- 결과적으로 루프가 초고속으로 돌면서
hi가 ready인 순간만 계속 집어먹고, lo가 ready가 되는 짧은 순간은 스케줄링/타이밍에 의해 쉽게 놓치게 됩니다.
여기서 핵심은 “select가 불공정하다”가 아니라, 폴링 루프가 공정성 논의를 무의미하게 만든다는 점입니다.
해결: default를 제거하고, 필요하면 time.Ticker로 양보
default가 꼭 필요하다면 “바쁜 대기”가 되지 않게 양보 지점을 넣어야 합니다.
select {
case <-hi:
// ...
case <-lo:
// ...
case <-time.After(200 * time.Microsecond):
// 주기적으로만 깨어나서 폴링
}
또는 더 명시적으로 time.Ticker를 쓰면 time.After의 타이머 객체 생성 비용도 피할 수 있습니다.
ticker := time.NewTicker(200 * time.Microsecond)
defer ticker.Stop()
for {
select {
case <-hi:
case <-lo:
case <-ticker.C:
// 폴링 or housekeeping
}
}
기아 현상 재현 2: “항상 준비된” 채널과 “가끔 준비된” 채널
이번에는 default 없이도 “한쪽만 계속 처리되는 것처럼 보이는” 상황을 만들어보겠습니다.
fast는 버퍼가 크고 생산자가 계속 밀어 넣어 거의 항상 readyslow는 이벤트가 가끔 오며, 오더라도 소비가 늦으면 밀림
package main
import (
"fmt"
"runtime"
"sync/atomic"
"time"
)
func main() {
runtime.GOMAXPROCS(1)
fast := make(chan int, 1<<16)
slow := make(chan int, 8)
var fastCnt uint64
var slowCnt uint64
go func() {
i := 0
for {
fast <- i
i++
}
}()
go func() {
i := 0
for {
time.Sleep(200 * time.Microsecond)
slow <- i
i++
}
}()
end := time.Now().Add(2 * time.Second)
for time.Now().Before(end) {
select {
case <-fast:
atomic.AddUint64(&fastCnt, 1)
case <-slow:
// 일부러 처리 비용을 크게 만들어 밀리게 함
time.Sleep(300 * time.Microsecond)
atomic.AddUint64(&slowCnt, 1)
}
}
fmt.Printf("fast=%d slow=%d\n", fastCnt, slowCnt)
}
관찰 포인트:
slow를 처리하는 동안fast는 계속 쌓입니다(버퍼가 크면 더 심함).- 루프가 다시
select로 돌아오면fast는 거의 항상 ready입니다. slow는 처리 비용 때문에 “ready였던 시점”이 지나가거나, 버퍼가 꽉 차서 생산이 막히는 등 시스템 전체가 흔들립니다.
이건 select가 특정 case를 고의로 굶기는 게 아니라, 서비스 시간 차이 + 입력률 차이가 만들어내는 전형적인 큐잉 문제입니다.
해결 전략 1: 우선순위 select (2단계 선택)
“취소/타임아웃/제어 신호는 반드시 먼저 처리” 같은 요구가 있으면, 우선순위를 코드로 표현해야 합니다.
for {
// 1단계: 우선 처리(논블로킹)
select {
case <-ctx.Done():
return
case msg := <-controlCh:
handleControl(msg)
continue
default:
}
// 2단계: 일반 처리(블로킹)
select {
case msg := <-dataCh:
handleData(msg)
case msg := <-controlCh:
handleControl(msg)
case <-ctx.Done():
return
}
}
장점:
- 제어 신호가 들어오면 즉시 선점
- 데이터가 폭주해도 취소/리밋/리로드 같은 운영 신호가 굶지 않음
단점:
- 우선순위를 “강제”하는 만큼, 데이터 처리량이 약간 희생될 수 있음
해결 전략 2: 드레인(drain)으로 한 번에 묶어 처리
한 채널이 계속 ready인 상황에서는, select를 매번 도는 오버헤드도 크고 다른 채널이 끼어들 틈도 줄어듭니다. 이때는 한 번 받으면 가능한 만큼 짧게 드레인하고, 그 다음에 다른 채널을 확인하는 패턴이 유용합니다.
for {
select {
case <-ctx.Done():
return
case m := <-dataCh:
handle(m)
// 최대 N개까지만 추가로 처리하고 빠져나와 공정성 확보
for i := 0; i < 64; i++ {
select {
case m2 := <-dataCh:
handle(m2)
default:
i = 64
}
}
case c := <-controlCh:
handleControl(c)
}
}
포인트:
- 무제한 드레인은 오히려 다른 채널을 영원히 못 보게 만들 수 있으니, **상한
N**을 둡니다. N은 처리 비용과 지연 목표에 맞춰 튜닝합니다.
해결 전략 3: 채널 분리 대신 “단일 큐”로 합치기
여러 입력을 select로 섞어 처리하는 대신, 입력 고루틴에서 단일 work queue로 합치면 공정성 문제가 단순해집니다.
type Event struct {
Kind string
Payload any
}
work := make(chan Event, 1024)
go func() {
for m := range dataCh {
work <- Event{Kind: "data", Payload: m}
}
}()
go func() {
for c := range controlCh {
work <- Event{Kind: "control", Payload: c}
}
}()
for {
select {
case <-ctx.Done():
return
case ev := <-work:
switch ev.Kind {
case "control":
handleControl(ev.Payload)
case "data":
handleData(ev.Payload)
}
}
}
이 방식은 “어느 채널이 먼저 선택되느냐”가 아니라 “work 큐에서 어떤 이벤트가 먼저 들어왔느냐”로 문제가 바뀝니다. 공정성 기준을 큐잉 정책으로 옮길 수 있다는 게 장점입니다.
해결 전략 4: 버퍼 크기는 공정성이 아니라 “폭주 형태”를 바꾼다
버퍼는 공정성을 보장하지 않습니다. 다만 폭주의 양상을 바꿉니다.
- 큰 버퍼: 생산자 블로킹이 줄어들어 처리량은 늘 수 있지만, 지연이 숨겨지고(큐가 쌓임) 한쪽이 계속 밀리는 현상이 길게 지속
- 작은 버퍼: 조기에 backpressure가 걸려 시스템이 빨리 “느려짐을 드러내고” 상류에서 조절 가능
운영에서 흔한 장애 패턴은 “버퍼가 커서 한동안 멀쩡해 보이다가, 어느 순간 지연이 임계치를 넘으며 타임아웃이 연쇄”로 터지는 것입니다. 타임아웃이 연쇄로 깨질 때의 대응 사고방식은 OpenAI Responses API 408 타임아웃 재현과 해결 실전 가이드처럼 “지연의 원인을 숨기지 말고 관측/차단”하는 쪽이 유리합니다.
해결 전략 5: 처리 비용이 큰 case는 워커 풀로 격리
select 루프에서 어떤 case는 가벼운데, 다른 case는 무겁다면(예: 압축, DB 호출, 외부 API) 무거운 작업이 루프를 점유해 다른 이벤트를 굶길 수 있습니다.
이때는 select 루프는 “디스패처”로만 두고, 무거운 작업은 워커에게 넘깁니다.
jobs := make(chan Job, 1024)
// worker pool
for i := 0; i < 8; i++ {
go func() {
for job := range jobs {
process(job)
}
}()
}
for {
select {
case <-ctx.Done():
return
case j := <-incoming:
select {
case jobs <- j:
// enqueue ok
default:
// 큐가 가득 찼으면 드롭/리젝트/백프레셔 중 선택
}
case c := <-controlCh:
handleControl(c)
}
}
여기서 중요한 건 “큐가 가득 찼을 때 정책”입니다.
- 드롭: 손실 허용(메트릭/로그 필수)
- 리젝트: 상류에 에러 반환
- 백프레셔: 블로킹으로 상류 속도 제한
정책이 불명확하면 결국 지연이 쌓여 더 큰 장애로 갑니다.
디버깅 팁: 기아처럼 보일 때 먼저 확인할 것
default가 있는지 확인: 바쁜 대기/폴링이 아닌가- 처리 비용 차이: 특정 case에서
time.Sleep, I/O, 락 경합이 있는가 - 버퍼 크기: 큰 버퍼가 지연을 숨기고 있지 않은가
GOMAXPROCS와 스케줄링: 단일 코어에서 재현이 쉬움- 관측: 채널 길이(
len(ch)), 처리 시간, 대기 시간을 메트릭으로
추가로, 시스템이 메모리 압박을 받으면 큐가 더 쌓이고 GC 압력까지 더해져 “특정 작업이 굶는 것처럼” 보이기도 합니다. 메모리 압박을 로그로 추적하는 방법은 리눅스 OOM Killer 로그로 메모리 누수 추적하기도 참고할 만합니다.
정리: select는 만능 공정성 도구가 아니다
select는 ready case들 사이에서 랜덤 선택을 하지만, 강한 공정성을 보장하지는 않습니다.- 기아 현상은 보통
default폴링, 입력률/처리비용 불균형, 과도한 버퍼링, 무거운 작업의 루프 점유에서 시작합니다. - 해결은 “공정성에 대한 기대”가 아니라 “정책”을 코드로 박는 것입니다.
- 우선순위
select - 제한된 드레인
- 단일 큐로 합치기
- 워커 풀 격리
- 버퍼/백프레셔 정책 설계
- 우선순위
운영 환경에서 중요한 건 “랜덤이면 언젠가 공정해지겠지”가 아니라, 지연 목표와 실패 정책을 명시하고 관측 가능하게 만드는 것입니다.