Pull to refresh

Храним 300 миллионов объектов в CLR процессе

Reading time 5 min
Views 35K

Камень преткновения — GC


Все managed языки такие как Java или C# имеют один существенный недостаток — безусловное автоматическое управление паматью. Казалось бы, именно это и является преимуществом managed языков. Помните, как мы барахтались с dandling-указателями, не понимая, куда утекают драгоценные 10KB в час, заставляя рестартать наш любимый сервер раз в сутки? Конечно, Java и C# (и иже с ними) на первый взгляд разруливают ситуацию в 99% случаев.

Так-то оно так, только вот есть одна проблемка: как быть с большим кол-вом объектов, ведь в том же .Net никакой магии нет. CLR должен сканировать огромный set объектов и их взаимных ссылок. Это проблема частично решается путём введения поколений. Исходя из того, что большинство объектов живёт недолго, мы высвобождаем их быстрее и поэтому не надо каждый раз ходить по всем объектам хипа.

Но проблема всё равно есть в тех случаях, когда объекты должны жить долго. Например, кэш. В нём должны находиться миллионы объектов. Особенно, учитывая возрастание объемов оперативки на типичном современном серваке. Получается, что в кэше потенциально можно хранить сотни миллионов бизнес-объектов (например, Person с дюжиной полей) на машине с 64GB памяти.

Однако на практике это сделать не удаётся. Как только мы добавляем первые 10 миллионов объектов и они “устаревают” из первого поколения во второе, то очередной полный GC-scan “завешивает” процесс на 8-12 секунд, причём эта пауза неизбежна, т.е. мы уже находимся в режиме background server GC и это только время “stop-the-world”. Это приводит к тому, что серверная апликуха просто “умирает” на 10 секунд. Более того, предсказать момент “клинической смерти” практически невозможно.
Что же делать? Не хранить много объектов долго?

Зачем


Но мне НУЖНО хранить очень много объектов долго в конкретной задаче. Вот например, я храню network из 200 миллионов улиц и их взаимосвязей. После загрузки из flat файла моё приложение должно просчитать коэффициенты вероятностей. Это занимает время. Поэтому я это делаю сразу по мере загрузки данных с диска в память. После этого мне нужно иметь object-graph, который уже прекалькулирован и готов “к труду и обороне”. Короче, мне нужно хранить резидентно около 48GB данных в течении нескольких недель при этом отвечаю на сотни запросов в секунду.

Вот другая задача. Кэширование социальных данных, которых скапливаются сотни миллионов за 2-3 недели, а обслуживать необходимо десятки тысяч read-запросов в секунду.

Как


Вот мы и решили сделать свой memory manager и назвали его “Pile” (куча). Ибо никак не обойти “калечность” managed memory model. Unmanaged memory ничем не спасает. Доступ к ней занимает время на проверки, которые “убивают” скорость и усложняют весь дизайн. Ни .Net, ни Java не умееют работать в “нормальном” режиме с кусками памяти, которые не на хипе.

Что мы сделали? Наш memory manager — это абсолютно 100% managed код. Мы динамически выделяем массивы byte, которые мы называем сегментами. Внутри сегмента у нас есть указатель — обычный int. И вот мы получаем PilePointer:
/// <summary>
/// Represents a pointer to the pile object (object stored in a pile).
/// The reference may be local or distributed in which case the NodeID is>=0.
/// Distributed pointers are very useful for organizing piles of objects distributed among many servers, for example
///  for "Big Memory" implementations or large neural networks where nodes may inter-connect between servers.
/// The CLR reference to the IPile is not a part of this struct for performance and practicality reasons, as
///  it is highly unlikely that there are going to be more than one instance of a pile in a process, however
///  should more than 1 pile be allocated than this pointer would need to be wrapped in some other structure along
///   with source IPile reference
/// </summary>
 public struct PilePointer : IEquatable<PilePointer>
 {
    /// <summary>
    /// Distributed Node ID. The local pile sets this to -1 rendering this pointer as !DistributedValid
    /// </summary>
    public readonly int NodeID;

    /// <summary>
    /// Segment # within pile
    /// </summary>
    public readonly int Segment;

    /// <summary>
    /// Address within the segment
    /// </summary>
    public readonly int Address;
…………………………………………………………………
}


Обратите внимание на NodeID, о нём ниже. Получить PilePointer можно следующим образом:

var obj = new MyBusinessType();
var pilePointer = Pile.Put(obj);
…………………………………………
// где-то в другом месте, где есть поинтер
var originalObj = Pile.Get(pilePointer);


Мы получим копию оригинального объекта, который мы погрузили в Pile с помощью Put(), либо PileAccessViolation, если pointer неправильный.

Pile.Delete(pilePointer) 

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

Вопрос: как это сделано и что мы храним в byte[], ведь мы не можем хранить объекты CLR с реальными поинтерами, тогда они запутывают GC. Нам как раз надо обратное — хранить что-то в нашем формате, убрав managed references. Таким образом, мы сможем хранить данные, а GC не будет знать, что это объекты, ну и не будет их визитировать. Это возможно сделать через сериализацию. Конечно, имеются в виду не встроенные сериализаторы .Net (такие как BinaryFormatter), а наши родные в NFX.

PilePointer.NodeID позволяет “размазывать” данные по распределённым “кучам”, так как он идентифицирует узел в когорте distributed pile.

А теперь главный вопрос. Зачем это всё надо, если “под капотом” используется сериализация и это медленно?

Скорость


Реально это работает так: объект < 300 байт, погруженный в byte[] с помошью NFX Slim сериализации, занимает в среднем меньше на 10-25% места чем native объект CLR в памяти. Для больших объектов эта разница стремится к нулю. Почему так получается? Дело в том, что NFX.Serialization.Slim.SlimSerializer использует UTF8 для строк и variable length integer encoding + не нужен 12+ байт CLR header. В итоге камнем преткновения становится скорость сериализатора. SlimSerializer “держит” феноменальную скорость. На одном ядре Intel I7 Sandy Bridge с частотой 3GHz мы превращаем 440 тысяч PilePointer’ов в объект в секунду. Каждый объект в этом тесте имеет 20 заполненных полей и занимает 208 байт памяти. Вставка объектов в Pile одним ядром 405 тысяч в секунду. Такая скорость достигается за счёт динамической компиляции expression trees для каждого сериализируемого объекта в pile-сегмент. В среднем SlimSerializer работает раз в 5 быстрее, чем BinaryFormatter, хотя для многих простых типов этот коэффициент достигает 10. С точки зрения спэйса SlimSerializer пакует данные в 1/4 — 1/10 того, что делает BinaryFormatter. Ну и самое главное. SlimSerializer НЕ ТРЕБУЕТ специальной разметки полей в объектах, с которыми мы работаем. Т.е. хранить можно всё, что угодно, кроме delegates.

Многопоточный тест на вставку данных стабильно держит больше 1 миллиона транзакций в секунду на CoreI7 3GHz.
Ну и теперь самое главное. Аллокировав 300.000.000 объектов в нашем процессе полный GC занимает менее 30 миллисекунд



Итоги


Технология NFX.ApplicationModel.Pile позволяет избежать непредсказуемых задержек, вызванных сборщиком мусора GC, держа сотни миллионов объектов резидентно в памяти в течении длительного времени (недели), обеспечивая скорость доступа выше, чем “out-of-process” решения (такие как MemCache, Redis et.al).

Pile основан на специализированном memory manager’е, который аллокирует большие byte[] и распределяет память для приложения. Погруженный в Pile объект идентифицируется структурой PilePointer, ктр. занимает 12 байт, что способствует созданию эффективных object graphs, где объекты взаимно ссылаются.

Get the code:

NFX GitHub
Tags:
Hubs:
+38
Comments 81
Comments Comments 81

Articles