Списки ввода-вывода в Elixir. Часть 1: повышение производительности вывода данных
Чтобы программа работала быстрее, она должна делать как можно меньше.
Предположим, нужно записать данные в файл или послать ответ браузеру. Каким в этом случае будет минимальный набор действий?
Ответ: скопировать каждый байт данных в файл или сокет.
Чтобы получить мгновенное время отклика, как у фреймворка Phoenix, это действие должно быть единственным. С Phoenix это возможно благодаря одной любопытной структуре данных под названием «список ввода-вывода».
Используя такую структуру, можно повысить эффективность кода. Чтобы понять, как это работает, рассмотрим то, с чем разработчики сталкиваются каждый день, — операцию конкатенации строк.
Строки и списки ввода-вывода
Конкатенация в Elixir выглядит примерно так:
Интерполяция, в общем-то, делает то же самое, но немного красивее:
Чтобы исполнить этот код, виртуальная машина BEAM должна:
-
выделить память под строку «James»;
-
выделить память под строку «Hi,»;
-
выделить память под третью строку и скопировать две другие в неё, чтобы получилось «Hi, James».
Копирование — это уже лишние действия. Кроме того, чем больше строк, тем больше используется памяти и тем больше будет работы для сборщика мусора.
В Elixir сцепление строк можно реализовать гораздо более эффективно с помощью списка ввода-вывода.
Список ввода-вывода — это список данных (например, строк или IO.puts/1
и File.write/2
работают с определёнными данными, в качестве которых может выступать либо простая строка, либо список ввода-вывода.
Списки ввода-вывода могут быть вложенными, но функции ввода-вывода всё равно будут воспринимать их как плоские.
На первый взгляд может показаться, что ничего не изменилось, ведь результат остался прежним. Однако подобная структуризация выходных данных положительно сказывается на производительности.
Во-первых, списки позволяют реализовать повторения более грамотно.
В приведённом примере строка <li>
создаётся только один раз, а для её дублирования в списке используются указатели. Таким образом, чем больше в содержимом вывода повторов, тем лучше, ведь теперь не потребуется выделять память под каждую строку.
Списки ввода-вывода можно вкладывать друг в друга, что позволяет создавать их намного быстрее. Обычно, добавление элемента к связному списку выполняется за O(N): перебрать все элементы списка, дойти до последнего и поместить в него указатель на новый элемент. Иммутабельность данных усложняет всё ещё больше: последний элемент изменять нельзя, его можно только копировать. А это означает, что копировать придётся и предыдущий элемент, и тот, что стоит перед ним, и так до самого начала списка.
При этом, используя вложения, можно добавить элемент в список, просто обернув этот список в другой.
Эта операция выполнится за время O(1) и не потребует копирования данных.
Списки ввода-вывода также оказывают значительное влияние на системные вызовы.
Системные вызовы
Большинство приложений не способны напрямую взаимодействовать с файлами и сокетами. Чтобы предписать операционной системе какие-либо действия от своего имени, они используют системные вызовы. В свою очередь, ОС осуществляет контроль доступа к файлам и учитывает нюансы работы с тем или иным железом.
Рассмотрим такой пример, написанный на Elixir:
Всё достаточно просто: открываем файл, создаём несколько строк, связываем их друг с другом и выводим результат в файл.
Исполняя последнюю строчку кода, виртуальная машина BEAM инициирует системный вызов для записи в файл. Воспользовавшись
BEAM осуществляет системный вызов write
и даёт команду: переписать 9 байт из ячейки памяти по адресу 0×00000000146007e2
. Эта строка длиной 9 байт состоит из трёх частей: foo
(3 байта), bar
(3 байта) и foo
(3 байта).
Теперь посмотрим, что получится, если строку кода, в которой эти три части объединяются в единое целое, превратить в комментарий:
На этот раз передаём в функцию :file.write/2
список ввода-вывода. Казалось бы, не такое уж большое изменение, но взгляните на системный вызов:
Получаем один вызов функции writev
для вывода трёх фрагментов данных: foo
с одного адреса памяти, bar
— с другого, а foo
с того же адреса, что и первый фрагмент.
Интересно, не правда ли? Конечная строка foobarfoo
в самой программе не создаётся. Три фрагмента собираются воедино непосредственно в файле.
Когда конкатенация осуществляется программно, две строки помещаются в память виртуальной машины, их содержимое копируется в третью строку, которую операционная система выводит в файл.
В случае со списком ввода-вывода можно забыть о реализации конкатенации строк и выделении памяти для девятибайтовой строки и избавить сборщик мусора от работы по её удалению.
Всё, что останется сделать виртуальной машине, — это обратиться к ОС, и она скопирует данные в файл.
Особенности работы с writev
Как было сказано ранее, виртуальная машина не осуществляет конкатенацию строк в списке при реализации операций ввода-вывода. Если запустить этот код в IEx и отследить системные вызовы, то каждый элемент списка окажется отдельным аргументом writev
.
И всё же, чтобы убедиться, что writev
действительно используется в приведённом фрагменте кода, я принял пару важных решений.
Во-первых, я сделал так, чтобы файл открывался в режиме raw. Так, BEAM не будет создавать отдельный процесс для работы с файлом, как это происходит по умолчанию. Об этом подробно написано в документации Erlang для функции file:open/2
и в разделе «Performance» внизу страницы.
Во-вторых, я использовал два вызова функций Erlang, а не Elixir, как может показаться:
Эта функция Elixir —
Данная функция открывает и закрывает дескриптор файлов. В текущей версии Erlang/OTP (19.1.2) в функции :file:write_file/3
есть баг: raw
.
Ещё одна особенность работы с writev заключается в том, что можно объединять только строки длиной до 64 байт. Причиной этому служит специфика работы BEAM с поиском строк в памяти и копированием данных между процессами. Если список ввода-вывода содержит строки длиннее 64 байт (
Используйте списки ввода-вывода
Если вы планируете организовать вывод данных и записать их в файл, то вот небольшой совет: забудьте о конкатенации. Воспользуйтесь списком ввода-вывода.
С их помощью вывод можно будет реализовать гораздо проще, расход памяти сократится, а BEAM вместо копирования данных будет вызывать writev.