async-task is the task abstraction underneath Rust executors
Task abstraction for building executors
At a glance
- What is it?
- async-task is a small Rust crate that defines what a scheduled future is, splitting it into a Runnable that an executor polls and a Task that a caller awaits. It is the building block under the smol and async-std runtimes, licensed Apache-2.0 or MIT, and it deliberately contains no runtime of its own.
- Who is it for?
- async-task suits a Rust developer writing an executor or a runtime-shaped library, since it hands you the Runnable and Task split and leaves the scheduling policy to you, which is exactly the part that varies between runtimes.
- Can I use it commercially?
- Yes. Apache-2.0 is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
- Is it still maintained?
- Activity is slowing. The repository last received commits 6 months ago.
- What is it written in?
- Mainly Rust, according to GitHub's language statistics.
Answers come from the project's GitHub data, last synced on October 22, 2026, and from our analysis. They are not legal advice.
Editorial analysis
The problem: a future needs somewhere to live
async-task is a task abstraction for building executors, and the README explains the problem in three sentences.
To spawn a future onto an executor, we first need to allocate it on the heap and keep some state attached to it. The state indicates whether the future is ready for polling, waiting to be woken up, or completed. Such a stateful future is called a task.
That is the entire premise. A bare future is a value with no storage of its own, and to put one on a queue you need somewhere to keep it, plus a record of whether it is currently runnable, parked on a waker, or finished. Every executor needs that record, and the record is what this crate is.
The consequence is that the crate contains no runtime, no timer, no thread pool and no I/O reactor. It is the piece that lets a runtime exist rather than the runtime itself, and the README's framing, task abstraction for building executors, is meant literally.
The provenance is worth noting. The crate lives in the smol-rs organisation, it is written by Stjepan Glavina, and the dev dependencies include smol itself, so it is the same author's own runtime built on top of it. That relationship is the reason to look at this crate rather than at the runtime, if you are writing an executor.
The Runnable and Task split
The core API is the split of a spawned future into two halves that are used by different parties, and the README walks through the whole sequence.
A task is created using either spawn(), spawn_local(), or spawn_unchecked(), which returns a Runnable and a Task:
// A future that will be spawned.
let future = async { 1 + 2 };
// A function that schedules the task when it gets woken up.
let schedule = move |runnable| sender.send(runnable).unwrap();
// Construct a task.
let (runnable, task) = async_task::spawn(future, schedule);
// Push the task into the queue by invoking its schedule function.
runnable.schedule();The Runnable is used to poll the task's future, and the Task is used to await its output.
So the executor holds the Runnable and the caller holds the Task. The caller never sees the Runnable and the executor never sees the Task, and that separation is what makes the design work. The schedule closure is the seam between them: when a waker fires, the executor calls the schedule function it was handed, which puts the Runnable back on the queue.
The three constructors differ in what they promise. spawn_local() is for a task that is not Send, so it can only run on the thread it was created on. spawn_unchecked() is the escape hatch that skips the Send check, and it exists for runtimes that can guarantee safety by other means. Plain spawn() is the checked one.
The queue itself is not the crate's business. The README shows it as a flume unbounded channel, and the dev dependencies include flume, but any queue works, which is why the crate is usable by executors with entirely different scheduling strategies.
The run loop and why the Runnable disappears
The executor side is a loop that takes scheduled tasks from the queue and runs them:
for runnable in receiver {
runnable.run();
}That is the entire executor in the example, and the detail that makes it correct is in how the Runnable behaves. Method run() polls the task's future once. Then, the Runnable vanishes and only reappears when its Waker wakes the task, thus scheduling it to be run again.
The vanishing is the interesting part. A Runnable is a one-shot token, not a handle you keep. Once it has been run, it cannot be run again, and the only way it comes back is through the waker, which means the task explicitly asked to be rescheduled. That removes a whole category of bug, where a task gets polled twice concurrently or a stale Runnable gets run after the task was cancelled, because the type system makes those states unrepresentable rather than merely discouraged.
It also explains why the crate has no notion of cancellation beyond dropping the Task. The Task is the caller's handle, and dropping it is the signal that nobody wants the result any more.
The whole design is therefore a small protocol between two parties, and both halves are visible in the example: the executor polls, the caller awaits, and the waker is the only thing that moves a task from parked back to runnable.
The package shape and what the feature flags mean
The Cargo.toml is short and it says a good deal about the crate's design constraints.
The package is async-task at version 4.7.1, edition 2021, with a rust-version of 1.57, and the categories are asynchronous, concurrency and no-std. That last category is the important one: the crate is usable without the standard library, and the default feature is std with an empty std feature, meaning the standard library adds nothing to the API and can be turned off.
The dependency list has a single optional entry, portable-atomic, with a comment explaining why: it uses the portable-atomic polyfill atomics on targets without them. So the crate depends on an atomic abstraction rather than std::sync::atomic directly, which is what makes the no-std and unusual-target support possible in the first place.
The dev dependencies describe the test environment. flume for the queue, futures-lite for the async helpers, once_cell, pin-project-lite, atomic-waker as a test waker, easy-parallel and flaky_test for the concurrency tests, and smol itself for running the examples.
There is a patch section that rewrites the dependency to use this version of async-task when running tests:
[patch.crates-io]
async-task = { path = "." }That is a standard trick for a crate that other crates in the tree depend on, and it is worth noting that the dev dependency on smol is the one that pulls in the published async-task, which is exactly the case the patch fixes.
The repository layout is src/, tests/, benches/, examples/ and a CHANGELOG.md. The examples directory holds spawn.rs, spawn-local.rs, spawn-on-thread.rs and with-metadata.rs, which is the fastest way to see the differences between the three constructors.
Licensing, versions and the maintenance picture
The licence is dual, and the README is explicit about the mechanism.
Licensed under either of the Apache License, Version 2.0, with the LICENSE-APACHE file or the standard URL, or the MIT license, with the LICENSE-MIT file, at your option.
And the contribution rule: unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in the work, as defined in the Apache-2.0 license, shall be dual licensed as above, without any additional terms or conditions.
That is the standard way a Rust crate that wants to maximise adoption handles licensing, and it means the choice is available to the downstream user rather than imposed by the author. A company with a policy against Apache-2.0 can use the MIT side without a discussion.
On versions, the release history is v4.7.1 from 2024-04-27, v4.7.0 from 2024-01-04 and v4.6.0 from 2023-12-17. The last push to the repository was on 2026-04-15, so the code is being touched, and the repository is not archived. The version numbering follows semver without a 1.0 or 2.0 break, which for a crate with this shape is a reasonable sign of API stability.
What the crate does not have is documentation beyond the README and the API reference on docs.rs, and the README itself is a tutorial rather than a specification. For a crate this small that is a workable trade, since the source is short enough to read, and the examples directory exists to make the four common cases concrete.
Where async-task fits, and where it stops
The comparison worth making is with writing the task machinery yourself, and with using a full runtime.
Against writing it yourself, the difference is that the subtle part is the protocol between the Runnable, the Task and the waker, and getting the one-shot semantics of the Runnable wrong is a class of bug that shows up as a hang or a double poll under load rather than as a compile error. The crate makes those states unrepresentable, and it is small enough to read end to end.
Against using a full runtime, the difference is scope. smol and async-std are the runtimes built on this crate, and they bring a scheduler, a blocking thread pool, timers, networking and the async I/O stack with them. If your problem is await this future in my application, you want one of those, and the choice of executor is not a decision you need to make. If your problem is that the existing executor does not fit your workload, then this crate is the layer you build on.
The limits are inherent to the design. The crate has no scheduler, so a task that is spawned and never woken is never polled, which is correct but means the caller has to hold the Task and the executor has to be driven. There is no built-in cancellation beyond dropping the Task, so a task that is blocked on something that never completes stays allocated. And because the state is heap allocated, the per task cost is a heap allocation, which is fine for a runtime that pools and reuses them and is a consideration for one that does not.
The build constraint is the other limit. The rust-version is 1.57, which is old enough to be usable in conservative projects, and the no-std category with the portable-atomic dependency is what lets the crate run on targets without native atomics, which matters for the embedded and WebAssembly-adjacent uses that the smol family cares about.
For an application developer, none of this is the right level of abstraction. For someone writing an executor, a runtime binding, or a structured concurrency primitive with its own scheduling rules, this crate is the layer that lets them do that without reimplementing the Runnable protocol.
Editorial conclusion
async-task suits a Rust developer writing an executor or a runtime-shaped library, since it hands you the Runnable and Task split and leaves the scheduling policy to you, which is exactly the part that varies between runtimes. It is the wrong tool for an application that just needs to await some futures, because for that a runtime such as smol or async-std already exists, and it is the wrong tool if you need the higher-level conveniences of a full runtime, since this crate has none. Before adopting it, read the source and the examples directory, which holds spawn, spawn-local, spawn-on-thread and with-metadata variants, and check that your planned executor can own the task's state, since that is the whole contract.
Frequently asked questions
What does async-task do?
It provides the task abstraction for building executors. A spawned future is allocated on the heap with state attached indicating whether it is ready for polling, waiting to be woken up, or completed, and that stateful future is what the crate calls a task.
What is the difference between the Runnable and the Task?
The Runnable is used to poll the task's future and is held by the executor, while the Task is used to await its output and is held by the caller. After run() polls the future once the Runnable vanishes and only reappears when the Waker wakes the task.
How do I create a task with async-task?
Use spawn(), spawn_local() or spawn_unchecked(), which return a Runnable and a Task. You pass a future and a schedule closure that puts the Runnable on your queue, then call runnable.schedule() to push the task into that queue.
What licence is async-task released under?
Dual licensed, at your option, under the Apache License Version 2.0 or the MIT license, and contributions are dual licensed the same way unless the contributor states otherwise.
Does async-task include a runtime?
No. It contains no executor, scheduler, timer or I/O reactor, which is why the crate's own categories include no-std and why the default std feature is empty. The examples show the executor side as a simple loop that takes runnables from a queue and calls run() on each.
Official sources
Add this badge to your README
If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.
[](https://hysenlabs.com/projects/smol-rs-async-task)Was this useful?