Skip to content
academia.sh

Lesson 09 / 10

Asynchronous Input/Output

Suspension while waiting on input or output is modeled by a resource that returns Pending until it is ready, not by a real network or file; what is measured is how many polls it takes to complete, not the resource's time to become ready.

Published Updated

In this lesson

  • Shows how suspension while waiting on input or output is built with a future modeling a resource that is not yet ready
  • Confirms that the poll count comes from the resource's own logic by comparing the poll counts of resources carrying different readiness delays
  • Explains the difference between the boundary the Standard Library and Network Applications course measures through type information and the boundary this lesson measures through the progress condition
Contents

The previous lesson examined two kinds of task: one that never pauses and one that pauses once by its own decision. In both, the reason for pausing was the task’s own logic — it depended on no outside condition. Most of what a program actually waits on in the real world is not like this: a network connection takes an uncertain amount of time to respond, a read request sent to a file system waits for hardware to become ready. This lesson takes up this kind of wait — but, per the course’s ban on environment-dependent data, without using a real network or file. The wait is built with a model in which a resource that is not ready returns Pending, and wakes itself once it is ready.

The reason for not using a real network socket or file descriptor is not just convenience. The determinism constraint the course has kept since its start requires that a run produce the same number no matter which machine it runs on. How long a real network request takes depends on the network itself; how long a real file read takes depends on the storage device — this would make it impossible for the course to produce an identical output across five separate runs. Instead, the abstract shape of the real-world “resource is not ready yet” situation is captured: a poll call returning Pending and later turning into a Ready. Why the resource is not ready — a network delay, a disk access — falls outside this model; what stays inside the model is only how many times it takes to be asked before it is ready.

Modeling a Resource That Is Not Ready

MockResource is the most reduced form of an input/output resource: it carries how many polls until it becomes ready in a counter. On every poll call, if the counter is nonzero it decreases by one, returns Pending, and wakes itself; once it reaches zero it writes a record to the channel and returns Ready.

// advance.rs — a slice of the core: a future modeling a resource that is not ready.

use std::future::Future;
use std::pin::Pin;
use std::sync::mpsc::Sender;
use std::sync::{Arc, Condvar, Mutex};
use std::task::{Context, Poll, Wake, Waker};

pub struct Notifier {
    pub ready: Mutex<bool>,
    pub condvar: Condvar,
}

impl Wake for Notifier {
    fn wake(self: Arc<Self>) {
        *self.ready.lock().unwrap() = true;
        self.condvar.notify_one();
    }
}

pub fn run<F: Future>(task: F) -> (F::Output, u32) {
    let mut task = Box::pin(task);
    let n = Arc::new(Notifier {
        ready: Mutex::new(false),
        condvar: Condvar::new(),
    });
    let waker: Waker = n.clone().into();
    let mut context = Context::from_waker(&waker);
    let mut poll_count = 0;
    loop {
        poll_count += 1;
        match task.as_mut().poll(&mut context) {
            Poll::Ready(x) => return (x, poll_count),
            Poll::Pending => {
                let mut ready = n.ready.lock().unwrap();
                while !*ready {
                    ready = n.condvar.wait(ready).unwrap();
                }
                *ready = false;
            }
        }
    }
}

// models an input/output resource that is not ready: each poll decreases the remaining
// count by one; once remaining reaches zero it is considered ready and sends the value.
pub struct MockResource {
    pub remaining: u32,
    pub name: &'static str,
    pub channel: Sender<String>,
}

impl Future for MockResource {
    type Output = &'static str;
    fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<&'static str> {
        if self.remaining == 0 {
            self.channel.send(self.name.to_string()).unwrap();
            Poll::Ready(self.name)
        } else {
            self.remaining -= 1;
            cx.waker().wake_by_ref();
            Poll::Pending
        }
    }
}

pub async fn read_data(resource: MockResource, channel: Sender<String>) -> &'static str {
    let _ = resource.await;
    channel.send(String::from("read-complete")).unwrap();
    "read"
}

MockResource is the generalized form of the previous lesson’s PausesOnce: PausesOnce paused exactly once, MockResource pauses for however many times the remaining field says. In a real program, this number would stand in for a hardware interrupt, an operating system notification, or a network packet’s arrival; here it is only an integer, and it determines the resource’s behavior — not the real event it stands in for. read_data is an async fn awaiting this resource: it does not advance itself until resource.await completes, exactly like the outer body awaiting an inner future in the previous lesson.

MockResource’s poll method calls cx.waker().wake_by_ref() on every Pending return — just like PausesOnce. This is the most important difference from a real resource: a real network socket does not wake itself; a notification mechanism belonging to the operating system (a subsystem that tells the runtime when a socket is ready) calls the Waker. MockResource imitates this outside notification — having no timer of its own, it chooses to wake itself immediately every time and decrease remaining by one on the next poll call. The result, seen from outside, is the same: the executor sees Pending, gets a chance to poll again after some time, and the resource eventually becomes ready.

How Many Polls, How Much Waiting

The following program runs resources constructed with three different delays: one short (waits one poll), one long (waits four polls), and one awaited from inside an async fn.

// input-output.rs — how many polls it takes a resource that is not ready to complete.
mod advance;
use advance::{run, read_data, MockResource};
use std::sync::mpsc::channel;

fn main() {
    let (sender, receiver) = channel::<String>();

    let (_value_a, poll_count_a) = run(MockResource {
        remaining: 1,
        name: "resource-short-wait",
        channel: sender.clone(),
    });
    let (_value_b, poll_count_b) = run(MockResource {
        remaining: 4,
        name: "resource-long-wait",
        channel: sender.clone(),
    });

    let resource_c = MockResource {
        remaining: 2,
        name: "resource-inner",
        channel: sender.clone(),
    };
    let (_value_c, poll_count_c) = run(read_data(resource_c, sender.clone()));

    drop(sender);
    let mut records: Vec<String> = receiver.try_iter().collect();
    records.sort();

    println!("short wait (remaining=1) -> completed in {} polls", poll_count_a);
    println!("long wait  (remaining=4) -> completed in {} polls", poll_count_b);
    println!("read, waiting on an inner resource -> completed in {} polls", poll_count_c);
    println!();
    println!("records ({}):", records.len());
    for record in &records {
        println!("  {}", record);
    }
}
short wait (remaining=1) -> completed in 2 polls
long wait  (remaining=4) -> completed in 5 polls
read, waiting on an inner resource -> completed in 3 polls

records (4):
  read-complete
  resource-inner
  resource-long-wait
  resource-short-wait

AP5. The poll count is one more than the remaining field’s starting value: remaining: 1 completes in two polls, remaining: 4 in five. The rule belongs to the same family as the n+1n+1 rule in the async/await lesson — there it was nn consecutive suspension points, here it is nn consecutive Pending returns; both count the final call — the one returning Ready — into the total. resource-inner (remaining: 2) is awaited from inside read_data and completes in three polls — two Pending plus one Ready, exactly the count it would have completed in if handed directly to run. This stayed identical across five separate runs.

The Waiting Task’s Class

resource-short-wait and resource-long-wait fall into the when-polled class: both are handed directly to run, both advance according to their own remaining counters. read_data, by contrast, lands in the same position as the previous lesson’s await_inner — its own advancement depends on resource_c completing, and so it falls into the when-something-else-finishes class. resource_c itself is still when-polled — the two classes sit together here too, as in the previous lesson, in different layers of the same task tree.

The Standard Library and Network Applications course measured what a value crossing a network boundary keeps and loses of its type information — in what form the type on one side of the boundary reaches the other side. This lesson measures the same kind of boundary from a different axis: what a task needs in order to advance while it waits on an input/output resource. Type information is never in question here — the value MockResource produces is already &'static str from the start; the boundary separates not a type’s shape, but a task’s progress condition.

Both measurements take up the same boundary — the boundary between a program’s own code and a resource coming from outside — but they ask different questions. There the question was “does the value crossing this boundary keep the same type”; here the question is “what does this side do until the resource on the other side of the boundary is ready.” The difference between resource-short-wait and resource-long-wait is not a type difference — both come from the same MockResource type, both share the same Output; the difference is only in the starting value of the remaining field, that is, in how often the progress condition gets satisfied.

The Executor Still Does Not Matter Here

The Executors lesson showed that two different executor implementations poll the same task the same number of times. The same result holds for a modeled input/output resource too. The following excerpt carries the previous lesson’s run_loop over into this lesson and runs the same MockResource through two different executors.

// advance.rs — the core slice changes: the previous lesson's second executor (run_loop)
// is introduced here, the same MockResource is compared across two executors.

use std::future::Future;
use std::pin::Pin;
use std::sync::mpsc::Sender;
use std::sync::{Arc, Condvar, Mutex};
use std::task::{Context, Poll, Wake, Waker};

pub struct Notifier {
    pub ready: Mutex<bool>,
    pub condvar: Condvar,
}

impl Wake for Notifier {
    fn wake(self: Arc<Self>) {
        *self.ready.lock().unwrap() = true;
        self.condvar.notify_one();
    }
}

pub fn run<F: Future>(task: F) -> (F::Output, u32) {
    let mut task = Box::pin(task);
    let n = Arc::new(Notifier {
        ready: Mutex::new(false),
        condvar: Condvar::new(),
    });
    let waker: Waker = n.clone().into();
    let mut context = Context::from_waker(&waker);
    let mut poll_count = 0;
    loop {
        poll_count += 1;
        match task.as_mut().poll(&mut context) {
            Poll::Ready(x) => return (x, poll_count),
            Poll::Pending => {
                let mut ready = n.ready.lock().unwrap();
                while !*ready {
                    ready = n.condvar.wait(ready).unwrap();
                }
                *ready = false;
            }
        }
    }
}

pub struct EmptyNotifier;

impl Wake for EmptyNotifier {
    fn wake(self: Arc<Self>) {}
}

pub fn run_loop<F: Future>(task: F) -> (F::Output, u32) {
    let mut task = Box::pin(task);
    let n = Arc::new(EmptyNotifier);
    let waker: Waker = n.clone().into();
    let mut context = Context::from_waker(&waker);
    let mut poll_count = 0;
    loop {
        poll_count += 1;
        if let Poll::Ready(x) = task.as_mut().poll(&mut context) {
            return (x, poll_count);
        }
    }
}

pub struct MockResource {
    pub remaining: u32,
    pub name: &'static str,
    pub channel: Sender<String>,
}

impl Future for MockResource {
    type Output = &'static str;
    fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<&'static str> {
        if self.remaining == 0 {
            self.channel.send(self.name.to_string()).unwrap();
            Poll::Ready(self.name)
        } else {
            self.remaining -= 1;
            cx.waker().wake_by_ref();
            Poll::Pending
        }
    }
}
// two-executors-io.rs — the same modeled resource, two different executors, the same poll count.
mod advance;
use advance::{run, run_loop, MockResource};
use std::sync::mpsc::channel;

fn main() {
    let (sender, receiver) = channel::<String>();

    let (_value_1, poll_count_1) = run(MockResource {
        remaining: 3,
        name: "resource-run",
        channel: sender.clone(),
    });
    let (_value_2, poll_count_2) = run_loop(MockResource {
        remaining: 3,
        name: "resource-loop",
        channel: sender.clone(),
    });

    drop(sender);
    let mut records: Vec<String> = receiver.try_iter().collect();
    records.sort();

    println!("run={} run_loop={} same: {}", poll_count_1, poll_count_2, poll_count_1 == poll_count_2);
    println!("records ({}):", records.len());
    for record in &records {
        println!("  {}", record);
    }
}
run=4 run_loop=4 same: true
records (2):
  resource-loop
  resource-run

AP6. Two resources constructed with the same remaining: 3 value, run through two utterly different executors, produce an identical poll count (4). MockResource‘s poll method has no idea which executor is calling it — it looks only at its own remaining field. This carries the previous lesson’s finding over to a modeled input/output resource: the executor does not interfere with how long a resource keeps someone waiting, it only interferes with what the calling thread does while it waits — and that second difference lives in an area this course forbids printing.

The Limiting Measurement

The modeled wait does not show a real delay’s duration. The remaining: 4 field requires five polls, but there is no actual waiting at all between these five calls — run’s condition variable wakes up immediately on every wake_by_ref call, because MockResource wakes itself on every Pending return. A real resource (a network socket, a file descriptor) would make this wake call at the moment it actually becomes ready, through an operating system notification; here that notification is imitated by the remaining counter decreasing. What is always measured is the poll count — not the waiting time, because that time depends on the environment, and this course has forbidden printing it from the start.

One consequence of this limit: if the number written into the remaining field is made larger, the poll count grows proportionally, but the run’s actual duration does not change in any measurable way — there is no real wait anywhere, only how many times the loop runs changes. In a real program this relationship reverses: the poll count can stay fixed (an executor can be designed to re-poll a resource only once it is genuinely ready), but the real waiting time changes depending on how fast the resource itself is. Seeing that these two axes — poll count and waiting time — can be independent of each other is necessary for understanding what the model represents and what it does not: this lesson measures only the first of the two, the one number this course has allowed to be printed from the very start.

Summary

  • Suspension while waiting on input or output is built with a future modeling a resource that is not ready: if the resource is not ready it returns Pending and wakes itself.
  • How many polls a resource takes to complete comes from the resource’s own remaining logic and is deterministic — it stays identical across five separate runs.
  • A directly polled resource falls into the when-polled class; an async fn awaiting it falls, for its own completion, into the when-something-else-finishes class.
  • The Standard Library and Network Applications course measured a boundary through type information; this lesson measures the same boundary through a task’s progress condition.
  • The modeled wait does not show the real delay; what is measured is the poll count, not the time elapsed.
  • The same modeled resource run through two different executors also produces the same poll count — the Executors lesson’s finding holds unchanged for modeled input/output too.

Next Step

This topic built, in four steps, what a task needs in order to advance: a task does not advance by being constructed, it advances only when polled; async/await makes visible in the source where these polls pause and from where they resume; an executor decides when these polls happen but does not interfere with the value a task produces; and an input/output resource can extend this polling chain by a number tied to its readiness delay. Across four lessons, the one number measured — how many times poll was called — stayed, every time, derivable in advance from the source.

The remaining question is what happens to a task that is written in the source but never completes, or is left uncompleted. This course’s closing lesson takes up the cancellation established by dropping a task: a dropped task is never polled again, and stays written in the source like this lesson’s MockResource, but shares the same fate as the _never_polled in the Futures lesson — never advancing. A timeout is modeled from the same frame too, not through duration but through poll count.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close