Concorrência Avançada em Zig: Padrões e Performance

Se você já conhece o básico de threads e concorrência em Zig, está na hora de avançar. Neste artigo, vamos explorar padrões de concorrência do mundo real — thread pools, filas produtor-consumidor, operações atômicas e técnicas lock-free que fazem a diferença em aplicações de alta performance.

A abordagem do Zig para concorrência é fundamentalmente diferente de linguagens como Go ou Java. Não existe runtime gerenciando goroutines ou thread pools implícitas. Você constrói exatamente o que precisa, com controle total sobre cada byte de memória e cada ciclo de CPU.

Thread Pool: O Padrão Mais Importante

Criar uma thread por tarefa (como fizemos no artigo de networking) funciona para poucos clientes, mas não escala. Um thread pool reutiliza um número fixo de threads para processar muitas tarefas:

const std = @import("std");

pub fn ThreadPool(comptime Task: type) type {
    return struct {
        const Self = @This();

        threads: []std.Thread,
        queue: TaskQueue,
        mutex: std.Thread.Mutex = .{},
        condition: std.Thread.Condition = .{},
        shutdown: std.atomic.Value(bool) = std.atomic.Value(bool).init(false),
        allocator: std.mem.Allocator,

        const TaskQueue = std.DoublyLinkedList(Task);

        pub fn init(allocator: std.mem.Allocator, num_threads: usize) !Self {
            var pool = Self{
                .threads = try allocator.alloc(std.Thread, num_threads),
                .queue = .{},
                .allocator = allocator,
            };

            // Inicia as worker threads
            for (pool.threads) |*t| {
                t.* = try std.Thread.spawn(.{}, workerLoop, .{&pool});
            }

            return pool;
        }

        pub fn submit(self: *Self, task: Task) !void {
            const node = try self.allocator.create(TaskQueue.Node);
            node.* = .{ .data = task };

            self.mutex.lock();
            self.queue.append(node);
            self.mutex.unlock();

            self.condition.signal();
        }

        fn workerLoop(pool: *Self) void {
            while (!pool.shutdown.load(.acquire)) {
                pool.mutex.lock();

                // Espera até ter uma tarefa ou shutdown
                while (pool.queue.len == 0 and !pool.shutdown.load(.acquire)) {
                    pool.condition.wait(&pool.mutex);
                }

                const node = pool.queue.popFirst();
                pool.mutex.unlock();

                if (node) |n| {
                    // Executa a tarefa
                    n.data.execute();
                    pool.allocator.destroy(n);
                }
            }
        }

        pub fn deinit(self: *Self) void {
            self.shutdown.store(true, .release);
            self.condition.broadcast();

            for (self.threads) |t| {
                t.join();
            }
            self.allocator.free(self.threads);
        }
    };
}

O uso de comptime para parametrizar o tipo Task é um padrão idiomático do Zig — o compilador gera código especializado para cada tipo de tarefa, sem overhead de vtables ou dispatch dinâmico. Isso é uma vantagem significativa sobre thread pools genéricos em C++ ou Java que dependem de herança ou interfaces.

Usando o Thread Pool

const MyTask = struct {
    id: u32,
    data: []const u8,

    pub fn execute(self: *const MyTask) void {
        std.debug.print("Thread {any} processando tarefa {d}: {s}\n", .{
            std.Thread.getCurrentId(),
            self.id,
            self.data,
        });
        // Simula trabalho pesado
        std.time.sleep(100 * std.time.ns_per_ms);
    }
};

pub fn main() !void {
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    defer _ = gpa.deinit();

    var pool = try ThreadPool(MyTask).init(gpa.allocator(), 4);
    defer pool.deinit();

    // Submete 20 tarefas para 4 threads
    for (0..20) |i| {
        try pool.submit(.{
            .id = @intCast(i),
            .data = "processamento batch",
        });
    }
}

Padrão Produtor-Consumidor com Fila Thread-Safe

O produtor-consumidor é fundamental para pipelines de processamento. Vamos implementar uma fila bloqueante que permite múltiplos produtores e consumidores:

const std = @import("std");

pub fn BoundedQueue(comptime T: type, comptime capacity: usize) type {
    return struct {
        const Self = @This();

        buffer: [capacity]T = undefined,
        head: usize = 0,
        tail: usize = 0,
        count: usize = 0,
        mutex: std.Thread.Mutex = .{},
        not_empty: std.Thread.Condition = .{},
        not_full: std.Thread.Condition = .{},

        pub fn push(self: *Self, item: T) void {
            self.mutex.lock();
            defer self.mutex.unlock();

            // Espera até ter espaço
            while (self.count == capacity) {
                self.not_full.wait(&self.mutex);
            }

            self.buffer[self.tail] = item;
            self.tail = (self.tail + 1) % capacity;
            self.count += 1;

            self.not_empty.signal();
        }

        pub fn pop(self: *Self) T {
            self.mutex.lock();
            defer self.mutex.unlock();

            // Espera até ter um item
            while (self.count == 0) {
                self.not_empty.wait(&self.mutex);
            }

            const item = self.buffer[self.head];
            self.head = (self.head + 1) % capacity;
            self.count -= 1;

            self.not_full.signal();
            return item;
        }
    };
}

Essa fila circular com tamanho fixo é alocada inteiramente na stack (ou dentro da struct que a contém), sem nenhuma chamada de allocator. Isso a torna extremamente rápida e previsível — ideal para sistemas embarcados ou de tempo real.

Operações Atômicas e Lock-Free

Para cenários onde mutexes são caros demais, Zig oferece std.atomic com suporte a todas as ordens de memória. Vamos implementar um contador atômico e uma flag de spin-lock:

const std = @import("std");

const AtomicCounter = struct {
    value: std.atomic.Value(u64) = std.atomic.Value(u64).init(0),

    pub fn increment(self: *AtomicCounter) u64 {
        return self.value.fetchAdd(1, .monotonic);
    }

    pub fn get(self: *const AtomicCounter) u64 {
        return self.value.load(.acquire);
    }
};

// SpinLock — útil para seções críticas muito curtas
const SpinLock = struct {
    locked: std.atomic.Value(bool) = std.atomic.Value(bool).init(false),

    pub fn acquire(self: *SpinLock) void {
        while (self.locked.cmpxchgWeak(
            false,
            true,
            .acquire,
            .monotonic,
        ) != null) {
            // Spin hint — reduz consumo de energia no loop
            std.atomic.spinLoopHint();
        }
    }

    pub fn release(self: *SpinLock) void {
        self.locked.store(false, .release);
    }
};

Quando Usar Cada Abordagem

PadrãoLatênciaUso de CPUMelhor Para
Mutex + Condition~100nsBaixoEsperas longas, I/O
SpinLock~10nsAltoSeções críticas < 1μs
Atomics~5nsMínimoContadores, flags
Lock-free queue~20nsMédioAlta concorrência

A regra geral: comece com mutex (é mais simples e correto), e só migre para atomics ou lock-free quando o profiling mostrar que o lock é o gargalo. Zig facilita esse benchmarking com ferramentas nativas.

WaitGroup: Sincronização de Tarefas

Para esperar que múltiplas tarefas concluam, o padrão WaitGroup é essencial — similar ao sync.WaitGroup de Go:

const std = @import("std");

pub fn main() !void {
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    defer _ = gpa.deinit();
    const allocator = gpa.allocator();

    const num_workers = 8;
    var wait_group = std.Thread.WaitGroup{};

    var threads = try allocator.alloc(std.Thread, num_workers);
    defer allocator.free(threads);

    // Inicia workers
    for (threads, 0..) |*t, i| {
        wait_group.start();
        t.* = try std.Thread.spawn(.{}, worker, .{ &wait_group, i });
    }

    // Espera todos terminarem
    wait_group.wait();
    std.debug.print("Todas as {d} tarefas concluídas!\n", .{num_workers});
}

fn worker(wg: *std.Thread.WaitGroup, id: usize) void {
    defer wg.finish();

    std.debug.print("Worker {d} iniciando...\n", .{id});
    // Simula trabalho
    std.time.sleep(std.time.ns_per_s * (id % 3 + 1));
    std.debug.print("Worker {d} concluído.\n", .{id});
}

Padrão Fan-Out/Fan-In para Processamento Paralelo

Um dos padrões mais úteis para processamento de dados em lote é o fan-out/fan-in — distribuir trabalho entre múltiplas threads e coletar os resultados:

const std = @import("std");

fn parallelMap(
    comptime T: type,
    comptime R: type,
    items: []const T,
    results: []R,
    comptime mapFn: fn (T) R,
    num_threads: usize,
) !void {
    const chunk_size = (items.len + num_threads - 1) / num_threads;
    var wait_group = std.Thread.WaitGroup{};

    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    defer _ = gpa.deinit();

    var threads = try gpa.allocator().alloc(std.Thread, num_threads);
    defer gpa.allocator().free(threads);

    var spawned: usize = 0;
    for (0..num_threads) |i| {
        const start = i * chunk_size;
        if (start >= items.len) break;
        const end = @min(start + chunk_size, items.len);

        wait_group.start();
        threads[i] = try std.Thread.spawn(.{}, struct {
            fn run(wg: *std.Thread.WaitGroup, src: []const T, dst: []R) void {
                defer wg.finish();
                for (src, 0..) |item, j| {
                    dst[j] = mapFn(item);
                }
            }
        }.run, .{ &wait_group, items[start..end], results[start..end] });
        spawned += 1;
    }

    wait_group.wait();
}

Este padrão é especialmente poderoso combinado com SIMD para processamento vetorial — cada thread pode processar um chunk dos dados usando instruções SIMD, multiplicando a performance.

Comparação com Outras Linguagens

Zig vs Go

Go oferece goroutines e channels como primitivas de primeira classe, com um scheduler M:N sofisticado. A vantagem é a facilidade de uso — go func() e pronto. A desvantagem é o overhead do runtime (~4KB por goroutine, scheduler, GC) e a falta de controle sobre afinidade de thread, prioridades e alocação de memória.

Em Zig, você monta a infraestrutura que precisa, nem mais nem menos. É mais trabalho inicial, mas o resultado é código que roda em microsegundos, não milissegundos.

Zig vs Rust

Rust garante ausência de data races em tempo de compilação com ownership e o trait Send/Sync. É poderoso, mas adiciona complexidade — lifetimes em estruturas concorrentes podem ser desafiadores. Zig não tem essas garantias estáticas, mas compensa com um runtime de debug que detecta data races em tempo de execução (via Thread Sanitizer).

Zig vs Kotlin/JVM

Se você vem de Kotlin e suas coroutines, a diferença fundamental é que Zig não tem GC. Em Kotlin, você pode criar milhões de coroutines porque a JVM gerencia a memória. Em Zig, cada thread tem um custo real de stack (~8MB padrão no Linux), então thread pools são essenciais.

Armadilhas Comuns

  1. Data races: Zig não previne data races em compilação — use -fsanitize=thread no modo de debug para detectá-las
  2. Deadlocks: sempre adquira locks na mesma ordem global; considere usar tryLock com timeout
  3. False sharing: alinhe dados compartilhados em cache lines (64 bytes) com align(64)
  4. Stack overflow em threads: o tamanho padrão da stack pode não ser suficiente — configure via std.Thread.SpawnConfig
  5. Esquecimento de defer: sempre use defer mutex.unlock() imediatamente após o lock

Conclusão

Concorrência em Zig não é para iniciantes — exige entender threads, memória e sincronização em nível de sistema. Mas essa explicitação é justamente o ponto: você sabe exatamente o que seu programa faz, sem mágica de runtime.

Para começar, construa um thread pool simples e evolua conforme a necessidade. Se você quer ver esses padrões aplicados na prática, confira nosso artigo sobre networking com sockets que usa threads para atender múltiplos clientes.

Leitura Complementar


Explore nosso glossário para entender termos como allocator, comptime e error union, ou mergulhe nos tutoriais para aprender Zig do zero.

Continue aprendendo Zig

Explore mais tutoriais e artigos em português para dominar a linguagem Zig.