A Million WebSockets and Go

Hallo iedereen! Mijn naam is Sergey Kamardin en ik ben ontwikkelaar bij Mail.Ru.

Dit artikel gaat over hoe we de krachtige WebSocket-server met Go hebben ontwikkeld.

Als u bekend bent met WebSockets, maar weinig over Go weet, hoop ik dat u dit artikel nog steeds interessant zult vinden in termen van ideeën en technieken voor prestatieoptimalisatie.

1. Inleiding

Om de context van ons verhaal te definiëren, moeten een paar woorden worden gezegd over waarom we deze server nodig hebben.

Mail.Ru heeft veel stateful systemen. E-mailopslag van gebruikers is daar een van. Er zijn verschillende manieren om statusveranderingen binnen een systeem bij te houden - en over systeemgebeurtenissen. Meestal gebeurt dit door periodieke systeempolling of systeemmeldingen over de statuswijzigingen.

Beide manieren hebben hun voor- en nadelen. Maar als het gaat om e-mail, hoe sneller een gebruiker nieuwe e-mail ontvangt, hoe beter.

E-mail polling omvat ongeveer 50.000 HTTP-zoekopdrachten per seconde, waarvan 60% de 304-status retourneert, wat betekent dat er geen wijzigingen in de mailbox zijn.

Daarom is besloten om het wiel opnieuw uit te vinden door een publisher-subscriber-server te schrijven (ook bekend als een bus, berichtenmakelaar of gebeurtenis) om de belasting van de servers te verminderen en de e-mailbezorging aan gebruikers te versnellen. kanaal) die enerzijds meldingen ontvangen over statuswijzigingen en anderzijds abonnementen voor dergelijke meldingen.

Eerder:

Nu:

Het eerste schema laat zien hoe het vroeger was. De browser heeft periodiek de API opgevraagd en gevraagd naar wijzigingen in de opslag (mailbox-service).

Het tweede schema beschrijft de nieuwe architectuur. De browser brengt een WebSocket-verbinding tot stand met de meldings-API, een client voor de Bus-server. Na ontvangst van nieuwe e-mail stuurt Storage een melding hierover naar Bus (1) en Bus naar zijn abonnees (2). De API bepaalt de verbinding om de ontvangen melding te verzenden en stuurt deze naar de browser van de gebruiker (3).

Dus vandaag gaan we het hebben over de API of de WebSocket-server. Voor de toekomst zal ik je vertellen dat de server ongeveer 3 miljoen online verbindingen zal hebben.

2. De idiomatische manier

Laten we eens kijken hoe we bepaalde delen van onze server zouden kunnen implementeren met behulp van gewone Go-functies zonder enige optimalisaties.

Voordat we doorgaan met net / http, laten we het hebben over hoe we gegevens zullen verzenden en ontvangen. De gegevens die boven het WebSocket-protocol staan ​​(bijv. JSON-objecten) worden hierna pakketten genoemd.

Laten we beginnen met het implementeren van de kanaalstructuur die de logica zal bevatten van het verzenden en ontvangen van dergelijke pakketten via de WebSocket-verbinding.

2.1. Kanaal struct

Ik wil uw aandacht vestigen op de lancering van twee goroutines voor lezen en schrijven. Elke goroutine heeft zijn eigen geheugenstapel nodig met een initiële grootte van 2 tot 8 KB, afhankelijk van het besturingssysteem en de Go-versie.

Wat betreft het bovengenoemde aantal van 3 miljoen online verbindingen, hebben we 24 GB geheugen (met de stapel van 4 KB) nodig voor alle verbindingen. En dat is zonder het geheugen dat is toegewezen voor de kanaalstructuur, de uitgaande pakketten ch.send en andere interne velden.

2.2. I / O-goroutines

Laten we eens kijken naar de implementatie van de "reader":

Hier gebruiken we de bufio.Reader om het aantal read () syscalls te verminderen en om zoveel te lezen als toegestaan ​​door de bufbuffergrootte. Binnen de oneindige lus verwachten we dat er nieuwe gegevens komen. Onthoud de woorden: verwacht dat er nieuwe gegevens komen. We komen er later op terug.

We zullen het parseren en verwerken van inkomende pakketten buiten beschouwing laten, omdat het niet belangrijk is voor de optimalisaties waar we het over zullen hebben. Buf is echter onze aandacht nu waard: standaard is dit 4 KB, wat nog 12 GB geheugen voor onze verbindingen betekent. Er is een vergelijkbare situatie met de "schrijver":

We doorlopen het uitgaande pakket kanaal c.sturen en schrijven ze naar de buffer. Dit is, zoals onze aandachtige lezers al kunnen raden, nog eens 4 KB en 12 GB geheugen voor onze 3 miljoen verbindingen.

2.3. HTTP

We hebben al een eenvoudige kanaalimplementatie, nu moeten we een WebSocket-verbinding hebben om mee te werken. Omdat we ons nog steeds onder de noemer Idiomatic Way bevinden, laten we het op de overeenkomstige manier doen.

Opmerking: als u niet weet hoe WebSocket werkt, moet worden vermeld dat de client overschakelt naar het WebSocket-protocol door middel van een speciaal HTTP-mechanisme genaamd Upgrade. Na de succesvolle verwerking van een upgrade-aanvraag, gebruiken de server en de client de TCP-verbinding om binaire WebSocket-frames uit te wisselen. Hier is een beschrijving van de framestructuur in de verbinding.

Merk op dat http.ResponseWriter geheugentoewijzing maakt voor bufio.Reader en bufio.Writer (beide met 4 KB buffer) voor * http.Request initialisatie en verder schrijven van antwoorden.

Ongeacht de gebruikte WebSocket-bibliotheek ontvangt de server na een succesvolle reactie op de upgrade-aanvraag I / O-buffers samen met de TCP-verbinding na de aanroep responseWriter.Hijack ().

Hint: in sommige gevallen kan de go: linknaam worden gebruikt om de buffers terug te brengen naar de sync.Pool in net / http via het call net / http.putBufio {Reader, Writer}.

We hebben dus nog 24 GB geheugen nodig voor 3 miljoen verbindingen.

Dus in totaal 72 GB geheugen voor de applicatie die nog niets doet!

3. Optimalisaties

Laten we eens kijken waar we het in het inleidende gedeelte over hebben gehad en onthouden hoe een gebruikersverbinding zich gedraagt. Na het overschakelen naar WebSocket verzendt de client een pakket met de relevante gebeurtenissen of zich met andere woorden abonneert op gebeurtenissen. Vervolgens (zonder rekening te houden met technische berichten zoals ping / pong), kan de client niets anders verzenden gedurende de hele levensduur van de verbinding.

De levensduur van de verbinding kan enkele seconden tot enkele dagen duren.

Dus de meeste tijd wachten onze Channel.reader () en Channel.writer () op de verwerking van gegevens voor ontvangst of verzending. Samen met hen wachten de I / O-buffers van elk 4 KB.

Nu is het duidelijk dat bepaalde dingen beter kunnen, niet waar?

3.1. Netpoll

Herinner je je de Channel.reader () -implementatie die verwachtte dat nieuwe gegevens zouden komen door vergrendeld te raken op de aanroep conn.Read () in de bufio.Reader.Read ()? Als er gegevens in de verbinding waren, heeft Go runtime onze goroutine "wakker gemaakt" en het toegestaan ​​om het volgende pakket te lezen. Daarna werd de goroutine opnieuw vergrendeld in afwachting van nieuwe gegevens. Laten we eens kijken hoe Go runtime begrijpt dat de goroutine moet worden "gewekt".

Als we de implementatie van conn.Read () bekijken, zien we de aanroep net.netFD.Read () erin:

Go gebruikt sockets in niet-blokkerende modus. EAGAIN zegt dat er geen gegevens in de socket zitten en dat om niet te worden vergrendeld bij het lezen uit de lege socket, OS de controle aan ons teruggeeft.

We zien een read () syscall van de verbindingsbestanddescriptor. Als lezen de EAGAIN-fout retourneert, roept runtime de pollDesc.waitRead () aan:

Als we dieper graven, zullen we zien dat netpoll wordt geïmplementeerd met epoll in Linux en kqueue in BSD. Waarom niet dezelfde aanpak gebruiken voor onze verbindingen? We kunnen een leesbuffer toewijzen en de lees-goroutine alleen starten als het echt nodig is: als er echt leesbare gegevens in de socket zijn.

Op github.com/golang/go is er het probleem van het exporteren van netpoll-functies.

3.2. Van goroutines afkomen

Stel dat we netpoll-implementatie voor Go hebben. Nu kunnen we voorkomen dat we de Channel.reader () goroutine starten met de interne buffer en ons abonneren op het geval van leesbare gegevens in de verbinding:

Het is eenvoudiger met Channel.writer () omdat we de goroutine kunnen uitvoeren en de buffer alleen kunnen toewijzen als we het pakket gaan verzenden:

Merk op dat we geen gevallen behandelen wanneer het besturingssysteem EAGAIN retourneert bij write () systeemaanroepen. We steunen op Go-runtime voor dergelijke gevallen, omdat het eigenlijk zeldzaam is voor dergelijke servers. Desondanks kan het indien nodig op dezelfde manier worden behandeld.

Na het lezen van de uitgaande pakketten van ch.send (een of meerdere), zal de schrijver zijn operatie voltooien en de goroutinestapel en de verzendbuffer vrijmaken.

Perfect! We hebben 48 GB bespaard door de stapel en I / O-buffers in twee continu lopende goroutines te verwijderen.

3.3. Controle van middelen

Een groot aantal verbindingen omvat niet alleen een hoog geheugenverbruik. Bij het ontwikkelen van de server ondervonden we herhaalde race-omstandigheden en deadlocks, vaak gevolgd door de zogenaamde zelf-DDoS - een situatie waarin de applicatieclients ongebreideld probeerden verbinding te maken met de server waardoor deze nog meer werd verbroken.

Als we bijvoorbeeld om een ​​of andere reden plotseling geen ping / pong-berichten konden verwerken, maar de handler van inactieve verbindingen dergelijke verbindingen bleef sluiten (ervan uitgaande dat de verbindingen waren verbroken en daarom geen gegevens verstrekten), leek de client elke N verbinding te verbreken. seconden en probeerde opnieuw verbinding te maken in plaats van te wachten op gebeurtenissen.

Het zou geweldig zijn als de vergrendelde of overbelaste server gewoon stopt met het accepteren van nieuwe verbindingen en de balancer ervoor (bijvoorbeeld nginx) het verzoek doorgeeft aan de volgende serverinstantie.

Bovendien, ongeacht de serverbelasting, als alle clients ons om welke reden dan ook plotseling een pakket willen sturen (vermoedelijk door een bug), zal de eerder opgeslagen 48 GB weer worden gebruikt, omdat we daadwerkelijk terugkeren naar de oorspronkelijke status van de goroutine en de buffer per verbinding.

Goroutine zwembad

We kunnen het aantal pakketten dat tegelijkertijd wordt verwerkt beperken met behulp van een goroutine-pool. Dit is hoe een naïeve implementatie van zo'n pool eruit ziet:

Nu ziet onze code met netpoll er als volgt uit:

Dus nu lezen we het pakket niet alleen bij het verschijnen van leesbare gegevens in de socket, maar ook bij de eerste mogelijkheid om de gratis goroutine in de pool te gebruiken.

Op dezelfde manier zullen we Verzenden () wijzigen:

In plaats van go ch.writer (), willen we schrijven in een van de hergebruikte goroutines. Voor een pool van N-goroutines kunnen we dus garanderen dat met N-aanvragen die gelijktijdig worden behandeld en de aangekomen N + 1, we geen N + 1-buffer toewijzen voor lezen. Met de goroutine-pool kunnen we ook Accept () en Upgrade () van nieuwe verbindingen beperken en de meeste situaties met DDoS vermijden.

3.4. Upgrade zonder kopie

Laten we een beetje afwijken van het WebSocket-protocol. Zoals eerder vermeld, schakelt de client over naar het WebSocket-protocol met behulp van een HTTP Upgrade-verzoek. Zo ziet het eruit:

Dat wil zeggen dat we in ons geval het HTTP-verzoek en de headers alleen nodig hebben om over te schakelen naar het WebSocket-protocol. Deze kennis en wat is opgeslagen in de http.Request suggereert dat we omwille van de optimalisatie waarschijnlijk onnodige toewijzingen en kopieën bij de verwerking van HTTP-aanvragen kunnen weigeren en de standaard net / http-server kunnen verlaten.

De http.Request bevat bijvoorbeeld een veld met dezelfde kopteksttype dat onvoorwaardelijk is gevuld met alle verzoekkoppen door gegevens uit de verbinding naar de waardenreeksen te kopiëren. Stelt u zich eens voor hoeveel extra gegevens in dit veld kunnen worden bewaard, bijvoorbeeld voor een grote cookiekop.

Maar wat te nemen?

WebSocket-implementatie

Helaas lieten alle bibliotheken die bestonden ten tijde van onze serveroptimalisatie ons toe om alleen een upgrade uit te voeren voor de standaard net / http-server. Bovendien maakte geen van de (twee) bibliotheken het mogelijk om alle bovenstaande lees- en schrijfoptimalisaties te gebruiken. Om deze optimalisaties te laten werken, moeten we een vrij lage API hebben om met WebSocket te werken. Om de buffers opnieuw te gebruiken, hebben we de procotol-functies nodig om er zo uit te zien:

func ReadFrame (io.Reader) (Frame, fout)
func WriteFrame (io.Writer, Frame) fout

Als we een bibliotheek met een dergelijke API hadden, zouden we pakketten als volgt uit de verbinding kunnen lezen (het schrijven van pakketten zou er hetzelfde uitzien):

Kortom, het was tijd om onze eigen bibliotheek te maken.

github.com/gobwas/ws

In het ideale geval is de ws-bibliotheek zo geschreven dat de protocollogica niet aan gebruikers wordt opgelegd. Alle lees- en schrijfmethoden accepteren standaard io.Reader- en io.Writer-interfaces, waardoor het mogelijk is om buffering of andere I / O-wrappers te gebruiken.

Naast upgrade-aanvragen van standaard net / http, ondersteunt ws zero-copy upgrade, de afhandeling van upgrade-aanvragen en het overschakelen naar WebSocket zonder geheugentoewijzingen of kopieën. ws.Upgrade () accepteert io.ReadWriter (net.Conn implementeert deze interface). Met andere woorden, we kunnen de standaard net.Listen () gebruiken en de ontvangen verbinding van ln.Accept () onmiddellijk overbrengen naar ws.Upgrade (). De bibliotheek maakt het mogelijk om aanvraaggegevens te kopiëren voor toekomstig gebruik in de applicatie (bijvoorbeeld Cookie om de sessie te verifiëren).

Hieronder staan ​​benchmarks voor de verwerking van Upgrade-aanvragen: standaard net / http-server versus net.Listen () met nulkopie-upgrade:

BenchmarkUpgradeHTTP 5156 ns / op 8576 B / op 9 allocaties / op
BenchmarkUpgradeTCP 973 ns / op 0 B / op 0 alloceert / op

Overschakelen naar ws en zero-copy upgrade heeft ons nog eens 24 GB bespaard - de ruimte die is toegewezen aan I / O-buffers bij verwerking door de net / http-handler.

3.5. Samenvatting

Laten we de optimalisaties structureren waarover ik u heb verteld.

  • Een gelezen goroutine met een buffer erin is duur. Oplossing: netpoll (epoll, kqueue); gebruik de buffers opnieuw.
  • Een schrijf-goroutine met een buffer erin is duur. Oplossing: start de goroutine indien nodig; gebruik de buffers opnieuw.
  • Met een storm van verbindingen werkt netpoll niet. Oplossing: gebruik de goroutines opnieuw met de limiet op hun aantal.
  • net / http is niet de snelste manier om Upgrade naar WebSocket af te handelen. Oplossing: gebruik de nulkopie-upgrade op een blote TCP-verbinding.

Dat is hoe de servercode eruit zou kunnen zien:

4. Conclusie

Voortijdige optimalisatie is de oorzaak van alle kwaad (of in ieder geval het meeste ervan) in de programmering. Donald Knuth

Natuurlijk zijn de bovenstaande optimalisaties relevant, maar niet in alle gevallen. Als de verhouding tussen vrije bronnen (geheugen, CPU) en het aantal online verbindingen bijvoorbeeld vrij hoog is, heeft optimalisatie waarschijnlijk geen zin. U kunt echter veel baat hebben bij het weten waar en wat u kunt verbeteren.

Bedankt voor uw aandacht!

5. Referenties

  • https://github.com/mailru/easygo
  • https://github.com/gobwas/ws
  • https://github.com/gobwas/ws-examples
  • https://github.com/gobwas/httphead
  • Russische versie van dit artikel