Concurrency is something we use and experience every day. Yet, concurrent systems fail in interesting ways, for various reasons. One of them is that some of us don’t understand how the concurrency runtimes or libraries we use work, which leads to poor use. I’m not going to argue Async/Await vs Fibers or Coroutines, I’ll leave that to the details of my P99 Conference talk coming up in October 2026.
My goal here is to share a fast-paced, hands-on tutorial on building a concurrency library from scratch. The scope of what it does will be limited, but it will still leave you with enough foundation to understand (or imagine) what happens within the black-box of the complex concurrency tools you use daily. That means:
- No I/O and no parallel execution (multi-core/multi-thread). Everything runs on a single-thread.
- Deterministic execution with logical time and a fixed-capacity scheduler.
Pre-requisites And Expectations
I’ve written for experienced programmers, which means that there’s little to no explanation about common programming concepts, and that they can translate the code into whatever language they’re familiar with. Despite that, I’ve carefully chosen names that I hope will make things descriptive, with a deterministic and explicit control-flow program.
The code will be built using the Odin programming language. However, you can easily replicate the same program in almost any programming language. If you’re curious to learn Odin, I’ve got a practical introductory series that you can read to get a quick hands-on about the language. The rest you can easily reference in the language reference documentation.
There’s a lot to cover on the topic of concurrency; therefore, some things will be intentionally omitted. The content of this article is an excerpt from an unannounced/unreleased hands-on book series about programming a concurrency library. The examples are some of what will go into the first book (they might still change).
By the end of this article, you will have built a minimalistic program that can handle multiple tasks concurrently.The Bakery POS
Imagine you’re modelling or making an application that’ll be used for managing various activities in a bakery. It’ll run on a cheap single-threaded processor, e.g Intel Celeron, because they can’t afford a dual or multicore processor.
The first version of your software manages what gets baked and collects orders and payments from customers. Here’s what it looks like:
Bakery :: struct {
minute: int,
customers_served: int,
first_customer_served_at: int,
}
bake_batch :: proc(bakery: ^Bakery, minutes, customers_waiting: int, print_log: bool) {
for elapsed in 0..< minutes {
bakery.minute += 1
// print log here
}
}
serve_customers :: proc(bakery: ^Bakery, customer_count: int, print_log: bool) {
customers_waiting := customer_count
customers_served := 0
for customers_waiting > 0 {
bakery.minute += 1
customers_waiting -= 1
customers_served += 1
bakery.customers_served = customers_served
if bakery.first_customer_served_at == 0 {
bakery.first_customer_served_at = bakery.minute
}
// print log
}
}
The baking process takes a batch of items and runs for a specifc duration, nothing else runs during that time. The customers_waiting and print_log are just parameters to enable observability. Serving customers also runs to completion, collecting orders before they’re sent to the next baking batch.
Executing the program follows a process defined as such:
run_bakery :: proc(print_log := true) -> Bakery {
bakery: Bakery
bake_batch(&bakery, BAKE_MINUTES, print_log)
serve_customers(&bakery, CUSTOMER_COUNT, print_log)
return bakery
}
Given that the baking process takes ten hypothetical minutes, and serving customers costs one minute per customer, how long would it take to bake (one batch) and serv ten customers?
minute 01 | oven | batch baking | 9 min remaining; 10 waiting
minute 02 | oven | batch baking | 8 min remaining; 10 waiting
minute 10 | oven | batch baking | 0 min remaining; 10 waiting
minute 11 | cashier | serve customer | #01 served
minute 12 | cashier | serve customer | #02 served
minute 20 | cashier | serve customer | #10 served
Twenty minutes 🐌
minute 01 02 03 04 05 06 07 08 09 10 11 12 ... 20
├────────────────────────────┤
oven █ █ █ █ █ █ █ █ █ █
cashier █ █ ... █
└────── baking ─────────────┘└─ serving ─┘
total: 20 min
A blocking ten-minute baking process prevents the cashier from serving customers. You’ve written or seen applications where some actions freezes the app for a few seconds, or minutes, even in multi-core processors. How do you solve such a problem?
You may say, use multi-thread!, but one thread is not the problem. The oven did not freeze the bakery; the loop in bake_batch did. Think of the oven like the hardware peripherals that’s part of your computer — you send data to it and get data out. You don’t want to waste CPU time waiting for a response (a blocking request). You want a solution that allows you to serve customers while the baking is going on with the oven.
One Thread, Many Tasks
There are various ways to solve that, and I’m sure you can already picture at least one way to achieve that.
Given that baking takes ten minutes, you can set up a timer and service customers in the meantime. Then process the oven response when the timer elapses. In an ideal situation, you would service ten customers for each sequence you’re waiting for the oven to bake and return baked items. But what if the oven encountered a problem or delay that cost the baking time to be twelve minutes?
The easy answer is to restart the timer, but for how long? Another ten minutes? That’ll be a waste if you only needed two extra minutes. If you’re going to use a precise extra time, how do you know the right number? The oven — like your PC hard drive — only knows to take in a baking request and return the baked items (or return an error). So what would you choose?
Timers aren’t a bad solution. It is helpful to know the tradeoff and how it affects the rest of your program.
You can derive another solution from looking at those operations as Tasks, Objects, or Actors.
You define a cashier and oven Actors that have distinct responsibility, or independent task structures for them, that take turns to execute. The executor (or runner) executes the given operation and switches between task/actor by running each of them for a set time — preemption — or allowing each task to willingly pause and return control, so the executor can run other task.
Ten Customers, One Thread, and a Tiny Scheduler
Let’s go with task switching. The bake_batch function should pause its execution and allow serve_customer to run, and add a new component — the scheduler — that knows when and how to schedule and run both tasks.
To pause a task, give it a memory and a stopping point.
You can store memory for the various tasks using the following structure:
Oven :: struct {
remaining: int,
}
Cashier :: struct {
served: int,
remaining: int,
}
These are data that previously lived inside bake_batch and serve_customer. Keeping them contained this way means that the function can re-run without forgetting where it stopped.
Next, give the tasks a stopping point, a means to manage progress and completions. At the stop point, the task can return data that represents its completion or a chance to run again. Given that the oven runs for a given duration, you could define a function for its execution like this:
Step :: enum {Yield, Done}
oven_step :: proc(oven: ^Oven) -> Step {
oven.remaining -= 1
return .Done if oven.remaining == 0 else .Yield
}
cashier_step :: proc(cashier: ^Cashier) -> Step {
if cashier.remaining <= 0 do return .Done
cashier.served += 1
cashier.remaining -= 1
return .Done if cashier.remaining == 0 else .Yield
}
This way, a task becomes a persistent state plus a function that advances it step-by-step.
Tasks: Taking Turns
With tasks that can progress step-by-step, you need a way to run and switch between tasks — The Scheduler. The scheduler is the thing that knows how to juggle multiple tasks so that they run efficiently in your single-threaded processor.
Put simply, a loop that collects runnable tasks and runs them when they’re ready.
╔═══════════════════════════════════════════════════════════════╗
║ SCHEDULER CONTROL FLOW ║
╚═══════════════════════════════════════════════════════════════╝
┌──────────────────┐
│ START / RESUME │
└────────┬─────────┘
│
▼
┌──────────────────┐
│ next tick │
│ tick += 1 │
└────────┬─────────┘
│
▼
┌────────────────────────┐
│ oven_done ? │
└──────────┬─────────────┘
│
┌────────────┴────────────┐
│ │
yes no
│ │
│ ▼
│ ┌──────────────────┐
│ │ oven_step() │
│ └────────┬─────────┘
│ │
│ ▼
│ ┌──────────────────┐
│ │ Yield or Done ? │
│ └────────┬─────────┘
│ │
│ ┌────────────┴────────────┐
│ │ │
│ Yield Done
│ │ │
│ │ oven_done = true
│ │ │
└──────────┴──────────────┬──────────┘
│
▼
┌────────────────────────┐
│ cashier_done ? │
└──────────┬─────────────┘
│
┌───────────┴───────────┐
│ │
yes no
│ │
│ ▼
│ ┌──────────────────┐
│ │ cashier_step() │
│ └────────┬─────────┘
│ │
│ ▼
│ ┌──────────────────┐
│ │ Yield or Done ? │
│ └────────┬─────────┘
│ │
│ ┌────────────┴────────────┐
│ │ │
│ Yield Done
│ │ │
│ │ cashier_done = true
│ │ │
└─────────┴────────────┬────────────┘
│
▼
┌─────────────────────────┐
│ oven_done && │
│ cashier_done ? │
└───────────┬─────────────┘
│
┌────────────┴────────────┐
│ │
no yes
│ │
│ ▼
│ ┌────────────────┐
│ │ DONE │
│ └────────────────┘
│
└──────────────────────────────┐
│
┌────────────────────────────────────┘
│
▼
┌──────────────────┐
│ next tick │
└──────────────────┘
The scheduler loop for the bakery example then becomes:
for !oven_done || !cashier_done {
tick += 1
if !oven_done {
oven_done = oven_step(&oven) == .Done
}
if !cashier_done {
cashier_done = cashier_step(&cashier) == .Done
}
}
The scheduler runs until all tasks are done. The tick is a logical time, where each tick is equivalent to one minute, which means that every task is scheduled to run within that time slice. Based on the new turn-by-turn program, how long would it take to execute, given the same ten customers and a baking time of ten minutes?
tick 01 (bakery minute 01)
oven | batch baking | 9 min remaining
cash | serve customer | #01 served
tick 02 (bakery minute 02)
oven | batch baking | 8 min remaining
cash | serve customer | #02 served
...
tick 10 (bakery minute 10)
oven | batch baking | 0 min remaining
cash | serve customer | #10 served
summary | cashier finished at tick 10 (bakery minute 10), rather than minute 20
The execution time time drops from twenty to ten minutes! Same work, same single-threaded processor.
Every unfinished task receives one turn on every tick. The advance one step and return a status of finished or paused/resumable. The state required for the task to resume where it stopped are outside the step function.
A Nano-Coroutine Runtime
Although tied to the bakery application, you have built a tiny stackless coroutine runtime without threads or async I/O. The tick loop is the event loop, where oven_step and cashier_step are resumable operations. The Oven or Cashier struct is an Actor or coroutine frame with persistent state needed for the work they do.
The controlled manual loop is only the beginning. In the planned book (first in the series), the loop grows into a reusable, deterministic scheduler with precise tick semantics and heterogeneous tasks, tracing, type-erasable event handler functions, and more. There’s much more I hope to do for the book series or accompanying workshop, but they’re still heavily in design/ideation phase. For now, you can follow my work on a concurrency framework that builds on some of these basic ideas: check out TINA on GitHub.
What’s Next
You’ve learnt a bit about concurrent programming through this fast-paced tutorial. You saw how to do multi-tasking on a single-threaded environment, and how some of that knowledge could transfer to whatever concurrency primitive you use for work.
You learnt how two structs, a Step result, and a tiny loop become a coroutine runtime that can efficiently multitask.There might be a follow-up article to this one, but there’ll definitely be a book announcement soon (and probably a course or workshop).
Subscribe to the newsletter so you get the launch chapter, release date, and subscriber offer.
I omitted some code to keep the article short (or long) enough to be comprehensible and useful to you. If you want the complete code, email me (or reply to one of my newsletter emails).