memory security without loss of speed
Rust is a high-performance, memory-safe programming language. Other compiled programming languages, such as C, can run quickly and with minimal glitches, but they lack the functionality to ensure proper program memory allocation. Therefore, languages like Rust that put memory safety first are gaining more and more attention. In this article, we'll talk about how Rust guarantees memory safety.
Built-in Security
A good place to start when talking about memory safety is that Rust's memory safety features are built directly into the language. Not only are they mandatory, but they are put in place before the code even runs. The fact is that in many programming languages, errors related to memory safety are too often discovered only at runtime. In Rust, program behavior that does not ensure memory safety is treated not as runtime errors, but as compiler errors. Thus, entire classes of problems, such as use-after-free errors, will simply be syntactically incorrect in Rust and, accordingly, the code will not execute.
Of course, this does not mean that code written in Rust is completely error-free. Some runtime issues, such as race conditions, are still the responsibility of the developer. A race condition is a design flaw in a multi-threaded system in which its operation depends on the order in which parts of the code are executed.
In the example below, we perform a bounds check and then an unsafe data access occurs with an unchecked value:
use std::thread;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
let data = vec![1, 2, 3, 4];
let idx = Arc::new(AtomicUsize::new(0));
let other_idx = idx.clone();
// `move` фиксирует other_idx по значению, перемещая его в этот поток
thread::spawn(move || {
// Можно изменить idx, потому что это значение
// является атомарным, поэтому оно не может вызвать скачок данных.
other_idx.fetch_add(10, Ordering::SeqCst);
});
if idx.load(Ordering::SeqCst) < data.len() {
unsafe {
// Неправильная загрузка idx после того, как мы выполнили проверку границ.
// Это могло измениться. Это условие гонки, * и оно опасно*
// потому что мы решили использовать `get_unchecked`, что является `небезопасным`.
println!("{}", data.get_unchecked(idx.load(Ordering::SeqCst)));
}
}
The Rust compiler cannot prevent this error, and the programmer must make his code invulnerable to race conditions. For example, like this:
use std::thread;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
let data = vec![1, 2, 3, 4];
// Arc, чтобы память, в которой хранится AtomicUsize, все еще существовала для
// увеличения другим потоком, даже если мы полностью завершим выполнение
// до этого. Rust не будет компилировать программу без этого, из-за
// требований к времени жизни thread::spawn!
let idx = Arc::new(AtomicUsize::new(0));
let other_idx = idx.clone();
// `move` фиксирует other_idx по значению, перемещая его в этот поток
thread::spawn(move || {
// Можно изменять idx, потому что это значение
// является атомарным, поэтому оно не может вызвать скачок данных.
other_idx.fetch_add(10, Ordering::SeqCst);
});
// Индексируем со значением, загруженным из atomic. Это безопасно, потому что мы
// считываем атомарную память только один раз, а затем передаем копию этого значения
// в реализацию индексации Vec. Эта индексация будет выполнена корректно
// границы проверены, и нет никаких шансов, что значение изменится
// в середине.
println!("{}", data[idx.load(Ordering::SeqCst)]);
It's worth noting here that memory-managed languages, such as C#, Java or Python, almost completely eliminate the need for a developer to manually manage memory. This allows programmers to fully focus on writing code and debugging. But you have to pay for everything, and for this convenience too. In particular, you have to pay other costs, such as speed or increased execution time. Rust's compiled executables can be very compact, run at native code speed by default, and remain memory-safe.
Let's take a look at how Rust implements these features to work safely and efficiently. Let's start by looking at working with variables.
Immutability by default
In most programming languages we have constants and we have variables. We initialize the values of constants, as a rule, at the beginning of the program and then they remain unchanged. The values of variables, according to their names, can change while the program is running.
And in Rust, all variables are immutable by default, meaning they cannot be reassigned or changed. To change they must be specifically declared as mutable.
For example, a construction like this:
fn main() {
let x = 5;
println!("The value of x is: {x}");
x = 6;
println!("The value of x is: {x}");
}
When compiling it will return an error:
$ cargo run
Compiling variables v0.1.0 (file:///projects/variables) error[E0384]: cannot assign twice to immutable variable `x` --> src/main.rs:4:5
|
2 | let x = 5;
| -
| |
| first assignment to `x`
| help: consider making this binding mutable: `mut x`
3 | println!("The value of x is: {x}");
4 | x = 6;
| ^^^^^ cannot assign twice to immutable variable
Correct option:
fn main() {
let mut x = 5;
println!("The value of x is: {x}");
x = 6;
println!("The value of x is: {x}");
}
Here we are explicitly saying that x is mutable. This approach forces developers to be fully aware of which values in the program must be changed and when. The resulting code is easier to analyze because it tells you what might change and where. This allows you to minimize random changes in variable values and protects against programmer errors.
Immutability or constant
Let's take a closer look at how the concept of “immutable by default” differs from the concept of a constant. An immutable variable can be evaluated and then stored as immutable at runtime, meaning it can be evaluated, stored, and then not modified. However, the constant must be computable at compile time before the program can be run. Many kinds of values—for example, those entered by the user—cannot be stored as constants in this way.
It is worth noting that, for example, C++ assumes the opposite approach: by default, everything is mutable, and if we want to make objects immutable, we must use the const keyword.
Ownership, borrowing, and references in Rust
In most programming languages, objects have no ownership and can be accessed by any other object at any time. Accordingly, all responsibility for how something is modified lies with the programmer. In other languages, such as Python, Java or C#, there are no proficiency rules, but only because they are not necessary. Object access and therefore memory safety is handled by the runtime. Again, this comes at the expense of speed or size and availability of the runtime environment.
But in Rust, every value has an “owner,” which means that only one object at a time, at any given point in the code, can have full control over the value to perform read or write operations. Ownership can be temporarily transferred or “borrowed,” but this behavior is strictly enforced by the Rust compiler. Any code that violates the ownership rules for a given object simply does not compile.
Let's try to figure it out using a simple example. In the example below, the variable is valid from the time it is declared until the end of its current scope.
{
// s здесь недействительна, потому что ее еще не объявили
let s = "hello"; // С этого места s действительна
// здесь s тоже действительна
} // а с этого места s больше недействительна
In other words, there are two important points here: when s falls into scope, it becomes valid. And it remains valid until it goes out of scope.
About life time
References to values in Rust not only have owners, but also a lifetime, that is, the area for which the reference is valid. In most Rust code, the lifetime can be implicit because the compiler keeps track of it. But it can also be explicitly specified for more complex use cases. However, attempting to access or change anything outside of its lifecycle or after it has gone “out of scope” results in a compiler error. This again prevents entire classes of dangerous bugs from being introduced into production Rust code.
So use-after-free errors or “dangling pointers” occur when you try to access something that was theoretically freed or out of scope. This is depressingly common in C and C++. C has no formal control over the lifetime of an object at compile time. C++ has concepts such as “smart pointers” to avoid this, but they are not implemented by default; you must agree to their use. Language security becomes a matter of individual programming style or institutional requirements, rather than something that the language as a whole provides.
In Rust, the lifetime of a variable begins when it is created and ends when it is destroyed. Although lifetimes and scopes are often used together, they are not the same thing. Take for example the case where we borrow a variable using &. The lifetime of a variable is determined by where it is declared. As a result, the borrowing is valid until it ends before the borrower is destroyed.
Let's see an example:
fn main() {
let i = 3; // Начало времени жизни `i`
{
let borrow1 = &i; // Начало времени жизни `borrow1`
println!("borrow1: {}", borrow1);
} // Окончание времени жизни `borrow1`
{
let borrow2 = &i; // Начало времени жизни `borrow2`
println!("borrow2: {}", borrow2);
} // Окончание времени жизни `borrow2`
} // Окончание времени жизни `i`
In this example, we see different uses of lifetime in both variable declarations and borrowings.
Conclusion
We've looked at some of the constructs Rust uses to make it memory safe without significant performance overhead. But you have to pay for everything. Mastering these constructs can be challenging in terms of understanding how these mechanisms work in the Rust language. However, once you understand them, you can significantly improve the security of the code you develop.
I would like to invite you to the free webinars of the course Rust Developer. Professional: