Списки ввода-вывода в Elixir. Часть 1: повышение производительности вывода данных
Чтобы программа работала быстрее, она должна делать как можно меньше.
Предположим, нужно записать данные в файл или послать ответ браузеру. Каким в этом случае будет минимальный набор действий?
Ответ: скопировать каждый байт данных в файл или сокет.
Чтобы получить мгновенное время отклика, как у фреймворка Phoenix, это действие должно быть единственным. С Phoenix это возможно благодаря одной любопытной структуре данных под названием «список ввода-вывода».
Используя такую структуру, можно повысить эффективность кода. Чтобы понять, как это работает, рассмотрим то, с чем разработчики сталкиваются каждый день, — операцию конкатенации строк.
Строки и списки ввода-вывода
Конкатенация в Elixir выглядит примерно так:
name = "James"
IO.puts "Hi, " <> name # => "Hi, James"
Интерполяция, в общем-то, делает то же самое, но немного красивее:
name = "James"
IO.puts "Hi, #{name}" # => "Hi, James"
Чтобы исполнить этот код, виртуальная машина BEAM должна:
-
выделить память под строку «James»;
-
выделить память под строку «Hi,»;
-
выделить память под третью строку и скопировать две другие в неё, чтобы получилось «Hi, James».
Копирование — это уже лишние действия. Кроме того, чем больше строк, тем больше используется памяти и тем больше будет работы для сборщика мусора.
В Elixir сцепление строк можно реализовать гораздо более эффективно с помощью списка ввода-вывода.
Список ввода-вывода — это список данных (например, строк или IO.puts/1
и File.write/2
работают с определёнными данными, в качестве которых может выступать либо простая строка, либо список ввода-вывода.
name = "James"
IO.puts ["Hi, ", name] # => "Hi, James"
IO.puts ["Hi, ", 74, 97, 109, 101, 115] # => "Hi, James"
Списки ввода-вывода могут быть вложенными, но функции ввода-вывода всё равно будут воспринимать их как плоские.
IO.puts ["Hi, ", [74, [97, [109, [101, [115]]]]]] # => "Hi, James"
На первый взгляд может показаться, что ничего не изменилось, ведь результат остался прежним. Однако подобная структуризация выходных данных положительно сказывается на производительности.
Во-первых, списки позволяют реализовать повторения более грамотно.
users = [%{name: "Amy"}, %{name: "Joe"}]
response = Enum.map(users, fn (user) ->
["<li>", user.name, "</li>"]
end)
IO.puts response
В приведённом примере строка <li>
создаётся только один раз, а для её дублирования в списке используются указатели. Таким образом, чем больше в содержимом вывода повторов, тем лучше, ведь теперь не потребуется выделять память под каждую строку.
Списки ввода-вывода можно вкладывать друг в друга, что позволяет создавать их намного быстрее. Обычно, добавление элемента к связному списку выполняется за O(N): перебрать все элементы списка, дойти до последнего и поместить в него указатель на новый элемент. Иммутабельность данных усложняет всё ещё больше: последний элемент изменять нельзя, его можно только копировать. А это означает, что копировать придётся и предыдущий элемент, и тот, что стоит перед ним, и так до самого начала списка.
При этом, используя вложения, можно добавить элемент в список, просто обернув этот список в другой.
names = ["A", "B"] # => ["A", "B"]
names = [names, "C"] # => [["A", "B"], "C"]
Эта операция выполнится за время O(1) и не потребует копирования данных.
Списки ввода-вывода также оказывают значительное влияние на системные вызовы.
Системные вызовы
Большинство приложений не способны напрямую взаимодействовать с файлами и сокетами. Чтобы предписать операционной системе какие-либо действия от своего имени, они используют системные вызовы. В свою очередь, ОС осуществляет контроль доступа к файлам и учитывает нюансы работы с тем или иным железом.
Рассмотрим такой пример, написанный на Elixir:
# Here I'm calling an Erlang function. I'll explain why later.
{:ok, file} = :file.open("/tmp/tmp.txt", [:write, :raw])
foo = "foo"
bar = "bar"
output = [foo, bar, foo]
output = Enum.join(output)
# Another Erlang function call
:file.write(file, output)
Всё достаточно просто: открываем файл, создаём несколько строк, связываем их друг с другом и выводим результат в файл.
Исполняя последнюю строчку кода, виртуальная машина BEAM инициирует системный вызов для записи в файл. Воспользовавшись
write:return Write data (9 bytes): 0x00000000146007e2
BEAM осуществляет системный вызов write
и даёт команду: переписать 9 байт из ячейки памяти по адресу 0×00000000146007e2
. Эта строка длиной 9 байт состоит из трёх частей: foo
(3 байта), bar
(3 байта) и foo
(3 байта).
Теперь посмотрим, что получится, если строку кода, в которой эти три части объединяются в единое целое, превратить в комментарий:
{:ok, file} = :file.open("/tmp/tmp.txt", [:write, :raw])
foo = "foo"
bar = "bar"
output = [foo, bar, foo]
# output = Enum.join(output)
:file.write(file, output)
На этот раз передаём в функцию :file.write/2
список ввода-вывода. Казалось бы, не такое уж большое изменение, но взгляните на системный вызов:
writev:return Writev data 1/3: (3 bytes): 0x0000000014600430
writev:return Writev data 2/3: (3 bytes): 0x0000000014600120
writev:return Writev data 3/3: (3 bytes): 0x0000000014600430
Получаем один вызов функции writev
для вывода трёх фрагментов данных: foo
с одного адреса памяти, bar
— с другого, а foo
с того же адреса, что и первый фрагмент.
Интересно, не правда ли? Конечная строка foobarfoo
в самой программе не создаётся. Три фрагмента собираются воедино непосредственно в файле.
Когда конкатенация осуществляется программно, две строки помещаются в память виртуальной машины, их содержимое копируется в третью строку, которую операционная система выводит в файл.
В случае со списком ввода-вывода можно забыть о реализации конкатенации строк и выделении памяти для девятибайтовой строки и избавить сборщик мусора от работы по её удалению.
Всё, что останется сделать виртуальной машине, — это обратиться к ОС, и она скопирует данные в файл.
Особенности работы с writev
Как было сказано ранее, виртуальная машина не осуществляет конкатенацию строк в списке при реализации операций ввода-вывода. Если запустить этот код в IEx и отследить системные вызовы, то каждый элемент списка окажется отдельным аргументом writev
.
{:ok, file} = :file.open("/tmp/tmp.txt", [:write, :raw])
:file.write(file, some_iolist_of_your_own)
И всё же, чтобы убедиться, что writev
действительно используется в приведённом фрагменте кода, я принял пару важных решений.
Во-первых, я сделал так, чтобы файл открывался в режиме raw. Так, BEAM не будет создавать отдельный процесс для работы с файлом, как это происходит по умолчанию. Об этом подробно написано в документации Erlang для функции file:open/2
и в разделе «Performance» внизу страницы.
Во-вторых, я использовал два вызова функций Erlang, а не Elixir, как может показаться:
File.write("/tmp/tmp.txt", some_iodata, [:raw])
Эта функция Elixir —
:file.write_file("/tmp/tmp.txt", some_iodata, [:raw])
Данная функция открывает и закрывает дескриптор файлов. В текущей версии Erlang/OTP (19.1.2) в функции :file:write_file/3
есть баг: raw
.
Ещё одна особенность работы с writev заключается в том, что можно объединять только строки длиной до 64 байт. Причиной этому служит специфика работы BEAM с поиском строк в памяти и копированием данных между процессами. Если список ввода-вывода содержит строки длиннее 64 байт (
Используйте списки ввода-вывода
Если вы планируете организовать вывод данных и записать их в файл, то вот небольшой совет: забудьте о конкатенации. Воспользуйтесь списком ввода-вывода.
С их помощью вывод можно будет реализовать гораздо проще, расход памяти сократится, а BEAM вместо копирования данных будет вызывать writev.