LINUX.ORG.RU

Сериализация/десериализация и экономия на спичках

 ,


0

3

Всем привет.

Имеется некий клиент-серверный проект. Для обмена данными между клиентом и сервером применяется некий бинарный протокол обмена.

Преобразование структур данных в байты осуществляется примерно следующим образом: сперва высчитывается, сколько структура займёт байт, после чего конструируется QByteArray заданного размера и в этот самый QByteArray через memcpy вставляются поля структуры. В случае десериализации соответственно, обратно через memcpy заполняются поля структуры.

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

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

Может кто поделиться опытом, есть ли смысл запариваться с подходом, который применяется сейчас (и потенциально плодит ошибки, которые не всегда сразу видно) или же пусть это дело фигачится через QDatastream, хоть и «медленнее», но зато не валится в случае чего?


Я делал на QDataStream исключительно из-за удобства - там уже написаны сериализаторы для большинства типов. Но он тебя не спасёт от проблем, если ты запишешь int32, а считаешь int64. По скорости не замерял. Перешёл на memcpy только потому что в проекте нет Qt.

ox55ff ★★★★★
()

Я конечно понимаю, что свой велосипед сериализации-десериализации роднее, но может лучше использовать либы типа msgpack или protobuf. Или и вовсе - json+gzip. У меня используется и вовсе xmlrpc + gzip уже более 15 лет. Добавление, изменение полей структуры обмена не приводит к неработоспособности старых бинарников. «Старые» бинарники просто «не видят» новых полей в полученной структуре.

sigurd ★★★★★
()
Последнее исправление: sigurd (всего исправлений: 1)

packed struct + reinterpret_cast.

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

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

В случае, если при передаче меняется endianess, то я бы завёл отдельные типы под примитивы в net order, в структуру помещал уже их, а при использовании разворачивал. Забыть не получится, компилятор заругает, что удобно. Ну и опять же, повышает наглядность, в особенности когда делали рукожопы и endianess смешанная (привет modbus).

Для массивов переменной длины можно использовать пустой массив, как часть структуры, но только если он идёт последним. Всё, что идёт после него (crc, например), придётся получать уже функциями.

Для modbus будет выглядеть как-то так:

#include <cstdint>
#include <span>

using namespace std;

enum class int16_be: int16_t {};
enum class int32_be: int16_t {};
enum class int64_be: int16_t {};
enum class uint16_be: uint16_t {};
enum class uint32_be: uint16_t {};
enum class uint64_be: uint16_t {};

enum class Crc16_le: uint16_t {};
enum class RegAddr_be: uint16_t {};

enum class DevAddr: uint8_t {
	Broadcast = 0xFF,
};

enum class Cmd: uint8_t {
	// Known modbus commands.
};

struct MbReq {
	DevAddr     m_addr;
	Cmd         m_cmd;
	RegAddr_be  m_reg;
	uint16_be   m_val;
	Crc16_le    m_crc;

	Crc16_le    calc_crc() const;
};


struct MbAnswer {
	DevAddr     m_addr;
	Cmd         m_cmd;
	uint8_t     m_size;
	union {
		uint8_t     m_data8[];
		uint16_be   m_data16[];
		// Сюда же можно добавить различные варианты ответов, уже разобранные по полям.
	};

	uint8_t size() const { return sizeof(MbAnswer) + m_size; }

	Crc16_le read_crc() const {
		const uint8_t* raw = reinterpret_cast<const uint8_t*>(this) + m_size;
		return *reinterpret_cast<const Crc16_le*>(raw);
	}

	Crc16_le calc_crc() const;
};


void parse_answer(span<uint8_t> recv) {
	if (recv.size() < sizeof(MbAnswer) + sizeof(Crc16_le)) {
		return;
	}
	MbAnswer& ans = *reinterpret_cast<MbAnswer*>(recv.data());
	if (recv.size() != ans.size() + sizeof(Crc16_le)) {
		return;
	}
	span<uint16_be> data{ans.m_data16, (ans.m_size / 2)};
	// .....
}

На flexible array in union будет ругаться. Компиляторы вообще их не долюбливают, но clang и gcc жуют нормально. Меня это устраивает.

Если тебя не устраивает, то просто uint8_t data[], а уже его точно так же reinterpret_cast в нужное представление.

Ivan_qrt ★★★★★
()
Ответ на: комментарий от ox55ff

Но он тебя не спасёт от проблем, если ты запишешь int32, а считаешь int64.

Дык это ж поле структуры, с которым работа идёт по имени, тип у него один и тот же будет, что при сериализации, что при десериализации.

По скорости не замерял.

Я попробовал замерить, нагуглив в интернете считалку времени выполнения (поэтому правильность под вопросом). У меня получилось так, что на объёме в четыреста тысяч int64_t memcpy быстрее QDatastream раз в 12-13, на объёме в четыре миллиона int64_t быстрее уже в 5-6 раз.

Видимо, при росте объёма данных относительные накладные расходы на аллокацию памяти будут занимать всё меньшую долю. Ну, это такое, ламерское предположение.

s3rjke
() автор топика
Ответ на: комментарий от Ivan_qrt

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

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

s3rjke
() автор топика
Ответ на: комментарий от s3rjke

Ну тогда как вариант делать структуру с методом next(), который будет возвращать span на следующую пачку данных.

class Msg {
public:
    Msg(span<const uint8_t> recv): m_raw{recv}, m_pos{0} {}

    span<const uint8_t> next() {
        if (m_pos >= m_raw.size()) {
            return {};
        }
        size_t size = m_raw[m_pos++];
        if (m_pos + size > m_raw.size()) {
            return {};
        }
        auto res = span{&m_raw[m_pos], size};
        m_pos += size;
        return res;
    }

private:
    span<const uint8_t> m_raw;
    size_t m_pos;
};

Заголовок пакета можно так же структурой оставить, как выше, если он есть и постоянен.

Если есть теги, обозначающие типы данных, то из next возвращать не span, а структуру тег + спан. А на основе тега уже можно преобразовывать к соответствующему виду.

Ivan_qrt ★★★★★
()
Последнее исправление: Ivan_qrt (всего исправлений: 1)
Ответ на: комментарий от yoghurt

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

tz4678_2
()

ЯННП. Еслии структура POD, то ее можно копировать 1:1 целиком, зачем по полям разбирать? И std::vector какой нить вполне подходит под промежуточный буфер, там не так много аллокаций будет (к тому что не надо высчитывать размер каждый раз, это лишнее).

У меня давно работает лисапед для бинарных форматов:

  1. Есть семейство функций dump/load для записи/чтения в поток std::string, std::vector, std::map и пр.

  2. Есть возможность перегружать их для своих не-POD типов или указывать что такой то тип пишется/читается как POD хотя и не POD.

  3. есть обертка на variadic template, которая позволяет писать/читать несколько объектов.

В итоге запись/чтение выглядят как

A a;
B b[10]
std::vector<C> c;

dump(S, a, b, c);
...
load(S, a, b, c);

Все функции возвращают результат как true/false, при неудаче откатывают поток назад - это удобно если заранее не знаешь точно формата и может потребоваться несколько разных попыток чтения. Но это обычно для файлов.

AntonI ★★★★
()
Ответ на: комментарий от s3rjke

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

Каждая строка/вектор сериализуются как длина (в элементах) и дальше его элементы;-)

Если хочется уменьшить число операций чтения из сокета - накапливаете данные в промежуточном буфере и шлете сначала длину буфера. Но тут надо смотреть как сокет работает и не будет ли это лишней буферизацией.

AntonI ★★★★
()
Последнее исправление: AntonI (всего исправлений: 1)
Ответ на: комментарий от AntonI

Еслии структура POD, то ее можно копировать 1:1 целиком

m32 vs m64 и привет. Это мы ещё даже за пределы x86 не смотрели, а там начинаются LE vs BE, alignment и тому подобные радости.

bugfixer ★★★★
()
Ответ на: комментарий от yoghurt

Сами подходы тоже разные - от того что хочет ТС до какого нить pickle.

Я знаю только одну общую рекомендацию - знай что ты читаешь, а если не знаешь то имей возможность откатиться.

AntonI ★★★★
()
Ответ на: комментарий от AntonI

Но тут надо смотреть как сокет работает и не будет ли это лишней буферизацией.

В контексте TCP? Не будет. Надо всеми силами стремиться минимизировать количество send(). Делать send(len) + send(body) очень большая ошибка.

bugfixer ★★★★
()
Ответ на: комментарий от tz4678_2

заменил его альтернативно-одаренный формат передачи данных на общепринятый json или упаси б-же на xml

До-до. А потом все встает колом на сериализации/десериализации десятков ГБ данных. Ох уж эти пэхапэшники со своим манямирком и неистребимым стремлением пихать json/xml во все дыры…

AntonI ★★★★
()
Последнее исправление: AntonI (всего исправлений: 1)
Ответ на: комментарий от bugfixer

Ты когда то говорил, я помню. Я так глубоко не копал (у меня вообще это все поверх std::cin/cout работает) и честно говоря не знаю. Наверное это зависит от конкрентной реализации TCP?

AntonI ★★★★
()
Ответ на: комментарий от AntonI

Я давным давно не встречал m32 под linux (если ты про разрядность платформы или как там оно).

Не всё то солнышко что встаёт ;) Мы за m32 держимся до последнего (по многим причинам) и на m64 модули в проде переводятся только когда все остальные возможности уменьшить memory footprint конкретного инстанса и уместиться в 4GB исчерпаны. Так что не надо пока m32 со счетов списывать, ой не надо…

bugfixer ★★★★
()
Ответ на: комментарий от bugfixer

Ну дык у вас своя песочница которая весьма специфична, у нас своя. Что там у ТС знает только ТС.

Я кстати не в курсе, если собирать структуру из uint32_t и пр, она одинаковая будет под m32 и m64? Или все таки выравниваться может по разному?

AntonI ★★★★
()
Ответ на: комментарий от AntonI

Или все таки выравниваться может по разному?

Запросто может. Например double по разному выравнивается емнип. Но с этим можно бороться, всякие там packed итп. Если оставаться в мире x86 то всё сильно проще так как он действительно довольно всепрощающ. Есть архитектуры (спарки например) где misaligned 32bit register load приводит к bus error + coredump.

bugfixer ★★★★
()
Ответ на: комментарий от tz4678_2

В огороде бузина, в Киеве дядька… Вы вообще не о том.

json/xml я глазами тоже не читаю, а вызов наколенной бинарной сериализации от обращения к json в смысле API идеологически не отличается ничем.

AntonI ★★★★
()
Ответ на: комментарий от AntonI

Еслии структура POD, то ее можно копировать 1:1 целиком, зачем по полям разбирать?

Дык в полях лежат данные переменной длины, вектора, строки всякие.

У меня давно работает лисапед для бинарных форматов:

Ну вот у меня понемногу вырисовывается лисапедство, большой вопрос, насколько оно оправдано, когда есть встроенные классы фреймворка Qt, на котором я пишу. Хотя они и несколько медленнее, зато знакомы любому пользователю.

s3rjke
() автор топика
Ответ на: комментарий от s3rjke

Дык в полях лежат данные переменной длины, вектора, строки всякие.

Тогда по фен-шую для таких структур перегружаются dump/load.

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

А вот на этот вопрос я не возьмусь отвечать, поскольку не знаю специфики Ваших задач. Для моих задач лисапедство вполне оправдано, но у нас то своя специфика…

AntonI ★★★★
()

офсета

Зачем ты их считаешь? Похоже, что плохо продумал, отсюда боль. Должны быть ПОД структуры и их массивы, писать можно целиком и никакого допиливания это требовать не будет в будущем.

kvpfs ★★
()
Ответ на: комментарий от bugfixer

Запросто может. Например double по разному выравнивается емнип. Но с этим можно бороться, всякие там packed итп

Выравнивание лучше делать руками, а не расчитывать на компилятор или packed с misaligned.

kvpfs ★★
()
Ответ на: комментарий от kvpfs

Выравнивание лучше делать руками

Зависит. Если это что-то что живёт в SHM меня мало будет интересовать как именно оно легло, меня будет интересовать исключительно идентичность layout между m32 и m64.

bugfixer ★★★★
()
Ответ на: комментарий от bugfixer

Не, естественно что во всё руками я свои добивки не вставляю, обычно мне пофиг, речь в контексте сериализируемых структур. static_assert с проверкой выравнивания + там же можно проверить и endianness, на «неправильной» архитектуре всё это хозяйство не скомпилится. Значение alignof элементов можно выбрать строжайшим из поддерживаемых архитектур.

С цпп 20 можно «создавать» простые структуры прямо кастом в чаровом буфере без всяких копий. В общем я не знаю нафиг сюда тащить какой-то внешний комбайн, задачка на 2-3 десятка строк.

kvpfs ★★
()

В какой-то момент возникает вопрос, а надо ли так запариваться, есть же QDatastream

Я для себя написал свои классы BinaryInputStream и BinaryOutputStream. Но это имеет смысл, если тебе не нужен Qt. Свой класс писать недолго, больше времени потратишь на болтовню на лоре.

pathfinder ★★★★
()
Ответ на: комментарий от pathfinder

Я кстати не в курсе, если собирать структуру из uint32_t и пр, она одинаковая будет под m32 и m64? Или все таки выравниваться может по разному?

Заполнять структуру, читая из битового потока, надо исключительно «по полям», собирая из примитивов типа uint32_t, int16_t, float и т.д.

Никакого memcpy() всего POD.

pathfinder ★★★★
()
Последнее исправление: pathfinder (всего исправлений: 2)
Ответ на: комментарий от pathfinder

если тебе не нужен Qt

У меня весь проэхт на нём, и клиент, и сервер. Сервер можно было бы и без Qt написать, но я не знаю (тупо не вникал) аналогов асинхронной модели сигналов-слотов в других библиотеках.

Я для себя написал свои классы BinaryInputStream и BinaryOutputStream.

Ну, это что-то вроде классов - аналогов QDatastream? Отличие моего велосипедного кода по сути в том, что он экономит аллокации и не делает всякие проверки при чтении (типа выхода за границы). С одной стороны работает быстрее, с другой, нужен ли этот выигрыш. Тут выше уже написали о минусах помимо тех, что в стартовом посте.

Да и переложить ответственность с велосипеда на библиотеку приятней, чем у себя ошибки искать. %)

s3rjke
() автор топика

Конечно лучше QDatastream. Вообще лучше не мешать чистый C/C++ и Qt - иногда такие ошибки вылезают - туши свет. Если нет требований к скорости или еще каких, то лучше всегда брать qt-шный класс, а не изобретать велосипед. В крайнем случае будет на кого вину свалить - это не я, это все Qt!

guskov_roman
()