第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
创建线程后,你必须决定它的归宿:
join():等待线程结束。主线程会被阻塞,直到子线程执行完毕。这是最常用的方式,因为它能确保资源被正确回收,并且能捕获子线程的错误。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. 并发陷阱
- 死锁 (Deadlock):线程 A 只有了锁 1 等锁 2,线程 B 持有了锁 2 等锁 1。两者互相等待,永久卡死。
- 对策:总是按相同的顺序获取锁。
- 忘记解锁:如果代码在解锁前提前
return或抛出错误。- 对策:始终使用
defer mutex.unlock()。
- 对策:始终使用
- 栈溢出:线程的栈空间是有限的(默认几 MB)。不要在线程栈上分配巨大的数组。
- 对策:大对象使用
allocator分配在堆上。
- 对策:大对象使用
总结
Zig 的并发模型回归本源,简单直接:
std.Thread:直接操作 OS 线程。Mutex/RwLock:经典的同步原语。Pool:高效处理大量任务。Atomic:高性能的无锁操作。
虽然 Zig 曾有一个非常前卫的 async/await 语法(基于无栈协程),但在目前的稳定版本(及 0.11+)中,该特性被暂时移除以进行重构。目前的并发编程主要依赖上述的线程模型。
掌握了线程,我们就能充分利用多核 CPU 的性能。下一章,我们将探索 Zig 更底层的能力——向量化编程 (SIMD)。