Pull to refresh

Comments 78

Очень интересная и полезная статья!
Потихоньку двигаю свой C# проект протокол-ориентированного сервера (к сожалению пока не компилируется, вчера не доборол реализацию простого пуллинга байт-буферов), в котором тоже использую то, что вы называете zero-copy upgrade. Грубо говоря, мне пришел буфер с байтами, из него сразу читаю некой машиной состояний данные, каждый шаг которой матчит ту или иную часть http запроса рукопожатия.
Вот хэндлер для чтения фрейма 13 версии из байт-буфера, основанного тоже на машине состояний, где один из шагов — разбор заголовка.
Кстати, пока что реализация однопоточная и обработка каждого пришедшего куска данных в байт-буффере происходит в потоке, вызванном из libuv (та, что из Node.js), но планирую добавить планировщик через похожий пул задач (или потоков), чтобы принимать подключения и данные в одном libuv-потоке, ставить в очередь на обработку, а обработку выполнять планировщиком по мере освобождения потоков.
Эх… хотелось бы уже допилить прототип до нормального состояния и прогнать нагрузочный тест, сколько выдержит такая архитектура (честно говоря, содранная, но сильно упрощенная из проекта netty). По предварительный тестам, рукопожатие около 100-150нс, а чтение фреймов — ориентировочно 1-2нс на каждый байт payload данных на один поток обработки. На i7 2.2GHz.

А вы не пробовали доверить построение конечного автомата компилятору, через async/await?

Что Вы имеете ввиду? Автомат там удобен по одной простой причине: исходное Http (допустим) сообщение размером 512 байт может быть разбито на 6 кусков по 100, 100, 100, 100, 100, 12 байт, если размер буфера в сокетах установить в 100 байт. Автомат при чтении текущего куска может перейти в какое-то состояние и запомнить сколько прочитал, что прочитал, потом, не переходя в новое состояние, закончить выполнение. Причем текущий буфер может быть недочитан. При поступлении следующего буфера, предыдущий и новый буферы склеиваются и аккумулированный буфер передается снова в автомат, где текущее состояние пробует продолжить читать буфер и матчить что-нибудь, после чего может сделать переход в другое состояние для матчинга чего-то следующего. Не совсем понял, чем тут поможет 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);
    }
}
Код кажется не оптимальным. Много syscall-ов (отдельный вызов на каждый токен). Это может привести к большому количеству context switch и как следствие заметное замедление сервиса.
Я не знаю C#, возможно ReadUntil* внутри содержит буффер, где копит данные «на следующие чтения» — тогда сисколов много не будет, но тогда будет плохо предсказуемое потребление памяти. Ну, еще я бы это не назвал конечным автоматом.
Вариант с автоматом не так зависит от количества хедеров и имеет более «ровное» потребление памяти одновременно.

Нет, вы не поняли. Это не стандартные функции, их тоже надо реализовать.

Представьте, что вам пришел буфер, в котором header Connection находится в конце и не полностью, а его продолжение прийдет первыми байтами в новом буфере (на данный момент обработки нового буфера нет): первая порция байт-буфера "...................Connecti" и вторая порция байт-буфера «on: Upgrade\r\n........»
— Ваш ReadUntilSpace что сделает? Не понятно. Допустим он завершится, тогда выполнение перейдет в SkipUntilEOL, который также никуда не сможет прочитать.
Ваш код будет работать, если Вы получили целостное Http сообщение.
А автомат, о котором я говорю, на шаге чтения названия заголовка просто остановится, прочитав Connecti, запишет, что заголовок Connection пока что еще матчится, но не до конца. Автомат закончит обработку буфера и все. При поступлении следующей порции байт-буфера «on: Upgrade\r\n........», автомат продолжит с сохраненного состояния, дочитает «on: », запомнит общее состояние автомата и что заголовок Connection заматчен успешно и переключится на следующий шаг чтения значения заголовка. Так обрабатывается все сообщение, всеми приходящими кусками. Декодирование веб-сокет фрейма работает почти аналогично, только сильно проще.

Э… Вы не обратили внимание на оператор await? Если в буфере неполный заголовок — то ReadUntilSpace вернет незавершенную задачу. Оператор await обнаружит это и остановит метод ReadHeader, он тоже вернет незавершенную задачу. Когда придет следующая часть, оба методв будут продолжены.


Говорю же, это и есть конечный автомат, только генерируемый компилятором.

Мы точно говорим о C#? Когда придет следующая часть, то куда она будет передана, если у Вас уже висит незавершенная задача, запущенная с определенными параметрами (не вижу в Вашем примере)? Поясните, где в Вашем примере запускаемая задача берет первый кусок сообщения, потом останавливается и ждет получения слеующего. Это важно для понимания Вашего хода мыслей в этом обсуждении.

Если используется системный проактор — то функция 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 не прикрутить?
Ragel targets C, C++ and ASM.

Где в этом списке C#?

В более ранней версии: Ragel targets C, C++, Obj-C, C#, D, Java, Go and Ruby. Берёте ragel 7.0.0.9 — и всё.

Не знаю какое бревно на них упало и почему они вдруг решили выпилить поддержку 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 распределяет коннекты между экземплярами.


А что за демон для вебсокет-соединений?

UFO just landed and posted this here

Спасибо! Всегда есть куда развиваться.

Nginx в первую очередь берет на себя ssl.


А что go не умеет ssl? Или у go биндинги на openSSL другие?
SSL это не единственная задача с которой справляется Nginx есть еще множество не очевидных нюансов в работе с удаленными подключениями.
nginx распределяет коннекты между экземплярами

Плюс, таймауты на чтение из клиентского коннекта, ограничение доступа к апстриму и т. д.


Можно конечно, переписать на Go логику, отлично работающую в nginx, но не уверен, что это принесет гигантский профит. Возможно, стоит попробовать – но сейчас есть более приоритетные задачи =)

Как вам уже ответили, это работа с ожиданиями I/O операций.
Закрытие подключений, повторное использование socket, правильная работа с обработкой вновь поступающих подключений.
Оптимизированная работа с приемом и обработкой трафика (это не так просто как звучит, т.к. из удаленных источников данные могут приходить в довольно неожиданных порциях). На допущениях того что трафик в приложении идет через локальное подключение можно довольно сильно оптимизировать свой сервер обработчик.
«Fraud» трафик в конце концов.
Думаю что не целесообразно тратить кучу времени на реализацию полноценного web сервера когда есть уже готовые и проверенные временем решения (iis/http.sys под windows и nginx для остальных)
На допущениях того что трафик в приложении идет через локальное подключение можно довольно сильно оптимизировать свой сервер обработчик.


А еще это дополнительный оверхед. Так как юникс сокеты не бесплатны.

Думаю что не целесообразно тратить кучу времени на реализацию полноценного web сервера когда есть уже готовые и проверенные временем решения


Ну так я к чему и веду. Все это уже есть. Например, на яве в netty. Получается бросили кучу уисилий, написали велосипед и он всеравно будет медленней, чем готовые решения.

Эм, кажется мы друг друга не поняли. Мы же вроде не бросили кучу усилий и не писали велосипед на разработку web-сервера, а взяли nginx?

Ну как же, Вы бросили усилия на написание высокопроизводительных вебсокетов в го, которые уже давно есть на других языках и платформах. При этом добавили сверху енджинкс, добавив оверхед, чтобы закрыть дырки по фукнционалу в го.

Ну по крайней, мере это что я вижу. Из описаного выше.

Дырок в функциональности Go нет. Дырка появилась бы во времени, которое пришлось бы потратить на призрачный профит переписывания функциональности nginx.


Ну и да, мы бросили усилия для того, чтобы в Go теперь тоже были высокопроизводительные вебсокеты =) На других языках они ведь тоже не сразу появились.


А вообще, что-то мне подсказывает, что в свете упоминания netty, уместно будет процитировать эту шутку:


Дырок в функциональности Go нет.


А то что Вы написали сокеты, это не в счет? И дырки есть, иначе — зачем вам енджинкс? Очевидно, что Вы им закрываете недостающий фукнционал в вашей реализации вебсокетов. (я не про лоад балансинг, а про другие пункты что Вы описали).

уместно будет процитировать эту шутку


Ну Вы же понимаете, что дело не в языке, а в людях, которые пишут код.
И дырки есть, иначе — зачем вам енджинкс?

Кажется, это рекурсия!

Кажется, у вас Java головного мозга. Срочно обратитесь к доктору.

ЗЫ: а у JVM время старта большое.
Это был просто пример готового инструмента, который решает задачу, без необходимости реализовывать протокол самому.
Вы так говорите, будто это что-то плохое. На самом деле ребята сделали очень хорошую вещь. Кроме решения собственной проблемы они еще и сообществу оказали помощь. Было бы странно менять инструменты при любой задаче, которая требует реализации чего-либо, или вы думаете, что в Java все мгновенно появилось?

Или вы решили через вопрос целесообразности nginx перед нашим WebSocket-сервером решили привести к тому, что нецелесообразно было пилить WebSocket-сервер? =)

Все это уже есть. Например, на яве в netty. Получается бросили кучу уисилий, написали велосипед и он всеравно будет медленней, чем готовые решения.

Автор в том числе акцентирует внимание на zero-copy upgrade, смысл которого в том, чтобы не парсить Http заголовки, а сразу их анализировать и матчить. В netty дефолтная реализация в виде пайплайна будет выглядеть примерно так: HttpResponseEncoder + HttpRequestDecoder + HttpObjectAggregator + WebSocketsHandshaker — т.е. в netty как раз готовое решение будет далеко не zero-copy.
Но на netty можно написать свой хэндлер, который будет zero-copy, но тогда это та же работа, что они и сделали (почти, за исключением остальной инфраструктуры).
Ну так задача специфичная. Вам никто не мешает переопределить HttpObjectDecoder и убрать от туда парсинг хидеров, вопрос нескольких часов.

Go умеет SSL, при этом, если я не ошибаюсь, без binding'ов – т.е. своя реализация.

UFO just landed and posted this here

Извиняюсь, с телефона прочитал "домен" как "демон" =) Вопрос тогда не актуален.

rule задал правильный вопрос на счет ограничения в количестве одновременных подключений к серверу по tcp/ip.
В статье я не заметил ни слова о том как преодолено ограничение в количестве локальных портов.
Как Nginx может держать открытыми 3 млн внешних подключений?
Ведь все они приходят с одного внешнего порта (ip адреса) для которого драйвер tpc/ip будет использовать всего 65535 портов (к тому же часть из которых зарезервированы), которые даже после закрытия соединения еще какое то время удерживаются в ожидании повторных подключений с удаленного адреса.
Что касается адресации – вы, наверное, имеете в виду проблему портов, когда nginx не может спроксировать больше ~64K коннектов на локальный демон? Ее можно решать, как вы правильно сказали, добавлением виртуальных интерфейсов, либо, как это сделали мы – уйти от TCP-сокетов с адресацией по порту, в сторону UNIX-сокетов.
Скажу по другому.
Как 3млн. ВНЕШНИХ подключений удерживаются Nginx?
То как они взаимодействуют с внутренними процессами, отдельный разговор (и тут у меня нет вопросов, т.к. решений множество, на что автор и указал в своем сообщении).
Помню на какой то конференции товарищи презентовали железку которая могла держать более 1млн. одновременных подключений и там была реализована черная магия, а на выходе (к обработчику подключений) множество небольших запросов с ключом сессии.
Например в Windows это можно сделать через NLB который действует на третьем уровне сетевого протокола (по сути работает на уровне драйвера, до выделения порта) и перераспределяет запросы на множество сетевых адаптеров (возможно и виртуальные), но это решение довольно не оптимально в плане производительности (наверняка есть супер железки которые выполняют подобные действия и одну из них как раз использовали для своего решения в mail.ru)

А в чем, собственно, проблема держать 3 миллиона внешних подключений если памяти достаточно?

Сижу, думаю о том, что хватит бомбардировать сервера, пусть они сами звонят клиентам, пора решаться на websocket в продакшене, и тут БАЦ! Ваша статья! Это ж надо так вовремя! Сопицот СПАСИБО!
8 Кб на горутину в максимуме? Это много очень, по сравнению с более другими языками, в которых, например, на процесс выделяется 338 8-байтовых слов, причем 233 байта — это куча.

Поправка — куча 233 слова, не байта.

8 Кбайт – это который в зависимости от операционной системы и версии Go. В последних версиях и в Linux, если я не ошибаюсь, стек начинается с 2 Кбайт.


на процесс выделяется 338 8-байтовых слов

Судя по всему, речь идет об Erlang/Elixir? =)


Получается, (338-223) * 8 = 920 байт стека? Выходит, с учетом кучи (338 * 8 = 2704) разницы нет? )


Вот в ponylang, например, акторы занимают 256 байт памяти… Но это уже совсем другая история =)

Угу, это я за любимый Erlang слегка подтапливаю:)

Спору нет – erlang для этого, очевидно, хорош. Не зря WhatsApp рекорды по соединениям на одном сервере с ним ставил =)

Спору нет – erlang для этого, очевидно, хорош. Не зря WhatsApp рекорды по соединениям на одном сервере с ним ставил =)


Ну он не так уж и хорош, если углубится в детали. Во-первых, им для этого пришлось допиливать виртуалку ерланга, так как у ерланга были проблемы с большими нагрузками.
Во-вторых, если посчитать нагрузку на ядро, то у них она всего лишь 9к рек-сек. Учитывая что это чат, это явно результаты ниже среднего.

А где-то есть подробности?

Ну если далеко не ходить, то — https://habrahabr.ru/post/276951/
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 и на этом пока вопрос закрылся =)

Ага. Смотрел тоже и pico и h2o и всякие facilio — тлен это всё)
проще маленький нужный кусочек самому написать или стыбрить мякотку парсинга из подходящей либы
Делал C++ реализацию WebSocket (и заодно HTTP): https://github.com/pavelkolodin/fir
На этом был построен сетевой стек веб-игруни: https://fintank.ru/
Когда делал HTTP, читал сырцы nginx, пытался получить zero-copy где только можно, в websocket-части до сих пор не выпилил одно место где кусок данных лишний раз копируется.
Я конечно могу сказать, что поделие было вызвано отсутствием на тот момент нормальной реализации websocket для C/C++, но посмотрим правде в лицо: скорее всего просто руки чесались :)

На C++ вроде есть недурственный uWebSockets, но это не точно.

Спасибо за статью!
А планируются ли ещё статьи по этой системе? Интересно узнать ещё чем проводите нагрузочное тестирование, насколько сложная маршрутизация внутри и какое количество сообщений в среднем проходит через систему?

В ближайшее время не планировалось. Если интересно про маршрутизацию и некоторые цифры – есть видео с РИТ2017:


Прочитал постановку задачи и не понял чем не угодили:
— Реализации обычных ESB — WSO2 или Mule — если это используется в другом приложении
— Zabbix, если это все ради мониторинга

Был какой-то анализ на тему взять готовое vs велосипед?

Упс, ответил ниже в треде =)

В общении между шиной и клиентами мы не хотим использовать протоколы кровавого энтерпрайза SOAP, JBI, JMS и т. д. В почте у нас нет Java, и большинство вариантов использования покрывает внутренний бинарный протокол IProto. Нам хотелось использовать что-то совсем легковесное и контролирумое чуть более, чем полностью – мы еще не пришли к конечной логике работы системы и можем захотеть ее поменять.


И это не ради мониторинга.

UFO just landed and posted this here
Интересно было бы почитать про подобный же опыт, но на rust
а как обстоит сейчас дело с GC — сильно ли он влияет на мгновенную отдачу результата клиенту?

С GC дело обстоит неплохо. Это практически не влияет на мгновенную отдачу – GC случаются редко и на короткие промежутки времени (зависит от GOGC).

И что, на масштабах mail.ru действительно дешевле потратить время и ресурсы на разработку и дальнейшую поддержку этого решения, вместо того чтобы просто воткнуть лишние 64GB памяти и ограничиться тривиальным идиоматическим кодом?

Sign up to leave a comment.