Rust: безопасный парсинг с нулевым копированием

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

Пример того, как HTML-сайт обрабатывается компьютером. Автор: Эрвин Хофман

Учитывая это, легко понять важность подобных алгоритмов, а также значение их эффективности для общей производительности системы. Одним из способов оптимизации таких программ является использование технологии, называемой zero-copy (нулевое копирование).

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


В качестве примера простейшего парсера с нулевым копированием я создал программу на языке C, которая выполняет парсинг массива байтов в заранее определенный struct. Программа предполагает, что полученные данные будут содержать заголовочный байт, за которым следует строка.

struct ParsedData {
uint8_t header;
char *payload;
};

void parse_buffer(uint8_t *buffer, struct ParsedData *parsed_data) {
parsed_data->header = buffer[0];
parsed_data->payload = (char *)&buffer[1];
};

Код достаточно прост: в struct перемещается значение примитива (header  —  заголовок) и указатель на массив символов (payload  —  полезная нагрузка). Заметим, что хотя некоторые могут назвать эту процедуру копированием, она не выделяет новую память, а лишь выполняет простые инструкции перемещения значений и указателей:

Функция parse_buffer, декомпилированная с помощью Ghidra (Linux x86_64 (GCC), O3-оптимизация)

А теперь применим парсер. В следующей программе выделим буфер и сымитируем получение определенных данных. После этого выполним парсинг с помощью вышеупомянутой функции и выведем полезную нагрузку. 

#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

struct ParsedData {
uint8_t header;
char *payload;
};

void parse_buffer(uint8_t *buffer, struct ParsedData *parsed_data) {
parsed_data->header = buffer[0];
parsed_data->payload = (char *)&buffer[1];
};

void get_data(uint8_t *buffer) {
const uint8_t data[] = {255, 't', 'e', 's', 't'};
memcpy(buffer, data, sizeof(data));
}

int main(void) {
// Выделение буфера для полученных данных
uint8_t *buffer = malloc(1024); // Игнорируем, если указатель - NULL

// Имитация получения данных из другого места (например, из сокета)
get_data(buffer);

// Парсинг буфера в struct ParsedData
struct ParsedData parsed_data;
parse_buffer(buffer, &parsed_data);

// Вывод содержимого полезной нагрузки
printf("%s\n", parsed_data.payload);

free(buffer);

return 0;
}

Выполнив этот код, увидим, что парсер работает и полезная нагрузка выводится корректно. Но причем тут Rust?

Дело в том, что здесь возникает одна из самых больших проблем, связанных с языком C/C++: безопасность памяти, точнее ее отсутствие. Несмотря на то что в данном сценарии все работает, код чреват несколькими глубинными сбоями.

  • Применение указателя к динамически выделяемому буферу может привести к неопределенному поведению при попытке прочитать полезную нагрузку после того, как буфер будет освобожден.
  • Переменные в языке C по умолчанию мутабельны, а это означает, что данные в исходном буфере могут быть изменены, в результате чего из структуры впоследствии будет считана не та полезная нагрузка. 

Для убедительности я изменил код таким образом, чтобы он выводил полезную нагрузку как после манипуляций с буфером, так и после его освобождения:

// Вывод содержимого полезной нагрузки 
printf("Original: %s\n", parsed_data.payload);

// Манипуляция с исходным буфером
buffer[1] = 'j';

// Вывод содержимого полезной нагрузки
printf("Tampered: %s\n", parsed_data.payload);
free(buffer);

// Вывод содержимого полезной нагрузки
printf("Freed: %s\n", parsed_data.payload);

Вот результат этого изменения:

Original: test
Tampered: jest
Freed:

Существуют способы предотвращения такого рода проблем, и их устранение на 100% зависит от разработчика. Причем в условиях многопоточности подобные решения принимать гораздо сложнее. Это одна из причин, по которой некоторые разработчики избегают парсеров с нулевым копированием, используя постоянное копирование данных.


Rust решает две упомянутые ранее проблемы с помощью системы borrow checker. Она работает путем верификации на соответствие набору правил во время компиляции, ограничивая применение небезопасных практик.

Вот правила и задачи, которые они решают.

  • Владение (Ownership). Только одна переменная может одновременно владеть значением, причем значение освобождается после того, как переменная выходит из области видимости.
    Решаемая задача: компилятор может детерминированно определять момент освобождения значения, что позволяет безопасно освобождать значение в неявном виде без сборщика мусора.
  • Заимствование (Borrowing). На значение можно ссылаться (заимствовать) много раз, если оно неизменяемое, но только один раз, если на него идет ссылка как на изменяемое.
    Решаемая задача: если на значение ссылаются как на неизменяемое, то оно гарантированно не будет изменено другой сущностью, пока остается заимствованным.
  • Время жизни (Lifetimes). На значение можно ссылаться (заимствовать) только в том случае, если оно живет по крайней мере столько же, сколько и переменная, которая на него ссылается.
    Решаемая задача: ссылки гарантированно всегда действительны (нет висячих указателей).

Разобравшись с принципами работы системы borrow checker, создадим в Rust парсер с ненулевым копированием, а затем преобразуем его в парсер с нулевым копированием. Так вы сможете понять не только разницу между ними (включая бенчмарки), но и связанные с этим трудности.

После определения struct создадим функцию, которая выполняет парсинг массива байтов, перемещая значение заголовка, создавая новый вектор с байтами полезной нагрузки и преобразуя вектор в строку. Функция to_vec отвечает за выделение кучи и копирование данных из фрагмента массива, а строковый метод from_utf8 перемещает владение от вновь созданного Vec и использует его данные на месте:

pub struct ParsedData {
pub header: u8,
pub payload: String,
}

impl ParsedData {
pub fn parse(data: &[u8]) -> ParsedData {
let header = data[0];

let payload_bytes = data[1..data.len()].to_vec();
let payload = String::from_utf8(payload_bytes).unwrap();

ParsedData {
header,
payload,
}
}
}

Чтобы применить нулевое копирование, необходимо удалить функцию to_vec, а также использовать ссылку на str вместо String. Если выполнить это, то получим следующий код:

pub struct ParsedData {
pub header: u8,
pub payload: &str,
}

impl ParsedData {
pub fn parse(data: &[u8]) -> ParsedData {
let header = data[0];

let payload = std::str::from_utf8(&data[1..data.len()]).unwrap();

ParsedData {
header,
payload,
}
}
}

Но не все так просто. Необходимо помнить, что мы получим ссылки только в том случае, если компилятор сможет определить, что значение, на которое идет ссылка, будет жить достаточно долго. Решим эту проблему, добавив в код явное время жизни, чтобы исходные данные пережили свою ссылку внутри struct. 

pub struct ParsedData<'a> {
pub header: u8,
pub payload: &'a str,
}

impl<'a> ParsedData<'a> {
pub fn parse(data: &'a [u8]) -> ParsedData<'a> {
let header = data[0];

let payload = std::str::from_utf8(&data[1..data.len()]).unwrap();

ParsedData {
header,
payload,
}
}
}

Благодаря пропуску времени жизни, приведенный выше код можно упростить, удалив некоторые явные аннотации, которые не нужны для того, чтобы время жизни было детерминированным (это не всегда так).

pub struct ParsedData<'a> {
pub header: u8,
pub payload: &'a str,
}

impl ParsedData<'_> {
pub fn parse(data: &[u8]) -> ParsedData {
let header = data[0];

let payload = std::str::from_utf8(&data[1..data.len()]).unwrap();

ParsedData {
header,
payload,
}
}
}

Теперь создадим программу, использующую парсер, аналогично тому, как это было сделано для версии на языке С:

pub struct ParsedData<'a> {
pub header: u8,
pub payload: &'a str,
}

impl ParsedData<'_> {
pub fn parse(data: &[u8]) -> ParsedData {
let header = data[0];

let payload = std::str::from_utf8(&data[1..data.len()]).unwrap();

ParsedData {
header,
payload,
}
}
}
fn get_data() -> Vec<u8> {
const DATA: [u8; 5] = [255, 't' as u8, 'e' as u8, 's' as u8, 't' as u8];
DATA.to_vec() // Возвращение динамически размещенного массива (вектора)
}

fn main() {
// Имитация получения данных из другого места (например, из сокета). (Rust позволяет возвращать объект)
let buffer = get_data();

// Парсинг буфера в struct ParsedData
let parsed_data = ParsedData::parse(&buffer);

// Вывод содержимого полезной нагрузки
println!("{}", parsed_data.payload);
}

Теперь, имея готовую программу, смоделируем проблемы, обнаруженные ранее в коде на языке C. Для начала попробуем изменить исходный буфер. Вот новая функция main:

fn main() {
// Имитация получения данных из другого места (например, из сокета). (Rust позволяет возвращать объект)
let mut buffer = get_data(); // Сделайте их мутабельными для тестирования

// Парсинг буфера в struct ParsedData
let parsed_data = ParsedData::parse(&buffer);

// Вывод содержимого полезной нагрузки
println!("Original: {}", parsed_data.payload);

// Манипуляция с исходным буфером
buffer[1] = 'j' as u8;

// Вывод содержимого полезной нагрузки
println!("Tempered: {}", parsed_data.payload);
}

Если попытаться скомпилировать этот код, получим ошибку: cannot borrow `buffer` as mutable because it is also borrowed as immutable. Это происходит потому, что никакое другое заимствование не может произойти, если значение заимствовано как mutable (изменяемое).

Ошибка компилятора Rust: cannot borrow `buffer` as mutable because it is also borrowed as immutable

Теперь попробуем решить вторую проблему, обнаруженную в коде на языке C: обращение к полезной нагрузке после освобождения исходного буфера. Вот новая функция main:

fn main() {
let parsed_data = { // Создайте область видимости для имитации освобождения буфера (RAII)
// Имитация получения данных из другого места (например, из сокета). (Rust позволяет возвращать объект)
let buffer = get_data();

// Парсинг буфера в struct ParsedData
let parsed_data = ParsedData::parse(&buffer);

// Вывод содержимого полезной нагрузки
println!("{}", parsed_data.payload);

parsed_data
};

println!("Freed: {}", parsed_data.payload);
}

Если попытаться скомпилировать этот код, получим новую ошибку: `buffer` does not live long enough (`buffer` живет недостаточно долго). Это происходит потому, что в то время как буфер выходит за пределы области видимости, происходит перемещение владения parsed_data в переменную, находящуюся за ее пределами. Таким образом, правила Rust RAII освободят любую переменную, вышедшую за пределы области действия, и тогда время жизни, явно определенное между исходными данными и struct, будет нарушено.

Ошибка компилятора Rust: `buffer` does not live long enough

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


С помощью библиотеки Criterion я создал простой бенчмарк, в котором сравниваются парсеры с копированием и нулевым копированием. Тесты проводились с полезной нагрузкой в 1, 10, 100, 500 и 1000 символов:

Граф, показывающий среднее время парсинга для каждого алгоритма и объем полезной нагрузки

Результаты в приведенном графе удивляют. Нулевое копирование не только оказывается быстрее при любом объеме полезной нагрузки, но и может выполнить парсинг 1000 символов всего за 70% времени, которое требуется алгоритму копирования для парсинга одного символа.

Читайте также:

Читайте нас в Telegram, VK и Дзен


Перевод статьи Daniel Stuart: Rust: The joy of safe zero-copy parsers

Предыдущая статьяОсновные различия между React.js и React Native
Следующая статьяВопросы для собеседования iOS — Swift. Часть 1