Re[13]: WA: 3 млн tcp соединений на одном сервере
От: netch80 Украина http://netch80.dreamwidth.org/
Дата: 07.08.20 10:46
Оценка:
Здравствуйте, SkyDance, Вы писали:

N>>Вот например RabbitMQ борется с такой проблемой:


SD>Как обычно, у любой проблемы есть варианты решений. Хотя для начала надо понять, почему у них эта проблема существует вообще.

SD>1. Почему у writer'а длинная очередь?

Потому что туда поступили сообщения из внутренней обработки, и их надо отправить. Терять нельзя.

SD>2. Почему selective receive optimisation не работает для их случая?


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

SD>3. Почему они вообще используют gen_tcp?

SD>Ибо если б нужна была, достаточно было бы просто перебраться на socket API, который выполнен в виде NIF и не страдает от указанных недостатков.

Гениально, Ватсон!

>> Module socket was introduced in OTP 22.0.


Спасибо, что снизошли до нас, простых смертных. А все предыдущие ~15+ лет нам что делать было?
Мы начинали на R11, кажется.

N>>1. Да, ребята молодцы, что для решения проблемы они полезли в обход аж двух слоёв стандартной библиотеки

SD>Думаю, здесь было бы куда более логичным описать проблему более предметно.

Проблема очевидна: есть задача обрабатывать поток сообщений, не теряя их и адекватно реагируя на нагрузку (со стоимостью не выше линейной, в идеале — ниже).

Это проблема и MQ-движков, и нашего мониторинга.

SD> И вместо "обхода двух слоев" добавить нужную фунцкиональность. Но для этого им бы сначала пришлось разобраться в существующей. А вот это всегда и для всех было проблемой. Что уж там, сколько лет я пытаюсь подвинуть процесс интервью в направлении "проверять как кандидат умеет _читать_ код", но воз, увы, стоит на месте — все так же требует _писать_ код (который писать не нужно).


Опять "мышки, станьте ёжиками". У меня совершенно конкретный вопрос: как вы предлагаете писать, чтобы не получать квадратичную зависимость затрат CPU (хотя бы) от скорости потока, в условиях подобных неоптимизируемых синхронных вызовов (которые неизбежны хоть изредка)?

Хотя бы самую общую идею, как это сделать, но чтобы было реализуемо в пределах Erlang.

N>>2. Нет, надо было искать чудо и делать, чтобы очередь никогда не превышала K сообщений, где K < N. Как это делать — да хоть через ETS, которую наполняет другой процесс. Публичная ETS (потому что удаляет другой процесс, чем добавляет) не страшна, все свои.


SD>За такие "архите-крутные" решения вынесу порицание. И вообще, к моему страху и ужасу, кроме Вирдинга да еще пары человек понмания какие ужасы несут нам ETS, почти ни у кого нет. Как тут не вспомнить Хаскел, где усилие было приложено в нужном направлении, и ETS попросту нет (в том виде как они есть в Эрланге).


Роскошно. Повторю вопрос: как вы будете решать иначе?
ETS был тут совсем не от хорошей жизни.

N>>3. Надо было сидеть и страдать. Нефиг тут неуправляемые входные потоки принимать на процесс. TCP приёмник нормально контролирует, а заторы возникают по дороге? Пофиг, у меня всё работает (tm).


SD>Этот вариант я не понял. Что именно предлагается? Разделить на два процесса, где у одного очередь и flow control, а второй непосредственно пишет в сокет? Да, так делают, да, нормально работает, но да, костыль.


Мнэээ... вы хоть попробовали действительно детально расписать, как вы предполагаете реализацию этого?

Ну-ка попробую ("давно не брал я в руки шашек", но основы помню). Итак, процесс A получает входные сообщения, процесс B отправляет пусть штатным образом через gen_tcp:send (напоминаю, socket API нам ещё не доступен). Мы защищаемся от слишком длинной очереди процесса B, передавая эту обязанность на A.

Очевидно, что если B передаёт каким-то образом "а ну дай следующие данные на передачу", их надо собрать одним сообщением. Проблема в receive именно в количестве сообщений, а если я все передаваемые пакеты набью в одно, оно будет тяжёлым (объём данных никуда не девается), но одним. Потом процесс будет разбирать его и отправлять по кусочкам.
И чтобы не ожидать синхронизации с A, B всегда просит запас на сколько-то сообщений, если есть. Он передаёт в сторону A сообщения типа "у меня сейчас <N готовых на отправку, давай следующие" (XON) или "у меня сейчас >=N, копи у себя" (XOFF). (Пока не думаем, как он именно это передаёт в A. Можно рассматривать на грубых мерах уровня XON/XOFF, а не более тонких типа "скинь мне ещё 200 сообщений", покамест это неважно.)
Теперь: B должен же как-то реагировать на внешний мир, пока это происходит? Значит, надо разбирать поступившее относительно мелкими порциями, давая в промежутках управляющей системе что-то сделать. Может, она пошлёт стандартное OTP сообщение "расскажи, чем ты занят" (не помню, как выглядит), может, скажет проапгрейдиться, а может, скажет потухнуть. Значит, один раз на K сообщений надо отдать управление основному циклу (мы ж считаем, что он не самопальный? значит, берём gen_server, вряд ли что-то иное). А себе тогда надо послать сообщение типа "работай дальше".

Вроде с B утвердили. В нормальной обстановке у него безумной очереди нет: в произвольный момент там будут только какие-то из набора: 1) порция данных от A, <=1 сообщение; 2) самому себе про про продолжение разбора, <=1 сообщение; 3) какое-то разумное количество '$call', если спрашивают; 4) что-то управляющее от системы, <=1 сообщение.

Теперь переходим к A. Он накапливает входную очередь в своё состояние и сбрасывает его по заказу от B. Теперь пусть последний сигнал от B — "XOFF, копи у себя". В очереди опять ~20000 сообщений, источники не могут группировать. B передаёт A: XON. Теперь этот XON становится в хвост этих 20000 сообщений... B давно всё отправил и ждёт нового поступления, а A накапливает сообщения, раздуваясь, хотя давно мог бы скинуть порцию для B.

Вот это как раз тот случай, когда мы применили ETS, что вам так не нравится. В нём B выставлял этот флажок XON/XOFF, а A в обработке своего handle_cast (или handle_info, не помню) раз на 100 отработанных сообщений проверял его и смотрел, может он отправлять на B. Некошерно, зато мгновенно.

Итак, оно типа работает (хотя и потребовало заметного усложнения обоих процессов). Теперь: у этой системы есть свой мониторинг мониторинга. Каждый процесс должен отвечать подробностями своего состояния. Делаем это на сообщениях? Опять, пока "покажи личико" стоит в хвосте очереди, A прорабатывает свой входной набор.

Да, мы это обошли — и снова через ваш нелюбимый ETS! В приложении процесс listener отрабатывает длинный вход и рапортует своё состояние через ETS процессу manager, а заодно проверяя там — опять же каждые пусть 100 сообщений, или по таймеру — запросы от manager по ETS. А manager не получает длинную очередь, и отрабатывает все управляющие команды. И так в каждом приложении.

А теперь как это было бы на более разумно спроектированном средстве: никаких множественных процессов, никаких сложных буферизаций, никаких нечестных каналов мгновенного взаимодействия. Просто: пусть у нас 3 очереди. 0 — входной поток (cast + info), 1 — синхронные запросы (call + служебные OTP), 2 — ответы на наши синхронные запросы. Даже если receive читает, пусть по шаблону, в порядке приоритета: 2, 1, 0 (но пока копается в 0, всё равно реагирует на поступление в очередь 2) — то всё решено, реакция своевременная и адекватная, поведение устойчивое и линейное, и одного процесса хватает с головой. Ещё лучше, конечно, ограничить, что ответы на call читаются только из 2, но это следующий шаг.

И, что крайне важно, это всё не помешало бы всему остальному, что вы тут пишете про "правильный дизайн".

Ну теперь хочу послушать ваши предложения. Только в технической конкретике, иначе спор неравный, и без фокусов типа socket API, который недоступен (машины времени, простите, нет), по уровню 2010 года (когда вопрос про очереди уже был поднят и получил отказ), максимум 2012. Потом можно перейти к 2020, вдруг что изменилось.

SD> Но вообще эти страдания не совсем логичны. Впрочем, хорошо уже то, что у них таки есть разделение на r/w. Потому как куда более суровы страдания тех, кто в одном процессе все это делает, а потом жалуется, что gen_tcp:send() не возвращает управление пока данные не уйдут хотя бы в kernel send buffer.


Это как раз более понятная проблема и решаемая: 1) просто распараллелить отправщиков (считаем, TCP соединения разные), 2) задержка линейна, а не квадратична.

SD>Пожалуй, что "иное" — просто используйте socket. Если его нет в вашей версии, — портируйте. Вполне нормальная практика backport'ить что-то из старших версий, скажем, мы в древние времена бэкпортили crypto, чтобы работало с аппаратным ускорением (через УМЗ), а не как в R16B.


Этой "старшей версии" ещё в 2011 просто нет, бэкпортить нечего. Надо писать самому. Лезть на сишный уровень в сложный чужой код...
Понимаете, почему я во втором проекте всё это выбросил, и просто перегнал на другой язык? И взлетело за 3 дня.

N>>Нет, для себя мы её решили — костылями различной кривости.

SD>Может, в этом причина недовольства?

Не-а

N>>В спокойной обстановке на участке NMU — GL поток сообщений в десятки раз меньше входного потока NMU (то есть тысячи, если не сотни, в секунду, уровня "тут всё спокойно, тангаж, крен, рысканье в норме, температура 36.6"), при проблемах — может подскакивать до равного потока (считаем, те же 100K mps). Работа при пиковой нагрузке, соответственно, критична (должно быть всё гладко-линейно и должен ещё быть запас производительности).

SD>Это же невозможно по определению. Или вы должны быть overprovisioned, чтобы соблюдать гарантии, или должна быть load shedding логика для защиты от перегрузки, или — backpressure.
SD>"Все уже украдено до нас": https://ferd.ca/handling-overload.html

И снова вы не хотите читать, что вам пишут.
У нас не было проблемы переполнения именно такого рода, как тут пишут, не было причины выставлять какие-то произвольные лимиты на основании представления, что это предел, и всё такое.
Если бы система адекватно (то есть с зависимостью не выше линейной) реагировала на нагрузку, пусть там ждёт 100000, 1000000 сообщений — всё пофиг; мы их просто обрабатываем.
Да, это именно что overprovisioned, и в данной ситуации это намеренно. Пусть каждый узел будет нагружен даже в самом тяжёлом случае на 50%, на 30% — всё отлично, нас это устроило бы, задача не просто допускает такой overprovisioning, она косвенно это требует.

Но квадратичность зависимости от текущей длины очереди:
1. Не даёт возможность создать никакого запаса нагрузки.
2. Не допускает устойчивого поведения: в отличие от линейной реакции, где любой локальный перекос просто чуть удлиняет очереди и спокойно разгребается вслед — тут подскок приводит к тому, что времени разгребать уже нет, надо дропать без обработки или дохнуть.

N>>2) Если ему хоть иногда надо делать синхронные вызовы (gen_server:call) и соответственно сразу ждать отвёт — всё, суши вёсла


SD>Хаха, как знакомо, все с тем же Вирдингом общались на тему "кто же это придумал gen_server:call и какие кары ждут его в аду"

SD>Все правильно: как только нарушается стройная концепция ("процессы обмениваются сообщениями") и приходит императивный девелопер "нам нужно сделать RPC", как все сразу начинает работать не так, как задумано. Embrace concurrency, adopd asynchronicity, и так далее. Пока это не случится, будут все те же одинаковые проблемы с блокировкой процесса там, где не следует.

Ну вот именно что "всё правильно", только другое "всё": в ад должны пойти те, кто уже более 10 лет отказывают в простых дешёвых надёжных мерах решить проблему, ссылаясь на "как задумано".

И от синхронных вызовов отказаться как-то нереально. Хотя сократить их долю, да, можно.

N>>: из накопления входной очереди выше 10-20K сообщений он не способен уже выйти. Граница неточная, но, похоже, связана с размером кэша процессора.

SD>Прочитав еще пару комментариев ниже, и увидев, как другие участники восхищаются скоростью постановки диагноза, просто обязан прокомментировать. Во-первых, отослать к моему выступлению на прошлой code MESH, — как раз на тему "in god we trust, all others must bring data".

Если бы кто-то нормально отреагировал на эти вопросы в 2011-м или около того, у него были бы не просто данные, у него была бы живая установка на самому пощупать во всех аспектах. А сейчас, извините, поезд ушёл. Но повторение проблемы через пару лет на другом проекте с независимым кодом — для меня достаточно, чтобы сделать вывод (о квадрате, не о кэше).

SD>Во-вторых, конечно же, у меня есть куда более простое, логичное и понятное объяснение, почему это происходит, ибо — я в теме разобрался чуть менее поверхностно. Допускаю, что у вас на тот момент не было никого, что мог бы просто воспользоваться gdb/perf/fprof/eprof/cprof, и понять, что дело не в кэше, а в том, как работает GC. Но да, что занятно, Rick Reed в свое время тоже наступил на сию граблю, но на то он и Rick Reed, чтобы разобраться. Я тогда еще и рядом не стоял. Могу лишь гордится тем, что учился у таких людей.


Мне на самом деле нет принципиальной разницы тут: кэш, GC или стая ворон. Это только дополнительный триггер, который приводил к более резкой границе срабатывания. Мы могли бы выдержать торможение и в 10 раз на переходе границы, но — фиксированное. Вот пусть ровно в 12.4 раза, но не больше.
Реально же измерения на тесте типа "заполняем входную очередь до заданного количества, потом начинаем разбирать" показали, что время растёт с характеристикой, близкой к квадрату количества сообщений.

SD>Так вот, возвращаясь к вопросу, — возможно в древних версиях, которыми вы пользовались, еще не было off-heap message queues, поэтому и случалась death spiral, когда с ростом количества сообщений в очереди GC становился все более и более дорогим, что вело к еще более быстрому наполнению очереди, что вело к еще более медленному GC, и так далее.


Там есть функции пощупать, чем занят сейчас процесс (без вопроса ему самому) — собственно, process_info() с соответствующими параметрами. Если бы вы были правы, там оно показывало бы GC. Но оно не показывало GC. Так что этот фактор если и влиял, то далеко не главным.

(Ну или показометр врал. Кто знает... но тогда это опять же проблема Erlang.)

N>> Временное переполнение, безвредное в других условиях, становится фатальным (надо только рубить процесс).

SD>Кто хочет, ищет причины. Кто хочет — решения. Мы нашли решения: во-первых, добавили патч для flush message queue, во-вторых, допилили GC для более удачной работы с длинными очередями, в-третьих, когда доделали off-heap mq, просто перелезли на них.

Вот именно, что ищет решения. Нашим решением для второго проекта был уход от Erlang. И не пожалели.

N>>Нет. Backpressure вообще недопустим <...> Остановить агентов мы были в состоянии. Реально они сами останавливались, не получив команды "даю разрешение на 5 отчётов".


SD>Или я чего-то не понимаю, или... это как раз и есть backpressure (в самом простом и классическом варианте, т.е. credit control. Странно, что это вы считаете "хаком". Это вполне легальный, и один из самых простых в реализации, способов. В конце концов, Ulf Wiger еще в 2010 (на деле, в 2005, но опубликовали позже) с теоретической стороны это рассмотрел. Позже, в том же Elixir, Jose Valim сделал аналогичный GenStage. Да что там, реализаций немало.


Супер статья, да:

>> For lack of a better analysis,we called them “monster waves” (see Figure 5) although we do notyet know exactly what causes it.


Ну хоть по вашему мнению, вы победили эти monster waves, или нет? )

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

SD>И. Справедливости ради, это же и есть holy grail. SEDA, и все эти akka streams, и все это reactive programming, — все аналогичные data flow pipelines, все это отнюдь не ново! Все уже было, и whitepapers более чем навалом, и реализаций. Если уж вы работали над "Ломоносовым", это ведь в МГУ, как могло так получиться, что вы не были знакомы со всеми работами в этом направлении? Уж на что моя должность прикладного характера, но даже я с ними знаком. Ибо без такого знакомства я был бы обречен написать очередной worker pool.


А кто вам сказал, что не были знакомы? Мне сейчас не доступна история внутренних событий тех времён, но раскопки шли, и какие-то работы рассматривались. Имя Wigerʼа я помню с тех времён.
Тем не менее, их не хватило.

Поставьте сами эксперимент такого же рода. Какое-то современное железо (не менее Corei3 или Ryzen3, если x86), незагруженный узел. Сделайте процесс, который принимает поток сообщений с отдельного узла (какая-нибудь структурка в кортеже байт на 200) и обрабатывает их с участием синхронного вызова (пусть в трёх вариантах: сначала типа оптимизированного gen_server:call, потом gen_tcp:send и наконец чего-то своего) хотя бы один раз на 100 сообщений. И ещё один, который периодически мониторит этот процесс вызовами, на которые тот должен в handle_call отвечать своим состоянием. И начните его грузить, равномерным потоком, под потолок 50% нагрузочной способности на текущем железе. Потом сделайте внешнее вмешательство: просто заставьте заснуть процесс на 2-3 секунды (годится call с сообщением, которое зовёт sleep)... а потом смотрите, как он выкарабкивается из этой ситуации.
Потом возьмите от того пикового ламинарного потока — 95%, 90%, 85%... и каждый раз приостанавливайте процесс и смотрите, восстановится или нет.
Критерий восстановления: если остановить входной поток, через 6 секунд (дадим запас, хотя 4 секунд должно было хватить) очередь процесса исчерпывается полностью.
Потом повторить на R12-R13 и на железе того времени (пусть будут Nehalem всех видов).
Основная цифра, которая меня интересует по результату: пусть RL — пиковый рейт для устойчивого ламинарного состояния (сообщений в секунду). Пусть RX — пиковый рейт для режима "пригрузив, остановить процесс на 2 секунды, запустить снова, и чтобы он восстановился после этого" (повторюсь, восстановление — это когда через 4-6 секунд все входные сообщения проработаны, и процесс спит в ожидании). Чему равно RL/RX, и как она зависит от железа, версии Erlang и варианта синхронного вызова?
И если RL/RX более 2 (дадим запас, хотя я бы смотрел на случай >1.4), что именно тормозит восстановление?

SD>>>А надо было обсуждать с Lukas Larsson, Kenneth Lundin, Rickard Green, Sverker Eriksson, Kjell Winnblad. Круг, действительно, узок, и он не включает ни одной из указанных выше фамилий.

N>>Общением с кем-то из core team занимались другие коллеги. Успеха не добились.

SD>Что такое core team?

SD>OTP team, у них нет "core", есть VM, есть PS, и есть еще один человек сбоку.

Core team — это общее понятие. Мне пофиг, как оно называется в конкретном случае — core team, cardinal crew, steering committee или как-то ещё.

SD>И так уже лет... пятнадцать. И вот это, да, проблема, слишком мало драйверов роста.


Ну если вы будете вместо того, чтобы помогать пусть даже на среднего качества разработке добиваться успехов, требовать высочайшего качества подготовки программистов/архитекторов/etc. — не удивляйтесь, что у вас "слишком мало драйверов роста", их больше и не будет — наоборот, будет только меньше.

SD>Именно в том и дело, что в Java/C++ и прочих "закатах солнца вручную" очень легко выстрелить себе в ногу. Вот в самом деле, персональная нить на клиента — это же, черт подери, удобно, и очень правильно! И, собственно, так и должно быть (более того, должно быть две нити на клиента, одна на вход, другая на выход, in/out pipes). Потому что это точно воспроизводит всю коммуникационную специфику. Неспроста же это решение возникло в телекоме. Обмен сообщениями. Просто, понятно, гениально.


Спасибо, посмеялся.

N>>Но это надо смотреть реализацию в деталях: что делается и как.


SD>Как обычно. Дьявол в деталях. И в уровне разработчиков. То, что десяток профессионалов сделют на Эрланге, может быть недоступно сотне "обычных порошков". Для меня, кстати, это совсем недавнее открытие. Раньше я как-то и представить не мог, что один разработчик может быть на порядок более производителен, чем другой. То есть, раньше, в моем понимании, команда из пятерых человек по определению сделает больше, чем один гений. Вынужден признать, что глубоко заблуждался.


У меня такого заблуждения никогда не было. Но вы вместо обсуждения технических деталей опять впали в философию, где святой дух гения помогает Эрлангу, но не помогает Джаве.
The God is real, unless declared integer.
 
Подождите ...
Wait...
Пока на собственное сообщение не было ответов, его можно удалить.