Noob learns Asynchronous Rust.

noob-learns-asynchronous-rust.

I’m basically documenting my journey here.

Prerequisites

Closure ✨

Function that has no name and can capture variables from surrounding scope.

Creating Closure:

This is an example of a simple closure 👇

let increment = |x| x+1;

Here, whatever is written inside the bracket is the input, and whatever is written blatantly outside; is the output.

Using Closure:

println!("increment of 2 is {}", increment(2));

Syntax & Example:

If the output is static:

let name = |input| output;

If the output is to be evaluated:

let name = |input| -> returnType { expression };

Here’s a proper example👇

let age_allowed = |x: u8| -> &'static str { if x > 17 { "pass" } else { "underage" } };
println!("{}", age_allowed(20));

Thread ✨

Group of code that executes to fulfill a certain functionality.

Naturally, all code runs on the main thread. However, we can spawn additional threads to run more code.

We will learn about ‘uses of threads’ later on, right now we will just cover ‘how to use’ threads.

use std::thread;

fn main() {

    thread::spawn(|| {
        println!("what is real?");
    })

    // thread is a module which has a function called 'spawn'
    // spawn() takes any closure as argument
    // closure returns an evaluated output
    // spawn() spawns a thread and executes the output in it

    println!("this is main");
}

The above code is a basic example of using threads in Rust.

From the above example, sometimes you’ll see “what is real?” getting printed, and sometimes it won’t. This happens because the spawned thread doesn’t get a chance to execute everytime.
This behaviour is entirely depended upon how your device’s OS schedules the threads. This is why we see inconsistent behavior.

To be more clear, the only time spawned thread gets executed is when the main thread is on the wait. In order to maintain consistency, we’ll manually put the main thread on hold until the spawned thread is done executing by:-

Explicitly defining an amount of time for main thread to sleep:

thread::sleep(Duration::from_secs(1));

Using functions that make sure main thread is on wait until specified spawned thread(s) are done executing:

thread::spawn(|| { /* expression */ }).join().unwrap();

Variables & Spawned Threads ✨

Suppose you’re meant to write a code for downloading something. We all know that download occurs in the background without interrupting anything else that you’re doing, meaning you’ll likely spawn a thread for it. 

println!("Which one do you wish to download?: 1 or 2");

You’ll capture user input into a variable from here (duh), but would it be safe to just directly pass on this variable to spawned thread?

OWNERSHIP RULES

  • Whenever you reference a variable, it’s called borrowing.
  • When you don’t, it’s called moving the value or ownership transfer.
  • Now, the problem with references are that they can be invalidated. Suppose creating a variable and referencing it to a function. While that function may still be running; the variable itself may no longer exist (used up or dropped). In such case, the reference will be invalidated; therefore lifetimes exist.
  • However, lifetimes tend to complicate the code therefore we prefer to move the variable into the closures.

BACK TO TOPIC

thread::spawn(move || {
        println!("Downloading option {}...", input);
    }).join().unwrap();

So, this is how you move the value into the closure.

Arc ✨

Due to “Ownership Rules”, once we have moved a variable to a thread, we can no longer use it in other threads.

use::std::thread;

fn main(){
    let something = 5;
    thread::spawn(move || {
            println!("{}", something);
        }).join().unwrap();
    thread::spawn(move || {
            println!("{}", something);  //ERROR
        }).join().unwrap();
}

To get past this, we use a wrapping datatype called Arc.

  • Arc stands for Atomic Reference Count.
  • Since the variable is moved to one thread so it obviously cannot be used in other threads.
  • In order to make our data sharable to multiple-threads, we will wrap it inside a data structure that grants multiple ownership for a variable. Arc is exactly that.
use std::thread;
use std::sync::Arc;

fn main(){
    let something = Arc::new(5);


    let c1 = Arc::clone(&something);
    thread::spawn(move || {

            println!("{}", c1);

        }).join().unwrap();

    let c2 = Arc::clone(&something);
    thread::spawn(move || {

            println!("{}", c2);

        }).join().unwrap();
}

Cool! so now we can share data between multiple threads 🏆.

Interior Mutability - Mutex ✨

If you notice, Arc doesn’t grant mutability alongside of ownership, meaning all the references passed to Arc are immutable. You cannot modify the variable. This is, however, by design.

use std::thread;
use std::sync::Arc;

fn main() {
    let v = Arc::new(vec![1,2,3]);

    let r1 = Arc::clone(&v);
    thread::spawn(move|| {
        r1.push(4);     //ERROR
    }).join().unwrap();
}

“WHY ?”

If a variable is mutable and shared with multiple threads then multiple threads modifying that variable at the same time could lead to race-conditions and crash the program.

To avoid this, variables cloned via Arc from main thread to another threads are immutable.

“But then how will we modify our shared-data?”

Here comes the use of Mutex.
Mutex is technically a datastructure, but in layman’s term it’s a lock that ensure that a variable can be accessed by only one entity at a time. Hence we use another wrapping datatype called Mutex here.
Mutex allows the data to be mutable, but with a twist.

Syntax:

use std::sync::Mutex;

fn main() {
    let m = Mutex::new(5);
    {
        let mut num = m.lock().unwrap(); //locking the data so no other thread can use it at the moment. Using unwrap() because lock() returns result.
        *num = 6;
    }
    println!("m = {:?}", m);
}

MUTEX OVERVIEW

  • Think of the shared-data as washroom, whenever you’d visit the washroom you’d lock the door so nobody else uses it until you’re done. Likewise, whenever you will write on shared-data, you will apply a lock method on it so no other thread uses it.
  • So in hindsight, Mutex does allow mutability but only once you have applied the “lock” method on the shared-data. This is called interior mutability.
  • This is achieved because lock method essentially gives a mutable reference to the data inside the mutex, which is protected by the type system from being accessed without acquiring the lock.
  • The lock method also returns a result so that if a thread is trying to access a locked shared-data then it can be handled eloquently.

You can have a more technical read about Mutex here.

BACK TO TOPIC

use std::thread;
use std::sync::{Arc, Mutex};

fn main() {

    let v = Arc::new(Mutex::new(vec![1,2,3]));

    let r1 = Arc::clone(&v);
    thread::spawn(move|| {

        let mut data = r1.lock().unwrap();
        data.push(4);

    }).join().unwrap();

    let r2 = Arc::clone(&v);
    thread::spawn(move|| {

        let mut data = r2.lock().unwrap();
        data.push(5);

    }).join().unwrap();

    println!("{ :?}", v.lock().unwrap());    
}

So, in hindsight, if your data is read-only then you don’t need Mutex.

Theory of Asynchronous Programming

Tasks ✨

Task refers to work or a specific action that a computer program is designed to perform.

Tasks can be divided into 2 categories:-

  • CPU bound: Require a lot of computational resources. They spend most of their time using the CPU (solving heavy computaional, processing data, etc.).
  • Input/Output bound: How a program communicates with external devices, such as keyboards, mice, disks, networks, etc.

Asynchronous Programming is more associated with I/O than CPU.

I/O Handling ✨ 

Blocking & Non-blocking are two different ways of handling I/O bound tasks in a computer program.

Defination

Blocking I/O: Program that waits for the I/O operations to complete before continuing its execution. For example, if the program wants to read some data from a file, it will call a function that blocks current thread until the data is available for it to read. This means that the program cannot do anything else until it reads the data.
Non-blocking I/O: Program that does not waits for the I/O operation to complete, but returns some kind of result or promise which describes the progress. For example, if the program wants to read some data from a file, it will call a function that instantly returns a result. Result will show ‘pending’ if the data is not available yet. This means that the program can do other things while the I/O operation is in progress.

In Layman’s Terms:

Blocking I/O is like ordering a pizza and waiting for it to be delivered before doing anything else.
Non-blocking I/O is like ordering a pizza and doing other things while the pizza is being prepared and delivered. You can check the status of the pizza from time to time, but you don’t have to wait for it.

The Need for Multi-Threading ✨

Everthing has limits, and so does a thread. 
‘Bottleneck’ refers to that point which represents the peak computational capactity of a thread. If a process were to exceed it, it will cause performance degradation and many more issues.

So when dealing with CPU-bound tasks that demand significant computational power, offloading them to separate threads can prevent bottlenecks. A program can distribute the computational load to other threads, preventing a single thread from monopolizing resources and potentially improving overall performance.

Hence, mulit-threading is only needed when there’s a high computational load on the main thread.

Summary ✨

  • Synchronous: By default, a program handles I/O operations by blocking.Everything runs on the main thread and execution occurs one-by-one.
  • Asynchronous & Single-Threaded: It provides an alternative approach where I/O operations can be non-blocking. Everything runs on main thread and execution may occur in parallel.
  • Asynchronous & Multi-Threaded: When an application requires both I/O and CPU-bound tasks then more threads are spawned to prevent bottleneck.
    I/O operations and CPU-bound tasks run on different threads, and execution is complex.

Starting Asynchronous Programming

We are now equipped with neccesary knowledge to actually start doing asynchronous programming. Honestly, I couldn’t find any resouce more useful for this than the video below:

Anything else that I will write here in continuation will indirectly be just a rip-off of the above video anyway (because the video is so good).

The above video contains topic:

  • Programming in Synchronous, Asynchronous Single-Threaded & –
  • Asynchronous Multi-Threaded.
  • Futures
  • Tokio Runtime
  • Tokio Features

With that, it’ll be all. 🎓
Rest of expertise will only come from working on real-world projects. Some project ideas are - Web Scraper, Chat Application, File Downloader & REST API.

Ciao! 🍹

Total
0
Shares
Leave a Reply

Your email address will not be published. Required fields are marked *

Previous Post
10-websites-to-learn-anything-for-free-on-the-internet

10 Websites To Learn Anything For Free On The Internet

Next Post
5-underrated-skills-that-will-get-you-promoted

5 Underrated Skills That Will Get You Promoted

Related Posts