Merge branch 'master' into typo-future

This commit is contained in:
Carl Fredrik Samson
2020-04-11 00:27:21 +02:00
committed by GitHub
23 changed files with 1163 additions and 1018 deletions

View File

@@ -1,6 +1,6 @@
# Implementing Futures - main example
We'll create our own `Futures` together with a fake reactor and a simple
We'll create our own Futures together with a fake reactor and a simple
executor which allows you to edit, run an play around with the code right here
in your browser.
@@ -23,9 +23,9 @@ Let's start off by getting all our imports right away so you can follow along
```rust, noplaypen, ignore
use std::{
future::Future, pin::Pin, sync::{mpsc::{channel, Sender}, Arc, Mutex},
task::{Context, Poll, RawWaker, RawWakerVTable, Waker},
thread::{self, JoinHandle}, time::{Duration, Instant}
future::Future, pin::Pin, sync::{ mpsc::{channel, Sender}, Arc, Mutex,},
task::{Context, Poll, RawWaker, RawWakerVTable, Waker}, mem,
thread::{self, JoinHandle}, time::{Duration, Instant}, collections::HashMap
};
```
@@ -51,8 +51,8 @@ a `Future` has resolved and should be polled again.
fn block_on<F: Future>(mut future: F) -> F::Output {
// the first thing we do is to construct a `Waker` which we'll pass on to
// the `reactor` so it can wake us up when an event is ready.
let mywaker = Arc::new(MyWaker{ thread: thread::current() });
// the `reactor` so it can wake us up when an event is ready.
let mywaker = Arc::new(MyWaker{ thread: thread::current() });
let waker = waker_into_waker(Arc::into_raw(mywaker));
// The context struct is just a wrapper for a `Waker` object. Maybe in the
@@ -69,10 +69,9 @@ fn block_on<F: Future>(mut future: F) -> F::Output {
// We poll in a loop, but it's not a busy loop. It will only run when
// an event occurs, or a thread has a "spurious wakeup" (an unexpected wakeup
// that can happen for no good reason).
let val = loop {
let val = loop {
match Future::poll(future, &mut cx) {
// when the Future is ready we're finished
Poll::Ready(val) => break val,
@@ -88,26 +87,26 @@ In all the examples you'll see in this chapter I've chosen to comment the code
extensively. I find it easier to follow along that way so I'll not repeat myself
here and focus only on some important aspects that might need further explanation.
Now that you've read so much about `Generators` and `Pin` already this should
Now that you've read so much about `Generator`s and `Pin` already this should
be rather easy to understand. `Future` is a state machine, every `await` point
is a `yield` point. We could borrow data across `await` points and we meet the
exact same challenges as we do when borrowing across `yield` points.
> `Context` is just a wrapper around the `Waker`. At the time of writing this
book it's nothing more. In the future it might be possible that the `Context`
object will do more than just wrapping a `Future` so having this extra
object will do more than just wrapping a `Future` so having this extra
abstraction gives some flexibility.
As explained in the [chapter about generators](./3_generators_pin.md), we use
`Pin` and the guarantees that give us to allow `Futures` to have self
`Pin` and the guarantees that give us to allow `Future`s to have self
references.
## The `Future` implementation
Futures has a well defined interface, which means they can be used across the
entire ecosystem.
entire ecosystem.
We can chain these `Futures` so that once a **leaf-future** is
We can chain these `Future`s so that once a **leaf-future** is
ready we'll perform a set of operations until either the task is finished or we
reach yet another **leaf-future** which we'll wait for and yield control to the
scheduler.
@@ -131,9 +130,8 @@ struct MyWaker {
#[derive(Clone)]
pub struct Task {
id: usize,
reactor: Arc<Mutex<Reactor>>,
reactor: Arc<Mutex<Box<Reactor>>>,
data: u64,
is_registered: bool,
}
// These are function definitions we'll use for our waker. Remember the
@@ -173,48 +171,57 @@ fn waker_into_waker(s: *const MyWaker) -> Waker {
}
impl Task {
fn new(reactor: Arc<Mutex<Reactor>>, data: u64, id: usize) -> Self {
Task {
id,
reactor,
data,
is_registered: false,
}
fn new(reactor: Arc<Mutex<Box<Reactor>>>, data: u64, id: usize) -> Self {
Task { id, reactor, data }
}
}
// This is our `Future` implementation
impl Future for Task {
// The output for our kind of `leaf future` is just an `usize`. For other
// futures this could be something more interesting like a byte array.
type Output = usize;
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
// Poll is the what drives the state machine forward and it's the only
// method we'll need to call to drive futures to completion.
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
// We need to get access the reactor in our `poll` method so we acquire
// a lock on that.
let mut r = self.reactor.lock().unwrap();
// we check with the `Reactor` if this future is in its "readylist"
// i.e. if it's `Ready`
// First we check if the task is marked as ready
if r.is_ready(self.id) {
// if it is, we return the data. In this case it's just the ID of
// the task since this is just a very simple example.
// If it's ready we set its state to `Finished`
*r.tasks.get_mut(&self.id).unwrap() = TaskState::Finished;
Poll::Ready(self.id)
} else if self.is_registered {
// If it isn't finished we check the map we have stored in our Reactor
// over id's we have registered and see if it's there
} else if r.tasks.contains_key(&self.id) {
// If the future is registered alredy, we just return `Pending`
// This is important. The docs says that on multiple calls to poll,
// only the Waker from the Context passed to the most recent call
// should be scheduled to receive a wakeup. That's why we insert
// this waker into the map (which will return the old one which will
// get dropped) before we return `Pending`.
r.tasks.insert(self.id, TaskState::NotReady(cx.waker().clone()));
Poll::Pending
} else {
// If we get here, it must be the first time this `Future` is polled
// so we register a task with our `reactor`
// If it's not ready, and not in the map it's a new task so we
// register that with the Reactor and return `Pending`
r.register(self.data, cx.waker().clone(), self.id);
// oh, we have to drop the lock on our `Mutex` here because we can't
// have a shared and exclusive borrow at the same time
drop(r);
self.is_registered = true;
Poll::Pending
}
// Note that we're holding a lock on the `Mutex` which protects the
// Reactor all the way until the end of this scope. This means that
// even if our task were to complete immidiately, it will not be
// able to call `wake` while we're in our `Poll` method.
// Since we can make this guarantee, it's now the Executors job to
// handle this possible race condition where `Wake` is called after
// `poll` but before our thread goes to sleep.
}
}
```
@@ -230,9 +237,9 @@ is just increasing the refcount in this case.
Dropping a `Waker` is as easy as decreasing the refcount. Now, in special
cases we could choose to not use an `Arc`. So this low-level method is there
to allow such cases.
to allow such cases.
Indeed, if we only used `Arc` there is no reason for us to go through all the
Indeed, if we only used `Arc` there is no reason for us to go through all the
trouble of creating our own `vtable` and a `RawWaker`. We could just implement
a normal trait.
@@ -245,13 +252,13 @@ The reactor will often be a global resource which let's us register interests
without passing around a reference.
> ### Why using thread park/unpark is a bad idea for a library
>
>
> It could deadlock easily since anyone could get a handle to the `executor thread`
> and call park/unpark on it.
>
>
> 1. A future could call `unpark` on the executor thread from a different thread
> 2. Our `executor` thinks that data is ready and wakes up and polls the future
> 3. The future is not ready yet when polled, but at that exact same time the
> 3. The future is not ready yet when polled, but at that exact same time the
> `Reactor` gets an event and calls `wake()` which also unparks our thread.
> 4. This could happen before we go to sleep again since these processes
> run in parallel.
@@ -277,13 +284,13 @@ Since concurrency mostly makes sense when interacting with the outside world (or
at least some peripheral), we need something to actually abstract over this
interaction in an asynchronous way.
This is the `Reactors` job. Most often you'll see reactors in Rust use a library
This is the Reactors job. Most often you'll see reactors in Rust use a library
called [Mio][mio], which provides non blocking APIs and event notification for
several platforms.
The reactor will typically give you something like a `TcpStream` (or any other
resource) which you'll use to create an I/O request. What you get in return is a
`Future`.
`Future`.
>If our reactor did some real I/O work our `Task` in would instead be represent
>a non-blocking `TcpStream` which registers interest with the global `Reactor`.
@@ -303,6 +310,15 @@ for the sake of this example.
**Our Reactor will look like this:**
```rust, noplaypen, ignore
// This is a "fake" reactor. It does no real I/O, but that also makes our
// code possible to run in the book and in the playground
// The different states a task can have in this Reactor
enum TaskState {
Ready,
NotReady(Waker),
Finished,
}
// This is a "fake" reactor. It does no real I/O, but that also makes our
// code possible to run in the book and in the playground
struct Reactor {
@@ -312,106 +328,118 @@ struct Reactor {
dispatcher: Sender<Event>,
handle: Option<JoinHandle<()>>,
// This is a list of tasks that are ready, which means they should be polled
// for data.
readylist: Arc<Mutex<Vec<usize>>>,
// This is a list of tasks
tasks: HashMap<usize, TaskState>,
}
// We just have two kind of events. An event called `Timeout`
// and a `Close` event to close down our reactor.
// This represents the Events we can send to our reactor thread. In this
// example it's only a Timeout or a Close event.
#[derive(Debug)]
enum Event {
Close,
Timeout(Waker, u64, usize),
Timeout(u64, usize),
}
impl Reactor {
fn new() -> Self {
// The way we register new events with our reactor is using a regular
// channel
// We choose to return an atomic reference counted, mutex protected, heap
// allocated `Reactor`. Just to make it easy to explain... No, the reason
// we do this is:
//
// 1. We know that only thread-safe reactors will be created.
// 2. By heap allocating it we can obtain a reference to a stable address
// that's not dependent on the stack frame of the function that called `new`
fn new() -> Arc<Mutex<Box<Self>>> {
let (tx, rx) = channel::<Event>();
let readylist = Arc::new(Mutex::new(vec![]));
let rl_clone = readylist.clone();
let reactor = Arc::new(Mutex::new(Box::new(Reactor {
dispatcher: tx,
handle: None,
tasks: HashMap::new(),
})));
// Notice that we'll need to use `weak` reference here. If we don't,
// our `Reactor` will not get `dropped` when our main thread is finished
// since we're holding internal references to it.
// This `Vec` will hold handles to all the threads we spawn so we can
// join them later on and finish our programm in a good manner
let mut handles = vec![];
// Since we're collecting all `JoinHandles` from the threads we spawn
// and make sure to join them we know that `Reactor` will be alive
// longer than any reference held by the threads we spawn here.
let reactor_clone = Arc::downgrade(&reactor);
// This will be the "Reactor thread"
// This will be our Reactor-thread. The Reactor-thread will in our case
// just spawn new threads which will serve as timers for us.
let handle = thread::spawn(move || {
let mut handles = vec![];
// This simulates some I/O resource
for event in rx {
let rl_clone = rl_clone.clone();
println!("REACTOR: {:?}", event);
let reactor = reactor_clone.clone();
match event {
// If we get a close event we break out of the loop we're in
Event::Close => break,
Event::Timeout(waker, duration, id) => {
Event::Timeout(duration, id) => {
// When we get an event we simply spawn a new thread
// which will simulate some I/O resource...
// We spawn a new thread that will serve as a timer
// and will call `wake` on the correct `Waker` once
// it's done.
let event_handle = thread::spawn(move || {
//... by sleeping for the number of seconds
// we provided when creating the `Task`.
thread::sleep(Duration::from_secs(duration));
// When it's done sleeping we put the ID of this task
// on the "readylist"
rl_clone.lock().map(|mut rl| rl.push(id)).unwrap();
// Then we call `wake` which will wake up our
// executor and start polling the futures
waker.wake();
let reactor = reactor.upgrade().unwrap();
reactor.lock().map(|mut r| r.wake(id)).unwrap();
});
handles.push(event_handle);
}
}
}
// When we exit the Reactor we first join all the handles on
// the child threads we've spawned so we catch any panics and
// release any resources.
for handle in handles {
handle.join().unwrap();
}
// This is important for us since we need to know that these
// threads don't live longer than our Reactor-thread. Our
// Reactor-thread will be joined when `Reactor` gets dropped.
handles.into_iter().for_each(|handle| handle.join().unwrap());
});
reactor.lock().map(|mut r| r.handle = Some(handle)).unwrap();
reactor
}
Reactor {
readylist,
dispatcher: tx,
handle: Some(handle),
// The wake function will call wake on the waker for the task with the
// corresponding id.
fn wake(&mut self, id: usize) {
self.tasks.get_mut(&id).map(|state| {
// No matter what state the task was in we can safely set it
// to ready at this point. This lets us get ownership over the
// the data that was there before we replaced it.
match mem::replace(state, TaskState::Ready) {
TaskState::NotReady(waker) => waker.wake(),
TaskState::Finished => panic!("Called 'wake' twice on task: {}", id),
_ => unreachable!()
}
}).unwrap();
}
// Register a new task with the reactor. In this particular example
// we panic if a task with the same id get's registered twice
fn register(&mut self, duration: u64, waker: Waker, id: usize) {
if self.tasks.insert(id, TaskState::NotReady(waker)).is_some() {
panic!("Tried to insert a task with id: '{}', twice!", id);
}
self.dispatcher.send(Event::Timeout(duration, id)).unwrap();
}
fn register(&mut self, duration: u64, waker: Waker, data: usize) {
// registering an event is as simple as sending an `Event` through
// the channel.
self.dispatcher
.send(Event::Timeout(waker, duration, data))
.unwrap();
}
// We send a close event to the reactor so it closes down our reactor-thread
fn close(&mut self) {
self.dispatcher.send(Event::Close).unwrap();
}
// We need a way to check if any event's are ready. This will simply
// look through the "readylist" for an event macthing the ID we want to
// check for.
fn is_ready(&self, id_to_check: usize) -> bool {
self.readylist
.lock()
.map(|rl| rl.iter().any(|id| *id == id_to_check))
.unwrap()
// We simply checks if a task with this id is in the state `TaskState::Ready`
fn is_ready(&self, id: usize) -> bool {
self.tasks.get(&id).map(|state| match state {
TaskState::Ready => true,
_ => false,
}).unwrap_or(false)
}
}
// When our `Reactor` is dropped we join the reactor thread with the thread
// owning our `Reactor` so we catch any panics and release all resources.
// It's not needed for this to work, but it really is a best practice to join
// all threads you spawn.
impl Drop for Reactor {
fn drop(&mut self) {
self.handle.take().map(|h| h.join().unwrap()).unwrap();
@@ -430,21 +458,17 @@ which you can edit and change the way you like.
```rust, edition2018
# use std::{
# future::Future, pin::Pin, sync::{mpsc::{channel, Sender}, Arc, Mutex},
# task::{Context, Poll, RawWaker, RawWakerVTable, Waker},
# thread::{self, JoinHandle}, time::{Duration, Instant}
# future::Future, pin::Pin, sync::{ mpsc::{channel, Sender}, Arc, Mutex,},
# task::{Context, Poll, RawWaker, RawWakerVTable, Waker}, mem,
# thread::{self, JoinHandle}, time::{Duration, Instant}, collections::HashMap
# };
#
#
fn main() {
// This is just to make it easier for us to see when our Future was resolved
let start = Instant::now();
// Many runtimes create a glocal `reactor` we pass it as an argument
let reactor = Reactor::new();
// Since we'll share this between threads we wrap it in a
// atmically-refcounted- mutex.
let reactor = Arc::new(Mutex::new(reactor));
// We create two tasks:
// - first parameter is the `reactor`
@@ -482,157 +506,171 @@ fn main() {
// ends nicely.
reactor.lock().map(|mut r| r.close()).unwrap();
}
# // ============================= EXECUTOR ====================================
# fn block_on<F: Future>(mut future: F) -> F::Output {
# let mywaker = Arc::new(MyWaker{ thread: thread::current() });
# let mywaker = Arc::new(MyWaker {
# thread: thread::current(),
# });
# let waker = waker_into_waker(Arc::into_raw(mywaker));
# let mut cx = Context::from_waker(&waker);
#
# // SAFETY: we shadow `future` so it can't be accessed again.
# let mut future = unsafe { Pin::new_unchecked(&mut future) };
# let val = loop {
# let pinned = unsafe { Pin::new_unchecked(&mut future) };
# match Future::poll(pinned, &mut cx) {
# match Future::poll(future.as_mut(), &mut cx) {
# Poll::Ready(val) => break val,
# Poll::Pending => thread::park(),
# };
# };
# val
# }
#
#
# // ====================== FUTURE IMPLEMENTATION ==============================
# #[derive(Clone)]
# struct MyWaker {
# thread: thread::Thread,
# }
#
#
# #[derive(Clone)]
# pub struct Task {
# id: usize,
# reactor: Arc<Mutex<Reactor>>,
# reactor: Arc<Mutex<Box<Reactor>>>,
# data: u64,
# is_registered: bool,
# }
#
#
# fn mywaker_wake(s: &MyWaker) {
# let waker_ptr: *const MyWaker = s;
# let waker_arc = unsafe {Arc::from_raw(waker_ptr)};
# let waker_arc = unsafe { Arc::from_raw(waker_ptr) };
# waker_arc.thread.unpark();
# }
#
#
# fn mywaker_clone(s: &MyWaker) -> RawWaker {
# let arc = unsafe { Arc::from_raw(s).clone() };
# let arc = unsafe { Arc::from_raw(s) };
# std::mem::forget(arc.clone()); // increase ref count
# RawWaker::new(Arc::into_raw(arc) as *const (), &VTABLE)
# }
#
#
# const VTABLE: RawWakerVTable = unsafe {
# RawWakerVTable::new(
# |s| mywaker_clone(&*(s as *const MyWaker)), // clone
# |s| mywaker_wake(&*(s as *const MyWaker)), // wake
# |s| mywaker_wake(*(s as *const &MyWaker)), // wake by ref
# |s| drop(Arc::from_raw(s as *const MyWaker)), // decrease refcount
# |s| mywaker_clone(&*(s as *const MyWaker)), // clone
# |s| mywaker_wake(&*(s as *const MyWaker)), // wake
# |s| mywaker_wake(*(s as *const &MyWaker)), // wake by ref
# |s| drop(Arc::from_raw(s as *const MyWaker)), // decrease refcount
# )
# };
#
#
# fn waker_into_waker(s: *const MyWaker) -> Waker {
# let raw_waker = RawWaker::new(s as *const (), &VTABLE);
# unsafe { Waker::from_raw(raw_waker) }
# }
#
#
# impl Task {
# fn new(reactor: Arc<Mutex<Reactor>>, data: u64, id: usize) -> Self {
# Task {
# id,
# reactor,
# data,
# is_registered: false,
# }
# fn new(reactor: Arc<Mutex<Box<Reactor>>>, data: u64, id: usize) -> Self {
# Task { id, reactor, data }
# }
# }
#
#
# impl Future for Task {
# type Output = usize;
# fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
# fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
# let mut r = self.reactor.lock().unwrap();
# if r.is_ready(self.id) {
# println!("POLL: TASK {} IS READY", self.id);
# *r.tasks.get_mut(&self.id).unwrap() = TaskState::Finished;
# Poll::Ready(self.id)
# } else if self.is_registered {
# } else if r.tasks.contains_key(&self.id) {
# println!("POLL: REPLACED WAKER FOR TASK: {}", self.id);
# r.tasks.insert(self.id, TaskState::NotReady(cx.waker().clone()));
# Poll::Pending
# } else {
# println!("POLL: REGISTERED TASK: {}, WAKER: {:?}", self.id, cx.waker());
# r.register(self.data, cx.waker().clone(), self.id);
# drop(r);
# self.is_registered = true;
# Poll::Pending
# }
# }
# }
#
#
# // =============================== REACTOR ===================================
# enum TaskState {
# Ready,
# NotReady(Waker),
# Finished,
# }
# struct Reactor {
# dispatcher: Sender<Event>,
# handle: Option<JoinHandle<()>>,
# readylist: Arc<Mutex<Vec<usize>>>,
# tasks: HashMap<usize, TaskState>,
# }
#
# #[derive(Debug)]
# enum Event {
# Close,
# Timeout(Waker, u64, usize),
# Timeout(u64, usize),
# }
#
#
# impl Reactor {
# fn new() -> Self {
# fn new() -> Arc<Mutex<Box<Self>>> {
# let (tx, rx) = channel::<Event>();
# let readylist = Arc::new(Mutex::new(vec![]));
# let rl_clone = readylist.clone();
# let mut handles = vec![];
# let reactor = Arc::new(Mutex::new(Box::new(Reactor {
# dispatcher: tx,
# handle: None,
# tasks: HashMap::new(),
# })));
#
# let reactor_clone = Arc::downgrade(&reactor);
# let handle = thread::spawn(move || {
# let mut handles = vec![];
# // This simulates some I/O resource
# for event in rx {
# println!("REACTOR: {:?}", event);
# let rl_clone = rl_clone.clone();
# let reactor = reactor_clone.clone();
# match event {
# Event::Close => break,
# Event::Timeout(waker, duration, id) => {
# Event::Timeout(duration, id) => {
# let event_handle = thread::spawn(move || {
# thread::sleep(Duration::from_secs(duration));
# rl_clone.lock().map(|mut rl| rl.push(id)).unwrap();
# waker.wake();
# let reactor = reactor.upgrade().unwrap();
# reactor.lock().map(|mut r| r.wake(id)).unwrap();
# });
#
# handles.push(event_handle);
# }
# }
# }
#
# for handle in handles {
# handle.join().unwrap();
# }
# handles.into_iter().for_each(|handle| handle.join().unwrap());
# });
# reactor.lock().map(|mut r| r.handle = Some(handle)).unwrap();
# reactor
# }
#
# Reactor {
# readylist,
# dispatcher: tx,
# handle: Some(handle),
# fn wake(&mut self, id: usize) {
# self.tasks.get_mut(&id).map(|state| {
# match mem::replace(state, TaskState::Ready) {
# TaskState::NotReady(waker) => waker.wake(),
# TaskState::Finished => panic!("Called 'wake' twice on task: {}", id),
# _ => unreachable!()
# }
# }).unwrap();
# }
#
# fn register(&mut self, duration: u64, waker: Waker, id: usize) {
# if self.tasks.insert(id, TaskState::NotReady(waker)).is_some() {
# panic!("Tried to insert a task with id: '{}', twice!", id);
# }
# self.dispatcher.send(Event::Timeout(duration, id)).unwrap();
# }
#
# fn register(&mut self, duration: u64, waker: Waker, data: usize) {
# self.dispatcher
# .send(Event::Timeout(waker, duration, data))
# .unwrap();
# }
#
#
# fn close(&mut self) {
# self.dispatcher.send(Event::Close).unwrap();
# }
#
# fn is_ready(&self, id_to_check: usize) -> bool {
# self.readylist
# .lock()
# .map(|rl| rl.iter().any(|id| *id == id_to_check))
# .unwrap()
# fn is_ready(&self, id: usize) -> bool {
# self.tasks.get(&id).map(|state| match state {
# TaskState::Ready => true,
# _ => false,
# }).unwrap_or(false)
# }
# }
#
#
# impl Drop for Reactor {
# fn drop(&mut self) {
# self.handle.take().map(|h| h.join().unwrap()).unwrap();
@@ -640,11 +678,10 @@ fn main() {
# }
```
I added a debug printout of the events the reactor registered interest for so we can observe
two things:
I added a some debug printouts so we can observe a couple of things:
1. How the `Waker` object looks just like the _trait object_ we talked about in an earlier chapter
2. In what order the events register interest with the reactor
2. The program flow from start to finish
The last point is relevant when we move on the the last paragraph.
@@ -654,7 +691,7 @@ The `async` keyword can be used on functions as in `async fn(...)` or on a
block as in `async { ... }`. Both will turn your function, or block, into a
`Future`.
These `Futures` are rather simple. Imagine our generator from a few chapters
These Futures are rather simple. Imagine our generator from a few chapters
back. Every `await` point is like a `yield` point.
Instead of `yielding` a value we pass in, we yield the result of calling `poll` on
@@ -675,7 +712,7 @@ Future got 1 at time: 1.00.
Future got 2 at time: 3.00.
```
If these `Futures` were executed asynchronously we would expect to see:
If these Futures were executed asynchronously we would expect to see:
```ignore
Future got 1 at time: 1.00.
@@ -697,7 +734,7 @@ how they implement different ways of running Futures to completion.
[If I were you I would read this next, and try to implement it for our example.](./conclusion.md#building-a-better-exectuor).
That's actually it for now. There as probably much more to learn, this is enough
for today.
for today.
I hope exploring Futures and async in general gets easier after this read and I
do really hope that you do continue to explore further.