Native (AOT)
Compiled ahead-of-time to machine code that the CPU runs directly. No layer in between: fastest, smallest, but tied to one platform.
languages
C, C++, Rust, Go, Zig, Fortran, Swift, OCaml
$ ldd --version # how does your code actually run?
// execution-models
Every language needs a way to turn source code into running instructions. That "way" is the execution model, and it fundamentally determines startup time, peak performance, memory usage, and portability. The six families below cover essentially every language on the PL Timeline.
Compiled ahead-of-time to machine code that the CPU runs directly. No layer in between: fastest, smallest, but tied to one platform.
languages
C, C++, Rust, Go, Zig, Fortran, Swift, OCaml
Runs from bytecode, then recompiles hot paths into optimised native code while the program is running. Slow to warm up, near-native once hot.
languages
Java (HotSpot), C# (.NET), JavaScript (V8), Julia, LuaJIT, PyPy
Compiled to portable bytecode that a virtual machine executes, providing GC and a sandbox. Write once, run anywhere the VM exists.
languages
Java (JVM), C# (CLR), Erlang (BEAM), Python (CPython), Ruby (YARV)
Reads and executes the source statement by statement, no compile step. Instant startup and a live REPL, but slowest for heavy CPU work.
languages
Python, Ruby, PHP, Perl, sh / Bash, R, SQL
Compiled to a portable binary that runs inside a sandbox (browser or Wasmtime). Near-native speed; I/O only through explicit host imports.
languages
Rust, C, C++, Go, Zig, AssemblyScript
Translated to another high-level language (usually JS or C) rather than machine code, then that language's toolchain takes over.
languages
TypeScript → JS, Elm → JS, PureScript → JS, Cython → C
// diff --side-by-side
A quick overview of the key trade-offs between execution models. Every choice is a compromise: native compilation gives you peak speed but locks you to a target; interpreters start instantly but pay for it every instruction.
| Model | Startup | Peak perf. | Memory | Portability | Memory mgmt |
|---|---|---|---|---|---|
| Native (AOT) | Fast | Highest | Lowest | Recompile per target | Manual / ownership |
| JIT | Slow (warm-up) | Near-native | High | VM per platform | GC (tracing) |
| Bytecode VM | Medium | Good | Medium | VM per platform | GC (tracing) |
| Interpreter | Instant | Lowest | Medium | Interpreter per platform | GC (ref-counting / tracing) |
| WebAssembly | Fast | Near-native | Low | Universal (sandboxed) | Depends on source lang |
| Transpiler | Depends on target | Depends on target | Depends on target | Same as target | Same as target |
// std::thread vs async fn
Rust is unusual in giving you both models as first-class citizens with zero runtime by default. Synchronous code uses real OS threads and blocking calls: simple and perfect for CPU work. Asynchronous code uses async/.await plus an executor (Tokio, smol…) to juggle millions of tasks on a few threads: the right tool when you're I/O-bound. Neither is "better"; they solve different problems.
| Synchronous (threads) | Asynchronous (async / await) | |
|---|---|---|
| Concurrency unit | OS thread (1:1 with a kernel thread) | Future / task (M:N on a thread pool) |
| Spawned with | std::thread::spawn | tokio::spawn / smol::spawn |
| Cost per unit | ~1–8 MB stack + kernel bookkeeping | ~a few hundred bytes, no own stack |
| Practical count | Thousands | Millions |
| Scheduling | Pre-emptive, by the OS kernel | Cooperative, yields only at .await points |
| Blocking a call | Fine, only that one thread waits | Dangerous: stalls the whole executor (use spawn_blocking) |
| I/O | Blocking syscalls (read / write) | Non-blocking + epoll / kqueue / io_uring |
| CPU-bound work | Ideal (threads, rayon) | Poor: offload to a thread pool |
| Many connections | Limited by thread count | Excellent: the reason async exists |
| Cancellation | Hard: no safe way to kill a thread | Easy: just drop the future |
| Complexity | Simple, direct, no function colouring | async colouring, Send + 'static, Pin, lifetimes |
| Ecosystem | std, rayon, crossbeam | tokio, async-std, smol, futures |
Same task, both ways
Spawn 4 workers, each computes i * i, then collect the results
Synchronous : std::thread
use std::thread;
fn main() {
let mut handles = Vec::new();
for i in 0..4 {
// each worker gets its own OS thread (~MBs of stack)
handles.push(thread::spawn(move || i * i));
}
// join blocks the main thread until each finishes
let results: Vec<i32> = handles
.into_iter()
.map(|h| h.join().unwrap())
.collect();
println!("{:?}", results); // [0, 1, 4, 9]
}Asynchronous : tokio::spawn
#[tokio::main] // sets up the async runtime
async fn main() {
let mut handles = Vec::new();
for i in 0..4 {
// each worker is a lightweight task (~hundreds of bytes)
handles.push(tokio::spawn(async move { i * i }));
}
// .await yields instead of blocking the thread
let mut results = Vec::new();
for h in handles {
results.push(h.await.unwrap());
}
println!("{:?}", results); // [0, 1, 4, 9]
}What the sync version does
join() blocks the main thread until each worker returns. While it waits, that thread does nothing else.What the async version does
.await yields instead of blocking: while one task waits, the thread runs another. A stray blocking call here would freeze all of them.i * i this adds overhead for no gain. The payoff appears with thousands of I/O-bound tasks (sockets, DB queries) idling at once.Same output, different machine behaviour. Both print [0, 1, 4, 9], but the sync version spends its cost on threads and stacks (great for CPU), while the async version spends it on a scheduler and state machines (great for waiting on I/O). The rule of thumb: threads when you're compute-bound, async when you're I/O-bound and highly concurrent.
// tokio::spawn(async move { ... })
Beyond the base execution model, many languages need a concurrency runtime to schedule async tasks, manage I/O multiplexing, and distribute work across cores. Some languages bake it in (Go, Erlang); others let you choose (Rust, Python). The differences matter when you're building servers, embedded systems, or anything that waits on I/O.
Stackless coroutines via async/await. The compiler generates state machines; a user-chosen executor polls them.
Single-threaded event loop with async/await. I/O callbacks are queued; the microtask queue runs Promises.
asyncio event loop with async/await. The GIL limits true parallelism in CPython; use multiprocessing or a native extension for CPU work.
M:N scheduling with goroutines (lightweight green threads) and channels. No async/await syntax needed: every function is implicitly non-blocking.
Platform threads mapped 1:1 to OS threads, plus (since Java 21) virtual threads via Project Loom for M:N scheduling.
Task-based asynchronous pattern (TAP) with async/await. The runtime schedules continuations on the thread pool or a synchronisation context.
Actor model on the BEAM VM: each process is an isolated lightweight unit with its own heap, communicating solely via message passing.
// same task, different runtimes
Pick up to four runtimes and compare them directly. First the same spawn & collect task as above (4 workers computing i * i) to see the bare syntax, then a real-world outbox-relay worker: a listener watches for new work (e.g. database LISTEN/NOTIFY), signals a wakeup, and N relay workers pick it up and process it concurrently. Each snippet has a short note on what makes that runtime distinctive.
| Python asyncio | Rust / Tokio | Go (goroutines) | Rust / smol | |
|---|---|---|---|---|
| Description | Single-threaded event loop. asyncio.Event for signalling, create_task for fan-out, gather to join. | Multi-threaded work-stealing scheduler. tokio::sync::Notify for wakeup, tokio::spawn for fan-out, JoinSet to collect handles. | M:N scheduler built into the runtime. No async/await: goroutines block transparently. Channels and sync primitives for coordination. | Lightweight single-dependency executor. Same async/await, but uses smol::Timer and event_listener::Event instead of Tokio primitives. |
| Scheduler | Single-threaded event loop (selector / IOCP) | Multi-threaded work-stealing (configurable thread count) | M:N scheduler, goroutines multiplexed on OS threads | Single or multi-threaded (smol::Executor or global) |
| I/O backend | epoll / kqueue / IOCP via selectors | epoll / kqueue / IOCP (mio) | netpoller (epoll / kqueue / IOCP) | epoll / kqueue / IOCP (polling crate) |
| Task model | Coroutines (async def), single-threaded | Futures (state machines), Send + 'static for spawn | Goroutines (implicit green threads) | Futures, same trait bounds as Tokio |
| Wakeup / signal | asyncio.Event (set / wait / clear) | tokio::sync::Notify (notify_waiters / notified) | Channels (chan struct{}) or sync.Cond | event_listener::Event (notify / listen) |
| Fan-out | asyncio.create_task + asyncio.gather | tokio::task::JoinSet or tokio::spawn | go func() + sync.WaitGroup | smol::spawn + futures_lite combinators |
| Parallelism | No (GIL). Use multiprocessing for CPU work. | Yes, tasks distributed across a thread pool | Yes, GOMAXPROCS goroutines run in parallel | Yes, with smol::Executor on multiple threads |
| Best for | I/O-bound services, rapid prototyping, scripting | General-purpose servers, APIs, proxies | Microservices, CLIs, network infrastructure | CLIs, small services, libraries wanting minimal deps |
Spawn & collect
Same task as the sync/async example: spawn 4 workers, each computes i * i, then collect the results
Python asyncio
One event loop on one thread. async def marks a coroutine; gather runs them concurrently but never truly in parallel (the GIL). The simplest mental model of the five.
import asyncio
async def worker(i: int) -> int:
return i * i # a trivial async task
async def main() -> None:
# create_task schedules them; gather awaits all, in order
tasks = [asyncio.create_task(worker(i)) for i in range(4)]
results = await asyncio.gather(*tasks)
print(results) # [0, 1, 4, 9]
asyncio.run(main()) # starts + drives the loopRust / Tokio
#[tokio::main] bootstraps a multi-thread runtime. spawn moves each task onto a thread pool, so they can run in real parallel; the trade-off is that tasks must be Send + 'static.
#[tokio::main] // sets up the async runtime
async fn main() {
let mut handles = Vec::new();
for i in 0..4 {
// each worker is a lightweight task (~hundreds of bytes)
handles.push(tokio::spawn(async move { i * i }));
}
// .await yields instead of blocking the thread
let mut results = Vec::new();
for h in handles {
results.push(h.await.unwrap());
}
println!("{:?}", results); // [0, 1, 4, 9]
}Go (goroutines)
No async or await keywords at all. go f() starts a goroutine; a WaitGroup joins them. Blocking calls yield the thread transparently.
package main
import (
"fmt"
"sync"
)
func main() {
results := make([]int, 4)
var wg sync.WaitGroup
for i := 0; i < 4; i++ {
wg.Add(1)
go func(i int) { // no async keyword, just "go"
defer wg.Done()
results[i] = i * i
}(i)
}
wg.Wait() // join all goroutines
fmt.Println(results) // [0 1 4 9]
}Rust / smol
No runtime macro: block_on drives the futures directly. You assemble exactly the pieces you need, keeping the dependency footprint tiny.
fn main() {
// block_on drives the futures; no #[main] macro needed
smol::block_on(async {
let mut handles = Vec::new();
for i in 0..4 {
handles.push(smol::spawn(async move { i * i }));
}
let mut results = Vec::new();
for h in handles {
results.push(h.await); // smol tasks return the value directly
}
println!("{:?}", results); // [0, 1, 4, 9]
});
}Outbox relay worker
A real-world pattern: 1 listener signals a wakeup, N workers relay in parallel
Python asyncio
Wakeup uses asyncio.Event: you must clear() it yourself after each wait(). Fan-out is create_task + gather; everything shares one thread, so no locks are needed.
import asyncio
class OutboxRelayWorker:
def __init__(self, worker_count: int = 4):
self._worker_count = worker_count
async def _listen_loop(self, wakeup: asyncio.Event) -> None:
while True:
await asyncio.sleep(1) # stand-in for pg LISTEN/NOTIFY
wakeup.set() # wake every waiter at once
async def _relay_loop(self, wakeup: asyncio.Event) -> None:
while True:
await wakeup.wait() # suspend until signalled
wakeup.clear() # must reset the flag manually
print("relaying outbox batch...")
async def run(self) -> None:
wakeup = asyncio.Event()
# create_task schedules coroutines on the single event loop
tasks = [
asyncio.create_task(self._listen_loop(wakeup), name="listen"),
*[
asyncio.create_task(self._relay_loop(wakeup), name=f"relay-{i}")
for i in range(self._worker_count)
],
]
await asyncio.gather(*tasks) # join all tasks
asyncio.run(OutboxRelayWorker().run())Rust / Tokio
Notify is shared via Arc because tasks land on different threads. notified() needs no manual clear. JoinSet owns the spawned handles and lets you join them all in one loop.
use tokio::sync::Notify;
use std::sync::Arc;
// Notify is shared via Arc because tasks run on different threads
async fn listen_loop(wakeup: Arc<Notify>) {
loop {
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
wakeup.notify_waiters(); // wake all current waiters
}
}
async fn relay_loop(wakeup: Arc<Notify>) {
loop {
wakeup.notified().await; // no manual clear needed
println!("relaying outbox batch...");
}
}
#[tokio::main]
async fn main() {
let wakeup = Arc::new(Notify::new());
let mut set = tokio::task::JoinSet::new();
set.spawn(listen_loop(wakeup.clone())); // onto the thread pool
for _ in 0..4 {
set.spawn(relay_loop(wakeup.clone())); // Send + 'static required
}
while set.join_next().await.is_some() {} // join the whole set
}Go (goroutines)
The channel *is* the signal: there is no separate Event/Notify type. Workers range over the channel; a WaitGroup replaces JoinSet/gather. Everything is plain blocking code the scheduler makes concurrent.
package main
import (
"fmt"
"sync"
"time"
)
type OutboxRelayWorker struct {
workerCount int
}
func (w *OutboxRelayWorker) listenLoop(wakeup chan struct{}) {
for {
time.Sleep(1 * time.Second)
select {
case wakeup <- struct{}{}: // non-blocking send
default: // drop if no worker is ready
}
}
}
func (w *OutboxRelayWorker) relayLoop(wakeup chan struct{}, wg *sync.WaitGroup) {
defer wg.Done()
for range wakeup { // ranges until the channel closes
fmt.Println("relaying outbox batch...")
}
}
func (w *OutboxRelayWorker) Run() {
wakeup := make(chan struct{}, 1) // the channel *is* the signal
var wg sync.WaitGroup
go w.listenLoop(wakeup) // no async keyword, just "go"
for i := 0; i < w.workerCount; i++ {
wg.Add(1)
go w.relayLoop(wakeup, &wg)
}
wg.Wait()
}
func main() {
(&OutboxRelayWorker{workerCount: 4}).Run()
}Rust / smol
Same async/await as Tokio, but the primitives come from small crates: event_listener::Event replaces Notify, and you build the multi-thread Executor by hand instead of getting it from a macro.
use event_listener::Event;
use std::sync::Arc;
// event_listener::Event is smol's stand-in for Tokio's Notify
async fn listen_loop(wakeup: Arc<Event>) {
loop {
smol::Timer::after(std::time::Duration::from_secs(1)).await;
wakeup.notify(usize::MAX); // wake all listeners
}
}
async fn relay_loop(wakeup: Arc<Event>) {
loop {
wakeup.listen().await; // register + wait for a signal
println!("relaying outbox batch...");
}
}
fn main() {
let wakeup = Arc::new(Event::new());
// no runtime macro: build a multi-thread executor by hand
let ex = Arc::new(smol::Executor::new());
smol::block_on(ex.run(async {
ex.spawn(listen_loop(wakeup.clone())).detach();
for _ in 0..4 {
ex.spawn(relay_loop(wakeup.clone())).detach();
}
futures_lite::future::pending::<()>().await;
}));
}// from source to execution
All execution models are variations on the same pipeline. The key question is when compilation happens and how many layers sit between your source code and the CPU.