Списки ввода-вывода в 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 инициирует системный вызов для записи в файл. Воспользовавшись скриптом DTrace от Эвана Миллера, который упоминается в этой замечательной статье, получим следующее:

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 — делегирует вызов похожей функции Erlang file:write_file/3, которую можно вызвать следующим образом:

:file.write_file("/tmp/tmp.txt", some_iodata, [:raw])

Данная функция открывает и закрывает дескриптор файлов. В текущей версии Erlang/OTP (19.1.2) в функции :file:write_file/3 есть баг: она всегда помещает данные вывода в одну строку, даже при использовании режима raw.

Ещё одна особенность работы с writev заключается в том, что можно объединять только строки длиной до 64 байт. Причиной этому служит специфика работы BEAM с поиском строк в памяти и копированием данных между процессами. Если список ввода-вывода содержит строки длиннее 64 байт («refc» строки), они появятся в векторе функции writev отдельными записями.

Используйте списки ввода-вывода

Если вы планируете организовать вывод данных и записать их в файл, то вот небольшой совет: забудьте о конкатенации. Воспользуйтесь списком ввода-вывода.

С их помощью вывод можно будет реализовать гораздо проще, расход памяти сократится, а BEAM вместо копирования данных будет вызывать writev.

© 2020 / Россия Любые мысли и вопросы пишите на elixir@wunsh.ru.