Go의 net/http 서버는 연결당 고루틴 모델과 런타임의 M:N 스케줄링 전략을 결합하여 사용합니다. 서버가 TCP 연결을 수락하면, 즉시 해당 연결의 전체 생애 주기를 처리하기 위해 경량 고루틴을 생성하여 메인 수용 루프가 즉시 반환되고 다음 연결을 받을 수 있도록 합니다. 이러한 고루틴은 Go 스케줄러에 의해 제한된 OS 스레드 풀에 멀티플렉스되며, 차단된 I/O를 수행하는 고루틴을 주차하고 실행 가능한 고루틴을 사용 가능한 스레드에 재스케줄합니다. 이러한 아키텍처는 서버가 전통적인 연결당 스레드 서버의 메모리 오버헤드를 피하면서 수천 개의 동시 연결을 유지할 수 있도록 합니다.
우리는 50,000개의 IoT 장치로부터 데이터를 동시에 수집할 수 있는 실시간 텔레메트리 게이트웨이를 구축해야 했습니다.
문제 설명: Python과 Twisted를 사용한 초기 프로토타입은 필요한 동시성을 제공했지만, 복잡한 콜백 체인과 깊게 중첩된 오류 처리로 인해 빠르게 유지보수할 수 없게 되었습니다. 코드를 단순화하기 위해 Java의 스레드당 연결 접근 방식을 시도했을 때, 약 32,000개의 연결에서 운영 체제의 스레드 한계에 봉착해 OutOfMemoryError: unable to create new native thread와 같은 오류로 JVM이 충돌했습니다. 이유는 각 스레드가 1MB 이상의 가상 메모리를 소모했기 때문입니다.
고려한 다양한 솔루션:
명시적 상태 기계가 있는 Asyncio: 우리는 Python의 asyncio로 마이그레이션하여 단일 이벤트 루프와 코루틴을 사용해 볼 것을 평가했습니다. 이것은 스레드에 비해 메모리 사용량을 상당히 줄일 수 있지만, 모든 프로토콜 파싱 로직을 async/await 문법으로 재작성해야 하며 CPU 집약적인 작업으로 인해 이벤트 루프가 차단될 위험이 있었습니다. 비동기 경계를 넘는 스택 추적을 디버깅하는 것도 우리 개발 팀에게는 notoriously 어려운 작업이 되었습니다.
JVM 인스턴스의 수평 샤딩: 우리는 로드 밸런서 뒤에서 10개의 작은 Java 인스턴스를 실행하여 각 인스턴스가 5,000개의 스레드를 처리하도록 하는 것을 고려했습니다. 이 접근 방식은 프로세스당 스레드 한계를 해결했지만, 상당한 운영 복잡성을 추가했으며 추가 하드웨어 리소스가 필요하고 클러스터 전체에서 공유 상태 및 연결 고착화 관리를 복잡하게 만들었습니다. 이 마이크로 클러스터를 유지하는 운영 오버헤드는 Java에 남아 있는 것의 이점을 초과했습니다.
Go의 고루틴당 연결 모델: 우리는 Go로 게이트웨이를 다시 구현하기로 결정하였고, 표준 라이브러리의 net/http 및 net 패키지를 활용했습니다. 서버의 Serve 메서드는 수락된 각 TCP 연결에 대해 경량 고루틴을 자동으로 생성하며, Go 런타임의 스케줄러는 이를 제한된 OS 스레드 풀에 투명하게 멀티플렉스합니다. 이를 통해 우리는 복잡한 상태 기계 관리 없이 수십만 개의 연결로 확장할 수 있는 간단하고 동기적으로 보이는 I/O 코드를 작성할 수 있었습니다.
선택한 솔루션과 이유: 우리는 Go 구현을 선택했습니다. 왜냐하면 이벤트 기반 시스템의 확장성과 스레드 프로그래밍의 단순성을 결합하였기 때문입니다. 런타임은 스케줄링 및 비차단 I/O의 복잡성을 자동으로 처리하여 개발자가 동시성 원시값보다 비즈니스 로직에 집중할 수 있도록 허용했습니다. 또한, 고루틴의 2KB 초기 스택 크기 덕분에 이론적으로 수백만 개의 연결을 처리할 수 있었습니다.
결과: 생산 시스템은 단일 8코어 서버에서 75,000개의 동시 지속 연결을 성공적으로 관리하며 4GB 미만의 RAM을 소비했습니다. CPU 사용량은 35-40%로 안정적이었고, 스케줄러가 I/O 지연을 효율적으로 숨겼으며, 샤딩된 Java 인스턴스 클러스터를 관리하는 운영 부담을 없앴습니다.
수천 개의 고루틴이 동일한 채널 수신에서 차단될 때, Go 스케줄러는 어떻게 다수의 군중 문제를 방지하나요?
Go 스케줄러는 채널에 대해 선입선출(FIFO) 대기 큐를 사용합니다. 이는 세마포르 스타일의 모두 깨우기 방식이 아닙니다. 송신자가 채널에 쓰면, 스케줄러는 대기 큐에서 정확히 하나의 고루틴을 깨워 (가장 오랫동안 대기한 고루틴) 이 값만 소비하도록 보장합니다. 이를 통해 여러 고루틴이 깨어나 서로 경쟁한 후 대부분이 다시 잠들게 되는 군중 문제를 예방할 수 있습니다. 후보자들은 채널 작업이 조건 변수처럼 모든 대기자에게 방송된다고 잘못 가정하는 경우가 많습니다.
왜 GOMAXPROCS를 물리적 CPU 코어 수 이상으로 늘리면 I/O 바인드 Go HTTP 서버의 성능이 저하될 수 있나요?
Go의 스케줄러는 1.14 버전 이후로 비선형적이지만, 스레드 수(M)가 코어 수보다 많아지면 커널 수준의 컨텍스트 스위치 오버헤드가 증가합니다. I/O 바인드 서버의 경우 과도한 스레드는 스케줄러가 사용자 코드 실행보다 런큐 및 스레드 핸드오프 관리를 더 많이 하게 만듭니다. 또한, 각 OS 스레드는 커널 자원을 소비하며 (스레드 지역 저장을 위한 메모리와 커널 스택) 필요 이상의 병렬성을 초과하여 확장하면 운영 체제에 압박을 가할 수 있습니다.
Go의 net/http 서버는 고루틴 수용률이 연결 도착률에 비해 일시적으로 느릴 때 TCP SO_BACKLOG 큐를 어떻게 처리하나요?
서버는 커널의 수신 대기 큐( net.ListenConfig의 Backlog 또는 시스템 기본값에 의해 제어)의 의존하여 처리합니다. 고루틴 생성이 느리거나 핸들러가 수신기를 통해 연결을 수락하는 데에 느릴 경우, 커널은 수신 대기 큐에 들어오는 SYN을 대기시킵니다. 대기 큐가 가득 차면, 커널은 새로운 연결을 TCP RST로 거부합니다. Go의 Accept() 루프는 자신의 고루틴에서 실행되며 이상적으로 핸들러 고루틴을 신속하게 생성해야 합니다. 그러나 핸들러 생성이 지연되면(예: GC 일시 중지나 미들웨어의 뮤텍스 경합으로 인해) 연결이 끊어집니다. 후보자들은 Go가 사용자 공간 연결 대기열을 구현하지 않고 커널 대기에 전적으로 의존한다는 점을 종종 놓치며, SOMAXCONN 또는 ListenConfig.Backlog 조정이 급증하는 차단을 흡수하는 데 중요함을 인지하지 못합니다.