#scope #async #structured #experimental #parallel #concurrently

moro

Experimental structured concurrency support for async Rust (similar to trio's nurseries)

5 releases (3 breaking)

0.4.0 Jun 4, 2022
0.3.0 Jun 4, 2022
0.2.0 Jun 4, 2022
0.1.1 Apr 17, 2022
0.1.0 Apr 17, 2022

#486 in Concurrency

Download history 8/week @ 2023-12-10 28/week @ 2023-12-17 51/week @ 2023-12-24 14/week @ 2023-12-31 17/week @ 2024-01-07 4/week @ 2024-01-14 1/week @ 2024-01-21 54/week @ 2024-01-28 74/week @ 2024-02-04 43/week @ 2024-02-11 14/week @ 2024-02-18 64/week @ 2024-02-25 59/week @ 2024-03-03

193 downloads per month
Used in earendil

MIT/Apache

25KB
295 lines

moro

Experiments with structured concurrency in Rust

TL;DR

Similar to rayon or std::thread::scope, moro let's you create a scope using a moro::async_scope! macro. Within this scope, you can spawn jobs that can access stack data defined outside the scope:

let value = 22;
let result = moro::async_scope!(|scope| {
    let future1 = scope.spawn(async {
        let future2 = scope.spawn(async {
            value // access stack values that outlive scope
        });

        let v = future2.await * 2;
        v
    });

    let v = future1.await * 2;
    v
})
.await;
eprintln!("{result}"); // prints 88

Stack access, core scope API

Invoke moro::async_scope!(|scope| ...) and you get back a future for the overall scope that you can await to start up the scope.

Within the scope body (...) you can invoke scope.spawn(async { ... }) to spawn a job. This job must terminate before the scope itself is considered completed. The result of scope.spawn is a future whose result is the result returned by the job.

Early termination and cancellation

Moro scopes support early termination or cancellation. You can invoke scope.terminate(v).await and all spawned threads within the scope will immediately stop executing. Termination is commonly used when v is a Result to make Err values cancel (we offer helper methods like unwrap_or_cancel for this in the prelude).

An example that uses cancellation is shown in monitor -- in this example, several jobs are spawned which all examine one integer from the input. If any integers are negative, the entire scope is canceled.

Future work: Integrating with rayon-like iterators

I want to do this. :)

Frequently asked questions

Where does the name moro come from?

It's from the Greek word for "baby" (μωρό). The popular "trio" library uses the term "nursery" to refer to a scope, so I wanted to honor that lineage.

Apparently though "moros" is also the 'hateful' spirit of impending doom, which I didn't know, but is kinda' awesome.

Are there other async nursery projects available, and how does moro compare?

Yes! I'm aware of...

  • async_nursery, which is similar to moro but provides parallel execution (not just concurrent), but -- as a result -- requires a 'static bound.
  • FuturesUnordered, which can be used as a kind of nursery, but which also has a number of known footguns. This type is currently used in the moro implementation, but moro's API prevents those footguns from happening.
  • select operations are commonly used to "model" parallel streams; like with FuturesUnordered, this is an errorprone approach, and moro evolved in part as an alternative to select-like APIs.

Why do moro spawns only run concurrently, not parallel?

Parallel moro tasks cannot, with Rust as it is today, be done safely. The full details are in a later question, but the tl;dr is that when a moro scope yields to its caller, the scope is "giving up control" to its caller, and that caller can -- if it chooses -- just forget the scope entirely and stop executing it. This means that if the moro scope has started parallel threads, those threads will go on accessing the caller's data, which can create data races. Not good.

Isn't running concurrently a huge limitation?

Sort of? Parallel would definitely be nice, but for many async servers, you get parallelism between connections and you don't need to have parallelism within a connection. You can also use other mechanisms to get parallelism, but 'static bounds are required.

OK, but why do moro spawns only run concurrently, not parallel? Give me the details!

The Future::poll method permits safe code to "partially advance" a future and then, because a future is an ordinary Rust value, "forget" it (e.g., via std::mem::forget, though there are other ways). This would allow you to create a scope, execute it a few times, and then discard it without running any destructor:

async fn method() {
    let data = vec![1, 2, 3];
    let some_future = moro::async_scope!(|scope| {
        scope.spawn(async { 
            for d in &data {
                tokio::task::yield_now().await;
            }
        });
    });

    // pseudo-code, we'd have to gin up a context etc:
    std::future::Future::poll(some_future);
    std::future::Future::poll(some_future);
    std::mem::forget(some_future);
    return;
}

If moro tasks were running in parallel, there would be no way for us to ensure that the parallel threads spawned inside the scope are stopped before method returns. As a result, they would go on accessing the data from data even after the stack frame was popped and the data was freed. Bad.

But because moro is limited to concurrency, this is fine. Tasks in the scope only advance when they are polled (they're not parallel) -- so when you "forget" the scope, you simply stop executing the tasks too.

Note that this problem doesn't occur in libraries like rayon or the new std::thread::scope call. This is because sync code has a capability that async code lacks: a sync function can block its caller (this is because safe Rust forbids longjmp). But in async code, under Rust's current model, so long as you "await" something, you are giving up control to your caller and they are free to never poll you again. This means, I believe, that it is not possible to have a "scope" like moro's that safely refers to data outside of the scope, since that data is owned by your callers, and you cannot force them not to return. Put another way, async code can, by cooperating with the executor, ensure that some future runs to completion. Any call to tokio::spawn will do that. But you cannot ensure that your future is embedded in something else that runs to completion.

I do not believe parallel execution can be safely enabled without modifying the Future trait or Rust in some way. There are various proposals to change the Future trait to permit moro to support parallel execution (those same proposals would help for supporting io-uring, DMA, and other features), but the exact path forward hasn't been settled.

Dependencies

~1.3–2MB
~39K SLoC