A function can be halfway through its work and have nothing to do.
vthread, a virtual thread runtime for Rust, runs ordinary functions as virtual threads. When one waits through a vthread operation, it can pause and let another use the same operating system thread. The waiting function keeps its place and continues later.
starting a virtual thread
Start with a function that returns a value. This complete program runs it in a virtual thread:
fn main() -> vthread::Result<()> {
vthread::run(|scope| {
let mut task = scope.spawn("greeting", || "hello")?;
let message = task.join()?;
println!("{message}");
Ok(())
})
}
vthread::run starts the runtime and provides a scope. spawn starts the greeting as a virtual thread and returns a join handle. In the API, a virtual thread is a task. join waits for the result, so the program prints hello.
The scope owns its tasks and waits for their cleanup before finishing. The ? operators pass errors back to the caller.
Use spawn for work that needs to make progress independently: a connection being served or a worker waiting for its next job. An ordinary helper call stays in the same task.
The runtime can share a smaller set of OS threads among many tasks:
Loading diagram…
a virtual thread that waits
The greeting returns immediately. A connection handler may spend most of its life waiting for a client. This one echoes whatever it receives:
use vthread::net::TcpStream;
fn echo(stream: TcpStream) -> vthread::Result<()> {
let mut buffer = [0; 1024];
loop {
let count = stream.read(&mut buffer)?;
if count == 0 {
return Ok(());
}
stream.write_all(&buffer[..count])?;
}
}
Each handler owns a TcpStream, representing one connection, and a buffer. It reads bytes, writes them back, and repeats until the client closes its sending side.
The complete server accepts two connections and starts a task for each. Its spawning expression is:
scope .spawn("connection", move || echo(stream))?
The closure takes ownership of the connection, and the server keeps the returned handle to join later. The handlers have exactly one OS thread to share, configured with:
let runtime = vthread::Runtime::builder() .carriers(1) .build()?;
Now let one client go quiet. Its handler reaches read and has nothing to read. Because this is vthread's TcpStream, the task can pause while another client's task receives and echoes data on the same OS thread.
That OS thread is called a carrier. These handlers take turns on it. Tasks assigned to different carriers can run in parallel.
Each task has its own stack: the memory holding its active function calls and local values. The quiet handler keeps its call to echo and its buffer there. To pause, vthread saves its execution position and returns control to the carrier's scheduler.
Inside read, the socket operation first tries to read without blocking the OS thread. If it would have to wait, it can register for a notification and park the task: pause it until there's a reason to try again.
A socket readiness notification makes the task ready to try again, but it still needs a turn on the carrier. Once scheduled, it resumes inside read. Readiness isn't a guarantee: the socket operation may still need to wait.
Loading diagram…
The complete example includes listener setup, a pinned dependency, and terminal instructions. It exits after both clients close their sending sides.
when waiting holds a thread
A blocking read through std::net::TcpStream holds the carrier until it returns. So can std::thread::sleep or waiting for a lock that blocks the OS thread. Other tasks on that carrier must wait too. Putting a function inside spawn doesn't change its blocking behavior: vthread doesn't intercept calls in std.
Use vthread's waiting APIs directly. For example, vthread::sleep pauses the task while its carrier can run other work. Its networking, synchronization primitives, and channels cooperate in the same way.
For owned blocking work that can run on another thread, use vthread::blocking::run. It moves the work to a separate pool of OS threads while the virtual caller can park. The filesystem helpers use this pool too. Work still occupies an OS thread, and submission can fail when the bounded pool is full.
The runtime won't interrupt a long CPU loop to run another task on its carrier. The task must yield or reach a waiting operation that pauses it. This is cooperative scheduling.
where virtual threads fit
For the same echo server, Rust's std::thread::spawn could give each handler its own OS thread. Blocking libraries work directly, and the OS can run other threads while one is blocked. This is straightforward for a small set of workers, but every waiting connection keeps an OS thread and its resources.
An async handler could instead await the next read. The compiler keeps the state needed to resume in a future, without a separate native stack for each task. A runtime such as Tokio schedules these tasks on shared OS threads. This fits applications built around async libraries, where handlers and helpers express waiting through async functions and await.
With vthread, I'm exploring a runtime model built around stacks and explicit task ownership. An ordinary function can pause inside a helper and resume later, with its calls and local values intact. The costs include reserved stack space, tasks staying on one carrier, and deliberate integration with blocking libraries.
Loading diagram…
from one task to a program
In the complete server, a local scope groups the connection handlers under the server task. Like the greeting's scope, the local scope waits for its tasks to be cleaned up before returning. Dropping a join handle doesn't detach a task.
Our server propagates a handler's error when joining it. That makes the scope body fail, so the scope requests cancellation of its children and waits for cleanup. Another server could record the error and keep serving; failure policy belongs to the application.
A graceful shutdown stops accepting connections and lets the work already accepted finish, with cancellation available if it takes too long. Tasks observe cancellation at checkpoints; it cannot interrupt arbitrary Rust code. A blocking call already running in the worker pool can continue after its virtual caller stops waiting. Runtime shutdown waits for those calls too.
Every connection still holds a socket, a buffer, and stack space. vthread exposes resource limits, and spawn can fail when capacity is exhausted. The reference server limits the number of outstanding handlers, collecting results before accepting more.
Some work needs to outlive a connection, such as a notification job. A supervisor can own the workers, with a channel carrying jobs from requests. Sending to a full bounded channel can pause the sender until a worker makes room. The reference notification service combines these pieces on one runtime.
the choices behind vthread
Java 21 made virtual threads a standard feature through JEP 444. Its runtime and standard library let many existing blocking calls release their carrier. vthread works as a Rust library, using its own waiting APIs and explicit blocking delegation.
Once started, a task stays on one carrier. It can create values such as Rc, which can't be sent between threads, and keep them across a pause. An idle carrier can't take over that task. The closures passed to Scope::spawn, and the values they return, must still be Send + 'static. Local scopes allow borrowed, non-Send children, all on their parent's carrier.
The stack backend keeps stacks at stable addresses so references remain valid across a pause. Each stack reserves a fixed range of address space, even while its task waits. These stacks do not grow automatically. Completed stack allocations can be reused for later tasks.
where it stands
The 0.1.0 release is available for Linux x86_64 and macOS ARM64. You'll need Rust 1.96 or newer and panic unwinding enabled. The release notes cover supported workloads and runtime limits.
Try the two-connection server: keep one client quiet while the other sends data, then come back to the first. The reference application goes further with pipelines, services, and controlled shutdown.
I want to learn where this model fits in real programs. I'd like to hear about libraries that are awkward to integrate, waits that are hard to identify, or work whose lifetime is difficult to express. Those cases will shape vthread.