openlang

hostprobe: 큐 cap은 8, 워커는 4개인데 요청 100개가 전부 202를 반환하고 goroutine이 ~100까지 올라가요

cron이랑 스크립트 몇 개가 /probe?host=NAME으로 POST하는 작은 내부 서비스 hostprobe를 만들고 있어요. 워커 4개, 큐 8, probe는 일단 20초 sleep이에요 (ssh 체크는 나중에 넣을 거예요). 스크립트가 폭주해도 서비스가 작게 유지되면 좋겠어요.

테스트로 hey -n 100 -c 100을 날렸어요. 100개 전부 몇 ms 만에 202로 돌아왔어요. 9번째부터는 느려지거나 거절될 거라고 예상했는데요. /stats는 queue len=8 cap=8에 4개 실행 중이라고 나와서, 채널이랑 워커는 맞게 동작하는 것 같아요. 그런데 runtime.NumGoroutine()이 100 정도고, /debug/pprof/goroutine?debug=1을 보면 같은 지점에 큰 그룹 하나가 멈춰 있어요. 요청을 1000개 보내면 큐는 8인 채로 goroutine이 1000 정도까지 올라갔어요. 활성 상태 보기 메모리도 버스트할 때마다 조금씩 올라가요.

핸들러랑 풀을 한동안 안 건드려서, 사실상 잊어버린 코드를 멍하니 보고 있어요. 핸들러예요:

job := Job{Host: host, Enqueued: time.Now()}

go func() {
	q.jobs <- job
}()

atomic.AddInt64(&q.accepted, 1)
w.WriteHeader(http.StatusAccepted)
fmt.Fprintf(w, "queued %s\n", host)

풀은 흔한 for job := range q.jobs를 goroutine 4개에서 돌리는 거고, 채널은 make(chan Job, cfg.QueueDepth)예요.

늘어나는 goroutine은 어디서 오는 걸까요? 워커나 채널이 의심스러운데, 숫자상으로는 둘 다 멀쩡해요.

영어에서 번역원문 보기

이어지는 대화 3

서로의 근거를 따라
  1. 고유 링크

    go func() { q.jobs <- job }()가 답의 전부라고 생각해요. 채널은 제 역할을 하고 있어요. 버퍼에 8개가 있고, 4개는 워커가 들고 있고, 나머지 send는 전부 블록돼요. 그런데 블록된 send 하나하나가 각자 goroutine이라서 채널 send에서 대기하고 있고, 핸들러는 이미 202를 반환하고 넘어갔어요. 그러니까 큐가 누구한테도 push back을 못 하고, 뒤에 goroutine 대기실만 하나 생긴 거예요. pprof에서 보이는 그 큰 그룹이 그거고, 요청 1000개에 goroutine이 1000개 정도 나오는 이유도 그거예요. goroutine마다 스택이랑 job도 들고 있으니까, 버스트 뒤에 메모리가 슬금슬금 올라가는 것도 맞아떨어져요.

    9번째부터 거절하고 싶으면, 핸들러 안에서 select로 send를 하세요:

    select {
    case q.jobs <- job:
    	atomic.AddInt64(&q.accepted, 1)
    	w.WriteHeader(http.StatusAccepted)
    	fmt.Fprintf(w, "queued %s\n", host)
    case <-r.Context().Done():
    	return
    default:
    	http.Error(w, "queue full", http.StatusServiceUnavailable)
    }

    default가 있으면 바로 실패해요. 호출한 쪽이 기다리게 하고 싶으면 default를 빼세요. context case 덕분에 클라이언트가 끊으면 포기할 수 있어요. cron 스크립트한테는 Retry-After를 붙인 503(원하면 429)이 보통이에요.

    그리고 예전 코드가 말한 "queued"는 좀 후한 표현이라고 생각해요. 실제로는 "큐에 넣어 주겠다고 약속함, 언젠가는"에 가까웠어요.

    영어에서 번역원문 보기

같은 질문의 다른 갈래

  1. context를 취소했는데도 첫 에러 이후 worker goroutine이 계속 살아 있어요 (항상 workers + 2)

    안녕하세요, 작은 Go CLI인 shotcheck를 만들고 있어요. 폴더 안의 모든 PNG가 App Store 슬롯에서 요구하는 정확한 크기인지 확인하는 도구예요. worker 풀(기본 8개)이 jobs에서 이름을 읽어서 검…

    답글 4최근 활동 go
  2. filter를 async로 바꿔도 멈춤 현상은 그대로예요

    ledgerscan으로 400k개 항목을 검색해요. run을 async로 바꾸고 main에서 await했어요. 100 ms 하트비트 Timer가 계속 돌 줄 알았거든요. 그런데 실제로는 틱이 찍히고, searching "re…

    답글 2최근 활동 dart
  3. await for over ReceivePort가 isolate가 다 끝나도 끝나지 않아요

    안녕하세요, 온도 CSV를 요약해 주는 작은 dart 툴을 만들고 있어요. 큰 파일들이 서로 기다리지 않도록 파일마다 isolate를 하나씩 띄워요. WSL에서 이렇게 실행해요: dart run bin/summarize.da…

    답글 2최근 활동 dart