Skip to content

Day 78: std.synchronization模块:高级同步原语

我们在std.thread中已经学习了互斥锁(Mutex),它用于保护共享数据,确保一次只有一个线程可以访问。然而,并发编程中的协调问题远比这更复杂。有时我们需要:

  • 允许多个(但有数量限制的)线程同时访问一个资源池。
  • 让多个线程一直等待,直到所有线程都到达了某个同步点。

std.sync(或std.synchronization)模块提供了更高级的同步原语来解决这些问题,主要是信号量(Semaphore)和屏障(Barrier)。

信号量可以看作是一个管理着一组“许可证”的计数器。一个线程要想执行某个操作,必须先从信号量那里获取一个许可证(wait())。如果许可证已经发完,线程就会阻塞,直到有其他线程释放一个许可证(post())。

这对于控制对有限资源的访问非常有用,例如数据库连接池或线程池中的工作任务。

  • init(initial_permits: usize): 初始化一个信号量,并设定初始许可证数量。
  • wait(): 等待并获取一个许可证,使计数器减一。
  • post(): 释放一个许可证,使计数器加一。
const std = @import("std");
var sem = std.sync.Semaphore.init(2); // 最多允许2个并发访问
fn worker(id: u32) void {
std.debug.print("Worker {d} waiting...\n", .{id});
sem.wait();
std.debug.print("Worker {d} acquired semaphore!\n", .{id});
std.time.sleep(1 * std.time.ns_per_s);
sem.post();
std.debug.print("Worker {d} released semaphore.\n", .{id});
}

如果同时启动3个worker,你会发现总是有两个先进入,第三个必须等待其中一个完成后才能进入。

屏障(Barrier)用于让一组线程相互等待,直到所有线程都到达了同一个执行点。想象一下赛跑,所有选手都必须在起跑线上准备好,然后才能同时出发。

  • init(count: usize): 初始化一个屏障,并指定需要等待的线程数量。
  • wait(): 通知屏障“我已到达”,然后阻塞,直到所有count个线程都调用了wait()。
const std = @import("std");
const num_threads = 4;
var barrier = std.sync.Barrier.init(num_threads);
fn task(id: u32) void {
std.debug.print("Task {d} doing pre-barrier work...\n", .{id});
// ...
// 等待所有其他任务到达屏障
barrier.wait();
// 这部分代码只有在所有4个任务都调用了barrier.wait()之后才会执行
std.debug.print("Task {d} doing post-barrier work...\n", .{id});
}

信号量可以用来实现一个简单的固定大小的线程池的任务队列。

const std = @import("std");
var task_queue = std.ArrayList(fn()void).init(std.testing.allocator);
var tasks_available = std.sync.Semaphore.init(0);
fn worker_thread() void {
while (true) {
tasks_available.wait(); // 等待有任务可用
const task = task_queue.pop();
task();
}
}
fn submit_task(task: fn()void) !void {
try task_queue.append(task);
tasks_available.post(); // 通知worker有新任务
}

使用std.thread.Mutex和一个std.sync.Semaphore来构建一个读写锁(Readers-Writer Lock)。这个锁应该允许多个“读者”同时访问,但“写者”必须独占访问。提示:你需要一个互斥锁来保护读者计数器,并用信号量来控制写者的独占访问。

  • 自旋锁(Spinlock) vs. 阻塞锁 Mutex, Semaphore和Barrier都是阻塞式的。当一个线程等待时,它会被操作系统挂起,不消耗CPU。自旋锁则是在一个循环中不断检查锁是否可用,这会消耗CPU,但避免了线程上下文切换的开销。自旋锁只适用于锁的持有时间极短的场景。

  • 条件变量(Condition Variables) 条件变量是另一种重要的同步原语,它允许线程等待某个特定条件变为真。std.thread.Cond提供了这个功能。它总是与一个Mutex配合使用。

  • 如何避免死锁? 死锁是并发编程中的经典问题。一个常见的死锁场景是:线程A持有锁1并等待锁2,而线程B持有锁2并等待锁1。预防死锁的一个基本规则是:所有线程都必须以相同的顺序获取锁。

Zig的同步原语在底层通常是基于操作系统的futex(Fast Userspace Mutex)或类似的机制。这意味着在没有竞争的情况下,加锁和解锁操作可以在用户空间完成,非常快。只有当发生竞争(即一个线程需要等待)时,才会陷入内核,进行线程调度。

通过掌握Semaphore和Barrier等高级同步工具,你能够构建出更复杂、更精细、性能更高的并发应用程序,解决那些仅靠互斥锁难以应对的协调问题。