백만 웹 소켓 및 이동

여러분 안녕하세요! 저는 Sergey Kamardin이고 Mail.Ru의 개발자입니다.

이 기사에서는 Go를 사용하여 고부하 WebSocket 서버를 개발 한 방법에 대해 설명합니다.

WebSockets에 익숙하지만 Go에 대해 거의 알지 못한다면 성능 최적화를위한 아이디어와 기술 측면에서이 기사가 여전히 흥미 롭기를 바랍니다.

1. 소개

스토리의 맥락을 정의하기 위해이 서버가 왜 필요한지 몇 마디 말해야합니다.

Mail.Ru에는 많은 상태 저장 시스템이 있습니다. 사용자 이메일 저장 용량도 그 중 하나입니다. 시스템 내 및 시스템 이벤트에 대한 상태 변경을 추적하는 방법에는 여러 가지가 있습니다. 대부분 정기적 인 시스템 폴링 또는 상태 변경에 대한 시스템 알림을 통해 이루어집니다.

두 가지 방법 모두 장단점이 있습니다. 그러나 메일의 경우 사용자가 새 메일을 더 빨리받을수록 더 좋습니다.

메일 폴링은 초당 약 50,000 개의 HTTP 쿼리를 포함하며 그 중 60 %는 304 상태를 반환하므로 사서함에는 변경 사항이 없습니다.

따라서 서버의로드를 줄이고 사용자에게 메일을 빠르게 전달하기 위해 발행자-구독자 서버 (버스, 메시지 브로커 또는 이벤트라고도 함)를 작성하여 바퀴를 다시 발명하기로 결정했습니다. 채널) 한편으로는 상태 변경에 대한 알림을 수신하고 다른 한편으로는 이러한 알림에 대한 구독을 수신합니다.

이전 :

지금:

첫 번째 계획은 이전의 모습을 보여줍니다. 브라우저는 주기적으로 API를 폴링하고 저장소 (사서함 서비스) 변경에 대해 물었습니다.

두 번째 체계는 새로운 아키텍처를 설명합니다. 브라우저는 알림 서버 (버스 서버의 클라이언트)와 WebSocket 연결을 설정합니다. 새 이메일을 수신하면 Storage는 해당 알림을 버스 (1)로, 버스를 가입자 (2)에게 보냅니다. API는 수신 된 알림을 보낼 연결을 결정하고이를 사용자의 브라우저로 보냅니다 (3).

오늘은 API 또는 WebSocket 서버에 대해 이야기하겠습니다. 앞으로 서버에 약 3 백만 개의 온라인 연결이있을 것입니다.

2. 관용적 방법

최적화없이 일반 Go 기능을 사용하여 서버의 특정 부분을 구현하는 방법을 살펴 보겠습니다.

net / http로 진행하기 전에 데이터를주고받는 방법에 대해 이야기하겠습니다. 이하에서는 WebSocket 프로토콜 (예를 들어, JSON 객체) 위에있는 데이터를 패킷이라고한다.

WebSocket 연결을 통해 이러한 패킷을 송수신하는 논리를 포함하는 채널 구조 구현을 시작하겠습니다.

2.1. 채널 구조

두 번의 읽기 및 쓰기 goroutines 출시에 관심을 기울이고 싶습니다. 각 goroutine에는 운영 체제 및 Go 버전에 따라 초기 크기가 2-8KB 인 자체 메모리 스택이 필요합니다.

위에서 언급 한 3 백만 건의 온라인 연결 수와 관련하여 모든 연결에 24GB의 메모리 (4KB 스택)가 필요합니다. 채널 구조, 발신 패킷 ch.send 및 기타 내부 필드에 할당 된 메모리가 없습니다.

2.2. I / O 고 루틴

“독자”의 구현을 살펴 보자.

여기서는 bufio.Reader를 사용하여 read () syscall 수를 줄이고 buf 버퍼 크기에서 허용하는만큼 읽습니다. 무한 루프 내에서 우리는 새로운 데이터가 올 것으로 기대합니다. 새로운 데이터가 올 것으로 예상하십시오. 우리는 나중에 그들에게 돌아올 것입니다.

우리는 들어오는 패킷의 구문 분석 및 처리를 제외하고 이야기 할 최적화에 중요하지 않기 때문에 남겨 둘 것입니다. 그러나 buf는 현재 주목할 가치가 있습니다. 기본적으로 4KB이므로 연결에 12GB의 메모리가 필요합니다. "writer"와 비슷한 상황이 있습니다.

우리는 나가는 패킷 채널 c.send를 반복하여 버퍼에 씁니다. 이것은 세심한 독자들이 이미 짐작할 수 있듯이 3 백만 개의 연결을위한 또 다른 4KB 및 12GB의 메모리입니다.

2.3. HTTP

우리는 이미 간단한 채널 구현을 가지고 있습니다. 이제 WebSocket 연결을 작동시켜야합니다. 우리는 여전히 관용적 방식 아래에 있으므로, 상응하는 방식으로하자.

참고 : WebSocket의 작동 방식을 모르는 경우 업그레이드라는 특수한 HTTP 메커니즘을 통해 클라이언트가 WebSocket 프로토콜로 전환한다고 언급해야합니다. 업그레이드 요청을 성공적으로 처리 한 후 서버와 클라이언트는 TCP 연결을 사용하여 이진 WebSocket 프레임을 교환합니다. 다음은 연결 내부의 프레임 구조에 대한 설명입니다.

http.ResponseWriter는 * http.Request 초기화 및 추가 응답 쓰기를 위해 bufio.Reader 및 bufio.Writer (4KB 버퍼 모두)에 메모리를 할당합니다.

사용 된 WebSocket 라이브러리에 관계없이, 업그레이드 요청에 대한 성공적인 응답 후 서버는 responseWriter.Hijack () 호출 후 TCP 연결과 함께 I / O 버퍼를 수신합니다.

힌트 : 경우에 따라 go : linkname을 사용하여 버퍼를 동기화에 반환 할 수 있습니다. net / http.putBufio {Reader, Writer} 호출을 통해 net / http 내부의 풀입니다.

따라서 3 백만 개의 연결을 위해 24GB의 메모리가 추가로 필요합니다.

따라서 아직 아무것도하지 않는 응용 프로그램을위한 총 72GB의 메모리!

3. 최적화

소개 부분에서 이야기 한 내용을 검토하고 사용자 연결이 어떻게 작동하는지 기억해 봅시다. WebSocket으로 전환 한 후 클라이언트는 관련 이벤트가 포함 된 패킷을 보내거나 다시 말해 이벤트를 구독합니다. 그런 다음 (핑 / 퐁과 같은 기술 메시지를 고려하지 않음) 클라이언트는 전체 연결 수명 동안 다른 것을 보낼 수 없습니다.

연결 수명은 몇 초에서 몇 일까지 지속될 수 있습니다.

따라서 대부분 Channel.reader () 및 Channel.writer ()는 수신 또는 전송을위한 데이터 처리를 기다리고 있습니다. 대기와 함께 각각 4KB의 I / O 버퍼가 있습니다.

이제는 어떤 일을 더 잘할 수 있었습니까?

3.1. 넷폴

bufio.Reader.Read () 내부의 conn.Read () 호출에 잠겨 새로운 데이터가 올 것으로 예상했던 Channel.reader () 구현을 기억하십니까? 연결에 데이터가있는 경우 Go 런타임은 Goroutine을 "깨워서"다음 패킷을 읽을 수 있도록 허용했습니다. 그 후, 새로운 데이터를 기대하면서 고 루틴이 다시 잠겼습니다. Go 런타임이 Goroutine이 "깨어 있어야"한다는 것을 이해하는 방법을 살펴 보겠습니다.

conn.Read () 구현을 살펴보면 내부에서 net.netFD.Read () 호출이 표시됩니다.

Go는 비 차단 모드에서 소켓을 사용합니다. EAGAIN은 소켓에 데이터가 없으며 빈 소켓에서 읽을 때 잠겨 있지 않으면 OS가 제어권을 우리에게 돌려줍니다.

연결 파일 설명자에서 read () syscall을 볼 수 있습니다. read가 EAGAIN 오류를 반환하면 런타임에서 pollDesc.waitRead ()를 호출합니다.

더 깊이 파고 들면 리눅스에서는 epoll을 사용하고 BSD에서는 kqueue를 사용하여 netpoll이 구현되는 것을 볼 수 있습니다. 연결에 동일한 접근 방식을 사용하지 않는 이유는 무엇입니까? 소켓에 실제로 읽을 수있는 데이터가있을 때만 필요한 경우에만 읽기 버퍼를 할당하고 읽기 고 루틴을 시작할 수 있습니다.

github.com/golang/go에서 netpoll 함수를 내보내는 문제가 있습니다.

3.2. 고 루틴 제거하기

Go에 대한 netpoll 구현이 있다고 가정하십시오. 이제 내부 버퍼로 Channel.reader () 고 루틴을 시작하지 않고 연결에서 읽을 수있는 데이터 이벤트를 구독 할 수 있습니다.

Goroutine을 실행하고 패킷을 보낼 때만 버퍼를 할당 할 수 있기 때문에 Channel.writer ()를 사용하는 것이 더 쉽습니다.

운영 체제가 write () 시스템 호출에서 EAGAIN을 반환하는 경우를 처리하지 않습니다. 이러한 경우 Go 런타임에 의존하므로 실제로 이러한 종류의 서버에서는 드물게 발생합니다. 그럼에도 불구하고 필요한 경우 동일한 방식으로 처리 할 수 ​​있습니다.

ch.send (하나 또는 여러 개)에서 나가는 패킷을 읽은 후 기록기는 작업을 마치고 고 루틴 스택과 송신 버퍼를 해제합니다.

완전한! 지속적으로 실행되는 두 개의 고 루틴 내부에서 스택 및 I / O 버퍼를 제거하여 48GB를 절약했습니다.

3.3. 자원 관리

많은 수의 연결에는 높은 메모리 소비 만이 포함됩니다. 서버를 개발할 때 우리는 반복적 인 경쟁 조건과 교착 상태에 이어 종종 소위 self-DDoS (응용 프로그램 클라이언트가 서버에 연결하려고 시도하여 서버를 더 많이 손상시키는 상황)를 겪었습니다.

예를 들어, 어떤 이유로 든 갑자기 핑 / 퐁 메시지를 처리 ​​할 수 ​​없지만 유휴 연결 처리기가 이러한 연결을 계속 닫으면 (연결이 끊어져서 데이터가 제공되지 않은 경우) 클라이언트는 N마다 연결이 끊어진 것처럼 보입니다. 이벤트를 기다리지 않고 다시 연결을 시도했습니다.

잠긴 서버 나 오버로드 된 서버가 새 연결 수락을 중단하고 밸런서 (예 : nginx)가 다음 서버 인스턴스로 요청을 전달한 경우에 좋을 것입니다.

또한 서버로드에 관계없이 모든 클라이언트가 갑자기 어떤 이유로 든 (버그로 인해) 패킷을 보내려면 이전에 저장된 48GB가 다시 사용됩니다. 실제로 초기 상태로 돌아갑니다. 각 연결 당 goroutine 및 버퍼의.

고 루틴 풀

고 루틴 풀을 사용하여 동시에 처리되는 패킷 수를 제한 할 수 있습니다. 이러한 풀의 순진한 구현은 다음과 같습니다.

이제 netpoll을 사용한 코드는 다음과 같습니다.

이제 우리는 소켓에 읽을 수있는 데이터가 나타날 때뿐만 아니라 풀에서 무료 고 루틴을 취할 수있는 첫 번째 기회에 대해서도 패킷을 읽습니다.

마찬가지로 Send ()를 변경합니다.

go ch.writer () 대신 재사용되는 goroutines 중 하나를 작성하려고합니다. 따라서 N 고 루틴의 풀에 대해 N 요청을 동시에 처리하고 도착한 N + 1을 사용하면 N + 1 버퍼를 할당하지 않습니다. 또한 고 루틴 풀을 통해 새 연결의 Accept () 및 Upgrade ()를 제한하고 DDoS의 대부분의 상황을 피할 수 있습니다.

3.4. 무 복사 업그레이드

WebSocket 프로토콜에서 약간 벗어납니다. 이미 언급했듯이 클라이언트는 HTTP 업그레이드 요청을 사용하여 WebSocket 프로토콜로 전환합니다. 이것은 다음과 같습니다

즉, 우리의 경우에는 WebSocket 프로토콜로 전환하기 위해서만 HTTP 요청과 헤더가 필요합니다. 이 지식과 ​​http.Request에 저장된 내용은 최적화를 위해 HTTP 요청을 처리하고 표준 net / http 서버를 포기할 때 불필요한 할당 및 복사를 거부 할 수 있다고 제안합니다.

예를 들어, http.Request에는 연결에서 값 문자열로 데이터를 복사하여 모든 요청 헤더로 무조건 채워지는 동일한 이름 헤더 유형의 필드가 포함됩니다. 예를 들어, 대형 쿠키 헤더와 같이이 필드 안에 얼마나 많은 추가 데이터를 보관할 수 있는지 상상해보십시오.

그러나 무엇을 대가로 받아 들여야합니까?

WebSocket 구현

불행히도, 서버 최적화 시점에 존재하는 모든 라이브러리는 표준 net / http 서버에 대해서만 업그레이드를 수행 할 수있었습니다. 또한 (2) 라이브러리 중 어느 것도 위의 모든 읽기 및 쓰기 최적화를 사용할 수 없었습니다. 이러한 최적화가 작동하려면 WebSocket 작업을위한 다소 낮은 수준의 API가 있어야합니다. 버퍼를 재사용하려면 다음과 같은 프로 코톨 함수가 필요합니다.

func ReadFrame (io.Reader) (프레임, 오류)
func WriteFrame (io.Writer, Frame) 오류

그러한 API를 가진 라이브러리가 있다면 다음과 같이 연결에서 패킷을 읽을 수 있습니다 (패킷 쓰기는 동일하게 보일 것입니다).

요컨대, 우리 자신의 도서관을 만들 시간이었습니다.

github.com/gobwas/ws

이념적으로 ws 라이브러리는 사용자에게 프로토콜 조작 로직을 부과하지 않도록 작성되었습니다. 모든 읽기 및 쓰기 방법은 표준 io.Reader 및 io.Writer 인터페이스를 허용하므로 버퍼링 또는 기타 I / O 래퍼를 사용하거나 사용하지 않을 수 있습니다.

ws는 표준 net / http의 업그레이드 요청 외에도 무 복사 업그레이드, 업그레이드 요청 처리 및 메모리 할당이나 복사없이 WebSocket으로의 전환을 지원합니다. ws.Upgrade ()는 io.ReadWriter를 받아들입니다 (net.Conn은이 인터페이스를 구현합니다). 즉, 표준 net.Listen ()을 사용하여 수신 된 연결을 ln.Accept ()에서 ws.Upgrade ()로 즉시 전송할 수 있습니다. 라이브러리를 사용하면 나중에 응용 프로그램에서 사용할 수 있도록 요청 데이터를 복사 할 수 있습니다 (예 : 세션을 확인하기위한 쿠키).

아래에는 업그레이드 요청 처리의 벤치 마크가 있습니다 : 표준 넷 / http 서버 대 복사가없는 업그레이드 net.Listen () :

벤치 마크 업그레이드 HTTP 5156 ns / op 8576 B / op 9 allocs / op
벤치 마크 업그레이드 TCPP 973 ns / op 0 B / op 0 allocs / op

ws 및 zero-copy 업그레이드로 전환하면 net / http 핸들러가 요청을 처리 할 때 I / O 버퍼에 할당 된 공간 인 24GB가 추가로 절약되었습니다.

3.5. 개요

내가 말한 최적화를 구성 해 봅시다.

  • 내부 버퍼가있는 읽기 고 루틴은 비싸다. 해결책 : netpoll (epoll, kqueue); 버퍼를 재사용하십시오.
  • 내부 버퍼가있는 쓰기 고 루틴은 비싸다. 해결책 : 필요할 때 고 루틴을 시작하십시오. 버퍼를 재사용하십시오.
  • 연결이 급증하면 netpoll이 작동하지 않습니다. 해결 방법 : 고 루틴의 수를 제한하여 재사용하십시오.
  • net / http는 WebSocket으로의 업그레이드를 처리하는 가장 빠른 방법은 아닙니다. 해결 방법 : 베어 TCP 연결에서 무 복사 업그레이드를 사용하십시오.

서버 코드는 다음과 같습니다.

4. 결론

조기 최적화는 프로그래밍에서 모든 악 (또는 적어도 대부분)의 근원입니다. 도널드 크 누스

물론 위의 최적화는 관련이 있지만 모든 경우에 해당되는 것은 아닙니다. 예를 들어 사용 가능한 리소스 (메모리, CPU)와 온라인 연결 수 사이의 비율이 다소 높은 경우에는 최적화 할 필요가 없습니다. 그러나 어디에서 무엇을 개선해야하는지 알면 많은 이점을 얻을 수 있습니다.

관심을 가져 주셔서 감사합니다!

5. 참고 문헌

  • https://github.com/mailru/easygo
  • https://github.com/gobwas/ws
  • https://github.com/gobwas/ws-examples
  • https://github.com/gobwas/httphead
  • 이 기사의 러시아어 버전