Skip to content

Threads

A thread is an independent flow of execution.

A thread is an independent flow of execution.

A Zig program begins with one thread: the main thread. Additional threads may be created to perform work in parallel.

A thread runs a function.

Here is the smallest useful example:

const std = @import("std");

fn worker() void {
    std.debug.print("worker thread\n", .{});
}

pub fn main() !void {
    const thread = try std.Thread.spawn(.{}, worker, .{});

    thread.join();
}

Run it:

zig run main.zig

The output is:

worker thread

std.Thread.spawn creates a new operating system thread.

The first argument contains thread options:

.{}

This example uses the default options.

The second argument is the function to run:

worker

The third argument is a tuple containing the function arguments:

.{}

worker takes no parameters, so the tuple is empty.

The return value of spawn is a Thread object:

const thread = try std.Thread.spawn(...);

The thread begins executing immediately.

The call:

thread.join();

waits for the thread to finish.

If join is removed, the program may exit before the worker thread completes.

A thread function may take parameters:

const std = @import("std");

fn printNumber(n: u32) void {
    std.debug.print("number = {d}\n", .{n});
}

pub fn main() !void {
    const thread = try std.Thread.spawn(
        .{},
        printNumber,
        .{42},
    );

    thread.join();
}

The tuple:

.{42}

provides the arguments passed to the thread function.

Multiple threads may run at the same time:

const std = @import("std");

fn worker(id: u32) void {
    std.debug.print("worker {d} starting\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();
}

The output order is not guaranteed.

One run may produce:

worker 1 starting
worker 2 starting
worker 3 starting

Another may produce:

worker 3 starting
worker 1 starting
worker 2 starting

Threads execute concurrently. The operating system scheduler decides when each thread runs.

A thread shares memory with other threads in the same process.

This makes communication easy, but it also introduces problems.

Consider this program:

const std = @import("std");

var counter: u32 = 0;

fn increment() void {
    var i: u32 = 0;

    while (i < 100000) : (i += 1) {
        counter += 1;
    }
}

pub fn main() !void {
    const t1 = try std.Thread.spawn(.{}, increment, .{});
    const t2 = try std.Thread.spawn(.{}, increment, .{});

    t1.join();
    t2.join();

    std.debug.print("{d}\n", .{counter});
}

You might expect:

200000

But the result is undefined.

Two threads modify counter at the same time. This is a data race.

A data race occurs when:

  1. Multiple threads access the same memory.
  2. At least one access writes.
  3. The accesses are not synchronized.

Correct threaded programs require synchronization.

Zig provides synchronization primitives in the standard library:

PrimitivePurpose
std.Thread.MutexProtect shared data
std.Thread.RwLockShared and exclusive locking
std.Thread.ConditionWait for events
std.atomicAtomic memory operations
std.Thread.SemaphoreControl resource access

A mutex protects shared state:

const std = @import("std");

var counter: u32 = 0;
var mutex = std.Thread.Mutex{};

fn increment() void {
    var i: u32 = 0;

    while (i < 100000) : (i += 1) {
        mutex.lock();
        counter += 1;
        mutex.unlock();
    }
}

pub fn main() !void {
    const t1 = try std.Thread.spawn(.{}, increment, .{});
    const t2 = try std.Thread.spawn(.{}, increment, .{});

    t1.join();
    t2.join();

    std.debug.print("{d}\n", .{counter});
}

Now the result is predictable.

Only one thread may hold the mutex at a time.

The region between lock and unlock is called a critical section.

Thread creation is not free.

Creating too many threads can reduce performance because:

  • threads consume memory
  • context switching costs time
  • synchronization adds overhead

Many programs use a fixed number of worker threads instead of creating threads continuously.

Zig does not hide the operating system threading model. A Zig thread maps closely to a native system thread.

This keeps the behavior understandable and predictable.

Exercise 18-1. Write a program that creates four threads. Each thread should print its identifier five times.

Exercise 18-2. Modify the counter example so each thread increments the counter one million times.

Exercise 18-3. Remove the mutex from the synchronized counter program. Run the program several times and compare the results.

Exercise 18-4. Write a function that computes the sum of part of an array. Use two threads to compute the total sum of a larger array.