Author: Xiao Meng; Post editing: Gao Xianfeng.
Author introduction: Xiao Meng has 20 years of experience in software architecture from desktop to cloud to embedded. He is a domain system analysis expert and full-stack software architecture expert across communications, gaming, finance and intelligent connected vehicle industries. At present, he is working on the basic software development of intelligent driving. He successively served as the director of autonomous driving software platform of Jiliekkatong and the r&d director of autonomous driving software of China Auto Intelligent Control. Strong interest in the promotion of Rust technology stack in the automotive field and actual mass production practice.
1. The introduction
Ownership and lifecycle are very central to Rust. It’s not just Rust that has these two concepts, but C/C++ as well. Almost all memory safety problems also result from misuse of ownership and lifecycle. This problem applies to any programming language that does not use garbage collection to manage memory. Rust, however, clarifies these two concepts at the language level and provides language features that give users explicit control over ownership transfer and lifecycle declarations. At the same time, the compiler will check all kinds of errors, which improves the memory security of the program.
Ownership and life cycle involve a lot of language concepts, this article is mainly to sort out the concepts related to “ownership and life cycle”, and use UML class diagram to express the relationship between the concepts, to help better understand and grasp.
Illustration shows that
The diagrams in this article are UML class diagrams, which can be used to represent the analysis of concepts. Express dependencies, inheritance, aggregation, composition, and other relationships between concepts. Each rectangle in the diagram is a semantic concept, either an abstract language concept or a structure or Trait in the Rust library.
All diagrams use only the most basic symbols. Figure 1 gives a brief description of the symbolic system, mainly explaining the symbolic language used to express the relationships between concepts.
Dependencies:
Dependencies are the most fundamental relational semantics in UML. Represented by A dotted line with an arrow, A dependence and B are expressed in the following figure. Intuitive understanding can be that A “sees” B, while B can know nothing about A. For example, in the code structure A has A member variable of structure B, or in the implementation code of A has A local variable of B. So if you can’t find B, A won’t compile.
Association:
A solid line connection indicates that the two types are directly related, with an arrow indicating that they are visible in one direction, and no arrow indicating that they are visible to each other. An association is also a dependency, but more specific. Sometimes an association relationship between two types is so complex that it needs to be expressed in a single type, called an association type, such as H in the example diagram.
Polymerization and composition:
Both aggregation and composition represent the relationship between the whole and the parts. The difference is that “aggregated” wholes and parts can be separated, and parts can be shared across multiple wholes. In the “composition” relationship, the whole has a stronger exclusivity to the parts, the parts cannot be disassembled, and the parts have the same life cycle as the whole.
Inheritance and interface implementation:
Inheritance and interface implementation are both A generalization relationship. C inherits from A, indicating that A is A more general concept. The various relationship semantics in UML can also be expressed in UML itself, as shown in Figure 2: “association” and “inheritance” are both concrete representations of “dependency”.
General layout
Figure 3 is the general drawing of this paper, and the following sections are introduced.
2. Issues that ownership and life cycle are expected to address
Let’s start at the middle of the diagram, where ownership means owning a “memory area” for a variable. This area of memory, it can be on the heap, it can be on the stack, it can be in the code snippet, and there are also memory addresses that are directly used for I/O address mapping. These are all possible locations of memory areas.
In high-level languages, the memory location must be associated with one or more variables in order for it to be accessible in the program (low-level languages, such as assembly language, can access the memory address directly). That is, through this one or more variables, you can access the memory address.
This leads to three questions:
- Memory safety issues caused by incorrect access to memory
- Data consistency problems due to multiple variables pointing to the same area of memory
- Data race issues due to variables being passed in multiple threads
There are five typical cases of memory safety problems caused by the first problem:
- Use uninitialized memory
- Dereference a null pointer
- Dangling pointer (using memory that has been freed)
- Buffer overflow
- Illegal memory release (unallocated pointer or repeated pointer release)
These are issues that need to be handled very carefully by the developer in C/C++. For example, we could write a piece of C++ code that makes all five memory safety errors.
#include <iostream>
struct Point {
int x;
int y;
};
Point* newPoint(int x,int y) {
Point p { .x=x,.y=y };
return &p; // Hang the pointer
}
int main(a) {
int values[3] = {1.2.3 };
std::cout<<values[0] < <","<<values[3]<<std::endl; // Buffer overflow
Point *p1 = (Point*)malloc(sizeof(Point));
std::cout<<p1->x<<","<<p1->y<<std::endl; // Use uninitialized memory
Point *p2 = newPoint(10.10); // Hang the pointer
delete p2; // The memory was illegally freed
p1 = NULL;
std::cout<<p1->x<<std::endl; // Dereference the null pointer
return 0;
}
Copy the code
This code is compilable, of course, but the compiler still gives you a warning. This code also runs and prints information until the last error, “when dereferencing a null pointer,” occurs.
The language features of Rust provide a solution to the above problems, as shown in the following table:
The problem | The solution |
---|---|
Use uninitialized memory
|
The compiler disallows variables from reading unassigned variables |
Dereference a null pointer
|
Use an Option enumeration instead of a null pointer |
Dangling pointer
|
Lifecycle identification and compiler checking |
Buffer overflow
|
Compiler checks to deny data access beyond buffer boundaries |
Illegal memory release
|
Language level RAII mechanism, only the sole owner has the right to free memory |
Multiple variables modify the same memory area
|
Multiple variables are allowed to borrow ownership, but only one variable is allowed at a time |
Security issues when variables are passed across multiple threads
|
Sync and Send are traits that identify the thread-safe nature of a basic data type, whether it can transfer ownership or pass variable borrowing, as a basic fact. The generic qualifying syntax and Trait IMPL syntax are used to describe thread-safe rules for types. A rule engine-like mechanism is used during compilation to perform inference checks for cross-thread data passing in user code based on basic facts and predefined rules. |
3. Variable binding and ownership
Why is Rust called “variable binding” and not “variable assignment”? Let’s take a look at some C++ code and the corresponding Rust code.
C++:
#include <iostream>
int main(a)
{
int a = 1;
std::cout << &a << std::endl; /* Output 0x62Fe1c */
a = 2;
std::cout << &a << std::endl; /* Output 0x62Fe1c */
}
Copy the code
Rust:
fn main() {
let a = 1;
println!("a:{}",a); / / output 1
println!("&a:{:p}",&a); // 输出0x9cf974
//a=2; // An immutable binding cannot modify the value of the binding
let a = 2; // Rebind
println!("&a:{:p}",&a); // Output 0x9Cfa14 Address changed
let mut b = 1; // Create a mutable binding
println!("b:{}",b); / / output 1
println!("&b:{:p}",&b); // 输出0x9cfa6c
b = 2;
println!("b:{}",b); 2 / / output
println!("&b:{:p}",&b); // Output 0x9cfa6c No change in address
let b = 2; // Rebind to the new value
println!("&b:{:p}",&b); // Output 0x9cfba4 The address has changed
}
Copy the code
We can see that in C++ code, the variable a is assigned 1 and then 2, but its address does not change. In Rust code, a is an immutable binding and the a=2 action is rejected by the compiler. But you can use let to rebind, but then a’s address is changed, indicating that A is bound to another memory address. B is a mutable binding that can be re-assigned to the memory it points to using b = 2, with the address of B unchanged. But after rebinding with let, B points to the new memory region.
As you can see, “assignment” writes the value to the memory area associated with the variable, “binding” establishes the relationship between the variable and the memory area, and Rust also assigns ownership of the memory area to the variable.
Immutable binding means that a variable is bound to a memory address and given ownership. Data at that address can only be read through the variable but cannot be modified. Correspondingly, mutable bindings can modify the data associated with the memory region through variables. Syntactically, having the let keyword is binding; not having it is assignment.
Here we can see one difference between Rust and C++. There is no concept of “binding” in C++. Rust’s concept of variable binding is a key one, as it is the starting point for ownership. The ownership is determined by the clear binding, and the timing of unbinding determines the timing of resource release.
Ownership rules:
- Each value has its own owner variable
- There can be only one owner variable at a time
- Owner leaves scope, value discarded (release/destruct)
As the owner, it has the following rights:
- Control the release of resources
- Ownership in lending
- Transfer of ownership
4. Transfer of ownership
One of the important rights of an owner is to “transfer ownership”. This raises three questions:
- Why transfer?
- When will it be transferred?
- What means of transfer?
Related language concepts are shown below.
Why transfer ownership?
We know that variables in C/C++/Rust are associated with an area of memory, but they are always operated on in an expression and assigned to another variable, or passed between functions. What is actually expected to be passed is the contents of the memory region bound to the variable. If the memory region is large, copying the memory data into the new variable is an expensive operation. So you need to transfer ownership to the new variable and relinquish ownership to the current variable. So at the end of the day, transferring ownership is all about performance.
The timing of ownership transfer can be summarized as follows:
- Position expressions transfer ownership when they appear in value context
- Ownership is transferred when a variable is passed across scope
The first rule is a precise academic expression involving linguistic concepts such as positional expressions, value expressions, positional contexts, and value contexts. The simplest way to think about it is that there are all kinds of assignments. The expressions that explicitly point to the location of a memory region are positional expressions, and all others are value expressions. The various operations with assignment semantics have a positional context on the left and a value context on the right.
When a positional expression appears in a value context, the program semantics are to assign the data pointed to by the positional expression here to a new variable, and ownership shifts.
The second rule is “variables transfer ownership across scopes.”
The figure shows several common cross-scope behaviors that cover most cases, as well as simple sample code
- Variables are used within curly braces
- Match match
- If let and While let
- Move semantic function parameter passing
- Closures capture movement semantic variables
- Variables are returned from within a function
Why do variables transfer ownership across scopes?
In C/C++ code, whether to transfer ownership is specified by the programmer, either implicitly or explicitly.
Imagine that in C/C++ code, function Fun1 creates an instance A of type A on the stack and passes its pointer &a to function void fun2(A* param). We wouldn’t want fun2 to free that memory, because when Fun1 returns, The space on the stack is automatically freed.
If fun1 creates an instance of A on the heap and passes its pointer &a to fun2(A* param), then fun1 and fun2 need to negotiate who will free the memory space of A. Fun1 might expect to be freed by fun2. If it is freed by fun2, fun2 does not know whether the pointer is on the heap or the stack. At the end of the day, it is still a question of who owns the memory area pointing to A. C/C++ does not impose constraints at the language level. The fun2 function is designed to make assumptions about the context in which it is called, and to make conventions in the document about who is to free the memory of the variable. This makes it virtually impossible for the compiler to warn about incorrect usage patterns.
Rust requires that variables clearly transfer ownership when they cross scopes. The compiler knows exactly which variables are owned inside and outside the scope boundary, and can explicitly check for illegal use of variables, increasing code security.
There are two ways to transfer ownership:
- Mobile semantics – Perform ownership transfer
- Replication semantics – no transfers are performed, only bitwise replication of variables
Here I define “copy semantics” as one of the ways in which ownership is transferred, which means that “no transfer” is also a transfer. It looks strange. In fact, the logic is consistent, because the timing of the replication execution is the same as the timing of the transition. Only this data type is marked with the Copy tag trait, so the compiler instead performs bitwise replication when the transfer action should be performed.
The Copy Trait implemented in Rust’s standard library for all base types.
Notice here, the standard library
impl<T: ?Sized> Copy for &T {}
Copy the code
Copy is implemented for all reference types, which means that when we call a function with a reference argument, the reference variable itself is bitwise copied. The library does not implement the “Copy” Trait for mutable borrow&mut because there can only be one mutable borrow&mut. We’ll see an example later when closures capture ownership of variables.
5. Borrowing of ownership
A variable has a memory area ownership, and one of its ownership rights is “lending ownership.”
The conceptual relationship associated with lending ownership is shown in Figure 6
A variable that has ownership lends its ownership in two ways: “reference” and “smart pointer” :
-
References (including mutable borrows and immutable borrows)
-
Smart Pointers
- Exclusive smart pointer
Box<T>
- Non-thread-safe reference counting smart pointer
Rc<T>
- Thread-safe reference counting smart pointer
Arc<T>
- A weak pointer
Weak<T>
- Exclusive smart pointer
References are actually Pointers to the actual memory location.
There are two important safety rules for borrowing:
- Represents a borrowed variable whose lifetime cannot be longer than that of the borrowed variable (owner)
- There can only be one variable borrowing of the same variable
The first rule is to ensure that there is no “dangling pointer” memory safety issue. If this rule is violated, for example, if the variable A has ownership of the storage area and the variable b is some borrowed form of A, then b becomes a dangling pointer if the lifetime of B is longer than that of A and the storage space is freed after a is destroyed but B is still available.
The second is not to allow two variable borrows to avoid data consistency problems.
Struct Foo{v:i32}
fn main() {let mut f = Foo{v:10};
let im_ref = &f; // Get immutable references
let mut_ref = & mut f; // Get a mutable reference
//println! ("{}",f.v);
//println! ("{}",im_ref.v);
//println! ("{}",mut_ref.v);
}
Copy the code
The variable f owns the value, im_ref is its immutable borrow, and mut_ref is its mutable borrow. The above code can be compiled, but none of the variables are used, in which case the compiler does not forbid you to have both mutable and immutable borrowing. The last three lines of commented out code (6,7,8) use these variables. Open one or more lines of code with these comments, and the compiler reports a different form of error:
Open comment line | Compiler report |
---|---|
6 | correct |
7 | Error in line 5: cannot get a mutable borrow for f because immutable borrow already exists |
8 | correct |
6, 7 | Error in line 5: cannot get a mutable borrow for f because immutable borrow already exists |
6, 8 | Error on line 6: Cannot obtain immutable borrowings for f because mutable borrowings already exist |
An abstract expression of “borrowing”
Two generic traits in Rust’s core package, core::borrow:: borrow and core::borrow::BorrowMut, can be used to express the abstract meaning of “borrow,” representing variable and immutable borrowing, respectively. As mentioned earlier, “borrowing” can be expressed in many different ways (&T,Box
, Rc
, etc.), and the appropriate borrowing will be chosen for different usage scenarios. Their abstract form can be represented by core::borrow:: borrow. Type-related, Borrow is an abstract form of the concept of borrowing. In practice, the Borrow Trait comes in handy for situations where we want a certain type of Borrow, and where we want to support all possible forms of Borrow.
The definition of Borrow is as follows:
pub trait Borrow<Borrowed: ?Sized> {
fn borrow(&self) -> &Borrowed;
}
Copy the code
It has only one method that requires the return of a reference to the specified type.
Examples are provided in Borrow’s documentation
use std::borrow::Borrow;
fn check<T: Borrow<str>>(s: T) {
assert_eq!("Hello", s.borrow());
}
fn main() {let s: String = "Hello".to_string();
check(s);
lets: &str = "Hello";
check(s);
}
Copy the code
The argument to the check function says that it wants to receive any kind of “borrow” of type “STR” and then take the value and compare it to “Hello”.
The library implements Borrow< STR > for String, as follows
impl Borrow<str> for String{
#[inline]
fn borrow(&self) - > &str{&self[..]
}
}
Copy the code
So String can be used as an argument to check.
As you can see from the diagram, the standard library implements the Borrow Trait for all types of T, as well as for &t.
The code is as follows, how to understand this.
impl<T: ?Sized> Borrow<T> for T {
fn borrow(&self) -> &T { // is short for fn Borrow (self: &self), so the type of self is &t
self}}impl<T: ?Sized> Borrow<T> for &T {
fn borrow(&self) -> &T {
&**self}}Copy the code
That’s what makes Rust so interesting, it subtly embodies the consistency of language. Since the method that Borrow
is designed to retrieve a reference to T, the types T and &t can certainly do the same. In the implementation of Borrow for T,
Fn Borrow (&self)-> &t is short for fn Borrow (self: &self)-> &t, so self is of type &t and can be returned directly. In the implementation of Borrow for &T, fn Borrow (&self)->&T is an abbreviation of fn Borrow (self: &self)->&T, so self is of type &&T, which needs to be dereferensed twice to get T and return its reference.
The smart Pointers Box
,Rc
, and Arc
all implement Borrow
, and all obtain &t instances by dereferencing them twice before fetching the reference. Weak
does not implement Borrow
, which needs to be upgraded to Rc
to get data.
6. Life cycle parameters
The life cycle of a variable is mainly related to the scope of the variable, which is implicitly defined in most programming languages. Rust explicitly declares the lifecycle parameters of variables, which is a very unique design and has syntax features that are unlikely to be seen in other languages. The following diagrams relate to the concept of life cycle.
The role of life cycle parameters
The core role of life cycle parameters is to solve the problem of dangling Pointers. Let the compiler help check the life cycle of variables to prevent the variable from being usable after the area of memory to which the variable points has been freed. So when does the compiler have to introduce a specific syntax to identify the lifecycle, and not the lifecycle?
Let’s take a look at the most common dangling pointer problem, where a function returns a local variable inside the function as a reference:
struct V{v:i32}
fn bad_fn() -> &V{ // Compile error: expect a named lifecycle parameter
let a = V{v:10};
&a
}
let res = bad_fn();
Copy the code
This code is a typical dangling pointer error. A is a local variable in the function. When the function returns, a is destroyed and the reference to a is assigned to res, which is bound to an undefined value if successful.
Instead of reporting a dangling pointer error, the compiler says that the return type &v does not specify a lifecycle parameter. C++ compilers of similar code give warnings about dangling Pointers (warning: the address of a local variable has been returned).
Let’s specify a lifecycle parameter and see:
fn bad_fn<'a> () - > &'a V{
let a = V{v:10};
let ref_a = &a;
ref_a // Compile error: cannot return reference to local variable
}
Copy the code
This time the compiler is reporting a dangling pointer error. So what is the analysis logic of the compiler?
First of all, what exactly is the exact semantics of ‘a ‘here?
The reference to be returned by the function represents an in-memory data that has a lifetime range, and the ‘a parameter is required for that lifetime range. Just as &v is a requirement for the return value type, ‘a is a requirement for the return value lifecycle. All the compiler needs to check is whether the actual returned data is alive.
So what does the ‘a parameter require about the life cycle of the return value?
Let’s first distinguish between “function context”, which refers to the scope inside the function body, and “caller context”, which refers to the location where the function is called. The dangling pointer error mentioned above does not actually affect the execution of the program within the context of the function. The problem is that the caller context gets an invalid reference and uses it with an unpredictable error.
A reference returned by a function is given a variable in the caller context, such as:
let res = bod_fn();
Copy the code
Res gets the returned reference, and the ref_A reference within the function is bit-copied to the variable res (impl
Copy for &T {} specifies this rule. Res refers to the same data as res_A in the function. To ensure that there are no dangling Pointers in the caller context in the future, what the compiler really needs to ensure is that the lifetime of the data that res points to is no shorter than the lifetime of the RES variable itself. Otherwise, if the data has a short life cycle and is released first, the RES becomes a dangling pointer.
The ‘a ‘argument here can be understood as the life cycle of the variable res in the caller context that receives the return value from the function. Then ‘a’ requires that the life cycle of the data referred to in the return reference is no shorter than ‘a, that is, the life cycle of the variable that receives the return value from the caller context.
In the example above, the data life cycle referred to by ref_A in the function is the scope of the function. Before the function returns, the data is destroyed. The life cycle of the function is less than the res of the caller context.
In fact, the returned reference is either a static lifetime or an operational transformation based on the reference parameter input to the function, otherwise it would be the same because it is all a reference to local data.
Static life cycle
See the function
fn get_str<'a> () - > &'a str {
let s = "hello";
s
}
Copy the code
This function can be compiled through, and the return reference, although not derived from the input arguments, is a static lifecycle that can be checked.
Because the static life cycle can be understood as “infinite” in semantics, it is actually consistent with the process life cycle, that is, it is valid throughout the duration of the program.
The literal string of Rust is stored in the program code and is always valid in the code space after the program is loaded. This can be verified by a simple experiment:
let s1="Hello";
println!("&s1:{:p}", &s1);//&s1:0x9cf918
let s2="Hello";
println!("&s2:{:p}",&s2);//&s2:0x9cf978
//s1 and s2 are the same value but not the same address. They are two different reference variables
let ptr1: *const u8 = s1.as_ptr();
println!("ptr1:{:p}", ptr1);//ptr1:0x4ca0a0
let ptr2: *const u8 = s2.as_ptr();
println!("ptr2:{:p}", ptr2);//ptr2:0x4ca0a0
Copy the code
The original Pointers to s1 and s2 all point to the same address, indicating that the compiler only keeps one copy of the “Hello” literal, and all references point to it.
The static lifetime of get_str is longer than ‘a ‘required for the return value, so it is legal.
If I change get_str to
fn get_str<'a> () - > &'static str
Copy the code
That is, change the life cycle requirement for the return value to infinity, so you can only return a static string reference.
The life cycle of a function parameter
The previous example had no input parameters for simplicity, which is not a typical case. In most cases, the reference returned by a function is computed based on the input reference parameter. Take the following example:
fn remove_prefix<'a>(content:&'a str,prefix:&str) - > &'a str{
if content.starts_with(prefix){
let start:usize = prefix.len();
let end:usize = content.len();
letsub = content.get(start.. end).unwrap(); sub }else{
content
}
}
let s = "reload";
let sub = remove_prefix(&s0,"re");
println!("{}",sub); // Output: load
Copy the code
The remove_prefix function checks the input content string to see if there is a prefix represented by prefix. If yes, return the slice with no content prefix; if no, return content itself.
This function does not return the prefix in any case, so the prefix variable does not need to specify the life cycle.
What is returned from both branches of the function is transformed from the content variable and is returned as the value of the function. Therefore, the content must be labeled with the lifecycle parameter, and the compiler must compare the lifecycle parameter of the content with the required return value to determine whether it meets the requirements. That is, the life cycle of the actual returned data is greater than or equal to the life cycle required by the return parameter.
As mentioned earlier, we consider the life cycle parameter ‘a ‘specified in the return parameter as the life cycle of the variable receiving the return value in the caller context, in this case the string reference sub, so what does ‘a’ stand for in the input parameter?
This is a very confusing part of Rust syntax design. The lifecycle of both input and output parameters is marked ‘a ‘, as if to require that the lifecycle of both be the same, but in fact they are not.
Let’s first look at what happens if the life cycle of the input parameter is different from what is expected of the output parameter, as shown in the following two examples:
fn echo<'a.'b>(content: &'b str) - > &'a str {
content // Compile error: the life cycle of the referenced variable itself exceeds its borrowed target
}
fn longer<'a.'b>(s1: &'a str, s2: &'b str) - > &'a str {
if s1.len() > s2.len()
{ s1 }
else
{ s2 }// Compile error: lifecycle mismatch
}
Copy the code
The echo function input parameter lifecycle is marked ‘b ‘and the return value expects ‘a’. The compiler error message is a typical “dangling pointer” error. But the content doesn’t seem clear. The compiler points to the details -explain E0312, where the explanation is “the life cycle of the borrowed content is not consistent with expectations.” This error description is consistent with the actual error situation.
The longer function takes a life cycle ‘a ‘and a life cycle ‘b, and returns an expectation of ‘a. When s2 is returned, the compiler reports a life cycle mismatch. Mark the life cycle ‘b ‘in the longer function to be longer than ‘a, and you compile correctly.
fn longer<'a.'b: 'a>(s1: &'a str, s2: &'b str) - > &'a str {
if s1.len() > s2.len()
{ s1 }
else
{ s2 }// Compile passed
}
Copy the code
Going back to our previous question, what does the ‘a ‘in the input parameter mean?
We know that the lifecycle check that the compiler does in the context of a function definition is to ensure that “the lifecycle of the actual returned data is greater than or equal to the lifecycle required by the return parameter”. When the input parameter ‘a is given the same lifetime as the return value, you are artificially assuring the compiler that the actual given input parameter to the function, in the context of the caller, is not less than the lifetime of the variable that will be used to receive the return value.
When there are two life cycle parameters ‘a ‘b, and ‘b is greater than ‘a, of course there is a guarantee that the life cycle of the input parameter represented by ‘b ‘in the caller context is also long enough.
In a function definition, the compiler does not know what context the function will actually be called in the future. Lifecycle parameters are a protocol for the lifecycle of parameters between the function context and the caller context.
Just like the type declaration in a function signature, which specifies the types of input and output arguments to and from the caller, the compiler checks that the data type returned by the function body is consistent with the declared return value when compiling the function. The function also checks whether the returned variable lifecycle in the body of the function is the same as that declared for the lifetime of the parameter and the return value.
This is only one part of the compiler’s “function definition context lifecycle checking” mechanism, and another part is the “caller context versus lifecycle checking” mechanism. The check rules are as follows:
The function defines the context’s lifecycle check:
The life cycle annotation of the return value in the function signature can be any of the input annotations, as long as the life cycle of the returned temporary variable derived from the input parameters is equal to or longer than that of the return value annotation in the function signature. This ensures that the variable that receives the return value in the caller context does not become a dangling pointer due to invalidation of the input parameter.
The caller context checks the lifecycle:
In the caller context, the receiving function returns the borrowed variable res, whose lifetime cannot be longer than the returned borrowed lifetime (which is actually derived from the input borrowed parameter). Otherwise, res becomes a dangling pointer when the input parameter is invalid.
The remove_prefix function compiler has already been verified, so let’s build the following example in the caller context
let res: &str;
{
let s = String::from("reload");
res = remove_prefix(&s, "re") // Compile error: s lifecycle is not long enough
}
println!("{}", res);
Copy the code
In this example, when remove_prefix is called, the compiler will say “s is not long enough.” The braces in the code create a new lexical scope, causing the res to have a longer lifetime than the s inside the braces. This does not meet the life cycle requirements in function signatures. Function signatures require that the life cycle of the input parameter be no shorter than that of the return value.
The life cycle in the structure definition
When there are reference members in a structure, there is a potential problem with dangling Pointers and you need to identify lifecycle parameters for the compiler to help check.
struct G<'a>{ m:&'a str}
fn get_g() - > () {let g: G;
{
let s0 = "Hi".to_string();
let s1 = s0.as_str(); // Compile error: borrowed value not alive long enough
g = G{ m: s1 };
}
println!("{}", g.m);
}
Copy the code
In the example above, the structure G contains reference members and cannot be compiled without specifying the lifecycle parameter. The function get_g demonstrates how a lifecycle mismatch can occur in a consumer context.
The lifecycle of a structure is defined to ensure that the lifetime of a reference member in an instance of a structure is not shorter than the lifetime of the instance itself. Otherwise, access to a reference member constitutes access to a dangling pointer if the reference member’s data is destroyed first while the instance of the structure is alive.
In fact, the life cycle parameters of a structure can be compared to the life cycle parameters of a function. The life cycle of a member corresponds to the life cycle of the input parameters of a function, and the life cycle of the structure as a whole corresponds to the life cycle of the return value of a function. All of the previous analysis of function lifecycle parameters can then be applied.
If the structure has method members that return reference parameters, the method also needs to fill in lifecycle parameters. The returned reference source can be an input reference parameter of a method or a reference member of a structure. When doing lifecycle analysis, consider “method input reference parameters” and “structure reference members” as input parameters of the normal function, so that the previous lifecycle analysis methods for normal function parameters and return values can be applied.
Life cycle limits for generics
As mentioned earlier, lifecycle parameters are similar to type limits, such as in code
fn longer<'a>(s1:&'a str, s2:&'a str) - > &'a str
struct G<'a>{ m:&'a str }
Copy the code
In, ‘a appears next to the positional parameter type, one limits the static type of the parameter, and one limits the dynamic time of the parameter. ‘a ‘needs to be declared in the same position as the template parameters, inside the <> parentheses, where generic type parameters are placed.
So, is it ok to change the type to generic? What’s the semantics? What are the usage scenarios?
Let’s look at a code example:
use std::cmp::Ordering;
#[derive(Eq, PartialEq, PartialOrd, Ord)]
struct G<'a, T:Ord>{ m: &'a T }
#[derive(Eq, PartialEq, PartialOrd, Ord)]
struct Value{ v: i32 }
fn longer<'a, T:Ord>(s1: &'a T, s2: &'a T) -> &'a T {
if s1 > s2 { s1 } else { s2 }
}
fn main() {let v0 = Value{ v:12 };
let v1 = Value{ v:15 };
let res_v = longer(&v0, &v1);
println!("{}", res_v.v);/ / 15
let g0 = G{ m: &v0 };
let g1 = G{ m: &v1 };
let res_g = longer(&g0, &g1);/ / 15
println!("{}", res_g.m.v);
}
Copy the code
This example extends the Longer function to any type that implements the Ord trait. Ord is a built-in trait in the core package that implements comparison operations. I won’t go into details here. The longer function compares to the previous version by replacing the type T with the generic parameter T and adding a type qualification T:Ord to T.
The structure G is also extended to accommodate the generic T, but requires that T implement the Ord trait.
From the point of view of code and execution, there is nothing special about T as if it were a normal type, and the lifecycle parameter is still its original semantics.
In fact, however, “&’a T” implies another level of semantics: if T contains reference members, then the lifetime of the reference member is required to be no shorter than the lifetime of the instance T.
As usual, let’s construct a counterexample. The structure G contains a generic reference member inside. We use G for the longer function, but make the reference member inside G shorter than in G. The code is as follows:
fn main() {let v0 = Value{ v:12 };
let v1_ref: &Value; // Define the reference to v1 outside the braces below, intentionally extending the lifetime range of the variable
let res_g: &G<Value>;
{
let v1 = Value{ v:15 };
v1_ref = &v1; // Compile error: lifetime of v1 is not long enough.
let res_v = longer(&v0,v1_ref);
println!("{}",res_v.v);
}
let g0 = G{ m:&v0 };
let g1 = G{ m:v1_ref }; // At this point, v1_ref is already a dangling pointer
res_g = longer(&g0, &g1);
println!("{}", res_g.m.v);
}
Copy the code
The life cycle of variable G1 meets the requirements of the Longer function, but the life cycle of its internal reference member is too short.
This paradigm is triggered in the context of the “caller” check. It is difficult to design the lifecycle of generic parameters in the context of the “function definition or structure definition”. After all, T is just a reference to a type, and there is no specific type when we define it.
(struct G<‘a,T>{m:&’a T}); (struct G<‘a,T>{m:&’a T})
struct G<'a,T:'a>{m:&'a T}
Copy the code
Because T:’a is a clear statement of the semantics. But the first expression is also sufficient (I prove this by contradiction). So the compiler accepts the first, more simplified form of expression.
In summary, the lifecycle of a generic parameter has two meanings: one is the meaning of the generic type as if it were a normal type, and the other is the lifecycle constraint on a reference member within the generic type.
The life cycle of a Trait object
Look at the following code
trait Foo{}
struct Bar{v:i32}
struct Qux<'a>{m:&'a i32}
struct Baz<'a,T>{v:&'a T}
impl Foo for Bar{}
impl<'a> Foo for Qux<'a> {}impl<'a,T> Foo for Baz<'a,T>{}
Copy the code
The structs Bar,Qux, and Baz all implement trait Foo, so the &foo type can accept references to any of the three structs.
We call &foo a Trait object.
Trait objects can be understood as Pointers or references to interfaces or base classes, similar to other object-oriented languages. Pointers to base classes in other OO languages determine their actual type at runtime. Rust has no class inheritance; Pointers or references to traits have a similar effect, and the specific type is determined at runtime. So you don’t know the size at compile time.
Rust traits cannot have non-static data members. Therefore, the Trait itself does not have a reference member whose life cycle is less than the object itself. Therefore, the default life cycle of a Trait object is static. Let’s look at the following three functions:
fn check0() - > &'static Foo { // If 'static 'is not specified, the compiler will report an error, asking for the lifecycle argument and recommending 'static' instead
const b:Bar = Bar{v:0};
&b
}
fn check1<'a> () - > &'a Foo { // If 'a 'is not specified, the compiler will report an error
const b:Bar = Bar{v:0};
&b
}
fn check2(foo:&Foo) -> &Foo {// The life cycle parameter is omitted and the static life cycle is not required
foo
}
fn check3(foo:&'static Foo) -> &'static Foo {
foo
}
fn main() {let bar= Bar{v:0};
check2(&bar); // Compile to pass, indicating that chenk2 input and output parameters are not static lifecycle
//check3(&bar); // Compile error: bar life cycle is not long enough
const bar_c:Bar =Bar{v:0};
check3(&bar_c); // Check3 can only accept static parameters
}
Copy the code
Check0 and check1 indicate that when you return a reference to a Trait object as a function parameter, you need to specify the lifecycle parameter just as you would any other reference type. The life cycle parameter of the function check2 is omitted (as the compiler can infer), but the Trait object in this function is not static, which can be analyzed by the successful execution of check2(bar) in main, because bar is not static.
In fact, at runtime, a Trait object is always dynamically bound to a concrete structure type (such as Bar,Qux,Baz, etc.) that implements the Trait. This concrete type has a lifetime in its context, which can be static or more often non-static. Then the life cycle of the Trait object is also ‘a.
The life cycle of a structure or member | Trait object life cycle | |
---|---|---|
Foo | There is no | ‘static |
Bar | ‘a | ‘a |
Qux<‘a>{m:&’a str} | ‘a | ‘a |
Baz<‘a,T>{v:&’a T} | ‘a | ‘a |
fn qux_update<'a>(qux: &'a mut Qux<'a>, new_value: &'a i32) - > &'a Foo {
qux.v = new_value;
qux
}
let value = 100;
let mut qux = Qux{v: &value};
let new_value = 101;
let muted: &dyn Foo = qux_update(& mutqux, &new_value); The smart pointer version of qux_update is as follows:fn qux_box<'a>(new_value: &'a i32) - >Box<Foo +'a> {
Box::new(Qux{v:new_value})
}
let new_value = 101;
let boxed_qux:Box<dyn Foo> = qux_box(&new_value);
Copy the code
In the smart pointer that is returned, the type of the Box that contains the reference member also needs to specify the life cycle of the data to be boxed. The syntax is to add a life cycle parameter at the location of the boxed type, connected by a “+” sign.
In both versions of the code, a Trait has a static life cycle by default, but in reality its life cycle is determined by the life cycle of the structure that implements the Trait, not much different from the function parameter life cycle described earlier.
7. Ownership and lifecycle of smart Pointers
As shown in Figure 6, references and smart Pointers are both forms of “Pointers” in Rust, so they can both implement the STD ::Borrow ::Borrow Trait. In general, we get a reference to variables in the stack. Variables in the stack have a short lifetime. When the current scope exits, the stack variables in the scope will be collected. If we want the life cycle of a variable to be passed across the current scope or even between threads, it is best to create the data area of the variable binding on the heap.
The scope of variables on the stack is clear at compile time, so the compiler can determine when a variable on the stack will be released. Combined with the life cycle parameter life, the compiler can find the vast majority of misreferences to variables on the stack.
Memory management for variables on the heap is much more complicated than for stack variables. After allocating a chunk of memory on the heap, the compiler cannot determine the lifetime of the memory based on its scope and must specify it explicitly by the consumer. In C language, every chunk of memory allocated through malloc needs to be explicitly freed using free. In C++, new/delete. But when to call free or DELETE is a challenge. Especially when the code is complex, the code that allocates memory and the code that releases memory are not in the same code file, or even not in the same thread, it is hard to avoid mistakes to maintain the allocation and release only by manually tracking the logical relationship of the code.
The idea behind smart Pointers is that the system automatically decides when to reclaim memory for us. The main approach is to “allocate memory on the heap, but the pointer variable to that memory is itself on the stack, so the compiler can catch when the pointer variable leaves scope. At this point, the memory reclamation action is determined. If the pointer variable owns the memory area, the memory is freed, if it is a reference count pointer, the count is reduced, and the count is 0, the memory is reclaimed.
Rust’s Box
is an exclusive ownership pointer, Rc
is a reference count pointer, but the counting process is not thread safe, Arc
provides a thread safe reference count action that can be used across threads.
Let’s look at the definition of Box
pub struct Box<T: ?Sized>(Unique<T>);
pub struct Unique<T: ?Sized>{
pointer: *const T,
_marker: PhantomData<T>,
}
Copy the code
Box itself is a tuple structure that wraps a Unique
, which has a native pointer inside.
(Note: The latest version of Rust’s Box implementation can also specify memory allocators with generic parameters, giving the user control over the actual memory allocation. There is also the reason why multiple layers are encapsulated through Unique, which involves the specifics of the smart pointer implementation, which I won’t discuss here.)
Box does not implement the Copy Trait, which executes the mobile semantics when ownership is transferred.
Example code:
Struct Foo {v:i32}
fn inc(v:& mut Foo) -> &Foo {// Omit the life cycle parameter
v.v = v.v + 1;
v
}
// Return the Box pointer without a lifecycle argument, because the Box pointer is owned and does not become a dangling pointer
fn inc_ptr(mut foo_ptr:Box<Foo>) -> Box<Foo> {// The input parameter and the return parameter each undergo a transfer of ownership
foo_ptr.v = foo_ptr.v + 1;
println!("Ininc_ptr: {p} - {: p}", &foo_ptr, &*foo_ptr);
foo_ptr
}
fn main() {
let foo_ptr1 = Box::new(Foo{v:10});
println!("Foo_ptr1: {p} - {: p}", &foo_ptr1, &*foo_ptr1);
let mut foo_ptr2 = inc_ptr(foo_ptr1);
//println! ("{}",foo_ptr1.v); // Compile error, f0_ptr ownership has been lost
println!("Foo_ptr2: {p} - {: p}", &foo_ptr2, &*foo_ptr2);
inc(foo_ptr2.borrow_mut());// Get a reference to the data in the pointer and call the inc function of the referenced version
println!("{}",foo_ptr2.v);
}
Copy the code
Inc is the reference version and inc_ptr is the pointer version. Change the output of the code to:
Foo_ptr1:0x8dfad0-0x93a5e0 in inc_ptr: 0x8df960-0x93a5e0 foo_ptr2:0x8dfB60-0x93a5e0 12Copy the code
You can see that foo_ptr1 performs an ownership transfer when it enters the inc_ptr function, and again when it returns. So the three Box
variables have different addresses, but their internal data addresses are all the same, pointing to the same memory area.
Box itself has no reference members, but what about the lifecycle problems associated with T if it contains reference members?
Let’s change Foo member to reference member, as follows:
use std::borrow::BorrowMut;
struct Foo<'a>{v:&'a mut i32}
fn inc<'a>(foo:&'a mut Foo<'a- > & >)'a Foo<'a> {// The life cycle cannot be omitted
*foo.v=*foo.v + 1; // Perform the addition operation after dereferencing
foo
}
fn inc_ptr(mut foo_ptr:Box<Foo>) -> Box<Foo> {// The input parameter and the return parameter each undergo a transfer of ownership
*foo_ptr.v = *foo_ptr.v + 1; / Perform the addition operation after dereferencingprintln!("Ininc_ptr: {p} - {: p}", &foo_ptr, &*foo_ptr);
foo_ptr
}
fn main() {let mut value = 10;
let foo_ptr1 = Box::new(Foo{v:& mut value});
println!("Foo_ptr1: {p} - {: p}", &foo_ptr1, &*foo_ptr1);
let mut foo_ptr2 = inc_ptr(foo_ptr1);
//println! ("{}",foo_ptr1.v); // Compile error, f0_ptr ownership has been lost
println!("Foo_ptr2: {p} - {: p}", &foo_ptr2, &*foo_ptr2);
let foo_ref = inc(foo_ptr2.borrow_mut());// Get a reference to the data in the pointer and call the inc function of the referenced version
//println! ("{}",foo_ptr2.v); // A compilation error, unable to get immutable borrows for foo_ptr2.v because mutable borrows already exist
println!("{}", foo_ref.v);
}
Copy the code
The inc function lifecycle of the reference version can no longer be omitted. Since there are two life cycle values when returning a reference to Foo, one is the life cycle of an instance of Foo, and the other is the life cycle of a reference member in Foo, the compiler cannot infer and needs to specify. However, the life cycle of the smart pointer version inc_ptr is still not specified. Instances of Foo are wrapped in smart Pointers, and the lifecycle is managed by Box.
What happens to the life cycle of Box
if Foo is a Trait and the structure that implements it has reference members? The example code is as follows:
trait Foo{
fn inc(&mut self);
fn value(&self) - >i32;
}
struct Bar<'a>{v:&'a mut i32}
impl<'a> Foo for Bar<'a> {
fn inc(&mut self) {* (self.v)=*(self.v)+1
}
fn value(&self) - >i32{*self.v
}
}
fn inc(foo:& mut dyn Foo)->& dyn Foo {// Life cycle parameters are omitted
foo.inc();
foo
}
fn inc_ptr(mut foo_ptr:Box<dyn Foo>) -> Box< dyn Foo> {// The input parameter and the return parameter each undergo a transfer of ownership
foo_ptr.inc();
foo_ptr
}
fn main() {}Copy the code
Reference versions and smart pointer versions have no lifecycle parameters and can be compiled through. However, the main function is empty, meaning that the functions are not used, but the definitions are compiled. I’ll try using the quoted version first:
fn main() {let mut value = 10;
let mut foo1= Bar{v:& mut value};
let foo2 =inc(&mut foo1);
println!("{}", foo2.value()); / / output 11
}
Copy the code
It can compile through and output normally. Try the smart pointer version again:
fn main() {let mut value = 10;
let foo_ptr1 = Box::new(Bar{v:&mut value}); // Compile error: value lifecycle is too short
let mut foo_ptr2 = inc_ptr(foo_ptr1); // Compiler hint: type conversion requires value to be static lifecycle
}
Copy the code
Compilation failed. The error message is that the lifetime of value is too short and needs to be ‘static ‘. Because the Trait object (Box< dyn Foo>) has a static life cycle by default, the compiler concludes that the life cycle of the returned data is too short. Removing the last line inc_ptr will compile normally.
If the definition of inc_ptr is added with the lifecycle parameter, this code can be compiled. The modified inc_ptr is as follows:
fn inc_ptr<'a> (mut foo_ptr:Box<dyn Foo+'a- > >)Box<dyn Foo+'a> {
foo_ptr.inc();
foo_ptr
}
Copy the code
Why is it wrong for the pointer version to have no lifecycle parameters, but not for the reference version to have no lifecycle parameters?
Because the referenced version omits the lifecycle parameters, the full version is written as:
fn inc<'a>(foo:&'a mut dyn Foo)->&'a dyn Foo {
foo.inc();
foo
}
Copy the code
Closures and ownership
The use of closures is not covered here, just ownership related. Compared to normal functions, closures can capture variables in the text in addition to input parameters. Closures also support a move keyword to force the transfer of ownership of the captured variable.
Let’s see if move affects the input parameters:
// The structure Value does not implement the Copy Trait
struct Value{x:i32}
// No parameters are passed as references, so ownership is transferred
let mut v = Value{x:0};
let fun = |p:Value| println!("in closure:{}", p.x);
fun(v);
//println! ("callafterclosure:{}",point.x); // Compile error: ownership has been lost
// As a variable borrowed argument for a closure, the closure definition has no move and no ownership transfer
let mut v = Value{x:0};
let fun = |p:&mut Value| println!("in closure:{}", p.x);
fun(& mut v);
println!("call after closure:{}", v.x);
// Variable borrowings are used as an input parameter to the closure. The closure definition adds move, and ownership is not transferred
let mut v = Value{x:0};
let fun = move |p:& mut Value| println!("in closure:{}", p.x);
fun(& mut v);
println!("call after closure:{}", v.x);
Copy the code
As you can see, when a variable is passed to a closure as an input parameter, the ownership transfer rules are the same as for a normal function. The move keyword has no effect on the reference form of the closure input parameter, and the ownership of the input parameter is not transferred.
For context variables captured by closures, whether ownership is transferred is a little more complicated.
The following table lists more than 10 examples, each slightly different from the ones before and after. Analyzing these differences can help us draw a clearer conclusion.
It is important to first identify which variable is being captured. For example, in example 8, ref_v is an immutable borrow of V, and the closure captures ref_v, then the transfer of ownership has nothing to do with V, and no closure related transfer of ownership will occur in V.
After defining the captured variables, whether to transfer ownership is affected by a combination of three factors:
- How variables are captured (value, immutable borrow, mutable borrow)
- Whether the closure is move qualified
- Whether the type of the captured variable implements the “Copy” Trait
The rules for whether to transfer ownership are described in pseudocode are as follows:
If == value pass {if the type of the captured variable implemented "Copy" does not transfer ownership // example: 9 else transfer ownership // example: 1}} else {// if the closure does not have a move limit that does not transfer ownership // example: 2,3,6,10,12 else {// if the type of the variable that is captured does not transfer ownership // example: // example: 4,5,7,11,13,14}}Copy the code
First determine the capture mode, if the value is passed, equivalent to the variable across the scope, triggering the transfer of ownership. A move is required to trigger a transfer of ownership for a borrowed capture that is active on it. Whether to implement “Copy” is the final judgment. As mentioned earlier, we can think of the bit-copy semantics defined by the Copy Trait as a way to transition execution. The Copy Trait does not participate in the timing of the transition, but only when the final transition is executed.
- The difference between Example 1 and (example 2 and 3) is the capture method.
- The difference between (example 2, Example 3) and Example 4 is the move keyword.
- The difference between Example 6 and Example 7 demonstrates the impact of the move keyword on loan-mode capture.
- Example 8 illustrates the capture of immutable borrows, which are not transferred in any way because immutable borrows implement Copy.
- The difference between Example 8 and Example 11 is that the “immutable borrow” captured in Example 11 does not implement the “Copy” Trait.
- Examples 10 and 11 capture a “mutable borrowing variable” in an “immutable borrowing manner.”
- Examples 12, 13, and 14 demonstrate the effect on smart Pointers, and the judgment logic is consistent.
C++11 closures need to explicitly specify whether they are captured by value or by reference in the closure declaration. Rust is different. How Rust closures capture context variables does not depend on the declaration of the closure, but on how the captured variables are used inside the closure. In fact, the compiler will capture variables in a borrowed way whenever possible (for example, unless it is absolutely impossible, as in example 1.)
The implementation mechanism behind closures, namely Fn,FnMut, and FnOnce, is deliberately omitted. Because we don’t see the actual compiler implementation of the closure when we just use the closure syntax. So we judge the ownership transfer rule only from the closure syntax itself.
9. Ownership issues in a multi-threaded environment
Let’s change example 1 again. The context and closure implementation are unchanged, but the closure is executed in a different thread.
let v = Value{x:1};
let child = thread::spawn(||{ // The compiler reported an error asking for the move keyword
let p = v;
println!("inclosure:{}",p.x)
});
child.join();
Copy the code
At this point, the compiler reports an error asking to add the move keyword to the closure. That is, when a closure acts as an entry function for a thread, it enforces movement semantics on the captured context variable. Let’s look at an ownership system in a multi-threaded environment.
None of the previous discussions dealt with the sharing of variables across threads, but once multiple threads have access to the same variable, things get a little more complicated. There are two issues here, one is still a memory safety issue, that is, the “dangling pointer” and other 5 typical memory safety issues, and the other is the execution order of the thread which causes the execution result to be unpredictable. We will focus only on memory safety here.
First, how do multiple threads share variables? The previous example demonstrated that when a new thread is started, variables in the context are captured by a closure to enable multiple threads to share variables. This is a typical form from which we will illustrate ownership in a multithreaded environment.
Let’s look at the example code:
// The structure Value does not implement the Copy Trait
struct Value{x:i32}
let v = Value{x:1};
let child = thread::spawn(move| | {let p = v;
println!("in closure:{}",p.x)
});
child.join();
//println! ("{}",v.x); // Compile error: ownership has been lost
Copy the code
This is the correct implementation of the previous example, where the variable v is passed to another thread (within the closure), performing the ownership transfer
// Closures capture a reference variable and do not get ownership in any way. Can all references be passed this way in a multi-threaded environment?
let v = Value{x:0};
let ref_v = &v;
let fun = move| | {let p = ref_v;
println!("inclosure:{}",p.x)
};
fun();
println!("callafterclosure:{}",v.x);// Compilation succeeded
Copy the code
In this example, the closure captures a reference to a variable. The references to Rust are those that implement the Copy Trait and are bit-copied into the closure. The variable p.p is immutable and does not acquire ownership, but the immutable borrowing of the variable v is passed inside and outside the closure. So what happens if you make it multithreaded? This is the multithreaded implementation and compiler error:
let v:Value = Value{x:1};
let ref_v = &v; // Compile error: the borrowed value v0 is not long enough
let child = thread::spawn(move| | {let p = ref_v;
println!("in closure:{}",p.x)
}); // Compiler hint: parameter requires 'static life cycle 'when v0 is borrowed
child.join();
Copy the code
The core idea of the compiler is that the lifetime of V is not long enough. When the immutable borrow of V is passed into the closure and used in another thread, the main thread continues to execute, and v can go out of scope at any time and be recycled, and the reference variable in the child thread becomes a dangling pointer. If V is static life cycle, this code will compile and execute normally. Change the first line to:
const v:Value = Value{x:1};
Copy the code
Of course, passing only a reference to the static life cycle is of limited practical use, but most of the time we want to be able to pass non-static data to another thread. Arc
can be used to wrap data. Arc
is a smart pointer to the reference count, and the increment or subtraction of the pointer count is a thread-safe atomic operation, ensuring that the count changes are thread-safe.
// Thread safe reference counting smart pointer Arc can be passed between threads
let v1 = Arc::new(Value{x:1});
let arc_v = v1.clone();
let child = thread::spawn(move| | {let p = arc_v;
println!("Arc<Value>in closure:{}",p.x)
});
child.join();
//println! ("Arc
inclosure:{}",arc_v.x); // There was a compilation error and the ownership of the pointer variable was lost
Copy the code
If Arc
is replaced by Rc
, the compiler reports an error saying “Rc
cannot be passed safely between threads “.
One thing we can conclude from the above example is that, because of the move keyword in the closure definition, when a new thread is started with the closure, the ownership of the variable itself that is captured by the closure is necessarily transferred. Whether the captured variable is a “value variable” or a reference variable or a smart pointer (in the example above the ownership of v,ref_v,arc_v itself is transferred). But with references or Pointers, ownership of the data they refer to is not necessarily transferred.
So for the type struct Value{x:i32} above, its Value can be passed across multiple threads (transfer ownership), and its multiple immutable borrowings can exist across multiple threads simultaneously. Both &value and Arc
can be passed across multiple threads (transferring ownership of the reference or pointer variable itself), but Rc
cannot.
Rc
and Arc
are implemented only by the Rust standard library (STD), not even in the core library. That is, they are not part of Rust’s language mechanisms. So how does the compiler determine that Arc is safe to pass across threads and Rc is not?
Two tag traits are defined in the Marker. Rs file of the Rust core library:
pub unsafe auto trait Sync{}
pub unsafe auto trait Send{}
Copy the code
The implementation of the tag Trait is empty, but the compiler analyzes whether a type implements the tag Trait.
- If a type
T
Implement the”Sync“, meaningT
It can safely be shared across multiple threads by reference. - If a type
T
Implement the”Send“, meaningT
Can be passed safely across thread boundaries.
In the example above, the types Value, &value, and Arc
must all implement the “Send” Trait. Let’s see how that works.
The marker. Rs file also defines two rules:
unsafe impl<T:Sync+?Sized> Send for &T{}
unsafe impl<T:Send+?Sized> Send for & mut T{}
Copy the code
The meanings are:
- If type T implements”Sync“Is automatically the type
&T
To achieve”Send“. - If type T implements”Send“Is automatically the type
&mut T
To achieve”Send“.
Both of these rules are intuitive. For example, implementing “Sync” for the first rule T means that instances of the &t type of the same instance of T can occur in multiple threads. If thread A had an instance of &t first, how can thread B get an instance of &t? There must be A captured context variable sent in some way in thread A, such as A closure. In addition, &T implements the “Copy” Trait. There is no ownership risk, the data is read-only and there is no data competition risk, which is very safe. It’s also logically correct. Why is it not marked unsafe? Let’s put that aside for a moment and look at a few other rules designed for smart Pointers.
impl <T:?Sized>! marker::Send for Rc<T>{}
impl <T:?Sized>! marker::Sync for Rc<T>{}
impl<T:?Sized>! marker::Send for Weak<T>{}
impl<T:?Sized>! marker::Sync for Weak<T>{}
unsafe impl<T:?Sized+Sync+Send>Send for Arc<T>{}
unsafe impl<T:?Sized+Sync+Send>Sync for Arc<T>{}
Copy the code
These rules explicitly specify that Rc
and Weak
cannot implement “Sync” and “Send”.
If Sync and Send are implemented for type T, then Sync and Send are automatically implemented for Arc
. Arc
operates atomically on reference counts, so its clones can be used in multiple threads (i.e., Arc
implements “Sync “and” Send “), but why does it require that T also implements “Sync “and” Send “?
Arc
implements STD :: Borrow, and can retrieve &t instances from Arc
. Arc
instances from multiple threads can also retrieve &t instances from multiple threads, which requires T to implement Sync. Arc
is a smart pointer for reference counting. Any clone of Arc
in a thread is likely to be the last clone. To free memory, it must take ownership of the instance of T wrapped by Arc
, which requires T to be passed across threads.
The Rust compiler does no special processing for Rc
or Arc
, and is not even aware of their existence at the language level. The compiler itself simply inferences based on whether the type implements the “Sync” and “Send” tags. In effect, you can think of the compiler as implementing a rules engine to check the safety of variables passing across threads. The compiler implements “Sync” and “Send” directly for basic types, which exist as “axioms,” and then adds “theorems” to the library code, which are the rules listed above. The user can specify whether to implement Sync and Send, but in most cases the compiler will choose whether to implement Sync or Send by default. When the code is compiled, the compiler can reason according to these axioms and rules. This is the secret of the Rust compiler’s support for cross-thread ownership security.
For the rule engine, “axioms” and “theorems” are self-evident and do not need to be proved. They are declared by the designer, who guarantees their safety. The compiler only guarantees that as long as the theorems and axioms are error-free, its reasoning is error-free. Even though the word “axiom” and “theorem” are both marked unsafe, indicating that the declaration is checked for safety. The user can also define his or her own theorem, making it safe. Instead, negate class rules (implementation! Send or! Sync) are not declared unsafe, because they simply reject variable passing across threads and have no safety issues.
When the compiler determines that “Sync” and “Send” are appropriate for a type, it automatically implements this for them.
For example, the compiler implements Sync by default for the following types:
- Basic types like [u8] and [f64] are [Sync],
- The simple aggregation types that contain them (such as tuples, structures, and names) are also [Sync].
- “Immutable” types (e.g. &t)
- Types with simple inheritance variability, such as Box, Vec
- Most other collection types (if the generic parameter is [Sync], its container is [Sync]).
You can also manually specify it directly, using unsafe.
Below is a UML diagram of the concepts and types associated with cross-thread ownership.
Introduction to Editor:
Gao Xianfeng (nil?) I am a software developer and Rust language enthusiast. I like to work in a planned, organized and efficient way and love the open source culture. I am willing to contribute to the development of Rust Chinese community.