Skip to content

Latest commit

 

History

69 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

just-listen

CI

The simplest possible HTTP server, across two categories of languages.

Each implementation listens on port 8080 and replies with HTTP 200 to any request.

Why?

To see what each language looks like at the floor: before frameworks, before abstractions, before opinions. Run them, read them, compare them.

Two categories

Category 1 — API development

These languages ship an HTTP server in their standard SDK. No external packages required. They model how most application servers are built.

Language Command Prerequisite
Go make go Go 1.26+
Java make java JDK 25+
Node.js make nodejs Node.js 24 LTS+
.NET make dotnet .NET 10+

Category 2 — Load Balancer / Reverse Proxy

These languages lack a stdlib HTTP server. They use foundational HTTP libraries — the same layer that nginx, envoy, and HAProxy are built on. External dependencies are allowed but each is justified in ADR-0006.

Language Command Prerequisite
C make c C23 compiler + libmicrohttpd-dev
C++ make cpp C++23 compiler + libboost-all-dev
Rust make rust Rust 1.x stable
Zig make zig Zig 0.14+
D make d DMD 2.110+ or LDC

Test any server with:

curl -i http://localhost:8080/

Expected response: HTTP/1.1 200 OK


Category 1 implementations

Go (go/)

Uses net/http from the Go standard library. An empty handler causes net/http to flush a 200 automatically.

Run: make go or cd go && go run main.go


Java (java/)

Uses com.sun.net.httpserver.HttpServer — ships in every JDK. Uses JEP 512 Compact Source Files (finalized in Java 25): no class declaration, no javac step. Uses virtual threads (Executors.newVirtualThreadPerTaskExecutor()) for modern Java concurrency.

Run: make java or cd java && java Server.java


Node.js (nodejs/)

Uses the built-in node:http module with ES module syntax. node: prefix is canonical in Node.js 24.

Run: make nodejs or cd nodejs && node server.js


.NET (dotnet/)

Uses ASP.NET Core Minimal APIs via Microsoft.NET.Sdk.Web (ships with the .NET 10 SDK — no dotnet add package). Kestrel serves HTTP directly.

Run: make dotnet or cd dotnet && dotnet run


Category 2 implementations

C (c/)

Uses libmicrohttpd — a GNU embedded HTTP server library, pure C, LGPLv2.1. Present in every major Linux distro. The callback-based API requires no manual HTTP framing.

Run: make c (install: sudo apt-get install libmicrohttpd-dev)
Concurrency: OS threads (internal polling thread)
I/O: Event polling (epoll / kqueue)


C++ (cpp/)

Uses Boost.Beast over Boost.Asio. Asio is the proposed basis for the C++ networking stdlib; Beast adds the HTTP layer on top. Each connection is a C++20 coroutine (asio::awaitable<void> launched with asio::co_spawn); the loop co_awaits async_read / async_write, suspending the coroutine — not the OS thread — until the I/O completes.

Run: make cpp (install: sudo apt-get install libboost-all-dev)
Concurrency: C++20 stackless coroutine per connection on a single-threaded io_context (co_await at each async_read / async_write)
I/O: Event loop (Asio reactor: epoll / kqueue / io_uring)


Rust (rust/)

Uses tokio (async runtime) + hyper (HTTP library). Hyper is the foundation of pingora, linkerd2-proxy, and most production Rust HTTP infrastructure. No application framework layer.

Run: make rust or cd rust && cargo run --release
Concurrency: M:N async tasks on Tokio's multi-thread runtime
I/O: Event loop (mio: epoll / kqueue / IOCP)


Zig (zig/)

Uses std.http.Server from the Zig standard library — Zig is the only Category-2 language with a built-in HTTP server. Thread-per-connection via std.Thread.spawn.

Run: make zig or cd zig && zig build run
Concurrency: Thread-per-connection (std.Thread)
I/O: Blocking I/O


D (d/)

Uses vibe-http — the HTTP-only sub-package of the vibe.d project. vibe-http provides HTTP/1 + HTTP/2 built on vibe-core's fiber scheduler; it is the foundational layer that the vibe.d web framework builds on top of.

Run: make d or cd d && dub run
Concurrency: Fibers (vibe-core's M:N fiber scheduler over OS threads)
I/O: Event loop (eventcore: epoll / kqueue / IOCP)


Comparison

Category 1 — API development

Language Version LOC Run command HTTP primitive I/O event notification Event dispatch
Go 1.26 5 go run main.go net/http epoll Goroutine per connection
Java 25 8 java Server.java com.sun.net.httpserver epoll Virtual thread per request
Node.js 24 2 node server.js node:http epoll Callback on event loop
Node.js (Bun) latest 2 bun server.js node:http epoll Callback on event loop
Node.js (Bun Canary) canary 2 bun server.js node:http epoll Callback on event loop
.NET 10 3 dotnet run Kestrel (Sdk.Web) epoll Thread-pool task

Category 2 — Load Balancer / Reverse Proxy

Language Version LOC Run command HTTP library External dep I/O event notification Event dispatch
C C23 ~25 cc -std=c23 server.c -lmicrohttpd -o server && ./server libmicrohttpd libmicrohttpd-dev epoll OS thread per connection
C++ C++23 ~25 c++ -std=c++23 server.cpp -lpthread -o server && ./server Boost.Beast libboost-all-dev epoll C++20 coroutine per connection
Rust 1.x ~20 cargo run --release hyper tokio, hyper, hyper-util, http-body-util epoll Async task (Tokio)
Zig 0.14 ~25 zig build run std.http.Server none none (blocking) New thread per connection
D 2.110 ~10 dub run vibe-http vibe-http epoll Fiber per connection

Concurrency primitives

The OS thread is the foundation

Every concurrency model in the table above ultimately runs on OS threads — the only unit of execution the operating system kernel actually schedules onto CPU cores. An OS thread (a POSIX pthread / Linux task, created via clone(2)) is a kernel-managed sequence of execution with its own stack and register state. The kernel's scheduler decides which thread runs on which core and preempts it (forcibly suspends it) when its time slice expires or it blocks on a syscall.

OS threads are powerful but heavy:

  • Memory: each one reserves a stack (typically 1–8 MB of virtual address space by default), so tens of thousands of them cost real memory.
  • Creation/teardown: a syscall into the kernel — milliseconds-scale, not free.
  • Context switching: swapping one thread for another means a kernel trap, saving/restoring registers, and often a TLB/cache disruption — on the order of microseconds, which adds up under high concurrency (the "C10k" problem).

Because of this cost, language runtimes invented lighter-weight abstractions (goroutines, virtual threads, async tasks, fibers, coroutines). None of them escape OS threads — they all multiplex many cheap user-space units onto a small pool of OS threads (usually one per core). The runtime, not the kernel, decides which user-space unit runs on a thread next. The difference between them is mostly who yields control and when, and whether the runtime can move a unit between threads.

Comparing the lightweight units

All of the following are "run many concurrent tasks on few OS threads" schemes. They differ along three axes: scheduling (cooperative = a task only yields at explicit suspension points; preemptive = the runtime can interrupt it), stack model (does each task own a growable stack, or is its state compiled into a state machine?), and how blocking I/O is handled.

Primitive Language / Runtime Scheduled by Scheduling Stack model Yields on Can migrate across OS threads?
Goroutine Go runtime Go scheduler (M:N) Preemptive (async, since Go 1.14) Small growable stack (starts ~2 KB) Channel ops, blocking calls, function preemption Yes — work-stealing across GOMAXPROCS threads
Virtual thread JVM (Project Loom) JVM scheduler (M:N) Cooperative (yields on blocking I/O) Heap-stored continuation; unmounts from carrier Blocking I/O, synchronized/locks, sleeps Yes — mounts/unmounts on a pool of carrier threads
Async task Rust + Tokio Tokio executor (M:N) Cooperative No stack — compiled to a Future state machine .await points only Yes (with Send) — work-stealing scheduler
C++20 coroutine C++ + Boost.Asio Asio io_context / executor Cooperative Compiler-generated frame (co_await state machine) co_await points only Depends on executor — single-threaded here, pinned to one thread
Coroutine Kotlin (kotlinx.coroutines) Dispatcher (M:N) Cooperative Compiler-generated continuation (suspend functions) suspend call points only Yes — depends on dispatcher (e.g. Dispatchers.Default)
.NET Task .NET CLR Thread-pool task scheduler Cooperative async/await state machine on heap await points only Yes — runs on thread-pool worker threads
Swift Task Swift Concurrency Cooperative thread pool (M:N) Cooperative async/await continuation (state machine) await (and actor) suspension points Yes (Sendable) — unless pinned to an actor/@MainActor
Fiber D (vibe-core) / others Userspace fiber scheduler Cooperative Own dedicated stack (full context switch) Explicit yields / blocking wrapped calls Typically pinned to one thread (implementation-dependent)
Promise / async task JavaScript (Node.js, Bun) Single event loop (1:N) Cooperative async/await microtask (state machine) await points / promise resolution No — one event-loop thread, no parallelism

Mapping those units onto the two axes — stackful vs. stackless (how a task stores its suspended state) and cooperative vs. preemptive (who decides when it yields):

quadrantChart
    title Lightweight concurrency units
    x-axis "Stackless (state machine)" --> "Stackful (own call stack)"
    y-axis "Cooperative (yields itself)" --> "Preemptive (runtime interrupts)"
    quadrant-1 "Stackful + Preemptive"
    quadrant-2 "Stackless + Preemptive"
    quadrant-3 "Stackless + Cooperative"
    quadrant-4 "Stackful + Cooperative"
    "Goroutine": [0.78, 0.82]
    "Virtual thread": [0.70, 0.28]
    "Fiber": [0.85, 0.16]
    "Rust async task": [0.20, 0.34]
    "Kotlin coroutine": [0.32, 0.20]
    ".NET Task": [0.18, 0.12]
    "Swift Task": [0.30, 0.40]
    "C++20 coroutine": [0.42, 0.30]
    "JS Promise": [0.14, 0.26]
Loading

Note the top-left quadrant is empty: a stackless unit is just a heap state machine with no saved registers/stack, so there's nothing for a preemptive scheduler to safely interrupt mid-step. Goroutines sit alone on the preemptive side — Go's runtime can asynchronously interrupt a running goroutine, while every other unit only yields at explicit suspension points.

The key split is stackful vs. stackless:

  • Stackful (goroutines, fibers, virtual threads): each task has a real call stack, so it can suspend from anywhere, deep inside nested function calls, with no special syntax. Costs more memory per task (the stack), but ordinary blocking-looking code "just works."
  • Stackless (Rust async tasks, Kotlin coroutines, .NET Tasks, Swift Tasks, C++20 coroutines, JS promises): the compiler transforms the task into a state machine, so it can only suspend at explicit .await/suspend/co_await/await points. Extremely cheap (a task is just a struct on the heap), but it requires the "function coloring" of async/await to propagate through the call chain.

Cooperative vs. preemptive matters for fairness: a cooperative task that never hits a yield point (e.g. a tight CPU loop with no .await) can starve others sharing its OS thread. Go is unusual here — its scheduler can preempt a goroutine asynchronously, so a runaway loop won't block the whole runtime.

Parallelism vs. concurrency is a separate axis: every unit above except the JavaScript event loop multiplexes onto multiple OS threads (M:N), so tasks run in parallel across cores. JavaScript promises are concurrent but not parallel — they all share one event-loop thread, so a CPU-bound task blocks every other task until it yields. (Node.js offers real parallelism only via separate worker_threads or processes, not via promises.)

Why not Python, Ruby, or PHP?

Python's http.server, Ruby's WEBrick, and PHP's -S dev server all explicitly state they are not for production use. The ecosystem convention for each is to run application code behind a separate production server (Gunicorn, Puma, PHP-FPM) proxied by Nginx. These languages don't "serve HTTP themselves" in the same way Go, Java, Node.js, and .NET do. See ADR-0002.

Architecture Decisions

All structural decisions are documented in docs/adr/:

ADR Decision
0001 Use ADRs to document decisions
0002 Language selection rationale
0003 Standard SDK only — no external packages (Category 1)
0004 Standardize on port 8080
0005 One directory per language
0006 Load Balancer / Reverse Proxy category — justified external deps

Contributing

See CONTRIBUTING.md and TASKS.md for open work items.

License

MIT

About

No description, website, or topics provided.

Resources

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages