При создании программы Elixir вам часто нужно делить состояние. Например, в одной из моих предыдущих статей я показал, как кодировать сервер для выполнения различных вычислений и сохранения результата в памяти (а позже мы увидели, как сделать этот сервер пуленепробиваемым с помощью супервизоров). Однако существует проблема: если у вас есть один процесс, который заботится о состоянии, и многие другие процессы, которые обращаются к нему, производительность может серьезно пострадать. Это просто потому, что процесс может обслуживать только один запрос за раз.
Однако есть способы преодоления этой проблемы, и сегодня мы поговорим об одном из них. Познакомьтесь с таблицами Erlang Term Storage или просто таблицами ETS, быстрым хранилищем в памяти, которое может содержать кортежи произвольных данных. Как видно из названия, эти таблицы изначально были представлены в Erlang, но, как и в любом другом модуле Erlang, мы можем легко использовать их и в Elixir.
В этой статье вы будете:
- Узнайте, как создавать таблицы ETS и опции, доступные при создании.
- Узнайте, как выполнять чтение, запись, удаление и некоторые другие операции.
- Смотрите таблицы ETS в действии.
- Узнайте о дисковых таблицах ETS и о том, как они отличаются от таблиц в памяти.
- Посмотрите, как конвертировать ETS и DETS туда и обратно.
Все примеры кода работают с Elixir 1.4 и 1.5, который был недавно выпущен .
Введение в таблицы ETS
Как я упоминал ранее, таблицы ETS — это хранилище в памяти, которое содержит кортежи данных (называемые строками). Несколько процессов могут обращаться к таблице по ее идентификатору или имени, представленному в виде атома, и выполнять операции чтения, записи, удаления и другие операции. Таблицы ETS создаются отдельным процессом, поэтому, если этот процесс завершается, таблица уничтожается. Однако механизм автоматической сборки мусора отсутствует, поэтому таблица может зависать в памяти довольно долго.
Данные в таблице ETS представлены кортежем {:key, value1, value2, valuen}
. Вы можете легко просмотреть данные по их ключу или вставить новую строку, но по умолчанию не может быть двух строк с одним и тем же ключом. Операции на основе ключей выполняются очень быстро, но если по какой-то причине вам необходимо составить список из таблицы ETS и, скажем, выполнить сложные манипуляции с данными, это тоже возможно.
Более того, существуют дисковые таблицы ETS, которые хранят свое содержимое в файле. Конечно, они работают медленнее, но таким образом вы получаете простое хранилище файлов без каких-либо хлопот. Кроме того, ETS в памяти можно легко преобразовать в дисковые и наоборот.
Итак, я думаю, что пришло время начать наше путешествие и посмотреть, как создаются таблицы ETS!
Создание таблицы ETS
Чтобы создать таблицу ETS, используйте функцию new/2
. Пока мы используем модуль Erlang, его имя должно быть записано как атом:
1
|
cool_table = :ets.new(:cool_table, [])
|
Обратите внимание, что до недавнего времени вы могли создавать до 1400 таблиц на экземпляр BEAM, но это уже не так — вы ограничены только объемом доступной памяти.
Первым аргументом, передаваемым new
функции, является имя таблицы (псевдоним), тогда как второй содержит список параметров. Переменная cool_table
теперь содержит число, идентифицирующее таблицу в системе:
1
|
IO.inspect cool_table # => 12306
|
Теперь вы можете использовать эту переменную для выполнения последующих операций с таблицей (например, для чтения и записи данных).
Доступные Варианты
Давайте поговорим об опциях, которые вы можете указать при создании таблицы. Первое (и несколько странное) замечание заключается в том, что по умолчанию вы не можете использовать псевдоним таблицы каким-либо образом, и в основном это не имеет никакого эффекта. Но, тем не менее, псевдоним должен быть передан при создании таблицы.
Чтобы иметь доступ к таблице по псевдониму, вы должны предоставить опцию :named_table
например:
1
|
cool_table = :ets.new(:cool_table, [:named_table])
|
Кстати, если вы хотите переименовать таблицу, это можно сделать с помощью функции rename/2
:
1
|
:ets.rename(cool_table, :cooler_table)
|
Далее, как уже упоминалось, таблица не может содержать несколько строк с одним и тем же ключом, и это определяется типом . Существует четыре возможных типа таблиц:
-
:set
— это значение по умолчанию. Это означает, что вы не можете иметь несколько строк с одинаковыми ключами. Строки не переупорядочиваются каким-либо конкретным способом. -
:ordered_set
— то же, что и:set
, но строки упорядочены по терминам. -
:bag
несколько строк могут иметь один и тот же ключ, но строки по-прежнему не могут быть полностью идентичными. -
:duplicate_bag
строки могут быть полностью идентичны.
Есть одна вещь, о которой стоит упомянуть в отношении таблиц :ordered_set
. Как говорится в документации Erlang, эти таблицы обрабатывают ключи как равные, когда они сравниваются равными , а не только когда они совпадают . Что это обозначает?
Два термина в Erlang совпадают, только если они имеют одинаковое значение и одинаковый тип. Таким образом, целое число 1
соответствует только другому целому числу 1
, но не является числом с плавающей запятой 1.0
поскольку они имеют разные типы. Однако два слагаемых равны, если они имеют одинаковое значение и тип или оба являются числовыми и распространяются на одно и то же значение. Это означает, что 1
и 1.0
сравниваются равными.
Чтобы указать тип таблицы, просто добавьте элемент в список параметров:
1
|
cool_table = :ets.new(:cool_table, [:named_table, :ordered_set])
|
Другой интересный вариант, который вы можете передать :compressed
. Это означает, что данные внутри таблицы (но не ключи) будут — угадайте, что — хранятся в компактной форме. Конечно, операции, выполняемые над таблицей, станут медленнее.
Затем вы можете контролировать, какой элемент в кортеже должен использоваться в качестве ключа. По умолчанию используется первый элемент (позиция 1
), но это можно легко изменить:
1
|
cool_table = :ets.new(:cool_table, [{:keypos,2}])
|
Теперь вторые элементы в кортежах будут рассматриваться как ключи.
Последний, но не менее важный параметр контролирует права доступа к таблице. Эти права определяют, какие процессы могут получить доступ к таблице:
-
:public
любой процесс может выполнить любую операцию с таблицей. -
:protected
— значение по умолчанию. Только процесс-владелец может записывать в таблицу, но все процессы могут читать. -
:private
— только процесс-владелец может получить доступ к таблице.
Итак, чтобы сделать таблицу приватной, вы должны написать:
1
|
cool_table = :ets.new(:cool_table, [:private])
|
Хорошо, достаточно поговорить об опциях — давайте рассмотрим некоторые общие операции, которые вы можете выполнять с таблицами!
Операции записи
Чтобы что-то прочитать из таблицы, сначала нужно записать туда некоторые данные, поэтому давайте начнем с последней операции. Используйте функцию insert/2
чтобы поместить данные в таблицу:
1
2
|
cool_table = :ets.new(:cool_table, [])
:ets.insert(cool_table, {:number, 5})
|
Вы также можете передать список кортежей, например так:
1
|
:ets.insert(cool_table, [{:number, 5}, {:string, «test»}])
|
Обратите внимание, что если таблица имеет тип :set
и новый ключ соответствует существующему, старые данные будут перезаписаны. Аналогично, если таблица имеет тип :ordered_set
и новый ключ сравнивается со старым, данные будут перезаписаны, поэтому обратите на это внимание.
Операция вставки (даже с несколькими кортежами одновременно) гарантированно является атомарной и изолированной , что означает, что либо все хранится в таблице, либо ничего вообще. Также другие процессы не смогут увидеть промежуточный результат операции. В целом, это очень похоже на транзакции SQL .
Если вы беспокоитесь о дублировании ключей или не хотите перезаписывать ваши данные по ошибке, используйте вместо этого функцию insert_new/2
. Он похож на insert/2
но никогда не вставляет дублирующие ключи и вместо этого возвращает false
. Это относится и к таблицам :bag
и :duplicate_bag
:
1
2
3
|
cool_table = :ets.new(:cool_table, [:bag])
:ets.insert(cool_table, {:number, 5})
:ets.insert_new(cool_table, {:number, 6}) |> IO.inspect # => false
|
Если вы предоставите список кортежей, каждый ключ будет проверен, и операция будет отменена, даже если один из ключей будет дублирован.
Операции чтения
Отлично, теперь у нас есть некоторые данные в нашей таблице — как их получить? Самый простой способ — выполнить поиск по ключу:
1
2
|
:ets.insert(cool_table, {:number, 5})
IO.inspect :ets.lookup(cool_table, :number) # => [number: 5]
|
Помните, что для таблицы :ordered_set
ключ должен сравниваться равным предоставленному значению. Для всех других типов таблиц оно должно совпадать. Также, если таблица представляет собой :bag
или :ordered_bag
, функция lookup/2
может вернуть список с несколькими элементами:
1
2
3
|
cool_table = :ets.new(:cool_table, [:bag])
:ets.insert(cool_table, [{:number, 5}, {:number, 6}])
IO.inspect :ets.lookup(cool_table, :number) # => [number: 5, number: 6]
|
Вместо того, чтобы извлекать список, вы можете получить элемент в нужной позиции, используя lookup_element/3
:
1
2
3
|
cool_table = :ets.new(:cool_table, [])
:ets.insert(cool_table, {:number, 6})
IO.inspect :ets.lookup_element(cool_table, :number, 2) # => 6
|
В этом коде мы получаем строку под ключом :number
и затем занимаем элемент во второй позиции. Он также отлично работает с :bag
или :duplicate_bag
:
1
2
3
|
cool_table = :ets.new(:cool_table, [:bag])
:ets.insert(cool_table, [{:number, 5}, {:number, 6}])
IO.inspect :ets.lookup_element(cool_table, :number, 2) # => 5,6
|
Если вы хотите просто проверить, присутствует ли какой-либо ключ в таблице, используйте member/2
, который возвращает либо true
либо false
:
1
2
3
4
5
|
cool_table = :ets.new(:cool_table, [:bag])
:ets.insert(cool_table, [{:number, 5}, {:number, 6}])
if :ets.member(cool_table, :number) do
IO.inspect :ets.lookup_element(cool_table, :number, 2) # => 5,6
end
|
Вы также можете получить первый или последний ключ в таблице, используя first/1
и last/1
соответственно:
1
2
3
4
|
cool_table = :ets.new(:cool_table, [:ordered_set])
:ets.insert(cool_table, [{:b, 3}, {:a, 100}])
:ets.last(cool_table) |> IO.inspect # => :b
:ets.first(cool_table) |> IO.inspect # => :a
|
Кроме того, можно определить предыдущий или следующий ключ на основе предоставленного. Если такой ключ не может быть найден, будет возвращено :"$end_of_table"
:
1
2
3
4
5
|
cool_table = :ets.new(:cool_table, [:ordered_set])
:ets.insert(cool_table, [{:b, 3}, {:a, 100}])
:ets.prev(cool_table, :b) |> IO.inspect # => :a
:ets.next(cool_table, :a) |> IO.inspect # => :b
:ets.prev(cool_table, :a) |> IO.inspect # => :»$end_of_table»
|
Однако обратите внимание, что обход таблицы с использованием таких функций, как first
, next
, last
или prev
не изолирован. Это означает, что процесс может удалить или добавить дополнительные данные в таблицу во время итерации по ней. Одним из способов решения этой проблемы является использование safe_fixtable/2
, которое исправляет таблицу и гарантирует, что каждый элемент будет выбран только один раз. Таблица остается фиксированной, если процесс не освобождает ее:
1
2
3
4
5
|
cool_table = :ets.new(:cool_table, [:bag])
:ets.safe_fixtable(cool_table, true)
:ets.info(cool_table, :safe_fixed_monotonic_time) |> IO.inspect # => {256000, [{#PID<0.69.0>, 1}]}
:ets.safe_fixtable(cool_table, false) # => table is released at this point
:ets.info(cool_table, :safe_fixed_monotonic_time) |> IO.inspect # => false
|
Наконец, если вы хотите найти элемент в таблице и удалить его, используйте функцию take/2
:
1
2
3
4
|
cool_table = :ets.new(:cool_table, [:ordered_set])
:ets.insert(cool_table, [{:b, 3}, {:a, 100}])
:ets.take(cool_table, :b) |> IO.inspect # => [b: 3]
:ets.take(cool_table, :b) |> IO.inspect # => []
|
Операции удаления
Хорошо, теперь давайте скажем, что вам больше не нужен стол и вы хотите от него избавиться. Используйте delete/1
для этого:
1
2
|
cool_table = :ets.new(:cool_table, [:ordered_set])
:ets.delete(cool_table)
|
Конечно, вы можете удалить строку (или несколько строк) по ее ключу:
1
2
3
|
cool_table = :ets.new(:cool_table, [])
:ets.insert(cool_table, [{:b, 3}, {:a, 100}])
:ets.delete(cool_table, :a)
|
Чтобы очистить всю таблицу, используйте delete_all_objects/1
:
1
2
3
|
cool_table = :ets.new(:cool_table, [])
:ets.insert(cool_table, [{:b, 3}, {:a, 100}])
:ets.delete_all_objects(cool_table)
|
И, наконец, чтобы найти и удалить конкретный объект, используйте delete_object/2
:
1
2
3
4
|
cool_table = :ets.new(:cool_table, [:bag])
:ets.insert(cool_table, [{:a, 3}, {:a, 100}])
:ets.delete_object(cool_table, {:a, 3})
:ets.lookup(cool_table, :a) |> IO.inspect # => [a: 100]
|
Преобразование таблицы
Таблицу ETS можно преобразовать в список в любое время с помощью функции tab2list/1
:
1
2
3
|
cool_table = :ets.new(:cool_table, [:bag])
:ets.insert(cool_table, [{:a, 3}, {:a, 100}])
:ets.tab2list(cool_table) |> IO.inspect # => [a: 3, a: 100]
|
Помните, однако, что выборка данных из таблицы по ключам является очень быстрой операцией, и вы должны придерживаться ее, если это возможно.
Вы также можете tab2file/2
свою таблицу в файл, используя tab2file/2
:
1
2
3
|
cool_table = :ets.new(:cool_table, [:bag])
:ets.insert(cool_table, [{:a, 3}, {:a, 100}])
:ets.tab2file(cool_table, ‘cool_table.txt’) |> IO.inspect # => :ok
|
Обратите внимание, что второй аргумент должен быть charlist (строка в одинарных кавычках).
Существует несколько других доступных операций, которые можно применить к таблицам ETS, и, конечно, мы не будем обсуждать их все. Я действительно рекомендую просмотреть документацию Erlang на ETS, чтобы узнать больше.
Сохраняя государство с ETS
Чтобы суммировать факты, которые мы узнали до сих пор, давайте изменим простую программу, которую я представил в моей статье о GenServer . Это модуль CalcServer
который позволяет выполнять различные вычисления, отправляя запросы на сервер или получая результат:
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
|
defmodule CalcServer do
use GenServer
def start(initial_value) do
GenServer.start(__MODULE__, initial_value, name: __MODULE__)
end
def init(initial_value) when is_number(initial_value) do
{:ok, initial_value}
end
def init(_) do
{:stop, «The value must be an integer!»}
end
def sqrt do
GenServer.cast(__MODULE__, :sqrt)
end
def add(number) do
GenServer.cast(__MODULE__, {:add, number})
end
def multiply(number) do
GenServer.cast(__MODULE__, {:multiply, number})
end
def div(number) do
GenServer.cast(__MODULE__, {:div, number})
end
def result do
GenServer.call(__MODULE__, :result)
end
def handle_call(:result, _, state) do
{:reply, state, state}
end
def handle_cast(operation, state) do
case operation do
:sqrt -> {:noreply, :math.sqrt(state)}
{:multiply, multiplier} -> {:noreply, state * multiplier}
{:div, number} -> {:noreply, state / number}
{:add, number} -> {:noreply, state + number}
_ -> {:stop, «Not implemented», state}
end
end
def terminate(_reason, _state) do
IO.puts «The server terminated»
end
end
CalcServer.start(6.1)
CalcServer.sqrt
CalcServer.multiply(2)
CalcServer.result |> IO.puts # => 4.9396356140913875
|
В настоящее время наш сервер не поддерживает все математические операции, но вы можете расширить его по мере необходимости. Кроме того, моя другая статья объясняет, как преобразовать этот модуль в приложение и использовать возможности супервизоров, чтобы заботиться о сбоях сервера.
Теперь я хотел бы добавить еще одну функцию: возможность регистрировать все математические операции, которые были выполнены вместе с переданным аргументом. Эти операции будут храниться в таблице ETS, чтобы мы могли получить ее позже.
Прежде всего, измените функцию init
так, чтобы была создана новая именованная личная таблица с типом :duplicate_bag
. Мы используем :duplicate_bag
потому что могут быть выполнены две идентичные операции с одним и тем же аргументом:
1
2
3
4
|
def init(initial_value) when is_number(initial_value) do
:ets.new(:calc_log, [:duplicate_bag, :private, :named_table])
{:ok, initial_value}
end
|
Теперь handle_cast
обратный вызов handle_cast
чтобы он регистрировал запрошенную операцию, подготавливал формулу и затем выполнял фактические вычисления:
1
2
3
|
def handle_cast(operation, state) do
operation |> prepare_and_log |> calculate(state)
end
|
Вот prepare_and_log
функция prepare_and_log
:
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
|
defp prepare_and_log(operation) do
operation |> log
case operation do
:sqrt ->
fn(current_value) -> :math.sqrt(current_value) end
{:multiply, number} ->
fn(current_value) -> current_value * number end
{:div, number} ->
fn(current_value) -> current_value / number end
{:add, number} ->
fn(current_value) -> current_value + number end
_ ->
nil
end
end
|
Мы регистрируем операцию сразу (соответствующая функция будет представлена в ближайшее время). Затем верните соответствующую функцию или nil
если мы не знаем, как обрабатывать операцию.
Что касается функции log
, мы должны либо поддерживать кортеж (содержащий как имя операции, так и аргумент), так и атом (содержащий только имя операции, например :sqrt
):
01
02
03
04
05
06
07
08
09
10
11
|
def log(operation) when is_tuple(operation) do
:ets.insert(:calc_log, operation)
end
def log(operation) when is_atom(operation) do
:ets.insert(:calc_log, {operation, nil})
end
def log(_) do
:ets.insert(:calc_log, {:unsupported_operation, nil})
end
|
Затем calculate
функцию, которая либо возвращает правильный результат, либо сообщение остановки:
1
2
3
4
5
6
7
|
defp calculate(func, state) when is_function(func) do
{:noreply, func.(state)}
end
defp calculate(_func, state) do
{:stop, «Not implemented», state}
end
|
Наконец, давайте представим новую интерфейсную функцию для извлечения всех выполненных операций по их типу:
1
2
3
|
def operations(type) do
GenServer.call(__MODULE__, {:operations, type})
end
|
Обработка звонка:
1
2
3
|
def handle_call({:operations, type}, _, state) do
{:reply, fetch_operations_by(type), state}
end
|
И выполнить фактический поиск:
1
2
3
|
defp fetch_operations_by(type) do
:ets.lookup(:calc_log, type)
end
|
Теперь проверьте все:
1
2
3
4
5
6
7
|
CalcServer.start(6.1)
CalcServer.sqrt
CalcServer.add(1)
CalcServer.multiply(2)
CalcServer.add(2)
CalcServer.result |> IO.inspect # => 8.939635614091387
CalcServer.operations(:add) |> IO.inspect # => [add: 1, add: 2]
|
Результат верный, потому что мы выполнили два :add
операции :add
с аргументами 1
и 2
. Конечно, вы можете расширить эту программу по своему усмотрению. Тем не менее, не злоупотребляйте таблицами ETS, а используйте их, когда это действительно повысит производительность — во многих случаях использование неизменяемых является лучшим решением.
Диск ETS
Прежде чем закончить эту статью, я хотел сказать пару слов о дисковых таблицах ETS или просто DETS .
DETS очень похожи на ETS: они используют таблицы для хранения различных данных в виде кортежей. Разница, как вы уже догадались, заключается в том, что они полагаются на файловое хранилище, а не на память, и имеют меньше возможностей. DETS имеют функции, подобные тем, которые мы обсуждали выше, но некоторые операции выполняются немного по-другому.
Чтобы открыть таблицу, вам нужно использовать либо open_file/1
либо open_file/2
— там нет new/2
функции new/2
как в модуле :ets
. Поскольку у нас еще нет никакой существующей таблицы, давайте придерживаться open_file/2
, который собирается создать для нас новый файл:
1
|
:dets.open_file(:file_table, [])
|
Имя файла по умолчанию совпадает с именем таблицы, но это можно изменить. Второй аргумент, передаваемый open_file
— это список опций, записанных в форме кортежей. Есть несколько доступных опций, таких как :access
или :auto_save
. Например, чтобы изменить имя файла, используйте следующую опцию:
1
|
:dets.open_file(:file_table, [{:file, ‘cool_table.txt’}])
|
Обратите внимание, что есть также опция :type
которая может иметь одно из следующих значений:
-
:set
-
:bag
-
:duplicate_bag
Эти типы такие же, как для ETS. Обратите внимание, что DETS не может иметь тип :ordered_set
.
Нет опции :named_table
, поэтому вы всегда можете использовать имя таблицы для доступа к ней.
Стоит отметить, что таблицы DETS должны быть правильно закрыты:
1
|
:dets.close(:file_table)
|
Если вы этого не сделаете, таблица будет восстановлена при следующем открытии.
Вы выполняете операции чтения и записи так же, как и в ETS:
1
2
3
4
|
:dets.open_file(:file_table, [{:file, ‘cool_table.txt’}])
:dets.insert(:file_table, {:a, 3})
:dets.lookup(:file_table, :a) |> IO.inspect # => [a: 3]
:dets.close(:file_table)
|
Имейте в виду, однако, что DETS медленнее, чем ETS, потому что Elixir потребуется для доступа к диску, что, конечно, занимает больше времени.
Обратите внимание, что вы можете легко конвертировать таблицы ETS и DETS. Например, давайте используем to_ets/2
и скопируем содержимое нашей таблицы DETS в память:
1
2
3
4
5
6
7
8
9
|
:dets.open_file(:file_table, [{:file, ‘cool_table.txt’}])
:dets.insert(:file_table, {:a, 3})
my_ets = :ets.new(:my_ets, [])
:dets.to_ets(:file_table, my_ets)
:dets.close(:file_table)
:ets.lookup(my_ets, :a) |> IO.inspect # => [a: 3]
|
Скопируйте содержимое ETS в DETS, используя to_dets/2
:
1
2
3
4
5
6
7
|
my_ets = :ets.new(:my_ets, [])
:ets.insert(my_ets, {:a, 3})
:dets.open_file(:file_table, [{:file, ‘cool_table.txt’}])
:ets.to_dets(my_ets, :file_table)
:dets.lookup(:file_table, :a) |> IO.inspect # => [a: 3]
:dets.close(:file_table)
|
Подводя итог, дисковые ETS — это простой способ хранения содержимого в файле, но этот модуль немного менее мощный, чем ETS, и операции также медленнее.
Вывод
В этой статье мы поговорили о ETS и дисковых ETS-таблицах, которые позволяют нам хранить произвольные термины в памяти и в файлах соответственно. Мы увидели, как создавать такие таблицы, каковы доступные типы, как выполнять операции чтения и записи, как уничтожать таблицы и как преобразовывать их в другие типы. Вы можете найти больше информации о ETS в руководстве Elixir и на официальной странице Erlang .
Еще раз, не злоупотребляйте таблицами ETS и старайтесь придерживаться неизменных, если это возможно. Однако в некоторых случаях ETS может стать хорошим приростом производительности, поэтому знание об этом решении полезно в любом случае.
Надеюсь, вам понравилась эта статья. Как всегда, спасибо, что остаетесь со мной, и до скорой встречи!