与 Goroutine 的"运行时调度"或 Java 的"内存模型规范"不同,Zig 的并发哲学是把控制权完全交给你:标准库只提供最薄的线程与同步原语包装,不会替你决定并发模型。这也意味着,Zig 并发代码的每一行你都能精确预测其行为——前提是你理解锁、原子操作与内存序。
本文覆盖 std.Thread、Mutex/Condition/RwLock、std.atomic.Value、消息传递模式与锁竞争优化。
1. std.Thread 基础
1.1 创建与回收
const std = @import("std");
const Counter = struct {
value: usize = 0,
fn run(self: *Counter) void {
var i: usize = 0;
while (i < 1_000_000) : (i += 1) {
self.value += 1;
}
}
};
pub fn main() !void {
var counter = Counter{};
// spawn 的第三个参数是线程入口参数元组
const t1 = try std.Thread.spawn(.{}, Counter.run, .{&counter});
const t2 = try std.Thread.spawn(.{}, Counter.run, .{&counter});
// join 阻塞等待线程结束,并返回其返回类型(这里为 void)
t1.join();
t2.join();
std.debug.print("value = {d}\n", .{counter.value});
// 注意:两个线程同时 += 1,未加锁时结果是未定义的!
}
spawn 返回 std.Thread,join() 语义与 pthread_join 一致:回收线程资源并传播其错误。若线程永不退出,join 会永久阻塞——这是常见的死锁来源。
1.2 栈大小与配置
spawn 的第一个参数是 SpawnConfig:
const t = try std.Thread.spawn(.{
.stack_size = 64 * 1024, // 64KB 栈
.allocator = some_allocator, // 默认是 page_allocator
}, worker, .{});
每个线程栈默认由 page_allocator 分配,默认大小与平台相关。创建大量线程时(如连接池),应显式缩小栈并复用分配器。
1.3 线程局部变量
Zig 通过 threadlocal 关键字支持线程局部存储:
threadlocal var tls_buffer: [4096]u8 = undefined;
fn worker(id: usize) void {
// 每个线程看到自己独立的 tls_buffer
tls_buffer[0] = @intCast(id);
_ = id;
}
threadlocal 变量适合"每线程缓存"模式,能显著降低多线程下的缓存行竞争。
2. 同步原语
2.1 Mutex 与锁的作用域
上节 Counter 的 bug 是经典的读-改-写竞态。用 Mutex 修复:
const SafeCounter = struct {
mutex: std.Thread.Mutex = .{},
value: usize = 0,
fn increment(self: *SafeCounter) void {
self.mutex.lock();
defer self.mutex.unlock(); // 任何 return / panic 路径都会解锁
self.value += 1;
}
fn get(self: *SafeCounter) usize {
self.mutex.lock();
defer self.mutex.unlock();
return self.value;
}
};
铁律:lock 之后立刻写 defer unlock。Zig 没有 RAII,defer 是唯一可靠的解锁方式;一旦遗漏,一个 panic 或提前 return 就会让锁永远无法释放,整个系统死锁。
2.2 Condition 变量
Condition 解决"等待条件成立"的问题。经典的生产者-消费者队列:
const std = @import("std");
const Queue = struct {
mutex: std.Thread.Mutex = .{},
cond: std.Thread.Condition = .{},
items: std.ArrayList(usize) = undefined,
is_open: bool = true,
fn init(self: *Queue, allocator: std.mem.Allocator) void {
self.items = std.ArrayList(usize).init(allocator);
}
fn push(self: *Queue, item: usize) !void {
self.mutex.lock();
defer self.mutex.unlock();
try self.items.append(item);
self.cond.signal(); // 唤醒一个等待者
}
fn pop(self: *Queue) ?usize {
self.mutex.lock();
defer self.mutex.unlock();
while (self.items.items.len == 0 and self.is_open) {
self.cond.wait(&self.mutex); // 原子地释放锁并挂起
}
if (self.items.items.len == 0) return null;
return self.items.orderedRemove(0);
}
fn close(self: *Queue) void {
self.mutex.lock();
defer self.mutex.unlock();
self.is_open = false;
self.cond.broadcast(); // 唤醒所有等待者
}
};
cond.wait(&mutex) 的语义必须强调:它原子地释放 mutex 并挂起线程,被唤醒时重新获取 mutex 再返回。所以必须放在 while 循环里重查条件(spurious wakeup 与 is_open 变化都可能导致条件仍不成立)。
2.3 读写锁 RwLock
读多写少的场景,RwLock 让多个读者并发、写者独占:
const Cache = struct {
rwlock: std.Thread.RwLock = .{},
data: [128]u8 = undefined,
fn read(self: *Cache) u8 {
self.rwlock.lockShared();
defer self.rwlock.unlockShared();
return self.data[0];
}
fn write(self: *Cache, v: u8) void {
self.rwlock.lock();
defer self.rwlock.unlock();
self.data[0] = v;
}
};
RwLock 的代价是:读写切换时可能有较长的等待队列,若写者频繁出现,甚至比普通 Mutex 更慢(写者饿死读者)。读写锁不是免费的"并行加速器",只对明确的读多写少且有足够并发度的场景有意义。
3. 原子类型与操作
3.1 std.atomic.Value
无锁编程的基石是 std.atomic.Value(T),支持整数、布尔、指针等平凡类型:
const std = @import("std");
const AtomicCounter = struct {
value: std.atomic.Value(usize) = std.atomic.Value(usize).init(0),
fn increment(self: *AtomicCounter) void {
_ = self.value.fetchAdd(1, .monotonic);
}
fn get(self: *AtomicCounter) usize {
return self.value.load(.acquire);
}
};
pub fn main() void {
var counter = AtomicCounter{};
counter.increment();
counter.increment();
std.debug.print("count = {d}\n", .{counter.get()});
}
核心方法:
| 方法 | 行为 |
|---|---|
load(order) | 原子读 |
store(new, order) | 原子写 |
fetchAdd(n, order) | 原子加,返回旧值 |
fetchSub(n, order) | 原子减,返回旧值 |
cmpxchgStrong(expected, new, success_order, fail_order) | 比较交换,成功返回 null,失败返回当前值 |
cmpxchgWeak(...) | 可能假失败,用于自旋循环 |
3.2 内存序(Memory Order)
内存序决定了原子操作周围的普通内存访问如何排序,是并发编程中最容易出错的部分:
| 内存序 | 保证 | 典型用途 |
|---|---|---|
.monotonic(0.14 起又称 .relaxed) | 仅保证原子性,不保证排序 | 计数器、统计 |
.acquire | 该操作之后的读写不会被重排到其之前 | 读取锁标记、加载数据指针 |
.release | 该操作之前的读写不会被重排到其之后 | 发布数据、解锁 |
.acq_rel | acquire + release | RMW 操作(fetchAdd 等) |
.seq_cst | 全局一致顺序 | 需要跨线程严格因果时的兜底 |
一个标准的"发布-获取"模式——无锁地发布一个指向数据的指针:
const std = @import("std");
const SharedData = struct {
payload: [64]u8 = undefined,
ready: std.atomic.Value(bool) = std.atomic.Value(bool).init(false),
};
pub fn main() !void {
var shared = SharedData{};
const shared_ptr = &shared;
const writer = try std.Thread.spawn(.{}, struct {
fn run(p: *SharedData) void {
// 先写数据
@memcpy(&p.payload, "hello from writer");
// release:确保 payload 的写入在 ready=true 之前对其他线程可见
p.ready.store(true, .release);
}
}.run, .{shared_ptr});
const reader = try std.Thread.spawn(.{}, struct {
fn run(p: *SharedData) void {
// acquire:确保看到 ready=true 后,payload 的写入也可见
while (!p.ready.load(.acquire)) {
std.Thread.yield() catch {};
}
std.debug.print("{s}\n", .{&p.payload});
}
}.run, .{shared_ptr});
writer.join();
reader.join();
}
release/acquire 成对出现才有效:没有与 release 配对的 acquire,就没有跨线程的顺序保证。.seq_cst 虽然总是正确,但会抑制编译器/CPU 的指令重排,热路径上会损失性能,应谨慎使用。
3.3 自旋锁实现
利用 cmpxchgStrong 实现一个极简自旋锁(适合临界区极短的场景,如无锁数据结构的内部互斥):
const std = @import("std");
const SpinLock = struct {
flag: std.atomic.Value(bool) = std.atomic.Value(bool).init(false),
fn lock(self: *SpinLock) void {
while (self.flag.cmpxchgStrong(false, true, .acquire, .monotonic) != null) {
// 自旋:重试直到成功
std.atomic.spinLoopHint();
}
}
fn unlock(self: *SpinLock) void {
self.flag.store(false, .release);
}
};
自旋锁的适用边界很窄:临界区必须极短(几十条指令以内),否则应改用会让出 CPU 的 Mutex(底层是 futex,等待时睡眠)。在单核机器上,自旋锁是死锁陷阱——持有锁的线程永远得不到 CPU。
3.4 Futex
Linux 上 Mutex 的底层是 futex(Fast Userspace Mutex):未竞争时在用户态自旋/原子操作,竞争时才陷入内核睡眠。Zig 直接暴露了它:
const std = @import("std");
// 原子标记
const state: std.atomic.Value(u32) = std.atomic.Value(u32).init(0);
fn waitForSignal() void {
// 当 state 仍为 0 时挂起
while (state.load(.acquire) == 0) {
std.Thread.Futex.wait(&state, 0);
}
}
fn sendSignal() void {
state.store(1, .release);
std.Thread.Futex.wake(&state, 1); // 唤醒 1 个等待者
}
手写 futex 场景通常不如直接使用 Mutex + Condition,但在构建自旋锁退避、无锁队列的阻塞变体时非常有用。
4. 消息传递模式
共享内存 + 锁是"共享一切"模型,容易出错。消息传递把并发单元之间的交互收敛为"发消息"这一种操作,是现代并发架构的主流。Zig 没有内置 channel,但几十行就能构建一个线程安全的有界通道:
const std = @import("std");
const BoundedChannel = struct {
mutex: std.Thread.Mutex = .{},
can_push: std.Thread.Condition = .{},
can_pop: std.Thread.Condition = .{},
buf: []usize,
head: usize = 0,
count: usize = 0,
fn init(allocator: std.mem.Allocator, capacity: usize) !BoundedChannel {
return .{ .buf = try allocator.alloc(usize, capacity) };
}
fn deinit(self: *BoundedChannel, allocator: std.mem.Allocator) void {
allocator.free(self.buf);
}
fn push(self: *BoundedChannel, item: usize) void {
self.mutex.lock();
defer self.mutex.unlock();
while (self.count == self.buf.len) {
self.can_push.wait(&self.mutex); // 缓冲区满则等待
}
self.buf[(self.head + self.count) % self.buf.len] = item;
self.count += 1;
self.can_pop.signal();
}
fn pop(self: *BoundedChannel) usize {
self.mutex.lock();
defer self.mutex.unlock();
while (self.count == 0) {
self.can_pop.wait(&self.mutex); // 缓冲区空则等待
}
const item = self.buf[self.head];
self.head = (self.head + 1) % self.buf.len;
self.count -= 1;
self.can_push.signal();
return item;
}
};
const Worker = struct {
channel: *BoundedChannel,
fn run(self: *Worker) void {
while (true) {
const job = self.channel.pop();
if (job == 0) break; // 哨兵值:0 表示结束
std.debug.print("处理 job {d}\n", .{job});
}
}
};
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
var channel = try BoundedChannel.init(allocator, 8);
defer channel.deinit(allocator);
// 三个消费者
var workers: [3]Worker = undefined;
var threads: [3]std.Thread = undefined;
for (&workers, &threads) |*w, *t| {
w.* = .{ .channel = &channel };
t.* = try std.Thread.spawn(.{}, Worker.run, .{w});
}
// 生产者发送任务
var i: usize = 1;
while (i <= 10) : (i += 1) channel.push(i);
for (0..3) |_| channel.push(0); // 三个哨兵,通知各消费者退出
for (&threads) |*t| t.join();
}
消息传递模式的优势在于边界清晰:任务进入 channel 后,任何线程都不再共享可变状态,竞态被限制在 channel 内部这一个精心实现的地方。
5. 锁竞争与性能
5.1 锁竞争的成本
一次锁操作本身只有几十纳秒,但竞争会带来三个数量级的放大:
| 竞争级别 | 代价 | 对策 |
|---|---|---|
| 无竞争(uncontended) | ~25ns | 什么都不做,保持临界区短 |
| 有竞争(用户态) | ~100ns | 减小临界区、减少持锁时间 |
| 系统调用(futex 睡眠) | ~1-10μs | 避免热路径上让锁、批量处理 |
| 缓存行颠簸 | 吞吐下降数倍 | 线程局部数据、填充 cacheline |
5.2 减少竞争的六条手段
- 缩小临界区:只锁真正需要保护的数据,I/O 放锁外。
- 分片/分区:把一个大锁拆成多个小锁(如哈希表的 bucket 级锁)。
- 线程局部缓存:每线程累积计数,定期合并(对应
threadlocal)。 - 读写锁:读多写少时用
RwLock。 - 无锁/原子:短临界区用原子操作或自旋锁替代 mutex。
- 避免锁内调用未知函数:持锁时绝不做分配器分配、系统调用或回调。
5.3 三种同步原语的取舍
| 原语 | 等待语义 | 适用临界区 | 典型延迟 |
|---|---|---|---|
| 自旋锁 | 忙等,占用 CPU | 极短(<1μs) | 纳秒级 |
| Mutex | futex 睡眠,让出 CPU | 中等 | 微秒级 |
| RwLock | 读者并发,写者独占 | 读多写少 | 与 Mutex 同量级 |
选择原则:临界区短且等待时间可预期用自旋;临界区长或可能阻塞用 Mutex;读多写少且读者众多用 RwLock。 不要臆测性能,用 profile(如 Linux perf)确认瓶颈在锁上再动手。
6. 最佳实践与总结
6.1 黄金规则清单
- 持锁必配
defer unlock,防止 panic/提前 return 导致死锁。 Condition的 wait 必须包裹在while重查条件的循环里。- 内存序只用必要的强度:统计用
.monotonic,发布-获取用.release/.acquire,没有充分理由不用.seq_cst。 - 生产环境优先用
Mutex/Condition,自旋锁只在热路径且临界区极短时使用。 - 共享可变状态尽量收敛为消息传递;channel 的实现要独立且充分测试。
6.2 与相关专题的衔接
Zig 的并发代码经常与 https://plumephp.com/zig-async-network/ 的异步 I/O 配合(线程池 + 事件循环),与 https://plumephp.com/zig-std-data-structures/ 的容器共用分配器。如果你更熟悉 Go 的 goroutine 模型,可对照阅读 https://plumephp.com/posts/golang/ 专题,理解两种并发哲学在调度与内存模型上的根本差异。
6.3 总结
Zig 并发没有魔法:线程是线程,锁是锁,原子是原子,全部显式。这种透明性让你可以精确推理每一行并发代码的时序与成本,代价是你必须承担全部责任。掌握本文的同步原语与消息传递模式,你就能在保持正确性的前提下,写出吞吐可控、行为可预测的 Zig 并发系统。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。