ETS

ETS — хранилище термов Эрланга (Erlang term storage).

Эрланг применяется для создания высоконадёжных систем, работающих 99,9999999 % времени. Не должно быть такого, чтобы нам пришлось перезапускать Систему только из-за того, что мы к какому-то объекту решили добавить новое поле. Само по себе добавление новых полей это немалый труд. В других языках нам пришлось бы: добавить поля в классы (структуры), изменить методы (где это надо), добавить столбцы в таблицу СУБД, изменить SQL-запросы, всё это протестировать. Раз работы много, тут может быть и много ошибок (не одних, так других). И поэтому нам может потребоваться перезапустить Систему (или подсистему) не раз и не два.

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

В Эрланге нет пользовательских типов данных (таких как классы или структуры). Строго говоря, глядя на исходный код, может быть вообще не понятно, какого рода термы будут храниться в БД. А раз мы этого не знаем, значит, нам понадобится специализированное хранилище термов, а не такая СУБД, где заранее надо определять: вот строки, вот целые числа, вот даты…

Разработчики Erlang/OTP немало потрудились над тем, чтобы нам было комфортно хранить в памяти или на жёстком диске произвольные термы, а потом удобно искать нужные данные. Нам нет необходимости упрощать наши термы только потому, что для хранения произвольного терма нет универсального механизма. Если нам нужно хранить список кортежей, каждый из которых это атом плюс ещё список кортежей, в которых в том числе содержатся фунтермы, мы это можем сделать буквально за минуту. Нам не придётся городить множество таблиц, продумывать отношения между таблицами, обеспечивать превращение атомов в строки и обратно, хранение фунтермов и т.д.

В Эрланге нам дано много свободы в обращении с термами, но этой свободой надо уметь пользоваться. Программист освобождается от необходимости учить ещё один язык (SQL) для хранения данных, но зато ему надо быть более внимательным, не надеяться на помощь компилятора и стремиться писать максимально изящный код.

Состояние каждого актора может быть очень и очень сложным — объём хранимых данных в одном процессе может исчисляться мегабайтами и гигабайтами. При этом структура состояния изначально (на стадии написания программы) может быть не известна. Более того, во время работы эта структура состояния может меняться. Также надо не забывать про минимизацию побочных эффектов. Поэтому отражать состояние актора должно что-то одно.

Состояние актора можно хранить и им манипулировать несколькими способами:

ETS и DETS по своей функциональности очень похожи друг на друга. Основное различие в том, что ETS хранит термы в оперативной памяти компьютера, а DETS — на жёстком диске. Но есть и другие отличия. К сожалению, в DETS пока не реализована таблица вида ordered_set (упорядоченное множество).

ETS это своеобразная база данных. Базовые операции с ETS-таблицей такие:

ETS это “родная” система Эрланга, это часть языка — в том плане, что поддержка реализована внутри виртуальной машины. В отличие от “внешних” СУБД она обладает очень высокой скоростью, сравнимой со скоростью использования обычных переменных. Другое отличие состоит в том, что в ETS-таблице можно сохранить абсолютно любой терм, причём без всякой подготовки. Просто сохраняем — и всё. В одной таблице, таким образом, могут храниться совершенно разнородные данные: одна запись, например, содержит список атомов, другая — фунтерм.

Таблицу, если она стала уже не нужна, можно не удалять. Она автоматически удалится, когда актор, работающий с ней, завершит свою работу.

ETS-таблицы не обрабатываются сборщиком мусора. При этом её размер не влияет на работу сборщика мусора. У таблицы своя область памяти, не связанная с областью памяти актора.

ETS/DETS разрабатывались как основа для Мнезии. Она построена поверх них.

Пример создания ETS-таблицы, вставки в неё, чтения и удаления таблицы

-module(summator).
-export([main/0]).

main() ->
    Ets = ets:new(nechto, [set]),
    ets:insert(Ets, {age, 18}),
    [{age, Age}] = ets:lookup(Ets, age),
    io:format("Age: ~p~n", [Age]),
    ets:delete(Ets).

Скомпилировав и запустив эту программу, мы получим строку: “Age: 18”.

В первой строке функции main() мы запускаем функцию new/2 из стандартного модуля ets. Первый аргумент — произвольное название нашей ETS-таблицы. Я взял nechto. Второй аргумент — параметры создаваемой таблицы. В данном случае set. У этого типа таблицы все ключи должны быть уникальными. Ссылка на созданную таблицу привязывается к переменной Ets.

В следующей строке мы вызываем функцию ets:insert/2. Первый аргумент её — переменная со ссылкой на таблицу (Ets). Второй аргумент — кортеж {age, 18}, который мы вставляем в таблицу. Как правило, первый элемент кортежа это ключ.

Не стоит думать, что в ETS хранятся кортежи вида {КЛЮЧ, ЗНАЧЕНИЕ}. Тут немного не так. Мы могли бы вставить не кортеж {age, 18}, а кортеж {age, 18, “eighteen years”}. Примерно так мы можем вставить, а потом прочитать:

ets:insert(Ets, {age, 18, "eighteen years"}),
[{age, Age, Text}] = ets:lookup(Ets, age),

Ключом тут выступает атом age, а значением — весь кортеж {age, 18, "eighteen years"}, включая атом age.

Чтобы найти в таблице значения по ключу, мы использовали функцию ets:lookup/2. Аргументы: переменная со ссылкой на таблицу, ключ.

Стоит повнимательнее присмотреться к паттерну, который стоит слева от знака равенства. Он выглядит как [{age, Age}]. Почему здесь квадратные скобки? Дело в том, что etc:lookup/2 может найти не один подходящий кортеж, а несколько. Мы создали таблицу с типом set. В ней всегда будет лишь одна запись с конкретным ключом. Но могут быть и другие типы таблицы. Поэтому эта функция возвращает список кортежей.

В нашем примере функция возвращает список [{age, 18}]. Он сопоставляется с паттерном [{age, Age}]. В результате значение 18 привязывается к переменной Age, которую мы используем в следующей строке для вывода на печать.

Когда мы закончили работу с таблицей, её можно удалить с помощью функции ets:delete/1. Передаваемый ей аргумент — переменная со ссылкой на таблицу. После этого память освободится.

Проверка скорости ETS

-module(summator).
-export([main/0]).

main() ->
    Ets = ets:new(?MODULE, [set]),
    Vstav = fun() ->
        [ ets:insert(Ets, {{X, Y}, X+Y}) ||
            X <- lists:seq(1, 1000), Y <- lists:seq(1, 1000) ],
        true
    end,
    {Time, _} = timer:tc(Vstav, []),
    io:format("Time: ~p microseconds.~n", [Time]),
    Summa = fun() ->
        [ ets:lookup(Ets, {X, Y}) || X <- lists:seq(1, 1000),
            Y <- lists:seq(1, 1000) ],
        true
    end,
    {Time2, _} = timer:tc(Summa, []),
    io:format("Time: ~p microseconds.~n", [Time2]),
    ets:delete(Ets).

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

В первой строке тела main() мы создаём ETS-таблицу. Если раньше мы использовали просто какой-то случайно пришедший в голову атом nechto, то теперь решили использовать название модуля. Препроцессор Эрланга вместо ?MODULE поставит на это место summator (название нашего модуля).

Дело в том, что если какой-то параллельно работающий актор тоже решит открыть таблицу, дав ей название nechto, получится, что он тоже получит доступ к нашей таблице (на чтение, по умолчанию). Нам это может быть полезно, а может быть вредно. Я, подглядев этот трюк в книге Джо Армстронга, тоже выработал у себя привычку использовать ?MODULE.

Названия модулей уникальны, и значит, названия таблиц тоже будут уникальными. Чаще всего ETS/DETS таблицы нужны не для обмена данными (для этого есть инструмент сообщений между акторами), а для сохранения состояния актора. Очень логично поэтому называть таблицу названием модуля.

Дальше в четырёх строках мы определяем фунтерм и привязываем его к переменной Vstav. Вставка миллиона записей делается с помощью одной строки. Всё это выражение состоит из обработчика списка. lists:seq(1, 1000) генерирует числа от одного до тысячи. В качества уникального ключа в нашей таблице мы используем {X, Y} (миллион разных комбинаций). Возвращаем из нашей функции true — дабы не возвращать длиннющий список.

Чтобы засечь время, мы используем стандартную функцию timer:tc/2. Первый аргумент у неё — переменная с фунтермом. Второй — передаваемые аргументы. timer:tc/2 возвращает кортеж из двух элементов: сколько времени прошло, какое значение вернул фунтерм. Второе нам не интересно, поэтому мы используем анонимную переменную _.

Аналогично мы поступили и с генерацией запросов к ETS. Мы опять использовали похожий обработчик списка — чтобы сгенерировать все возможные ключи.

Итак, что мы получили в итоге?

$ erl -noshell -s summator main -s init stop
Time: 638531 microseconds.
Time: 267364 microseconds.

Это сколько ушло на миллион запросов. На одну же запись в БД ушло около 0,64 микросекунды. На чтение меньше — 0,27 мкс. Если учесть, что это не просто переменные, с ними можно делать кое-что интересное, например превратить в DETS и сохранить на диск, то вполне приятный результат.

Проверялось на машине с процессором AMD FX-4300 (4) @ 3.800 GHz.

Виды таблиц ETS

При создании таблицы следует определить её тип. Можно выбрать один из четырёх типов:

ETS и мягкий риалтайм

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

В Эрланге в запросах к ETS реализован мягкий риалтайм. При нём разброс времени запроса очень небольшой. Однако он всё же есть. При этом надо учесть особенности риалтайма в четырёх типах таблиц.

Для таблиц типа set время как записи, так и чтения является стабильным, причём независимо от размера таблицы. Для таблиц ordered_set используется бинарное дерево, облегчающее поиск, поэтому время записи и чтения зависит от размера этого дерева в настоящее время. А точнее, время записи и чтения пропорционально логарифму количества имеющихся записей. У таблиц bag и duplicate_bag время пропорционально наличному количеству объектов с одинаковым ключом.

Мои эксперименты показали, что примерно 98-99 % запросов действительно обладают стабильным временем выполнения. У этой части запросов стандартное отклонение небольшое, примерно ±5 %. Остальные запросы “выстреливают”, у них время выполнения больше: в разы, на порядок, на несколько порядков. Это касается запросов как на добавление, так и на чтение. Причём, хотя среднее время на чтение меньше, чем время на добавление, тем не менее почему-то чтение сопровождается большим разбросом времени (в больших таблицах). Эксперименты проходили на Erlang/OTP 27 [erts-15.0.1].

Поэтому, на мой взгляд, стоит осторожно относиться к заявленной возможности мягкого риалтайма у ETS.

Документация

ets — модуль ets в стандартной библиотеке.

qlc — модуль qlc для запросов к Мнезии, ETS, DETS и др.


© Алексей Карманов, 2024.