Rust: The Greatest Innovation in Modern Programming
Abstract
Among the history of programming languages, there always be some point where a revolutionary idea came up and then became a standard. Back in 1945, when the ENIAC coding system being finished, programmers used hard wires to represent the logic (or code in modern days) of the program, which was very complex and time-consuming. Then the assembly language came up in 1947. After that, BASIC emerged as high-level programming language.
These are all revolutionary changes to the computing industry. Though they may hard to understand compared to modern programming languages, they inspired people to create new languages, which have new and useful features compares to their predecessors. For example, C is more human-readable and can control the memory layout for low-level programmers. Then Objective-C and C++ extended C to have object-oriented features.
After a decade, programmers tend to write codes in more high level, such as HTTP servers and GUIs. They found out handling memory operations manually can make things harder than they supposed to, and it can be easily gone wrong. So interpreter-based languages like Java and Python came up, without any manual memory management, they could be used easily. Soon after there's JIT compilers to increase performance for these languages.
Now, programmers are trying hard to improve different aspect in concurrent programming. So languages like Go, Rust and Swift came up. Go focus on the simplicity of the code, and introduced lots of features like goroutine and channel to make concurrent programming easier. Rust and Swift are mainly focused on Safety.
In this post, I will introduce the ideas behind Rust programming language, with a detailed example found in The Rust Programming Language Book.
Ownership
When we are coding in C, we would always use pointers. But most of the security risks are related to pointers (Buffer overflow, Use-After-Free, etc). Rust tried to eliminate the pointers from its design. Instead, it introduced concept of ownership.
Take an example of the C code below, when we are trying to free the ptr1, both ptr1 and ptr2 are not being set to NULL (invalidated), which means, if we are trying to access the data on the heap again by ptr1/ptr2, an Use-After-Free issue will emerge.
Rust tend to solve this problem by ensuring exclusive access for the same resources. In another word, rust will use borrow checker to let only one variable owning a resource. This is called ownership. Now we will try to rewrite the C code into rust.
Rust compiler will complain about the resource.modify
line. That is because the variable resource now does not own a valid SomeResource in that line. This is called ownership transfer, and it is done implicitly. Most common scenarios of this happening are variable reassigning in example1, and function parameter in example 2.
Borrowing
Yet in a lot of cases, we will need to share access to other instances, and with ownership this will be pretty hard to implement. So the concept of borrowing came into place. Borrowing allows temporary transfer of read/write access to another variable, but only one variable can write to an instance. After a write to the resource, all read-only borrowings are invalidated.
Lifetime
Lifetime is a measure to a resource's valid time. It is a notation mostly in function declarations, which helps the compiler to determine the validity of a returned value of a function.
Take an example of the most simple case:
The compiler will complain about the lifetime notation, because it cannot determine whether a result referencing a or one that referencing b is returned. So a lifetime notation is required.
The 'a here is just another name with a single-quote as a mark. We can change the a to whatever we like (except 'static).
Then, when we call the function, the compiler could now determine which references are linked to the return value.
However, if we are certain that the function would only reference a set of variables in the parameter, we can make them appears as the same lifetime.
Note that 'static is a special lifetime keyword, meaning that the variable have only one instance, and it is always valid until the program terminates (The compiler would have mechanism to check on this). There is a special case in generic types, where the keyword means that the variable could live through the current scope.
An easier way to understand
We can use the concept in concurrent programming to understand the concept above more easily. A resource could be seen as a protected resource. To prevent race-condition, only one thread (variable) can write to the resource. A read-write lock is a great solution for this scenario, which can allow multiple threads read for a single resource but only one thread is allowed to write it.
Then, the pattern becomes clear.
Ownership: When we acquire access in a variable, we get write-locked.
Transfer: We can transfer the lock to another thread and therefore lose access of the resource.
Read-only Borrowing: A thread acquires read-lock to a resource.
Mutable Borrowing: A thread acquires write-lock to a resource.
Lifetime: A variable's lifetime is a critical section where the thread can access the variable safely.
An example project
By referencing the book, we used a similar implementation of the thread pool. First, we need to define a Job. We used a Box containing a dyn function with Send ability (trait).
A Box wrapped the inside value to the heap. The reason behind using a box is that a Trait is not a type and must be dynamically dispatched (Otherwise we don't know what is the size of the type, making the compiler impossible to copy/move the memory). We can think of Trait as an interface in Java.
dyn means the value is dynamically dispatched (by an invincible pointer), and the Send trait allows the function type to be Send through multiple threads. Note that these are a declaration of a type, which acts as a constraint of what the Job should look like.
Then, for the multi-thread communication, we used a channel, which is similar to channel in Go. A channel is a smart-pipe that can easily transfer data between threads (or even processes), without ever think of serializing/deserializing as long as the type information is known. We can use Sender and Receiver traits to access the channel.
Since the channel is not thread-safe and cannot be cloned (cannot use features like dup system call), we should protect it with a mutex, and use an atomic reference counter (Arc) to share the receiver between threads. Arc enables the possibility of using a traditional reference counter to manage the resource, which means even an Arc invalidated, the resource would still be in memory as long as another thread still have access to the resource (By clone).
To share the receiver, we need to first clone the Arc, and then move (transfer) it to the closure. In that closure, we will constantly read the Jobs it sent through, and dispatch it.
When the thread pool is shutting down, we need to wait until all threads finishes its work. drop is like a destructor in C++, which is called when the resource is no longer in use. Rust drops resources automatically when the resource is out of scope (lifetime). In this case, when we are dropping the thread pool, we join all threads to ensure that they exited.
The remaining logic is simple, we could just implement a single-thread HTTP server, and then, for each incoming connection, we send a closure to handle the connection.
[Extension] Scope of if let and while let
During the first iteration of my multi-thread server, I noticed something odd. The server does not act as multi-thread at all! This is the original code.
I figured out that after the if let expression, the mutex is still locked, causing other threads to wait when the current thread doing jobs. The fix is pretty simple — Make the cloned_receiver.lock().unwrap().recv()
out of the if let expression.
Out of curiosity, I started to dig up on this matters. Then I found this post https://stackoverflow.com/questions/58968488/why-is-this-mutexguard-not-dropped. The solution is pretty a straight-forward answer.
In my understanding, both while-let and if-let are synthetic sugar of match
expression. According to the rust reference, in match x { A => 1, B => 2 }
, the expression x
is the scrutinee.
A scrutinee is considered a place expression, which represents a memory location. Thus it have the potential to live out the entire program, i.e. having 'static lifetime. Hence, instead of consider the lock as a temporary value, the compiler preserves the lock reference in the whole block, until the routine is finished, causing the lock to be not released.
Therefore, the solution is to make the expression to be a value expression instead of a place expression. So that the lock will then be considered as temporaty value and dropped immediately after the recv().
Last updated