Waiting and Continuing

You send a letter and wait for a reply. Do you stand frozen at the mailbox, unable to move until the response arrives? Or do you continue with your life, to be interrupted when the answer finally comes?

Most programs spend enormous amounts of time waiting: for files to load, for networks to respond, for databases to query, for users to click. The question is how they wait.

The Blocking World

The simplest approach: wait until done.

erlang
    content = readFile("data.txt")     // program freezes here
    processed = process(content)        // continues after file is read
    save(processed)                     // freezes again

This is blocking (or synchronous) I/O. Each operation completes before the next begins. The thread is "blocked"---suspended, doing nothing---while waiting.

Blocking is simple and natural. The code reads like a recipe: do this, then that. But it has costs:

  • A UI thread that blocks becomes unresponsive---the dreaded frozen application
  • A server thread that blocks can't serve other requests
  • A program waiting for a slow network wastes CPU cycles doing nothing

The Non-Blocking Alternative

What if, instead of waiting, we said "let me know when you're done"?

    readFile("data.txt", whenDone: (content) => {
        processed = process(content)
        save(processed)
    })
    
    // This line runs IMMEDIATELY, before file is read
    doOtherThings()

This is non-blocking (or asynchronous) I/O. The readFile call returns immediately. The actual reading happens in the background. When complete, our callback is invoked with the result.

The program doesn't freeze. It continues doing other work, handling the file contents whenever they arrive.

Callbacks: The First Pattern

Callbacks are the oldest and most fundamental async pattern:

erlang
    fetchUser(userId, (user) => {
        // Called when user data arrives
        displayProfile(user)
    })

    onClick(button, () => {
        // Called when button is clicked
        submitForm()
    })

    setTimeout(1000, () => {
        // Called after 1 second
        showReminder()
    })

The pattern: start an operation, provide a function to handle the result. The operation calls your function when ready.

Callback Hell

But callbacks have a dark side. What if you need to chain async operations?

    readFile("config.txt", (config) => {
        parseConfig(config, (settings) => {
            connectDatabase(settings.db, (connection) => {
                queryUsers(connection, (users) => {
                    filterActive(users, (active) => {
                        sendEmails(active, (results) => {
                            logResults(results, () => {
                                done()
                            })
                        })
                    })
                })
            })
        })
    })

This is callback hell---nested callbacks that march ever rightward, forming a pyramid of doom. Each step depends on the previous, so each must nest inside the last.

Error handling makes it worse. Each callback needs its own error check, often duplicated at every level.

Promises: Values from the Future

A promise represents a value that will exist later. Instead of passing a callback, you receive an object that promises to eventually contain the result.

    promise = readFile("data.txt")
    
    // promise is not the content---it's a box that will contain the content
    
    promise.then((content) => {
        // Called when the content arrives
        process(content)
    })

The power emerges in chaining:

    readFile("config.txt")
        .then((config) => parseConfig(config))
        .then((settings) => connectDatabase(settings.db))
        .then((connection) => queryUsers(connection))
        .then((users) => filterActive(users))
        .then((active) => sendEmails(active))
        .then((results) => logResults(results))
        .then(() => done())
        .catch((error) => handleError(error))

The pyramid is gone. Each step is at the same indentation level. And error handling is centralized: one .catch() at the end handles errors from any step.

Promise States

A promise is always in one of three states:

Pending
The operation is still in progress
Fulfilled
The operation succeeded; the promise has a value
Rejected
The operation failed; the promise has an error
    promise = fetchData()
    
    // Initially: pending
    
    // Later, either:
    //   fulfilled with value
    //   rejected with error
    
    promise
        .then((value) => /* handle success */)
        .catch((error) => /* handle failure */)

Composing Promises

Promises compose. Run operations in parallel and wait for all:

    Promise.all([
        fetchUser(userId),
        fetchOrders(userId),
        fetchPreferences(userId)
    ]).then(([user, orders, prefs]) => {
        // All three completed
        displayDashboard(user, orders, prefs)
    })

Notice that Promise.all itself returns a promise---a promise that contains all the results. This is the Composite pattern: a promise of promises is still a promise. You can nest them arbitrarily, and the interface remains the same.

Or race them and take the first:

    Promise.race([
        fetchFromServer1(data),
        fetchFromServer2(data)
    ]).then((result) => {
        // First one wins
    })

Async/Await: The Synchronous Illusion

Promises linearize callbacks, but the code still looks different from synchronous code. What if async code could look synchronous?

javascript
    async function processData() {
        config = await readFile("config.txt")
        settings = await parseConfig(config)
        connection = await connectDatabase(settings.db)
        users = await queryUsers(connection)
        active = await filterActive(users)
        results = await sendEmails(active)
        await logResults(results)
        done()
    }

This looks exactly like the blocking version! But it's non-blocking. At each await, the function pauses, but the program continues running other code. When the awaited promise resolves, the function resumes.

Error handling uses familiar try/catch:

javascript
    async function processData() {
        try {
            config = await readFile("config.txt")
            settings = await parseConfig(config)
            // ... rest of the chain
        } catch (error) {
            handleError(error)
        }
    }

Coroutines: Pausable Functions

async/await is a specific case of a broader concept: coroutines---functions that can pause and resume.

    coroutine generateNumbers() {
        yield 1
        yield 2
        yield 3
    }

    gen = generateNumbers()
    gen.next()  // returns 1, pauses
    gen.next()  // returns 2, pauses
    gen.next()  // returns 3, pauses

Unlike regular functions that run to completion, coroutines can suspend mid-execution and resume later.

Generators: Lazy Sequences

One powerful use of coroutines is generating values on demand:

    coroutine fibonacci() {
        a = 0
        b = 1
        while true:
            yield a
            (a, b) = (b, a + b)
    }

    fib = fibonacci()
    fib.next()  // 0
    fib.next()  // 1
    fib.next()  // 1
    fib.next()  // 2
    fib.next()  // 3
    // ... infinite sequence, computed only as needed

Without coroutines, we'd need to store state externally:

wollok
    // Stateful object approach
    class FibonacciIterator {
        a = 0
        b = 1

        next() {
            result = a
            (a, b) = (b, a + b)
            return result
        }
    }

The coroutine is more natural---the state lives in local variables, and yield marks where to pause.

Cooperative Multitasking

Coroutines enable multiple tasks to share a single thread by voluntarily yielding:

python
    coroutine downloadFiles(urls) {
        for url in urls:
            startDownload(url)
            yield  // Let other coroutines run
            content = getResult(url)
            saveFile(content)
            yield  // Yield again
    }

    coroutine processUserInput() {
        while true:
            if hasInput():
                handleInput(getInput())
            yield  // Let other coroutines run
    }

    // Scheduler runs both, interleaving at yield points
    scheduler.run([downloadFiles(urls), processUserInput()])

Async I/O with Coroutines

Coroutines shine for I/O-heavy code. The coroutine yields while waiting; the runtime resumes it when data arrives:

    coroutine fetchAndProcess(url) {
        response = yield fetch(url)      // Pause until response
        data = yield response.json()     // Pause until parsed
        result = transform(data)
        yield save(result)               // Pause until saved
        return result
    }

Coroutines enable:

  • Generators: producing values on demand (lazy sequences)
  • Cooperative multitasking: multiple coroutines taking turns
  • Async I/O: pausing while waiting for external resources
  • State machines: complex control flow with natural syntax

The Event Loop

How does non-blocking I/O actually work? Most async systems use an event loop:

    while true:
        event = waitForNextEvent()
        handler = findHandler(event)
        handler(event)

The loop:

  1. Waits for something to happen (I/O complete, timer fires, user clicks)
  2. Finds the callback registered for that event
  3. Runs the callback
  4. Repeats

This is why callbacks should be fast. A slow callback blocks the event loop, freezing everything.

Choosing a Pattern

When to use which?

Callbacks: Simple, one-off async operations. Event handlers (clicks, keypresses).

Promises: Chained async operations. When you need to compose or combine async work.

Async/await: Complex async logic with conditions and loops. When you want readable, maintainable code.

Coroutines: When you need fine-grained control over pausing and resuming. Generators, cooperative tasks.

PatternStrengthWeakness
CallbacksSimple, universalNesting, error handling
PromisesChainable, composableStill somewhat verbose
Async/awaitReadable, familiarRequires language support

Asynchronous programming lets us wait without freezing. But it doesn't solve the deeper problem: what happens when multiple activities access the same data? The next chapter confronts the dangers of shared mutable state.