Go 고루틴 누수와 pprof채널·뮤텍스, 그리고 종료 경로
고루틴을 만들기는 쉽다. 어려운 건 누가 데이터를 바꾸고, 누가 일을 끝내고, 언제 함수가 반환해도 되는지 정하는 일이었다. 채널을 잘못 쓰면 고루틴이 영원히 갇히는데, GC는 갇힌 고루틴을 치워 주지 않는다.
고루틴을 만들기는 쉽다. 어려운 건 누가 데이터를 바꾸고, 누가 일을 끝내고, 언제 함수가 반환해도 되는지 정하는 일이었다. 채널을 잘못 쓰면 고루틴이 영원히 갇히는데, GC는 갇힌 고루틴을 치워 주지 않는다.
앞 글에서 GOGC와 GOMEMLIMIT을 바꾸는 동안 할당 누계 256MiB는 그대로였다. 이 숫자를 줄이는 방법 중 하나가 짧게 쓰고 버리는 버퍼를 sync.Pool로 다시 쓰는 것이다.
컨테이너 limit이 1GB인데 힙 800MB에서 OOMKilled가 났다. GC가 있는 언어인데 왜 메모리가 넘쳤을까. GC는 살아 있는 힙의 비율로 돌고, 컨테이너는 절대값으로 프로세스를 죽인다.
go를 붙이면 함수가 동시에 진행된다. 그렇다면 고루틴을 10만 개 만들면 스레드도 10만 개가 생길까. GOMAXPROCS를 1로 두면 한 번에 한 작업만 처리할까.
인터페이스, 슬라이스, defer, 임베딩, context — 다섯 편을 쓰고 돌아보니 함정이 대부분 두 개의 축에서 나오고 있었다. 하나는 "모든 것은 복사된다", 다른 하나는 "클래스 없이 객체지향을 한다".
context.WithTimeout으로 1시간을 걸면 그 요청은 1시간까지 기다린다고 생각했다. 그런데 30초에 죽었다. context는 끝까지 갔다. 다만 그게 유일한 타이머가 아니었다. 그리고 타이머를 고치고 나니, 진짜 문제는 따로 있었다.
ctx를 첫 인자로 받아서 넘기는 것까지는 하고 있었다. 그런데 취소하면 고루틴이 정말 멈추는지는 확인해 본 적이 없었다. 면접에서 그 질문에 말이 막힌 뒤 context를 처음부터 다시 봤다.
검증 단계가 타임아웃으로 죽었다. 30분 만에 "N+1이니까 배치로 묶자"는 답이 나왔고, 그건 절반만 맞았다. 최종 설계에 도착하기까지 두 번 방향을 틀어야 했는데, 남은 게 코드보다 그 과정이라서 정리해둔다.
Go에는 클래스도 상속도 없다. 그런데 객체지향이 아니라고 하기도 어렵다. "Go는 객체지향 언어인가"에 대한 공식 FAQ의 답이 "Yes and no"인 이유를, 상속을 뺀 자리에 무엇을 넣었는지 따라가며 정리했다.
Go는 예외를 뺐고, 그 자리를 defer와 값으로서의 error로 채웠다. 셋 다 문법은 금방 배우는데 함정은 몇 년째 나온다. 각각이 정확히 언제 무엇을 붙잡는지를 기준으로 다시 정리했다.
Go에서 자주 만나는 버그는 들고 있다고 생각한 것과 실제로 들고 있는 것이 다를 때 생긴다. 슬라이스는 데이터가 아니라 창이고, map은 주소를 보장하지 않고, string은 문자 배열이 아니다. 세 자료구조의 레이아웃을 하나씩 열어 본다.
nil 포인터를 반환했는데 err != nil이 참이 되는 이유는 인터페이스 값이 타입과 데이터 두 칸으로 되어 있기 때문이다. 두 칸 구조에서 메서드셋, 인터페이스를 둘 위치, 동적 디스패치 비용까지 따라간다.
컴파일 언어와 정적 타입이 런타임에 어떤 이득으로 바뀌는지 Go를 기준으로 따라갔다. 메모리 레이아웃, 컴파일러 최적화, escape analysis, GC와 스케줄러까지 도구로 직접 확인한다.