En million websockets og gå

Hej allesammen! Mit navn er Sergey Kamardin og jeg er en udvikler hos Mail.Ru.

Denne artikel handler om, hvordan vi udviklede den højbelastede WebSocket-server med Go.

Hvis du kender WebSockets, men ved lidt om Go, håber jeg, at du stadig finder denne artikel interessant med hensyn til ideer og teknikker til optimering af ydelsen.

1. Introduktion

For at definere konteksten i vores historie, skal der siges et par ord om, hvorfor vi har brug for denne server.

Mail.Ru har en masse stateful systemer. Opbevaring af bruger-e-mail er en af ​​dem. Der er flere måder at holde styr på tilstandsændringer i et system - og om systemhændelser. Oftest er dette enten gennem periodisk systemafstemning eller systemunderretninger om dens tilstandsændringer.

Begge måder har deres fordele og ulemper. Men når det kommer til mail, jo hurtigere en bruger modtager ny mail, desto bedre.

Mail polling involverer omkring 50.000 HTTP-forespørgsler pr. Sekund, hvoraf 60% returnerer 304-status, hvilket betyder, at der ikke er nogen ændringer i postkassen.

For at reducere belastningen på serverne og fremskynde postlevering til brugere blev beslutningen derfor truffet om at opfinde hjulet igen ved at skrive en udgiver-abonnentserver (også kendt som en bus, meddelelsesmægler eller begivenheds- kanal) der på den ene side vil modtage meddelelser om tilstandsændringer og abonnementer på sådanne underretninger på den anden side.

Tidligere:

Nu:

Det første skema viser, hvordan det var før. Browseren pollede periodisk API'en og spurgte om lagring (postkassetjeneste) ændringer.

Det andet skema beskriver den nye arkitektur. Browseren opretter en WebSocket-forbindelse med underretnings-API'et, som er en klient til Bus-serveren. Efter modtagelse af ny e-mail sender Storage en anmeldelse til den til Bus (1) og Bus til dens abonnenter (2). API'en bestemmer forbindelsen til at sende den modtagne anmeldelse og sender den til brugerens browser (3).

Så i dag skal vi tale om API'en eller WebSocket-serveren. Når jeg ser fremad, vil jeg fortælle dig, at serveren vil have omkring 3 millioner online-forbindelser.

2. Den idiomatiske måde

Lad os se, hvordan vi implementerer visse dele af vores server ved hjælp af almindelige Go-funktioner uden optimeringer.

Før vi fortsætter med net / http, lad os tale om, hvordan vi sender og modtager data. Dataene, der står over WebSocket-protokollen (f.eks. JSON-objekter), vil i det følgende blive benævnt pakker.

Lad os begynde at implementere kanalstrukturen, der vil indeholde logikken ved at sende og modtage sådanne pakker via WebSocket-forbindelsen.

2.1. Kanalstruktur

Jeg vil gerne henlede opmærksomheden på lanceringen af ​​to læse- og skrivegoroutiner. Hver goroutine kræver sin egen hukommelsestabel, der muligvis har en startstørrelse på 2 til 8 KB afhængigt af operativsystemet og Go-versionen.

Med hensyn til ovennævnte antal på 3 millioner online-forbindelser har vi brug for 24 GB hukommelse (med stakken på 4 KB) til alle forbindelser. Og det er uden den hukommelse, der er tildelt Kanalstrukturen, de udgående pakker ch.send og andre interne felter.

2.2. I / O-goroutiner

Lad os se på implementeringen af ​​"læseren":

Her bruger vi bufio.Reader til at reducere antallet af læse- () syscalls og til at læse så mange som tilladt af buf-bufferstørrelsen. Inden for den uendelige sløjfe forventer vi, at der kommer nye data. Husk ordene: forventer, at der kommer nye data. Vi vender tilbage til dem senere.

Vi overlader parsning og behandling af indgående pakker til side, da det ikke er vigtigt for de optimeringer, vi vil tale om. Dog er buf værd vores opmærksomhed nu: som standard er det 4 KB, hvilket betyder yderligere 12 GB hukommelse til vores forbindelser. Der er en lignende situation med "forfatteren":

Vi itererer på tværs af den udgående pakkekanal c.send og skriver dem til bufferen. Dette er, som vores opmærksomme læsere allerede kan gætte, endnu 4 KB og 12 GB hukommelse til vores 3 millioner forbindelser.

2.3. HTTP

Vi har allerede en simpel kanalimplementering, nu er vi nødt til at få en WebSocket-forbindelse til at arbejde med. Da vi stadig er under overskriften Idiomatisk måde, lad os gøre det på den tilsvarende måde.

Bemærk: Hvis du ikke ved, hvordan WebSocket fungerer, skal det nævnes, at klienten skifter til WebSocket-protokollen ved hjælp af en speciel HTTP-mekanisme kaldet Upgrade. Efter den vellykkede behandling af en opgraderingsanmodning bruger serveren og klienten TCP-forbindelsen til at udveksle binære WebSocket-rammer. Her er en beskrivelse af rammestrukturen inde i forbindelsen.

Bemærk, at http.ResponseWriter foretager hukommelsesallokering til bufio.Reader og bufio.Writer (begge med 4 KB buffer) til * http.Request initialisering og yderligere svarskrivning.

Uanset hvilket WebSocket-bibliotek, der bruges, modtager serveren efter et vellykket svar på opdateringsanmodningen I / O-buffere sammen med TCP-forbindelsen efter opkaldet ResponseWriter.Hijack ().

Tip: i nogle tilfælde kan go: -linknavnet bruges til at returnere bufferne til synk. Pool i net / http gennem opkaldsnet / http.putBufio {Reader, Writer}.

Vi har således brug for yderligere 24 GB hukommelse til 3 millioner forbindelser.

Så i alt 72 GB hukommelse til applikationen, der ikke gør noget endnu!

3. Optimeringer

Lad os gennemgå det, vi talte om i introduktionsdelen, og husk, hvordan en brugerforbindelse opfører sig. Efter skift til WebSocket sender klienten en pakke med de relevante begivenheder eller med andre ord abonnerer på begivenheder. Derefter (ikke under hensyntagen til tekniske meddelelser, såsom ping / pong), kan klienten muligvis ikke sende noget andet i hele forbindelsens levetid.

Forbindelsens levetid kan vare fra flere sekunder til flere dage.

Så for det meste venter vores Channel.reader () og Channel.writer () på håndteringen af ​​data til modtagelse eller afsendelse. Sammen med dem venter I / O-buffere på 4 KB hver.

Nu er det tydeligt, at visse ting kunne gøres bedre, ikke?

3.1. Netpoll

Kan du huske implementeringen af ​​Channel.reader (), der forventede, at nye data ville komme ved at blive låst på opkaldet conn.Read () inde i bufio.Reader.Read ()? Hvis der var data i forbindelsen, vækkede Go runtime vores goroutine og lod den læse den næste pakke. Derefter blev goroutinen låst igen, mens de forventede nye data. Lad os se, hvordan Go-runtime forstår, at goroutinen skal "vågne op".

Hvis vi ser på implementeringen conn.Read (), ser vi net.netFD.Read () -opkaldet inde i det:

Go bruger stikkontakter i ikke-blokerende tilstand. EAGAIN siger, at der ikke er nogen data i soklen, og at de ikke skal låses ved læsning fra det tomme stik, OS returnerer kontrol til os.

Vi ser en læst () syscall fra tilslutningsfilbeskrivelsen. Hvis read returnerer EAGAIN-fejlen, foretager runtime pollDesc.waitRead () -opkaldet:

Hvis vi graver dybere, ser vi, at netpoll implementeres ved hjælp af epoll i Linux og kqueue i BSD. Hvorfor ikke bruge den samme tilgang til vores forbindelser? Vi kunne allokere en læsebuffer og starte læsningsgoroutinen kun, når det virkelig er nødvendigt: når der virkelig er læsbare data i stikket.

På github.com/golang/go er der spørgsmålet om eksport af netpoll-funktioner.

3.2. At slippe af med goroutiner

Antag, at vi har netpoll-implementering til Go. Nu kan vi undgå at starte Channel.reader () goroutine med den indvendige buffer og tilmelde os for begivenheden af ​​læsbare data i forbindelsen:

Det er lettere med Channel.writer (), fordi vi kun kan køre goroutinen og allokere bufferen, når vi skal sende pakken:

Bemærk, at vi ikke håndterer sager, når operativsystemet returnerer EAGAIN ved skriv () systemopkald. Vi støtter os på Go-runtime i sådanne tilfælde, fordi det faktisk er sjældent for en sådan type servere. Ikke desto mindre kunne det håndteres på samme måde, hvis nødvendigt.

Efter at have læst de udgående pakker fra ch.send (en eller flere) afslutter forfatteren sin funktion og frigør goroutinestakken og sendebufferen.

Perfekt! Vi har gemt 48 GB ved at slippe af med stakken og I / O-buffere inde i to kontinuerligt kørende goroutiner.

3.3. Kontrol af ressourcer

Et stort antal forbindelser involverer ikke kun et højt hukommelsesforbrug. Når vi udviklede serveren, oplevede vi gentagne løbeforhold og deadlocks, ofte efterfulgt af den såkaldte self-DDoS - en situation, hvor applikationsklienterne voldsomt forsøgte at oprette forbindelse til serveren og dermed ødelægge den endnu mere.

For eksempel, hvis vi af en eller anden grund pludselig ikke kunne håndtere ping / pong-meddelelser, men behandleren af ​​ledige forbindelser fortsatte med at lukke sådanne forbindelser (antager, at forbindelserne blev ødelagt og derfor ikke leverede data), syntes klienten at miste forbindelsen hver N sekunder og prøvede at oprette forbindelse igen i stedet for at vente på begivenheder.

Det ville være dejligt, hvis den låste eller overbelastede server bare stoppede med at acceptere nye forbindelser, og balanceren før den (f.eks. Nginx) sendte anmodning til den næste serverforekomst.

Uanset serverbelastningen, hvis alle klienter pludselig ønsker at sende os en pakke af en eller anden grund (formodentlig af årsag til fejl), vil de tidligere gemte 48 GB være til ny brug, da vi faktisk kommer tilbage til den oprindelige tilstand af goroutinen og bufferen pr. forbindelse.

Goroutine pool

Vi kan begrænse antallet af pakker, der håndteres samtidigt ved hjælp af en goroutine-pool. Sådan ser en naiv implementering af en sådan pool ud:

Nu ser vores kode med netpoll således:

Så nu læser vi pakken, ikke kun når der er data, der kan læses, i soklen, men også ved den første mulighed for at tage den gratis goroutine op i poolen.

Tilsvarende ændrer vi Send ():

I stedet for at gå til ch.writer (), ønsker vi at skrive i en af ​​de genbrugte goroutiner. For en pool af N-goroutiner kan vi således garantere, at med N-anmodninger, der håndteres samtidigt og den ankomne N + 1, vil vi ikke tildele en N + 1-buffer til læsning. Goretinepuljen giver os også mulighed for at begrænse Accept () og Opgradering () af nye forbindelser og at undgå de fleste situationer med DDoS.

3.4. Nulkopi-opgradering

Lad os afvige lidt fra WebSocket-protokollen. Som allerede nævnt skifter klienten til WebSocket-protokollen ved hjælp af en HTTP-opdateringsanmodning. Sådan ser det ud:

Det er, i vores tilfælde har vi kun brug af HTTP-anmodningen og dens overskrifter for kun at skifte til WebSocket-protokollen. Denne viden og hvad der er gemt inde i http.Request antyder, at vi med henblik på optimering sandsynligvis kunne nægte unødvendige allokeringer og kopieringer, når vi behandler HTTP-anmodninger og opgiver standard net / http-serveren.

Eksempelvis indeholder http.Request et felt med den samme navn Header-type, der ubetinget udfyldes med alle anmodningsoverskrifter ved at kopiere data fra forbindelsen til værdiestrengene. Forestil dig, hvor meget ekstra data der kan opbevares inden for dette felt, for eksempel til en cookie-overskrift i stor størrelse.

Men hvad skal jeg tage til gengæld?

WebSocket-implementering

Desværre tillader alle biblioteker, der eksisterede på tidspunktet for vores serveroptimering, at vi kun kunne opgradere til standard net / http-serveren. Desuden gjorde ingen af ​​de (to) biblioteker det muligt at bruge alle ovenstående læse- og skriveoptimeringer. For at disse optimeringer skal fungere, skal vi have et temmelig lavt niveau API til at arbejde med WebSocket. For at genbruge bufferne har vi brug for procotolfunktionerne for at se sådan ud:

func ReadFrame (io.Reader) (Ramme, fejl)
func WriteFrame (io.Writer, Frame) fejl

Hvis vi havde et bibliotek med sådan en API, kunne vi læse pakker fra forbindelsen som følger (pakkeskrivningen ville se den ens ud):

Kort sagt var det tid til at lave vores eget bibliotek.

github.com/gobwas/ws

Ideologisk blev ws-biblioteket skrevet for ikke at pålægge brugerne sin protokolledriftslogik. Alle læse- og skrivemetoder accepterer standard io.Reader og io.Writer-grænseflader, hvilket gør det muligt at bruge eller ikke bruge buffering eller andre I / O-indpakninger.

Udover opdateringsanmodninger fra standard net / http understøtter ws nulkopi-opgradering, håndtering af opdateringsanmodninger og skift til WebSocket uden hukommelsesfordelinger eller kopieringer. ws.Upgrade () accepterer io.ReadWriter (net.Conn implementerer denne grænseflade). Med andre ord kunne vi bruge standardnet.Listen () og overføre den modtagne forbindelse fra ln.Accept () til ws.Upgrade (). Biblioteket gør det muligt at kopiere alle anmodningsdata til fremtidig brug i applikationen (for eksempel Cookie for at bekræfte sessionen).

Nedenfor er der benchmarks for behandling af opdateringsanmodning: standard net / http-server versus net.Lister () med nulkopi-opgradering:

BenchmarkUpgradeHTTP 5156 ns / op 8576 B / op 9 allocs / op
BenchmarkUpgradeTCP 973 ns / op 0 B / op 0 allocs / op

Skift til ws og nulkopi-opgradering sparede os yderligere 24 GB - den plads, der er tildelt til I / O-buffere efter anmodning behandling af net / http-behandleren.

3.5. Resumé

Lad os strukturere de optimeringer, jeg fortalte dig om.

  • En læst goroutine med en buffer indeni er dyre. Opløsning: netpoll (epoll, kqueue); genbruge bufferne.
  • En skrivegoroutin med en buffer indeni er dyre. Løsning: start goroutinen, når det er nødvendigt; genbruge bufferne.
  • Med en storm af forbindelser fungerer netpoll ikke. Løsning: genbruge goroutinerne med grænsen for deres antal.
  • net / http er ikke den hurtigste måde at håndtere Opgradering til WebSocket. Løsning: brug nulkopi-opgraderingen på bare TCP-forbindelse.

Sådan ser serverkoden ud:

4. Konklusion

For tidlig optimering er roden til alt ondt (eller i det mindste det meste af det) i programmeringen. Donald Knuth

Naturligvis er ovenstående optimeringer relevante, men ikke i alle tilfælde. For eksempel hvis forholdet mellem frie ressourcer (hukommelse, CPU) og antallet af online-forbindelser er ret højt, er der sandsynligvis ingen mening i at optimere. Du kan dog drage fordel af at vide, hvor og hvad du skal forbedre.

Tak for din opmærksomhed!

5. Henvisninger

  • https://github.com/mailru/easygo
  • https://github.com/gobwas/ws
  • https://github.com/gobwas/ws-examples
  • https://github.com/gobwas/httphead
  • Russisk version af denne artikel