Comments 78
Потихоньку двигаю свой C# проект протокол-ориентированного сервера (к сожалению пока не компилируется, вчера не доборол реализацию простого пуллинга байт-буферов), в котором тоже использую то, что вы называете zero-copy upgrade. Грубо говоря, мне пришел буфер с байтами, из него сразу читаю некой машиной состояний данные, каждый шаг которой матчит ту или иную часть http запроса рукопожатия.
Вот хэндлер для чтения фрейма 13 версии из байт-буфера, основанного тоже на машине состояний, где один из шагов — разбор заголовка.
Кстати, пока что реализация однопоточная и обработка каждого пришедшего куска данных в байт-буффере происходит в потоке, вызванном из libuv (та, что из Node.js), но планирую добавить планировщик через похожий пул задач (или потоков), чтобы принимать подключения и данные в одном libuv-потоке, ставить в очередь на обработку, а обработку выполнять планировщиком по мере освобождения потоков.
Эх… хотелось бы уже допилить прототип до нормального состояния и прогнать нагрузочный тест, сколько выдержит такая архитектура (честно говоря, содранная, но сильно упрощенная из проекта netty). По предварительный тестам, рукопожатие около 100-150нс, а чтение фреймов — ориентировочно 1-2нс на каждый байт payload данных на один поток обработки. На i7 2.2GHz.
А вы не пробовали доверить построение конечного автомата компилятору, через async/await?
Асинхронный код умеет так же.
async ValueTask<(string name, string value)> ReadHeader() {
var headerName = await ReadUntilSpace(MAX_HEADER_LENGTH);
if (unknown header) {
await SkipUntilEOL();
return (null, null);
}
else {
var headerValue = await ReadUntilEOL();
return (headerName, headerValue);
}
}
Я не знаю C#, возможно ReadUntil* внутри содержит буффер, где копит данные «на следующие чтения» — тогда сисколов много не будет, но тогда будет плохо предсказуемое потребление памяти. Ну, еще я бы это не назвал конечным автоматом.
Вариант с автоматом не так зависит от количества хедеров и имеет более «ровное» потребление памяти одновременно.
— Ваш ReadUntilSpace что сделает? Не понятно. Допустим он завершится, тогда выполнение перейдет в SkipUntilEOL, который также никуда не сможет прочитать.
Ваш код будет работать, если Вы получили целостное Http сообщение.
А автомат, о котором я говорю, на шаге чтения названия заголовка просто остановится, прочитав Connecti, запишет, что заголовок Connection пока что еще матчится, но не до конца. Автомат закончит обработку буфера и все. При поступлении следующей порции байт-буфера «on: Upgrade\r\n........», автомат продолжит с сохраненного состояния, дочитает «on: », запомнит общее состояние автомата и что заголовок Connection заматчен успешно и переключится на следующий шаг чтения значения заголовка. Так обрабатывается все сообщение, всеми приходящими кусками. Декодирование веб-сокет фрейма работает почти аналогично, только сильно проще.
Э… Вы не обратили внимание на оператор await? Если в буфере неполный заголовок — то ReadUntilSpace вернет незавершенную задачу. Оператор await обнаружит это и остановит метод ReadHeader, он тоже вернет незавершенную задачу. Когда придет следующая часть, оба методв будут продолжены.
Говорю же, это и есть конечный автомат, только генерируемый компилятором.
Если используется системный проактор — то функция ReadUntilSpace
будет реализована как-то так:
private readonly StringBuilder sb = new StringBuilder();
private readonly byte[] rbuf = ...;
private int roff, rlen;
ValueTask<string> ReadUntilSpace(int maxLen) {
sb.Clear();
while (sb[sb.Length-1] != ' ' && sb.Length <= maxLen) { // Тут на самом деле надо еще и на EOL проверить, но это усложнит пример
if (roff == rlen) {
rlen = await stream.ReadAsync(rbuf, 0, rbuf.Length);
roff = 0;
if (rlen == 0) throw ...;
}
if (rbuf[roff] > 128) throw ...;
sb,Append((char)rbuff[roff++]); // HTTP работает только с базовым набором ASCII
}
sb.Length--;
return sb.ToString();
}
Вызов NetworkStream.ReadAsync
приведет к вызову Socket.BeginReceive
, который начнет асинхронную операцию, привязав ее к системному IOCP, после чего NetworkStream.ReadAsync
вернет незавершенную задачу. После завершения операции чтения в пуле потоков будет вызван Socket.EndReceive
, с последующей отметкой задачи, возвращенной NetworkStream.ReadAsync
как завершенной, что в свою очередь вызовет продолжение выполнения метода ReadUntilSpace
и далее по цепочке.
Если же писать свой реактор, пожертвовав скоростью ради памяти, то заполнение опустевшего буфера будет чуть сложнее:
private TaskCompletionSource<ArraySegment<byte>> readOperation;
ValueTask<string> ReadUntilSpace(int maxLen) {
// ...
if (roff == rlen) {
Debug.Assert(readOperation == null);
readOperation = new TaskCompletionSource<bool>();
await readOperation.Task;
if (rlen == 0) throw ...;
}
// ...
}
public bool WaitingForData => readOperation != null;
public void DataAvailable(byte[] buffer, int offset, int count) {
Debug.Assert(roff == rlen);
var op = readOperation;
readOperation = null;
rbuf = buffer;
roff = offset;
rlen = count;
op.SetResult(false);
}
Ragel targets C, C++ and ASM.
Где в этом списке C#?
Не знаю какое бревно на них упало и почему они вдруг решили выпилить поддержку C# и Java, но FSM — не картошка, старые версии не гниют…
Но у меня пару вопросов возникло, я не очень силен в высоконагруженных системах, но разве можно иметь 3 миллиона соединений на одной машине?
Насколкьо я помню, в IP адресации четыре поля:
- адрес отправителя
- порт отправителя
- адрес назначения
- порт назначени
И получается что количество уникальных соединений ограничивается количеством портов при наличии одного сетевого интерфейса: 64К. Или это по другому работает?
Но даже при всем при этом, каждое сетевое соединение — это файловый дескриптор, а их вроде тоже там ограниченное количество, в районе 300К, что на порядок выше ваших чисел.
Как это разруливается и считали вы системные затраты на каждое соединение?
Эти четыре поля должны быть уникальны вместе, а не по-отдельности. То есть 64К — это ограничение на количество параллельных соединений между клиентов и сервером, а не общее.
Вот с количеством файловых дескрипторов уже сложнее, на ненастроенном сервере в него можно запросто упереться при таких экспериментах. К счастью, на linux лимит настраивается, а на windows просто очень большой (16 миллионов на процесс).
client1:11 <-> server:1000
client2:11 <-> server:1000
client3:11 <-> server:1000
client4:11 <-> server:1000
А на каждый дескриптор же память тоже выделяется, вы её тут не считали. Я не знаю сколько, но там точно права доступа, адрес, тип. Или это не значительно?
Да, будет работать. Более того, оно так и работает! — номер порта на сервере остается тем же самым, который и прослушивался.
Памяти на адрес, тип и права доступа нужно куда меньше, чем на буфера отправки и получения :-) Конечно же, серверу, который держит миллион соединений, нужно много оперативной памяти, это очевидно.
Привет, Спасибо!
Теоретически, 3 миллиона соединений на одной машине могут жить. Но для распределения нагрузки и наличия запаса ресурсов мы распределяем соединения на несколько серверов. Сейчас это 8 машин, было 4. Но судя по цифрам, после оптимизаций мы смогли бы держать 3 миллиона соединений на одной машине: не было бы запаса по CPU, но с памятью было бы все ок. Но и там, возможно, можно было бы что-то докрутить.
Что касается адресации – вы, наверное, имеете в виду проблему портов, когда nginx не может спроксировать больше ~64K коннектов на локальный демон? Ее можно решать, как вы правильно сказали, добавлением виртуальных интерфейсов, либо, как это сделали мы – уйти от TCP-сокетов с адресацией по порту, в сторону UNIX-сокетов.
Лимит на открытые файловые дескрипторы – это "ручка", которая настраивается в Linux на процесс или на пользователя.
По цифрам могу сказать про память, что до оптимизаций сервер потреблял ~60Кбайт на соединение, после – 10Кбайт. При этом, можно крутить флажок GOGC в Go, который так же немного влияет на цифры потребления памяти.
Хм, а зачем в этой схеме nginx? Мне почему-то кажется, что если отказаться еще и от него — можно еще сильнее разгрузить сервера.
Конечно же, тут понадобится отдельный домен для вебсокет-соединений, но не думаю что это проблема.
Nginx в первую очередь берет на себя ssl. Плюс, мы запускаем несколько экземпляров сервера на go, чтобы при падении/локе/жестком рестарте одного остальные продолжали работать: nginx распределяет коннекты между экземплярами.
А что за демон для вебсокет-соединений?
Nginx в первую очередь берет на себя ssl.
А что go не умеет ssl? Или у go биндинги на openSSL другие?
nginx распределяет коннекты между экземплярами
Плюс, таймауты на чтение из клиентского коннекта, ограничение доступа к апстриму и т. д.
Можно конечно, переписать на Go логику, отлично работающую в nginx, но не уверен, что это принесет гигантский профит. Возможно, стоит попробовать – но сейчас есть более приоритетные задачи =)
Закрытие подключений, повторное использование socket, правильная работа с обработкой вновь поступающих подключений.
Оптимизированная работа с приемом и обработкой трафика (это не так просто как звучит, т.к. из удаленных источников данные могут приходить в довольно неожиданных порциях). На допущениях того что трафик в приложении идет через локальное подключение можно довольно сильно оптимизировать свой сервер обработчик.
«Fraud» трафик в конце концов.
Думаю что не целесообразно тратить кучу времени на реализацию полноценного web сервера когда есть уже готовые и проверенные временем решения (iis/http.sys под windows и nginx для остальных)
На допущениях того что трафик в приложении идет через локальное подключение можно довольно сильно оптимизировать свой сервер обработчик.
А еще это дополнительный оверхед. Так как юникс сокеты не бесплатны.
Думаю что не целесообразно тратить кучу времени на реализацию полноценного web сервера когда есть уже готовые и проверенные временем решения
Ну так я к чему и веду. Все это уже есть. Например, на яве в netty. Получается бросили кучу уисилий, написали велосипед и он всеравно будет медленней, чем готовые решения.
Эм, кажется мы друг друга не поняли. Мы же вроде не бросили кучу усилий и не писали велосипед на разработку web-сервера, а взяли nginx?
Ну по крайней, мере это что я вижу. Из описаного выше.
Дырок в функциональности Go нет. Дырка появилась бы во времени, которое пришлось бы потратить на призрачный профит переписывания функциональности nginx.
Ну и да, мы бросили усилия для того, чтобы в Go теперь тоже были высокопроизводительные вебсокеты =) На других языках они ведь тоже не сразу появились.
А вообще, что-то мне подсказывает, что в свете упоминания netty, уместно будет процитировать эту шутку:
A Google org once complained their rewrite from Java to Go had made things 10X slower until they noticed the graph was in micros not millis. https://t.co/2GtNfFvCPJ
— Rob Pike (@rob_pike) June 24, 2017
Дырок в функциональности Go нет.
А то что Вы написали сокеты, это не в счет? И дырки есть, иначе — зачем вам енджинкс? Очевидно, что Вы им закрываете недостающий фукнционал в вашей реализации вебсокетов. (я не про лоад балансинг, а про другие пункты что Вы описали).
уместно будет процитировать эту шутку
Ну Вы же понимаете, что дело не в языке, а в людях, которые пишут код.
И дырки есть, иначе — зачем вам енджинкс?
Кажется, это рекурсия!
ЗЫ: а у JVM время старта большое.
Или вы решили через вопрос целесообразности nginx перед нашим WebSocket-сервером решили привести к тому, что нецелесообразно было пилить WebSocket-сервер? =)
Все это уже есть. Например, на яве в netty. Получается бросили кучу уисилий, написали велосипед и он всеравно будет медленней, чем готовые решения.
Автор в том числе акцентирует внимание на zero-copy upgrade, смысл которого в том, чтобы не парсить Http заголовки, а сразу их анализировать и матчить. В netty дефолтная реализация в виде пайплайна будет выглядеть примерно так: HttpResponseEncoder + HttpRequestDecoder + HttpObjectAggregator + WebSocketsHandshaker — т.е. в netty как раз готовое решение будет далеко не zero-copy.
Но на netty можно написать свой хэндлер, который будет zero-copy, но тогда это та же работа, что они и сделали (почти, за исключением остальной инфраструктуры).
Go умеет SSL, при этом, если я не ошибаюсь, без binding'ов – т.е. своя реализация.
Извиняюсь, с телефона прочитал "домен" как "демон" =) Вопрос тогда не актуален.
В статье я не заметил ни слова о том как преодолено ограничение в количестве локальных портов.
Как Nginx может держать открытыми 3 млн внешних подключений?
Ведь все они приходят с одного внешнего порта (ip адреса) для которого драйвер tpc/ip будет использовать всего 65535 портов (к тому же часть из которых зарезервированы), которые даже после закрытия соединения еще какое то время удерживаются в ожидании повторных подключений с удаленного адреса.
Что касается адресации – вы, наверное, имеете в виду проблему портов, когда nginx не может спроксировать больше ~64K коннектов на локальный демон? Ее можно решать, как вы правильно сказали, добавлением виртуальных интерфейсов, либо, как это сделали мы – уйти от TCP-сокетов с адресацией по порту, в сторону UNIX-сокетов.
Как 3млн. ВНЕШНИХ подключений удерживаются Nginx?
То как они взаимодействуют с внутренними процессами, отдельный разговор (и тут у меня нет вопросов, т.к. решений множество, на что автор и указал в своем сообщении).
Помню на какой то конференции товарищи презентовали железку которая могла держать более 1млн. одновременных подключений и там была реализована черная магия, а на выходе (к обработчику подключений) множество небольших запросов с ключом сессии.
Например в Windows это можно сделать через NLB который действует на третьем уровне сетевого протокола (по сути работает на уровне драйвера, до выделения порта) и перераспределяет запросы на множество сетевых адаптеров (возможно и виртуальные), но это решение довольно не оптимально в плане производительности (наверняка есть супер железки которые выполняют подобные действия и одну из них как раз использовали для своего решения в mail.ru)
Поправка — куча 233 слова, не байта.
8 Кбайт – это который в зависимости от операционной системы и версии Go. В последних версиях и в Linux, если я не ошибаюсь, стек начинается с 2 Кбайт.
на процесс выделяется 338 8-байтовых слов
Судя по всему, речь идет об Erlang/Elixir? =)
Получается, (338-223) * 8 = 920
байт стека? Выходит, с учетом кучи (338 * 8 = 2704
) разницы нет? )
Вот в ponylang, например, акторы занимают 256 байт памяти… Но это уже совсем другая история =)
Угу, это я за любимый Erlang слегка подтапливаю:)
Спору нет – erlang для этого, очевидно, хорош. Не зря WhatsApp рекорды по соединениям на одном сервере с ним ставил =)
Спору нет – erlang для этого, очевидно, хорош. Не зря WhatsApp рекорды по соединениям на одном сервере с ним ставил =)
Ну он не так уж и хорош, если углубится в детали. Во-первых, им для этого пришлось допиливать виртуалку ерланга, так как у ерланга были проблемы с большими нагрузками.
Во-вторых, если посчитать нагрузку на ядро, то у них она всего лишь 9к рек-сек. Учитывая что это чат, это явно результаты ниже среднего.
conn -> connection,
ch -> channel,
pkt -> packet
пожалуйста не экономьте чернила.
смотришь потом на переменную с именем c
и думаешь… "хм… а это channel
или connection
?"
До конца прошли путь на го, опустившись на самое дно. У нас была похожая ситуация — написали сокет сервер над http://sophia.systems/ на nim — https://github.com/recoilme/pudge
Но по мере написания, я чувствовал что мы опускаемся все ниже и ниже, переходя к ручному управлению памятью и погрязая в разборках как работает ним с епол и тп. В какой то момент я подумал что если мы все равно пишем на nim как на c — то почему бы не взять сразу готовую либу на си (libevent) и не скатиться в c окончательно?
Получился сокет сервер на си — https://github.com/recoilme/okdb Меня смущало еще то, что до этого я никогда не писал на си, у вас то с этим ситуация лучше), да это не проблема как выяснилось, си довольно простой. Работает без сбоев уже пол года — тьфу-тьфу-тьфу) Ну и знаете, кода и волшебных мест — стало меньше. Зато теперь я точно уверен что ни байта данных не расходуется «налево». Те может быть в вашей истории тоже не стоило упираться в гоу раз уж вам критичны ресурсы? Я просто профита от гоу не вижу здесь особо.
Экономишь — экономишь на сисколах, барахтаешься на самом дне, чтобы сэкономить жалкие 500 наносекунд, а потом видишь что десериализация объекта в перл жрет в 10 раз больше времени чем вся твоя убер система, и все равно все сводится к тому, что нужен кешик, уже в перле, над бд. И потом смотришь сверху на систему и понимаешь — что можно было ничего не менять, остаться на высоком ЯП, ибо конечная система все равно работает примерно с той же скоростью, лил.
Другими словами вот вы станцевали, а яваскрипт в http интерфейсе почты примерно так же тупит как раньше, например. И толку нет особо от всех этих танцев. Ну это я к примеру, может есть где то идеальный мир где всю систему пилит один человек и она работает максимально оптимально…
Ну профит в Go здесь как раз в том, что мы можем выборочно оптимизировать узкие места. Например, там, где не требуется высокая эффективность и производительность можно положиться на go runtime, сборщик мусора и т. д.
Были мысли из Go использовать pico http parser, но в итоге написал кусочек функционала на Go и на этом пока вопрос закрылся =)
На этом был построен сетевой стек веб-игруни: https://fintank.ru/
Когда делал HTTP, читал сырцы nginx, пытался получить zero-copy где только можно, в websocket-части до сих пор не выпилил одно место где кусок данных лишний раз копируется.
Я конечно могу сказать, что поделие было вызвано отсутствием на тот момент нормальной реализации websocket для C/C++, но посмотрим правде в лицо: скорее всего просто руки чесались :)
На C++ вроде есть недурственный uWebSockets, но это не точно.
А планируются ли ещё статьи по этой системе? Интересно узнать ещё чем проводите нагрузочное тестирование, насколько сложная маршрутизация внутри и какое количество сообщений в среднем проходит через систему?
— Реализации обычных ESB — WSO2 или Mule — если это используется в другом приложении
— Zabbix, если это все ради мониторинга
Был какой-то анализ на тему взять готовое vs велосипед?
В общении между шиной и клиентами мы не хотим использовать протоколы кровавого энтерпрайза SOAP, JBI, JMS и т. д. В почте у нас нет Java, и большинство вариантов использования покрывает внутренний бинарный протокол IProto. Нам хотелось использовать что-то совсем легковесное и контролирумое чуть более, чем полностью – мы еще не пришли к конечной логике работы системы и можем захотеть ее поменять.
И это не ради мониторинга.
И что, на масштабах mail.ru действительно дешевле потратить время и ресурсы на разработку и дальнейшую поддержку этого решения, вместо того чтобы просто воткнуть лишние 64GB памяти и ограничиться тривиальным идиоматическим кодом?
Миллион WebSocket и Go