Милион WebSockets и отидете

Здравейте всички! Казвам се Сергей Камардин и съм разработчик в Mail.Ru.

Тази статия е за това как разработихме сървъра с високо натоварване WebSocket с Go.

Ако сте запознати с WebSockets, но знаете малко за Go, надявам се, че все пак ще намерите тази статия интересна от гледна точка на идеи и техники за оптимизация на производителността.

1. Въведение

За да определим контекста на нашата история, трябва да се каже няколко думи за това, защо се нуждаем от този сървър.

Mail.Ru има много състоятелни системи. Потребителското съхранение на имейл е едно от тях. Има няколко начина да следите промените в състоянието в системата и за събитията в системата. Най-вече това става или чрез периодично системно проучване или известия на системата за промените в него.

И двата начина имат своите плюсове и минуси. Но когато става въпрос за поща, колкото по-бързо потребителят получава нова поща, толкова по-добре.

Изследването на пощата включва около 50 000 HTTP заявки в секунда, 60% от които връщат състоянието 304, което означава, че няма промени в пощенската кутия.

Следователно, за да се намали натоварването на сървърите и да се ускори доставката на поща до потребителите, беше взето решение да се измисли отново колелото, като се напише сървър на издател-абонат (известен също като автобус, посредник на съобщения или събитие, канал), които биха получавали известия за промени в състоянието от една страна и абонаменти за такива известия от друга.

Преди това:

Сега:

Първата схема показва какво е било преди. Браузърът периодично проучва API и пита за промените в съхранението (пощенска кутия).

Втората схема описва новата архитектура. Браузърът установява връзка с WebSocket с API за уведомяване, който е клиент на Bus сървъра. След получаване на нов имейл, Storage изпраща известие за него на Bus (1), а Bus на своите абонати (2). API определя връзката за изпращане на полученото известие и го изпраща до браузъра на потребителя (3).

Затова днес ще говорим за API или сървъра WebSocket. В бъдеще ще ви кажа, че сървърът ще има около 3 милиона онлайн връзки.

2. Идиоматичният начин

Нека да видим как бихме внедрили определени части от нашия сървър, използвайки обикновени функции Go, без никакви оптимизации.

Преди да продължим с net / http, нека поговорим за това как ще изпращаме и получаваме данни. Данните, които стоят над протокола WebSocket (например JSON обекти), ще бъдат наричани по-долу като пакети.

Нека започнем да прилагаме структурата на канала, която ще съдържа логиката на изпращане и получаване на такива пакети през връзката WebSocket.

2.1. Структура на канала

Бих искал да насоча вниманието ви към стартирането на две програми за четене и писане. Всяка goutut изисква собствен стек от памет, който може да има начален размер от 2 до 8 KB в зависимост от операционната система и Go версията.

По отношение на гореспоменатия брой от 3 милиона онлайн връзки, ще ни трябват 24 GB памет (със стека от 4 KB) за всички връзки. И това е без паметта, разпределена за структурата на канала, изходящите пакети ch.send и други вътрешни полета.

2.2. I / O goroutines

Нека да разгледаме изпълнението на „четеца“:

Тук използваме bufio.Reader, за да намалим броя на прочетените () системни обаждания и да четем толкова, колкото е разрешено от размера на буфера. В рамките на безкрайния цикъл очакваме да дойдат нови данни. Моля, запомнете думите: очаквайте да дойдат нови данни. Ще се върнем към тях по-късно.

Ще оставим настрана анализа и обработката на входящите пакети, тъй като не е важно за оптимизациите, за които ще говорим. Въпреки това, buf си заслужава нашето внимание сега: по подразбиране той е 4 KB, което означава още 12 GB памет за нашите връзки. Подобна ситуация има и с „писателя“:

Итератираме през изходящите пакети канал c.send и ги записваме в буфера. Това е, както нашите внимателни читатели вече могат да се досещат, още 4 KB и 12 GB памет за нашите 3 милиона връзки.

2.3. HTTP

Вече имаме проста реализация на канала, сега трябва да получим връзка за WebSocket, с която да работим. Тъй като все още сме под заглавието Идиоматичен път, нека го направим по съответния начин.

Забележка: Ако не знаете как работи WebSocket, трябва да се отбележи, че клиентът преминава към протокола WebSocket чрез специален HTTP механизъм, наречен Upgrade. След успешната обработка на заявка за надстройка, сървърът и клиентът използват TCP връзката, за да обменят двоични кадри WebSocket. Ето описание на структурата на рамката вътре във връзката.

Моля, обърнете внимание, че http.ResponseWriter прави разпределение на паметта за bufio.Reader и bufio.Writer (и двете с 4 KB буфер) за * http.Request инициализация и допълнително писане на отговор.

Независимо от използваната библиотека WebSocket, след успешен отговор на заявката за надстройка, след повикване responseWriter.Hijack () сървърът получава I / O буфери заедно с TCP връзката.

Съвет: в някои случаи името go: link може да се използва за връщане на буферите към sync.Pool вътре в net / http чрез мрежата за повикване / http.putBufio {Reader, Writer}.

По този начин се нуждаем от още 24 GB памет за 3 милиона връзки.

И така, общо 72 GB памет за приложението, което все още не прави нищо!

3. Оптимизации

Нека да разгледаме това, за което говорихме в уводната част и да си припомним как се държи потребителската връзка. След преминаване към WebSocket, клиентът изпраща пакет със съответните събития или с други думи се абонира за събития. Тогава (без да се вземат предвид техническите съобщения като ping / pong), клиентът може да не изпраща нищо друго за целия живот на връзката.

Животът на връзката може да продължи от няколко секунди до няколко дни.

Така че най-много време Channel.reader () и Channel.writer () чакат обработката на данни за получаване или изпращане. Заедно с тях чакащите са I / O буфери от 4 KB всеки.

Сега е ясно, че някои неща могат да се направят по-добре, нали?

3.1. Netpoll

Спомняте ли си внедряването на Channel.reader (), което очакваше да дойдат нови данни, като се блокира на повикването conn.Read () вътре в bufio.Reader.Read ()? Ако във връзка има данни, Go runtime "събуди" нашата gout и му позволи да прочете следващия пакет. След това groutut отново се заключи, докато очакваше нови данни. Нека видим как Go Runtime разбира, че трябва да се „събуди“.

Ако погледнем внедряването на conn.Read (), ще видим повикването net.netFD.Read () вътре в него:

Go използва гнезда в режим на блокиране. EAGAIN казва, че няма данни в гнездото и за да не се заключва при четене от празния гнездо, ОС ни връща контрола.

Виждаме четене () syscall от дескриптора на файла за връзка. Ако четенето връща грешката EAGAIN, срещата по време на изпълнение извиква pollDesc.waitRead ():

Ако копаем по-дълбоко, ще видим, че netpoll се осъществява с помощта на epoll в Linux и kqueue в BSD. Защо да не използваме същия подход за нашите връзки? Можем да разпределим буфер за четене и да стартираме goroutut за четене само когато това е наистина необходимо: когато има наистина четими данни в сокета.

На github.com/golang/go има проблем с експортирането на функции на netpoll.

3.2. Да се ​​отървем от goututines

Да предположим, че имаме реализация на netpoll за Go. Сега можем да избегнем стартирането на gorout на Channel.reader () с вътрешния буфер и да се абонираме за събитието на четими данни във връзката:

По-лесно е с Channel.writer (), защото можем да стартираме goroutut и да разпределим буфера само когато ще изпратим пакета:

Обърнете внимание, че не обработваме случаи, когато операционната система връща EAGAIN при системни разговори write (). Ние се облягаме на време за изпълнение на Go за такива случаи, защото всъщност това е рядкост за такъв тип сървъри. Независимо от това, може да се работи по същия начин, ако е необходимо.

След като прочете изходящите пакети от ch.send (един или няколко), писателят ще завърши операцията си и ще освободи стека на goroutine и буфера за изпращане.

Чудесно! Спестихме 48 GB, като се отървем от стека и I / O буферите вътре в две непрекъснато работещи goututines.

3.3. Контрол на ресурсите

Големият брой връзки включва не само висока консумация на памет. При разработването на сървъра ние изпитвахме многократни условия на състезание и задънена улица, често последвани от т. Нар. Self-DDoS - ситуация, когато клиентите на приложения настойчиво се опитват да се свържат със сървъра, като по този начин го разбиват още повече.

Например, ако по някаква причина изведнъж не успяхме да обработим пинг / понг съобщения, но обработващият неактивни връзки продължи да затваря такива връзки (предположим, че връзките са прекъснати и следователно не предоставят данни), клиентът изглежда загуби връзка с всеки N секунди и се опита да се свърже отново, вместо да чака събития.

Би било чудесно, ако заключеният или претоварен сървър просто спре да приема нови връзки и балансьорът преди него (например nginx) предаде заявка на следващия сървър.

Освен това, независимо от натоварването на сървъра, ако всички клиенти изведнъж искат да ни изпратят пакет по някаква причина (вероятно по причина на грешка), запазените преди това 48 GB ще бъдат отново полезни, тъй като всъщност ще се върнем към първоначалното състояние на groutut и буфера за всяка връзка.

Goroutine пул

Можем да ограничим броя обработени пакети едновременно с пул goroutine. Ето как изглежда наивното изпълнение на такъв пул:

Сега нашият код с netpoll изглежда по следния начин:

Така че сега четем пакета не само при появата на четими данни в сокета, но и при първата възможност да се възползвате от безплатната програма в пула.

По подобен начин ще променим Send ():

Вместо go ch.writer (), ние искаме да пишем в една от повторно използваните goututines. По този начин, за група от N goroutines можем да гарантираме, че с N заявки, обработвани едновременно и пристигналите N + 1, няма да разпределим N + 1 буфер за четене. Пулът на goroutut също ни позволява да ограничим Accept () и Upgrade () на нови връзки и да избегнем повечето ситуации с DDoS.

3.4. Надстройка с нулево копиране

Нека се отклоним малко от протокола WebSocket. Както вече беше споменато, клиентът преминава към протокола WebSocket чрез заявка за надстройка на HTTP. Ето как изглежда:

Тоест в нашия случай се нуждаем от HTTP заявката и нейните заглавки само за преминаване към протокола WebSocket. Това знание и това, което се съхранява вътре в http.Request предполага, че с цел оптимизация, ние вероятно бихме могли да откажем ненужни разпределения и копия при обработка на HTTP заявки и да изоставим стандартния net / http сървър.

Например, http.Request съдържа поле с едноименния тип Header, което се попълва безусловно с всички заявки, като копира данни от връзката към низовете със стойности. Представете си колко допълнителни данни биха могли да се съхраняват в това поле, например за заглавка на бисквитки с голям размер.

Но какво да вземем в замяна?

Реализация на WebSocket

За съжаление, всички библиотеки, съществуващи по време на нашата оптимизация на сървъра, ни позволиха да направим надстройка само за стандартния net / http сървър. Освен това нито една от (две) библиотеки не позволи да се използват всички гореописани оптимизации за четене и запис. За да работят тези оптимизации, трябва да имаме доста нисък API за работа с WebSocket. За да използваме отново буферите, се нуждаем от функциите procotol, за да изглеждат така:

func ReadFrame (io.Reader) (кадър, грешка)
func WriteFrame (io.Writer, Frame) грешка

Ако имахме библиотека с такъв API, бихме могли да четем пакети от връзката, както следва (писането на пакети ще изглежда същото):

Накратко, беше време да направим собствена библиотека.

github.com/gobwas/ws

Идеологически ws библиотеката е написана така, че да не налага логиката на работа на протокола на потребителите. Всички методи за четене и запис приемат стандартни интерфейси io.Reader и io.Writer, което дава възможност да се използва или да не се използва буфериране или всякакви други I / O опаковки.

Освен заявки за надграждане от стандартни net / http, ws поддържа ъпгрейд с нулево копиране, обработката на заявки за надстройка и преминаване към WebSocket без разпределение на памет или копиране. ws.Upgrade () приема io.ReadWriter (net.Conn реализира този интерфейс). С други думи, бихме могли да използваме стандартния net.Listen () и да прехвърлим получената връзка от ln.Accept () веднага на ws.Upgrade (). Библиотеката дава възможност да се копират всякакви данни за заявка за бъдеща употреба в приложението (например „Бисквитка“ за проверка на сесията).

По-долу има показатели за обработка на заявки за надстройка: стандартен net / http сървър срещу net.Listen () с надстройка с нулево копие:

BenchmarkUpgradeHTTP 5156 ns / op 8576 B / op 9 алока / оп
BenchmarkUpgradeTCP 973 ns / op 0 B / op 0 alocs / op

Преминаването към ws и надстройката с нулево копие ни спести още 24 GB - пространството, отпуснато за I / O буфери при обработка на заявка от обработващия мрежата / http.

3.5. резюме

Нека да структурираме оптимизациите, за които ви разказах.

  • Прочетена goutut с буфер вътре е скъпа. Решение: netpoll (epoll, kqueue); използвайте отново буферите.
  • Gorout за писане с буфер вътре е скъпо. Решение: стартирайте goroutine, когато е необходимо; използвайте отново буферите.
  • С буря от връзки, netpoll няма да работи. Решение: използвайте повторно функциите с ограничението за техния брой.
  • net / http не е най-бързият начин да се справите с Upgrade to WebSocket. Решение: използвайте надстройката с нулево копиране при гола TCP връзка.

Ето как може да изглежда сървърният код:

4. Заключение

Преждевременната оптимизация е в основата на всяко зло (или поне повечето от него) в програмирането. Доналд Кнут

Разбира се, горните оптимизации са уместни, но не във всички случаи. Например, ако съотношението между безплатни ресурси (памет, процесор) и броя на онлайн връзките е доста високо, вероятно няма смисъл в оптимизирането. Можете обаче да се възползвате много от това да знаете къде и какво да подобрите.

Благодаря за вниманието!

5. Референции

  • https://github.com/mailru/easygo
  • https://github.com/gobwas/ws
  • https://github.com/gobwas/ws-examples
  • https://github.com/gobwas/httphead
  • Руска версия на тази статия