Zig 并发与原子操作:线程、同步与消息传递

本文深入 Zig 的并发原语:std.Thread 线程模型、Mutex/Condition/RwLock 同步、std.atomic.Value 原子类型与内存序,以及消息传递模式与锁竞争优化,最后给出基准对比。

与 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_relacquire + releaseRMW 操作(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 减少竞争的六条手段

  1. 缩小临界区:只锁真正需要保护的数据,I/O 放锁外。
  2. 分片/分区:把一个大锁拆成多个小锁(如哈希表的 bucket 级锁)。
  3. 线程局部缓存:每线程累积计数,定期合并(对应 threadlocal)。
  4. 读写锁:读多写少时用 RwLock。
  5. 无锁/原子:短临界区用原子操作或自旋锁替代 mutex。
  6. 避免锁内调用未知函数:持锁时绝不做分配器分配、系统调用或回调。

5.3 三种同步原语的取舍

原语等待语义适用临界区典型延迟
自旋锁忙等,占用 CPU极短(<1μs)纳秒级
Mutexfutex 睡眠,让出 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 并发系统。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「系统编程」更多文章

  1. Zig 嵌入式开发:交叉编译与 MCU 裸机实践
  2. Zig 裸机开发:从零编写最小内核
  3. Zig 高级 FFI:动态库、回调、内存布局与 C++ ABI