第16章 线程与并发 (Threads & Concurrency)

第16章 线程与并发 (Threads & Concurrency)

在现代编程中,并发(Concurrency)和并行(Parallelism)是绕不开的话题。Zig 提供了非常轻量且符合人体工程学的线程支持。

Zig 的 std.Thread 是对操作系统原生线程(如 Linux 的 pthreads, Windows 的 Threads)的封装。它不依赖运行时(Runtime),没有像 Go 语言那样的 Goroutine 或 M:N 调度器。这意味着 Zig 的线程就是系统线程

1. 创建线程

使用 std.Thread.spawn 创建新线程。

const std = @import("std");
const Thread = std.Thread;

fn worker(id: u32) !void {
    std.debug.print("Worker {d} started\n", .{id});
    Thread.sleep(500 * std.time.ns_per_ms); // 模拟工作
    std.debug.print("Worker {d} finished\n", .{id});
}

pub fn main() !void {
    // spawn 的参数:(配置, 函数, 参数元组)
    // .{} 是默认配置 (stack_size 等)
    // .{1} 是传递给 worker 的参数元组
    const t1 = try Thread.spawn(.{}, worker, .{1});
    const t2 = try Thread.spawn(.{}, worker, .{2});

    // 等待线程结束
    t1.join();
    t2.join();
    
    std.debug.print("All jobs done\n", .{});
}

Join vs Detach

创建线程后,你必须决定它的归宿:

  1. join():等待线程结束。主线程会被阻塞,直到子线程执行完毕。这是最常用的方式,因为它能确保资源被正确回收,并且能捕获子线程的错误。
  2. detach():让线程“自生自灭”。主线程不再关心它何时结束,也不会回收它的资源(由操作系统接管)。通常用于后台守护任务。

警告:如果你既不 join 也不 detach,当线程句柄 (t1) 离开作用域时,程序行为在某些构建模式下可能是未定义的或导致 Panic。

2. 线程安全与互斥锁 (Mutex)

多线程最著名的问题就是数据竞争 (Data Race):多个线程同时修改同一块内存,导致结果不可预测。

Zig 提供了 std.Thread.Mutex 来保护临界区。

数据竞争示例

var counter: usize = 0;

fn increment() void {
    for (0..10000) |_| {
        counter += 1; // 危险!非原子操作
    }
}
// 两个线程跑完,counter 很可能小于 20000

使用 Mutex 修复

const Mutex = std.Thread.Mutex;
var counter: usize = 0;
var mutex = Mutex{}; // 初始化锁

fn increment() void {
    for (0..10000) |_| {
        mutex.lock();
        defer mutex.unlock(); // 确保退出作用域时解锁
        
        counter += 1; 
    }
}

3. 读写锁 (RwLock)

如果你的数据读多写少(例如配置信息),使用 Mutex 会导致所有读操作串行化,效率低下。这时应该用 std.Thread.RwLock

  • Shared Lock (读锁):允许多个线程同时持有。只要没人持有写锁,读锁就能申请成功。
  • Exclusive Lock (写锁):独占。同一时间只能有一个线程持有写锁,且此时不能有任何读锁。
var config_data: usize = 0;
var rwlock = std.Thread.RwLock{};

fn reader() void {
    rwlock.lockShared(); // 获取读锁
    defer rwlock.unlockShared();
    
    // ... 读取 config_data ...
}

fn writer() void {
    rwlock.lock(); // 获取写锁
    defer rwlock.unlock();
    
    // ... 修改 config_data ...
}

4. 线程池 (Thread Pool)

创建线程是有开销的(分配栈内存、系统调用)。如果你有大量短小的任务,频繁创建销毁线程会严重拖慢性能。

线程池 是一组预先创建好的线程。你只需把任务扔进去,池子里的空闲线程就会自动领取执行。

Zig 标准库提供了高效的线程池 std.Thread.Pool

const std = @import("std");
const Pool = std.Thread.Pool;
const WaitGroup = std.Thread.WaitGroup;

fn task(id: u32, wg: *WaitGroup) void {
    std.debug.print("Task {d} running\n", .{id});
    wg.finish(); // 任务完成,计数器 -1
}

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

    // 1. 初始化线程池
    var pool: Pool = undefined;
    // 创建 4 个工作线程
    try pool.init(.{ .allocator = allocator, .n_jobs = 4 });
    defer pool.deinit();

    // 2. 使用 WaitGroup 等待所有任务完成
    var wg = WaitGroup{};
    
    // 3. 提交任务
    for (0..10) |i| {
        wg.start(); // 计数器 +1
        // spawn 不会阻塞,它只是把任务放入队列
        try pool.spawn(task, .{ @as(u32, @intCast(i)), &wg });
    }

    // 4. 等待所有任务完成
    wg.wait(); 
    std.debug.print("All tasks finished\n", .{});
}

5. 原子操作 (Atomics)

对于简单的计数器或标志位,使用 Mutex 太重了。Zig 提供了原子操作来处理这些轻量级同步。

const std = @import("std");

pub fn main() void {
    // 创建一个原子布尔值
    var running = std.atomic.Value(bool).init(true);
    
    // 原子读取
    const is_running = running.load(.monotonic);
    
    // 原子写入
    running.store(false, .release);
    
    // 原子加减 (fetchAdd / fetchSub)
    var count = std.atomic.Value(u32).init(0);
    _ = count.fetchAdd(1, .monotonic);
}
  • 内存顺序 (Memory Ordering).monotonic, .acquire, .release, .seq_cst 等。这是一个深奥的话题,对于初学者,简单的计数器使用 .monotonic 通常足够;涉及跨线程同步信号时,通常使用 .acquire / .release

6. 并发陷阱

  1. 死锁 (Deadlock):线程 A 只有了锁 1 等锁 2,线程 B 持有了锁 2 等锁 1。两者互相等待,永久卡死。
    • 对策:总是按相同的顺序获取锁。
  2. 忘记解锁:如果代码在解锁前提前 return 或抛出错误。
    • 对策:始终使用 defer mutex.unlock()
  3. 栈溢出:线程的栈空间是有限的(默认几 MB)。不要在线程栈上分配巨大的数组。
    • 对策:大对象使用 allocator 分配在堆上。

总结

Zig 的并发模型回归本源,简单直接:

  1. std.Thread:直接操作 OS 线程。
  2. Mutex / RwLock:经典的同步原语。
  3. Pool:高效处理大量任务。
  4. Atomic:高性能的无锁操作。

虽然 Zig 曾有一个非常前卫的 async/await 语法(基于无栈协程),但在目前的稳定版本(及 0.11+)中,该特性被暂时移除以进行重构。目前的并发编程主要依赖上述的线程模型。

掌握了线程,我们就能充分利用多核 CPU 的性能。下一章,我们将探索 Zig 更底层的能力——向量化编程 (SIMD)


原文出处: https://pedropark99.github.io/zig-book/