Передавать пустые срезы между Rust и C/C++ на удивление сложно

Kate

Administrator
Команда форума
Эта статья будет посвящена обсуждению острой межязыковой проблемы, касающейся С, С++ и Rust.

В общих чертах она выглядит так:

  • В правила работы с указателями и memcpy в С не заложены грамотные способы представления пустого среза памяти.
  • В С++ с правилами указателей проблем нет, но поведение memcpy здесь аналогично её поведению в С.
  • Интерфейс внешних функций (Foreign Function Interface, FFI) в Rust не лишён накладных издержек. Rust использует несовместимое с C/C++ представление срезов, требуя их преобразования при передаче в обоих направлениях. При этом о преобразовании очень легко забыть.
  • Срезы в Rust также несовместимы с арифметикой указателей, что создаёт проблемы в работе итератора срезов стандартной библиотеки. (Обновление от 2024-01-16: похоже, над этой проблемой работают).

Поскольку проблемы FFI касаются нескольких языков, я писал статью в качестве общей справки, описывающей их несогласованность.

▍ Срезы​


Все три языка предлагают возможность работы со срезами, то есть непрерывными последовательностями объектов в памяти. (Ещё их называют интервалы (span), но я буду использовать термин «срез»). Как правило, срез выражается посредством указателя и длины, (start, count). Значение count представляет количество объектов некоего типа T, начинающихся с точки start.

Срез также можно определить с помощью двух указателей, (start, end). В таком случае он будет охватывать все объекты от start до end включительно. Такой вариант более удобен для итерации, поскольку для продвижения требует корректировки всего одного значения, но в этом случае длина оказывается менее очевидна. Пары итераторов в С++ представляют обобщение данной формы, и итераторы срезов в Rust тоже работают на основе этого принципа. Две описанные формы представления срезов можно конвертировать с помощью выражений end = start + count и count = end – start, используя арифметику указателей в стиле С, где всё масштабируется в соответствии с размером объекта. Мы будем в основном говорить о (start, count), но указанная двойственность говорит о том, что срезы тесно связаны с арифметикой указателей.

В С и С++ срезы в лучшем случае представляют библиотечные типы, создаваемые при помощи указателей и длины. Иногда функции просто получают или возвращают указатель и длину по отдельности, но всё равно используют их для представления срезов памяти. В Rust срезы – это языковые примитивы, но внутренние их компоненты открыты для небезопасного кода и FFI. Чтобы работать с каждым из этих компонентов, нам необходимо понимать, какие комбинации указателей и длин являются допустимыми.

Это несложно в случае среза положительной длины: start должен указывать в интервал, где присутствует не менее count объектов типа T. Но, предположим, что нам нужен пустой срез (с нулевой длиной). Каковы будут валидные представления такого пустого среза?

Мы определённо можем указать start внутри (или за пределами) массива T и установить count на нуль. Но нам может потребоваться создать пустой срез и при отсутствии массива. В качестве примера приведу дефолтную конструкцию std::span<T>() в C++ или &[] в Rust. Какие в таком случае есть варианты? В частности:

  1. Можно ли определить пустой срез как (nullptr, 0)?
  2. Можно ли определить пустой срез как (alignof(T), 0) или в виде иного выровненного адреса, не соответствующего аллокации?

Второй вопрос может показаться странным для специалистов по С и С++, но программисты на Rust узнают его как std::ptr::NonNull::dangling.

▍ C++​


В C++ разобраться с этим будет проще всего, так как в этом языке есть официальная спецификация, и он (почти) самодостаточен.

Начнём с того, что в С++ (nullptr, 0) является валидным пустым срезом. Такие срезы в порядке вещей возвращают типы стандартной библиотеки шаблонов (Standard Template Library, STL), и сам язык вполне с ними дружит:


Для формы (start, count) C++ определяет API вроде std::span, используя сложение указателей, а для формы (start, end) – пары итераторов с использованием вычитания указателей, так что указанное определение является и обязательным, и достаточным.

Кроме того, для С++ было бы непрактичным запрещать (nullptr, 0). В этом языке зачастую требуется взаимодействовать с API, которые определяют срезы как отдельные компоненты. Работая с таким API, никто не станет писать подобный код:

void takes_a_slice(const uint8_t *in, size_t len);

uint8_t placeholder;
takes_a_slice(&placeholder, 0);

Гораздо естественнее будет использовать nullptr:

void takes_a_slice(const uint8_t *in, size_t len);

takes_a_slice(nullptr, 0);

Это означает, что в целях практичности функции вроде takes_a_slice должны принимать (nullptr, 0). И для эффективной реализации подобных функций внутренние примитивы языка тоже должны принимать (nullptr, 0).

Что касается вопроса по (alignof(T), 0), то операции сложения и вычитания указателей требуют, чтобы указатели указывали на объект, и чтобы операция оставалась в границах этого объекта (или выходила на один элемент за его пределы). В С++ объект не может быть определён в alignof(T), то есть такой вариант недопустим и ведёт к неопределённому поведению. Подобное положение дел не сказывается непосредственно на юзабилити (никто не станет писать reinterpret_cast<uint8_t*>(1) для вызова takes_a_slice), но чуть позже мы увидим, что это имеет последствия для Rust FFI.

Естественно, ничто не мешает takes_a_slice определить собственные уникальные правила для этих тупиковых случаев. Помимо возможностей, стандартно предоставляемых системой типов, код пользователя редко формально определяет семантику, и нам следует смотреть в сторону более размытых соглашений. Печально, но в С и С++ эти размытые аспекты зачастую включают в себя грамотные определения. Если все связанные со срезами языковые примитивы согласованно используют для компонентов срезов одни и те же предусловия, наивно написанная функция будет подходить. Тогда это оказывается адекватной дефолтной интерпретацией для предусловий takes_a_slice. То, что в этой статье подразумевается под правилами для срезов в С++ и С, отчасти является сокращённой формой этого соглашения.

Однако С++ не совсем самодостаточен. Он заимствует из стандартной библиотеки С memcpy и прочие функции, дополненные семантикой С…

▍ C​


В С дела обстоят хуже. По тем же описанным выше причинам будет непрактичным отвергать (NULL, 0). Тем не менее правила С усложняют для функции принятие этой конструкции. В С, в отличие от С++, не прописаны особые случаи для (T*)NULL + 0 и (T*)NULL - (T*)NULL. Читайте пункты 8 и 9 раздела 6.5.6 спецификации N2310. Функция memcpy и остальная часть стандартной библиотеки аналогичным образом не допускают (NULL, 0).

Думаю, это следует рассматривать как баг в спецификации С, и компиляторы не должны выполнять оптимизацию, опираясь на него. В проекте BoringSSL эти правила С оказались настолько неуместными, что нам пришлось обёртывать стандартную библиотеку проверками n != 0. Правила арифметики указателей также накладывают издержки на процесс разработки. Более того, С++ наследует стандартную библиотеку С (но не используемые в ней правила указателей), включая это поведение. В С++ коде движка Chromium функция memcpy стала самым серьёзным препятствием к использованию функциональности UBSan.

К счастью, не всё потеряно. Никита Попов и Аарон Боллман составили предложение по исправлению этого бага в С. (Спасибо!) И пусть это не сделает С и С++ безопасными, но станет простым шагом по исправлению обозначенной проблемы.

Обратите внимание, если не брать во внимание вымышленные примеры с удалёнными проверками на нуль, текущие правила по факту не помогают компилятору оптимизировать код осмысленно. Реализация memcpy не может полагаться на валидность указателя для спекулятивного считывания, потому что, хоть memcpy(NULL, NULL, 0) и ведёт к неопределённому поведению, срезы в конце буфера допустимы:

char buf[16];
memcpy(dst, buf + 16, 0);

Если buf будет находиться в самом конце страницы, после которой ничего больше не аллоцируется, спекулятивное считывание из memcpy будет давать сбой.

▍ Rust​


Rust не допускает конструкцию (nullptr, 0). Функции вроде std::slice_from_raw_parts требуют, чтобы указатель был ненулевым. Это вызвано тем, что Rust рассматривает типы вроде &[T] и *[T] как аналогичные &T и *T. Они являются «ссылками» и «указателями», представленными как пары (start, count). Rust требует, чтобы каждый указатель имел «нулевое» значение вне своего ссылочного типа. Это используется в оптимизациях структур enum. Например, Option::<&[T]> имеет тот же размер, что &[T], потому что None использует это нулевое значение.

К сожалению, в Rust предпочли использовать (nullptr, 0) в качестве нулевого указателя среза, в связи с чем пустой срез, &[], использовать его не может. В итоге в этом языке пришлось придумывать необычное соглашение, которым стал ненулевой, выровненный, а в противном случае подвешенный, указатель, как правило (alignof(T), 0).

Определена ли для этого среза арифметика указателей? Насколько я понимаю, нет. (Обновление от 2024-01-16: похоже, над её определением работают).

Арифметика указателей в Rust реализуется с помощью методов add, sub_ptr и offset_from, которые стандартная библиотека определяет в виде аллоцированных объектов. Получается, чтобы эта арифметика работала с alignof(T), в каждом ненулевом адресе должны быть выделены нулевые срезы. Более того, offset_from требует, чтобы два подвешенных указателя, относящихся к одному срезу, указывали на одни и те же объекты. И хотя второе предложение третьего пункта этого раздела спецификации гласит, что приведение литералов даёт указатель, «допустимый для обращений к участкам памяти нулевого размера», там ничего не говорится об аллоцированных объектах или арифметике указателей.

В конечном итоге эта семантика происходит из LLVM (Low-Level Virtual Machine). Кое-что по этой теме сказано в Rustonomicon (начиная со слов «The other corner-case…»). Там делается вывод, что несмотря на возможность создания бесчисленного множества типов нулевого размера по адресу 0х01, Rust консервативно предполагает, что проверка на наложение (alias analysis) не позволит смещать alignof(T) на нуль в случае типов, имеющих размер. Это означает, что правила арифметики указателей в Rust несовместимы с пустыми срезами Rust. Но напомню, что итерация срезов и арифметика указателей тесно связаны. Приведённый в Rustonomicon образец итератора использует арифметику указателей, но вынужден предохранять сложение с помощью cap == 0 в into_iter и выполнять приведение к usize в size_hint.

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

▍ FFI​


Помимо проблем с внутренней согласованностью, всё это означает, что срезы в Rust и С++ несовместимы. Не все пары (start, count) в Rust можно передать в С++ и наоборот. Внутренние проблемы С затрудняют понимание ситуации с этим языком, но в данном случае логичным исправлением будет приведение его семантики в соответствие с С++.

Это означает, что Rust FFI не лишён издержек. При передаче срезов между С/С++ и Rust для избежания неопределённого поведения требуется преобразование в обоих направлениях.

Более же важными в сравнении с производительностью, лично для меня, являются проблемы с безопасностью и эргономикой. Не стоит ожидать от программистов тотального запоминания спецификаций. Если, имея &[T], вы пытаетесь вызвать С API, то естественным приёмом будет использование as_ptr, но это приведёт к возвращению несовместимого с С/С++ вывода.

Большинство известных мне крейтов Rust, обёртывающих С/С++ API, не выполняют преобразование, в связи с чем являются ненадёжными.

И это особо острая проблема, поскольку недочёты безопасности в С и С++ (в котором они значительно серьёзнее) представляют реальный вред для пользователей и требуют решения. Но код С и С++ существует уже полвека, так что нереально решить эти проблемы с помощью просто нового языка, не имеющего хорошего FFI. А каким он должен быть – хороший FFI? Думаю, как минимум вызов функции С/С++ из Rust не должен оказываться значительно менее безопасным, чем её вызов из С или С++.

Пожелания​


Пустые списки не должны быть настолько сложными. В качестве доработки можно изменить С, С++ и Rust в нескольких аспектах.

▍ Сделать так, чтобы C принимал nullptr​


Читайте предложение Никиты Попова и Аарона Боллмана.

▍ Исправить представление срезов в Rust​


Все проблемы с alignof(T) в конечном счёте возникают из-за нестандартного представления пустых срезов в Rust, обусловленного потребностью в «нулевом» значении *[T], которое не является &[T]. Создатели Rust могли выбрать любое из множества недвусмысленно используемых представлений, таких как (nullptr, 1), (nullptr, -1) или (-1, -1).

И хотя теперь это стало бы значительным изменением, учитывая проблемы совместимости, которые придётся проработать, на мой взгляд, данный вопрос стоит рассмотреть всерьёз. Так мы в корне избавимся от беспорядка, устранив описанную проблему надёжности не только в Rust FFI, но и в самом Rust. Эта проблема настолько реальна, что касается даже стандартной библиотеки.

И как по мне, это единственный вариант, который полностью удовлетворяет задачам Rust по реализации FFI с нулевыми издержками. Даже если сделать так, чтобы С и С++ принимали (alignof(T), 0) из Rust (читайте ниже), любые передаваемые срезы могут по-прежнему оказаться (nullptr, 0).

▍ Определить арифметику для недействительных указателей​


Если разработчики Rust оставят представление срезов без изменений, тогда нам следует определить арифметику для NonNull::dangling(). Неразумно ожидать, что низкоуровневый код Rust будет следить за корректностью всех операций смещения указателей.

Обновление от 2024-01-16: к счастью, над этим уже работают!

Там, где nullptr является одним значением, которое может просто использоваться для особого случая, присутствует множество значений alignof(T). Похоже, определять это потребуется в свете фактически аллоцированных объектов. Этот вопрос уже далеко за пределами моей компетенции, поэтому я могу вполне ошибаться в деталях и терминологии, но есть вариант объяснить это в контексте модели PNVI-ae-udi:

  • cast_ival_to_ptrval во время приведения мусорных значений возвращает специальный провенанс empty (без изменений из PNVI-ae-udi);
  • прибавление нуля к указателю с провенансом @empty допустимо и ведёт к возвращению исходного указателя;
  • выполнение вычитания между двумя указателями с провенансом @empty даст нуль, если у них будет одинаковый адрес.

Тем не менее здесь присутствует одна тонкость – cast_ival_to_ptrval может не вернуть @empty, если по этому адресу будет находиться объект. Возвращение конкретного провенанса подразумевает использование семантики «обработки устаревших указателей» (pointer zapping), которая здесь будет нежелательна. Для alignof(T) этого не должно происходить, если максимальное выравнивание выходит за пределы страницы памяти, а следующая страница не выделяется. Но Rust допускает не только alignof(T), а также и любой ненулевой целочисленный литерал, даже если память по этому адресу окажется подо что-то выделена. (Может, стоит использовать аспект «user-disambiguation» [PNVI-ae-udi, устранение противоречий для пользователя] и установить, что все приведения целых чисел в указатели могут дополнительно разрешаться в @empty? Повлияет ли это на выполняемую компилятором проверку на наложение?)

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

Если Rust (и LLVM) начнут принимать недействительные указатели, это исправит проблемы согласованности внутри самого языка, но не в его FFI. А если этому шагу также последуют стандарты С и С++, то он отчасти исправит проблему с FFI. Тогда мы сможем напрямую передавать срезы Rust в С и С++, но не наоборот. Прямую передачу срезов С/С++ в Rust можно наладить, только если Rust начнёт принимать форму (nullptr, 0).

(Использование в С/С++ alignof(T) в качестве указателя нужно только для Rust FFI, поэтому я не знаю, насколько оправданно было бы для С/С++ реализовывать возможность получения этой конструкции). Обновление от 2024-01-16: Нельсон Эльхаге напомнил мне, что ненулевые сигнальные указатели иногда используются для выделения нулевых байтов. И хотя в С функция malloc этого делать не может (malloc(0) должна возвращать либо nullptr, либо уникальный ненулевой указатель), другие аллокаторы вполне могут воспользоваться этим решением. Оно сделает проверки на ошибки более единообразными без резервирования пространства адресов. Так что есть ещё одна причина, помимо Rust, допустить использование этих указателей в С/С++.

▍ Вспомогательные функции FFI в стандартной библиотеке Rust​


Если представления срезов в разных языках не удастся сделать совместимыми, перед нами остаются угрозы для безопасности в Rust FFI. В этом случае в стандартной библиотеке Rust необходимо реализовать дополнительные решения, помогающие программистам выбирать правильные операции. Например, добавить аналоги для slice::from_raw_parts, slice::as_ptr и так далее, которые будут использовать представления С/С++, при необходимости выполняя внутреннее преобразование. Также нужно отчётливо задокументировать существующие функции, предупреждая, что их нельзя использовать для FFI. Наконец, необходимо проанализировать все существующие вызовы в crates.io, поскольку большинство из них наверняка используются для FFI.

В отношении slice::from_raw_parts можно пойти дальше и исправить саму функцию. Она станет обратно совместимой, но потребует ненужных преобразований в случае использования вне контекста FFI. Тем не менее, если в ходе анализа crates.io выяснится, что большинство вызовов в ней используются для FFI, это преобразование будет оправдано. Всё равно для случаев вне контекста FFI более подходящей окажется сигнатура типа, включающая std::ptr::NonNull.

Указанные доработки несколько улучшат ситуацию, но не станут идеальным решением. Мы по-прежнему не лишим FFI накладных издержек и будем уповать на то, что программисты не поленятся прочесть предупреждения о некорректности использования естественных приёмов.

 
Сверху