Открыть Электронные книги
Категории
Открыть Аудиокниги
Категории
Открыть Журналы
Категории
Открыть Документы
Категории
com
Руби под
микроскоп
Изучение внутреннего устройства Ruby с помощью
Обсуждение/отзывы:
http://patshaughnessy.net/ruby-under-a-microscope
@pat_shaughnessy
Оглавление
Теория: как хеш-таблицы расширяются, чтобы вместить больше значений ................................................. ...... 10
Введение
Каждый день вам приходится пользоваться автомобилем, чтобы ездить на работу, отвозить детей в школу и т. д., но как
часто вы когда-нибудь задумывались о том, как на самом деле работает ваш автомобиль? Когда в прошлые выходные вы
останавливались на красный свет по пути в продуктовый магазин, думали ли вы о теории и технике, лежащих в основе
двигателя внутреннего сгорания? Нет, конечно нет! Все, что вам нужно знать о своем автомобиле, — это какая педаль, как
поворачивать руль и несколько других важных деталей, таких как переключение передач, индикаторы поворота и т. д.
На первый взгляд, изучение внутренней реализации Ruby ничем не отличается: зачем вообще изучать, как был
реализован язык, когда все, что вам нужно сделать, это использовать его? Кого волнует, например, как объекты
Ruby Array или Hash работают внутри; все, что мне нужно знать, это базовое использование — как добавить что-
Что ж, на мой взгляд, есть несколько веских причин, по которым вам следует уделить время изучению
• Вы станете лучшим разработчиком Ruby. Изучая внутреннюю работу Ruby, вы можете лучше понять,
как Мац и остальная часть основной команды Ruby планировали использовать язык. Вы узнаете,
что работает хорошо, что работает быстро, а что нет. Вы станете лучшим разработчиком Ruby,
если будете использовать язык так, как он был задуман, а не только так, как вы предпочитаете.
• Вы можете многое узнать о компьютерных науках. Помимо того, что вы цените талант и дальновидность
основной команды Ruby, вы сможете учиться на их работе. При реализации языка Ruby основная команда
должна была решить многие из тех же проблем информатики, которые вам, возможно, придется решать в
вашей работе или в проекте с открытым исходным кодом. Очевидно, что это не относится к аналогии с
• Это весело! Я нахожу изучение алгоритмов и структур данных, которые использует Ruby, увлекательным, и я надеюсь, что
1
Рубин под микроскопом
«Неважно, насколько красива твоя теория, неважно, насколько ты умен. Если это не
согласуется с экспериментом, это неправильно». - Ричард Фейнман
ВРубин под микроскопом Я собираюсь научить вас, как Ruby работает внутри. Я буду использовать ряд простых и понятных
диаграмм, которые покажут вам, что происходит внутри, когда вы запускаете программу на Ruby. Подобно физику или химику, я
разработалтеорияо том, как все работает на самом деле, на основе многих часов исследований и изучения. Я проделал тяжелую
работу по чтению и пониманию внутреннего исходного кода Ruby на языке C, поэтому вам не нужно этого делать. Моя цель
состоит в том, чтобы некоторые из этих диаграмм вернулись к вам в голову в следующий раз, когда вы будете использовать ту
Но, как и любой хороший ученый, я знаю, что теория бесполезна без веских доказательств, подтверждающих ее. Поэтому после
объяснения некоторых аспектов внутреннего устройства Ruby, некоторых функций или поведения языка я проведуэксперимент
чтобы доказать, что моя теория верна. Для этого я буду использовать Ruby, чтобы протестировать себя! Я запущу несколько
небольших тестовых сценариев Ruby и посмотрю, дают ли они ожидаемый результат, работают ли они так же быстро или
медленно, как я ожидаю, действительно ли Ruby ведет себя так, как говорит моя теория.
Я предполагаю, что вы разработчик Ruby, который использует язык каждый день и заинтересован в том, чтобы узнать больше о
том, как язык работает внутри. Однако я не ожидаю, что вы будете свободно владеть языком программирования C, который Мац и
основная группа разработчиков Ruby использовали для создания интерпретатора. Я не буду шаг за шагом проходить код C,
объясняя все входы и выходы деталей кодирования C. Если вы заинтересованы в подобных вещах, то вашим лучшим ресурсом
будетРуководство по взлому Ruby , первоначально написанный на японском языке и частично переведенный на английский язык.
Тем не менее, для тех, кто знаком с C, я покажу несколько очень упрощенных фрагментов кода C,
чтобы дать вам более конкретное представление о том, что происходит внутри Ruby. Я также
укажу, в каком исходном коде MRI C я нашел фрагмент; это облегчит вам самостоятельное
изучение кода MRI C, если вы когда-нибудь решите это сделать. Как и в этом абзаце, я буду
Если вас не интересуют подробности кода C, просто пропустите эти желтые разделы.
2
Рубин под микроскопом
Вы, вероятно, очень хорошо знаете, как использовать объект Hash в своих программах на Ruby, но знаете ли вы, в чем
заключается самая замечательная и важная особенность объекта Ruby Hash? Что делает объект Hash интересным, так это не то,
что он может сохранять и искать значения с помощью ключей, а то, что он может делать это быстро...независимо от того, сколько
в нем элементов.
На этой диаграмме показано, сколько времени требуется Ruby 1.9 для поиска и извлечения значений из хэша для хэшей разных
размеров. Ось Y показывает, сколько времени в миллисекундах потребовалось моему тестовому коду Ruby для извлечения 10 000
значений из хеша. По оси X я показываю размер хэша в логарифмическом масштабе — другими словами, количество других
ключей, которые Ruby пришлось искать, чтобы найти ключ, который я запросил. Ясно, что объект Ruby Hash работает очень
быстро; на моем ноутбуке он может искать и находить ключ в хеше 10 000 раз примерно за 1,5 мс. Если посчитать, в среднем Ruby
3
Рубин под микроскопом
Но что действительно удивительно, так это не только то, что Ruby быстр, но и то, что Ruby одинаково быстр для хеша,
содержащего миллион ключей, как и для хеша, содержащего только один ключ! Что примечательно в этой диаграмме, так это то,
Если вы задумаетесь об этом на минуту, объект Hash на самом деле представляет собой мини-поисковик: каким-то образом Ruby
может взять любой ключ, очень быстро найти его среди, возможно, тысяч или даже миллионов других ключей, а затем вернуть
только одно значение, которое соответствует к этому ключу. Точно так же, когда вы сохраняете новую пару ключ/значение в
хэш, Ruby сначала быстро определяет, присутствует ли уже значение для этого ключа, и перезаписывает его, если оно есть. Как
В этой главе я объясню, что такое хеш-таблица и как она использует хэш-функцию для группировки элементов данных в
разные ячейки, что позволит Ruby впоследствии очень и очень быстро искать их. Я также объясню, как хеш-таблицы
расширяются, чтобы вместить любое количество ключей и значений. Попутно я проведу серию экспериментов, чтобы
получить некоторые данные, доказательства того, что Ruby действительно использует хеш-таблицы и хэш-функции для
внутренней реализации объекта Hash. Наконец, я исследую, как хэши сохраняют информацию о порядке в хеш-таблице —
Изучив код MRI C, который Ruby использует для реализации объекта Hash, я очень быстро
обнаружил, что Ruby использует так называемую «хеш-таблицу» для сохранения ключей и
широко используемая, хорошо известная, старая концепция в информатике. Они организуют значения в группы или
«ячейки» на основе целочисленного значения, вычисляемого из каждого значения, называемого «хэшем». Позже,
когда вам нужно найти и найти значение, пересчитав хэш-значение, вы сможете выяснить, в какой ячейке
4
Рубин под микроскопом
Слева структура «RHash»; это сокращение от «Ruby Hash». Все другие важные типы объектов, используемые внутри
Ruby, представлены похожими структурами, называемыми «RFile», «RArray», «RValue» и т. д. Каждая из этих структур,
включая RHash, содержит набор внутренних системных значений об этом объекте. которые Руби должен
отслеживать.
Справа я показываю хэш-таблицу, используемую этим хешем, представленную структурой «st_table». Эта структура C
содержит основную информацию о хеш-таблице, такую как количество записей, сохраненных в таблице, количество
ячеек и указатель на ячейки. Каждая структура RHash содержит указатель на соответствующую структуру st_table. Наконец,
я показываю несколько пустых контейнеров в правом нижнем углу. Ruby 1.8 и Ruby 1.9 изначально создают 11 бинов для
Лучший способ понять, как работает хэш-таблица, — это рассмотреть пример. Предположим, я добавляю
мой_хэш[:ключ] ="ценность"
При выполнении этой строки кода Ruby создаст новую структуру под названием «st_table_entry» и
5
Рубин под микроскопом
Здесь вы можете видеть, что Ruby сохранил новую пару ключ/значение в третьей корзине, #2. Ruby сделал это, взяв
заданный ключ (символ «:key» в этом примере) и передав его внутренней хеш-функции, которая возвращает
Затем Ruby берет значение хеш-функции, в данном примере «some_value», и вычисляет модуль по
некоторое_значение%11знак равно2
На этой диаграмме я представляю, что фактическое значение хеш-функции для «:key», разделенное на 11, дает в остатке 2.
Позже в этой главе я более подробно рассмотрю хеш-функции, которые на самом деле использует Ruby.
мой_хэш[:key2] ="значение2"
И на этот раз давайте представим, что хеш-значение «:key2», деленное на 11, дает в остатке 5:
6
Рубин под микроскопом
Теперь вы можете видеть, как Ruby помещает вторую структуру «st_table_entry» в ячейку №5, шестую ячейку:
Преимущество использования хеш-таблицы появляется позже, когда вы просите Ruby получить значение для данного ключа:
помещает my_hash[:key]
=>"ценность"
Если бы Ruby сохранил все ключи и значения в массиве или связанном списке, ему пришлось бы перебирать все
элементы в этом массиве списка в поисках :key. Это может занять очень много времени, в зависимости от
количества элементов. Но используя хеш-таблицу, Ruby может перейти прямо к ключу, который ему нужно найти,
7
Рубин под микроскопом
некоторое_значение%11знак равно2
… и теперь Ruby знает, что нужно искать в корзине № 2 запись с ключом :key. Аналогичным образом Ruby может позже
Хотите верьте, хотите нет, но библиотека C, используемая Ruby для реализации хеш-таблиц, была
Беркли, а затем модифицирована основной командой Ruby. Вы можете найти код хеш-таблицы Питера
Мура в файлах кода C «st.c» и «include/ruby/st.h». Все имена функций и структур используют соглашение
Код хэш-таблицы Питера Мура играет очень важную и центральную роль во внутреннем устройстве Ruby. Он
используется не только объектом Hash, но и во многих других местах, например, для отслеживания того, какие
методы определены в каждом объектном классе или модуле Ruby. Другими словами, Ruby использует код хэш-
таблицы Питера Мура для отслеживания своих внутренних данных, а не только ваших данных, которые вы
Между тем, определение структуры «RHash», которая представляет каждый объект Ruby Hash,
можно найти в файле include/ruby/ruby.h. Наряду с RHash здесь вы найдете все другие основные
8
Рубин под микроскопом
Мой первый эксперимент будет создавать хэши самых разных размеров, от 1 элемента до
вернуть значение из каждого из этих хэшей. Вы можете найти мой полный сценарий
самим собой. А пока вот важные фрагменты тестового кода. Во-первых, я создаю хэши разных размеров на
основе степеней двойки, запуская этот код для разных значений «экспоненты»:
размер =2**экспонента
хэш = {}
индекс = ранд
хэш[индекс] = ранд
конец
Здесь и ключи, и значения являются случайными плавающими значениями. Затем я измеряю, сколько времени требуется, чтобы
10000.раз делать
val = хэш[target_key]
конец
конец
конец
9
Рубин под микроскопом
Результаты: маленькие или очень большие хэши Ruby работают одинаково быстро!
Я уже показывал этот график выше; результаты поразительны: используя внутреннюю хеш-таблицу, Ruby может находить и
возвращать значение из хеша, содержащего более миллиона элементов, так же быстро, как это требуется для возврата одного из
небольшого хеша:
Очевидно, что хеш-функция, которую использует Ruby, очень быстра, и как только Ruby идентифицирует бин,
содержащий целевой ключ, он может очень быстро найти соответствующее значение и вернуть его. Как я сказал выше,
примечательно то, что значения на этой диаграмме более или менее плоские.
10
Рубин под микроскопом
среди 11 бинов в хеш-таблице Ruby все равно придется искать среди почти 100 000 элементов в каждом бине,
Здесь должно быть что-то еще. Мне кажется, что Ruby должен добавлять в хеш-таблицу больше бинов по мере
добавления все новых и новых элементов. Давайте еще раз посмотрим, как работает код внутренней хеш-таблицы Ruby.
Продолжая пример выше… предположим, я продолжаю добавлять все больше и больше элементов в свой хэш:
мой_хэш[:key3] = "значение3"
мой_хэш[:key4] = "значение4"
мой_хэш[:key5] = "значение5"
мой_хэш[:key6] = "значение6"
По мере того, как я добавляю все больше и больше элементов, Ruby будет продолжать создавать больше структур st_table_entry
Ruby использует связанный список для отслеживания записей в каждой ячейке: каждая структура st_table_entry содержит указатель на
следующую запись в той же ячейке. По мере добавления новых записей в хеш связанный список для каждого бина становится все длиннее
и длиннее.
Чтобы эти связанные списки не вышли из-под контроля, Ruby измеряет так называемую «плотность» или среднее
количество записей в ячейке. На моей диаграмме выше вы можете видеть, что среднее количество
11
Рубин под микроскопом
количество записей в ячейке увеличилось примерно до 4. Это означает, что модуль хеш-значений 11 начал возвращать
повторяющиеся значения для разных ключей и хэш-значений. Таким образом, при поиске целевого ключа Ruby может
потребоваться выполнить итерацию по небольшому списку после вычисления хэш-значения и определения того, какой бин
Как только плотность превысит 5, постоянное значение в исходном коде MRI C, Ruby выделит больше бинов, а затем
«перехэширует» или перераспределит существующие записи среди нового набора бинов. Например, если я продолжу добавлять
больше пар ключ/значение, через некоторое время Ruby отбросит массив из 11 ячеек, выделит массив из 19 ячеек, а затем
Отслеживая таким образом плотность бинов, Ruby может гарантировать, что связанные списки останутся короткими, а
извлечение хеш-элемента всегда будет быстрым — теперь, после вычисления хеш-значения, Ruby просто нужно пройти через 1
статическая пустота
перефразировать (таблица)
12
Рубин под микроскопом
ptr = table->bins[i];
ptr->next = new_bins[hash_val];
new_bins[hash_val] = указатель;
птр = следующий;
таблица->num_bins = новое_num_bins;
таблица->бины = новые_бины;
Здесь вызов метода «new_size» возвращает новое количество бинов, например 19. Как только Ruby получает
новое количество бинов, он выделяет новые бины, а затем выполняет итерацию по всем существующим
структурам st_table_entry — всем парам ключ/значение в хэше. Для каждой записи st_table_entry Ruby
пересчитывает позицию ячейки, используя ту же формулу модуля: hash_val = ptr->hash % new_num_bins. Затем
он сохраняет каждую запись в связанном списке для этой новой корзины. Наконец, Ruby обновляет структуру
13
Рубин под микроскопом
перераспределение записей, — измерить количество времени, которое Ruby требуется для сохранения
одного нового элемента в существующем хэше разных размеров. По мере того, как я добавляю все больше
и больше элементов в один и тот же хеш, в какой-то момент я должен увидеть доказательства того, что
Я сделаю это, создав 10 000 хэшей одинакового размера, обозначенного переменной «размер»:
хэши = []
10000.раз делать
хэш = {}
хэш[ранд] = ранд
конец
конец
После того, как все это настроено, я могу измерить, сколько времени потребуется, чтобы добавить еще один элемент в каждый хеш
hashes[n][size] = ранд
конец
конец
конец
То, что я обнаружил, было удивительно! Вот данные для Ruby 1.8:
14
Рубин под микроскопом
раз).
• Затем, по мере увеличения размера хеша с 2, 3 примерно до 60 или 65, время, необходимое для вставки нового
• Наконец, мы видим, что вставка каждой новой пары ключ/значение в хэш, содержащий 64, 65 или 66 элементов
• Затем мы видим огромный всплеск! Вставка 67-й пары ключ/значение занимает в два раза больше времени:
• Наконец, после вставки 67-го элемента время, необходимое для вставки дополнительных элементов, падает
15
Рубин под микроскопом
Что тут происходит? Что ж, дополнительное время, необходимое для вставки этой 67-й пары ключ/значение, тратится на то, что
Ruby перераспределяет массив бинов с 11 бинов на 19 бинов, а затем переназначает структуры st_table_entry новому массиву
бинов.
Вот тот же график для Ruby 1.9 — вы можете видеть, что на этот раз порог плотности бинов другой. Вместо того, чтобы тратить
дополнительное время на перераспределение элементов по ячейкам при 67-й вставке, Ruby 1.9 делает это при вставке 57-го
элемента. Позже вы можете увидеть, что Ruby 1.9 выполняет еще одно перераспределение после вставки 97-го элемента.
Если вам интересно, откуда берутся эти магические числа, 57, 97 и т. д., взгляните на
верхнюю часть файла кода «st.c» для вашей версии Ruby. Вы должны найти такой список
простых чисел:
16
Рубин под микроскопом
/*
*/
8+ 3,
16 + 3,
32 + 5,
64 + 3,
128 + 3,
256 + 27,
512 + 9,
. . . так далее...
В этом массиве C перечислены некоторые простые числа, которые встречаются вблизи степеней двойки. Код
хеш-таблицы Питера Мура использует эту таблицу, чтобы решить, сколько ячеек использовать в хеш-таблице.
Например, первое простое число в приведенном выше списке — 11, поэтому хеш-таблицы Ruby начинаются с 11
бинов. Позже, по мере увеличения количества элементов, количество бинов увеличивается до 19, а еще позже
до 37 и т. д.
Ruby всегда устанавливает количество ячеек хэш-таблицы как простое число, чтобы повысить вероятность того,
что хэш-значения будут равномерно распределены между ячейками, после вычисления модуля - после деления
на простое число и использования остатка. Математически здесь помогают простые числа, поскольку они с
меньшей вероятностью имеют общий множитель с целыми числами хэш-значения, на случай, если плохая хеш-
функция часто возвращает значения, которые не были полностью случайными. Если хеш-значения и количество
ячеек имеют общий коэффициент или если хеш-значения кратны количеству ячеек, то модуль может всегда
быть одним и тем же… что приводит к неравномерному распределению записей таблицы между ячейками.
# определить ST_DEFAULT_MAX_DENSITY5
17
Рубин под микроскопом
… который определяет максимально допустимую плотность или среднее количество элементов в ячейке.
Наконец, вы также должны быть в состоянии найти код, который решает, когда выполнять
перефразировать(таблица);
Вместо этого для Ruby 1.9 и Ruby 2.0 вы найдете этот код:
перефразировать(таблица);
Вы можете увидеть рехеширование Ruby 1.9 в первый раз, когда num_entries больше 5*11 или когда
Выше я показывал графики для Ruby 1.8 и Ruby 1.9 — а как же Ruby 2.0? Он работает как-то
иначе? Перераспределяет ли он бины таким же образом? При чтении исходного кода Ruby
2.0 я заметил множество изменений в коде, связанных с выделением бинов, что заставило
Хэши в Ruby 2.0 будут использовать несколько иные структуры данных для сохранения ключей и значений в хеш-таблице.
Вместо того, чтобы выделять бины и затем назначать записи ключ/значение (структуры st_table_entry) в бины, Ruby 2.0
будет вместо этого сохранять данные ключа/значения прямо в памяти, обычно выделенной для бинов. Вот как это
выглядит:
18
Рубин под микроскопом
В исходном коде MRI эти записи называются «упакованными». Здесь вы можете видеть, что ключи и значения сохраняются прямо
внутри корзин в правом нижнем углу, и что в таблице st_table есть новое значение, называемое «entries_packed». Это значение
устанавливается равным true всякий раз, когда ключи и значения сохраняются таким образом. Когда это значение равно false, это
Но подождите минутку! Если ведер нет, то это вообще не хеш-таблица, не так ли? Правильно: в Ruby 2.0
объект Hash фактически реализован внутри как массив, а не как хеш-таблица!
Однако это верно только для небольших хэшей — хэшей, все элементы которых помещаются в пространство памяти, обычно
используемое для массива бинов. Как только элементы хэша больше не помещаются в память массива бинов, данные ключа/
значения будут скопированы обратно в новые структуры st_table_entry и назначены бинам хеш-таблицы, как обычно.
Оптимизация здесь на самом деле не столько в экономии памяти, сколько во времени/скорости. В Ruby 2.0 основная
команда Ruby решила, что для небольших хэшей на самом деле быстрее просто сохранять ключи и значения в массиве и
искать целевой ключ, просто перебирая массив. Таким образом, Ruby полностью избегает вызова хеш-функции для
целевого ключа, который должен быть быстрым, но может быть медленным, если ключ представляет собой большую
строку или массив. Ruby также экономит время, избегая необходимости создавать и настраивать структуры st_table_entry
19
Рубин под микроскопом
Для этого теста я повторно запущу тот же код, что и в эксперименте №2, но на этот раз с
выпуска Ruby 2.0. И я сосредоточусь на данных для меньших размеров хэшей, чтобы посмотреть,
Вот результаты:
Опять же, здесь ось Y показывает время, необходимое для вставки одного нового элемента в
существующий хэш. По оси X я измеряю это время для хэшей разного размера, начиная с нуля (пустой
20
Рубин под микроскопом
• Затем требуется всего около 3 мс, чтобы вставить второй элемент, 10 000 раз.
• Вставка первого элемента занимает немного больше времени, но в Ruby 2.0 массив bin вообще не создается для
пустых хэшей. Вы можете рассматривать это как еще одну оптимизацию: пустые хэши имеют
Структура RHash и структура st_table, но вообще нет массива бинов. Это позволяет Ruby быстрее создавать новые
пустые хэши за счет того, что для вставки первого элемента требуется немного больше времени.
• Тогда вставка элементов до #6 выполняется очень быстро, так как они сохраняются непосредственно в массиве
bin, а Ruby 2.0 вообще не нужно выделять новые структуры st_table_entry или вызывать хеш-функцию.
• Но в массив помещаются только 6 элементов. Поэтому при вставке седьмого элемента Ruby 2.0 должен
создать 7 структур st_table_entry, вызвать хеш-функцию для каждого ключа и назначить их бинам.
Другими словами, Ruby 2.0 должен настроить хеш-таблицу в первый раз, когда вы вставляете седьмой
элемент!
Если вам интересно узнать больше о том, как работает новая оптимизация хэш-массива в Ruby 2.0,
найдите слово «упакованный» в файле st.c. Например, в Ruby 2.0 структура st_table содержит
найдете вычисление, которое определяет, что 6 элементов хэша должны быть сохранены в виде
И последнее замечание: это может показаться ненужной и неважной оптимизацией. В конце концов, когда у вас есть 7
элементов в хеше, оптимизация больше не применяется. Но подумайте о том, как часто разработчики Ruby используют хэши
для сохранения опций для вызовов методов и другими способами, для которых требуется всего несколько ключевых слов.
21
Рубин под микроскопом
пары значений должны храниться в хеше. Маленькие хэши довольно часто используются в приложениях Ruby —
большинство хэшей — это маленькие хэши, и поэтому эта оптимизация будет иметь большое значение.
способ реализации объекта Hash - если эта функция работает хорошо, хэши Ruby будут быстрыми, но плохая хэш-функция
теоретически может вызвать серьезные проблемы с производительностью. И не только это, как я упоминал выше, Ruby
использует внутренние хеш-таблицы для хранения своей собственной информации, а не только значений данных, которые вы
Сначала давайте еще раз рассмотрим, как Ruby использует хэш-значения. Помните, что когда вы сохраняете новый элемент — новую
пару ключ/значение — в хеше, Ruby присваивает его ячейке внутри внутренней хеш-таблицы, используемой этим хеш-объектом:
22
Рубин под микроскопом
Опять же, как это работает, Ruby вычисляет модуль хеш-значения ключа по количеству ячеек:
Причина, по которой это хорошо работает для Ruby, заключается в том, что хеш-значения Ruby представляют собой более или менее случайные целые
числа для любых заданных входных данных. Вы можете понять, как работает хеш-функция Ruby, вызвав метод «хэш» для любого объекта, подобного
этому:
$ ирб
=> 3277525029751053763
=> 234577060685640459
=> -3466223919964109258
=> -2297524640777648528
Здесь даже одинаковые значения имеют очень разные значения хеш-функции. Обратите внимание, что если я снова вызову «хеш», я всегда получаю одно и то
=> 3277525029751053763
=> 234577060685640459
Вот как на самом деле работает хэш-функция Ruby для большинства объектов Ruby:
23
Рубин под микроскопом
• Когда вы вызываете «хэш», Ruby находит реализацию по умолчанию в классе «Объект». Вы, конечно,
• Код C, используемый реализацией хэш-метода класса Object, получает значение указателя C для
целевого объекта, т. е. фактический адрес памяти структуры RValue этого объекта. По сути, это
• Затем Ruby пропускает его через сложную функцию C — хеш-функцию, которая смешивает и
Для строк и массивов это работает по-разному. В этом случае Ruby фактически выполняет итерацию по всем символам в
строке или элементам массива и вычисляет совокупное хеш-значение; это гарантирует, что хеш-значение всегда будет
одним и тем же для любого экземпляра строки или массива и всегда будет меняться при изменении любого из значений в
Наконец, целые числа и символы — еще один особый случай — для них Ruby просто передает их значения прямо в
хеш-функцию.
В Ruby 1.9 и 2.0 на самом деле используется хеш-функция MurmurHash, изобретенная Остином
Эпплби в 2008 году. Название Murmur происходит от операций машинного языка, используемых
в алгоритме: «умножить» и «повернуть». Если вас интересуют подробности того, как на самом
деле работает алгоритм Murmur, вы можете найти код C для него в файле исходного кода st.c
Ruby, около строки 1028. Или вы можете прочитать веб-страницу Остина на Murmur:http://
sites.google.com/site/murmurhash/ .
Кроме того, Ruby 1.9 и Ruby 2.0 инициализируют MurmurHash, используя случайное начальное значение, которое повторно
инициализируется при каждом перезапуске Ruby. Это означает, что если вы остановите и перезапустите Ruby, вы получите
разные хеш-значения для одних и тех же входных данных. Это также означает, что если вы попробуете это сами, вы получите
значения, отличные от тех, которые я получил выше. Однако хеш-значения всегда будут одинаковыми в одном и том же процессе
Ruby.
Поскольку хэш-значения являются псевдослучайными числами, как только Ruby разделит их на количество бинов,
например, 11, оставшиеся значения остатка (значения модуля) будут случайным числом от 0 до 10. Это означает,
что структуры st_table_entry будут равномерно распределяются по доступным бинам по мере их сохранения в хеш-
таблице. Равномерное распределение записей гарантирует, что Ruby сможет быстро искать и
24
Рубин под микроскопом
найти любой заданный ключ, так как количество записей в ячейке всегда будет небольшим. (В среднем она всегда будет
Но представьте, если бы хеш-функция Ruby не возвращала случайные целые числа — представьте, если бы вместо этого она возвращала одно и
то же целое число для каждого значения входных данных. Что случилось бы?
В этом случае каждый раз, когда вы добавляете любой ключ/значение в хэш, он всегда будет присваиваться одному и
тому же бину. Тогда Ruby получил бы все записи в одном длинном списке в этой одной корзине и без записей в какой-
Теперь, когда вы попытаетесь получить какое-то значение из этого хеша, Ruby придется просмотреть этот длинный
список, по одному элементу за раз, пытаясь найти запрошенный ключ. В этом сценарии загрузка значения из хэша Ruby
25
Рубин под микроскопом
размер =2**экспонента
хэш = {}
индекс = ранд
хэш[индекс] = ранд
конец
Но вместо того, чтобы вызывать «rand» для вычисления случайных значений ключей, на этот раз я создам новый пользовательский класс объектов с
именем «KeyObject» и буду использовать экземпляры этого класса в качестве значений ключей:
конец
размер =2**экспонента
хэш = {}
индекс = КлючевойОбъект.новый
хэш[индекс] = ранд
конец
По сути, это работает так же, как и в эксперименте 1, за исключением того, что Ruby придется вычислять хеш-значение для
каждого из этих объектов «KeyObject» вместо случайных значений с плавающей запятой, которые я использовал ранее.
После повторного запуска теста с этим классом KeyObject я перейду к изменению класса KeyObject и
переопределю метод «хэш», например:
26
Рубин под микроскопом
деф хэш
конец
конец
Я специально написал очень плохую хеш-функцию — вместо того, чтобы возвращать псевдослучайное целое число, эта
хеш-функция всегда возвращает целое число 4, независимо от того, для какого экземпляра объекта KeyObject вы ее
вызываете. Теперь Ruby всегда будет получать 4 при вычислении хеш-значения, и ему придется назначать все хэш-
элементы ячейке № 4 во внутренней хеш-таблице, как на диаграмме выше. Давай посмотрим что происходит….
конец
27
Рубин под микроскопом
Используя Ruby 1.9, я снова вижу, что Ruby требуется от 1,5 до 2 мс для извлечения 10 000 элементов из хеша, на этот раз с
Теперь давайте запустим тот же код, но на этот раз с плохой хэш-функцией в KeyObject:
деф хэш
4
конец
конец
Вот результаты:
28
Рубин под микроскопом
Вау - совсем другое! Обратите особое внимание на масштаб графика. По оси Y я показываю миллисекунды, а по оси X снова
количество элементов в хеше, показанное в логарифмическом масштабе. Но на этот раз обратите внимание, что у меня
есть 1000 миллисекунд - или фактических секунд - по оси Y! С 1 или небольшим количеством элементов я могу очень
быстро получить 10 000 значений — настолько быстро, что времени слишком мало, чтобы отобразить их на этом графике.
Но когда количество элементов превышает 100 и особенно 1000, время, необходимое для загрузки 10 000
значений, увеличивается линейно с размером хэша. Для хеша, содержащего около 10 000 элементов, загрузка
10 000 значений занимает более 1,6 полных секунды. Если я продолжу тест с большими хэшами, загрузка значений
Опять же, здесь происходит то, что все элементы хэша сохраняются в одну и ту же корзину, заставляя Ruby выполнять
29
Рубин под микроскопом
Помимо оптимизации хэша в Ruby 2.0 как массива, еще одно важное изменение кода, которое я заметил между
различными версиями MRI Ruby, было связано с сохранением информации о порядке в хеш-таблице. Немного
покопавшись, я понял, что Ruby 1.8 и Ruby 1.9 ведут себя по-разному в отношении порядка и перебора хэш-
элементов. Я обнаружил, что в Ruby 1.9 и 2.0 структура st_table содержит два дополнительных значения данных,
Значения head и tail являются указателями на структуры st_table_entry, формируя связанный список, который отслеживает
записи порядка, сохраненные в хеше. В моем предыдущем примере связанный список будет выглядеть так:
30
Рубин под микроскопом
Пунктирные линии обозначают связанный список. Когда первое значение добавляется в хеш-таблицу, указатели «голова»
и «хвост» указывают на него. Затем при добавлении второго значения формируется связанный список от заголовка до
первого значения и от первого до второго значения. Затем хвостовой указатель устанавливается на второе значение. Я не
показываю их здесь, но в структуру st_table_entry также добавлены новые указатели для поддержки связанного списка.
Зачем вся эта дополнительная работа? Связанный список позволяет Ruby 1.9 и Ruby 2.0 записывать порядок добавления
значений в хэш, а затем использовать эту информацию для возврата значений обратно вам в том же порядке. То есть,
если я вызову один из методов, связанных с итератором, например «каждый», «каждое_значение» или «каждый_ключ» и т.
д., Ruby 1.9 и Ruby 2.0 будут искать указатель «head» в структуре st_table, а затем начните перебирать структуры
31
Рубин под микроскопом
Ruby 1.8, однако, будет просто перебирать бины в массиве bin, а затем через структуры st_table_entry в том порядке, в
котором они будут найдены в хеш-таблице. Хотя этот порядок не является случайным - он связан со значением хеш-
функции каждого ключа - поскольку значения хеш-функции кажутся случайными значениями, порядок, в котором вы
хэш = {}
$ рубин -v
ruby 1.8.7 (30 июня 2011 г., уровень исправления 352) [i686-darwin11.2.0]
$ рубиновый эксперимент5.рб
Здесь вы можете видеть, что на самом деле эти два элемента возвращаются внеправильныйпорядок: сначала
возвращается значение для :two, а затем значение для :one. Оказывается, для Ruby 1.8 хеш-значения для этих двух
32
Рубин под микроскопом
Теперь давайте повторно запустим тот же код для Ruby 1.9 (или Ruby 2.0):
$ рубин -v
$ рубиновый эксперимент5.рб
Помните, что для объекта Hash повторение ключей по порядку является второстепенной функцией. Наиболее важной
особенностью хэша является возможность быстрого получения значения для любого заданного ключа. Как я уже говорил выше,
это делается путем вычисления хеш-значения и модуля по количеству ячеек, а не с помощью связанного списка начала/конца.
Ruby использует связанный список только тогда, когда вы вызываете «каждый» или один из других методов итератора. Ruby
Оказывается, JRuby реализует хэши более или менее так же, как MRI Ruby.
Объекты Java для представления хеш-таблицы и записей хеш-таблицы вместо структур памяти.
Вот как выглядит хеш-таблица внутри процесса JRuby:
33
Рубин под микроскопом
Здесь вместо структур памяти C RHash и st_table у нас есть объект Java с именем «RubyHash». И вместо массива bin и
структур st_table_entry у нас есть массив объектов Java с именем «RubyHashEntry». Объект RubyHash содержит
переменную экземпляра с именем «размер», которая отслеживает количество элементов в хеше, и другую
JRuby выделяет 11 пустых объектов RubyHashEntry при создании нового хеша; они образуют ячейки хеш-
таблицы. Затем, когда вы вставляете элементы в хэш, JRuby заполняет эти объекты ключами и значениями.
Вставка и извлечение элементов работает так же, как и в MRI: JRuby использует ту же формулу для деления хэш-
значения ключа на количество бинов и использует модуль, чтобы найти правильный бин:
По мере того, как вы добавляете в хэш все больше и больше элементов, JRuby формирует связанный список объектов RubyHashEntry по мере
необходимости, когда два ключа попадают в один и тот же бин — точно так же, как MRI:
34
Рубин под микроскопом
Кроме того, JRuby отслеживает плотность записей — среднее количество объектов RubyHashEntry в корзине — и при
Если вам интересно, вы можете найти код Java, который JRuby использует для реализации
хэшей, в файле исходного кода src/org/jruby/RubyHash.java. Я обнаружил, что его легче понять,
чем исходный код C из MRI, в основном потому, что в целом Java немного более читабелен и
легче для понимания, чем C, и потому что он объектно-ориентирован. Команда JRuby смогла
В некоторых случаях команда JRuby даже использовала те же имена идентификаторов, что и MRI; например, вы
чисел, что и MRI: 11, 19, 37 и т. д., которые попадают в степени двойки. Это означает, что JRuby будет
перераспределения записей.
35
Рубин под микроскопом
На высоком уровне Rubinius использует тот же алгоритм хэш-таблицы, что и MRI и JRuby, но
использует Ruby вместо C или Java. Это означает, что исходный код Rubinius примерно в 10 раз
проще для понимания, чем код MRI или JRuby, и это отличный способ узнать больше о хеш-
Поскольку это обычный Ruby, в Rubinius ваши объекты Ruby на самом деле реализуются с помощью реального класса Ruby, который
называется «Hash». Вы увидите, что у него есть несколько целочисленных атрибутов, таких как @size, @capacity и @max_entries, а также
переменная экземпляра с именем @entries, представляющая собой массив бинов, фактически содержащий хэш-данные. Rubinius реализует
массив bin с помощью класса Ruby под названием «Rubinius::Tuple», который представляет собой простой класс хранения, похожий на
массив. Rubinius сохраняет каждый элемент хэша внутри объекта Ruby под названием «Bucket», сохраняемого внутри массива @entries
Rubinius::Tuple.
Одно отличие, которое вы увидите в реализации хэш-таблицы Rubinius, состоит в том, что она использует простые степени
двойки, чтобы решить, сколько хеш-бинов нужно создать, вместо простых чисел. Изначально Rubinius использует 16 объектов
Bucket. Всякий раз, когда Rubinius нужно выделить больше бинов, он просто удваивает размер массива бинов — «@entries» в
приведенном выше коде. Хотя теоретически это менее идеально, чем использование простых чисел, оно существенно упрощает
код, а также позволяет Рубиниусу использовать побитовую арифметику для вычисления индекса бина вместо необходимости
36
Рубин под микроскопом
зависимости от того, начинаете ли вы в режиме совместимости с Ruby 1.8 или Ruby 1.9. Вот
деф[](ключ)
элемент.значение
еще
ключ по умолчанию
конец
конец
дефнайти_элемент(ключ)
key_hash = ключ.хэш
пункт = @entries[key_index(key_hash)]
пока вещь
вернуть изделие
конец
элемент = элемент.ссылка
конец
конец
37
Рубин под микроскопом
дефключевой индекс(ключ_хэш)
key_hash и @маска
конец
Вы можете видеть, что метод key_index использует побитовую арифметику для вычисления
индекса бина, поскольку количество бинов всегда будет степенью двойки для Rubinius, а не
простым числом. Поверьте мне, этот кодмноголегче понять, чем соответствующий код C в MRI
Ruby.
Вывод
Чем больше я смотрю на объект Ruby Hash, тем больше я впечатлен. На первый взгляд это кажется очень
очевидным и простым: вы вставляете значения и ключи, а позже можете получить их снова. Что может
быть проще? Но изучение деталей того, как Ruby реализует хэши, привело меня к большому количеству
знаний:
• Во-первых, я узнал о хеш-таблицах и хеш-функциях: что они собой представляют и как работают.
• Затем я понял, насколько на самом деле масштабируемы хэши Ruby. Теперь я могу написать код Ruby, который
сохраняет большой набор данных в хеш, будучи уверенным, что позже я смогу быстро и эффективно искать
• Я видел, как основная команда Ruby улучшила — и продолжает улучшать — работу объекта Ruby Hash. Не могу
38
Рубин под микроскопом
Эксперимент 1
требоватьэталон
ИТЕРАЦИИ = 10000
размер =2**экспонента
хэш = {}
целевой_индекс = 0
индекс = ранд
хэш[индекс] = ранд
конец
GC.старт
val = хэш[target_index]
конец
конец
конец
конец
39
Рубин под микроскопом
Эксперименты 2 и 3
ИТЕРАЦИИ = 10000
хэши = []
ИТЕРАЦИЙ.раз делать
хэш = {}
хэш[ранд] = ранд
конец
конец
требоватьэталон
GC.старт
hashes[n][size] = ранд
конец
конец
конец
конец
40
Рубин под микроскопом
Эксперимент 4
требоватьэталон
ИТЕРАЦИИ =10000
деф хэш
4
конец
конец
размер =2**экспонента
хэш = {}
целевой_индекс = 0
хэш_индекс = KeyObject.new
hash[hash_index] = ранд
конец
GC.старт
val = хэш[target_index]
конец
конец
конец
конец
41