golang unbuffer channel 로 인한 goroutine, memory leak을 해결한 이야기

Matthew Woo·2024년 11월 17일
0

Work

목록 보기
3/5
post-thumbnail

사내 Golang server 에서 goroutinechannel 을 사용하여 goroutine leak 이 발생하고 있었고 해결된 케이스를 다루고자 합니다.
이 글을 읽는 독자께서 Golang을 처음 접하신다면 goroutine 은 경량 스레드이고 channelgoroutine 간의 동시성 제어나 goroutine 간 데이터를 주고 받을 수 있는 buffer, pipeline 의 개념이라 생각하면 이 글을 이해하실 수 있습니다.


배경

필자는 광고회사에 재직중이며 외부 네트워크 사에서 광고를 받아오는 서버를 담당하는 Dane 이 해당 서버의 내부 모니터링을 위한 dashboard를 추가해주셨다.

OOM이 간헐적으로 발생하여 원인파악을 위해 추가해주신 것으로 알고 있어서 메트릭을 좀 지켜봤더니 한눈에 봐도 문제가 있다는걸 직감할 수 있었다. 이에 리포팅을 드리게 되었다.

어디선가 goroutine이 zombie처럼 정상적으로 종료되지 않고 쌓이는게아닐까 싶었다.
담당하거나 커밋해본 서버는 아니었지만 goroutine을 생성하는 곳은 많지 않아서 잠깐 처음들어가 봤더니 눈에 띄는 두 곳이 있었다.


문제점

문제되는 코드는 다음과 같았다.

// receiver
func SendRequestBatch(ctx context.Context, requests []*Request) []*AD {
	...
    
	ads := s.initializeADList(len(requests))
	groupedRequest := s.orderRequest(requests)
    anotherGroupedRequest := s.anothoerOrderRequest(requests)
    
    // unbuffered chan
    out := make(chan *response)
    
    ...
    
    // sender 함수 호출
	s.sendRequests(ctx, groupedRequest, out)
    s.sendRequests2(ctx, anotherGroupedRequest, out)
	
    // 외부 호출 후 응답을 채널로 받는 쪽. receiver
	for range ads {
		select {
		case res := <-out:
			ads[res.order] = res.ad
		case <-ctx.Done():
			continue // timeout case.
		}
	}

	return ads
}


// sender 
func sendRequests(ctx context.Context, requests []orderedRequest, out chan *response) {
		...
		go func() {
			...
            // 외부에 복수의 요청을 보낸 다음 이를 chan로 보냄. sender
			ads := s.client.GetGroupedRequest(ctx, rawRequestList)
			for i := range v {
				out <- &response{
					ad:    ads[i],
					order: v[i].order,
				}
			}
		}()
		...
}

두 가지 문제가 있다.
1.
unbuffed channel을 사용하면서 여러 goroutine이 aync하게 동작하도록 의도한 코드로 보이지만 sync 하게 동작한다. receiver는 하난데 sender는 복수이며 각각 response를 받아와 넣어주려는 코드이다. 하지만 buffer가 없기때문에 하나의 response 씩 처리된다.

복수의 sender 입장에서 어느 sender가 먼저 response를 넣어준 상황에서 recevier 가 해당 response를 받아가지 않는 이상 다른 sender 들도 block 되는 상황이 발생한다.
recevier 입장에서도 chan로 부터 하나의 response씩만 전달 받을 수 있다.

퍼포먼스가 좋을 수 없다.

2
sender goroutine이 종료되지 않을 수 있다.
위의 1에서 설명한 상황에서 receiver 가 먼저 종료될 경우, sender는 receiver가 chan의 response가 처리될 때 까지 block 되기 때문에 종료되지 않고 block 된 상태로 zombie goroutine 이 누적되는 상태에 빠지게 된다.


개선 후

// before
out := make(chan *response)

// after
out := make(chan *response, len(requests))

Dane이 이를 buffered chan로 변경 및 배포해주셨다.
이제 receiver가 먼저 종료되더라도, sender가 block되지 않고 종료될 수 있었다.
배포결과, 언제 배포했는지 확인이 가능할 만큼 지표의 개선을 볼 수 있었다.


회사코드다보니 네이밍을 변경, 메소드를 일반 함수로 변경, 중간에 코드들을 ... 로 지워두긴 했지만 좀 더 가독성 있게 리펙토링할 부분도 보인다.

오늘의 결론. channel 사용 시에는 unbuffered, buffered chan을 주의 깊게 구분하여 용도에 잘 맞게 사용할 것.

profile
지속가능하고 안정적인 시스템을 만들고자 합니다.

0개의 댓글