| Crates.io | clockworker |
| lib.rs | clockworker |
| version | 0.1.1 |
| created_at | 2026-01-19 22:13:59.571756+00 |
| updated_at | 2026-01-19 22:35:17.350074+00 |
| description | A single-threaded async executor with EEVDF-based fair scheduling and pluggable task schedulers |
| homepage | |
| repository | https://github.com/nikhilgarg28/clockworker |
| max_upload_size | |
| id | 2055428 |
| size | 147,060 |
Clockworker, loosely inspired by Seastar, is a single-threaded async executor with powerful, pluggable scheduling. Clockworker is agnostic to the underlying async runtime and can sit on top of any runtime like Tokio, Monoio, or Smol.
⚠️ Early/Alpha Release: This project is in early development. APIs may change in breaking ways between versions. Use at your own risk.
There is a class of settings where single-threaded async runtimes are a great fit. Several such runtimes exist in the Rust ecosystem—Tokio, Monoio, Glommio, etc. But almost none of these (with the exception of Glommio) provide the ability to run multiple configurable work queues with different priorities. This becomes important for many real-world single-threaded systems, at minimum to separate foreground and background work. Clockworker aims to solve this problem.
It does so via work queues with configurable time-shares onto which tasks can be spawned. Clockworker has a two-level scheduler: the top-level scheduler chooses the queue to poll based on its fair time share (inspired by Linux CFS/EEVDF), and then a task is chosen from that queue based on a queue-specific scheduler, which is fully pluggable—you can use one of the built-in schedulers or write your own by implementing a simple trait.
Note that Clockworker itself is just an executor loop, not a full async runtime, and is designed to sit on top of any other runtime.
JoinHandle::abort()JoinError::Panic)Add to your Cargo.toml:
[dependencies]
clockworker = "0.1.0"
The simplest example - spawn a task and wait for it:
use clockworker::{ExecutorBuilder, LAS};
use tokio::task::LocalSet;
#[tokio::main]
async fn main() {
let local = LocalSet::new();
local.run_until(async {
// Create executor with a single queue using LAS scheduler
let executor = ExecutorBuilder::new()
.with_queue(0, 1, LAS::new())
.build()
.unwrap();
// Get handle to the queue
let queue = executor.queue(0).unwrap();
// Spawn executor in background
local.spawn_local(async move {
executor.run().await;
});
// Spawn a task
let handle = queue.spawn(async {
println!("Hello from clockworker!");
42
});
// Wait for task to complete
let result = handle.await;
println!("Task result: {:?}", result); // Ok(42)
}).await;
}
Allocate CPU time proportionally between queues:
use clockworker::{ExecutorBuilder, LAS, yield_maybe};
use tokio::task::LocalSet;
use std::sync::Arc;
use std::sync::atomic::{AtomicU32, Ordering};
#[tokio::main]
async fn main() {
let local = LocalSet::new();
local.run_until(async {
// Create executor with two queues:
// - Queue 0: weight 8 (gets 8/9 of CPU time)
// - Queue 1: weight 1 (gets 1/9 of CPU time)
let executor = ExecutorBuilder::new()
.with_queue(0, 8, LAS::new()) // High priority queue
.with_queue(1, 1, LAS::new()) // Low priority queue
.build()
.unwrap();
let executor_clone = executor.clone();
local.spawn_local(async move {
executor_clone.run().await;
});
let high_count = Arc::new(AtomicU32::new(0));
let low_count = Arc::new(AtomicU32::new(0));
// Spawn tasks in both queues
let high_queue = executor.queue(0).unwrap();
let low_queue = executor.queue(1).unwrap();
high_queue.spawn({
let count = high_count.clone();
async move {
loop {
count.fetch_add(1, Ordering::Relaxed);
yield_maybe().await;
}
}
});
low_queue.spawn({
let count = low_count.clone();
async move {
loop {
count.fetch_add(1, Ordering::Relaxed);
yield_maybe().await;
}
}
});
// After running for a bit, high_count should be ~8x low_count
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
println!("High: {}, Low: {}",
high_count.load(Ordering::Relaxed),
low_count.load(Ordering::Relaxed));
}).await;
}
Cancel tasks using JoinHandle::abort():
use clockworker::{ExecutorBuilder, LAS};
use tokio::task::LocalSet;
use tokio::time::{sleep, Duration};
#[tokio::main]
async fn main() {
let local = LocalSet::new();
local.run_until(async {
let executor = ExecutorBuilder::new()
.with_queue(0, 1, LAS::new())
.build()
.unwrap();
let executor_clone = executor.clone();
local.spawn_local(async move {
executor_clone.run().await;
});
let queue = executor.queue(0).unwrap();
// Spawn a long-running task
let handle = queue.spawn(async {
loop {
println!("Running...");
sleep(Duration::from_millis(100)).await;
}
});
// Cancel it after 500ms
sleep(Duration::from_millis(500)).await;
handle.abort();
// Wait for cancellation to complete
let result = handle.await;
assert!(result.is_err());
println!("Task cancelled: {:?}", result);
}).await;
}
By default, the executor also panics when any of the tasks panic (same behavior as Tokio's single-threaded runtime). However, this can be configured:
use clockworker::{ExecutorBuilder, LAS, JoinError};
use tokio::task::LocalSet;
#[tokio::main]
async fn main() {
let local = LocalSet::new();
local.run_until(async {
// Configure executor to catch panics instead of crashing
let executor = ExecutorBuilder::new()
.with_queue(0, 1, LAS::new())
.with_panic_on_task_panic(false) // Catch panics
.build()
.unwrap();
let executor_clone = executor.clone();
local.spawn_local(async move {
executor_clone.run().await;
});
let queue = executor.queue(0).unwrap();
let handle = queue.spawn(async {
panic!("Something went wrong!");
});
// Panic is caught and returned as JoinError::Panic
match handle.await {
Err(JoinError::Panic(err)) => {
println!("Task panicked: {}", err);
}
_ => {}
}
}).await;
}
Group related tasks together for better scheduling. This can be useful for policies across tenants, gRPC streams, noisy clients, or even cases where a task spawns many child tasks and you want to make lineage-aware scheduling choices.
use clockworker::{ExecutorBuilder, LAS};
use tokio::task::LocalSet;
#[tokio::main]
async fn main() {
let local = LocalSet::new();
local.run_until(async {
let executor = ExecutorBuilder::new()
.with_queue(0, 1, LAS::new())
.build()
.unwrap();
let executor_clone = executor.clone();
local.spawn_local(async move {
executor_clone.run().await;
});
let queue = executor.queue(0).unwrap();
// Group tasks by tenant ID. The hash of group IDs is passed to the queue
// scheduler, so you can configure any behavior you want.
queue.group("tenant1").spawn(async { /* some task */ });
queue.group("tenant2").spawn(async { /* some task */ });
}).await;
}
Use LAS when you need low latency and fair scheduling:
ExecutorBuilder::new()
.with_queue(0, 1, LAS::new())
.build()
LAS prioritizes tasks that have received the least CPU time, which helps ensure:
Use RunnableFifo for simple FIFO ordering:
use clockworker::RunnableFifo;
ExecutorBuilder::new()
.with_queue(0, 1, RunnableFifo::new())
.build()
Tasks are ordered by when they become runnable (not arrival time). If a task goes to sleep and wakes up, it goes to the back of the queue.
Use ArrivalFifo for strict arrival-time ordering:
use clockworker::ArrivalFifo;
ExecutorBuilder::new()
.with_queue(0, 1, ArrivalFifo::new())
.build()
Tasks maintain their position based on when they were first spawned, even if they go to sleep.
Clockworker uses a two-level scheduling approach:
This design allows you to:
LocalSet or similarLicensed under the Apache License, Version 2.0. See LICENSE for details.