Правильный способ сериализации двоичных данных в C++

Прочитав следующее 1 и 2 Q/As и имеющие использовал метод, обсуждаемый ниже, в течение многих лет на архитектурах x86 с GCC и MSVC и не видел проблем, теперь я очень запутался в том, что должно быть правильным, но также важным «наиболее эффективным» способом сериализации, а затем десериализации двоичного данные с помощью С++.

Учитывая следующий «неправильный» код:

int main()
{
   std::ifstream strm("file.bin");

   char buffer[sizeof(int)] = {0};

   strm.read(buffer,sizeof(int));

   int i = 0;

   // Experts seem to think doing the following is bad and
   // could crash entirely when run on ARM processors:
   i = reinterpret_cast<int*>(buffer); 

   return 0;
}

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

Тем не менее, приведенные выше ответы, по-видимому, указывают на то, что С++ касается всего этого неопределенного поведения.

Если предположить, что выравнивание места в буфере, из которого будет выполняться приведение, не соответствует, то верно ли, что единственным решением этой проблемы является копирование байтов 1 на 1? Возможно, есть более эффективная техника?

Кроме того, за эти годы я видел много ситуаций, когда структура, полностью состоящая из модулей (с использованием конкретных прагм компилятора для удаления заполнения), приводится к типу char* и впоследствии записывается в файл или сокет, а затем считывается обратно в буфер. и буфер возвращается к указателю исходной структуры (игнорируя потенциальные проблемы с порядком байтов и числами с плавающей запятой/двойным форматом между машинами), этот тип кода также считается неопределенным поведением?

Ниже приведен более сложный пример:

int main()
{
   std::ifstream strm("file.bin");

   char buffer[1000] = {0};

   const std::size_t size = sizeof(int) + sizeof(short) + sizeof(float) + sizeof(double);

   const std::size_t weird_offset = 3;

   buffer += weird_offset;

   strm.read(buffer,size);

   int    i = 0;
   short  s = 0;
   float  f = 0.0f;
   double d = 0.0;

   // Experts seem to think doing the following is bad and
   // could crash entirely when run on ARM processors:
   i = reinterpret_cast<int*>(buffer); 
   buffer += sizeof(int);

   s = reinterpret_cast<short*>(buffer); 
   buffer += sizeof(short);

   f = reinterpret_cast<float*>(buffer); 
   buffer += sizeof(float);

   d = reinterpret_cast<double*>(buffer); 
   buffer += sizeof(double);

   return 0;
}

person Sami Kenjat    schedule 09.11.2012    source источник
comment
Вы можете решить проблему выравнивания, используя, например, std::aligned_storage<sizeof(int), std::alignment_of<int>::value>::type вместо char[sizeof(int)] (или, если у вас нет C++11, могут быть аналогичные функции, специфичные для компилятора).   -  person abarnert    schedule 09.11.2012
comment
@abarnert приведенный выше пример прост, в общем случае можно сериализовать множество разных типов последовательно в один буфер и ожидать, что они будут прочитаны все обратно, кроме того, буфер может быть запросом из пула, в котором могут не выполняться какие-либо выравнивания и т. д.   -  person Sami Kenjat    schedule 09.11.2012
comment
Судя по тону вашего вопроса, похоже, вы не так много спрашиваете, могу ли я это сделать (вы знаете, что не можете) или почему это не сработает? (вам уже объяснили), почему они не разработали язык по-другому, чтобы он работал? (Или, может быть, даже, существует ли язык, близкий к C++, который был разработан так, чтобы он работал?)   -  person abarnert    schedule 09.11.2012
comment
@abarnert: я понимаю архитектурные причины того, почему стандарт не хотел говорить ничего конкретного о псевдонимах типов из произвольной памяти, мой вопрос больше об эффективном, но правильном и определенном поведенческом решении - если есть одно специальное, которое не требуется memcpy, который будет очень дорогим даже для 4- или 8-байтовых копий... на самом деле дороже, чем дополнительное удвоение чтений и сдвиг влево/вправо, который исправляет невыровненные чтения.   -  person Sami Kenjat    schedule 09.11.2012
comment
Что заставляет вас думать, что копирование 4 байтов будет медленнее, чем чтение 4 байтов, чтение еще 4 байтов, сдвиг или выполнение и сохранение результата?   -  person abarnert    schedule 09.11.2012
comment
@abarnet: я провел несколько тестов, и кажется, что чтение, сдвиги и т. д. выполняются одновременно, тогда как memcpy имеет вызов функции и накладные расходы на настройку стека, а также копирование (если компилятор не обрабатывает это как специальная функция - которую делает GCC)   -  person Sami Kenjat    schedule 09.11.2012
comment
Итак, компилятор, который вы тестировали, действительно работает быстрее с memcpy, но вы решили, что это читерство, так что это не считается?   -  person abarnert    schedule 09.11.2012
comment
@abarnert: ну, есть соответствие стандартам и работающий код, который четко определен ... тогда возникает проблема фактического выполнения работы. К сожалению, они не всегда совпадают.   -  person Sami Kenjat    schedule 09.11.2012
comment
И никто не говорит, что вы всегда должны писать код, соответствующий стандартам. Когда кто-то говорит, что ваш код выйдет из строя на ARM, это означает, что вам лучше не делать этого в коде, который, возможно, придется компилировать для ARM. Если вы используете его в коде, который будет работать только, скажем, на x86_64 Windows 7, это нормально. Вы должны знать, что ваш код не соответствует стандарту и что он рухнет на ARM, если вам когда-нибудь понадобится его портировать или написать что-то подобное в приложении для iOS, но вы все равно можете используйте его для написания приложения для Windows 7.   -  person abarnert    schedule 09.11.2012
comment
Между тем, если вы пишете код, который, возможно, придется запускать на ARM, на самом деле выполнение работы, по-видимому, означает отсутствие 75% вероятности сбоя каждый раз, когда вы читаете int из файла или сокета, верно?   -  person abarnert    schedule 09.11.2012
comment
давайте продолжим это обсуждение в чате   -  person Sami Kenjat    schedule 09.11.2012


Ответы (2)


Во-первых, вы можете правильно, переносимо и эффективно решить проблему выравнивания, используя, например, std::aligned_storage::value>::type вместо char[sizeof(int)] (или, если у вас нет C++ 11, может быть аналогичная функциональность для конкретного компилятора).

Даже если вы имеете дело со сложным POD, aligned_stored и alignment_of предоставят вам буфер, в который вы можете memcpy вводить и извлекать POD, встраивать его и т. д.

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

Однако просто использовать reinterpret_cast для случайного выровненного по символам буфера недостаточно. Давайте посмотрим, почему:

приведение переинтерпретировать указывает компилятору, что он может обрабатывать память в буфере как целое число

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

и впоследствии может выдавать инструкции, совместимые с целыми числами, которые требуют/предполагают определенные выравнивания для рассматриваемых данных.

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

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

Да, он может выдавать инструкции с дополнительными чтениями и сдвигами. Но он также может выдавать инструкции, которые их не выполняют, потому что вы сказали ему, что это не обязательно. Таким образом, он может выдать инструкцию «чтение выровненного слова», которая вызывает прерывание при использовании на невыровненных адресах.

Некоторые процессоры не имеют инструкции «чтение выровненного слова», а просто «читают слово» быстрее с выравниванием, чем без него. Другие могут быть настроены на подавление ловушки и вместо этого вернуться к более медленному «чтению слова». Но другие, такие как ARM, просто потерпят неудачу.

Если предположить, что выравнивание места в буфере, из которого будет выполняться приведение, не соответствует, то верно ли, что единственным решением этой проблемы является копирование байтов 1 на 1? Возможно, есть более эффективная техника?

Вам не нужно копировать байты 1 на 1. Вы можете, например, memcpy каждую переменную одну за другой в правильно выровненное хранилище. (Это было бы копирование байтов 1 на 1, если бы все ваши переменные имели длину 1 байт, и в этом случае вы бы не беспокоились о выравнивании в первую очередь…)

Что касается приведения POD к char* и обратно с использованием специфичных для компилятора прагм... что ж, любой код, который полагается на специфичные для компилятора прагмы для корректности (а не, скажем, для эффективности), очевидно, не является корректным переносимым C++. Иногда «исправить с помощью g++ 3.4 или более поздней версии на любой 64-битной платформе с прямым порядком байтов с 64-битными двойниками IEEE» достаточно для ваших случаев использования, но это не то же самое, что действительно быть действительным C++. И вы, конечно, не можете ожидать, что он будет работать, скажем, с Sun cc на 32-битной платформе с обратным порядком байтов с 80-битными двойниками, а потом жаловаться, что это не так.

Для примера, который вы добавили позже:

// Experts seem to think doing the following is bad and
// could crash entirely when run on ARM processors:
buffer += weird_offset;

i = reinterpret_cast<int*>(buffer); 
buffer += sizeof(int);

Эксперты правы. Вот простой пример того же самого:

int i[2];
char *c = reinterpret_cast<char *>(i) + 1;
int *j = reinterpret_cast<int *>(c);
int k = *j;

Переменная i будет выровнена по некоторому адресу, кратному 4, скажем, 0x01000000. Итак, j будет по адресу 0x01000001. Таким образом, строка int k = *j выдаст инструкцию для чтения 4-байтового выровненного 4-байтового значения из 0x01000001. Скажем, на PPC64 это займет примерно в 8 раз больше времени, чем int k = *i, но, скажем, на ARM произойдет сбой.

Итак, если у вас есть это:

int    i = 0;
short  s = 0;
float  f = 0.0f;
double d = 0.0;

И вы хотите записать это в поток, как вы это делаете?

writeToStream(&i);
writeToStream(&s);
writeToStream(&f);
writeToStream(&d);

Как вы читаете обратно из потока?

readFromStream(&i);
readFromStream(&s);
readFromStream(&f);
readFromStream(&d);

Предположительно, какой бы поток вы ни использовали (будь то ifstream, FILE*, что угодно) есть в нем буфер, поэтому readFromStream(&f) проверит, доступно ли sizeof(float) байт, если нет, прочитает следующий буфер, а затем скопирует первые sizeof(float) байт из буфер по адресу f. (На самом деле, это может быть даже умнее — например, разрешено проверять, не находитесь ли вы близко к концу буфера, и если это так, выполнить асинхронное упреждающее чтение, если разработчик библиотеки подумал, что это будет хорошей идеей.) Стандарт не говорит, как он должен делать копию. Стандартные библиотеки не должны выполняться нигде, кроме реализации, частью которой они являются, поэтому ifstream вашей платформы может использовать memcpy или *(float*), или встроенный компилятор, или встроенный ассемблер — и он, вероятно, будет использовать то, что является самым быстрым на вашей платформе.

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

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

Если в вашем приложении есть что-то особенное, что делает что-то другое более эффективным, или если вы пишете стандартную библиотеку, то, конечно, вы должны пойти дальше и сделать это. Пока вы (и любые потенциальные пользователи вашего кода) знаете, где вы нарушаете стандарт и почему (и вы на самом деле оптимизируете вещи, а не просто делаете что-то, потому что «кажется, что это должно быть быстрее»), это совершенно разумно.

Кажется, вы думаете, что было бы полезно поместить их в какую-то "упакованную структуру" и просто написать это, но в стандарте С++ нет такой вещи, как "упакованная структура". Некоторые реализации имеют нестандартные функции, которые вы можете использовать для этого. Например, и MSVC, и gcc позволят вам упаковать вышеуказанное в 18 байтов на i386, и вы можете взять эту упакованную структуру и memcpy ее, reinterpret_cast ее в char * для отправки по сети, что угодно. Но он не будет совместим с точно таким же кодом, скомпилированным другим компилятором, который не понимает специальные прагмы вашего компилятора. Он даже не будет совместим с родственным компилятором, таким как gcc для ARM, который упаковывает то же самое в 20 байт. Когда вы используете непереносимые расширения стандарта, результат не является переносимым.

person abarnert    schedule 09.11.2012
comment
abarnert: Вы, кажется, думаете, что было бы полезно поместить их в какую-то упакованную структуру. Нет, это не то, что я сказал, все, что я сказал, это обычная практика в кодовых базах, которые я видел, чтобы использовать такой трюк. . - person Sami Kenjat; 09.11.2012
comment
@abarnet: что касается множественной записи, я согласен с этим, но вместо того, чтобы вызывать ifstream.write несколько раз, может быть более эффективным записать все переменные, которые вы хотите, в буфер, а затем записать буфер, используя один вызов .write - Кстати, вопрос записи из типа в буфер не проблема, его возвращение проблематично. - person Sami Kenjat; 09.11.2012
comment
@SamiKenjat: Вы понимаете, что ifstream буферизуется, а буферизация выполняется людьми, которые написали стандартную библиотеку для вашей платформы, и, вероятно, сделают свою работу лучше, чем вы, если только не возникнут какие-то необычные проблемы, связанные с приложением, верно? - person abarnert; 09.11.2012
comment
@abarnet: из потока, верно? Это сайт QA, так как такие примеры остаются простыми - игрушечные примеры, в реальной жизни обычно можно сериализовать МБ или даже ГБ данных, этот 64-килобайтный буфер потока бесполезен, и когда срабатывает запись, превышающая размер буфера, внутренний буфер потока полностью игнорируется, что касается обратного, пожалуйста, также примите во внимание множество других способов, с помощью которых данные могут поступать в программу (сокеты, usb и т. д.), ни один из этих типов ввода не буферизуется изначально. - person Sami Kenjat; 09.11.2012
comment
Я сказал ifstream, потому что вы сказали, что это проблематично, и это ifstream. Но то же самое верно в обоих направлениях. Кроме того, почему буфер размером 64 КБ бесполезен? Когда вы сериализуете гигабайты данных, вы по-прежнему записываете дисковые блоки размером 8 КБ или отправляете по сети 4 КБ. В любом случае, если вам не нравится буфер, вы можете заменить его на более крупный или создать собственный буферизованный поток с нуля. Вы можете написать специфичный для платформы код для платформ, которые, по вашему мнению, можно оптимизировать лучше, чем средство реализации, и переносимый код для всего остального. Итак, я все еще не понимаю, в чем ваша проблема. - person abarnert; 09.11.2012
comment
А что касается других типов, которые не буферизуются… вы думаете, что ввод-вывод сокетов не буферизуется? Вы вызываете recv() и send() по 1 байту за раз? В любом случае, весь смысл FILE*, fstream и т. д. заключается в том, что они оборачивают буфер вокруг чего-то еще, например POSIX fd или дескриптора Windows. - person abarnert; 09.11.2012
comment
давайте продолжим это обсуждение в чате - person Sami Kenjat; 09.11.2012

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

person Jessica Eldridge    schedule 13.12.2012