🎓 A1Learner
🔍
0 of 46 lessons completed

Zig Thread Join Patterns

A join is the point where the main thread waits for a spawned thread to finish. Joining before reading shared state keeps the result the same on every run.

Join in spawn order

Two workers write to their own slots. The main thread joins the first worker, then the second, then prints both slots.

const std = @import("std");
fn setSlot(slot: *i32, v: i32) void {
    slot.* = v;
}
pub fn main() !void {
    var out = std.io.getStdOut().writer();
    var results: [2]i32 = .{ 0, 0 };
    var t1 = try std.Thread.spawn(.{}, setSlot, .{ &results[0], 5 });
    var t2 = try std.Thread.spawn(.{}, setSlot, .{ &results[1], 7 });
    t1.join();
    t2.join();
    try out.print("slots={d} {d}\n", .{ results[0], results[1] });
}

Join every thread in a loop

Handles live in an array, and one loop joins them all. The results are printed only after the loop.

const std = @import("std");
fn double(slot: *i32, n: i32) void {
    slot.* = n * 2;
}
pub fn main() !void {
    var out = std.io.getStdOut().writer();
    var results: [3]i32 = .{ 0, 0, 0 };
    var threads: [3]std.Thread = undefined;
    for (0..3) |i| {
        threads[i] = try std.Thread.spawn(.{}, double, .{ &results[i], @as(i32, @intCast(i + 1)) });
    }
    for (threads) |t| {
        t.join();
    }
    try out.print("doubled={d} {d} {d}\n", .{ results[0], results[1], results[2] });
}

Join order need not match spawn order

Threads may be joined in any order. The printed order comes from the main thread, not from the join order.

const std = @import("std");
fn setSlot(slot: *i32, v: i32) void {
    slot.* = v;
}
pub fn main() !void {
    var out = std.io.getStdOut().writer();
    var results: [2]i32 = .{ 0, 0 };
    var t1 = try std.Thread.spawn(.{}, setSlot, .{ &results[0], 11 });
    var t2 = try std.Thread.spawn(.{}, setSlot, .{ &results[1], 22 });
    t2.join();
    t1.join();
    try out.print("slots={d} {d}\n", .{ results[0], results[1] });
}

Example

const std = @import("std");
fn double(slot: *i32, n: i32) void {
    slot.* = n * 2;
}
pub fn main() !void {
    var out = std.io.getStdOut().writer();
    var results: [4]i32 = .{ 0, 0, 0, 0 };
    var threads: [4]std.Thread = undefined;
    for (0..4) |i| {
        threads[i] = try std.Thread.spawn(.{}, double, .{ &results[i], @as(i32, @intCast(i + 1)) });
    }
    for (threads) |t| {
        t.join();
    }
    var total: i32 = 0;
    for (results) |v| {
        total += v;
    }
    try out.print("doubled={d} {d} {d} {d} total={d}\n", .{ results[0], results[1], results[2], results[3], total });
}

Key points

  • Join: a join is a wait for one spawned thread to finish.
  • Thread.join waits for that thread, and only then is the thread's work visible to the main thread.
  • Joining in spawn order means waiting for the first thread, then the second, then the third.
  • Thread array: a thread array is an array that holds one handle per spawned thread.
  • Joining in a loop waits for every thread in an array, one after another.
  • Shared state is read only after every thread that writes it has been joined.
  • Printing happens only after all joins, so the printed order never depends on which thread finished first.

Four handles live in an array. One loop spawns the workers and another loop joins them all. The main thread prints the slots and their total only after every join.

Try it Yourself

Edit the code, then press Run.

Running is simulated

Exercise

When must the main thread read a value a worker wrote?