Статьи

Введение в таблицы ETS в эликсире

При создании программы Elixir вам часто нужно делить состояние. Например, в одной из моих предыдущих статей я показал, как кодировать сервер для выполнения различных вычислений и сохранения результата в памяти (а позже мы увидели, как сделать этот сервер пуленепробиваемым с помощью супервизоров). Однако существует проблема: если у вас есть один процесс, который заботится о состоянии, и многие другие процессы, которые обращаются к нему, производительность может серьезно пострадать. Это просто потому, что процесс может обслуживать только один запрос за раз.

Однако есть способы преодоления этой проблемы, и сегодня мы поговорим об одном из них. Познакомьтесь с таблицами Erlang Term Storage или просто таблицами ETS, быстрым хранилищем в памяти, которое может содержать кортежи произвольных данных. Как видно из названия, эти таблицы изначально были представлены в Erlang, но, как и в любом другом модуле Erlang, мы можем легко использовать их и в Elixir.

В этой статье вы будете:

  • Узнайте, как создавать таблицы ETS и опции, доступные при создании.
  • Узнайте, как выполнять чтение, запись, удаление и некоторые другие операции.
  • Смотрите таблицы ETS в действии.
  • Узнайте о дисковых таблицах ETS и о том, как они отличаются от таблиц в памяти.
  • Посмотрите, как конвертировать ETS и DETS туда и обратно.

Все примеры кода работают с Elixir 1.4 и 1.5, который был недавно выпущен .

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

Данные в таблице ETS представлены кортежем {:key, value1, value2, valuen} . Вы можете легко просмотреть данные по их ключу или вставить новую строку, но по умолчанию не может быть двух строк с одним и тем же ключом. Операции на основе ключей выполняются очень быстро, но если по какой-то причине вам необходимо составить список из таблицы 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, чтобы узнать больше.

Чтобы суммировать факты, которые мы узнали до сих пор, давайте изменим простую программу, которую я представил в моей статье о 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 или просто 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 может стать хорошим приростом производительности, поэтому знание об этом решении полезно в любом случае.

Надеюсь, вам понравилась эта статья. Как всегда, спасибо, что остаетесь со мной, и до скорой встречи!