$ ldd --version # how does your code actually run?

Runtimes

// execution-models

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.

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

JIT (Just-In-Time)

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

Bytecode VM

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)

Interpreter

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

WebAssembly

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

Transpiler

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

Side-by-Side Comparison

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.

ModelStartupPeak perf.MemoryPortabilityMemory mgmt
Native (AOT)FastHighestLowestRecompile per targetManual / ownership
JITSlow (warm-up)Near-nativeHighVM per platformGC (tracing)
Bytecode VMMediumGoodMediumVM per platformGC (tracing)
InterpreterInstantLowestMediumInterpreter per platformGC (ref-counting / tracing)
WebAssemblyFastNear-nativeLowUniversal (sandboxed)Depends on source lang
TranspilerDepends on targetDepends on targetDepends on targetSame as targetSame as target

// std::thread vs async fn

Synchronous vs Asynchronous (Rust)

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 unitOS thread (1:1 with a kernel thread)Future / task (M:N on a thread pool)
Spawned withstd::thread::spawntokio::spawn / smol::spawn
Cost per unit~1–8 MB stack + kernel bookkeeping~a few hundred bytes, no own stack
Practical countThousandsMillions
SchedulingPre-emptive, by the OS kernelCooperative, yields only at .await points
Blocking a callFine, only that one thread waitsDangerous: stalls the whole executor (use spawn_blocking)
I/OBlocking syscalls (read / write)Non-blocking + epoll / kqueue / io_uring
CPU-bound workIdeal (threads, rayon)Poor: offload to a thread pool
Many connectionsLimited by thread countExcellent: the reason async exists
CancellationHard: no safe way to kill a threadEasy: just drop the future
ComplexitySimple, direct, no function colouringasync colouring, Send + 'static, Pin, lifetimes
Ecosystemstd, rayon, crossbeamtokio, 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

  • Asks the OS for 4 real threads, each with its own multi-MB stack. The kernel schedules them pre-emptively across cores.
  • join() blocks the main thread until each worker returns. While it waits, that thread does nothing else.
  • For CPU work this is exactly right: 4 threads can use 4 cores in true parallel. For 10,000 workers it collapses: thread creation and context switches dominate.

What the async version does

  • Creates 4 tiny tasks (state machines of a few hundred bytes), all multiplexed onto Tokio's small thread pool. No new OS threads per worker.
  • .await yields instead of blocking: while one task waits, the thread runs another. A stray blocking call here would freeze all of them.
  • For pure 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 { ... })

Async & Concurrency Runtimes

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.

Rust

Stackless coroutines via async/await. The compiler generates state machines; a user-chosen executor polls them.

  • TokioGeneral-purpose multi-threaded, work-stealing scheduler. The default choice for servers.
  • smolLightweight, single-dependency executor. Good for CLIs and small services.
  • embassyAsync executor for embedded / bare-metal (no_std). Runs on microcontrollers.
  • glommioThread-per-core, io_uring-based. Optimised for high-throughput storage and networking.
  • compioCompletion-based (io_uring / IOCP). Cross-platform alternative to glommio.

JavaScript / TypeScript

Single-threaded event loop with async/await. I/O callbacks are queued; the microtask queue runs Promises.

  • V8 (Chrome, Node.js, Deno, Bun)JIT-compiled engine with libuv (Node) or native event loop (Deno/Bun).
  • SpiderMonkey (Firefox)Mozilla's engine with baseline + IonMonkey JIT tiers.
  • JavaScriptCore (Safari, Bun)Apple's engine, also used by Bun for fast startup.

Python

asyncio event loop with async/await. The GIL limits true parallelism in CPython; use multiprocessing or a native extension for CPU work.

  • asyncio (stdlib)Default event loop. selector-based on Unix, IOCP on Windows.
  • uvloopDrop-in replacement built on libuv. 2-4x faster than the default loop.
  • TrioStructured-concurrency-first library: nurseries enforce task lifetime rules.
  • AnyIOCompatibility layer that works on top of asyncio or Trio.

Go

M:N scheduling with goroutines (lightweight green threads) and channels. No async/await syntax needed: every function is implicitly non-blocking.

  • Go runtimeBuilt-in scheduler multiplexes goroutines across OS threads. netpoller handles I/O.

Java / Kotlin (JVM)

Platform threads mapped 1:1 to OS threads, plus (since Java 21) virtual threads via Project Loom for M:N scheduling.

  • Platform threadsClassic OS threads, heavyweight (~1MB stack each).
  • Virtual threads (Loom)JVM-managed lightweight threads, millions per process. Blocking calls auto-yield.
  • Kotlin coroutinesStructured concurrency with suspend/resume, dispatched to thread pools.

C#

Task-based asynchronous pattern (TAP) with async/await. The runtime schedules continuations on the thread pool or a synchronisation context.

  • .NET ThreadPool + Task schedulerWork-stealing thread pool. Task.Run dispatches CPU work; I/O operations use IOCP.

Erlang / Elixir

Actor model on the BEAM VM: each process is an isolated lightweight unit with its own heap, communicating solely via message passing.

  • BEAM VMPreemptive scheduler with per-process reduction budgets. Millions of processes, no shared state.
  • OTPFramework of supervisors, gen_servers and behaviours. Fault tolerance by design.

// same task, different runtimes

Same Pattern, 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
DescriptionSingle-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.
SchedulerSingle-threaded event loop (selector / IOCP)Multi-threaded work-stealing (configurable thread count)M:N scheduler, goroutines multiplexed on OS threadsSingle or multi-threaded (smol::Executor or global)
I/O backendepoll / kqueue / IOCP via selectorsepoll / kqueue / IOCP (mio)netpoller (epoll / kqueue / IOCP)epoll / kqueue / IOCP (polling crate)
Task modelCoroutines (async def), single-threadedFutures (state machines), Send + 'static for spawnGoroutines (implicit green threads)Futures, same trait bounds as Tokio
Wakeup / signalasyncio.Event (set / wait / clear)tokio::sync::Notify (notify_waiters / notified)Channels (chan struct{}) or sync.Condevent_listener::Event (notify / listen)
Fan-outasyncio.create_task + asyncio.gathertokio::task::JoinSet or tokio::spawngo func() + sync.WaitGroupsmol::spawn + futures_lite combinators
ParallelismNo (GIL). Use multiprocessing for CPU work.Yes, tasks distributed across a thread poolYes, GOMAXPROCS goroutines run in parallelYes, with smol::Executor on multiple threads
Best forI/O-bound services, rapid prototyping, scriptingGeneral-purpose servers, APIs, proxiesMicroservices, CLIs, network infrastructureCLIs, 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 loop

Rust / 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

The Big Picture

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.

Source code.rs / .c / .py / .jsAOT compilergcc, rustc, goMachine codeELF / Mach-O / PEBytecode + JITJVM, V8, CLROptimised codeat runtimeInterpreterCPython, MRI, BashExecuted livestatement by stmtCPUhardware executionx86 / ARM / RISC-V
AOT-compiled languages (Rust, C, Go) take the top path: everything is resolved before execution. JIT languages (Java, JS) defer optimisation to runtime. Interpreters (Python, Bash) skip compilation entirely.