Пишу, по большей части, про историю, свою жизнь и немного про программирование.

Забавный баг в FFI (PHP 7.4.2)

В последнее время очень заинтересовался FFI в ПХП — интерфейсом к языку Си, появившимся в версии 7.4. Очень полезное, как по мне, нововведение, позволяющее расширять язык, обходясь минимальными знаниями о других языках программирования.

Очевидно, потребность в чём-нибудь таком давно назрела, появлялся даже специальный язык для написания расширений — «Зефир», очень похожий на ПХП, принципиальных отличий нет, порог входа — низкий. При этом он транслируется в Си, благодаря чему достигается почти нативная производительность.

К несчастью, модуль FFI в ПХП только-только появился и содержит по меньшей мере один очень неприятный баг, который может привести к труднопонимаемым ошибкам в программах.

Небольшой код для иллюстрации проблемы:

const IRRELEVANT = "Hello ";

echo IRRELEVANT;  // Выведет «Hello»

FFI::cdef('char *memcpy(char *dst, const char *src, size_t len);')
    ->memcpy("Hello ", "world!", 6);

echo IRRELEVANT; // Выведет «world!»

В чём суть? Мы объявляем константу IRRELEVANT, убеждаемся, что она содержит присвоенное ей значение, потом вызываем через FFI копирование одной строки в другую, и константа внезапно меняет своё значение. Как это произошло? Чтобы понять, немного модифицируем программу.

function memcpy(string $dst, string $src):void
{
    FFI::cdef('char *memcpy(char *dst, const char *src, size_t len);')
         ->memcpy($dst, $src, strlen($src));
}

$rock1 = str_repeat('ROCK', 1);
$rock2 = str_repeat('ROCK', 1);

var_dump($rock1, $rock2); // Выведет «ROCK» два раза

memcpy($rock1, "SOCK");
memcpy($rock2, "LOCK");

var_dump($rock1); // выведет «SOCK»
var_dump($rock2); // выведет «LOCK»

Что у нас тут? Вызов memcpy обёрнут в функцию, которая ничего не возвращает и ничего не должна модифицировать — параметры передаются по значению, а не ссылке. Тем не менее, если вызвать объявленную функцию и передать в неё переменные, их значение будет изменено.

В ПХП для обеспечения производительности используется механизм копирования при записи — если куда-то переменную нужно передать по значению, то интерпретатор для скорости передаёт её по ссылке, а если требуется модификации переданного значения, копирует его. Модификацию отслеживает сам интерпретатор, но в случае FFI этого, по всей видимости, не происходит.

В силу этой особенности, переменные остаются ссылочными, попадая в таком виде в сишный код, где модифицируются — вызов memcpy меняет первую переданную в него переменную, копируя в неё значение второго параметра.

Кстати, если изменить строку инициализации переменных на вот такую:

$rock1 = $rock2 = str_repeat('ROCK', 1);

то после вызовов memcpy обе переменные получат значение «LOCK» — работает тот же самый механизм.

Почему же в первом листинге изменилось значение константы? Сейчас разберёмся, осталось совсем немного.

Ещё одной распространённой техникой увеличения производительности (и снижения потребления памяти заодно) является интернирование, её используют многие интерпретаторы и компиляторы. В общем случае она заключается в том, что какие-то простые сущности (небольшие строки, числа и т. п.) хранятся один раз и все переменные, которые их содержат, ссылаются на одну и ту же копию данных.

Таким образом константа IRRELEVANT и строка «Hello » в первом параметре — суть одна и та же область памяти. Когда функция memcpy модифицирует эту область, меняются сразу все значения всего, что, для оптимальности, ссылается на это же место. Естественно, константа изменяется тоже.

Поскольку интернирование не работает во время выполнения, результат str_repeat('ROCK', 1); не интернируется и во втором листинге такого эффекта не создаётся.

Как же разорвать эту мистическую связь и заставить работать код так как задумывалось? Для этого нужно, чтобы интерпретатор создал новую область памяти, которую будет портить FFI, не затрагивая нашу переменную. Это можно сделать, например, при помощи уже упомянутой функции str_repeat:

function memcpy(string $dst, string $src):void
{
    // Создаём новое место, которое испортит memcpy
    $dst = str_repeat($dst, 1);
    FFI::cdef('char *memcpy(char *dst, const char *src, size_t len);')
        ->memcpy($dst, $src, strlen($src));
}

$rock = 'ROCK';
var_dump($rock); // Выведет «ROCK»

memcpy($rock, "SOCK");
var_dump($rock); // Так же выведет «ROCK»

Внимание! Это некорректный способ, корректный описан в более поздней заметке.

Естественно, подойдут и substr, и sprintf, и вообще любые функции, в общем случае возвращающие модифицированную строку. Интересно, что implode([$dst]) от бага не защищает, видимо для этого случая внутри работает какая-то оптимизация, возвращающая значение $dst по ссылке до первой модификации.

Всё описанное верно для версии актуальной на текущий момент — 7.4.2. Надеюсь, в скором времени этот довольно забавный баг устранят и необходимость в таких ухищрениях исчезнет.

5 комментариев
Alexey Shamrin 2020

Зачем это делать? Есть реальные случаи, когда нужно что-то подобное?

strcpy("Hello ", "world!")

Евгений Степанищев 2020

Вы же понимаете, что это просто пример, иллюстрирующий проблему? Я как-то даже не ожидал, что кто-то может подумать, что это реальный код из жизни. В реальной жизни у вас там будет две переменных, если принимающая будет содержать короткую строку, которая совпадёт по значению с другой переменной или константой, то это другая переменная или константа будет перетёрта.

Я надеялся, что это достаточно очевидно из написанной статьи. ☹️

Alexey Shamrin 2020

Можно посмотреть на код, который больше похож на реальную жизнь?

Объяснения в посте выглядят в целом верно. Но str_repeat мог бы и не помочь. Например, если бы PHP интернировал также и результат str_repeat(..., 1). Просто такую оптимизацию никто не стал делать.

Первый параметр strcpy должен быть указателем на отдельно выделенную память достаточного размера. И в FFI есть метод для выделения памяти: FFI::new. Его и надо использовать вместо str_repeat.

https://www.php.net/manual/en/ffi.new.php

(В комментариях можно писать блоки код? Последняя моя попытка с грохотом провалилась.)

Евгений Степанищев 2020

Можно посмотреть на код, который больше похож на реальную жизнь?

В одном из заметок у меня был вполне реальный код: https://bolknote.ru/all/php-ffi/.

Но str_repeat мог бы и не помочь.

Мог бы, но помог.

Первый параметр strcpy должен быть указателем на отдельно выделенную память достаточного размера. И в FFI есть метод для выделения памяти: FFI::new. Его и надо использовать вместо str_repeat.

В общем случае я могу хотеть копировать куда угодно.

В комментариях можно писать блоки код? Последняя моя попытка с грохотом провалилась.

Нет.

Alexey Shamrin 2020

Другими словами, это никакой не баг в PHP.

Евгений Степанищев 2020

Конечно баг.

Alexey Shamrin 2020

В следующих версиях PHP код перестанет работать, если str_repeat начнёт интернировать строки, как сейчас делает implode.

Пример в этом посте полагается на внутренние детали реализации строк в PHP. Пришлось даже перебрать разные способы «перехитрить» PHP. Поэтому я настаиваю, что это не баг в PHP.

Прошлый пример FFI тоже не корректный: mkdtemp переписывает template, а не выделяет новую память. Достаточно вывести значение $template до и после вызова, чтобы в этом убедиться. Правильно — выделить память через FFI::new, скопировать туда template через FFI::memcpy, и только потом вызвать Сишный mkdtemp.

Евгений Степанищев 2020

Пример в этом посте полагается на внутренние детали реализации строк в PHP. Пришлось даже перебрать разные способы «перехитрить» PHP.

Пример в этом посте показывает баг в ПХП и даёт способ что-то с этим сделать до его исправления.

Прошлый пример FFI тоже не корректный: mkdtemp переписывает template, а не выделяет новую память.

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

FFI тут нарушает сематнику языка. В этом я вижу баг.

Alexey Shamrin 2020

Оказывается, уважаемый автор уже обсуждал это в багтрекере PHP.

https://bugs.php.net/bug.php?id=79230

Другой комментатор: «I think this is a deliberate design decision of FFI.»

Мне кажется, главная проблема, что FFI магически приводит PHP-строки к указателю на char в Си. Было бы лучше, если бы всегда требовалось явное приведение. Или хотя бы жирное предупреждение про запрет модификации. Как, например, сделано в Python ctypes (тоже обертка над libffi, как и FFI в PHP):

«You should be careful, however, not to pass [pointer types] to functions expecting pointers to mutable memory. If you need mutable memory blocks, ctypes has a create_string_buffer() function which creates these in various ways.»

https://docs.python.org/3/library/ctypes.html

Да, всегда копировать PHP строки при передаче в FFI тоже решит эту проблему. Но замедлит все случаи, когда копирование не требуется.

Евгений Степанищев 2020

Да, тут нужно какое-то решение, которое приведёт семантику ПХП к семантике FFI, а сейчас это выглядит неожиданно. С си-тайпс я знаком, много работал с этим модулем в «Яндексе».

Было бы лучше, если бы всегда требовалось явное приведение.

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