Milijonas žiniatinklio kišenių ir eik

Sveiki visi! Mano vardas Sergejus Kamardinas ir aš esu „Mail.Ru.“ kūrėjas.

Šis straipsnis yra apie tai, kaip sukūrėme didelės apkrovos „WebSocket“ serverį su „Go“.

Jei esate susipažinęs su „WebSockets“, tačiau mažai ką žinote apie „Go“, tikiuosi, kad šis straipsnis vis tiek jums bus įdomus, kalbant apie idėjas ir metodus, kaip optimizuoti našumą.

1. Įvadas

Norint apibrėžti mūsų istorijos kontekstą, reikėtų pasakyti keletą žodžių apie tai, kodėl mums reikalingas šis serveris.

„Mail.Ru“ turi daug valstybinių sistemų. Vartotojo el. Pašto saugykla yra viena iš jų. Yra keli būdai, kaip sekti būsenos pokyčius sistemoje - ir apie sistemos įvykius. Dažniausiai tai vyksta periodinėmis sistemos apklausomis arba sistemos pranešimais apie jos būsenos pokyčius.

Abu būdai turi savo privalumų ir trūkumų. Bet kai kalbama apie paštą, tuo greičiau vartotojas gauna naujus laiškus, tuo geriau.

Pašto apklausa apima apie 50 000 HTTP užklausų per sekundę, iš kurių 60% grąžina 304 būseną, ty pašto dėžutėje pokyčių nėra.

Todėl, siekiant sumažinti serverių apkrovą ir pagreitinti pašto pristatymą vartotojams, buvo nuspręsta iš naujo sugalvoti ratą, parašius leidėjo-abonento serverį (dar vadinamą autobusu, pranešimų makleriu ar renginių- kanalas), kuris gautų pranešimus apie būsenos pasikeitimus, viena vertus, ir tokių pranešimų prenumeratą.

Anksčiau:

Dabar:

Pirmoji schema parodo, kokia ji buvo anksčiau. Naršyklė periodiškai apklausė API ir klausė apie saugyklos (pašto dėžutės paslaugos) pakeitimus.

Antroje schemoje aprašoma naujoji architektūra. Naršyklė užmezga „WebSocket“ ryšį su pranešimų API, kuri yra magistralės serverio klientas. Gavusi naują el. Laišką, „Storage“ siunčia pranešimą apie tai „Bus“ (1), o „Bus“ - savo abonentams. API nustato ryšį, norėdamas išsiųsti gautą pranešimą, ir siunčia jį į vartotojo naršyklę (3).

Taigi šiandien mes kalbėsime apie API arba „WebSocket“ serverį. Žvelgdamas į priekį pasakysiu jums, kad serveris turės apie 3 milijonus internetinių jungčių.

2. Idiomatinis būdas

Pažiūrėkime, kaip mes pritaikysime tam tikras serverio dalis naudodami paprastas „Go“ funkcijas be jokių optimizacijų.

Prieš pradėdami dirbti su net / http, pakalbėkime apie tai, kaip mes siųsime ir gausime duomenis. Duomenys, esantys virš „WebSocket“ protokolo (pvz., JSON objektai), toliau bus vadinami paketais.

Pradėkime įgyvendinti „Channel“ struktūrą, kurioje bus tokių paketų siuntimo ir gavimo per „WebSocket“ ryšį logika.

2.1. Kanalo sandara

Norėčiau atkreipti jūsų dėmesį į tai, kad buvo pradėtos dvi skaitymo ir rašymo bendrosios programos. Kiekvienas įprastas daiktas reikalauja savo atminties krūvos, kurios pradinis dydis gali būti nuo 2 iki 8 KB, atsižvelgiant į operacinę sistemą ir „Go“ versiją.

Atsižvelgiant į aukščiau paminėtą 3 milijonų internetinių jungčių skaičių, visoms jungtims mums reikės 24 GB atminties (su 4 KB krūve). Tai be kanalo struktūrai skirtos atminties, išeinančių paketų ch.send ir kitų vidinių laukų.

2.2. I / O bendrosios programos

Pažvelkime į „skaitytojo“ įgyvendinimą:

Čia mes naudojame „bufio.Reader“, kad sumažintume „read ()“ įsijungimų skaičių ir perskaitytume tiek, kiek leidžia bufio buferio dydis. Tikimės, kad begalinėje kilpoje bus naujų duomenų. Atminkite žodžius: tikėkitės, kad bus naujų duomenų. Prie jų grįšime vėliau.

Paliksime nuošalyje gaunamų paketų analizę ir apdorojimą, nes tai nėra svarbu optimizavimams, apie kuriuos mes kalbėsime. Tačiau „buf“ verta mūsų dėmesio dabar: pagal nutylėjimą tai yra 4 KB, tai reiškia dar 12 GB atminties mūsų jungtims. Panaši situacija ir su „rašytoju“:

Mes pakartojame išeinančių paketų kanalą c.send ir užrašome juos į buferį. Tai, kaip jau gali atspėti mūsų dėmesingi skaitytojai, yra dar 4 KB ir 12 GB atminties mūsų 3 milijonams jungčių.

2.3. HTTP

Mes jau turime paprastą „Channel“ diegimą, dabar turime gauti „WebSocket“ ryšį, kad galėtume dirbti. Kadangi mes vis dar esame „Idiomatinio kelio“ antraštėje, darykime tai atitinkamai.

Pastaba: Jei nežinote, kaip veikia „WebSocket“, reikia paminėti, kad klientas perjungia „WebSocket“ protokolą naudodamas specialų HTTP mechanizmą, vadinamą „Upgrade“. Sėkmingai apdoroję atnaujinimo užklausą, serveris ir klientas naudoja TCP ryšį keisdami dvejetainius „WebSocket“ kadrus. Čia yra rėmo struktūros aprašymas jungties viduje.

Atminkite, kad „http.ResponseWriter“ skiria atmintį „bufio.Reader“ ir „bufio.Writer“ (abu su 4 KB buferiu) * http.Request inicijavimui ir tolesniam atsakymo rašymui.

Nepaisant naudojamos „WebSocket“ bibliotekos, po sėkmingo atsakymo į „Upgrade“ užklausą serveris gauna įvesties ir išvesties buferius kartu su TCP ryšiu po „responseWriter.Hijack“ () skambučio.

Užuomina: kai kuriais atvejais „go: link“ vardas gali būti naudojamas norint grąžinti buferius į „sync.Pool“ tinkle / http per skambučio tinklą / http.putBufio {Reader, Writer}.

Taigi 3 milijonams jungčių mums reikia dar 24 GB atminties.

Taigi, iš viso 72 GB atminties programai, kuri dar nieko nedaro!

3. Optimizavimas

Peržiūrėkime tai, apie ką kalbėjome įžanginėje dalyje, ir prisiminkime, kaip elgiasi vartotojas. Perjungęs į „WebSocket“, klientas išsiunčia paketą su atitinkamais įvykiais arba, kitaip tariant, užsisako įvykius. Tuomet (neatsižvelgdamas į techninius pranešimus, tokius kaip ping / pong), klientas gali nieko daugiau nepasiųsti per visą ryšio laiką.

Ryšio laikas gali trukti nuo kelių sekundžių iki kelių dienų.

Taigi ilgiausiai mūsų „Channel.reader“ () ir „Channel.writer“ () laukia duomenų tvarkymo, norėdami juos priimti ar siųsti. Kartu su jais laukiama po 4 KB I / O buferių.

Dabar akivaizdu, kad tam tikrus dalykus galima padaryti geriau, ar ne?

3.1. „Netpoll“

Ar prisimeni „Channel.reader ()“ diegimą, kuris tikėjosi gauti naujus duomenis užrakinus jungtį.Read () skambinti „bufio.Reader.Read“ () viduje? Jei ryšyje buvo duomenų, „Go runtime“ „pažadino“ mūsų goroutine ir leido jai perskaityti kitą paketą. Po to goroutine vėl užrakinta, tikėdamasi naujų duomenų. Pažiūrėkime, kaip „Go runtime“ supranta, kad gorutinas turi būti „pažadintas“.

Jei pažvelgsime į conn.Read () įgyvendinimą, pamatysime net.netFD.Read () skambutį jo viduje:

„Go“ naudoja lizdus neužblokuojančiu režimu. EAGAIN sako, kad lizde nėra duomenų, o ne tam, kad būtų galima užrakinti skaitant iš tuščio lizdo, OS grąžina mums valdymą.

Iš jungties failo aprašo matome perskaitytą () sistemos kvietimą. Jei skaitymas grąžina EAGAIN klaidą, vykdymo laikas verčia pollDesc.waitRead () skambinti:

Jei gilinsimės giliau, pamatysime, kad „netpoll“ yra įdiegtas naudojant „epoll“ „Linux“ ir „kqueue“ - BSD. Kodėl gi nenaudodami to paties požiūrio savo jungtyse? Galėtume skirti skaitymo buferį ir pradėti skaityti gorutine tik tada, kai to tikrai reikia: kai lizde yra tikrai skaitomų duomenų.

Github.com/golang/go yra „netpoll“ funkcijų eksporto problema.

3.2. Atsikratyti gorutinų

Tarkime, kad turime „netpoll“ diegimą „Go“. Dabar galime išvengti „Channel.reader ()“ bendrosios programos paleidimo iš vidinio buferio ir užsiprenumeruoti skaitomų duomenų ryšį:

Su „Channel.writer“ () lengviau, nes mes galime paleisti „goroutine“ ir paskirstyti buferį tik tada, kai ketiname išsiųsti paketą:

Atkreipkite dėmesį, kad mes nenagrinėjame atvejų, kai operacinė sistema grąžina EAGAIN rašydama () sistemos skambučius. Tokiems atvejams mes pasikliaujame „Go“ veikimo laiku, nes tokio tipo serveriuose tai iš tikrųjų yra reta. Nepaisant to, prireikus, su ja galima būtų elgtis taip pat.

Perskaitęs išeinančius paketus iš ch.send (vieno ar kelių), rašytojas baigs savo darbą ir išlaisvins bendrą rietuvę bei siuntimo buferį.

Puikus! Sutaupėme 48 GB, atsikratydami krūvos ir įvesties / išvesties buferių, esančių dviejuose nepertraukiamai veikiančiuose gorutinuose.

3.3. Išteklių kontrolė

Daugybė jungčių reikalauja ne tik daug atminties. Kurdami serverį, mes patyrėme pakartotines lenktynių sąlygas ir aklavietes, kurias dažnai lydėjo vadinamieji „self-DDoS“ - situacija, kai programų klientai sėsliai bandė prisijungti prie serverio, taip dar labiau sulaužydami.

Pavyzdžiui, jei dėl kažkokių priežasčių staiga nepajėgėme susitvarkyti su „ping / pong“ žinutėmis, bet neveikiančių ryšių tvarkytojas ir toliau uždarinėjo tokius ryšius (tarkime, kad ryšiai nutrūko ir nepateikė duomenų), klientas prarado ryšį kas N sekundžių ir bandė vėl prisijungti, o ne laukti įvykių.

Būtų puiku, jei užrakintas ar perkrautas serveris tiesiog nustotų priimti naujus ryšius, o prieš jį esantis balansavimo įrenginys (pavyzdžiui, „nginx“) perduotų prašymą kitai serverio instancijai.

Be to, nepaisant serverio apkrovos, jei visi klientai staiga nori atsiųsti mums paketą dėl kokių nors priežasčių (greičiausiai dėl klaidos), anksčiau sutaupyti 48 GB bus vėl naudojami, nes mes iš tikrųjų grįšime į pradinę būseną. kiekvienos jungties vertės ir buferio.

Puošnus baseinas

Mes galime apriboti paketų, tvarkomų vienu metu, skaičių naudojant įprastą baseiną. Štai kaip atrodo naivus tokio baseino įgyvendinimas:

Dabar mūsų kodas su „netpoll“ atrodo taip:

Taigi dabar mes skaitome paketą ne tik tada, kai duomenų duomenys pasirodo lizde, bet ir pirmą kartą pasinaudoję nemokama goroutine baseine.

Panašiai pakeisime Siųsti ():

Užuot rašę ch.writer (), norime parašyti viename iš pakartotinai naudojamų goroutine. Taigi, jei naudojate N bendrosios programos baseiną, galime garantuoti, kad tuo pačiu metu tvarkant N užklausas ir gavus N + 1, mes nepaskirsime N + 1 buferio skaitymui. Bendras baseinas taip pat leidžia apriboti naujų jungčių priėmimą () ir atnaujinimą () ir išvengti daugumos situacijų naudojant DDoS.

3.4. Nulio kopijos atnaujinimas

Šiek tiek nukrypkime nuo „WebSocket“ protokolo. Kaip jau buvo minėta, klientas perjungia į „WebSocket“ protokolą naudodamas „HTTP Upgrade“ užklausą. Kaip tai atrodo:

T. y., Mūsų atveju mums reikalinga tik HTTP užklausa ir jos antraštės, norint pereiti prie „WebSocket“ protokolo. Šios žinios ir tai, kas saugoma „http.Request“ viduje, rodo, kad optimizuodami tikriausiai galime atsisakyti nereikalingų asignavimų ir kopijų apdorodami HTTP užklausas ir atsisakyti standartinio „net / http“ serverio.

Pavyzdžiui, „http.Request“ yra to paties pavadinimo tipo antraštės laukas, kuris besąlygiškai užpildomas visomis užklausų antraštėmis, kopijuojant duomenis iš ryšio į reikšmių eilutes. Įsivaizduokite, kiek papildomų duomenų galėtų būti šiame lauke, pavyzdžiui, didelės apimties slapuko antraštėje.

Bet ką pasiimti mainais?

„WebSocket“ diegimas

Deja, visos mūsų serverio optimizavimo metu egzistavusios bibliotekos leido mums atnaujinti tik standartinį net / http serverį. Be to, nė viena iš (dviejų) bibliotekų negalėjo naudoti visų aukščiau išvardytų skaitymo ir rašymo optimizacijų. Kad šios optimizacijos veiktų, mes turime turėti gana žemo lygio API, skirtą darbui su „WebSocket“. Norėdami pakartotinai naudoti buferius, mums reikia, kad procotolio funkcijos atrodytų taip:

„func ReadFrame“ („io.Reader“) (rėmelis, klaida)
„func WriteFrame“ („io.Writer“, „Frame“) klaida

Jei turėtume biblioteką su tokia API, paketus iš ryšio galėtume perskaityti taip (paketų rašymas atrodytų taip pat):

Trumpai tariant, atėjo laikas sukurti savo biblioteką.

github.com/gobwas/ws

Ideologiškai ws biblioteka buvo parašyta taip, kad vartotojams nereikėtų primesti savo protokolo operacijos logikos. Visi skaitymo ir rašymo metodai priima standartines „io.Reader“ ir „io.Writer“ sąsajas, kurios leidžia naudoti arba nenaudoti buferio ar kitų I / O įvyniojimų.

Be standartinių net / http atnaujinimo užklausų, „ws“ palaiko ir nulinės kopijos atnaujinimą, atnaujinimo užklausų tvarkymą ir perjungimą į „WebSocket“ be atminties paskirstymo ar kopijavimo. „ws.Upgrade“ () priima „io.ReadWriter“ („net.Conn“ įgyvendina šią sąsają). Kitaip tariant, mes galėtume naudoti standartinį net.Listen () ir gautą ryšį iš ln.Accept () nedelsdami perkelti į ws.Upgrade (). Biblioteka suteikia galimybę nukopijuoti bet kokius užklausos duomenis, kad juos ateityje būtų galima naudoti programoje (pvz., Slapukas sesijai patvirtinti).

Žemiau pateikiami atnaujinimo užklausų apdorojimo etalonai: standartinis „net / http“ serveris, palyginti su „net.Listen“.

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

Perjungimas į „ws“ ir nulinio egzemplioriaus atnaujinimą sutaupė dar 24 GB - vietos, skirtos I / O buferiams, paprašius jas apdoroti „net / http“ tvarkyklėje.

3.5. Santrauka

Suprojektuokime optimizavimus, apie kuriuos jums sakiau.

  • Perskaitytas gorutinas su buferiu viduje yra brangus. Sprendimas: netpoll (epoll, kqueue); pakartotinai naudoti buferius.
  • Gorutinos rašymas su buferiu yra brangus. Sprendimas: prireikus paleiskite gorutiną; pakartotinai naudoti buferius.
  • Dėl ryšių audros „netpoll“ neveiks. Sprendimas: pakartotinai naudokite gorutines, neviršydami jų skaičiaus.
  • „net / http“ nėra greičiausias būdas atnaujinti „WebSocket“. Sprendimas: naudokite nulinio egzemplioriaus atnaujinimą, naudodami TCP jungtį.

Tai galėtų atrodyti serverio kodas:

4. Išvada

Per ankstyvas optimizavimas yra viso blogo (ar bent jau didžiojo jo) priežastis, susijusi su programavimu. Donaldas Knutas

Žinoma, aukščiau išvardytos optimizacijos yra aktualios, tačiau ne visais atvejais. Pvz., Jei laisvųjų išteklių (atminties, procesoriaus) ir internetinių ryšių skaičiaus santykis yra gana didelis, turbūt nėra prasmės optimizuoti. Tačiau žinodami, kur ir ką reikia patobulinti, galite gauti daug naudos.

Ačiū už dėmesį!

5. Nuorodos

  • https://github.com/mailru/easygo
  • https://github.com/gobwas/ws
  • https://github.com/gobwas/ws-examples
  • https://github.com/gobwas/httphead
  • Šio straipsnio rusiška versija