A thread is a separate path of execution inside one program.
A normal program starts with one thread. That thread begins at main, runs each statement in order, and ends when main returns.
pub fn main() void {
// one thread runs this code
}A multithreaded program has more than one thread running at the same time. One thread may read a file. Another thread may handle a network connection. Another thread may perform a calculation.
Threads are useful when you want to do independent work in parallel.
The Basic Idea
Imagine this program:
const std = @import("std");
fn worker() void {
std.debug.print("Hello from worker thread\n", .{});
}
pub fn main() !void {
const thread = try std.Thread.spawn(.{}, worker, .{});
thread.join();
std.debug.print("Back in main thread\n", .{});
}This program creates a new thread.
The new thread runs this function:
fn worker() void {
std.debug.print("Hello from worker thread\n", .{});
}The main thread continues after calling std.Thread.spawn.
This line waits for the worker thread to finish:
thread.join();Without join, the main function could finish before the worker thread has completed its work.
What std.Thread.spawn Does
The general shape is:
const thread = try std.Thread.spawn(.{}, function_name, arguments);The first argument is the thread configuration:
.{}For now, this means “use the default thread options.”
The second argument is the function the new thread should run:
workerThe third argument is a tuple of arguments passed to that function:
.{}An empty tuple means the function receives no arguments.
So this:
try std.Thread.spawn(.{}, worker, .{});means:
Create a new thread. Run worker. Pass it no arguments.
Passing Arguments to a Thread
A thread function can receive arguments like any other function.
const std = @import("std");
fn printNumber(n: u32) void {
std.debug.print("number = {}\n", .{n});
}
pub fn main() !void {
const thread = try std.Thread.spawn(.{}, printNumber, .{42});
thread.join();
}The function expects one argument:
fn printNumber(n: u32) voidThe thread receives that argument here:
.{42}The argument list must match the function parameters.
Multiple Threads
You can start more than one thread.
const std = @import("std");
fn worker(id: u32) void {
std.debug.print("worker {} started\n", .{id});
}
pub fn main() !void {
const t1 = try std.Thread.spawn(.{}, worker, .{1});
const t2 = try std.Thread.spawn(.{}, worker, .{2});
const t3 = try std.Thread.spawn(.{}, worker, .{3});
t1.join();
t2.join();
t3.join();
std.debug.print("all workers finished\n", .{});
}Each call to spawn creates a new thread.
Each call to join waits for one thread to finish.
The output order is not guaranteed. You may see:
worker 1 started
worker 3 started
worker 2 started
all workers finishedThat is normal. Once threads run at the same time, the operating system decides when each thread gets CPU time.
Threads Share Memory
Threads inside the same process share memory.
This is powerful, but dangerous.
If two threads access the same data at the same time, and at least one thread writes to it, you can get a data race.
Here is a bad example:
const std = @import("std");
var counter: u32 = 0;
fn incrementMany() void {
var i: u32 = 0;
while (i < 1000) : (i += 1) {
counter += 1;
}
}
pub fn main() !void {
const t1 = try std.Thread.spawn(.{}, incrementMany, .{});
const t2 = try std.Thread.spawn(.{}, incrementMany, .{});
t1.join();
t2.join();
std.debug.print("counter = {}\n", .{counter});
}You might expect the final value to be 2000.
But this program is not safe. Both threads update counter at the same time.
This line looks simple:
counter += 1;But internally it means something like:
- read
counter - add
1 - write the result back
Two threads can interleave those steps and lose updates.
Use a Mutex to Protect Shared Data
A mutex is a lock.
Only one thread can hold the lock at a time. If another thread tries to lock the same mutex, it must wait.
const std = @import("std");
var counter: u32 = 0;
var mutex = std.Thread.Mutex{};
fn incrementMany() void {
var i: u32 = 0;
while (i < 1000) : (i += 1) {
mutex.lock();
counter += 1;
mutex.unlock();
}
}
pub fn main() !void {
const t1 = try std.Thread.spawn(.{}, incrementMany, .{});
const t2 = try std.Thread.spawn(.{}, incrementMany, .{});
t1.join();
t2.join();
std.debug.print("counter = {}\n", .{counter});
}Now the update is protected:
mutex.lock();
counter += 1;
mutex.unlock();Only one thread can change counter at a time.
A safer style uses defer:
mutex.lock();
defer mutex.unlock();
counter += 1;This ensures the mutex is unlocked when the current scope exits.
Passing Shared State Explicitly
Global variables are easy for small examples, but larger programs should usually pass shared state explicitly.
const std = @import("std");
const State = struct {
mutex: std.Thread.Mutex = .{},
counter: u32 = 0,
};
fn incrementMany(state: *State) void {
var i: u32 = 0;
while (i < 1000) : (i += 1) {
state.mutex.lock();
defer state.mutex.unlock();
state.counter += 1;
}
}
pub fn main() !void {
var state = State{};
const t1 = try std.Thread.spawn(.{}, incrementMany, .{&state});
const t2 = try std.Thread.spawn(.{}, incrementMany, .{&state});
t1.join();
t2.join();
std.debug.print("counter = {}\n", .{state.counter});
}This is better because the shared data is grouped in one place.
const State = struct {
mutex: std.Thread.Mutex = .{},
counter: u32 = 0,
};The thread function receives a pointer:
fn incrementMany(state: *State) voidThat means each thread works with the same shared State.
Thread Lifetime Matters
A thread must not use memory that has already gone out of scope.
This is unsafe:
fn startThread() !std.Thread {
var value: u32 = 123;
return try std.Thread.spawn(.{}, usePointer, .{&value});
}The variable value lives only inside startThread.
When startThread returns, value is gone. The new thread may still hold a pointer to invalid memory.
The rule is simple:
A thread must not receive a pointer to data that may disappear before the thread finishes.
The easiest beginner rule is:
Create the data in the same scope where you also join the thread.
pub fn main() !void {
var value: u32 = 123;
const thread = try std.Thread.spawn(.{}, usePointer, .{&value});
thread.join();
}Here, value stays alive until after join.
Thread Functions and Errors
A thread function can return an error union, but the error does not automatically return to the parent thread in the same way that try works in one call stack.
For beginners, start with thread functions that return void:
fn worker() void {
// do work
}If a worker can fail, store the result somewhere shared and protect it with synchronization, or design the worker to report errors through a queue, channel-like structure, or shared result object.
A simple pattern is:
const std = @import("std");
const Result = struct {
mutex: std.Thread.Mutex = .{},
failed: bool = false,
};
fn worker(result: *Result) void {
const ok = false;
if (!ok) {
result.mutex.lock();
defer result.mutex.unlock();
result.failed = true;
}
}
pub fn main() !void {
var result = Result{};
const thread = try std.Thread.spawn(.{}, worker, .{&result});
thread.join();
if (result.failed) {
std.debug.print("worker failed\n", .{});
}
}This is not the only way to report errors, but it shows the main idea: the parent thread and worker thread need an explicit communication path.
Threads Are Not Always the Best Tool
Threads are powerful, but they add complexity.
Use threads when work is truly independent or parallel. Good examples include:
| Use case | Why threads help |
|---|---|
| CPU-heavy work | Multiple CPU cores can run work in parallel |
| Blocking file or network work | One thread can wait while another keeps working |
| Background jobs | Long work can run without stopping the main flow |
| Servers | Different connections or tasks can be handled concurrently |
Avoid threads when a simple loop is enough. A single-threaded program is easier to test, easier to debug, and easier to reason about.
A Good Beginner Rule
Start with one thread.
Add threads only when you can clearly answer these questions:
What data does each thread own?
What data is shared?
Who protects the shared data?
When does each thread finish?
Who calls join?
If those answers are unclear, the program is not ready for threads.
Threads in Zig are explicit. You create them directly, pass data directly, synchronize directly, and wait for them directly. That makes them a good fit for systems programming, but it also means you must be precise.