Досега говорихме, че процесите могат да си комуникират САМО чрез размяна на съобщения...
- С Erlang/OTP идва и Erlang Term Storage или ETS.
- ETS представлява ключ-стойност база данни, живееща в паметта, която е част от BEAM виртуалната машина. |
- Тя НЕ Е имплементирана на Erlang. Вместо това е вградена в самата виртуална машина. |
- Това означава, че е написана на C и е оптимизирана за конкурентно писане и четене, а вътрешно съхранява данни, които са MUTABLE. |
- Понякога ни се налага да имаме процес, който съхранява състояние, което трябва да е достъпно от няколко процеса. |
- Пример : имаме някакъв service (HTTP Server?) и искаме да си водим статистика, кой потребител, колко request-а е направил. |
- Бихме могли да използваме тази информация за доста неща... |
- Как бихме могли да направим това? |
defmodule RequestsPerUser do
use GenServer
def start_link do
GenServer.start_link(__MODULE__, nil, name: __MODULE__)
end
def init(_), do: {:ok, %{}}
def update(user) do
GenServer.cast(__MODULE__, {:update, user})
end
def get(user), do: GenServer.call(__MODULE__, {:get, user})
def handle_call({:get, user}, _, state) do
{:reply, Map.get(state, user, 0), state}
end
def handle_cast({:update, user}, state) do
{:noreply, Map.update(state, user, 1, & &1 + 1)}
end
end
- За всеки request към нашата услуга, даден процес ще прави нещо и ще връща резултат на потребителя.
- Преди да върне този резултат, ще извиква RequestsPerUser.update/1 за да увеличи броя на request-ите за дадения потребител:
RequestsPerUser.update("pe6o")
#=> :ok
- При 1000 рекуеста на секунда, това означава, че на процеса RequestsPerUser ще бъдат изпращани 1000+ съобщения за всяка секунда. |
- Всяко от тези съобщения се обработва последователно. |
- Извикване до RequestsPerUser.get/1 може да отнеме дадено време... |
- Имаме bottleneck : много процеси използват един процес за пазене и промяна на споделено състояние. |
- Ако една програма използва множество нишки/процеси за да постигне конкурентно писане и четене, ако има една нишка/процес, до която всички други опират, все едно имаме последователна програма. |
- Как да имплементираме споделено състояние между процеси с конкурентен достъп? |
- Отговорът е ETS. |
- Както споменахме ETS означава Erlang Term Storage. |
- Всеки процес в Elixir/Erlang си има собствен стек и heap |
- Също така има споделена памет за по-големите binary-та. |
- Има и още една споделена памет - ETS. |
- ETS не е имплементирана на Erlang и не е изградена от Erlang процеси и persistent структури като Map или List. |
- ETS е част от виртуалната машина, написана е на C и структурите, които използва са mutable. |
- Интерфейсът към ETS е на Erlang и отвън може да се приеме, че до таблиците ѝ имаме достъп чрез съобщения, но това не е така. |
- Достъпът до таблица в ETS е доста бърз и конкурентен. |
- ETS се състои от множество таблици. |
- Всяка таблица може да се създаде от който и да е процес и се притежава от процес. |
- Има 4 типа таблици : :set, :ordered_set, :bag и :duplicate_bag. |
- Всяка таблица има множество записи, които винаги са представени от tuple. |
- Един елемент от всеки от тези tuple-и e ключ и по него може да се търси с бърз достъп. |
Таблици от тип :set
- Тези таблици могат да имат запис с даден ключ само веднъж. |
- Имплементирани са с линейна хеш таблица, която обещава бърз достъп за четене и писане. |
- Тази хеш таблица е mutable. |
- Това е типа на таблиците в ETS по подразбиране. |
Таблици от тип :ordered_set
- Тези таблици се държат по същия начин като :set таблиците, но записите им са сортирани по ключовете си. |
- Възможно е да обхождаме тези записи, използвайки наредбата им. |
- Имплементирани са чрез AVL дърво и имат достъп за четене и писане от порядъка на O(log(n)). |
Таблици от пип :bag и :duplicate_bag
- Таблиците от тип :bag позволяват множество записи с един и същи ключ, които обаче не може да са напълно еднакви.
- Пример:
{1, "пе6о", 16}
{1, "то6о", 16}
Таблици от пип :bag и :duplicate_bag
- При :bag няма как да имаме:
{1, "пе6о", 16}
{1, "пе6о", 16}
Таблици от пип :bag и :duplicate_bag
- Ако искаме да имаме напълно повтарящи се записи, ще използваме :duplicate_bag.
- Тези типове таблици са имплементирани чрез същия тип хеш таблица като :set таблиците.
- Когато се създаде ETS таблица, тя принадлежи на процес. |
- Когато този процес спре да съществува, таблицата или се предава на друг процес, или се изчиства от паметта. |
- Има три възможни типа достъп до ETS таблици. |
- Процесът, който притежава таблица може да чете и пише в нея.
- Останалите процеси могат само да четат от дадената таблица.
- Това е достъпът по подразбиране.
- Процесът, който притежава таблица може да чете и пише от нея.
- Останалите процеси нямат достъп до таблицата.
- Всички процеси имат както достъп за четене, така и достъп за писане до таблицата.
- Таблици могат да бъдат създавани от който и да е процес. |
- Този процес става собственик на таблицата. |
- За работа с ETS таблици, използваме Erlang-ския модул :ets. |
:ets.new(:users, [])
#=> #Reference<0.4003328283.155844609.148120>
table = :ets.new(:users, [])
#=> #Reference<0.4003328283.155844609.148120>
:ets.info(table)
#=> [
#=> read_concurrency: false,
#=> write_concurrency: false,
#=> compressed: false,
#=> memory: 300,
#=> owner: #PID<0.89.0>,
#=> heir: :none,
#=> name: :users,
#=> size: 0,
#=> node: :nonode@nohost,
#=> named_table: false,
#=> type: :set,
#=> keypos: 1,
#=> protection: :protected
Атрибутът :named_table:
:ets.insert(
:users,
{"meddle", 34, "Sofia", [:elixir, :gaming, :meddling]}
)
#=> ** (ArgumentError) argument error
Атрибутът :named_table:
:ets.insert(
table,
{"meddle", 34, "Sofia", [:elixir, :gaming, :meddling]}
)
#=> true
:ets.info(table, :size)
#=> 1
Атрибутът :named_table:
:ets.new(:users, [:named_table]) # Сега ще е атом
#=> :users
:ets.info(:users, :named_table)
#=> true
:ets.insert(
:users,
{"meddle", 34, "Sofia", [:elixir, :gaming, :meddling]}
)
#=> true
Нека имаме таблица, която съдържа набор от хора. В нея ще имаме:
Искаме редовете да са сортирани по nick-овете и таблицата да е достъпна за четене и писане отвсякъде:
:ets.new(
:people, [:named_table, :public, :ordered_set, keypos: 3]
)
#=> :people
insert - Добавя редове, презаписва съществуващи:
:ets.insert(
:people,
[
{
"Петър", "Петров", "pe60", 55,
["Еврофутбол", "мачове", "ракийка и салатка", "Цеца"]
},
{
"Милена", "Стоева", "milen4it0", 23,
["социални мрежи", "кафенца", "клубчета", "дрешки"]
}
]
)
#=> true
insert - Добавя редове, презаписва съществуващи:
:ets.info(:people, :size)
#=> 2
insert_new - Не създава ако нещо съществува вече:
:ets.insert_new(
:people,
[
{
"Милена", "Стоева", "milen4it0", 23,
["социални мрежи", "кафенца", "клубчета", "дрешки"]
},
{
"Слави", "Боянов", "reductions", 25,
["база данни", "elixir", "бридж", "разсъждения!"]
}
]
)
#=> false
insert_new - Не създава ако нещо съществува вече:
:ets.info(:people, :size)
#=> 2
- Можем да добавим и по-големи или по-малки tuple-и.
- Важното е да имат поне толкова елементи, колкото е :keypos.
- В нашия случай - три. Ако опитаме да добавим кортеж с два елемента:
:ets.insert(:people, {"Слави", "Боянов"})
#=> ** (ArgumentError) argument error
#=> (stdlib) :ets.insert(:people, {"Слави", "Боянов"})
- Има много начини да четем от ETS таблици.
- Най-простият начин е да търсим по ключ:
:ets.lookup(:people, "pe60")
#=> [
#=> {"Петър", "Петров", "pe60", 55,
#=> ["Еврофутбол", "мачове", "ракийка и салатка",
#=> "Цеца"]}
#=> ]
:ets.lookup(:people, "meddle")
#=> []
- Функцията :ets.lookup/2 винаги връща списък.
- Това е така, защото при bag и duplicate_bag таблици има вероятност да имаме повече от един резултат.
- Ако ни трябва само дадена колона за даден ключ можем да ползваме lookup_element.
- Забелязвате, че при ETS позициите започват от 1.
age = :ets.lookup_element(:people, "pe60", 4)
#=> 55
age = :ets.lookup_element(:people, "meddle", 4)
#=> ** (ArgumentError) argument error
- Erlang има специален 'език' за намиране на данни.
- Използва се за селекция и се нарича 'match спецификация'.
Една match-спецификация се състои от:
- Head : Описва редовете, които искаме да match-нем или изберем.
- Guard : Допълнителни филтри към тези редове-записи.
- Result : Как да изглежда резултата (Трансформации).
match_spec = [
{
{:"$1", :"$2", :_, :_, :"$3"}, # Head
[
{:andalso, {:is_list, :"$3"},
{:>, {:length, :"$3"}, 3}}
], # Guard
[{{:"$1", :"$2"}}] # Result
}
]
:ets.select(:people, match_spec)
#=> [{"Милена", "Стоева"}, {"Петър", "Петров"}]
match_func =
fn {v1, v2, _, _, v3} when is_list(v3) and length(v3) > 3 ->
{v1, v2}
end
#=> #Function<6.99386804/1 in :erl_eval.expr/5>
:ets.fun2ms(match_func)
#=> [
#=> {{:"$1", :"$2", :_, :_, :"$3"},
#=> [{:andalso, {:is_list, :"$3"}, {:>, {:length, :"$3"}, 3}}],
#=> [{{:"$1", :"$2"}}]}
#=> ]
:ets.match(:people, {:"$1", :"$2", :"_", :"_", :"_"})
#=> [["Милена", "Стоева"], ["Петър", "Петров"]]
Съпоставянето си е нормалното Elixir/Erlang съпоставяне, което означава, че можем да правим и такива неща:
:ets.match(:people, {:"_", :"_", :"$2", :"_", [:"$1" | :"_"]})
[["социални мрежи", "milen4it0"], ["Еврофутбол", "pe60"]]
- ETS съдържа функции за обхождане на редовете на таблица като списък. |
- При :ordered_set таблици редът е по ключовете, които са сортирани. |
- Ако таблицата е друг тип редът зависи от това как се пазят записите вътрешно. |
- Функцията :ets.first/1 ще върне ключа на първия ред. |
- Съответно :ets.last/1 ще върне ключа на последния ред. |
:ets.next(:people, :ets.first(:people))
#=> "pe60"
:ets.prev(:people, "pe60")
#=> "milen4it0"
:ets.next(:people, "pe60")
#=> :"$end_of_table"
:ets.prev(:people, "milen4it0")
#=> :"$end_of_table"
Могат да се задават стойности за които няма ключове в таблицата, те ще се сравнят и ще се намери най-близкия ключ:
:ets.prev(:people, "s")
#=> "pe60"
:ets.next(:people, "s")
#=> :"$end_of_table"
- Лесен начин е да се използва :ets.insert/2 за вече съществуващ ключ, което ще презапише реда. |
- Има и :ets.update_element/3 - задава се таблица, ключ и наредена двойка от позиция за промяната и нова стойност. |
:ets.update_element(:people, "pe60", {1, "Пешо"})
#=> true
:ets.lookup(:people, "pe60")
#=> [
#=> {"Пешо", "Петров", "pe60", 55,
#=> ["Еврофутбол", "мачове", "ракийка и салатка",
#=> "Цеца"]}
#=> ]
- Има специализирани функции update_counter за атомарно увеличаване или намаляване на брояч, пазен в таблицата.
- Пример е следната имплементация на RequestsPerUser:
defmodule RequestsPerUser do
use GenServer
def start_link do
GenServer.start_link(__MODULE__, nil, name: __MODULE__)
end
def init(_) do
{:ok, :ets.new(__MODULE__, [:named_table, :public])}
end
def update(user) do
:ets.update_counter(__MODULE__, user, {2, 1}, {user, 0})
end
def get(user) do
case :ets.match(__MODULE__, {user, :"$1"}) do
[[n]] -> n
[] -> 0
end
end
end
RequestsPerUser.start_link()
#=> {:ok, #PID<0.115.0>}
RequestsPerUser.get("meddle")
#=> 0
RequestsPerUser.update("meddle")
#=> 1
RequestsPerUser.get("meddle")
#=> 1
Лесно можем да изтрием запис по ключ:
:ets.delete(:people, "pe60")
#=> true
:ets.info(:people, :size)
#=> 1
Или по съвпадение:
:ets.match_delete(:people, {"Милена", :"_", :"_", :"_", :"_"})
#=> true
:ets.info(:people, :size)
#=> 0
Можем и да освободим цялата таблица от паметта, когато поискаме:
:ets.delete(:people)
#=> true
- Публични таблици могат да бъдат изтрити от който и да е процес.
- Ако таблица не е публична, тя може да бъде изтрита само от процесът, който я притежава.
- Ако процесът owner спре да съществува, ETS таблицата му е изчистена от паметта. |
- Ако не искаме това да се случи, можем да зададем процес-наследник. |
- Това може да стане в :ets.new/2 с опцията {:heir, pid, data}. |
defmodule Heir do
use GenServer
def transfer_data(table) do
GenServer.call(__MODULE__, {:transfer_data, table})
end
def init(_), do: {:ok, %{}}
def handle_info({:"ETS-TRANSFER", table, from, data}, state) do
IO.puts(
[
"#{inspect table} transfered from ",
"#{inspect from} to #{inspect self()}"
]
)
{:noreply, Map.put(state, table, data)}
end
def handle_call({:transfer_data, table}, _, state) do
{:reply, Map.get(state, table), state}
end
end
{:ok, heir_pid} = Heir.start_link()
#=> {:ok, #PID<0.115.0>}
spawn(fn ->
:ets.new(
:some_table,
[:named_table, {:heir, heir_pid, "woo"}]
)
end)
#=> #PID<0.118.0>
#output: :some_table transfered from #PID<0.118.0> to #PID<0.115.0>
:ets.info(:some_table, :owner)
#=> #PID<0.115.0>
- Когато има зададен :heir, ако процесът-собственик на таблица умре, таблицата не се изчиства от паметта. |
- Процесът-наследник получава съобщение от типа {:"ETS-TRANSFER", име-или-референция-към-таблицата, pid-на-предишния-собственик, информация-зададена-при-определянето-на-наследник}. |
- В момента, в който owner процесът спре да съществува, таблицата се притежава от heir процеса. |
- Информацията зададена при определянето на наследника може да се използва за дебъг или история на наследяването. |
- Ако не зададем наследник при създаване на таблицата, можем да го направим в последствие, използвайки :ets.setopts(table, heir_tuple). |
- Възможно е да променим собственика на таблица и ръчно, даже, когато процесът-собственик на таблицата още съществува. |
- Това става с :ets.give_away/3 (:ets.give_away(table, heir_pid, transfer_data)). |
- По подразбиране наследника има стойност :none. Винаги можем да премахнем наследник, като зададем тази стойност. |
- Можем да зададем опцията :compressed на таблица. |
- Това означава, че всичко освен ключа на даден запис ще е компресирано. |
- Това ще оптимизира паметта използвана от таблицата. |
- Цената е, че четене на цели редове ще стане по-бавно. |
- По бавно ще стане и добавянето на нови записи. |
- Има две опции за оптимизиране на често среащни случаи. |
- И двете по подразбиране са изключени (false). |
- read_concurrency: true - Ако имаме множество четения без писания често.
- Ако го направим и често редуваме четения и писания, достъпът до таблицата ще се влоши.
- write_concurrency: true - Ако имаме много промени.
- Промени от типа на insert, delete.
- Ако редуваме писането с чести четения, това не е добра идея.
- По-бързите конкурентни писания идват с цената на повече използвана памет.
- Можем да комбинираме двете опции ако имаме периоди на много четения и периоди на множество писания.
- Тези опции са свързани с начина на заключване и синхронизиране на достъпа до записите в таблиците.
- Най-добре е с наблюдение и тестване да определите стойностите на тези опции.
- Споделено състояние между процеси, които да имат конкурентен и бърз достъп до него. |
- Пазене на текущото състояние на процес, което да бъде възстановено, ако процеса умре. |
- Вместо Map или List за оптимизация, макар че не винаги това ще е най-бързото решение. |
- За регистриране на познати процеси. |
- Когато искаме да работим с данни, които могат да бъдат изпращани от един node към друг. |
- Когато искаме дистрибутирана база данни върху множество node-ове. |
- Когато искаме да запазим данните си между рестартирания на node-а си. |
- В повечето случаи проста комуникация между процеси върши работа. |
- При тестване, ако имаме нужда от временно състояние в паметта по-добре да ползваме Agent. |
- При мемоизация на някакъв резултат, докато си строим данни, които ще използваме по-късно в даден процес, по-добре да ползваме структура в heap-а на този процес. |
- ETS има основната задача да пребори single-process bottleneck проблема. Ако данните ни живеят само в процесът, който ги ползва или ако само един процес ги чете/пише от текущия, ETS не е необходима. |
- DETS и Mnesia, които също са част от Erlang/OTP са базирани и използват ETS.