曼德博程序与并发 — 一个多线程画图程序串起半本语言
曼德博程序与并发 — 一个多线程画图程序串起半本语言
这一章讲一件事: 把一个算曼德博集合(一种分形图案)的命令行程序,从单线程改写成多线程。 听起来普通,但原书把它当全书的「串珠绳」:泛型函数、match 表达式、Option 类型、切片、
?错误操作符、move闭包、scoped 线程——每个特性都在程序的某一步登场, 而且每一步都回答「没有这个特性会怎样」。
1. 顶层全景:数据怎么流过整个程序
命令行:mandel.png 4000x3000 -1.20,0.35 -1,0.20
│ parse_pair / parse_complex 解析(泛型 + match)
▼
像素缓冲:vec![0; 1200 万] ──一个像素一字节
│ 切成若干条带(chunks_mut),一条带 = 若干整行
▼
每条带一个线程:render 计算每个像素的颜色
│ escape_time:复数迭代,跑出半径 2 的圆就判定逃逸
▼
全部线程结束后:write_image 写成 PNG(? 传播错误)图说:程序在「像素缓冲」处分叉到多个线程,在写文件处汇合——这条分叉-汇合结构就是本章主线。
2. 要算什么:曼德博集合的判定规则
原书先带你推四步纯数学,每一步都是一个小程序:
第 1 步,反复平方。 loop { x = x * x }:小于 1 的数越平方越小趋于 0;1 还是 1;
大于 1 趋于无穷大——行为完全可预测1。
第 2 步,平方后加一个常数。 x = x*x + c:行为开始难料。实验发现:
c 大于 0.25 或小于 −2.0 时 x 终会飞走,否则待在 0 附近2。
第 3 步,换成复数。 复数是有实部、虚部两个坐标的数;迭代 z = z*z + c 在复平面上产生真正诡异的图案。
数学定义一句话:曼德博集合 = 所有「z 不飞无穷远」的复数 c 的集合3。
第 4 步,让无穷可判定。 两个诀窍:其一,只迭代有限次(比如 255 次),当作近似; 其二,已证明:z 一旦离开以原点为圆心、半径 2 的圆,就必定飞走4。
于是全书这个程序的心脏只有几行:
fn escape_time(c: Complex<f64>, limit: usize) -> Option<usize> {
let mut z = Complex { re: 0.0, im: 0.0 };
for i in 0..limit {
if z.norm_sqr() > 4.0 { // 距离平方 > 4,即出了半径 2 的圆
return Some(i); // 第 i 次迭代逃逸
}
z = z * z + c;
}
None // 迭代到上限还没逃逸:大概率在集合里
}z.norm_sqr() 是距离平方。不开发根号、直接和 4.0 比,是因为比较快5。
这个 escape_time 就是本章主线例子的核心一步:每调一次,输入一个复数点,输出「逃逸用了几次迭代」或「没逃逸」。
escape_time 的两个参数与「差不多判定」
值得花两行看清这个函数签名的心思:escape_time(c, limit) 里,limit 是「最多试几次」的上限。
返回 Some(i) 是铁证(第 i 次迭代出了半径 2 的圆,数学上必飞无疑);
返回 None 只是「试到上限也没抓到它逃逸」——是「大概是集合成员」的工程判定,不是证明。
无限迭代等不起,有限迭代换近似:精度与成本都捏在 limit 这一个数里6。
文档注释:三斜杠的约定
函数上方那几行 /// 不是普通注释,是文档注释:rustdoc 工具收走它们、连同被描述的代码一起
生成在线文档,Rust 标准库的全部文档就是这么写的。写库给别人用的时候,/// 和写测试一样,
是交付物的一部分7。
3. Option:把「可能没有」写进类型
escape_time 的返回类型 Option<usize> 引出一个全书反复出现的类型。Option 是枚举(enum)——
定义里列出了一个值可能的几种形态的类型:
enum Option<T> {
None, // 没有值
Some(T), // 有一个 T 类型的值
}它是泛型:任何类型 T 都可以套进 Option<T>,表示「可选的值」8。
在这个程序里:Some(i) = 不在集合里、第 i 次迭代逃逸;None = 到上限也没证明它不在集合里9。
这一节同时带出两个语法常识:for i in 0..limit 是区间迭代(0 到 limit−1);
/// 开头的注释是文档注释,rustdoc 会收走生成网页——标准库文档就是这么写的10。
4. 解析命令行:泛型函数与 match 的第一次实战
程序要从命令行读 4000x3000(分辨率)和 -1.20,0.35(复平面角落)。解析函数:
fn parse_pair<T: FromStr>(s: &str, separator: char) -> Option<(T, T)> {
match s.find(separator) {
None => None,
Some(index) => {
match (T::from_str(&s[..index]), T::from_str(&s[index + 1..])) {
(Ok(l), Ok(r)) => Some((l, r)),
_ => None,
}
}
}
}泛型:<T: FromStr> 读作「对任何实现了 FromStr trait 的类型 T」——一次定义出一族函数:
parse_pair::<i32> 解析整数对,parse_pair::<f64> 解析浮点对(像 C++ 的函数模板)11。
match:把值按形态拆开,逐分支处理。外层 match s.find(separator):find 找不到分隔符(隔开左右两半的那个字符)就返回 None,
整条 match 的值就是 None(解析失败);找到了就进到内层。
内层同时解析左右两半,凑成 (Ok(l), Ok(r)) => Some((l, r))——只有两个都成功才返回 Some;
下划线 _ 是通配模式,兜住「任何其他形态」12。
然后 parse_complex("1.25,-0.0625") 调 parse_pair、把结果装进 Complex { re, im }——
这里用了上一章说的字段简写;测试里 parse_complex("1.25,-0.0625") 得到
Some(Complex { re: 1.25, im: -0.0625 }),parse_complex(",-0.0625") 得到 None13。
为什么 match 在这里不可替代(对照 C):C 里你得先调 find 存下标、再手动检查返回值、 再写 if-else;Rust 把「值的可能形态」写在类型里,编译器逼你把每种形态都处理掉。 这还不是 match 的全貌——模式匹配是一套小语言,留在后面章节。
5. 从像素到复数,从复数到颜色
像素映射(text/07 文件):pixel_to_point 把「第几行第几列」换算成复平面坐标14。
换算本身按列号线性(均匀变化)取值:在两端之间按比例取中间值;虚部那行有个负号,因为像素行号往下增大、
虚部往上增大。两个语法点:元组字段用数字访问(pixel.0);pixel.0 as f64 是显式类型转换——
Rust 一般拒绝隐式数值转换,要写 as,啰嗦但换来「哪些转换发生在哪」一清二楚
(隐式转换是 C/C++ 历史上有名的 bug 和安全洞来源)15。
渲染(text/08 文件):render 遍历每个像素,调 escape_time,按结果上色:
pixels[row * bounds.0 + column] =
match escape_time(point, 255) {
None => 0, // 在集合里:黑色
Some(count) => 255 - count as u8, // 逃得越快越亮
};三个知识点挤在一行:&mut [u8] 是第一个参数的类型——「指向一段 u8 切片的可变引用」,
切片的引用是「胖指针」(下一章细讲),要往里写就必须借可变的16。
match 作为表达式:它产生一个值,直接赋给数组元素17。
as u8:又一次显式转换(迭代次数是 usize,颜色要 u8)。
6. 写文件与 ?:错误的接力棒
write_image 要处理「建文件失败」「编码失败」两种错。教科书式写法是把 Result 层层传:
fn write_image(filename: &str, pixels: &[u8], bounds: (usize, usize))
-> Result<(), ImageError>
{
let output = File::create(filename)?; // ← 这个问号
let encoder = PngEncoder::new(output);
encoder.write_image(pixels, bounds.0 as u32, bounds.1 as u32, ExtendedColorType::L8)?;
Ok(())
}? 是一整段 match 的缩写:File::create 返回 Err(e) 就立刻从 write_image 返回、把 e 交给调用方;
Ok(f) 则取出 f 继续往下18。没有 ? 的版本要展开写四行:
let output = match File::create(filename) {
Ok(f) => f,
Err(e) => { return Err(e); }
};返回类型 Result<(), ImageError> 里那个 () 是单元(unit)类型,即只有一个值的类型,相当于 C 的 void:
「成功时没什么好返回的,失败时返回一个错误」19。
原书在这里特意立了一个新手易犯的错:? 不能用在 main 里(main 不返回值),
要么 match,要么 unwrap/expect;要么把 main 改成返回 Result(后面的章节教)20。
单线程版的完整 main 与「向量当矩形数组」
并发改造之前,先看清单线程版的完整结构——尤其是那个像素缓冲的造法21:
let mut pixels = vec![0; bounds.0 * bounds.1]; // 宽 × 高 个字节,全 0
render(&mut pixels, bounds, upper_left, lower_right);
write_image(&args[1], &pixels, bounds).expect("error writing PNG file");vec![0; n] 造出 n 个 0——这里 n = 宽 × 高。整个缓冲就是一块内存,按「行优先」解读成矩形:
第 r 行第 c 列的字节在 pixels[r * 宽度 + c](render 里那行赋值的下标就是这个式子)。
一张 4000×3000 的灰度图 = 1200 万个字节(8 个二进制位)、一字节一个像素——没有结构体、没有对象头,
就是一块裸的连续内存,这正是系统编程的玩法22。
render(&mut pixels, …) 借出可变引用往里写;write_image(&pixels, …) 借出共享引用只读——
同一块缓冲,按「写阶段 / 读阶段」先后换着借,两不相欠。
7. 并发:把缓冲切成条带,一条带一个线程
单线程版本在 4000×3000 的图上要跑约 4.7 秒,只用一个核。方案朴素到极点: 把像素缓冲按行切成若干条带,每条带一个线程,各算各的,完了再写盘23。
代码骨架:
let threads = std::thread::available_parallelism().expect("…").get();
let rows_per_band = bounds.1.div_ceil(threads);
let bands = pixels.chunks_mut(rows_per_band * bounds.0);
std::thread::scope(|spawner| {
for (i, band) in bands.enumerate() {
// …算出这条带对应的复平面角落…
spawner.spawn(move || {
render(band, band_bounds, band_upper_left, band_lower_right);
});
}
});逐件拆:
available_parallelism():问系统「能开几个线程」,返回保证非零的数24。div_ceil:除法向上取整——750 行 ÷ 4 线程 = 188 行/条带;C 里得写(n + t - 1) / t, 这个写法在极端溢出情形下还对25。chunks_mut(n):把缓冲切成互不重叠的可变切片,每片 n 个像素(即若干整行), 迭代器逐个吐出来26。「互不重叠」是要害:每个切片是唯一可写入口,天然没有两个线程写同一字节。std::thread::scope:作用域线程。它保证闭包返回前所有线程都跑完—— 所以 scope 一返回,整张图就算完了,可以放心写文件27。move闭包:move || { … }让闭包接管它用到的变量(这里是这条带的切片); 接管之后,外面的代码就不能再碰这条带——所有权跟着闭包进了线程28。
跑出来的对比(书里的实录):单线程 4.678 秒;四线程后 real 1.436 秒(墙上时间), user 4.922 秒(各核合计)——合计没省,墙上时间省了近四分之三29。
第一次构建的实录与 release 的必要性
曼德博程序第一次按 release 构建的实录里有个数字值得记住:编译 25 秒、运行 4.7 秒。
命令是 cargo build --release——发布构建开优化,开发构建默认不优化(为了编译快)。
算分形的程序不拿 release 跑等于自缚手脚;这也是 Rust 的默认哲学:
日常迭代求快,交付执行求快,两个目标分开配置30。
8. 「安全是看不见的」:全书的题眼
最后一小段(独立的一页,text/11)是整章的落点,值得整段转述其义:
这个多线程程序和别的语言写的长得几乎一样:切分缓冲、各算各的、汇合写盘。 Rust 的特别之处不在写出来的代码里,而在写不出来的代码里—— 把缓冲切错的那些变体(两个线程拿到重叠的条带之类)会引入数据竞争, 「这些变体没有一个能过 Rust 编译器的静态检查」;C/C++ 编译器则乐于帮你探索这个充满隐蔽竞争的程序空间31。
这句话同时预告了后两章的结构:第 3、4 章讲内存安全规则,再往后讲这些规则怎么顺带保证并发卫生32。
9. 作者的判断与证据
- 判定规则(逃逸半径 2)是数学事实,不是工程近似——书里说「已被证明」4,可验证。
- 性能数字是实测(4.678s → 1.436s,书内实录),但只对那台测试笔记本成立;你机器上比例不同,方向一致。
- **「所有 Rust 函数都是线程安全的」**是作者最强的断言,它的论证依赖还没讲到的所有权规则——本章只是演示,不是证明。
10. 边界与局限
- 线程数取「可用并行度」(并行:一台机器上同时能跑多少个线程)是最朴素的策略:负载不均是显然的(边界像素算得久,内部像素秒回); 书里说「以后会大幅改进」,指的是还没放出的并发章节。
move闭包和 scoped 线程的细节(为什么借用也能安全跨线程)依赖第 4 章的寿命规则,本章只给了结论。- 泛型、match 的模式全家桶都只有雏形;原书承诺的专章均未放出。
11. 可带走的
主线例子一行版: 命令行解析出分辨率与复平面角落 → 建 1200 万字节缓冲 → chunks_mut 切成条带 →
每线程 render 一条带(每像素 escape_time,255 次迭代内出半径 2 的圆就算逃逸) → scope 等齐 → ? 写 PNG。
- 曼德博集合 = 「z = z² + c 从 0 迭代不逃逸」的 c;可判定的近似:有限次迭代 + 半径 2 逃逸判据;
Option<T>(None / Some(v))把「可能没有」写进类型;枚举 = 列出所有形态的类型;- 泛型
<T: FromStr>一次定义一族函数,类型参数常可推断; - match 强制处理值的每一种形态;
_兜其余;match 本身是有值的表达式; - Rust 不隐式转换数值,
as显式转——啰嗦换边界清晰; &mut [u8]= 可变切片引用,想写就必须借可变的;切片引用是胖指针;?= 「错了就 return Err、对了就取值」的缩写;不能在 main 里直接用;chunks_mut切出互不重叠的可变切片,是「无重叠即无竞争」的构造性保证;std::thread::scope保证闭包返回前所有线程跑完;move闭包把变量所有权带进线程;- Rust 并发安全的本质:错误的切分变体过不了编译——安全不在写出的代码里,在被拒绝的代码里。
12. 原文地图
| 主题 | 原书章 | 原文位置 |
|---|---|---|
| 四步数学推导 | What the Mandelbrot Set Actually Is | text/05-fm-what-the-mandelbrot-set-actually-is.txt:3(搜「pure mathematics」) · text/05-fm-what-the-mandelbrot-set-actually-is.txt:66(搜「defined as」) |
| 半径 2 判据、escape_time | What the Mandelbrot Set Actually Is | text/05-fm-what-the-mandelbrot-set-actually-is.txt:70(搜「circle of radius 2」) · text/05-fm-what-the-mandelbrot-set-actually-is.txt:82(搜「escape_time」) |
| Option 定义 | What the Mandelbrot Set Actually Is | text/05-fm-what-the-mandelbrot-set-actually-is.txt:103(搜「enumerated type」) |
| 距离平方不比开方 | What the Mandelbrot Set Actually Is | text/05-fm-what-the-mandelbrot-set-actually-is.txt:111(搜「norm_sqr」) |
| 泛型 parse_pair、match | Parsing Pair Command-Line Arguments | text/06-fm-parsing-pair-command-line-arguments.txt:39(搜「generic function」) · text/06-fm-parsing-pair-command-line-arguments.txt:73(搜「wildcard pattern」) |
| 字段简写 | Parsing Pair Command-Line Arguments | text/06-fm-parsing-pair-command-line-arguments.txt:97(搜「shorthand」) |
| 像素映射、as 转换 | Mapping from Pixels to Complex Numbers | text/07-fm-mapping-from-pixels-to-complex-numbers.txt:53(搜「refuses to convert」) |
| render、&mut [u8] | Plotting the Set | text/08-fm-plotting-the-set.txt:32(搜「mutable reference」) |
? 操作符、单元类型 | Writing Image Files | text/09-fm-writing-image-files.txt:33(搜「unit type」) · text/09-fm-writing-image-files.txt:48(搜「shorthand」) |
main 里别用 ? | Writing Image Files | text/09-fm-writing-image-files.txt:56(搜「beginner's mistake」) |
| 条带切分与 scoped 线程 | A Concurrent Mandelbrot Program | text/10-fm-a-concurrent-mandelbrot-program.txt:72(搜「horizontal bands」) · text/10-fm-a-concurrent-mandelbrot-program.txt:76(搜「scoped thread」) |
| chunks_mut / move / 计时 | A Concurrent Mandelbrot Program | text/10-fm-a-concurrent-mandelbrot-program.txt:115(搜「nonoverlapping」) · text/10-fm-a-concurrent-mandelbrot-program.txt:144(搜「takes ownership」) · text/10-fm-a-concurrent-mandelbrot-program.txt:154(搜「1.436s」) |
| 单线程 main 与矩形数组 | A Concurrent Mandelbrot Program | text/10-fm-a-concurrent-mandelbrot-program.txt:12(搜「Usage」) · text/10-fm-a-concurrent-mandelbrot-program.txt:34(搜「vec!」) |
| 第一次构建的实录 | A Concurrent Mandelbrot Program | text/10-fm-a-concurrent-mandelbrot-program.txt:53(搜「release」) |
| 安全是看不见的 | Safety Is Invisible | text/11-fm-safety-is-invisible.txt:5(搜「static checks」) |
Footnotes
-
出处:「What the Mandelbrot Set Actually Is」第 13 段(
text/05-fm-what-the-mandelbrot-set-actually-is.txt:13,搜「approaches zero」)。 ↩ -
出处:「What the Mandelbrot Set Actually Is」第 28 段(
text/05-fm-what-the-mandelbrot-set-actually-is.txt:28,搜「0.25」)。 ↩ -
出处:「What the Mandelbrot Set Actually Is」第 66 段(
text/05-fm-what-the-mandelbrot-set-actually-is.txt:66,搜「does not fly out to infinity」)。 ↩ -
出处:「What the Mandelbrot Set Actually Is」第 70 段(
text/05-fm-what-the-mandelbrot-set-actually-is.txt:70,搜「it's been shown」)。 ↩ ↩2 -
出处:「What the Mandelbrot Set Actually Is」第 111 段(
text/05-fm-what-the-mandelbrot-set-actually-is.txt:111,搜「norm_sqr」)。 ↩ -
出处:「What the Mandelbrot Set Actually Is」第 70–92 段(
text/05-fm-what-the-mandelbrot-set-actually-is.txt:70-92,搜「probably be a member」)。 ↩ -
出处:「What the Mandelbrot Set Actually Is」第 113 段(
text/05-fm-what-the-mandelbrot-set-actually-is.txt:113,搜「documentation comments」)。 ↩ -
出处:「What the Mandelbrot Set Actually Is」第 96–103 段(
text/05-fm-what-the-mandelbrot-set-actually-is.txt:96-103,搜「enumerated type」)。 ↩ -
出处:「What the Mandelbrot Set Actually Is」第 105 段(
text/05-fm-what-the-mandelbrot-set-actually-is.txt:105,搜「how long we had to iterate」)。 ↩ -
出处:「What the Mandelbrot Set Actually Is」第 113 段(
text/05-fm-what-the-mandelbrot-set-actually-is.txt:113,搜「documentation comments」)。 ↩ -
出处:「Parsing Pair Command-Line Arguments」第 43 段(
text/06-fm-parsing-pair-command-line-arguments.txt:43,搜「type parameter」)。 ↩ -
出处:「Parsing Pair Command-Line Arguments」第 69–73 段(
text/06-fm-parsing-pair-command-line-arguments.txt:69-73,搜「wildcard pattern」)。 ↩ -
出处:「Parsing Pair Command-Line Arguments」第 87–95 段(
text/06-fm-parsing-pair-command-line-arguments.txt:87-95,搜「passes failures along」)。 ↩ -
出处:「Mapping from Pixels to Complex Numbers」第 24–27 段(
text/07-fm-mapping-from-pixels-to-complex-numbers.txt:24-27,搜「Why subtraction here」)。 ↩ -
出处:「Mapping from Pixels to Complex Numbers」第 53 段(
text/07-fm-mapping-from-pixels-to-complex-numbers.txt:53,搜「refuses to convert」)。 ↩ -
出处:「Plotting the Set」第 32 段(
text/08-fm-plotting-the-set.txt:32,搜「mutable reference」)。 ↩ -
出处:「Plotting the Set」第 38–39 段(
text/08-fm-plotting-the-set.txt:38-39,搜「colors the pixel black」)。 ↩ -
出处:「Writing Image Files」第 48–52 段(
text/09-fm-writing-image-files.txt:48-52,搜「shorthand」)。 ↩ -
出处:「Writing Image Files」第 33 段(
text/09-fm-writing-image-files.txt:33,搜「unit type」)。 ↩ -
出处:「Writing Image Files」第 56 段(
text/09-fm-writing-image-files.txt:56,搜「beginner's mistake」)。 ↩ -
出处:「A Concurrent Mandelbrot Program」第 7–30 段(
text/10-fm-a-concurrent-mandelbrot-program.txt:7-30,搜「Usage」)。 ↩ -
出处:「A Concurrent Mandelbrot Program」第 34–50 段(
text/10-fm-a-concurrent-mandelbrot-program.txt:34-50,搜「rectangular array」)。 ↩ -
出处:「A Concurrent Mandelbrot Program」第 70–72 段(
text/10-fm-a-concurrent-mandelbrot-program.txt:70-72,搜「only one」)。 ↩ -
出处:「A Concurrent Mandelbrot Program」第 107 段(
text/10-fm-a-concurrent-mandelbrot-program.txt:107,搜「available_parallelism」)。 ↩ -
出处:「A Concurrent Mandelbrot Program」第 111 段(
text/10-fm-a-concurrent-mandelbrot-program.txt:111,搜「div_ceil」)。 ↩ -
出处:「A Concurrent Mandelbrot Program」第 115 段(
text/10-fm-a-concurrent-mandelbrot-program.txt:115,搜「nonoverlapping」)。 ↩ -
出处:「A Concurrent Mandelbrot Program」第 123 段(
text/10-fm-a-concurrent-mandelbrot-program.txt:123,搜「waits for all such threads」)。 ↩ -
出处:「A Concurrent Mandelbrot Program」第 144 段(
text/10-fm-a-concurrent-mandelbrot-program.txt:144,搜「takes ownership」)。 ↩ -
出处:「A Concurrent Mandelbrot Program」第 158 段(
text/10-fm-a-concurrent-mandelbrot-program.txt:158,搜「factor of almost four」)。 ↩ -
出处:「A Concurrent Mandelbrot Program」第 51–66 段(
text/10-fm-a-concurrent-mandelbrot-program.txt:51-66,搜「release」)。 ↩ -
出处:「Safety Is Invisible」第 5 段(
text/11-fm-safety-is-invisible.txt:5,搜「static checks」)。原文:「C 或 C++ 编译器会乐于帮你探索充满隐蔽数据竞争的庞大程序空间;Rust 在出问题之前就会告诉你。」 ↩ -
出处:「Safety Is Invisible」第 7 段(
text/11-fm-safety-is-invisible.txt:7,搜「Chapters 3 and 4」)。 ↩