- PVSM.RU - https://www.pvsm.ru -
Примитивные целочисленные типы, поддерживаемые процессорами, являются ограниченным приближением к бесконечному набору целых чисел, которыми мы привыкли оперировать в реальной жизни. Это ограниченное представление не всегда совпадает с "реальными" числами, например 255_u8 + 1 == 0
. Зачастую программист забывает об этой разнице, что легко может приводить к багам.
Rust — это язык программирования, целью которого является защита от багов, он фокусируется на предотвращении наиболее коварных из них — ошибок работы с памятью, но также старается помочь программисту избежать остальных проблем: утечек памяти [1], игнорирования ошибок [2] и, как мы увидим, переполнения целых чисел [3].
Политика обнаружения и предотвращения переполнения в Rust менялась несколько раз на пути к релизу 1.0.0, состоявшемуся в прошлом году. В итоге, присутствует недопонимание того, как переполнение обрабатывается и какие последствия вызывает.
До версии 1.0.0-alpha переполнение было циклическим и результат соответствовал использованию дополнения до двух [4] (как и поступает большинство современных процессоров). Однако, такое решение не оптимально: неожиданное и незамеченное переполнение часто приводит к ошибкам. Это особенно плохо в С и С++ из-за того, что переполнение знаковых целых чисел является неопределённым поведением (undefined behaviour), что вместе с недостаточной защитой от нарушения безопасности работы с памятью может легко приводить к её повреждению. Впрочем, в более заботящихся о безопасности языках, таких как Rust, это всё ещё вызывает проблемы: существует много примеров переполнения, они обнаруживаются в видеоиграх (экономике [5], индикаторах здоровья [6] и т.д.), реализациях двоичного поиска [7] и даже в программном обеспечении для авиации [8]. Проще говоря, код наподобие max(x - y, z)
периодически встречается и может давать неправильные результаты, если числа являются беззнаковыми и x - y
вызывает переполнение. Как результат, возникло желание сделать Rust более безопасным в отношении переполнения целых чисел.
Нынешнее состояние дел определено в RFC 560 [9]:
+
, -
и т.д.) проверяются на предмет переполнения и при его наличии вызывают панику.Проверки на переполнение могут быть вручную включены или выключены независимо от типа сборки, как глобально, так и на уровне отдельных операций.
Тем не менее, на некоторые проверки вы повлиять не можете: х / 0
и MIN / -1
[10] (для знаковых целых чисел) и аналогично для %
. Эти вычисления являются неопределённым поведением в С и LLVM, что и стало причиной поведения rustc, хотя мне кажется, что Rust теоретически мог бы рассматривать MIN / -1
как нормальное переполнение и возвращать MIN
при отключенных проверках.
Надеемся, благодаря проверкам в отладочном режиме, ошибки, связанные с переполнением, в коде на Rust будут обнаруживаться раньше. Более того, если вы действительно рассчитываете на переполнение, это придётся указать в коде явно, что уменьшает количество ложных срабатываний как для будущих статических анализаторов, так и для кода, который включает проверку переполнения во всех режимах.
Результатом переполнения могло бы быть неопределённое поведение, однако одна из ключевых целей Rust — обеспечивать безопасность работы с памятью, а такая неопределённость (аналогичная неопределённому поведению в С) явно противоречит этой цели. Переменная, содержащая неопределённое значение, не обязана сохранять одинаковое значение между использованиями:
// Псевдо-Rust
let x = undefined;
let y = x;
let z = x;
assert_eq!(y, z); // потенциальная проблема
Это приведёт к катастрофическим последствиям, если от такого значения зависит безопасность. Например, при проверке выхода за границы массива foo[x]
:
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
известен во время компиляции, а значит всё выражение будет заменено константой.
Результат переполнения мог бы быть неспецифицирован, в этом случае компилятор был бы обязан предполагать, что оно может произойти, но имел бы право вернуть в результате любое значение (или не возвращать его вовсе). И в самом деле, в первой версии [11] RFC 560 [9] по проверке переполнения целых чисел было предложено:
Изменить поведение на возврат неспецифицированного значения или вызов паники, в зависимости от того, проверяется ли переполнение.
[…]
- Теоретически реализация возвращает неспецифицированное значение. На практике, однако, результат будет аналогичен циклическому переполнению. Реализациям следует избегать излишней непредсказуемости и неожиданного поведения, чтобы не провоцировать ошибки.
- И самое важное: это не неопределённое поведение в понимании С. Неспецифицированным будет исключительно результат операции, а не поведение всей программы целиком, как в С. Программист не может полагаться на какое-то конкретное значение при переполнении, но компилятор не имеет права использовать предположение о том, что переполнение не возникнет, для оптимизации.
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_...
возвращают результат дополнения до двух.saturating_...
возвращают наибольшее/наименьшее значение при возникновении переполнения.overflowing_...
возвращают результат дополнения до двух вместе с булевым значением, сигнализирующим о возникшем переполнении.checked_...
возвращают Option
, который принимает значение None
в случае переполнения.Все эти операции могли бы быть реализованы в терминах overflowing_...
, но стандартная библиотека старается упростить решение наиболее часто возникающих задач.
Если вы действительно хотите использовать циклическое переполнение, тогда можете написать что-то вроде x.wrapping_sub(y).wrapping_add(z)
. Это даст ожидаемый результат, а многословность может быть уменьшена путём использования типа из стандартной библиотеки Wrapping
[20].
Возможно, это ещё не финальное состояние: в RFC также упоминаются потенциальные улучшения [21]. В будущем в Rust могут быть добавлены операторы, подобные циклическому &+
из Swift. Это не было сделано сразу, так как Rust пытается быть консервативным и, в разумной степени, минималистичным, а также из-за потенциальной возможности отключать проверки переполнения (например, отдельная функция может быть явно помеченной и в её коде будут отсутствовать проверки во всех режимах). В частности, в последнем заинтересованы самые активные (потенциальные) пользователи Servo [22] и Gecko [23].
Если бы хотите иметь проверки переполнения во всём коде, то вынуждены или использовать повсюду checked_add
(не слишком удобно!) или явно включить их. Хотя они по умолчанию работают только в отладочном режиме, проверки переполнения можно включить передав -C debug-assertions=on
rustc (компилятору Rust) или задав поле debug-assertions
в cargo profile [24]. Также идёт работа по возможности включать их независимо от других отладочных проверок (в данный момент rustc поддерживает нестабильную опцию -Z force-overflow-checks flag
).
Rust стремится быть настолько быстрым, насколько это возможно, и при разработке проверок переполнения вопросы производительности рассматривались очень серьёзно. Производительность — одна из основных причин, по которым проверки были отключены для релизных сборок по умолчанию. Разумеется, это значит, что скорость не была принесена в жертву удобству обнаружения ошибок во время разработки.
К сожалению, проверки на переполнение требуют больше кода и инструкций:
[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 в данный момент генерирует избыточные сравнения и манипуляции с регистрами, так что потеря в производительности даже больше!) это компилируется в следующее (с некоторыми изменениями для ясности):
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, которая содержит ошибку [25]. Безусловно, все эти дополнительные инструкции раздражают, так же, как и необходимость использовать 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
не интегрируется напрямую с проверками переполнения: она не устанавливает флаг состояния процессора, свидетельствующий об этом.
Однако, ещё больший удар по производительности наносит то, что проверки переполнения препятствуют другим оптимизациям. Во-первых, проверки сами по себе упорядочивают код (препятствуя таким вещам, как развёртывание, переупорядочивание и векторизация циклов). Во-вторых, паника и размотка стека заставляет компилятор вести себя более консервативно [26].
Все эти соображения объясняют, почему проверки переполнения не включены в релизной сборке, где, как правило, важна максимально возможная производительность.
При этом, даже если проверки переполнения включены в релизном режиме, потери производительности могут быть уменьшены, как и в случае с проверками выхода за пределы массива. С одной стороны, компиляторы могут выполнять анализ диапазонов и доказывать, что отдельные операции никогда не приведут к переполнению. И действительно [27], этой теме уделяется [28] много [29] внимания [30]. С другой стороны, проблемы, вызванные использованием паники, можно частично решить заменив панику аварийным завершением программы [31], если позволяет предметная область.
В RFC о переполнении заложена возможность дополнительных оптимизаций: разрешается "отложенная паника [32]", то есть реализации могут выполнять последовательность операций a + b + c + d
и паниковать один раз в самом конце, если любое из вычислений привело к переполнению, вместо того чтобы проверять каждую отдельную операцию tmp = a + b
, затем tmp + c
и т.д. Хотя на данный момент это не реализовано, такая возможность есть.
Все усилия по разработке, обсуждению и реализации этой схемы обработки целочисленных переполнений были бы напрасны, если бы они не помогали обнаружить ошибки на практике. Лично я нашёл несколько проблем в своём коде с выражениями вроде cmp::max(x - y, z)
(они не попали в интернет, так что ссылок не будет) практически сразу после написания, особенно в сочетании с инфраструктурой тестирования, такой как quickcheck [33].
Проверки переполнения обнаружили ошибки в нашей экосистеме, например, такие (список не полный!):
За пределами Rust существует много других примеров опасности ошибок переполнения. В 2011 году они попали в список 25 самых частых ошибок CWE/SANS [41]. Некоторые языки, например Swift, всегда осуществляют проверки переполнения, а другие, такие как Python 3 и Haskell, избегают переполнения, по умолчанию используя числа с произвольной точностью. Более того, некоторые компиляторы C поддерживают опции, заменяющие неопределённое поведение на циклическое переполнение (-fwrapv
) и помогающие обнаружить переполнение (-fsanitize=signed-integer-overflow
).
Автор: DarkEld3r
Источник [42]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/programmirovanie/162621
Ссылки в тексте:
[1] утечек памяти: https://habrahabr.ru/post/281370/
[2] игнорирования ошибок: https://doc.rust-lang.org/std/result/#results-must-be-used
[3] переполнения целых чисел: https://en.wikipedia.org/wiki/Integer_overflow
[4] дополнения до двух: https://ru.wikipedia.org/wiki/%D0%94%D0%BE%D0%BF%D0%BE%D0%BB%D0%BD%D0%B8%D1%82%D0%B5%D0%BB%D1%8C%D0%BD%D1%8B%D0%B9_%D0%BA%D0%BE%D0%B4_(%D0%BF%D1%80%D0%B5%D0%B4%D1%81%D1%82%D0%B0%D0%B2%D0%BB%D0%B5%D0%BD%D0%B8%D0%B5_%D1%87%D0%B8%D1%81%D0%BB%D0%B0)
[5] экономике: http://www.gamasutra.com/blogs/MaxWoolf/20130508/191959/Diablo_III_Economy_Broken_by_an_Integer_Overflow_Bug.php
[6] индикаторах здоровья: http://www.codeproject.com/Articles/802368/Integer-Overflow-in-Hearthstone
[7] двоичного поиска: http://googleresearch.blogspot.com.au/2006/06/extra-extra-read-all-about-it-nearly.html
[8] авиации: http://www.nytimes.com/2015/05/01/business/faa-orders-fix-for-possible-power-loss-in-boeing-787.html?_r=1
[9] RFC 560: https://github.com/rust-lang/rfcs/pull/560
[10] MIN / -1
: http://blog.regehr.org/archives/887
[11] первой версии: https://github.com/nikomatsakis/rfcs/blob/630dd70a51c0c7e166be78cd3bc8f1247664db28/text/0000-integer-overflow.md#semantics-of-overflow-with-the-built-in-types
[12] wrapping_add: http://doc.rust-lang.org/std/primitive.i32.html#method.wrapping_add
[13] wrapping_sub: http://doc.rust-lang.org/std/primitive.i32.html#method.wrapping_sub
[14] saturating_add: http://doc.rust-lang.org/std/primitive.i32.html#method.saturating_add
[15] saturating_sub: http://doc.rust-lang.org/std/primitive.i32.html#method.saturating_sub
[16] overflowing_add: http://doc.rust-lang.org/std/primitive.i32.html#method.overflowing_add
[17] overflowing_sub: http://doc.rust-lang.org/std/primitive.i32.html#method.overflowing_sub
[18] checked_add: http://doc.rust-lang.org/std/primitive.i32.html#method.checked_add
[19] checked_sub: http://doc.rust-lang.org/std/primitive.i32.html#method.checked_sub
[20] Wrapping
: http://doc.rust-lang.org/std/num/struct.Wrapping.html
[21] потенциальные улучшения: https://github.com/rust-lang/rfcs/blob/master/text/0560-integer-overflow.md#alternatives-and-possible-future-directions
[22] Servo: https://github.com/rust-lang/cargo/issues/2262
[23] Gecko: https://wiki.mozilla.org/Oxidation#Rust_.2F_Cargo_nice-to-haves
[24] cargo profile: http://doc.crates.io/manifest.html#the-profile-sections
[25] ошибку: https://llvm.org/bugs/show_bug.cgi?id=25614
[26] более консервативно: http://danluu.com/integer-overflow/
[27] действительно: http://blog.regehr.org/archives/1384
[28] уделяется: https://github.com/apple/swift/blob/16b3d6c8d5b2d610cdfd72898f6ab384e632b69b/lib/SILOptimizer/Transforms/RedundantOverflowCheckRemoval.cpp
[29] много: https://github.com/llvm-mirror/llvm/blob/8b47c17a53d683f313eaaa93c4a53de26d8fcba5/lib/Transforms/InstCombine/InstCombineAddSub.cpp#L893-L987
[30] внимания: https://github.com/gcc-mirror/gcc/blob/fd3211e13bbbb6882f477aa75a36eb0ccdec485f/gcc/tree-vrp.c#L9792-L9884
[31] заменив панику аварийным завершением программы: https://github.com/rust-lang/rfcs/blob/master/text/1513-less-unwinding.md
[32] отложенная паника: https://github.com/rust-lang/rfcs/blob/master/text/0560-integer-overflow.md#delayed-panics
[33] quickcheck: https://crates.io/crates/quickcheck
[34] стандартная библиотека: https://github.com/rust-lang/rust/pull/22532#issuecomment-75168901
[35] компилятор: https://github.com/rust-lang/rust/pull/31281
[36] встроенная инфраструктура тестирования производительности: https://github.com/rust-lang/rust/pull/23127
[37] Servo: https://github.com/servo/servo/issues/6040
[38] image: https://github.com/PistonDevelopers/image/pull/412
[39] url: https://github.com/servo/rust-url/issues/124
[40] webrender: https://github.com/servo/webrender/pull/243
[41] список 25 самых частых ошибок CWE/SANS: http://cwe.mitre.org/top25/
[42] Источник: https://habrahabr.ru/post/282958/?utm_source=habrahabr&utm_medium=rss&utm_campaign=best
Нажмите здесь для печати.