Бенчмаркая FrozenDictionary: заменил Dictionary — получил от ×0,76 до ×1,9

от автора

Уважаемые читатели, в этой статье я хочу рассказать о том, насколько FrozenDictionary на деле быстрее обычного Dictionary — и представить свои выводы.

FrozenDictionary появился в .NET 8 для словарей, которые собирают один раз при старте приложения, а дальше только читают: конфигурации, таблицы маршрутизации, соответствия строк. Устроен он так: при создании анализирует набор ключей и подбирает под него внутреннюю реализацию, поэтому чтение получается быстрее, чем у обычного Dictionary.

Замеры на четырёх машинах и трёх рантаймах показали, что выигрыш есть, но он сильно зависит от размера набора и от того, как устроены ключи. У обычного Dictionary версия рантайма на результат почти не влияет: между .NET 8 и .NET 10 разница держится в пределах 9% и на части замеров идёт в минус. А вот Frozen за те же версии заметно ускорился, и разрыв между двумя словарями вырос именно поэтому.

Сравниваются пять способов найти значение по строковому ключу:

  • Dictionary;

  • FrozenDictionary — неизменяемая копия словаря, которую рантайм при создании подгоняет под конкретный набор ключей;

  • ImmutableDictionary — неизменяемый словарь, устроенный как дерево, а не как хэш-таблица;

  • ReadOnlyDictionary — обёртка без методов записи; исходный Dictionary при этом остаётся изменяемым;

  • switch по строковым константам, если ключи известны при компиляции.

Будет 3 истории:

  • сколько на самом деле выигрывает Frozen — и почему на пяти ключах он проиграл;

  • почему он выигрывает — и куда на самом деле уходит время в поиске по строке;

  • промахи, switch по строкам и во сколько обходится создание.

Замеры сделаны на четырёх машинах:

CPU

Ядра/потоки

Частота

ОС

№1

AMD Ryzen 9 5950X

16 / 32

3,4 ГГц, до 4,9 в турбо

Windows 10 1809

№2

Intel Core i9-10900KF

10 / 20

3,7 ГГц, до 5,3 в турбо

Windows 10 22H2

№3

2 × Intel Xeon Silver 4314

2×16 / 64

2,4 ГГц, до 3,4 в турбо

Windows Server 2022

№4

Intel Xeon W-2255

10 / 20

3,7 ГГц, до 4,5 в турбо

Windows Server 2022

BenchmarkDotNet 0.15.8, сборка Release. Патч-версии рантайма на машинах разные: .NET 10.0.5, 10.0.10, 10.0.1 и 10.0.1, у .NET 9 и .NET 8 они тоже не совпадают. На одной машине все три рантайма работают в одинаковых условиях, поэтому сравнивать имеет смысл столбцы внутри машины, а не числа разных машин между собой.

Соседний сюжет про ключи словарей — в статье «Бенчмаркая ключи Dictionary: забыл IEquatable — получил ×5 и 96 байт на каждый поиск».

История 1. Сколько на самом деле выигрывает Frozen

Бенчмарк устроен так: словари со строковыми ключами на 5, 50 и 500 записей, каждый вызов выполняет 256 поисков через TryGetValue. Ключи для поиска выбираются заранее генератором случайных чисел с фиксированным начальным значением, порядок обхода перемешан, чтобы у ветвлений не было искусственно предсказуемого шаблона.

Ключи в двух форматах: короткие вроде k7 и длинные с общим префиксом вида /api/v2/orders/handler_7, как в веб-приложении. Компаратор нигде не задаётся: для строковых ключей Dictionary подставляет свой, об этом во второй истории. GlobalSetup сверяет суммы всех способов между собой и падает при расхождении.

Начнём с главного: 500 коротких ключей, только попадания, все три рантайма (наносекунды на 256 поисков).

Машина

Способ

.NET 8

.NET 9

.NET 10

№1 Ryzen 5950X

Dictionary

2400

2460

2235

№1 Ryzen 5950X

Frozen

2376

1926

1893

№2 i9-10900KF

Dictionary

3644

3217

3378

№2 i9-10900KF

Frozen

2483

2337

2138

№3 2×Xeon Silver

Dictionary

4077

3666

3806

№3 2×Xeon Silver

Frozen

3646

2789

2764

№4 Xeon W-2255

Dictionary

4922

4859

5192

№4 Xeon W-2255

Frozen

3553

3352

3223

500 коротких ключей, попадания: у Dictionary между .NET 8 и .NET 10 разница в пределах 9%, у Frozen — до 24%, но по версиям она раскладывается на каждой машине по-своему

У Dictionary единого направления по рантаймам не видно:

  • №1 — .NET 10 быстрее .NET 9 на 9%;

  • №2 — медленнее на 5%;

  • №3 — медленнее на 4%;

  • №4 — медленнее на 7%.

Где-то плюс, где-то минус. За три версии рантайма поиск по строковому ключу у Dictionary остался прежним, и обновление .NET само по себе его не ускорит.

А вот Frozen за те же три версии ускорился, и заметно: с .NET 8 на .NET 9 он выиграл от 6 до 24% в зависимости от машины, с .NET 9 на .NET 10 добавил ещё от 1 до 9%. Так что разрыв между двумя словарями растёт не потому, что Dictionary сдал, а потому, что Frozen подтянулся.

Frozen на 500 ключах быстрее почти везде. Исключение одно: на №1 на .NET 8 разница 24 нс при погрешности замера в 47 нс, то есть там два словаря неразличимы.

Теперь как это выглядит на разных размерах и форматах ключей на .NET 10, во сколько раз Frozen быстрее Dictionary.

Ключей

Формат

№1 Ryzen

№2 i9

№3 Xeon Silver

№4 Xeon W

5

k7

×0,99

×0,98

×1,21

×0,98

5

маршрут

×1,09

×0,83

×1,01

×0,76

50

k7

×1,06

×1,49

×1,10

×1,60

50

маршрут

×1,55

×1,90

×1,81

×1,80

500

k7

×1,18

×1,58

×1,38

×1,61

500

маршрут

×1,47

×1,80

×1,79

×1,82

 Во сколько раз Frozen быстрее Dictionary на .NET 10: от ×0,76 на пяти ключах маршрутов до ×1,90 на пятидесяти

Итог первой истории:

  • на 5 ключах брать Frozen незачем: на коротких ключах он идёт вровень с Dictionary на трёх машинах из четырёх, на ключах маршрутов проигрывает до ×0,76;

  • на 50 и 500 ключах Frozen быстрее в 1,06–1,61 раза на коротких ключах;

  • на ключах маршрутов с общим префиксом — в 1,47–1,90 раза, и это лучший результат из всех замеренных.

Разброс выигрыша между машинами больше, чем между размерами набора: на 500 коротких ключах Frozen выигрывает ×1,18 на №1 и ×1,61 на №4. Поэтому по одной машине судить нельзя, смотреть надо всю таблицу.

История 2. Почему Frozen быстрее: куда уходит время в поиске по строке

Чтобы понять, откуда берётся разница, я разложил поиск на части. Профиль тот же: 256 поисков, 500 ключей, .NET 10. Меняется только способ:

  • Dictionary с компаратором по умолчанию;

  • Dictionary с явным StringComparer.Ordinal;

  • Dictionary со своим компаратором;

  • Dictionary с ключом int;

  • HashSet;

  • хэширование строки без словаря.

Способ

№1 Ryzen

№2 i9

№3 Xeon Silver

№4 Xeon W

Dictionary, компаратор по умолчанию

2288

3403

3570

5404

Dictionary, StringComparer.Ordinal

2223

3468

3642

5202

Dictionary, свой компаратор

3166

4411

5211

6038

Dictionary с ключом int

813

790

1135

1270

HashSet<string>.Contains

2394

3781

3850

5468

Только хэш строки, без словаря

940

965

1564

1357

500 ключей, .NET 10: поиск по ключу int в 2,8–4,3 раза быстрее, чем по строковому, а на одно хэширование строки уходит 25–44% времени поиска

Из таблицы можно сделать выводы:

  • строковый ключ отнимает в 2,8–4,3 раза больше времени, чем целочисленный: сам поиск по хэш-таблице не долгий, всё уходит на операции со строкой;

  • хэширование строки забирает 25–44% всего поиска, и там, где получается, Frozen хэширует не всю строку, а короткий срез из неё;

  • свой компаратор вместо встроенного отнимает ещё 12–46%.

Последние две строки требуют оговорки. Строка «только хэш строки» считает StringComparer.Ordinal.GetHashCode, а это рандомизированный хэш Marvin.

Dictionary с компаратором по умолчанию берёт не его, а внутренний NonRandomizedStringEqualityComparer, и считается он быстрее. Так что 25–44% — это оценка сверху, реальная доля хэша меньше. И 12–46% на своём компараторе объясняются тем же: он считает хэш другим алгоритмом.

Сам быстрый путь виден в машинном коде Dictionary.TryGetValue на .NET 10:

; System.Collections.Generic.Dictionary`2[__Canon,int]:TryGetValue(__Canon,byref):bool (Tier1); Disasm/Listings_Comp_2/disasm_net10.txt     mov      rbp, gword ptr [rbx+0x18]   ; поле _comparer    mov      rcx, 0x7FF9C6484E50         ; адрес таблицы методов ожидаемого типа    cmp      qword ptr [rbp], rcx        ; тот ли это компаратор    jne      G_M000_IG22                 ; нет - уходим на общий путь через вызов    lea      rcx, bword ptr [rsi+0x0C]   ; да - берём символы строки напрямую ; Total bytes of code 760

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

Разница появляется раньше, в конструкторе Dictionary. Три компаратора — дефолтный, StringComparer.Ordinal и StringComparer.OrdinalIgnoreCase — он меняет на свой нерандомизированный, а тот считает хэш быстрее. Поэтому в таблице выше явный Ordinal идёт наравне с дефолтным. Любой другой компаратор остаётся как есть, и хэш считается тем, что написано в нём: у моего это Marvin. Отсюда и потерянные 12–46%.

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

Dictionary это отслеживает: как только в одной цепочке набирается больше ста коллизий (HashHelpers.HashCollisionThreshold), он переключается на рандомизированный компаратор и пересчитывает хэши всей таблицы — в исходниках это видно по комментарию «If we hit the collision threshold we’ll need to switch to the comparer which is using randomized string hashing» в методе вставки Dictionary.cs.

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

Теперь то же самое у Frozen:

; System.Collections.Frozen.FrozenDictionary`2[__Canon,int]:TryGetValue(__Canon,byref):bool (Tier1); Disasm/Listings_Comp_2/disasm_net10.txt     mov      r14d, dword ptr [rbx+0x08]  ; длина строки (поле _stringLength)    mov      ecx, r14d    sub      ecx, dword ptr [rbp+0x20]   ; смещение среза, выбранное при создании    cmp      ecx, dword ptr [rbp+0x24]    ja       G_M000_IG11                 ; длина не подходит - выход без сравнения     lea      rcx, bword ptr [rbx+2*rcx+0x0C] ; адрес среза внутри строки    mov      dword ptr [rsp+0x28], eax       ; длина среза    call     [System.Collections.Frozen.Hashing:GetHashCodeOrdinal(ReadOnlySpan<char>)]

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

Для ключа /api/v2/orders/handler_7 это означает, что из 24 символов в хэш идут единицы — отсюда и ×1,8 на ключах маршрутов, где общий префикс занимает больше половины строки.

Логика подбора среза лежит в KeyAnalyzer.cs в исходниках рантайма, сокращённый фрагмент:

// dotnet/runtime, KeyAnalyzer.cs - TryUseSubstring (сокращено) // срез длиннее 8 символов не берём - анализ перестаёт окупатьсяconst int MaxSubstringLengthLimit = 8; // перебираем длину среза и его позицию в строкеint maxSubstringLength = Math.Min(minLength, MaxSubstringLengthLimit);for (int count = 1; count <= maxSubstringLength; count++){    for (int index = 0; index <= minLength - count; index++)    {        // первый срез, который развёл ключи, становится ключом хэширования    }}

Здесь важно не ошибиться в том, что именно делает срез. Он используется только для вычисления хэша. Само равенство проверяется по всей строке целиком — в том же листинге Frozen дальше идёт вызов SpanHelpers.SequenceEqual, и точно такой же вызов есть у Dictionary.

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

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

Console.WriteLine(dict.ToFrozenDictionary().GetType().Name);

Ключей

Формат

Реализация

5

k7

LengthBucketsFrozenDictionary

5

маршрут

LengthBucketsFrozenDictionary

50

k7

OrdinalStringFrozenDictionary_RightJustifiedSubstring

50

маршрут

OrdinalStringFrozenDictionary_RightJustifiedSubstring

500

k7

OrdinalStringFrozenDictionary_Full

500

маршрут

OrdinalStringFrozenDictionary_RightJustifiedSubstring

.NET 10, компаратор по умолчанию: срез достаётся только трём наборам из шести

На пяти ключах выбирается LengthBucketsFrozenDictionary — он раскладывает ключи по длине строки и хэш не считает. Но у всех пяти ключей длина одинаковая, поэтому раскладывать их не по чему: они оказываются в одной корзине, и поиск сводится к перебору. Обогнать этим хэш-таблицу нечем — на коротких ключах Frozen и Dictionary идут вровень на трёх машинах из четырёх, а на маршрутах Frozen проигрывает.

На 500 коротких ключах выбирается OrdinalStringFrozenDictionary_Full, то есть хэш по всей строке, ровно как у Dictionary. Срез не подобрался: у k0…k499 самая короткая строка в два символа, а двумя символами 500 ключей не развести. Frozen всё равно быстрее в 1,18–1,61 раза, но уже не за счёт хэша: ключи и значения лежат в двух плоских массивах, без цепочек и без двойной адресации через buckets и entries, как в Dictionary.

Срез подобрался для обоих наборов на 50 ключей и для маршрутов на 500, причём везде RightJustified — выровненный по концу строки. Для маршрутов это логично: начало у них общее, различаются только хвостом.

История 3. Промахи, switch по строкам и во сколько обходится создание

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

Замеряются два варианта:

  • ключ той же формы: набор k0…k499, а ищем k500…k999;

  • ключ другой формы: к существующему дописано _miss, то есть он на пять символов длиннее любого из набора.

Ключей / формат

Что ищем

№1

№2

№3

№4

500, k7

попадание

2254 / 1896

3502 / 2177

4063 / 2775

5369 / 3154

500, k7

промах, та же форма

1613 / 1398

1832 / 1321

2260 / 1870

2808 / 1693

500, k7

промах, другая длина

1738 / 644

1941 / 562

2589 / 772

2901 / 803

500, маршрут

попадание

3980 / 2717

5214 / 2930

7272 / 3992

7753 / 4200

500, маршрут

промах, та же форма

2320 / 1654

3198 / 1731

3869 / 2380

4464 / 2291

500, маршрут

промах, другая длина

2488 / 643

3165 / 668

4296 / 841

4558 / 809

500 ключей, .NET 10: на промахе той же формы Frozen быстрее Dictionary в 1,2–2,0 раза, на ключе другой длины — в 2,7–5,6 раза

У Frozen два вида промаха различаются в 2,1–2,8 раза, у Dictionary почти не различаются. Причина в листинге из второй истории: первой идёт проверка длины, и ключ, не попавший в диапазон длин набора, отбрасывается до вычисления хэша. Ключ подходящей длины проходит дальше — считается хэш и идёт обращение к таблице.

На промахах Frozen быстрее Dictionary:

  • ключ той же формы — в 1,2–2,0 раза;

  • ключ другой длины — в 2,7–5,6 раза.

Вторая цифра выше, но в кэше, где промахи выглядят как попадания, работает первая.

Теперь switch. Если набор ключей известен на этапе компиляции, все словари проигрывают обычному switch по строковым константам. Словари в таблице ниже построены на ключах маршрутов, поэтому сравнивать их надо со второй строкой; первая приведена, чтобы видеть switch на коротких ключах.

Способ

№1 Ryzen

№2 i9

№3 Xeon Silver

№4 Xeon W

switch, короткие ключи (k7)

653

560

1008

969

switch, ключи маршрутов

697

648

993

1254

FrozenDictionary, ключи маршрутов

2174

2299

3274

3644

Dictionary, ключи маршрутов

3359

4358

5866

6510

50 ключей, .NET 10: switch обгоняет Frozen в 2,9–3,6 раза и Dictionary в 4,8–6,7 раза, а длина ключа замедляет его не больше, чем на 29%

Switch обгоняет Frozen в 2,9–3,6 раза, а Dictionary в 4,8–6,7 раза. Длина ключа на это почти не влияет: на маршрутах по 24 символа switch отстаёт от трёхсимвольных не больше, чем на 29%, а на №3 разницы между ними нет.

Компилятор не строит хэш-таблицу: Roslyn разворачивает switch в проверку длины и сравнение символов (изменение вошло в компилятор в PR dotnet/roslyn#66081).

Замеряемый метод выглядит так:

// FrozenProof, Subjects.cs [MethodImpl(MethodImplOptions.NoInlining)]public static int LookupSwitch50(string[] keys){    int sum = 0;    for (int i = 0; i < keys.Length; i++)    {        sum += keys[i] switch        {            "k0" => 0, "k1" => 1, "k2" => 2, "k3" => 3, "k4" => 4,            // ... всего 50 констант            "k49" => 49,            _ => 0,        };    }     return sum;}

В его Tier1-листинге 66 инструкций cmp, 2611 байт кода и ни одного вызова метода. Ни одного вызова — не потому, что компилятор обошёлся без string.Equals, а потому что JIT встроил все пятьдесят сравнений в тело метода: это написано в шапке того же листинга — «50 single block inlinees».

Сам фрагмент сравнения выглядит так:

; FrozenProof.Subjects:LookupSwitch50(System.String[]):int (Tier1); Disasm/Listings_Comp_2/disasm_net10.txt G_M000_IG08:    mov      r10d, dword ptr [r8+0x0C]  ; первые два символа одним словом    mov      r9d, r10d                  ; (поле _firstChar, а не длина)    xor      r9d, 0x31006B              ; сравниваем с символами 'k' и '1'    movzx    r11, word  ptr [r8+0x10]   ; третий символ    xor      r11d, 56                   ; сравниваем с символом '8'    or       r9d, r11d                  ; объединяем оба сравнения    je       G_M000_IG159               ; переход на ветку ключа "k18" ; Total bytes of code 2611

Раскладка полей System.String на x64: по смещению 0x08 лежит длина, по 0x0C — первый символ. Поэтому чтение четырёх байт по 0x0C даёт сразу два символа, а 0x31006B — это ‘k’ (0x006B) и ‘1’ (0x0031). Дальше читается третий символ и сравнивается с ‘8’. Оба сравнения объединяются через or, и вместо двух условных переходов остаётся один.

У подхода есть ограничение: все ключи должны быть записаны в коде строковыми константами. Под 500 ключей пришлось бы писать сотни строк с case, так что на практике switch подходит только небольшим фиксированным наборам.

Теперь создание. Сколько занимает сборка словаря на 500 ключей на .NET 10:

Способ

№1 Ryzen

№2 i9

№3 Xeon Silver

№4 Xeon W

Выделено

Dictionary

4702 нс

5852 нс

8220 нс

8840 нс

14 720 Б

ReadOnlyDictionary

4583 нс

5820 нс

7794 нс

9305 нс

14 760 Б

FrozenDictionary

25 169 нс

29 665 нс

40 971 нс

51 469 нс

96 880 Б

ImmutableDictionary

65 804 нс

89 228 нс

113 761 нс

117 372 нс

32 072 Б

Сборка словаря на 500 ключей, .NET 10: Frozen строится в 5,0–5,8 раза дольше Dictionary, и на сборку уходит 97 КБ против 14,7

Frozen строится в 5,0–5,8 раза дольше Dictionary на 500 ключах и в 6,1–6,9 раза на 50 — это перебор срезов из второй истории. В колонке Allocated у него 97 КБ против 14,7 у Dictionary, но это аллокации за одну сборку, а не размер готового словаря: туда попадают и временные структуры, которые нужны только на время анализа ключей.

Остальные два:

  • ImmutableDictionary — медленнее остальных и на сборке, в 13–15 раз, и на чтении, в 2,0–2,3 раза. Внутри AVL-дерево, поиск идёт спуском по узлам со сравнением на каждом. Оттуда же и 32 КБ: каждая пара ключ-значение лежит в отдельном узле со ссылками на потомков. Взамен Add возвращает новый словарь, переиспользуя неизменённые узлы;

  • ReadOnlyDictionary — чтение и создание как у Dictionary в пределах 6%, память отличается на 40 байт, размер самой обёртки.

Выводы

По цифрам:

  • у Dictionary между .NET 8, 9 и 10 единого направления нет: разница в пределах 9% и на части машин в минус. Frozen за те же версии ускорился на 9–24%, но на какую версию пришёлся прирост — зависит от машины: на №1 и №3 почти весь дала девятка, на №2 десятка дала больше девятки;

  • Frozen быстрее Dictionary в 1,06–1,61 раза на 50–500 коротких ключах и в 1,47–1,90 раза на ключах маршрутов с общим префиксом. На 5 коротких ключах выигрыша нет, на 5 маршрутах — проигрыш до ×0,76;

  • хэширование строки занимает 25–44% времени поиска — это оценка сверху, снятая на более медленном хэше, чем берёт сам Dictionary. На ключах маршрутов Frozen выигрывает именно на хэше: считается срез до 8 символов, а не вся строка. На коротких ключах срез не подбирается, и выигрыш идёт за счёт внутренней раскладки Frozen;

  • компаратор, которого Dictionary не знает, отнимает 12–46%: три известных ему — дефолтный, StringComparer.Ordinal и StringComparer.OrdinalIgnoreCase — он меняет на свой нерандомизированный, а любой другой оставляет как есть, вместе с его хэшем;

  • у Dictionary быстрый путь может пропасть во время работы: после сотни коллизий в одной цепочке он сам переключается на рандомизированный хэш и пересчитывает таблицу, что снаружи не видно. У Frozen такого переключения нет, но и цепочки в нём короткие — набор ключей известен при создании;

  • на промахах Frozen быстрее в 1,2–2,0 раза, если промах той же формы, и в 2,7–5,6 раза, если ключ не подходит по длине;

  • switch по строковым константам быстрее Frozen в 2,9–3,6 раза и Dictionary в 4,8–6,7 раза;

  • сборка словаря: Frozen в 5,0–6,9 раза дольше Dictionary, Immutable — в 13–15 раз; ReadOnlyDictionary равен Dictionary.

Что делать на практике:

  • набор до десятка ключей — оставить Dictionary, Frozen там ничего не даёт;

  • набор от полусотни ключей, собираемый при старте, — Frozen оправдан, и тем сильнее, чем длиннее ключи и чем больше у них общего префикса;

  • словарь, который пересоздаётся в горячем коде: Frozen тут не подходит, его сборка занимает в 5–7 раз больше времени, чем у Dictionary;

  • небольшой фиксированный набор строковых констант: тут switch быстрее любого словаря;

  • без замера свой компаратор для строкового ключа писать не стоит, встроенный быстрее на 12–46%;

  • ImmutableDictionary стоит брать там, где словарь читают из нескольких потоков и при этом меняют: Add, SetItem и Remove не трогают исходный словарь, а возвращают новый, и большая часть узлов у них общая. Если нужно только быстрое чтение, он проигрывает всем остальным;

  • ReadOnlyDictionary почти не влияет ни на скорость, ни на память. Только это не иммутабельность — у самой обёртки методов записи нет, а исходный Dictionary по своей ссылке меняется как обычно.

Код из статьи

  • FrozenProof — бенчмарки, прогоны на четырёх машинах и листинги машинного кода

Ссылки

Всем удачи и до новых встреч!

ссылка на оригинал статьи https://habr.com/ru/articles/1063034/