Reddit

Go 1.28 HTTP/2 parking: 85-93 MiB less RSS with 5,000 idle connections

Go 1.28 HTTP/2 파킹: 유휴 연결 5,000개에서 RSS 85~93MiB 감소

Go 1.28 개발 버전은 HTTP/2 연결에서 처리할 일이 없을 때 연결 담당 goroutine을 종료하고, 새 작업이 오면 다시 실행하는 파킹 기능을 추가했습니다. 한 머신에서 유휴 연결 5,000개를 측정한 결과, 파킹을 켜면 같은 바이너리에서 RSS가 84.7~92.6MiB 줄고 goroutine은 5,000개 감소했습니다.

에디터 노트

유휴 HTTP/2 연결이 많은 서버에서 연결마다 대기하던 goroutine을 없애면 스택 메모리와 프로세스 RSS가 함께 줄어든다는 결과가 나왔습니다. 같은 개발 바이너리에서 설정만 바꿔 비교했고 세 차례 모두 RSS가 감소해 파킹과 메모리 변화의 관계를 보여줍니다. 다만 측정 대상은 한 머신의 개발 리비전이며 연결은 대부분 유휴 상태였습니다. 활성 스트림이나 실제 애플리케이션 부하에서 같은 폭으로 줄어든다는 자료는 없습니다. 댓글의 질문은 변경 내용을 더 설명해 달라는 수준이며 별도의 검증이나 반론은 제시하지 않습니다. 따라서 85~93MiB는 해당 벤치마크 조건의 결과로 읽어야 합니다.

AI 요약

Go 1.28 개발 버전의 HTTP/2 서버 변경을 유휴 연결 1,000개와 5,000개에서 측정했습니다. 비교 결과 파킹을 켠 구성에서 RSS와 goroutine 수, goroutine 스택 메모리가 감소했습니다. 작성자는 한 머신의 특정 개발 리비전과 유휴 연결 위주 벤치마크에서 얻은 결과이므로 실제 서비스에 그대로 적용할 수치는 아니라고 설명합니다.

HTTP/2 연결 담당 goroutine 파킹

HTTP/2 연결은 활성 스트림이 없어도 계속 열려 있을 수 있습니다. 기존 동작에서는 연결을 처리하는 goroutine이 serveLoop에서 새 작업이 생길 때까지 대기합니다. Go 1.28 개발 코드의 파킹 기능은 처리할 작업이 없고 내부 안전 조건을 충족하면 그 goroutine을 종료합니다. 새 작업이 도착하면 새 goroutine이 serveLoop를 맡습니다.

파킹은 활성 스트림이 없는 연결에만 한정되지 않습니다. 핸들러가 실행 중이어도 serveLoop가 당장 처리할 일이 없고 안전 조건을 만족하면 연결 담당 goroutine이 파킹될 수 있습니다. 모든 연결이나 실행 중인 모든 핸들러가 파킹되는 것은 아닙니다. 파킹은 애플리케이션 요청 처리 방식의 변경이 아니라 서버 내부에서 연결별 goroutine을 관리하는 방식입니다. tryPark 같은 구현 내부 이름과 임시 GODEBUG 설정 http2serveparking에서 파킹이라는 명칭을 따왔으며 공개 API는 아닙니다.

벤치마크 구성

작성자는 세 구성을 같은 머신과 벤치마크 코드로 비교했습니다. Go 1.27.2, 파킹을 켠 Go 1.28 개발 빌드, 그리고 GODEBUG=http2serveparking=0으로 파킹을 끈 동일한 Go 1.28 바이너리입니다. 테스트한 Go 리비전은 5c51011e82ed754af072baaf4338ff35e76fe705이며 벤치마크 코드는 86753bd4d1f79621a4f3c6927c8239419e54bde2에 고정했습니다.

연결 수는 0개, 1,000개, 5,000개로 설정했습니다. 각 구성에서 각 조건을 열 차례 측정했고 전체 실험을 같은 Linux 머신에서 세 번 반복했습니다. 연결이 안정된 뒤 짧은 측정 구간을 수집하고 중앙값을 사용했습니다. 테스트 장비는 Intel Core i7-1265U, Linux 6.18.56, 메모리 약 32GiB이며 GOMAXPROCS=12, GOGC=100으로 설정했습니다. 측정 전에 강제로 가비지 컬렉션을 실행하지 않고 자연스러운 동작을 유지했습니다.

측정 항목은 Linux가 보고하는 프로세스 RSS, Go 런타임의 goroutine 수, goroutine 스택 메모리인 StackInuse, 할당된 힙 메모리인 HeapAlloc입니다. 각 지표는 서로 다른 메모리 영역이나 상태를 나타내므로 같은 의미로 볼 수 없습니다.

RSS와 goroutine 수 변화

연결 5,000개에서 Go 1.28의 파킹 비활성화 RSS는 세 차례 측정에서 218.28MiB, 223.73MiB, 217.94MiB였습니다. 파킹 활성화 RSS는 각각 129.19MiB, 131.11MiB, 133.25MiB였습니다. 같은 바이너리에서 파킹을 켜자 RSS가 84.70~92.62MiB, 비율로는 약 38.9~41.4% 줄었습니다. 연결 1,000개에서도 감소 폭은 약 15.7~16.6MiB였습니다. 연결이 없는 기준 상태의 RSS는 구성별로 약 8.5~8.7MiB였습니다.

5,000개 연결에서 파킹을 끄면 goroutine은 약 10,007개, 켜면 약 5,007개였습니다. 차이는 정확히 5,000개입니다. 연결 1,000개에서도 goroutine 차이가 1,000개였습니다. StackInuse 역시 5,000개 연결에서 파킹을 끄면 약 99MiB, 켜면 약 21MiB였고 세 차례 실험에서 약 78MiB 감소했습니다.

RSS 감소분 전체가 스택에서 나온 것은 아닙니다. RSS에는 스택 외에도 여러 메모리가 포함되며 힙 할당, 런타임 상태, 운영체제의 영향도 받을 수 있습니다. 다만 goroutine 수와 StackInuse 감소는 파킹 동작과 함께 나타났습니다. HeapAlloc은 일관된 방향을 보이지 않았습니다. 어떤 실험에서는 파킹을 켠 쪽이 더 적었고 다른 실험에서는 약간 더 많았습니다. 따라서 작성자는 이를 고정된 힙 절감량이나 전체 메모리의 일정 비율이 줄어드는 결과로 설명하지 않습니다.

결과의 범위

측정은 한 머신과 한 벤치마크 설계, Go 1.28의 특정 개발 리비전을 대상으로 했습니다. 최종 Go 1.28 릴리스의 결과가 아니며 실제 트래픽, 활성 스트림, 애플리케이션 워크로드를 섞어 테스트하지 않았습니다. 같은 Go 1.28 바이너리에서 임시 설정으로 파킹을 켜고 꺼 비교했지만 시스템 잡음을 완전히 제거하지는 못합니다. 세 차례 반복은 결과의 방향과 범위를 보여줄 뿐 보편적인 보장을 뜻하지 않습니다. 비교에 사용한 GODEBUG=http2serveparking=0은 임시 설정이며 문서화되지 않았습니다.

Reddit 반응

  • @atkrad — Go 1.28 초안 릴리스 노트를 읽다가 HTTP/2 서버가 연결 담당 goroutine을 처리하는 방식의 변경을 발견했습니다. 유휴 HTTP/2 연결 1,000개와 5,000개에서 파킹을 켠 같은 Go 개발 바이너리와 끈 바이너리를 비교했습니다. 연결 5,000개에서 파킹은 세 차례 측정 동안 RSS를 85~93MiB 줄였고 서버의 goroutine도 5,000개 줄었습니다. 최종 릴리스가 아닌 개발 리비전의 결과이며 벤치마크는 대부분 유휴 상태인 연결을 다룹니다. 전체 방법론과 차트, 재현 가능한 벤치마크는 링크에서 확인할 수 있습니다.
  • @itsmontoya — 서버 동작에서 무엇을 바꿨나요?

원문: abdolirad.me / 번역·요약: Trawling