Concurrency & Threading
main to the last. This lesson makes the program run in several places at once — and explains why the context, the invisible companion of every procedure since lesson one, suddenly matters.
Odin's approach is worth stating before any code. There is no async keyword, no implicitly scheduled coroutine, and no runtime that decides when your function runs. A thread is a thread: it starts, it runs a procedure, and it ends. Everything about the order of events is yours to manage, which is more work than a magic keyword and considerably less surprising when something goes wrong.
Concurrency in Odin
Look at Odin's own library description, quoted from the first line of the threading package:
// Quoted from core/thread/thread.odin, the package's own header comment:
// Multi-threading operations to spawn threads and thread pools.
package thread
Two nouns: threads and thread pools. That is the whole feature list at the package level, and it tells you what to expect — the language gives you the ability to run procedures in parallel and to manage a group of workers, then stops there.
Threads, Not Magic
The procedural model you learned in Phase 3 still holds. A thread runs one of your procedures; that procedure has an implicit context like every other procedure; and the context is the part that changes when a second thread appears.
Concurrency is also not free. Two threads touching the same memory create a race: the result depends on which one arrives first, and that order is not something you can read from the source. Everything else in this lesson is a way of either avoiding that situation or making it well-defined.
The Toolbox
Three packages carry the weight:
| Package | What it holds |
|---|---|
core:thread | Creating, starting, joining threads and thread pools |
core:sync | Synchronisation primitives: atomics, mutexes, conditions, and per-platform futex operations |
core:sync/chan | Channels — a queue between threads, so they can pass values instead of sharing them |
And one you already know: base:intrinsics. The atomic operations you will use are intrinsics, compiled to the right instruction for the target — the same package that gave you unaligned_load and the threading package its own internal aliases for it.
Spawning a Thread
A thread runs a procedure, and the procedure's signature is fixed by the package. Quoted from core:thread:
// Quoted from core/thread/thread.odin:
Thread_Proc :: #type proc(^Thread)
Read the type carefully, because it explains a design decision you will see everywhere below. The thread procedure takes a pointer to the thread itself — not to your data. Your data is reached through that thread, from fields the creator fills in before starting it. This is why the thread procedure always has the same signature no matter what you want to pass: the parameter is the handle, and the payload hangs off it.
create, start, and join
The lifecycle has three steps and the package documents each. Quoted from the doc comment on create:
Create a thread in a suspended state with the given priority.
This procedure creates a thread that will be set to run the procedure
specified by `procedure` parameter with a specified priority. The returned
thread will be in a suspended state, until `start()` procedure is called.
To start the thread, call `start()`. Also the `create_and_start()`
procedure can be called to create and start the thread immediately.
And the signature, quoted from the same file:
create :: proc(procedure: Thread_Proc, priority := Thread_Priority.Normal, name: Maybe(string) = nil) -> ^Thread
Three things in one line. The priority is a defaulted parameter, so most calls do not mention it. The name is a Maybe(string) — the optional-value pattern from Phase 2, here defaulting to nil, which is a small and honest use of it: a thread may or may not have been given a name. And the result is a pointer, ^Thread, because a thread is a thing the runtime owns and you hold a handle to.
Because creation and starting are separate, there is a window in which you can fill in the handle's fields before the thread starts running — which is exactly how arguments are passed. The package is explicit that this is the design, quoted from the field comments in the Thread struct:
// Quoted from the Thread struct's field comments:
// User-supplied pointer, that will be available to the thread once it is
// started. Should be set after the thread has been created, but before
// it is started.
data: rawptr,
So the shape of a hand-written thread is: create it, set its data to point at your payload, start it, and join it later. The join is what keeps you honest — quoted from the documentation of the automatic-cleanup flag, which warns you not to use it and join at the same time:
**Do not** dereference the `^Thread` pointer, if this flag is specified.
That includes calling `join`, which needs to dereference `^Thread`.
Threads come with a small state record so the runtime can keep track of where each one is. Quoted from the package:
Thread_State :: enum u8 {
Started,
Joined,
Done,
Self_Cleanup,
}
Those four names are the four things that can be true of a thread: it has been started, someone has joined it, it has finished, and it is responsible for cleaning up after itself. The framework is deliberately small — no scheduler states, no suspend and resume, no priorities in the state machine beyond the separate Thread_Priority enum.
Threads With Arguments
Passing a payload through a rawptr works, but it costs you a conversion and a moment of care: rawptr is untyped, so nothing checks that the pointer you stored is the pointer the thread reads. Odin offers a friendlier door — a family of procedures that take normal typed arguments and do the packing for you. Quoted from the package, with the argument list abbreviated:
// Quoted from core/thread/thread.odin, abbreviated:
create_and_start_with_poly_data4 :: proc(arg1: $T1, arg2: $T2, arg3: $T3, arg4: $T4,
fn: proc(arg1: T1, arg2: T2, arg3: T3, arg4: T4),
init_context: Maybe(runtime.Context) = nil,
priority := Thread_Priority.Normal, self_cleanup := false,
name: Maybe(string) = nil) -> (t: ^Thread)
where size_of(T1) + size_of(T2) + size_of(T3) + size_of(T4) <= size_of(rawptr) * MAX_USER_ARGUMENTS
This is Phase 5's parametric polymorphism doing real work. The $T1 parameters are polymorphic, so fn is checked against the very types you passed — pass a string and an int, and fn must accept a string and an int. The where clause then constrains the size of the whole payload, and the constant it names explains the limit:
// Quoted from core/thread/thread.odin:
// Maximum number of user arguments for polymorphic thread procedures.
MAX_USER_ARGUMENTS :: 8
The arguments travel in an array of raw pointers, so eight machine words is the ceiling; the family exists in variants from one argument up to eight. Inside, the package stores the bytes of each argument into that array and reads them back out in the same order — a miniature demonstration of the bytes-and-offsets reasoning from the data-oriented lesson, and a good reason to prefer the helper over hand-packing your own struct.
The Context Is Per Thread
Here is the part of threading that surprises people, and Odin documents it at length in the Thread struct. Quoted verbatim:
**Note**: If this field is **not** set, the temp allocator will be managed
automatically. If it is set, the allocators must be handled manually.
**IMPORTANT**:
By default, the thread proc will get the same context as `main()` gets.
In this situation, the thread will get a new temporary allocator which
will be cleaned up when the thread dies. ***This does NOT happen when
`init_context` field is initialized***.
A Thread Gets Its Own Context
Recall what the context is: a small struct carrying your allocators, logger, random generator, and the other ambient services. Every procedure body has an implicit one. Until now there was exactly one of them and it belonged to main.
With threads there is one per thread, and the reason is memory. A temporary allocator is a bump pointer into a block of memory: "allocate" advances the pointer, "free all" resets it. That is fast precisely because it is not thread-safe — two threads sharing one bump pointer would hand out the same block twice. So the runtime gives each new thread a fresh temporary allocator, and cleans it up when the thread dies.
That is the good default, and it comes with one sentence you must not skip: "This does NOT happen when init_context field is initialized." If you supply the thread's context yourself, the automatic bookkeeping is switched off. The package states the consequence plainly in the next comment:
If `init_context` is initialized, and `temp_allocator` field is set to
the default temp allocator, then `runtime.default_temp_allocator_destroy()`
procedure needs to be called from the thread procedure, in order to prevent
any memory leaks.
And the source comments record why the library has to intervene at all, with a phrase worth remembering. Quoted from the private helper that prepares a thread's context:
NOTE(tetra, 2023-05-31):
Ensure that the temp allocator is thread-safe when the user provides a specific initial context to use.
Without this, the thread will use the same temp allocator state as the parent thread, and thus, bork it up.
The Temporary Allocator Warning
Put the three quotes together and you get the practical rules for memory in threads:
| Situation | What happens to the temporary allocator |
|---|---|
| You create a thread with no context of your own | The thread gets a fresh temporary allocator, destroyed when it dies — nothing to do |
You pass your own init_context | The automatic management is off: allocators are your responsibility |
| Your custom context uses the default temporary allocator | Call runtime.default_temp_allocator_destroy() from the thread procedure, or leak |
Notice how squarely this sits on Phase 4. Every allocation in Odin names its allocator, which is why a per-thread allocator is expressible at all; and the temporary allocator's "free everything at once" rule is what makes it the natural choice for a worker whose whole lifetime is one task. The same theme explains a convention you met in the foreign-interface lesson: where no context exists to pass in, the library reaches for proc "contextless".
Sharing Data Without Ruining It
Threads would be easy if they never touched the same memory, and useless if they never could. The interesting middle is: they share, but only through mechanisms that make the order of events well-defined.
Atomic Operations
The simplest mechanism is the atomic operation: an instruction the hardware guarantees will complete as one indivisible step. Odin exposes them as intrinsics, and the threading package itself uses them for its own bookkeeping. Quoted from the package, where a thread marks itself as self-cleaning:
// Quoted from core/thread/thread.odin: setting a flag in a bit_set
// that another thread may be reading at the same time.
if self_cleanup {
intrinsics.atomic_or(&t.flags, {.Self_Cleanup})
}
Everything in that line is already familiar: flags is a bit_set (Phase 3), {.Self_Cleanup} is a set literal, and atomic_or is the atomic cousin of the | operator. What changes is only the guarantee: a plain OR followed later by a plain read could interleave on another core, while atomic_or cannot.
Odin's intrinsics package provides the atomic family — load, store, add, and the bitwise forms, together with exchange and compare-and-swap variants — so compound operations can be built on a guaranteed foundation rather than approximated.
Mutexes and Channels
Atomics are enough for a flag or a counter and awkward for anything larger, because protecting a region of code is a different problem from protecting a single word. For regions, core:sync offers the classic machinery — a mutex to serialize access, a read-write mutex when readers may share, a condition variable and a wait group for coordinating completion — alongside the per-platform futex operations they are built on.
The package's other half is a different idea entirely, and often the better one. A channel is a queue with thread-safe send and receive, and it lets two threads cooperate by passing values rather than sharing them: one thread owns the data at any moment, and ownership travels with the value. When the data has a natural producer and consumer, this removes the shared region instead of protecting it, which is why it lives in its own subpackage, core:sync/chan.
Threads and Pools
Creating a thread is not free: the operating system has to reserve a stack and register the thread with the scheduler, which costs far more than the work in a typical small task. That is why the package's own description says "threads and thread pools" in the same breath.
When a Thread Is the Wrong Tool
The pattern that pays is: create a fixed number of workers once, give them jobs from a shared queue, and let them run until the queue is empty. Then the per-task cost is a queue operation rather than a thread creation. Odin's threading package provides that shape directly with its pool type, and the queues and guards it needs come from core:sync.
The same logic sits behind a fact from the next lesson: Odin's test runner is multi-threaded by default, and the testing documentation warns you to keep that in mind — too little parallelisable work and the extra cores go to waste. That is the worker-pool judgement in one sentence: parallelise coarse tasks, not tiny ones.
Where This Goes Next
You now have programs that run in several places at once, and programs that talk to C. The last lesson of this phase is about believing them: automated tests to check what should stay true, a formatter to keep the source honest, and the tools you reach for when a program misbehaves instead of merely disagreeing with you.
The Page in One Breath
- A thread is a procedure with a fixed signature,
proc(^Thread), and your data travels through the handle it is given. createproduces a suspended thread,startbegins it,joinwaits — and a self-cleaning thread must not be joined.create_and_start_with_poly_data*passes up toMAX_USER_ARGUMENTS(8) typed arguments, checked by polymorphic parameters and awhereclause.- Each thread gets its own context and its own temporary allocator; supplying your own context switches that bookkeeping off.
- Use atomics for a word, a mutex for a region, and a channel when you can pass ownership instead of sharing it.
- Prefer a pool of workers over a thread per task.