Ziglings 笔记 105: 并行计算 π (Parallel Math)

古老公式的现代引擎

在 Ziglings 的第 105 个练习中,我们把目光投向了数学。 莱布尼茨在 1682 年发表了一个优雅的 $\pi$ 计算公式。虽然它收敛速度很慢,但它的结构非常适合用来演示 数据并行 (Data Parallelism)

如果我们要计算 10 亿项,单核 CPU 可能会跑得风扇狂转。既然我们有两个 CPU 核心,为什么不让它们分工合作呢?

挑战:双核计算

任务是启动两个线程:

  1. 第一个线程计算所有正数项(已在代码中给出)。
  2. 第二个线程计算所有负数项(需要我们补充)。

公式拆解: $$\pi = \underbrace{4/1}{\text{Main}} - \underbrace{4/3}{\text{Thread 2}} + \underbrace{4/5}{\text{Thread 1}} - \underbrace{4/7}{\text{Thread 2}} + \underbrace{4/9}_{\text{Thread 1}} \dots$$

解决方案

我们需要正确配置第二个线程的启动参数:目标变量 pi_minus 和起始分母 3

const std = @import("std");

pub fn main() !void {
    const count = 1_000_000_000;
    var pi_plus: f64 = 0;
    var pi_minus: f64 = 0;

    {
        // 线程 1: 计算加项 (4/5, 4/9...)
        // 起始分母: 5, 步长: 4
        const handle1 = try std.Thread.spawn(.{}, thread_pi, .{ &pi_plus, 5, count });
        defer handle1.join();

        // 线程 2: 计算减项 (4/3, 4/7...)
        // 起始分母: 3, 步长: 4
        const handle2 = try std.Thread.spawn(.{}, thread_pi, .{ &pi_minus, 3, count });
        defer handle2.join();
    }
    
    // 汇总结果
    // PI ≈ 4 + (正数项之和) - (负数项之和)
    std.debug.print("PI ≈ {d:.8}\n", .{4 + pi_plus - pi_minus});
}

// 通用的计算函数
// pi: 结果写入的指针
// begin: 起始分母
// end: 循环上限
fn thread_pi(pi: *f64, begin: u64, end: u64) !void {
    var n: u64 = begin;
    // 步长为 4,因为我们把正负项分开了,每隔一项取一个
    while (n < end) : (n += 4) {
        // @floatFromInt 将整数转换为浮点数
        pi.* += 4 / @as(f64, @floatFromInt(n));
    }
}

核心知识点总结

1. 为什么不需要锁?

在多线程编程中,只要涉及到共享内存,通常就需要锁。但在这个例子中,我们巧妙地避开了冲突:

  • 线程 A 独占 pi_plus
  • 线程 B 独占 pi_minus。 这种数据隔离是最高效的并发策略。我们在最后汇总结果时(main 线程),两个子线程已经结束,所以读取也是安全的。

2. 指针的生命周期

我们传递给线程的是栈变量的地址 (&pi_minus)。 如果主线程在子线程结束前退出了(比如没有 join),栈内存会被回收,子线程写入时就会导致程序崩溃。 defer handle.join() 不仅是等待,更是为了保证内存安全

3. 性能提示 (ReleaseFast)

练习注释中提到,在 Debug 模式下运行这段代码会很慢(因为有大量的溢出检查和调试信息)。 如果你想体验真正的速度,尝试使用优化标志运行:

zig run -O ReleaseFast main.zig

你会发现计算 10 亿次除法和加法几乎在瞬间完成。


后续预告:我们已经体验了简单的多线程。但如果线程之间需要实时通信怎么办?接下来的练习可能会介绍 Futex、Atomic 或者更高层的并发原语。准备好处理更复杂的同步问题了吗?