Мифы и легенды о переполнении целых чисел в Rust
Примитивные целочисленные типы, поддерживаемые процессорами, являются ограниченным
приближением к бесконечному набору целых чисел, которыми мы привыкли оперировать в
реальной жизни. Это ограниченное представление не всегда совпадает с «реальными»
числами, например 255_u8 + 1 == 0
. Зачастую программист забывает об этой разнице,
что легко может приводить к багам.
Rust — это язык программирования, целью которого является защита от багов, он фокусируется на предотвращении наиболее коварных из них — ошибок работы с памятью, но также старается помочь программисту избежать остальных проблем: утечек памяти, игнорирования ошибок и, как мы увидим, переполнения целых чисел.
Переполнение в Rust
Политика обнаружения и предотвращения переполнения в Rust менялась несколько раз на пути к релизу 1.0.0, состоявшемуся в прошлом году. В итоге, присутствует недопонимание того, как переполнение обрабатывается и какие последствия вызывает.
До версии 1.0.0-alpha переполнение было циклическим и результат соответствовал
использованию дополнения до двух
(как и поступает большинство современных процессоров). Однако, такое решение не
оптимально: неожиданное и незамеченное переполнение часто приводит к ошибкам.
Это особенно плохо в С и С++ из-за того, что переполнение знаковых целых чисел
является неопределённым поведением (undefined behaviour), что вместе с недостаточной
защитой от нарушения безопасности работы с памятью может легко приводить к её повреждению.
Впрочем, в более заботящихся о безопасности языках, таких как Rust, это
всё ещё вызывает проблемы: существует много примеров переполнения, они обнаруживаются
в видеоиграх (экономике,
индикаторах здоровья и т. д.),
реализациях двоичного поиска
и даже в программном обеспечении для авиации.
Проще говоря, код наподобие max(x - y, z)
периодически встречается и может давать
неправильные результаты, если числа являются беззнаковыми и x - y
вызывает
переполнение. Как результат, возникло желание сделать Rust более безопасным в
отношении переполнения целых чисел.
Нынешнее состояние дел определено в RFC 560:
- В отладочной сборке арифметические операции (
+
,-
и т. д.) проверяются на предмет переполнения и при его наличии вызывают панику. - В релизе проверки на результат переполнения отсутствуют, а результат гарантирует цикличность.
Проверки на переполнение могут быть вручную включены или выключены независимо от типа
сборки, как глобально, так и на уровне отдельных операций.
Тем не менее, на некоторые проверки вы повлиять не можете: х / 0
и
MIN / -1
(для знаковых целых чисел) и аналогично
для %
. Эти вычисления являются неопределённым поведением в С и LLVM, что и стало
причиной поведения rustc, хотя мне кажется, что Rust теоретически мог бы рассматривать
MIN / -1
как нормальное переполнение и возвращать MIN
при отключённых проверках.
Надеемся, благодаря проверкам в отладочном режиме, ошибки, связанные с переполнением, в коде на Rust будут обнаруживаться раньше. Более того, если вы действительно рассчитываете на переполнение, это придётся указать в коде явно, что уменьшает количество ложных срабатываний как для будущих статических анализаторов, так и для кода, который включает проверку переполнения во всех режимах.
Миф: результат переполнения не определён (undefined)
Результатом переполнения могло бы быть неопределённое поведение, однако одна из ключевых целей Rust — обеспечивать безопасность работы с памятью, а такая неопределённость (аналогичная неопределённому поведению в С) явно противоречит этой цели. Переменная, содержащая неопределённое значение, не обязана сохранять одинаковое значение между использованиями:
1 2 3 4 5 6 | // Псевдо-Rust let x = undefined; let y = x; let z = x; assert_eq!(y, z); // потенциальная проблема |
Это приведёт к катастрофическим последствиям, если от такого значения зависит безопасность.
Например, при проверке выхода за границы массива foo[x]
:
1 2 3 4 5 6 7 8 9 | let x = undefined; // let y = foo[x]; // что эквивалентно следующему: let y = if x < foo.len() { unsafe { *foo.get_unchecked(x) } } else { panic!("index out of bounds") }; |
Если значение переменной х
различается при сравнении x < foo.len()
и при непосредственном
доступе к массиву, то гарантии могут быть нарушены: в сравнении может оказаться 0 < foo.len()
,
а при доступе по индексу получится foo.get_unchecked(123456789)
. Непорядок!
Таким образом, в отличие от целых чисел со знаком в С, в Rust переполнение не может быть
неопределённым. Другими словами, компилятор должен предполагать, что переполнение может
произойти, если только он не может доказать обратное. Это влечёт за собой неочевидные
последствия: x + 1 > x
не всегда истинно, в то время как компиляторы С предполагают,
что данное условие всегда выполняется, если х
является целым со знаком.
«Но как же производительность?» Я уже предчувствую этот вопрос. Действительно, неопределённое
поведение упрощает оптимизации, позволяя компилятору делать предположения. Следовательно,
отказ от такого поведения может влиять на скорость. Неопределённость переполнения целых чисел
особенно полезна в С потому, что такие числа часто используются как индуктивные переменные в
циклах, соответственно, возможность строить предположения, позволяет более точный анализ
количества итераций цикла: for (int i = 0; i < n; i++)
будет выполняться n
раз так как
можно предположить, что n
не содержит отрицательное значение. Rust обходит большинство таких
проблем используя положительные числа в качестве индексов (0..n
всегда даст n
шагов) и
допуская легковесные итераторы для прямого обхода структур данных, например for x in some_array { ... }
.
Эти итераторы могут использовать знания о внутреннем устройстве структур данных, не заставляя
пользователя иметь дело с неопределённым поведением.
Также Rust, в отличии от С, не может свести x * 2 / 2
просто к x
, если x
являет целым
со знаком. Данная оптимизация не применяется (если только вы вручную не напишете x
вместо
сложного арифметического выражения), однако в моей практике подобные выражения наиболее
часто встречаются, когда x
известен во время компиляции, а значит всё выражение будет заменено
константой.
Миф: результат переполнения не специфицирован (unspecified)
Результат переполнения мог бы быть не специфицирован, в этом случае компилятор был бы обязан предполагать, что оно может произойти, но имел бы право вернуть в результате любое значение (или не возвращать его вовсе). И в самом деле, в первой версии RFC 560 по проверке переполнения целых чисел было предложено:
Изменить поведение на возврат неспецифицированного значения или вызов паники, в зависимости от того, проверяется ли переполнение. […]
- Теоретически реализация возвращает неспецифицированное значение. На практике, однако, результат будет аналогичен циклическому переполнению. Реализациям следует избегать излишней непредсказуемости и неожиданного поведения, чтобы не провоцировать ошибки.
- И самое важное: это не неопределённое поведение в понимании С. Неспецифицированным будет исключительно результат операции, а не поведение всей программы целиком, как в С. Программист не может полагаться на какое-то конкретное значение при переполнении, но компилятор не имеет права использовать предположение о том, что переполнение не возникнет, для оптимизации.
RFC и и «неспецифицированный» результат переполнения (то есть 127_i8 + 1
может вернуть
-128
или 0
или 127
или любое другое значение) стал предметом оживлённых дискуссий,
которые повлекли за собой его изменение.
Благодаря усилиям отдельных людей, RFC принял современный вид: в результате переполнения значение или не будет возвращено вовсе (например, случится паника), или же будет возвращён циклический результат, соответствующий использованию дополнения до двух. Теперь формулировка выглядит так:
Операции +, -, * могут приводить к переполнению (overflow) или исчезновению порядка (underflow). Если проверки включены, тогда произойдёт паника. В противном случае результатом будет циклическое переполнение.
Зафиксированный результат переполнения — защитная мера: ошибки могут не иметь последствий,
даже если переполнение не обнаружено. Выражение x - y + z
вычисляется как (x - y) + z
и,
следовательно, вычитание может приводить к переполнению (например, x = 0
и y = 1
, оба
беззнаковые), но если z
достаточно большое (в нашем примере z >= 1
), результат будет
аналогичен использованию «чисел из реального мира».
Изменение произошло ближе к концу обсуждения, состоящего из 160 комментариев, так что его было легко пропустить, из-за чего люди могут продолжать думать, что результат переполнения не специфицирован.
Миф: программист не может управлять обработкой переполнения
Одним из аргументов против введения проверки переполнения было существование программ и
алгоритмов, которые полагаются на цикличное переполнение, таких как: алгоритмы вычисления
хеша, некоторые структуры данных (например, кольцевой буфер) и даже кодеки. Для данных
алгоритмов использование +
в отладочном режиме будет некорректным: произойдёт паника,
хотя такое использование переполнения было сознательным. К тому же в отдельных случаях может
возникнуть желание включить проверки не только для отладочной сборки.
RFC и стандартная библиотека предоставляют четыре набора методов, помимо обычных операторов:
- wrapping_add, wrapping_sub, …
- saturating_add, saturating_sub, …
- overflowing_add, overflowing_sub, .
- checked_add, checked_sub, …
Это должно покрывать все «особые случаи»:
wrapping_...
возвращают результат дополнения до двух.saturating_...
возвращают наибольшее/наименьшее значение при возникновении переполнения.overflowing_...
возвращают результат дополнения до двух вместе с булевым значением, сигнализирующим о возникшем переполнении.checked_...
возвращаютOption
, который принимает значениеNone
в случае переполнения.
Все эти операции могли бы быть реализованы в терминах overflowing_...
, но стандартная
библиотека старается упростить решение наиболее часто возникающих задач.
Если вы действительно хотите использовать циклическое переполнение, тогда можете написать
что-то вроде x.wrapping_sub(y).wrapping_add(z)
. Это даст ожидаемый результат, а
многословность может быть уменьшена путём использования типа из стандартной библиотеки
Wrapping
.
Возможно, это ещё не финальное состояние: в RFC также упоминаются
потенциальные улучшения.
В будущем в Rust могут быть добавлены операторы, подобные циклическому &+
из Swift.
Это не было сделано сразу, так как Rust пытается быть консервативным и, в разумной степени,
минималистичным, а также из-за потенциальной возможности отключать проверки переполнения
(например, отдельная функция может быть явно помеченной и в её коде будут отсутствовать
проверки во всех режимах). В частности, в последнем заинтересованы самые активные (потенциальные)
пользователи Servo и
Gecko.
Если бы хотите иметь проверки переполнения во всём коде, то вынуждены или использовать
повсюду checked_add
(не слишком удобно!) или явно включить их. Хотя они по умолчанию
работают только в отладочном режиме, проверки переполнения можно включить передав
-C debug-assertions=on
rustc (компилятору Rust) или задав поле debug-assertions
в cargo profile. Также идёт
работа по возможности включать их независимо от других отладочных проверок (в данный
момент rustc поддерживает нестабильную опцию -Z force-overflow-checks flag
).
Миф: выбранный подход к проверкам переполнения замедляет код
Rust стремится быть настолько быстрым, насколько это возможно, и при разработке проверок переполнения вопросы производительности рассматривались очень серьёзно. Производительность — одна из основных причин, по которым проверки были отключены для релизных сборок по умолчанию. Разумеется, это значит, что скорость не была принесена в жертву удобству обнаружения ошибок во время разработки.
К сожалению, проверки на переполнение требуют больше кода и инструкций:
1 2 3 4 5 6 7 8 9 | [no_mangle] pub fn unchecked(x: i32, y: i32) -> i32 { x.wrapping_add(y) } #[no_mangle] pub fn checked(x: i32, y: i32) -> i32 { x + y } |
С -O -Z force-overflow-checks
на x86 (на 32-bit ARM LLVM в данный момент генерирует
избыточные сравнения и манипуляции с регистрами, так что потеря в производительности
даже больше!) это компилируется в следующее (с некоторыми изменениями для ясности):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | unchecked: leal (%rdi,%rsi), %eax retq checked: pushq %rax addl %esi, %edi jo .overflow_occurred movl %edi, %eax popq %rcx retq .overflow_occurred: leaq panic_loc2994(%rip), %rdi callq _ZN9panicking5panic20h4265c0105caa1121SaME@PLT |
Тут больше инструкций, чем должно быть при условии, что checked
встраивается (что и
должно происходить): в этом случае работа с регистрами с помощью pushq
/pop
/movl
не
обязательна. Я полагаю, что даже без встраивания управление стеком с помощью pushq
/popq
не
требуется, но, к сожалению, сейчас Rust использует версию LLVM, которая содержит
ошибку. Безусловно, все эти дополнительные
инструкции раздражают, так же, как и необходимость использовать add
вместо lea
.
На x86 использование lea
(load effective address) для арифметики может быть очень полезным:
она позволяет производить относительно сложные вычисления и, как правило, вычисляется в
отдельной части CPU и его конвейера, в отличии от add
, что способствует более высокому
параллелизму на уровне инструкций. x86 ISA разрешает разыменование результата сложных вычислений
с указателями: общая форма A(r1, r2, B)
(в AT& T синтаксисе), что эквивалентно
r1 + B * r2 + A
где r1
и r2
— регистры, а A
и B
— константы. Как правило, это
используется напрямую в инструкциях по работе с памятью, таких как mov
. Например,
let y = array_of_u32[x];
может скомпилироваться во что-то вроде mov (array_of_u32.as_ptr(), x, 4), y
,
так как каждый элемент имеет размер 4. Но lea
позволяет выполнить арифметику не затрагивая
память. В общем, возможность использовать lea
для арифметики довольно полезна. Недостаток
заключается в том, что lea
не интегрируется напрямую с проверками переполнения: она не
устанавливает флаг состояния процессора, свидетельствующий об этом.
Однако, ещё больший удар по производительности наносит то, что проверки переполнения препятствуют другим оптимизациям. Во-первых, проверки сами по себе упорядочивают код (препятствуя таким вещам, как развёртывание, переупорядочивание и векторизация циклов). Во-вторых, паника и размотка стека заставляет компилятор вести себя более консервативно.
Все эти соображения объясняют, почему проверки переполнения не включены в релизной сборке, где, как правило, важна максимально возможная производительность.
При этом, даже если проверки переполнения включены в релизном режиме, потери производительности могут быть уменьшены, как и в случае с проверками выхода за пределы массива. С одной стороны, компиляторы могут выполнять анализ диапазонов и доказывать, что отдельные операции никогда не приведут к переполнению. И действительно, этой теме уделяется много внимания. С другой стороны, проблемы, вызванные использованием паники, можно частично решить заменив панику аварийным завершением программы, если позволяет предметная область.
В RFC о переполнении заложена возможность дополнительных оптимизаций: разрешается
«отложенная паника»,
то есть реализации могут выполнять последовательность операций a + b + c + d
и
паниковать один раз в самом конце, если любое из вычислений привело к переполнению,
вместо того чтобы проверять каждую отдельную операцию tmp = a + b
, затем tmp + c
и т. д. Хотя на данный момент это не реализовано, такая возможность есть.
Миф: проверки не обнаруживают ошибки
Все усилия по разработке, обсуждению и реализации этой схемы обработки целочисленных
переполнений были бы напрасны, если бы они не помогали обнаружить ошибки на практике.
Лично я нашёл несколько проблем в своём коде с выражениями вроде cmp::max(x - y, z)
(они не попали в интернет, так что ссылок не будет) практически сразу после написания,
особенно в сочетании с инфраструктурой тестирования, такой как quickcheck.
Проверки переполнения обнаружили ошибки в нашей экосистеме, например, такие (список не полный!):
- стандартная библиотека
- компилятор
- встроенная инфраструктура тестирования производительности
- Servo
- image
- url
- webrender
За пределами Rust существует много других примеров опасности ошибок переполнения. В 2011
году они попали в список 25 самых частых ошибок CWE/SANS.
Некоторые языки, например Swift, всегда осуществляют проверки переполнения, а другие,
такие как Python 3 и Haskell, избегают переполнения, по умолчанию используя числа с
произвольной точностью. Более того, некоторые компиляторы C поддерживают опции,
заменяющие неопределённое поведение на циклическое переполнение (-fwrapv
) и помогающие
обнаружить переполнение (-fsanitize=signed-integer-overflow
).