Русский
Русский
English
Статистика
Реклама

Блог компании crazy panda

Релиз акторного фреймворка rotor v0.09 (c)

10.10.2020 20:06:06 | Автор: admin

actor system


rotor ненавязчивый С++ акторный микрофремворк, похожий на своих старших братьев caf и sobjectizer. В новом релизе внутреннее ядро полностью было переделано с помощью механизмов плагинов, так что это затронуло жизненный цикл акторов.


Связывание акторов


Всякая система акторов базируется на взаимодействии между ними, т.е. в отправлении сообщений друг другу (а также в возможных побочных эффектах в качестве реакции на эти сообщения или в создании новых сообщений, появляющихся в качестве реакции на события внешнего мира). Однако, чтобы сообщение было доставлено целевому актору, он должен оставаться активным (1); другими словами, если актор A собирается отправить сообщение М актору B, он должен быть уверен, что актор B онлайн и не будет выключен в процессе пересылки сообщения M.


До версии v0.09 подобная гарантия была только для отношений родитель/потомок, между супервайзером и дочерним актором, т. к. для последнего выполняется гарантия того, сообщение будет доставлено до его супервайзера в силу того, что супервайзер владеет дочерним актором, и его время жизни покрывает времена жизни всех своих дочерних акторов. Начиная с версии v0.09 появилась возможность связывания двух произвольных акторов A и B, так что после подтверждения связи (link), можно быть уверенным, что все последующие сообщения будут доставлены.


Для связывания акторов можно использовать такой код:


namespace r = rotor;void some_actor_t::on_start() noexcept override {    request<payload::link_request_t>(b_address).send(timeout);}void some_actor_t::on_link_response(r::message::link_response_t &response) noexcept {    auto& ec = message.payload.ec;    if (!ec) {        // успех связывания    }}

Однако, данный код не рекомендуется использовать напрямую потому что он не очень удобен. Это становится очевидным при попытке связать актор A с двумя и более акторами, т.к. some_actor_t должен будет вести внутренний счётчик успешных связываний. В данном случае помогает система плагинов, и код будет уже выглядеть так:


namespace r = rotor;void some_actor_t::configure(r::plugin::plugin_base_t &plugin) noexcept override {    plugin.with_casted<r::plugin::link_client_plugin_t>(        [&](auto &p) {            p.link(B1_address);            p.link(B2_address);        }    );}

Это более удобно в виду того, что плагин link_client_plugin_t поставляется в базовом классе всех акторов actor_base_t. Тем не менее, это скорей всего не всё, что хотелось бы иметь, т.к. остаются не отвеченными важные вопросы: 1) Когда происходит связывание акторов (и обратный вопрос когда происходит их разъединение)? 2) Что случится, если целевой актор ("сервер") не существует или откажет в связывании? 3) Что случится если целевой актор решит выключиться, в то время как есть связанные с ним акторы-клиенты?


Чтобы ответить на этот вопрос нужно рассмотреть жизненный цикл актора.


Асинхронная инициализация и выключение актора


Упрощённо жизненный цикл актора (состояние, state) выглядит следующим образом: new (ctor) -> initializing -> initialized -> operational -> shutting down -> shut down.



Основная работа осуществляется актором, когда он находится в состоянии operational, и здесь собственно пользователь фреймворка должен решить, чем именно будет заниматься актор.


Во время фазы инициализации (I-фазы, т.е. initializing -> initialized), актор подготавливает себя для будущей работы: находит и связывается с другими акторами, устанавливает соединение с БД, получает необходимые ему ресурсы для полноценной работы. Ключевая особенность rotor'а, что I-фаза асинхронна, т. е. актор сообщает супервайзеру, когда он готов (2).


Фаза выключения (S-фаза, т.е. shutting down -> shut down ) комплиментарна I-фазе, т.е. актора просят выключится, а когда он готов, он сообщает об этом своему супервайзеру.


Несмотря на кажущуюся простоту, основная сложность лежит здесь в масштабируемости (composability) подхода, при котором акторы формируют эрланго-подобные иерархии ответственностей (см. мою статью Trees of Supervisors). Перефразируя, можно сказать, что любой актор может дать сбой во время I- или S-фазы, что может повлечь за собой безопасный и ожидаемый коллапс всей иерархии независимо от местоположения актора в ней. Конечная цель в итоге это либо вся иерархия приходит в рабочее состояние (operational), либо она в конце концов становится выключенной (shut down).



(Пояснение к картинке. Сплошная линия обозначение отношение владения, пунктирная отношения связи).


rotor уникален в этом отношении. Ничего подобного нет в caf. Может создаться ошибочное представление, что в sobjectizer'е присутствует shutdown helper, предназначение которого аналогично S-фазе выше; однако, после публичных дискуссий с автором в англоязчыной версии статьи, выяснилось, что данные вспомогательные классы нужны для "длительного" (гарантированного) выключения акторов, даже если в Environment'е был вызван метод stop. С точки зрения sobjectizer'а отложенная инициализация (и выключение), аналогичные I- и S-фазам rotor'a, могут быть смоделированы с помощью встроенного механизма поддержки состояний и это обязанность пользователя фрейворка, если такова потребность его бизнес-логики. В rotor'е же это встроено в сам фреймворк.


При использовании rotor'а было обнаружено, что во время I-фазы (S-фазы) потенциально множество ресурсов должны быть получены (освобождены) асинхронно, что обозначает, что нет единого компонента, способного решить, что текущая фаза завершена. Вместо этого это решение есть плод совместных усилий, приложенных в определённом порядке. И здесь в игру вступают плагины, представляющие из себя атомарные кусочки, каждый из которых ответственен за собственную работу по инициализации или выключению.


Что же такое плагин в контексте rotor'а? Плагин это некий аспект поведения актора, определяющий реакцию актора на некоторые сообщения или группу сообщений. Лучше пояснить на примерах. Плагин init_shutdown, ответственен за инициализацию (выключение) актора, т. е. после опроса о готовности всех плагины, генерируется ответ на запрос о готовности инициализации (выключения); или, например, плагин child_manager, доступный только для супервайзеров, и ответственный за порождение дочерних акторов и всю машинерию связанную с этим, как то генерация запросов дочерним акторам на инициализацию, выключение и т. п. Несмотря на то, что существует возможность свои плагины, на текущий момент я не вижу необходимости в этом, поэтому она остаётся недокументированной.


Таким образом, обещанные ответы, относящиеся к link_client_plugin_t:


  • В: когда происходит связывание (отвязывание) актров? О: когда актор в состоянии initializing (shutting down).


  • В: что случится, если целевой актор не существует или отказывает в связывании? О: т. к. это случается во время инициализации актора, то плагин обнаружит это условие и начнёт выключение актора-клиента; также, возможно, это вызовет каскадный эффект, т.е. его супервайзер тоже выключится и так далее вверх по иерархии владения.


  • В: что случится, если целевой актор решит выключиться, при том, что с ним связаны активные акторы-клиенты? О: актор-сервер попросит клиентов отвязаться, и только когда все связанные клиенты подтвердят это, актор-сервер продолжит процедуру выключения (3).



Упрощённый пример


Будем предполагать, что имеется драйвер базы данных с асинхронным интерфейсом для одного из движков событий (event loop), доступных для rotor'а, а также что имеются TCP-клиенты, подключающиеся к нашему сервису. За обслуживание базы данных будет отвечать актор db_actor_t, а принимать клиентов будет acceptor_t. Начнём с первого:


namespace r = rotor;struct db_actor_t: r::actor_base_t {    struct resource {        static const constexpr r::plugin::resource_id_t db_connection = 0;    }    void configure(r::plugin::plugin_base_t &plugin) noexcept override {        plugin.with_casted<r::plugin::registry_plugin_t>([this](auto &p) {            p.register_name("service::database", this->get_address())        });        plugin.with_casted<r::plugin::resources_plugin_t>([this](auto &) {            resources->acquire(resource::db_connection);            // инициировать асинхронное соединение с базой данных        });    }    void on_db_connection_success() {        resources->release(resource::db_connection);        ...    }    void on_db_disconnected() {        resources->release(resource::db_connection);    }    void shutdown_start() noexcept override {        r::actor_base_t::shutdown_start();        resources->acquire(resource::db_connection);        // асинхронное закрытие соединения с базой данных и сброс данных    }};

Внутреннее пространство имён resource используется для идентификации соединения с БД как ресурсом. Это общепринятая практика, чтобы не использовать в коде магические цифры вроде 0. Во время конфигурации, которая является частью инициализации, когда плагин registry_plugin_t готов, он асинхронно зарегистрирует адрес актора в регистре (о нём будет рассказано позже). Затем с помощью resources_plugin_t захватывается "ресурс" подключения к БД, чтобы блокировать дальнейшую инициализацию актора и начинается соединение с БД. Когда будет подтверждено соединение с БД, ресурс будет освобождён и актор db_actor_t перейдёт в рабочее состояние. S-фаза аналогична: блокируется выключение до тех пор, пока все данные не будут сброшены в БД и пока соединение не будет закрыто; после этого процедура выключения актора завершается (4).


Код актора, который будет принимать клиентов, выглядит приблизительно так:


namespace r = rotor;struct acceptor_actor_t: r::actor_base_t {    r::address_ptr_t db_addr;    void configure(r::plugin::plugin_base_t &plugin) noexcept override {        plugin.with_casted<r::plugin::registry_plugin_t>([](auto &p) {            p.discover_name("service::database", db_addr, true).link();        });    }    void on_start() noexcept override {        r::actor_base_t::on_start();        // начать приём клиентов, например:        // asio::ip::tcp::acceptor.async_accept(...);    }    void on_new_client(client_t& client) {        // send<message::log_client_t>(db_addr, client)    }};

Основное в данном случае, это метод configure. Когда плагин registry_plugin_t готов, он будет сконфигурирован на обнаружение сервиса service::database, а когда адрес db_actor_t будет найден и сохранён в члене класса db_addr, то тогда с ним будет произведено связывание. Если же адрес актора service::database не будет обнаружен, то актор acceptor_actor_t начнёт выключаться (т. е. on_start не будет вызван). Если всё будет успешно проинициализировано, то актор начнёт принимать новых клиентов.


Собственно, сама операционная часть была опущена ради краткости, т. к. она не изменилась в новой версии rotor'а. Как обычно нужно определить полезную нагрузку (payload), сообщения, методы для работы с этими сообщениями и подписаться на них.


Скомпонуем всё вместе в файле main.cpp; будем считать, что используется boost::asio в качестве цикла событий.


namespace asio = boost::asio;namespace r = rotor;...asio::io_context io_context;auto system_context = rotor::asio::system_context_asio_t(io_context);auto strand = std::make_shared<asio::io_context::strand>(io_context);auto timeout = r::pt::milliseconds(100);auto sup = system_context->create_supervisor<r::asio::supervisor_asio_t>()               .timeout(timeout)               .strand(strand)               .create_registry()               .finish();sup->create_actor<db_actor_t>().timeout(timeout).finish();sup->create_actor<acceptor_actor_t>().timeout(timeout).finish();sup->start();io_context.run();

Как видно, в новом rotor'е активно используется шаблон builder. С помощью него создаётся корневой супервайзер sup, а уже он в свою очередь порождает 3 актора: два пользовательских (db_actor_t и acceptor_actor_t) и неявно созданный актор-регистр. Как обычно для акторных систем, все акторы слабо связаны друг с другом, т.к. они разделяют только общий интерфейс сообщений (опущено в статье).


Акторы "просто создаются" в данном месте, без знания о том, как они связаны между собой. Это следствие слабой связанности между акторами, которые с версии v0.09 стали более автономными.


Конфигурация выполнения могла бы быть полностью другой: акторы могли бы создаваться на разных потоках, на разных супервайзерах, и даже на различных движках, но их реализация оставалась бы по сути одной и той же (5). В этих случаях было бы несколько корневых супервайзеров, однако актор-регистр должен быть создан один, и его адрес разделён между всеми супервайзерами, чтобы акторы смогли найти друг друга. Для этого существует метод get_registry_address() в базовом супервайзере.


Итоги


Наиболее существенным изменением в новой версии rotor'а является разбиение на плагины его ядра. Наиболее важными для пользователя фрейморка являются плагины: link_client_plugin_t, позволяющий установить виртуальное соединение между акторами, плагин registry_plugin_t, дающий возможность регистрации и обнаружения адресов акторов по символическим именам, а также плагин resources_plugin_t, с помощью которого можно приостановить процедуру инициализации (выключения) до появления некоторого асинхронного внешнего события.


Так же присутствуют менее важные изменения в новом релизе, такие как система доступа к непубличным
свойствам и шаблон builder для интанцирования акторов.


Любая обратная связь приветствуется.


P.S. Я хотел бы поблагодарить Crazy Panda за поддержку в моих начинаниях по развитию данного проекта, а также автора sobjectizer'а за высказанные им критические замечания.


Примечания


(1) В настоящее время попытка доставки сообщения актору, супервайзер которого уже отработал и был удалён, ведёт к краху программы (UB).


(2) Если актор не подтвердит успешную инициализацию супервайзеру, сработает таймер инициализации, и супервайзер сделает запрос на выключение актора, т.е. состояние operational будет пропущено.


(3) Может возникнуть вопрос, что произойдёт, если актор не подтвердит отвязывание вовремя? Это нарушение контракта, и будет вызван метод system_context_t::on_error(const std::error_code&), который распечатает ошибку на консоль и вызовет std::terminate(). Для избегания нарушений контрактов, нужно настраивать таймеры, чтобы позволить акторам-клиентам во время отвязаться.


(4) Во время процедуры выключения плагин registry_plugin_t проинструктирует регистр, чтобы все зарегистрированные имена текущего актора были удалены из регистра.


(5) Исключение составляет, когда используются различные циклы событий. Если актор использует API цикла событий, то, очевидно, что смена цикла событий повлечёт переписывание внутренностей актора. Тем не менее, это никак не затронет использование API rotor'а.

Подробнее..

Topleaked для анализа утечек памяти

27.08.2020 14:07:16 | Автор: admin


Что делает большинство программистов, когда узнают, что в их программе течёт память? Ничего, пусть пользователь покупает больше оперативы. Посмею предположить, что берут надёжный проверенный временем инструмент, такой как valgrind или libasan, запускают и смотрят отчёт. Там обычно написано, что объекты созданные на такой-то строчке программы такого-то файла не были освобождены. А почему? Вот этого нигде не написано.
Данный пост посвящён инструменту поиска утечек topleaked, идее статистического анализа, лежащего в его основе, и методам применения этого анализа.


Про topleaked я уже писал на хабре, но всё же повторю основную идею в общих чертах. Если какие-то объекты не освобождаются, то они копятся в памяти. А значит у нас много однородных, похожих друг на друга последовательностей. Если утечёт больше, чем реально используется, то самые частые из них это части утёкших объектов. Обычно в программах на C++ находятся указатели на vtbl классов. Таким образом можно выяснить, объекты какого типа мы забываем освобождать. Понятное дело, что в топе много мусора, часто встречающихся строк, да и тот же valgrind расскажет нам, что и где утекло гораздо лучше. Но topleaked изначально создавался не для того, чтобы соперничать с отработанными годами технологиями. Он был придуман, как инструмент решения задачи, которая ничем другим не решается анализ невоспроизводимых утечек. Если в тестовом окружении повторить проблему не удаётся, то любой динамический анализ бесполезен. Если ошибка возникает только "в бою", да ещё и нестабильно, то максимум, что мы можем получить логи и дамп памяти. Вот этот дамп можно анализировать в topleaked.


Упрощённый пример использования из прошлого поста

Возьмём простую C++ программу с утечкой памяти, которая сама завершится с записью дампа памяти из-за abort()


#include <iostream>#include <assert.h>#include <unistd.h>class A {    size_t val = 12345678910;    virtual ~A(){}};int main() {    for (size_t i =0; i < 1000000; i++) {        new A();    }    std::cout << getpid() << std::endl;    abort();}

Запустим topleaked


./toleaked leak.core

Формат вывода по умолчанию построчный топ в человекочитаемом виде.


0x0000000000000000 : 10503470x0000000000000021 : 10000030x00000002dfdc1c3e : 10000000x0000558087922d90 : 10000000x0000000000000002 : 1980x0000000000000001 : 1800x00007f4247c6a000 : 1640x0000000000000008 : 1600x00007f4247c5c438 : 1530xffffffffffffffff : 141

Пользы от него мало, разве что мы можем увидеть число 0x2dfdc1c3e, оно же 12345678910, встречающееся миллион раз. Уже этого могло бы хватить, но хочется большего. Для того, чтобы увидеть имена классов утекших объектов, можно отдать результат в gdb простым перенаправлением стандартного потока вывода на вход gdb с открытым файлом дампа. -ogdb опция, меняющая формат на понятный gdb.


$ ./topleaked -n10 -ogdb /home/core/leak.1002.core | gdb leak /home/core/leak.1002.core...<много текста от gdb при запуске>#0  0x00007f424784e6f4 in __GI___nanosleep (requested_time=requested_time@entry=0x7ffcfffedb50, remaining=remaining@entry=0x7ffcfffedb50) at ../sysdeps/unix/sysv/linux/nanosleep.c:2828      ../sysdeps/unix/sysv/linux/nanosleep.c: No such file or directory.(gdb) $1 = 1050347(gdb) 0x0:      Cannot access memory at address 0x0(gdb) No symbol matches 0x0000000000000000.(gdb) $2 = 1000003(gdb) 0x21:     Cannot access memory at address 0x21(gdb) No symbol matches 0x0000000000000021.(gdb) $3 = 1000000(gdb) 0x2dfdc1c3e:      Cannot access memory at address 0x2dfdc1c3e(gdb) No symbol matches 0x00000002dfdc1c3e.(gdb) $4 = 1000000(gdb) 0x558087922d90 <_ZTV1A+16>:       0x87721bfa(gdb) vtable for A + 16 in section .data.rel.ro of /home/g.smorkalov/dlang/topleaked/leak(gdb) $5 = 198(gdb) 0x2:      Cannot access memory at address 0x2(gdb) No symbol matches 0x0000000000000002.(gdb) $6 = 180(gdb) 0x1:      Cannot access memory at address 0x1(gdb) No symbol matches 0x0000000000000001.(gdb) $7 = 164(gdb) 0x7f4247c6a000:   0x47ae6000(gdb) No symbol matches 0x00007f4247c6a000.(gdb) $8 = 160(gdb) 0x8:      Cannot access memory at address 0x8(gdb) No symbol matches 0x0000000000000008.(gdb) $9 = 153(gdb) 0x7f4247c5c438 <_ZTVN10__cxxabiv120__si_class_type_infoE+16>:     0x47b79660(gdb) vtable for __cxxabiv1::__si_class_type_info + 16 in section .data.rel.ro of /usr/lib/x86_64-linux-gnu/libstdc++.so.6(gdb) $10 = 141(gdb) 0xffffffffffffffff:       Cannot access memory at address 0xffffffffffffffff(gdb) No symbol matches 0xffffffffffffffff.(gdb) quit

Читать не очень просто, но возможно. Строки вида $4 = 1000000 отражают позицию в топе и количество найденных вхождений. Ниже идут результаты запуска x и info symbol для значения. Тут мы можем видеть, что миллион раз встречается vtable for A, что соответствует миллиону утекших объектов класса A.


Про всё это я уже писал. Как верно отметили в комментариях, идее уже сто лет в обед, ну или как минимум 15. История на этом не заканчивается, она только начинается.


Ясно что, но почему?


До ката был поставлен важный вопрос почему память утекает? Отладочные утилиты как правило говорят, где был объект или массив создан, но не где он должен быть удалён. И topleaked тут не исключение. Понять, почему тот или иной кусок памяти не был освобождён, может только программист, найдя ошибочный сценарий. Но что если пойти дальше поиска типов? Если мы можем пройтись по всем объектам, которые считаем утёкшими, то мы можем искать общие черты среди них. Приведу реальный пример, историю, ради которой была написана новая фича.


Ближе к делу и проблемам


Есть сервис. Он существенно нагружен, через него проходят сотни тысяч пользователей. Утечка любой мелочи на каждый запрос или подключение смерти подобна взорвётся за считанные минуты. Сервис был отлажен и проработал 3 месяца без перезапуска процесса. Не рекордный аптайм, но всё же. И вот через 3 месяца мы выясняем, что всё это время он по чуть-чуть подтекал. Вроде бы мелочь, он превысил своё штатное потребление раза в 2-3 перезапустил и забыл. Но вместе с памятью текли файловые дескрипторы. Поскольку сервис полностью сетевой, то эти дескрипторы незакрытые сокеты, а значит у нас проблема в логике. Сервис написан почти полностью на C++ с очень небольшими вкраплениями перла. Это на самом деле мало влияет на последующее повествование, но от конкретики отталкиваться проще. С тем же успехом можно было допустить ту же ошибку на C, D, Rust, Go или NodeJS. И искать её можно точно так же, разве что с js были бы проблемы.


Метод глядения в код нам ничего не дал. Все возможные, как мне тогда казалось, сценарии использования сетевых соединений приводят к потере ссылок на объект, что благодаря умному указателю приводит к деструктору, который безусловно сделает close. Анализ и мониторинг дал оценку, что не закрывается примерно каждый сотый сокет. Сессии долгие (игровые сессии клиентов игры), поэтому для того, чтобы упереться в ограничение открытых fd на процесс (512000 в нашем случае) понадобились месяцы. Найти в логах признаки от этих незакрытых клиентов тоже не удавалось. На первый взгляд всё открытое закрывалось. Смотреть было больше некуда, и я полез читать дамп памяти процесса, снятый незадолго до достижения максимума открытых соединений.


Набираем статистику


Первый запуск topleaked сообщил очевидный факт утекают объекты клиентских подключений. Спасибо, капитан, это мы уже и так знали по незакрытым сокетам. Нас интересует специфика этих подключений, ведь основная масса исправно помирает, когда положено. И вот тут зародилась идея: что если пройтись по всем этим объектам в дампе и посмотреть их состояние. В данном случае у нас в классе было свойство state enum, отвечающий за логическое состояние клиента. Условно говоря: не подключен, подключен, прошёл хэндшейк websocket, прошла авторизация. Если знать, из какого состояния объекты утекают, то и искать проще.


Тут есть загвоздка. Topleaked не понимает форматов дампов, он просто открывает файл как бинарный поток, режет по 8 байт и строит топ самых частых 8-байтовых последовательностей. Это не какой-то сложный замысел, так было проще написать первую версию, ну а дальше нет ничего более постоянного, чем что-то временное. Вот только из-за отсутствия структуры невозможно понять, где лежат нужные нам значения. Всё, что у нас есть это значение указателя на vtbl, интересующего нас класса. А ещё мы знаем, что эти указатели, как и все свойства лежат в объекте. То есть можно поискать в дампе интересующий указатель на vtbl и по какому-то смещению относительно найденной позиции в файле будет лежать state. Это смещение фиксированное, так как зависит только от лейаута класса. Осталось только найти это смещение.


В случае C++ есть проблема отсутствие ABI или каких-нибудь внятных правил расположения свойств в объектах. Для POD или trivial типов есть чёткие правила ещё из мира C. А вот расположение указателя на виртуальную таблицу, как и само существование виртуальной таблицы, не стандартизировано. К счастью на практике всё просто. Если не сильно мудрить с множественным наследованием и рассматривать конечный класс в иерархии, то на linux gcc выяснится, что vtbl первое свойство объекта. А значит offsetof(state) и есть наше смещение. На более простом примере это выглядит так:


struct Base {    virtual void foo() = 0;};struct Der : Base {    size_t a = 15;    void foo() override {    }};int main(){    for (size_t i = 0; i < 10000; ++i) {        new Der;    }    auto d = new Der;    cout << offsetof(Der, a) << endl;    abort();    return 0;}

Здесь мы распечатали offsetof Der::a, утекли 10000 объектов и упали. Для начала запустим topleaked в штатном режиме


topleaked  my_core.core0x0000000000000000 : 501240x000000000000000f : 100050x0000000000000021 : 100040x000055697c45cd78 : 100020x0000000000000002 : 1950x0000000000000001 : 1820x00007fe9cbd6c000 : 1670x0000000000000008 : 1610x00007fe9cbd5e438 : 1540x0000000000001000 : 112

0x000055697c45cd78 это указатель на vtbl класса Der. offsetof равен 8. Значит нужно поискать этот указатель, отступить на 8 и прочитать значение. Для поиска воспользуемся отдельным режимом работы topleaked поиском. Флаг -f отвечает за то, что будем искать в дампе, --memberOffset смещение интересующего поля относительно найденного в -f, а --memberType тип поля. Поддерживаются uint8, uint16, uint32 и uint64.


topleaked my_core.core -f0x55697c45cd78 --memberOffset=8 --memberType=uint64

Получаем:


0x000000000000000f : 100010x000055697ccaa080 : 1

Мы видим 10000 значений 0x0f, которые сами и записали, а так же небольшой шум.


Happy End


В реальной ситуации всё работает примерно так же. Сначала в тестовом окружении я убедился, что смещение корректно и поиск находит то, что нужно, а потом запустил на реальном дампе. Полученный вывод сначала удивил, а потом порадовал. Нашлось несколько тысяч авторизованных клиентов, цифры соответствовали количеству онлайн пользователей на момент падения. Но самое главное, что нашлись сотни тысяч не просто неавторизованных, а объектов в самом первом состоянии. Это состояние означает, что клиенты подключились к серверу по TCP, но не послали ни байта ни websocket upgrade, ни чего-нибудь неожиданного. Они подключились и молчали. Это самое простое место для отладки нашего кода минимум, значит и ошибаться негде. Оказалось всё просто, автор кода (каюсь, это был я) не понимал гарантий TCP. Если не включать дополнительных опций и не пытаться ничего делать с сокетом, то невозможно никак понять, что он отключился. Нет встроенных пингов или таймаутов неактивности по умолчанию. Есть только расширение, которое все поддерживают, но которое выключено TCP Keep Alive. Подробнее можно прочитать https://blog.cloudflare.com/when-tcp-sockets-refuse-to-die/


Самое неприятное то, что на самом деле мы про это знали. В игровой протокол добавлена похожая логика с проверкой неактивных сокетов, и она работает. Только включается она после установления websocket соединения. Поэтому и утечке были подвержены только те, кто потерял связь до посылки первого пакета.


Ещё немного про D


Не могу не отметить, как просто было добавить описанный выше функционал. Если посмотреть коммит, то мы увидим, что для каждого поддерживаемого типа данных (uint 8/16/32/64) добавилось по строке:


readFile(name, offset, limit)    .findMember!uint64_t(pattern, memberOffset)    .findMostFrequent(size).printResult(format);

findMember новая функция, реализующая смещение, а findMostFrequent та же самая функция, которая строит топ самых частых значений. Благодаря шаблонам и алгоритмам на диапазонах (ranges) не потребовалось ничего менять. При том, что изначально эта функция работала с массивами, а теперь ей отдали весьма своеобразный итератор, который ищет и прыгает по файлу.


Инструкция по установке

Бинарных сборок нет, поэтому так или иначе понадобится собрать проект из исходников. Для этого потребуется компилятор D. Варианта три: dmd референсный компилятор, ldc основанный на llvm и gdc, входящий в gcc, начиная с 9-й версии. Так что, возможно, вам не придётся ничего устанавливать, если есть последний gcc. Если же устанавливать, то я рекомендую ldc, так как он лучше оптимизирует. Все три можно найти на официальном сайте.
Вместе с компилятором поставляется пакетный менеджер dub. При помощи него topleaked устанавливается одной командой:


dub fetch topleaked

В дальнейшем для запуска будем использовать команду:


dub run topleaked -brelease-nobounds -- <filename> [<options>...]

Чтобы не повторять dub run и аргумент компилятора brelease-nobounds можно скачать исходники с гитхаба и собрать запускаемый файл:


dub build -brelease-nobounds

В корне папки проекта появится запускаемый topleaked.


Сcылка на гитхаб


P.S. Спасибо Crazy Panda за возможность делать и использовать такие штуки в работе, а также за мотивацию к написанию постов. Иначе бы текст пылился еще год на жёстком диске, как это было с прошлым постом про topleaked.

Подробнее..

Игровая статика, или как я перестал бояться и полюбил Google Apps Script

07.08.2020 14:13:35 | Автор: admin


Приветствую! Сегодня хотелось бы поговорить на одну тему, с которой так или иначе сталкивается любой гейм-дизайнер. И эта тема боль и страдания работа со статикой. Что такое статика? Если вкратце это все постоянные данные, с которыми взаимодействует игрок, будь то характеристики его оружия или параметры подземелья и его обитателей.

Представьте, у вас в игре есть 100500 видов различных мечей и им всем внезапно потребовалось немного поднять базовый урон. Обычно, в таком случае запрягается старый добрый эксель, а результаты затем вставляются в JSON/XML руками или при помощи регулярок, но это долго, хлопотно и чревато ошибками валидации.

Давайте посмотрим, как для таких целей может подходить Google Spreadsheets и встроенный в него Google Apps Script и можно ли на этом сэкономить время.

Заранее оговорюсь, что речь идет о статике для f2p-игр или игр-сервисов, которым свойственны регулярные обновления механик и пополнение контента, т.е. указанный выше процесс постоянен.

Итак, для редактирования тех же мечей вам потребуется выполнить три операции:
  1. извлечь текущие показатели урона (если у вас нет готовых расчетных таблиц);
  2. рассчитать обновленные значения в старом добром экселе;
  3. перенести новые значения в игровые JSON-ы.

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

С хранением все еще более-менее понятно и стандартизировано: в большинстве случаев статика это просто набор отдельных JSON-ов, лежащий где-то в VCS. Бывают, конечно, более экзотические случаи, когда все хранится в реляционной (или не очень) базе, или, что самое страшное, в XML. Но, если вы выбрали их, а не обычный JSON, то скорее всего у вас уже есть весомые на то основания, т.к. производительность и удобство использования этих вариантов весьма сомнительны.

А вот что касается структуры статики и ее редактирования изменения будут зачастую радикальные и ежедневные. Конечно, в некоторых ситуациях ничто не заменит по эффективности обычный Notepad++ вкупе с регулярками, но нам все же хочется иметь инструмент с более низким порогом входа и удобством для редактирования командой.

В качестве подобного инструмента лично мне подошел банальный и многим известный Google Spreadsheets. Как и у любого инструмента, у него есть свои плюсы и минусы. Попробую рассмотреть их с точки зрения ГД.

Плюсы Минусы
  • Совместное редактирование
  • Удобно переносить расчеты из других спредшитов
  • Макросы (Google Apps Script)
  • Есть история редактирования (вплоть до ячейки)
  • Родная интеграция с Google Drive и прочими сервисами

  • Лагает при большом количестве формул
  • Нельзя создавать отдельные ветки изменений
  • Лимит времени отработки скриптов (6 минут)
  • Сложность в отображении nested JSON-ов


Для меня плюсы значительно перевешивали минусы, и в связи с этим было решено попробовать найти обходной путь по каждому из представленных минусов.

Что получилось в итоге?


В Google Spreadsheets сделан отдельный документ, в котором есть лист Main, где мы управляем выгрузкой, и остальные листы, по одному на каждый игровой объект.
При этом, чтобы привычный nested JSON уложить в плоскую таблицу, пришлось немного переизобрести велосипед. Допустим, мы имели следующий JSON:

{  "test_craft_01": {    "id": "test_craft_01",    "tags": [ "base" ],"price": [ {"ident": "wood", "count":100}, {"ident": "iron", "count":30} ],"result": {"type": "item","id": "sword","rarity_wgt": { "common": 100, "uncommon": 300 }}  },  "test_craft_02": {    "id": "test_craft_02","price": [ {"ident": "sword", "rarity": "uncommon", "count":1} ],"result": {"type": "item","id": "shield","rarity_wgt": { "common": 100 }}  }}


В таблицах такую структуру можно представить как пару значений полный путь значение. Отсюда родился самопальный язык разметки пути, в котором:

  • text это поле или объект
  • / разделитель иерархии
  • text[] массив
  • #number индекс элемента в массиве


Таким образом, в таблицу JSON будет записан следующим образом:


Соответственно, добавление нового объекта такого типа это еще один столбец в таблице и, если у объекта были какие-то особые поля, то расширение списка строк с ключами в keypath.

Разделение на root и остальные уровни это дополнительное удобство в целях использования фильтров в таблице. В остальном работает простое правило: если значение в объекте не пустое, то мы его добавим в JSON и выгрузим.
На случай, если же в JSON будут добавляться новые поля и кто-то ошибется в пути он проверяется следующей регуляркой на уровне условного форматирования:
=if( LEN( REGEXREPLACE(your_cell_name, "^[a-zA_Z0-9_]+(\[\])*(\/[a-zA_Z0-9_]+(\[\])*|\/\#*[0-9]+(\[\])*)*", ""))>0, true, false)

А теперь о том, как происходит выгрузка. Для этого необходимо перейти на лист Main, выбрать желаемые объекты для выгрузки в столбце #ACTION и
нажать на Палпатина ( )


В результате будет запущен скрипт, который возьмет данные с листов, указанных в поле #OBJECT, и выгрузит их в JSON. Путь для выгрузки указан в поле #PATH, а место, куда будет выгружен файл, это ваш личный Google Drive, привязанный к учетной записи Google, под которой вы просматриваете документ.

Поле #METHOD позволяет настроить, как именно требуется выгрузить JSON:
  • Если single выгружается один файл с названием, равным названию объекта (без эмодзи, конечно же, они тут только для читаемости)
  • Если separate каждый объект с листа будет выгружен в отдельный JSON.

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

При попытке реализовать честный вызов метода экспорта я столкнулся с интересной особенностью спредшитов: повесить вызов функции на картинку можно, а указать в вызове этой функции аргументы нельзя. После небольшого периода фрустрации было принято решение продолжить эксперимент с велосипедом и родилась идея разметки самих листов с данными.
Так, например, в таблицах появились якоря ###data### и ###end_data### на листах с данными, по которым определяются области атрибутов для выгрузки.

Исходники


Соответственно, как выглядит сбор JSON-a на уровне кода:
  1. Берем поле #OBJECT и ищем все данные листа с таким именем
    var sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(name)
    
  2. Ищем координаты основных якорей, по которым будем фильтровать данные листа (идем по рэнжу как по двумерному массиву, пока не найдем ячейку со значением == тексту якоря)
    function GetAnchorCoordsByName(anchor, data){  var coords = { x: 0, y: 0 }    for(var row=0; row<data.length; row++){    for(var column=0; column<data[row].length; column++){      if(data[row][column] == anchor){        coords.x = column;        coords.y = row;        }    }  }  return coords;}
    
  3. Отрезаем столбцы объектов, которые выгружать не потребуется (для них в строке с якорем ###enable### можно изначально выставить true|false)
    function FilterActiveData(data, enabled){    for(var column=enabled.x+1; column<data[enabled.y].length; column++){    if(!data[enabled.y][column]){      for(var row=0; row<data.length; row++){        data[row].splice(column, 1);      }      column--;    }  }  return data}
    
  4. Отрезаем строки за пределами якорей ###data### и ###end_data###
    function FilterDataByAnchors(data, start, end){  data.splice(end.y)  data.splice(0, start.y+1);    for(var row=0; row<data.length; row++){    data[row].splice(0,start.x);  }  return data;}
    
  5. Забираем данные первого столбца в качестве ключей наших атрибутов
    function GetJsonKeys(data){  var keys = [];    for(var i=1; i<data.length; i++){    keys.push(data[i][0])  }  return keys;}
    
  6. Пробегаемся по каждому столбцу и создаем на каждый из них по объекту
    //На вход получаем отфильтрованные значения. //В случае, если экспорт идет как single-file, - сюда приходят все столбцы с листа. //Иначе - метод вызывается столько раз, сколько будет создано separate JSON-овfunction PrepareJsonData(filteredData){  var keys = GetJsonKeys(filteredData)    var jsonData = [];  for(var i=1; i<filteredData[0].length; i++){    var objValues = GetObjectValues(filteredData, i);       var jsonObject = {      "objName": filteredData[0][i],      "jsonBody": ParseToJson(keys, objValues)    }    jsonData.push(jsonObject)  }    return jsonData;}//Упаковываем в JSON конкретный столбец (пары ключ-значение)function ParseToJson(fields, values){  var outputJson = {};  for(var field in fields){    if( IsEmpty(fields[field]) || IsEmpty(values[field]) ){       continue;     }    var key = fields[field];    var value = values[field];        var jsonObject = AddJsonValueByPath(outputJson, key, value);  }  return outputJson;}//Добавляем конкретный атрибут в JSON по его полному путиfunction AddJsonValueByPath(jsonObject, path, value){  if(IsEmpty(value)) return jsonObject;    var nodes = PathToArray(path);  AddJsonValueRecursive(jsonObject, nodes, value);    return jsonObject;}//Разбиваем string с адресом поля на сегментыfunction PathToArray(path){  if(IsEmpty(path)) return [];  return path.split("/");}//Рекурсивно проверяем, существует ли нода адреса, и если нет - добавляемfunction AddJsonValueRecursive(jsonObject, nodes, value){  var node = nodes[0];    if(nodes.length > 1){    AddJsonNode(jsonObject, node);    var cleanNode = GetCleanNodeName(node);    nodes.shift();    AddJsonValueRecursive(jsonObject[cleanNode], nodes, value)  }  else {    var cleanNode = GetCleanNodeName(node);    AddJsonValue(jsonObject, node, value);  }  return jsonObject;}//Добавляем ранее не существовавшую ноду в JSON. Индексы массивов обрабатываются отдельно.function AddJsonNode(jsonObject, node){  if(jsonObject[node] != undefined) return jsonObject;  var type = GetNodeType(node);  var cleanNode = GetCleanNodeName(node);    switch (type){    case "array":      if(jsonObject[cleanNode] == undefined) {        jsonObject[cleanNode] = []      }      break;    case "nameless":       AddToArrayByIndex(jsonObject, cleanNode);      break;    default:        jsonObject[cleanNode] = {}  }  return jsonObject;}//Добавляем новый объект в массив по указанному индексуfunction AddToArrayByIndex(array, index){  if(array[index] != undefined) return array;    for(var i=array.length; i<=index; i++){    array.push({});  }  return array;}//Заполняем конечный атрибут значением (после того, как проверен полный путь до атрибута)function AddJsonValue(jsonObject, node, value){  var type = GetNodeType(node);  var cleanNode = GetCleanNodeName(node);  switch (type){    case "array":      if(jsonObject[cleanNode] == undefined){        jsonObject[cleanNode] = [];      }      jsonObject[cleanNode].push(value);      break;    default:      jsonObject[cleanNode] = value;  }  return jsonObject}//Узнаем тип ноды.//Если object - будем добавлять вложенные ключи по дефолту//Если array - проверяем его наличие и создаем, если его нет//Если nameless - проверяем в массиве выше наличие объекта с соответствующим индексом, и если такого нет - создаемfunction GetNodeType(key){  var reArray       = /\[\]/  var reNameless    = /#/;    if(key.match(reArray) != null) return "array";  if(key.match(reNameless) != null) return "nameless";    return "object";}//Вычищаем из имени ноды псевдоразметку для указания конечного значения уже в JSONfunction GetCleanNodeName(node){  var reArray       = /\[\]/;  var reNameless    = /#/;    node = node.replace(reArray,"");    if(node.match(reNameless) != null){    node = node.replace(reNameless, "");    node = GetNodeValueIndex(node);  }  return node}//Извлекаем индекс объекта массива из nameless-объектаfunction GetNodeValueIndex(node){  var re = /[^0-9]/  if(node.match(re) != undefined){    throw new Error("Nameless value key must be: '#[0-9]+'")  }  return parseInt(node-1)}
    
  7. Полученный JSON передаем для создания соответствующего файла в Google Drive
    //Основной метод, в который необходимо передать: путь, имя файла (с расширением) и string с данными.function CreateFile(path, filename, data){  var folder = GetFolderByPath(path)     var isDuplicateClear = DeleteDuplicates(folder, filename)  folder.createFile(filename, data, "application/json")  return true;}//Ищем конкретную папку в GoogleDrive по полному путиfunction GetFolderByPath(path){  var parsedPath = ParsePath(path);  var rootFolder = DriveApp.getRootFolder()  return RecursiveSearchAndAddFolder(parsedPath, rootFolder);}//Разбиваем полный путь к папке на сегментыfunction ParsePath(path){  while ( CheckPath(path) ){    var pathArray = path.match(/\w+/g);    return pathArray;  }  return undefined;}//Проверяем валидность переданного на вход путиfunction CheckPath(path){  var re = /\/\/(\w+\/)+/;  if(path.match(re)==null){    throw new Error("File path "+path+" is invalid, it must be: '//.../'");  }  return true;}//Если вдруг в папке уже есть файл с таким именем, с которым мы хотим создать файл, - маркируем старый на удаление. //Иначе - получим дублирование файлов, т.к. старый сам не удалитсяfunction DeleteDuplicates(folder, filename){  var duplicates = folder.getFilesByName(filename);    while ( duplicates.hasNext() ){    duplicates.next().setTrashed(true);  }}//Штатной возможности поиска по пути нет, поэтому мы идем от корневого раздела вниз до конечного, ища каждый сегмент пути по имениfunction RecursiveSearchAndAddFolder(parsedPath, parentFolder){  if(parsedPath.length == 0) return parentFolder;     var pathSegment = parsedPath.splice(0,1).toString();  var folder = SearchOrCreateChildByName(parentFolder, pathSegment);    return RecursiveSearchAndAddFolder(parsedPath, folder);}//Ищем в parent папку name, и если нет - создаемfunction SearchOrCreateChildByName(parent, name){  var childFolder = SearchFolderChildByName(parent, name);     if(childFolder==undefined){    childFolder = parent.createFolder(name);  }  return childFolder}//Перебираем итератор файлов в parent на предмет соответствия name на входеfunction SearchFolderChildByName(parent, name){  var folderIterator = parent.getFolders();    while (folderIterator.hasNext()){    var child = folderIterator.next();    if(child.getName() == name){       return child;    }  }  return undefined;}
    


Готово! Теперь идем в Google Drive и забираем там свой файлик.

Для чего была нужна возня с файлами в Google Drive, и почему не постить сразу в гит? В основном только для того, чтобы можно было проверять файлы до того, как они улетели на сервер и совершили непоправимое. В будущем быстрее будет пушить файлы напрямую.
Чего нормально решить не удалось: при проведении различных A/B-тестов всегда возникает необходимость создавать отдельные ветки статики, в которых меняется часть данных. Но так как по сути это еще одна копия дикта, мы можем для A/B-теста копировать сам спредшит, поменять данные в нем и уже оттуда выгружать данные для теста.

Заключение.


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

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

Основным бутылочным горлышком производительности становится API Google Drive: поиск и удаление/создание файлов занимает максимальное время, здесь помогает только выгрузка не всех файлов сразу или выгрузка листа не отдельными файлами, а единым JSON-ом.

Надеюсь, подобный клубок извращений будет полезным для тех, кто все еще редактирует JSON-ы руками и регулярками, а также выполняет балансные расчеты статики в Excel вместо Google Spreadsheets.

Ссылки:


Пример спредшита-экспортера
Ссылка на проект в Google Apps Script
Подробнее..

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru