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ão | Latência | Uso de CPU | Melhor Para |
|---|---|---|---|
| Mutex + Condition | ~100ns | Baixo | Esperas longas, I/O |
| SpinLock | ~10ns | Alto | Seções críticas < 1μs |
| Atomics | ~5ns | Mínimo | Contadores, flags |
| Lock-free queue | ~20ns | Médio | Alta 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
- Data races: Zig não previne data races em compilação — use
-fsanitize=threadno modo de debug para detectá-las - Deadlocks: sempre adquira locks na mesma ordem global; considere usar
tryLockcom timeout - False sharing: alinhe dados compartilhados em cache lines (64 bytes) com
align(64) - Stack overflow em threads: o tamanho padrão da stack pode não ser suficiente — configure via
std.Thread.SpawnConfig - 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
- Concorrência básica em Zig — fundamentos de threads e channels
- io_uring e async I/O — I/O assíncrono de alta performance no Linux
- SIMD e processamento vetorial — combine paralelismo de thread com paralelismo de dados
- Testes em Zig — como testar código concorrente
- Error handling — tratamento de erros em contextos multi-thread
- Clean code em Zig — boas práticas para código legível
Explore nosso glossário para entender termos como allocator, comptime e error union, ou mergulhe nos tutoriais para aprender Zig do zero.