Сообщений 54    Оценка 351 [+1/-1]         Оценить  
Система Orphus

Сериализация в .NET. Выпрямляем своими руками

Автор: Владислав Чистяков
The RSDN Group

Источник: RSDN Magazine #2-2003
Опубликовано: 02.09.2003
Исправлено: 10.12.2016
Версия текста: 1.0
О проблеме
Сериализация массива структур
Результаты смелого эксперимента советских ученых:
Комментарии и выводы
Кто виноват? И что делать?
Исходные коды теста
Сериализация DataSet-а
Результаты теста
Исходные коды
P.S.

Исходный код сериализатора и тестов
Библиотека ZLib (MC++), 200kB
Библиотека SharpZipLib (C#), 1,5MB

О проблеме

Я не раз на форумах RSDN заявлял, что сериализация в дотнете сделана очень неоптимально. Данная статья является научным доказательством правоты моих слов. А так же описывает пути, позволяющие уменьшить негативное влияние этого факта на прикладные приложения.

Сериализация массива структур

Итак, я сделал тест, в котором создается массив структур размером в сто тысяч элементов (ну, чтобы было, что измерять) который сериализуется с помощью:

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

Обе библиотеки можно найти на прилагаемом к журналу CD.

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

Все тесты повторялись по три раза, чтобы исключить влияние инициализации на результаты теста.

Вот

Результаты смелого эксперимента советских ученых:

Время сериализации    |   Размер   | Сжатие   (быстрое) | Сжатие (нормальное)
 сохранение | загрузка |  stream-а  | время  |  размер   | время  | размер
   (сек.)   |  (сек.)  |   (байт)   | (сек.) |  (байт)   | (сек.) | (байт)
------------+----------+------------+--------+-----------+--------+---------+
                        CustomSerialization
     0.0621 |   0.1243 |  3 641 406 | 0.2307 | 1 353 346 | 0.8073 | 1 209 622
     0.0616 |   0.1198 |  3 641 406 | 0.2321 | 1 353 346 | 0.8259 | 1 209 622
     0.0660 |   0.1295 |  3 641 406 | 0.2444 | 1 353 346 | 0.8225 | 1 209 622
                        BinaryFormatter
     1.0207 |   2.1445 |  5 041 593 | 0.3118 | 1 777 823 | 1.2298 | 1 690 670
     1.0066 |   2.1887 |  5 041 593 | 0.3079 | 1 777 823 | 1.2285 | 1 690 670
     1.0007 |   2.1943 |  5 041 593 | 0.3079 | 1 777 823 | 1.2293 | 1 690 670
                        SoapFormatter
     3.1607 |   8.7860 | 12 285 042 | 0.3809 | 2 204 652 | 0.8129 | 1 875 580
     3.1064 |   8.7989 | 12 285 042 | 0.3887 | 2 204 652 | 0.8293 | 1 875 580
     3.1029 |   8.8187 | 12 285 042 | 0.3849 | 2 204 652 | 0.8196 | 1 875 580
                        XmlSerializer
     1.9823 |   2.4203 | 13 595 633 | 0.3600 | 1 934 505 | 0.7528 | 1 609 710
     1.8117 |   2.3614 | 13 595 633 | 0.3597 | 1 934 505 | 0.7528 | 1 609 710
     1.8057 |   2.3591 | 13 595 633 | 0.3695 | 1 934 505 | 0.7601 | 1 609 710

Скажу честно, если говорить о размерах данных, то я думал о бинарном форматере несколько хуже. Бинарный форматер умудряется создать всего на четверть более пухлый, чем следовало бы. Но по скорости форматеры дотнета не выдерживают никакой критики. Разница в десятки раз (около 16/18 между бинарными и 50/73 (!) между SOAP и бинарным).

И это притом, что сериализацией по сути дела заведует сам компилятор!

Сжатие несколько скрашивает грустную ситуацию с размером сериализованных данных, но даже сжатый файл на 30-60 процентов больше идеального. Плюс потери времени на сжатие. А ведь сериализовались совершенно простые структуры.

Вы только представьте! За время сериализации BinaryFormatter-ом можно успеть не только сериализовать, но и сжать данные, уменьшив их в 3 раза!

SOAP-форматер по времени вообще убийственен.

Комментарии и выводы

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

Ремоутинг и COM+... Именно их данная проблема задевает в первую очередь. Дело в том, что при маршалинге данные сериализуются. А значит время, потраченное на этот процесс, замедляет работу ремоутинга. Раздутый результат сериализации только усугубляет ситуацию.

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

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

Лучше всего начинать заниматься оптимизацией (а ручная сериализация и есть оптимизация) только если есть проблемы с производительностью. При этом стоит в первую очередь постараться реализовать ручную сериализацию для классов, которые используются наиболее часто, и/или используются для передачи значительных объемов данных.

Кто виноват? И что делать?

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

А вот что делать? Дотнет 1.1 не решает проблему. Ждать же более новой версии дотнета еще очень долго, да и скорее всего, проблема в нем снова не будет решена. Единственное, что может заставить Microsoft заняться сериализацией - это какой-нибудь конкурент. Например, Sun может заявить, что Ява сериализует объекты в ХХХ раз быстрее... И тогда буквально через полгода сериализация в .NET станет круче паровоза. Однако верится в это с трудом. Так что нужно брать все в свои руки.

Пути... В первую очередь можно написать ручную сериализацию для наиболее часто используемых объектов, хранящих/передающих большие объемы данных. DataSet просто напрашивается на то, чтобы первым надругались над ним. Вернее не над ним, а на одной из его составляющих – DataTable. Ведь именно этот класс хранит реальные данные.

  1. В идеале нужно сделать код автоматической сериализации для произвольного объекта. С моей (сильно абстрактной) колокольни это видится так: пишется небольшой класс, который для каждого типа на лету создает (с помощью System.Reflection.Emit) код сериализации. Это позволит, потратив немного времени на начальном этапе, получить практически идеальную (по всем параметрам) сериализацию. Оптимально поместить эту логику в собственный форматер или SerializationSurrogate, который будет подменять сериализацию для объектов. Тогда и другие приложения смогли бы воспользоваться преимуществами этого подхода, причем без переделки. Однако путь этот сложный, и не факт, что выигрыш будет достойным.
  2. Вопреки моде и рекламе стараться передавать по сети бинарные данные. Причем не грех будет попытаться сжать данные (но делать это нужно осторожно, так как это – ресурсоемкий процесс). Тот же DataSet можно сериализовать в бинарный вид даже при передаче по http. При этом можно использовать или формат base64, или (что еще лучше) стандарт DIME (позволяющий делать бинарные вложения в http-запрос). Красота и читаемость XML в данном случае совершенно не нужна. Признайтесь честно, часто вы просматриваете сетевые пакеты? В конце концов, всегда можно превратить данные в XML и полюбоваться на них. А вот на диске можно хранить и XML. Если его много, лучше не пользоваться SOAP-форматером. Найдите где-нибудь намного более эффективный SAX-парсер и разбирайте данные им. Однако занятие это не легкое. И если проблем с производительностью нет, лучше не искать проблем на свою голову. Бесполезно пытаться ускорить процесс разбора XML DOM-парсерами. По крайней мере тот, что входит в дотнет, работает еще медленнее, чем SOAP-форматер.

Ну и последний вопрос. Стоит ли ломать копья? Шестидесятикратная разница в скорости может убедить кого угодно. Особенно если пользователи уже начинают подвывать. Например, похожая проблема назревает в rsdn@home. Несколько хоумщиков, одновременно выбирающих сообщения, могут серьезно и довольно надолго затормозить сервер. А ведь страдают от этого в основном онлайн-пользователи. Ведь хоумщики в это время пьют чай.

Исходные коды теста

Код теста (C#):

Сериализация DataSet-а

Реализовать ручную сериализацию для классов прикладного приложения – занятие неблагодарное. Если придерживаться стратегии получения максимальной отдачи при минимальном вкладе, то нужно создать ручную сериализацию для объектов, которые чаще всего используются при передаче данных по сети. DataSet как раз и является таким объектом. Он универсален, а значит, с его помощью можно передавать совершенно разные данные. Причем код самого DataSet-а при этом изменять не нужно (да и как, собственно, это сделать?!). Ваше желание, думаю, усилится, если я скажу, что DataSet принципиально не умеет сериализоваться в бинарную форму. То есть он прекрасно может быть сохранен средствами BinaryFormatter, но при этом в поток будет сохранено XML-представление (попросту размеченный текст) DataSet-а. Сделано это так. Для DataSet-а реализована ручная сериализация. При этом в методе ISerializable.GetObjectData попросту добавляются два значения, KEY_XMLSCHEMA и KEY_XMLDIFFGRAM, содержащие (как не трудно догадаться по их именам) XML-описание и содержимое DataSet-а. Вот код этого метода объекта DataSet:

      void ISerializable.GetObjectData(SerializationInfo info, 
  StreamingContext context)
{
  string s = GetXmlSchemaForRemoting(null);
  string ss = null;
  info.AddValue(KEY_XMLSCHEMA, s);
  StringBuilder b = new StringBuilder(EstimatedXmlStringSize() * 2);
  StringWriter w = new StringWriter(b);
  XmlTextWriter x = new XmlTextWriter(w);
  WriteXml(x, XmlWriteMode.DiffGram);
  ss = w.ToString();
  info.AddValue(KEY_XMLDIFFGRAM, ss);
}

Примерно такой же код содержит метод GetObjectData объекта DataTable.

Ух ты! - подумают многие. А зачем же это сделано? По всей видимости, господа из Microsoft были вынуждены делать ручную сериализацию для DataSet-а, поскольку столь сложный объект, как DataSet очень трудно корректно сохранить автоматически. А может быть, они просто пытались улучшить скорость сериализации. Как бы то ни было, но получилось все это у них (как говорилось в одном анекдоте) препохабно. Мало того, что объем сериализации даже при сериализации BinaryFormatter-ом не лезет ни в какие ворота. Так еще к тому же и скорость сериализации (а особенно загрузки) просто катастрофически низка. Вы хотите видеть цифры? Их есть у меня. Но об этом позже. Ну а создать ручную бинарную сериализацию у парней из Microsoft, видимо, просто не хватило терпения. Хотя возможно, что сериализация в XML была приоритетом, поставленным менеджерами компании, а схема сериализации с Iserializable не позволяет получить информацию о том, в какого типа стриме происходит сериализация. Дело в том, что изначально предполагалось, что программист не будет сам производить сериализацию. Он должен был просто последовательно вызвать метод SerializationInfo.AddValue для всех полей класса. Но если попытаться как-то оптимизировать этот процесс, придется передавать в AddValue данные в определенном формате. Это и случилось с DataSet-ом. И так как приоритет был отдан XML и SOAP, мы получили то, что получили.

В общем, если верить мне, ручная сериализация DataSet-а может дать значительное повышение производительности.

Однако сериализация DataSet-а – не такая простая задача, как это может показаться поначалу. Дело в том, что DataSet – это комплексный объект, состоящий из кучи маленьких объектов. DataSet содержит коллекции:

Таблица в свою очередь содержит коллекции:

Почти каждый из этих классов содержит вложенные объекты и коллекции.

Самый хитрый из этих объектов – DataRow.

Дело в том, что строка может хранить две версии данных – Current и Original. Кроме того, хранимые в строке данные могут содержать значение DbNull, то есть NULL баз данных (это совсем не то же самое, что null).

Версии нужны, чтобы DataSet мог хранить информацию о модификации строки (удалении, добавлении, изменении), и чтобы после модификаций строка сохраняла информацию о предыдущем состоянии. Все это нужно, чтобы на базе информации, хранимой в DataTable, можно было сгенерировать SQL-скрипт, модифицирующий БД (например, чтобы можно было удалить строку из БД). DataSet должен не удалять ее из себя явно, а только помечать ее как удаленную. Реализуется это так. В DataRow хранится две переменных: oldRecord и newRecord. Удаленные строки содержат в oldRecord номер записи, а в newRecord – -1. Вставленные строки, наоборот, содержат в oldRecord -1, а в newRecord – номер записи. Модифицированные записи хранят в oldRecord номер старой записи, а в newRecord – новой. В неизмененных строках oldRecord и newRecord содержат один и тот же номер записи. Версия, хранимая в oldRecord, называется Original, а в newRecord – Current. oldRecord и newRecord – это скрытые члены класса, и легально получить доступ к ним невозможно. Но получить данные для заданной версии все же можно. Для этого используется перегруженный индексатор объекта DataRow. Его второй параметр может принимать значения DataRowVersion.Original или DataRowVersion.Current.

Узнать, какие версии доступны, можно с помощью метода:

      bool HasVersion(DataRowVersion version);

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

Added – строка добавлена. При этом присутствует только Current-версия строки (oldRecord = -1, а newRecord равна номеру записи, хранящей информацию о строке).

Deleted – строка удалена. При этом присутствует только Original-версия строки (oldRecord равна номеру записи, хранящей информацию об удаленной строке, а newRecord = -1).

Modified – строка изменена. При этом присутствует и Original-, и Current-версия.

Unchanged – строка не была изменена. Такое состояние может быть у строки после загрузки данных из БД или после применения к строке метода AcceptChanges.

Detached – строка не добавлена к DataTable. Это состояние нас не интересует, так как мы не будем сериализовать отдельные строки, а строки, подключенные к DataTable, не могут иметь этого состояния.

При записи строки в поток нужно записывать информацию о состоянии строки и версии строки. Напомню, несмотря на большое количество состояний строки, версий может быть только две. Причем только в случае измененной строки (DataRowState.Modified) действительно появляется необходимость сериализации обоих состояний.

Я создал упрощенный вариант сериализации DataSet-а. Он умеет сериализовать в бинарный формат таблицы DataSet-а. При сериализации учитываются ячейки, содержащие DbNull и версии строк, но не поддерживается сериализация ограничений (constrains), связей между таблицами (relations) и некоторых других аспектов. Этот сериализатор можно с успехом применить во многих приложениях, так как неподдерживаемые возможности используются на практике не так часто. К тому же несложно доделать сериализацию необходимых возможностей DataSet-а самостоятельно. Размер сериализованных данных при этом не должен серьезно увеличиться, так как все возможности, для которых я не реализовал сериализацию, являются чисто декларативными. Единственное, почему я не реализовал их сериализацию – это то, что на это нужно время, и не малое.

Результаты теста

 Время сериализации    |   Размер   | Сжатие   (быстрое) | Сжатие (нормальное)
 сохранение | загрузка |  stream-а  | время  |  размер   | время  | размер
   (сек.)   |  (сек.)  |   (байт)   | (сек.) |  (байт)   | (сек.) | (байт)
------------+----------+------------+--------+-----------+--------+---------+
                        RsdnDataSerializer
     0.2175 |   0.4357 |  1 320 126 | 0.0561 |   306 853 | 0.2343 |   255 201
     0.2081 |   0.4338 |  1 320 126 | 0.0566 |   306 853 | 0.2320 |   255 201
     0.1965 |   0.4359 |  1 320 126 | 0.0564 |   306 853 | 0.2343 |   255 201
                        BinaryFormatter
     2.8173 |   9.1600 |  9 057 810 | 0.2146 |   900 317 | 0.4050 |   791 163
     2.1798 |   9.0301 |  9 057 810 | 0.2159 |   900 317 | 0.4044 |   791 163
     2.2008 |   9.0180 |  9 057 810 | 0.2154 |   900 317 | 0.4044 |   791 163
                        XmlSerializer
     2.0541 |   9.3235 | 11 962 601 | 0.2615 |   919 852 | 0.4787 |   800 479
     2.0009 |   9.3226 | 11 962 601 | 0.2598 |   919 852 | 0.4785 |   800 479
     1.9373 |   9.3173 | 11 962 601 | 0.2599 |   919 852 | 0.4772 |   800 479
                        SoapFormatter
     3.1591 |  11.2147 | 14 078 329 | 0.3069 | 1 081 327 | 0.5629 |   825 619
     3.1357 |  11.2414 | 14 078 329 | 0.3071 | 1 081 327 | 0.5612 |   825 619
     3.1420 |  11.2351 | 14 078 329 | 0.3086 | 1 081 327 | 0.5637 |   825 619

Исходные коды

Код теста (C#):

P.S.

Пользуясь случаем, передаю привет AVK и всем, кто считает что сериализация в .NET сделана нормально.


Эта статья опубликована в журнале RSDN Magazine #2-2003. Информацию о журнале можно найти здесь
    Сообщений 54    Оценка 351 [+1/-1]         Оценить