Rill: A Garbage-Collector-Free, Go-Style Concurrent Functional Language (From the Basics to a Web Server)
A long tour of Rill from start to finish: algebraic data types, Result and ?, tail recursion, strands and channels, timeouts with select, memory management without a garbage collector, and the Rill server behind this blog. Every code sample was run; the outputs are real.

For a few years one question kept coming back to me: can you put the type system of Haskell and OCaml, the concurrency model of Go and speed close to C's output into one language, with no virtual machine and no garbage collector in the way? Rill is the language I wrote to look for the answer. The page you are reading right now is served by an HTTP server written in Rill.
This is going to be a long tour. We start from the basics of the language and move through algebraic data types, carrying errors with ?, loops that are really tail recursion, concurrency with strands and channels, managing memory without a garbage collector, and finally the server behind this blog. I compiled and ran every code sample in this post before writing it. The outputs are real outputs. (The strings inside the programs are in Turkish because they are exactly the programs I ran.)
What Rill is, and what it is not
Rill is a strict, statically typed functional language. It compiles straight to native code through LLVM. A handful of core decisions give the language its character:
- Type inference everywhere. Annotations are optional. Generics, traits and requirements such as "this type must be ordered" are inferred and checked by the compiler.
- Algebraic data types and pattern matching. Nested patterns, guards, and an exhaustiveness checker that names the case you forgot.
- No garbage collector. Memory is managed with reference counting inserted at compile time (Perceus-style). A value is freed the moment it dies; there are no pauses.
- Go's concurrency model.
spawnand typed channels over green threads. Rill calls them strands (the counterpart of a goroutine in Go). - Native code through LLVM -O3. Generics are monomorphized; nothing is boxed and trait calls are resolved at compile time. "Hello world" is 33 KB.
- One canonical syntax.
rill fmtgives every program exactly one spelling.
What Rill does not have is just as deliberate: no mutable variables, no exceptions, no null, no implicit conversions, no inheritance. The only things that change are pieces of storage: a buffer (Buf) element and a Map entry. Even there, what changes is not a binding but the storage the name points at.
First steps: installing and "hello"
The compiler is written in Rust and needs LLVM 21:
brew install llvm@21
cargo build --release
./target/release/rill run examples/hello.rill
./target/release/rill build main.rill -o main && ./main
The whole toolchain lives in one binary: build, run, check, test, fmt, repl, doc, explain and a language server (rill lsp). Seeing the type of the name under the cursor in your editor is the one place where a signature is said out loud in a language that infers everything.
Algebraic data types and pattern matching
In Rill a data type is the list of its constructors. The example below defines three shapes and computes the area of each. For the triangle we use Heron's formula, and for the square root we call C's math library directly:
type Shape
Circle(r: Float)
Rect(w: Float, h: Float)
Triangle(a: Float, b: Float, c: Float)
fn area(s) = match s
Circle(r) -> 3.14159 * r * r
Rect(w, h) -> w * h
Triangle(a, b, c) ->
p = (a + b + c) / 2.0
sqrt(p * (p - a) * (p - b) * (p - c))
fn describe(s) = match s
Circle(r) if r > 10.0 -> "büyük bir daire"
Circle(_) -> "bir daire"
Rect(w, h) if w == h -> "bir kare"
Rect(_, _) -> "bir dikdörtgen"
Triangle(_, _, _) -> "bir üçgen"
extern "m" fn sqrt(x: Float) -> Float
fn main() =
shapes = Cons(Circle(1.0), Cons(Rect(2.0, 2.0), Cons(Triangle(3.0, 4.0, 5.0), Cons(Circle(12.0), Nil))))
each(shapes, \s -> println(describe(s) + ": " + float_to_str(area(s))))
println(fold(map(shapes, area), 0.0, \acc, a -> acc + a))
Output:
bir daire: 3.14159
bir kare: 4
bir üçgen: 6
büyük bir daire: 452.38895999999994
465.53054999999995
A few things are worth pointing out:
- We did not write a single type (apart from constructor fields). The compiler works out by itself that
areaisShape -> Float. - The
ifguards indescribecover nothing. The lineCircle(r) if r > 10.0does not count as covering circles on its own; delete theCircle(_)line below it and the compiler names the missingCircle(_)case. A guarded arm cannot "hide" a case. - The line
extern "m" fn sqrt(...)links the function straight from C'slibm. There is no wrapper layer in between. - Floats print in the shortest form that reads back exactly:
452.38895999999994is precisely the number computed.
You can read the fields of a type with a single constructor with a dot (p.x). A type with several constructors can only be answered by match, because the field may not be there. Instead of changing a record you build a new one: { p | health = p.health - 35 } reads as "everything p has, except these".
Errors are values: Result and ?
Rill has no exceptions. A function that can fail says so in its type and the caller cannot forget it. That used to have a cost: a match for every call, two of whose three lines were the same every time. The ? operator is exactly those two lines: the value inside the Ok, or else the whole function's Err.
Let's write a tiny configuration reader:
type Config
Config(host: Str, port: Int)
fn field(text, key) =
found = find(lines(text), \l -> starts_with(l, key + "="))
match found
Some(l) -> Ok(str_sub(l, str_len(key) + 1, str_len(l)))
None -> Err("eksik alan: " + key)
fn number(s) =
n = str_to_int(s)
if n > 0 && n < 65536 then Ok(n) else Err("geçersiz port: " + s)
fn parse_config(text) =
host = field(text, "host")?
port = number(field(text, "port")?)?
Ok(Config(host, port))
fn main() =
println(parse_config("host=127.0.0.1\nport=8080"))
println(parse_config("host=127.0.0.1"))
println(parse_config("host=x\nport=99999"))
Ok(Config(127.0.0.1, 8080))
Err(eksik alan: port)
Err(geçersiz port: 99999)
? adds nothing at run time. The compiler turns the rest of the block into the Ok arm and produces the match you would have written by hand. A tail call after a ? is still a tail call. When layers have their own error types and the function's error type is defined as a sum type (such as type AppErr with Io(IoErr) and Parse(ParseErr)), ? carries the lower layer's error out wrapped in the matching constructor. Nothing is converted silently unless the type declares it.
No loops, and yet loops
Rill has no classic while loop. What it has instead is tail recursion, and that is a promise, not a hint: Rill functions are compiled with LLVM's guaranteed-tail-call convention and the call is marked musttail. The optimizer cannot take that guarantee back. Even a loop written as several functions calling each other in tail position runs in constant stack.
fn sum_to(n) =
fn go(i, acc) = if i > n then acc else go(i + 1, acc + i)
go(1, 0)
fn main() =
println(sum_to(100000000))
squares = buf_int(10)
for i in 0..10
squares[i] = i * i
total = for i in 0..10 with acc = 0
acc + squares[i]
println(total)
println(range(1, 20) |> filter(\x -> x % 3 == 0) |> map(\x -> x * x) |> sum)
words = split("rill ile yaz rill ile derle rill ile çalıştır", " ")
counts = map_new()
each(words, \w -> map_set(counts, w, map_or(counts, w, 0) + 1))
println(sort_by(map_items(counts), fst))
5000000050000000
285
819
Cons((derle, 1), Cons((ile, 3), Cons((rill, 3), Cons((yaz, 1), Cons((çalıştır, 1), Nil)))))
There are four ideas in this small program:
- Local functions are loops, not closures.
gosees thenof the enclosing function. The compiler lifts it to the top level and passesnas a parameter. Nothing is allocated and 100 million steps run in constant stack. for ... withreturns what it carries. The body's value becomes the nextacc. Writingacc = acc + v[i]in the body is refused, and the error tells you to usewith, because otherwise the loop would throw every iteration away.- A pipeline becomes one loop. The chain
range |> filter |> map |> sumis generated as a single loop with no list in it when the elements are numbers and the functions are written as lambdas. Map(k, v)is a hash table. Anything with structural equality can be a key; the compiler writes the hash function from the type.m[k]returns anOptionbecause the key may not be there. The order of a table belongs to the table; when you need an order you ask for it explicitly withsort_by.
Concurrency: strands and channels
Rill uses Go's model: many lightweight threads talking over rendezvous channels. Strands really are cheap: 100,000 strands take about 53 MB and start in microseconds. The secret is the copy-on-block stack model: a parked strand costs about 300 bytes.

First let's write a worker pool. Four worker strands listen on a job channel; each computes the Fibonacci number for the n it receives and sends the result back as a tuple:
fn fib(n) = if n < 2 then n else fib(n - 1) + fib(n - 2)
fn worker(id, jobs, results) =
n = recv(jobs)
send(results, (id, n, fib(n)))
worker(id, jobs, results)
fn collect(results, k, acc) =
if k == 0 then acc else collect(results, k - 1, Cons(recv(results), acc))
fn main() =
jobs = channel(16)
results = channel(16)
for id in 0..4
spawn worker(id, jobs, results)
for n in 25..33
send(jobs, n)
got = sort_by(collect(results, 8, Nil), job_of)
each(got, report)
fn job_of(t) =
(_, n, _) = t
n
fn report(t) =
(id, n, f) = t
println("fib(" + int_to_str(n) + ") = " + int_to_str(f) + " [işçi " + int_to_str(id) + "]")
fib(25) = 75025 [işçi 0]
fib(26) = 121393 [işçi 0]
fib(27) = 196418 [işçi 0]
fib(28) = 317811 [işçi 0]
fib(29) = 514229 [işçi 0]
fib(30) = 832040 [işçi 0]
fib(31) = 1346269 [işçi 1]
fib(32) = 2178309 [işçi 1]
Worker 0 doing most of the jobs is not a bug. In the default build there is a single worker thread and the strands take turns on it. worker calling itself forever is not a problem either; it is a tail call and the stack does not grow.
For real parallelism, build the program with --parallel and give the number of workers with RILL_THREADS. I gave the same pool 8 equal jobs (8 × fib(35)) and took the median of 11 runs on an M4. Process start-up is included in the time:
| Build | Time (median) |
|---|---|
rill build (one worker) | 131 ms |
rill build --parallel, RILL_THREADS=8 | 53 ms |
Without changing a single line, the code got about 2.5 times faster. One caveat: in a --parallel build there is no lock on storage shared by strands (a Map, a Buf). The compiler asks you to mark values that are shared on purpose with apart(x); that is your promise that "I write these apart".
Timeouts: select and timeout
When you wait on a service you do not want to wait forever. select waits on several channels at once and runs the arm of whichever is ready first. A timeout(ms) arm becomes ready when nothing has happened for that long:
fn slow(ch, ms, v) =
sleep_ms(ms)
send(ch, v)
fn ask(ch, ms) =
r = select
v = recv(ch) -> "yanıt geldi: " + v
timeout(ms) -> "zaman aşımı (" + int_to_str(ms) + " ms)"
r
fn main() =
fast = channel()
spawn slow(fast, 10, "hızlı servis")
println(ask(fast, 100))
lazy = channel()
spawn slow(lazy, 500, "yavaş servis")
println(ask(lazy, 100))
yanıt geldi: hızlı servis
zaman aşımı (100 ms)
There is an important engineering detail here: the timeout arm does not spend a strand or a channel. It is parked inside the poller, next to the channels, and costs nothing while it waits. Before this, every select that needed a timeout spent a strand and a channel as its own clock. For a server that puts a deadline on every request in flight, that meant one strand per request.
The same mechanism stops a program: close(ch) wakes everyone waiting on the channel with a single broadcast, and wait_signal() is how the program hears that it should stop. If all strands are blocked, the runtime reports a deadlock and stops the program instead of hanging silently.
A ring of 100,000 strands
The classic stress test for a concurrency model: we connect 100,000 strands into a ring with channels. Each strand adds one to the number it receives and passes it on:
fn relay(inp, out) =
v = recv(inp)
send(out, v + 1)
fn build(first, prev, k) =
if k == 0 then prev
else
next = channel()
spawn relay(prev, next)
build(first, next, k - 1)
fn main() =
t0 = now_nanos()
first = channel()
last = build(first, first, 100000)
send(first, 0)
println(recv(last))
println(int_to_str((now_nanos() - t0) / 1000000) + " ms")
100000
10 ms
Creating 100,000 strands and 100,000 channels and sending a message all the way round the ring took between 8 and 17 ms on my machine, and the process peaked at 68 MB. The comparative measurements in the Rill repository (Apple M4, median of 25 runs, outputs verified identical across the three languages) are below:

The speed of computation comes from LLVM. The allocation result (binary trees) comes from reference counting with a pooled allocator and parameterless constructors that never allocate. The concurrency result comes from the copy-on-block stack model. Of the three languages, Rill is also the smallest in peak memory and binary size: 32–34 KB against 2.1 MB for Go and about 1 MB for OCaml.
Memory without a garbage collector
This is my favourite part of Rill. The compiler inserts the reference-counting operations itself, following ownership rules (Perceus-style). Memory is freed the moment the last reference dies, deterministically and without pauses. Parameterless constructors such as Nil and None are static singletons and never allocate.
The really nice part is reuse. When a match takes a box apart and nothing else can reach it by name, the next constructor of the same shape is built inside that box, not in a new one. That is why map, filter, reverse and split walk a list without allocating anything; each one writes every link over the link it read.

These decisions are not visible in the source, but you do not have to guess. rill explain is the code generator's own report:
type Point
P(x: Float, y: Float)
fn total(xs, acc) = match xs
Nil -> acc
Cons(x, rest) -> total(rest, acc + x)
fn main() =
xs = Cons(1, Cons(2, Cons(3, Nil)))
doubled = map(xs, \x -> x * 2)
println(total(doubled, 0))
p = P(1.0, 2.0)
println(p.x + p.y)
println(range(1, 1000) |> filter(\x -> x % 7 == 0) |> map(\x -> x * x) |> sum)
$ rill explain explain.rill
fn total(xs, acc) # line 4
`xs` is borrowed: never kept or given away here, so the caller's reference is enough
line 6: `rest` is passed to the tail call borrowed: the callee never keeps it
fn main() # line 8
line 9: `Cons(..)` allocates a box with 2 field(s)
line 10: `map` walks the list in place, writing each link over the one it read
line 12: `P(..)` is a flat value: in registers, no box and no count
line 14: this pipeline is one loop: no list is built
The report reads line by line:
totalborrows the list: since it keeps nothing, the caller's reference is enough and the count is never touched.mapwalks the list in place; no new cells are allocated fordoubled.P(1.0, 2.0)is a flat value: a single-constructor record holding only numbers is never boxed and travels in registers.(q, r) = divmod(a, b)costs what returning two numbers costs in C.- The pipeline is one loop; no list is built in between.
The one known weakness of reference counting is cycles: it cannot free a value that holds itself. In a program made of immutable values, the only way to build a cycle is for a piece of storage to hold something that holds it. Rill refuses m[k] = v where v holds m at the point where it is written. For cases that cannot be seen at compile time, such as two maps holding each other, running with RILL_ALLOC_CHECK=1 gives a report naming the functions that made the blocks that were never freed.
Enough I/O to write servers
A strand waiting on a socket is parked while the scheduler polls the operating system. So one strand per connection costs nothing while it waits. Everything above TCP is ordinary Rill code. The web/http module is an HTTP/1.1 server of a few hundred lines: request parsing, routing, responses and keep-alive. Here is a complete web server:
import "web/http" as h
fn route(req: h.Request) =
p = h.path_of(req.path)
if p == "/" then h.html_response(200, "<h1>Merhaba Rill!</h1>")
else if p == "/selam" then h.text_response(200, "selam " + h.param(h.query_of(req.path), "ad"))
else h.json_response(404, "{\"hata\":\"bulunamadı\"}")
fn main() = h.serve_n(18777, \req -> route(req), 10)
$ curl localhost:18777/
<h1>Merhaba Rill!</h1>
$ curl "localhost:18777/selam?ad=Bar%C4%B1%C5%9F"
selam Barış
$ curl localhost:18777/x
{"hata":"bulunamadı"}
The compiled binary is 119 KB. serve_n answers a fixed number of requests and stops, for tests; a real server uses serve or serve_taking. The library has also been thought through against attacks: a connection that sends nothing is dropped after 15 seconds, request headers are limited to 64 KB and the body to 1 MB, and a request carrying both Content-Length and Transfer-Encoding (request smuggling) is refused without an answer.
In the measurement from the Rill repository, the same single-route service is compared with Go's net/http:

With keep-alive, Rill is ahead in both throughput and latency and uses about 9 times less memory. The reason the p99 latency is 3.2 times tighter is simple: nothing pauses to collect garbage.
How does this blog work?
The page you are reading brings all of the pieces above together. The server is a single binary written in Rill (about 600 KB). Behind it is OxiDB, a database of my own that serves document, SQL, in-memory key-value and S3 engines from one process. The interface is zap, my fine-grained reactive library with no virtual DOM.
| Layer | Technology | What it does |
|---|---|---|
| HTTP | Rill web/http | One strand per connection, keep-alive |
| Templates | tools/tmplgen | Templates are turned into Rill code at build time |
| Markdown | md.rill | This post's HTML, produced once when it is saved |
| SQL | OxiDB SQL | Posts and tags; transactions and foreign keys |
| NoSQL | OxiDB documents | Full-text search with stemming, and view analytics |
| In-memory | OxiMem (RESP) | Sessions, counters, page cache, rate limiting |
| Object store | OxiDB S3 | The images in this post, with SigV4-signed requests |
I particularly like that templates are translated into Rill at build time. A template is really a function. Static text becomes string literals and tags become the code they stand for. Rendering a page is nothing but a run of appends to a StrBuf; nothing is parsed and no name is looked up at run time:
%% import "views"
%= t_card(c: Card)
<article class="card">
<p class="meta"><time datetime="<%= c.iso %>"><%= c.date %></time> · <%= c.reading %> dk okuma</p>
<h2><a href="/yazi/<%= c.slug %>"><%= c.title %></a></h2>
<% if len(c.tags) > 0 %><p class="tags"><% each t in c.tags %><a href="/etiket/<%= t %>">#<%= t %></a><% end %></p><% end %>
</article>
Everything inside <%= ... %> is plain Rill code and is HTML-escaped automatically. There is no second expression language to learn.
What happens when a post is read also shows Rill's concurrency model nicely. The page is first looked up in the OxiMem cache. The view counter is incremented in OxiMem at once and put into the page. The document for the events collection, used for analytics, is handed to a separate strand (spawn), so the page does not wait for the database's fsync. The database connections are a pool inside a channel: a request takes a connection, uses it and puts it back. A request that finds no connection waits. That gives the server, for free, the backpressure it wants anyway:
fn make_pool(host, port, n) =
ch = channel(n)
fn fill(k) =
if k <= 0 then Ok(ch)
else
match connect_to(host, port)
Err(e) -> Err(e)
Ok(fd) ->
send(ch, fd)
fill(k - 1)
fill(n)
fn with_conn(pool, ask) =
fd = recv(pool)
res = ask(fd)
send(pool, fd)
res
Saving a post is a single transaction in the SQL engine. First the row, then its tags are replaced. A link name (slug) already used by another post is refused by the UNIQUE constraint, and that refusal becomes the answer shown to the user:
fn save_post(fd, d: Draft) =
now = now_iso()
ignore(exec(fd, "BEGIN", Nil))
saved = if d.id > 0 then update_post(fd, d, now) else insert_post(fd, d, now)
match saved
Err(e) ->
ignore(exec(fd, "ROLLBACK", Nil))
Err(e)
Ok(id) -> match replace_tags(fd, id, d.tags)
Err(e) ->
ignore(exec(fd, "ROLLBACK", Nil))
Err(e)
Ok(_) -> match exec(fd, "COMMIT", Nil)
Err(e) -> Err(e)
Ok(_) ->
reindex(fd, id)
Ok(id)
Testing
A test is a function whose name starts with test_ and that takes nothing. rill test compiles the file once and runs each test in its own process, so a failing test does not affect the rest. The tests of this blog's Markdown converter look like this:
fn test_plain_and_slug() =
assert_eq(md_plain("# Başlık\n\nBir **iki**, `üç`"), "Başlık Bir iki, üç")
assert_eq(slugify("Rill ile Çalışma Zamanı: 101!"), "rill-ile-calisma-zamani-101")
$ rill test src/md.rill
ok test_blocks
ok test_plain_and_slug
2 tests: 2 passed, 0 failed
The examples in documentation comments are tests as well. A comment line indented by four spaces is an example; the expected output goes after # =>, and rill doctest runs and checks them. Every function in the standard library has such an example, so what the reference pages show is what the code actually does.
What was hard, and what I learned
The topics that took me the most time while writing Rill were the ones that looked simplest at first sight:
- The order of floating-point additions. Splitting a
Floatsum into vector lanes changes the order of additions, and that changes the answer in the last bits. Rill wants+to mean what it says, so it does not do this by default. A function that accepts reordering says so explicitly withfast fn; a dot product gets five times faster that way. - The limit of the tail-call promise. The promise is made through the machine's registers. More than six integer or pointer parameters fall back to the C calling convention. The compiler warns about such functions calling each other; the fix is to collect the extras in a single tuple, because a tuple is one pointer.
- Characters versus bytes.
#s,s[i]ands[i:j]are bytes and stay that way; a lexer and a network protocol want bytes."günaydın"is ten bytes but eight letters. Characters live one level up, in functions that say so in their names (char_count,char_take). - One canonical syntax.
rill fmtgiving every program exactly one spelling makes the language predictable to read, diff and generate. This blog's template generator benefits from that too.
The best test of a language is writing something real with it. This blog was the first real test, for me, of Rill's claim of "enough I/O to write servers".
Conclusion
Rill is an experiment in bringing together the type safety of the ML family, Go's strand-and-channel model and performance close to C, without a garbage collector. Thanks to type inference, code stays as short as a scripting language. Thanks to compile-time reference counting, memory behaviour is predictable and visible with rill explain. Thanks to the tail-call guarantee, loops can be written as functions, and strands are so cheap that opening one per connection stops being a concern.
Next up are more platforms (first experiments on RISC-V and the ESP32 already run), a richer standard library and more capabilities in the language server. I would be glad to hear your questions or experiments on X or LinkedIn. In the next post I will write about OxiDB, the database behind this blog, and how it serves four different data models from one process.