30 days of Rust - Day Eighteen - Concurrency
Modern day computers are very fast at processing data. This is partly thanks to concurrent programming as well as parallel programming. These two methods have been known to be likely to generate a lot of bugs, of which Rust's team is working hard to change.
Concurrent programming refers to different parts of a program executed independently.
Parallel programming refers to different parts of a program executed at the same time.
Rust refers to both of these as just concurrency
to keep things simple.
Day 18 - Concurrency
A single processor on your computer can process multiple tasks so fast that it can seem as if they're all happening at the same time. What really happens under the hood is a processor multi-tasks switching from one task to the other the same way you can be in the kitch washing the dishes while cooking at the same time. Just a whole lot faster.
Splitting computation this way greatly improves performance, with added complexity though. The challenge is having those tasks run in order.
- Race condition : threads are accessing data in an inconsistent order.
- Deadlocks : when two threads are waiting for each other.
- Bugs which are hard to reproduce.
Using multiple threads in rust
In order to create new threads, we use the thread::spawn
and pass it a closure of the code we want it to run on that newly created thread.
use std::thread;
use std::time::Duration;
fn main() {
thread::spawn(|| {
for i in 1..10 {
println!("hi number {} from the spawned thread!", i);
thread::sleep(Duration::from_millis(1));
}
});
for i in 1..5 {
println!("hi number {} from the main thread!", i);
thread::sleep(Duration::from_millis(1));
}
}
So the above code creates a main thread when it is run. Inside the main thread, we call thread::spawn
which creates a new thread running the code inside it. Then thread::sleep
basically makes the thread pause for a bit. Basically while it is paused, the processor will switch to the other thread in the meantime. The output of the above code is;
hi number 1 from the main thread!
hi number 1 from the spawned thread!
hi number 2 from the main thread!
hi number 2 from the spawned thread!
hi number 3 from the main thread!
hi number 3 from the spawned thread!
hi number 4 from the main thread!
hi number 4 from the spawned thread!
hi number 5 from the spawned thread!
Wait a minute. Isn't the spawned thread supposed to run all the way from 1 to 9? Well, yes that's what it's supposed to do but the issue is that once the main thread has completed its job, the entire program shuts down. It doesn't care what else still needs to be performed.
Luckily, there is a way to solve this. The thread::spawn
method returns a JoinHandle<()>
which we can call a .join
method on which will pause the main thread from continuing any further or exiting until the child thread has completed its job.
Let's update our code;
use std::thread;
use std::time::Duration;
fn main() {
let handle = thread::spawn(|| {
for i in 1..10 {
println!("hi number {} from the spawned thread!", i);
thread::sleep(Duration::from_millis(1));
}
});
for i in 1..5 {
println!("hi number {} from the main thread!", i);
thread::sleep(Duration::from_millis(1));
}
handle.join().unwrap();
}
The output will now look like;
hi number 1 from the main thread!
hi number 2 from the main thread!
hi number 1 from the spawned thread!
hi number 3 from the main thread!
hi number 2 from the spawned thread!
hi number 4 from the main thread!
hi number 3 from the spawned thread!
hi number 4 from the spawned thread!
hi number 5 from the spawned thread!
hi number 6 from the spawned thread!
hi number 7 from the spawned thread!
hi number 8 from the spawned thread!
hi number 9 from the spawned thread!
Moving ownership between threads
In order to use variables from the main thread inside the closure, we need to use the move
keyword. What this does is it moves the ownership from the main thread to the child thread.
use std::thread;
fn main() {
let v = vec![1, 2, 3];
let handle = thread::spawn(move || {
println!("Here's a vector: {:?}", v);
});
handle.join().unwrap();
}
So basically, Rust's ownership saved us from having to introduce potential bugs. In the code above, once we use the move
keyword, we are basically getting a guarantee that the main thread will never use the v
variable again. This is because the new thread might change or even drop v
which could cause issues if the main thread tries using it again.
Transferring data between threads using channels
In order to achieve safe concurrency, Rust has message passing which is an idea apparently popularized by the Go programming language. This is implemented by Channels
.
A nice way to think of channels is by the analogy of a river. If you put a rubber duck in a river, it will flow downstream.
A channel has 2 parts, namely a transmitter and receiver. The transmitter puts the rubber duck and the receiver catches it downstream.
use std::sync::mpsc;
use std::thread;
fn main() {
// create a channel
let (sender, receiver) = mpsc::channel();
// create a new thread, create a value, send the value using the channel to main thread
thread::spawn(move || {
let val = String::from("I was created in the child thread, will be sent to main thread");
sender.send(val).unwrap();
});
// receive and print the message from the child thread
let received = receiver.recv().unwrap();
println!("I have received this message from the child thread: {}", received);
}
The code above outputs;
I have received this message from the child thread: I was created in the child thread, will be sent to main thread
Basically what happened is that the main thread created a channel, then thereafter created a child thread and passed to it the sender. The child thread sends a message "val"
downstream which is later received by the main thread.
So imagine if our code has to do some expensive calculations. We could create a new thread and have it work on the calculations and return a result later without blocking the execution of the main thread. Nice.
Rust also allows for sharing state concurrently, which is not as highly recommended and a bit more complex than using channels. You can visit the docs to learn more about it here
Conclusion
Rust has amazing type and ownership checks which help solve the concurrency issue in a relatively safe and easier way. This helps us avoid many hard to resolve bugs known in many other programming languages when it comes to concurrency.
Rust actually titled this fearless concurrency
.
Thanks for spending some time learning Rust with me. Until next time. Peace ✌🏼 .