🎓 A1Learner
🔍
0 of 46 lessons completed

Zig Thread Pool Deep

A thread pool is a fixed set of worker threads that run tasks handed to it. It reuses its workers instead of starting a new thread for each task.

Run tasks in a pool

Eight tasks each add 10 to a shared total. The main thread waits with waitAndWork, then prints the total.

const std = @import("std");
const Context = struct {
    mutex: std.Thread.Mutex = .{},
    total: i64 = 0,
    fn add(ctx: *@This(), n: i64) void {
        ctx.mutex.lock();
        defer ctx.mutex.unlock();
        ctx.total += n;
    }
};
pub fn main() !void {
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    defer _ = gpa.deinit();
    const alloc = gpa.allocator();
    var ctx = Context{};
    var pool: std.Thread.Pool = undefined;
    try pool.init(.{ .allocator = alloc, .n_jobs = 4 });
    defer pool.deinit();
    var wg: std.Thread.WaitGroup = .{};
    var i: usize = 0;
    while (i < 8) : (i += 1) {
        pool.spawnWg(&wg, Context.add, .{ &ctx, 10 });
    }
    pool.waitAndWork(&wg);
    const out = std.io.getStdOut().writer();
    try out.print("{d}\n", .{ctx.total});
}

Tasks write separate results

Six tasks each square one number into its own slot. No mutex is needed here, because no two tasks touch the same slot.

const std = @import("std");
fn square(slot: *i64, n: i64) void {
    slot.* = n * n;
}
pub fn main() !void {
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    defer _ = gpa.deinit();
    const alloc = gpa.allocator();
    var results: [6]i64 = .{ 0, 0, 0, 0, 0, 0 };
    var pool: std.Thread.Pool = undefined;
    try pool.init(.{ .allocator = alloc, .n_jobs = 2 });
    defer pool.deinit();
    var wg: std.Thread.WaitGroup = .{};
    var i: usize = 0;
    while (i < 6) : (i += 1) {
        pool.spawnWg(&wg, square, .{ &results[i], @as(i64, @intCast(i + 1)) });
    }
    pool.waitAndWork(&wg);
    var sum: i64 = 0;
    for (results) |v| {
        sum += v;
    }
    const out = std.io.getStdOut().writer();
    try out.print("{d}\n", .{sum});
}

One pool, two batches

The same pool runs a second batch after the first finishes. Each batch gets its own wait group.

const std = @import("std");
const Context = struct {
    mutex: std.Thread.Mutex = .{},
    total: i64 = 0,
    fn add(ctx: *@This(), n: i64) void {
        ctx.mutex.lock();
        defer ctx.mutex.unlock();
        ctx.total += n;
    }
};
pub fn main() !void {
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    defer _ = gpa.deinit();
    const alloc = gpa.allocator();
    var ctx = Context{};
    var pool: std.Thread.Pool = undefined;
    try pool.init(.{ .allocator = alloc, .n_jobs = 4 });
    defer pool.deinit();
    var wg1: std.Thread.WaitGroup = .{};
    var i: usize = 0;
    while (i < 4) : (i += 1) {
        pool.spawnWg(&wg1, Context.add, .{ &ctx, 1 });
    }
    pool.waitAndWork(&wg1);
    var wg2: std.Thread.WaitGroup = .{};
    i = 0;
    while (i < 4) : (i += 1) {
        pool.spawnWg(&wg2, Context.add, .{ &ctx, 10 });
    }
    pool.waitAndWork(&wg2);
    const out = std.io.getStdOut().writer();
    try out.print("{d}\n", .{ctx.total});
}

Example

const std = @import("std");
const Context = struct {
    mutex: std.Thread.Mutex = .{},
    total: i64 = 0,
    fn addSquare(ctx: *@This(), n: i64) void {
        ctx.mutex.lock();
        defer ctx.mutex.unlock();
        ctx.total += n * n;
    }
};
pub fn main() !void {
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    defer _ = gpa.deinit();
    const alloc = gpa.allocator();
    var ctx = Context{};
    var pool: std.Thread.Pool = undefined;
    try pool.init(.{ .allocator = alloc, .n_jobs = 4 });
    defer pool.deinit();
    var wg: std.Thread.WaitGroup = .{};
    var i: i64 = 1;
    while (i <= 10) : (i += 1) {
        pool.spawnWg(&wg, Context.addSquare, .{ &ctx, i });
    }
    pool.waitAndWork(&wg);
    const out = std.io.getStdOut().writer();
    try out.print("{d}\n", .{ctx.total});
}

Key points

  • Thread pool: a thread pool is a fixed group of worker threads that runs tasks from a shared list.
  • Worker: a worker is one thread in the pool that picks up tasks and runs them.
  • The pool is created with an allocator and a worker count, and it is shut down with deinit.
  • WaitGroup: a wait group is a counter that tracks tasks still running.
  • spawnWg hands a task to the pool and counts it in the wait group.
  • waitAndWork makes the calling thread help run tasks until the wait group is done.
  • Shared state touched by pool tasks still needs a mutex, because tasks run at the same time.

Ten tasks each add one square to a shared total under a mutex. The pool runs them on four workers. waitAndWork waits until every task is done, so the printed total is the full sum of squares from 1 to 10.

Try it Yourself

Edit the code, then press Run.

Running is simulated

Exercise

What does waitAndWork do?