Ziglings 笔记 105: 并行计算 π (Parallel Math)
古老公式的现代引擎
在 Ziglings 的第 105 个练习中,我们把目光投向了数学。 莱布尼茨在 1682 年发表了一个优雅的 $\pi$ 计算公式。虽然它收敛速度很慢,但它的结构非常适合用来演示 数据并行 (Data Parallelism)。
如果我们要计算 10 亿项,单核 CPU 可能会跑得风扇狂转。既然我们有两个 CPU 核心,为什么不让它们分工合作呢?
挑战:双核计算
任务是启动两个线程:
- 第一个线程计算所有正数项(已在代码中给出)。
- 第二个线程计算所有负数项(需要我们补充)。
公式拆解: $$\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 或者更高层的并发原语。准备好处理更复杂的同步问题了吗?