Skip to content

zirman/suspenders-js

Repository files navigation

Suspenders.js: Structured concurrency for JavaScript

Suspenders.js 0.0.9 (alpha)

Suspenders.js is a library for asynchronous programming that supports coroutines and "structured concurrency". Suspenders.js takes inspiration from Kotlin's coroutines to combine functional and imperative programming styles for asynchronous programming.

Apache 2.0 License

Intro

Anyone who has written a large amount of asynchronous code in JavaScript is familiar with callback hell. Coroutines flatten those callbacks into what looks like normal synchronous code. Coroutines are special JavaScript generators that suspend whenever it would block waiting for a result. When an result is ready, it resumes the coroutine where it left off. Unlike Promises or Async/Await, coroutines can be stopped at any time. Coroutines are guarenteed to run their cleanup logic inside of finally blocks when they are cancelled. This ensures that resources are not leaked when a coroutine is canceled.

Coroutines allow for more efficient resource management through cancellation. For example if a database connection is opened in a coroutine's try block and closed in the finally block, it is guarenteed that the db connection is closed regardless of if the coroutine completes or is canceled. This is because all finally blocks are run automatically when a coroutine is canceled.

Why another async programming library?

Why choose Suspenders.js over JavaScript Promises? Promises are good when your async process is simple, short running and will never need to be canceled. With Suspenders.js, your async processes can run for as long as required and then be canceled when they are no longer needed. Structured concurrency provides a simpler sytnax that is similar to async/await, but also has advanced features for longer running groups of processes that are coordinated together.

Why choose Suspenders.js over async/await? Async await doesn't have methods for canceling running processes or grouping related processes. Structured concurrency has advanced features for longer running groups of processes that need to be coordinated together and canceled together.

Why choose Suspenders.js over Rx.js. Suspenders.js coroutines have a simpler syntax using the yield* keyword for unwrapping values. For example const x = yield* await() to get an single value asynchronously instead of await().flatMap((x) => ...). Also coordinating a tree of processes using structured concurrency has no analog in Rx.js. Coroutines are easier to write, because they do not require a large library of combinators. Reading coroutines is more natural due to the syntax being indistingusable from normal synchronous code.

Structured Concurrency Terminology

What is structured concurrency? It a way of organizing running processes into a hierarchy and managing the state and error handling of this tree of processes. Nodes on this tree are called Jobs. Jobs have 3 states, RUNNING, COMPLETING or COMPLETED. A Job tree automatically removes Jobs that have COMPLETED. A COMPLETING Job is waiting for all of it's children to complete before COMPLETING itself. No new RUNNING child Jobs can be added as a direct child to a COMPLETING Job.

A CoroutineScope is a special type of Job for starting, also known as launching, a coroutine. A coroutine has an associated node in the Job tree with the parent being the CoroutineScope.

Unhandled thrown errors are propagated up the tree until it is either handled by an error handler function associated with a Job or special type of Job called a SupervisorScope. Unlike all other Jobs, SupervisorScopes do not propagate errors up the Job tree for unhandled errors in their children and do not cancel their other child Jobs.

Structured Concurrency error propagation

An example of a tree of running Jobs
-* CoroutineScope-1
 |-* CoroutineJob-1
 |-* CoroutineJob-2
 |-* CoroutineScope-2
 | |-* CoroutineJob-3
 | |-* CoroutineJob-4
 |
 |-* SupervisorScope-1
   |-* CoroutineJob-5
   |-* CoroutineJob-6

Flows

TODO

Examples

import { awaitPromise, channel, Coroutine, CoroutineScope, Deferred, Scope } from "suspenders-js"

// Coroutine that suspends for 2 seconds and then returns "resolved"
function* returnAfter2Seconds(): Coroutine<string> {
    yield* delay(2_000) // yield* is required for generator to suspend
    return "resolved"
}

// Starts a coroutine in a `CoroutineScope`
CoroutineScope().launch(function* () {
    // *print* "hello"
    console.log("hello")
    // *wait 2 seconds* and returns "resolved"
    const result = yield* returnAfter2Seconds() // yield* is required to call other coroutines
    // *print* "resolved"
    console.log(result)
})

// Asynchronously call coroutines.

function* jobA(): Coroutine<number> {
    yield* delay(100)
    return 1
}

function* jobB(): Coroutine<number> {
    yield* wait(200)
    return 2
}

CoroutineScope().launchCoroutineScope(function* (this: Scope) {
    // Starts a child coroutine without suspending the current coroutine
    const deferredA: Deferred<number> = this.async(function* () {
        return yield* jobA() // runs concurrently
    })

    const deferredB: Deferred<number> = this.async(jobB) // simplified syntax

    // This suspends the current coroutine until there is a result for deferredA
    const resultA = yield* resultA.await()
    const resultB = yield* resultB.await()
})

// Flows emit multiple async values. They provide a typical functional interface like map() and
// .filter(). Flows are cold, meaning they don't start producing values unless they have an
// observer.

flow<number>(function* (emit) {
    for (let i = 1; i <= 200; i++) {
        yield* emit(i) // yield* is required for back pressure
    }
})
    .filter(x => x % 2 === 1)
    .map(x => x + x)
    .onEach(x => console.log(x))
    // This will start the flow in a `Scope`.
    .launchIn(CoroutineScope())

// This starts a coroutine that consumes two flows in order.

CoroutineScope().launch(function* () {
    // suspends until flow completes

    yield* flowOf<number>(function* (emit) {
        for (let i = 1; i <= 200; i++) {
            yield* emit(i)
        }
    })
        .filter(x => x % 2 === 1)
        .map(x => x + x)
        // Collect() consumes the flow until it completes.
        // Resumes the coroutine once the flow has completed.
        .collect(x => console.log(x))

    yield* flow<number>(function* (emit) {
        for (let i = 1; i <= 200; i++) {
            yield* emit(i)
        }
    })
        .filter(x => x % 2 === 1)
        .map(x => x + x)
        .collect(x => console.log(x))
})

// Channels are for communication between coroutines.

const channel = channel<number>()

// Producer/consumer coroutines communicating through a channel.

CoroutineScope().launch(function* () {
    for (let i = 1; i <= 200; i++) {
        yield* channel.send(i)
    }
})

CoroutineScope().launch(function* () {
    for (; ;) {
        const x = yield* this.suspend(channel.receive)

        if (x % 2 === 1) {
            const y = x + x
            console.log(y)
        }
    }
})

References

Kotlin Coroutines

Notes on structured concurrency, or: Go statement considered harmful

Structured Concurrency

Roman Elizarov — Structured concurrency

Taming the Asynchronous Beast with CSP Channels in JavaScript

Communicating Sequential Processes

About

Asynchronous programming library using coroutines and functional reactive programming for JavaScript.

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published