编程语言研究书库

第 4 章拆解 — 流、缓冲与数据编码

流、缓冲与数据编码

这一章讲什么: 管道里的数据怎么流才快。书用三个经典 UNIX 工具各打一层: zcat(缓冲:减少跨进内核的次数)→ zwc(SIMD:减少 CPU 执行的轮数)→ z64(位运算:编码转换本身)。 收尾是三组基准测试,把「缓冲多大合适」「寻址读比顺序读快多少」从经验法则变成数字。

主线例子:两个。① 4 GB 的 zcat 对 1 GB 随机文件:5.25 秒 vs 206 毫秒; ② 字符串 Zig! 进 z64 编码器,逐位看它怎么变成 WmlnIQ==。

1. 顶层全景:三个工具,三层「少干活」

  层 1:跨内核边界次数   zcat:4KB 缓冲 → 一次 read 喂满再 write   (25.46×)
  层 2:CPU 执行轮数     zwc :@Vector(32,u8) 一次比较 32 字节 + 位运算代替 if  (对 wc 35×)
  层 3:读入的总字节数   zunzip/positional:只读要的那 4KB,不扫整个文件      (1143 vs 0.7 MiB/s)

  图说:书在章末把三层收成一句话——缓冲减少跨内核边界的次数,SIMD 减少 CPU 的迭代次数,
  寻址减少从存储读的字节数;三个技巧作用在栈的不同层,纪律是同一条:
  弄清硬件到底花多少,找最短的路。[^1]

zwc 的两个版本和系统 wc 的对决表(1 GB 随机文件,hyperfine)1:

实现耗时相对
macOS 自带 wc(1)3.914 s1×
zwc(逐字节)624.4 ms6.3×
zwcOptimized(SIMD)111.0 ms35.3×

2. 核心原理

2.1 zcat 的智能缓冲:一次读满 4 KB,而不是一次读一字节

「缓冲为什么值钱」的直接证据先摆出来:同一份 1 GB 随机文件,无缓冲 zcat 跑 5.25 秒(其中 4.145 秒花在系统态——八成时间在等内核逐条处理 read/write),带缓冲版只要 206 毫秒,快 25.46 倍2。书的结论是这章的地基:效率不在于你干活多快,而在于你被打断的次数多寡3。

zcatBuffer 的写法有三个值得逐条学的决定:

  • stdout 配 4096 字节栈缓冲,stderr 配空缓冲。空缓冲(&.{})是 Zig 里「彻底退出缓冲」的惯用写法——内部容量为零,每次 writeAll 直接变成自己的 write(2) 系统调用。这是 UNIX 的老规矩:报错必须立刻出现在终端,如果 stderr 也走缓冲,致命错误可能死在缓冲区里永远没打出来4。
  • 用 isTty() 探测输出目的地:管道/重定向(不是终端)就关掉逐行扫描,把 4 KB 填满才发一次系统调用,榨取吞吐;终端就反过来,每遇到换行符 flush 一次,牺牲系统调用换「日志实时可见」5。
  • 结尾的 flush() 不是可选的。缓冲区没满、程序就退出了,剩下的字节被悄悄丢掉——输出看起来被截断,而且没有任何报错。书特别点出:这是缓冲 I/O 最常见的 bug,因为它只在「总输出恰好不是缓冲区的整数倍」时才现身6。

2.2 zwc 的朴素版:一遍数完三件事

计数之前先定架构:三个计数器(行、词、字节)在一次遍历里同时更新。朴素写法可能数行一遍、倒回去数词一遍、再倒回去数字节一遍——三遍就是三倍的 I/O 成本;zwc 对每个字节只碰一次7。

算法核心是一个布尔状态 in_word:遇到空白字符就把它清零;遇到非空白且之前不在词里,就算一个词的开头。这样「词与词之间多个空格」不会数出假词8。实测:echo 'Hi there\n\n\n' 输出 4 2 12——4 行、2 词、12 字节,和 wc 一致9。

2.3 SIMD 版:把 32 个字节装进一条指令,把 if 换成位与

SIMD(单指令多数据)是 CPU 内部的并行:普通代码是标量的——一条指令处理一个数据;SIMD 用宽向量寄存器一次装入 16/32/64 字节,一条指令里同时比较全部 32 个字节10。Zig 里就是 @Vector(32, u8) 这个类型——它直接映射到 CPU 的 SIMD 寄存器:x86-64 有 AVX2 时它就是一条 256 位指令,Apple Silicon 的 arm64 上拆成两条 128 位 NEON;编译加 -mcpu=native 让后端用满本机最宽的寄存器11。

100 MB 测试数据上,标量版 0.214 秒,SIMD 版 0.0065 秒,快 32.8 倍12。这个数量级来自两件事的叠加:

第一,路宽了 32 倍。 标量版处理 1 亿字节要执行几亿条指令(每个字节:加载、判换行、判空白、更新计数、循环回跳);SIMD 版一轮循环干 32 个字节的活。

第二,if 没了。 数据是随机的,标量版的 if (!is_space) 会让 CPU 的分支预测器猜错约一半——每次猜错都要把流水线里排好的活冲掉重来。SIMD 版用与、或、非的位运算代替分支,数学是确定性的,流水线永不中断13。

词数统计的位技巧值得单独走一遍(书上给的 5 字节例 " Hi ",数出 1 个词,位置在 H):

curr_bits(本字节是空白吗):  1 1 0 0 1
prev_bits(上一字节是空白吗): 0 1 1 0 0   ← curr_bits 左移 1 位;位置 0 用上一块的末状态填
~curr_bits(本字节非空白):   0 0 1 1 0
word_starts = 与:           0 0 1 0 0   ← 只有位置 2(H)是「词的开始」
popCount(word_starts) = 1

图说:word_starts = (~curr_bits) & prev_bits 回答的就是「当前非空白、且前一个是空白的位置在哪」——词的定义本身。然后把 32 个答案压成一个 32 位整数,@popCount() 一条硬件指令数出 1 的个数,整个块一个词也没循环、一个分支也没走14。跨块连续性靠 prev_was_space 记住上一块的最后一个字节,一个词被 32 字节边界或 64 KB 缓冲边界劈开也算一个词;末尾凑不满 32 字节的 1–31 个字节,退回到普通的逐字节循环收尾15。

2.4 z64 编码:3 字节 → 24 位 → 4 个 6 位索引

Base64 解决的真问题:二进制数据(图片、密钥、序列化结构体)里可能含有文本协议扛不住的字节——一个空字节就能截断字符串、搞坏解析器。Base64 把 8 位字节重新分组为 6 位小块,每块 0–63 映射到一张 64 字符的表(A–Z a–z 0–9 + /);代价是体积膨胀约 33%(3 字节变 4 字节),尾部长度不够就补 =16。

主线例子,输入 Zig! 共 4 字节,书上一路算到底17:

第 1 组 "Zig":  Z=90(01011010) i=105(01101001) g=103(01100111)
                拼成 24 位: 01011010 01101001 01100111
                切 4 个 6 位: 010110 100110 100101 100111
                值:           22     38     37     39
                查表:          W      m      l      n
第 2 组 "!":   !=33(00100001),后面补 16 个 0 凑 24 位
                切出: 001000(=8→I)  010000(=16→Q)  后 12 位全是填充 → ==
结果: WmlnIQ==

图说:这 6 个输出字符每一个都能从二进制位上手工验出来——编码器没有黑箱18。实现上,z64 每次读 4 KB,主循环只处理「能整除 3 的部分」(chunks * 3),于是内层循环不需要任何边界检查;每次读剩下的 1–2 个字节(leftover)挪到缓冲区开头,等下一块数据来把它们拼齐——流被任意切割,3 字节分组永不散架19。EOF 时剩下的不足 3 字节走 encodeLastChunk:手动补零、产出 2 或 3 个字符、最后一位永远是 =20。

2.5 z64 解码:四字符一组合,遇到 = 就收工

解码是编码的镜像,但有一个编码没有的麻烦:Base64 流里常混着换行等可忽略字符。解法是查表即过滤——预建一张 256 项的解码表,无效字符(换行、空白)在表里是 0xFF,循环里一行 if (val == 0xFF) continue 就剥掉了21。有效字符按 4 个一组(quad)收进一个小缓冲,凑满 4 个就拼回 24 位、切 3 字节写入输出缓冲;输出缓冲快满就一次性落盘。遇到 = 就知道数据到头了:把 quad 里剩下的 1–2 个字符冲洗成最后的字节,落盘、返回,后面跟什么都无所谓22。实测往返:echo -n "Zig Lang" | z64 出 WmlnIExhbmc=,再解码回原串23。

2.6 缓冲该多大:基准说了算

读基准(100 MB 文件,10 次试验,M4 Pro)读数24:

缓冲平均耗时吞吐
128 B245.689 ms407 MB/s
512 B64.156 ms1559 MB/s
1 KB33.742 ms2964 MB/s
4 KB10.854 ms9213 MB/s
16 KB5.579 ms17925 MB/s

写基准(50 MB 文件)同向:256 B 缓冲 213.5 ms / 234 MB/s,16 KB 缓冲 10.8 ms / 4628 MB/s,而且最大缓冲的标准差最小(0.386 ms)——缓冲大不只是快,还更稳25。两条曲线的形状一致:小缓冲时每次系统调用都要等调度器回话,开销主导;过了 4–16 KB,内核的预读机制(readahead,默认窗口 128 KB,探测到顺序读就提前几页)已经在替你预取,继续加缓冲收益递减26。

2.7 寻址读与「跳过不读」:最快的读法是不读

Zig 0.16 里文件 I/O 用 positional read(readPositionalAll(文件, 缓冲, 偏移))取代传统的「先 seek 再 read」两步走:偏移直接作参数,不动任何文件游标——多个协程可以同时从同一文件的不同偏移读而互不干扰,底层直接映射 POSIX 的 pread(2),内核能更高效地满足27。

基准:要读 100 MB 文件末尾的 4 KB,顺序全读 0.69 MiB/s、顺序带缓冲 0.71 MiB/s,positional 直读 1143 MiB/s——差 1600 倍。道理一句话:顺序读被迫遍历整个 100 MB 前缀才够得着目标,寻址直接跳到 100 MB 处只读 4 KB;读数据最快的方式是跳过你不需要的28。zunzip(第 3 章)只读 ZIP 末尾 66 KB 就是这个原则的实战。

再往下钻一层:write 离开进程后不是直接上盘,而是进 Linux 块层的 I/O 调度器,由它决定请求到达设备的顺序和时机29。书上给了四种调度器各自的适用面:none(NVMe 默认,直通)、mq-deadline(HDD/SATA SSD 默认,读 500 ms 写 5 秒的死线防饿死)、Kyber(多队列 SSD 的公平延迟)、BFQ(按进程预算保障带宽,桌面发行版默认)30。以及对写密集负载的两个旋钮:vm.dirty_ratio / dirty_background_ratio 控制脏页什么时候开始挡写、什么时候开始后台刷盘——生产服务更稳妥的替代是每次逻辑事务后显式 fdatasync(),把持久性写进代码而不是依赖管理员配置31。

最后一块是直接 I/O:绕过页缓存、在用户缓冲和设备之间直传,适合「自己管缓存的数据库」或「单遍顺序、页缓存纯浪费」的场景;代价是缓冲地址、文件偏移、传输长度三者都必须对齐逻辑块大小(512 B 或 4 KB),普通 alloc 默认只保证 8/16 字节对齐,不满足时内核在运行时才甩 EINVAL——正确姿势是 allocator.alignedAlloc(u8, block_size, len)32。

3. 作者的判断与证据

  • 判断: 「效率不是你干活多快,而是你被打断的次数多寡」3。证据:zcat 25.46×(系统态时间从 4.145 s 降到 160 ms)。
  • 判断: SIMD 提速的本质是「路宽 + 消灭分支误预测」两个因素叠加,不只是 32 倍并行13。证据:32.8× 的实测(比纯路宽的 32× 略高,与误预测消除一致)——这是推断,书没有分开测两个因素各贡献多少。
  • 判断: 「默认 macOS wc 没有为现代硬件优化」33。证据:wc 3.9 秒对 zwcOptimized 111 毫秒。局限:单平台单次测试,其他平台的 wc 实现(如 GNU coreutils)结果可能不同。
  • 判断: 缓冲甜点区 4–16 KB 是经验可查的,但「用基准在自己机器上量」优于背规则34。证据:同一份基准程序在不同缓冲尺寸下的完整曲线。

4. 边界与局限

  • SIMD 版 zwc 的词计数依赖 ASCII 空白定义(空格、\t–\r),非 ASCII 文本(UTF-8 多字节)的「词」语义与 wc 可能不同14。
  • 所有基准都在 macOS(M4 Pro)上跑;Linux 的调度器、预读细节不同,绝对数字搬不走,形状可参考30。
  • I/O 调度器一节明说是 Linux 专属,macOS/Windows 读者只能拿概念29。
  • 写基准只测了顺序写;随机写、追加写的缓冲经济学没有覆盖25。
  • z64 的解码遇到 = 就返回,容忍了格式松散的输入,但也意味着 = 之后如果真还有数据会被静默丢弃22。

5. 可带走的

  1. stderr 永远不配缓冲(空缓冲 &.{}),报错必须立刻见屏——这是 UNIX 规矩,不是风格。
  2. 管道输出用满缓冲榨吞吐,终端输出按行 flush 保响应;isTty() 一调用,两种人格。
  3. 忘 flush 是缓冲 I/O 第一大坑,且只在「输出不是缓冲区整数倍」时发作——出口处 defer flush。
  4. 一遍流只扫一次:所有计数器在一次遍历里同时更新,别倒带。
  5. SIMD 的正确打开方式:@Vector(32, u8) + 常量 @splat 比对 + @popCount 计数;-mcpu=native 用满本机寄存器。
  6. 数据随机时 if 是敌人:把判断改写成位运算((~curr) & prev),流水线不冲水。
  7. 跨块状态(prev_was_space、leftover)是流式算法的真正难点:先想好边界怎么续,再写主循环。
  8. Base64 编码 3→4 字节、膨胀 33%;解码用 256 项查表同时完成「映射 + 过滤无效字符」;= 是显式终止符。
  9. 缓冲从 128 B 加到 16 KB 读速差 44 倍;甜点区 4–16 KB,再大收益递减(预读替你干了)。
  10. 只读一段就别顺序扫:readPositionalAll 直跳目标偏移——「跳过不读」是最快的读法。

6. 原文地图

主题原书章原文位置
缓冲 vs 无缓冲 25.46×The cost of system callstext/43-fm-the-cost-of-system-calls.txt:337(搜「5.247 s」) · :374(搜「how rarely you are interrupted」)
stderr 空缓冲惯例The cost of system callstext/43-fm-the-cost-of-system-calls.txt:130(搜「opt out of buffering」)
忘 flush 的输出截断The cost of system callstext/43-fm-the-cost-of-system-calls.txt:234(搜「most common bug」)
单遍三计数Counting lines, words, and characterstext/44-fm-counting-lines-words-and-characters.txt:35(搜「single pass」)
in_word 状态机Counting lines, words, and characterstext/44-fm-counting-lines-words-and-characters.txt:120(搜「transitions」)
@Vector 映射与 mcpuAchieving high-performance text counting with SIMDtext/45-fm-achieving-high-performance-text-counting-with-si.txt:94(搜「AVX2」)
word_starts 位推导Achieving high-performance text counting with SIMDtext/45-fm-achieving-high-performance-text-counting-with-si.txt:441(搜「only position 2 matches」)
32.8× 对标Comparing the two ZWC implementationstext/46-fm-comparing-the-two-zwc-implementations.txt:25(搜「32.80x」)
分支预测误测Comparing the two ZWC implementationstext/46-fm-comparing-the-two-zwc-implementations.txt:57(搜「guess wrong」)
zwc 对 wc 35×Comparing zwc with wctext/47-fm-comparing-zwc-with-wc.txt:44(搜「35.27」)
Base64 33% 膨胀Comparing zwc with wctext/47-fm-comparing-zwc-with-wc.txt:172(搜「33% larger」)
Zig! → WmlnIQ== 全程Comparing zwc with wctext/47-fm-comparing-zwc-with-wc.txt:238(搜「Zig!」)
6000 字节输出缓冲的来历Implementing the encoder logictext/48-fm-implementing-the-encoder-logic.txt:79(搜「5464」)
leftover 续块策略Implementing the encoder logictext/48-fm-implementing-the-encoder-logic.txt:173(搜「leftover bytes must be preserved」)
解码表过滤无效字符Decoding streamstext/49-fm-decoding-streams.txt:111(搜「0xFF」)
读缓冲曲线 128B→16KBBenchmarking reading speed for various buffer sizestext/50-fm-benchmarking-reading-speed-for-various-buffer-si.txt:199(搜「245.689」)
写缓冲曲线与稳定性How the buffer size affects writing speedtext/51-fm-how-the-buffer-size-affects-writing-speed.txt:74(搜「213.547」)
positional 取代 seek+readBenchmarking file seeking with sequential readingtext/52-fm-benchmarking-file-seeking-with-sequential-readin.txt:53(搜「positional reads」)
1143 vs 0.7 MiB/sBenchmarking file seeking with sequential readingtext/52-fm-benchmarking-file-seeking-with-sequential-readin.txt:115(搜「1143.18」)
I/O 调度器与预读Benchmarking file seeking with sequential readingtext/52-fm-benchmarking-file-seeking-with-sequential-readin.txt:175(搜「512 bytes」)
O_DIRECT 三对齐Benchmarking file seeking with sequential readingtext/52-fm-benchmarking-file-seeking-with-sequential-readin.txt:353(搜「aligned to the logical block size」)
4–64 KB 甜点区Practical guidance for Zig programstext/53-fm-practical-guidance-for-zig-programs.txt:5(搜「4–64 KB」)
章末三层总结Practical guidance for Zig programstext/53-fm-practical-guidance-for-zig-programs.txt:85(搜「reducing unnecessary work」)

本章拆解依据原书第 4 章(含各小节)。每条出处可用 node scripts/book-verify.mjs systems-programming-with-zig 回核。

Footnotes

  1. 出处:「Comparing zwc with wc」第 30、34、38 段(text/47-fm-comparing-zwc-with-wc.txt:30,搜「3.914 s」;:34,搜「624.4」;:38,搜「111.0」)。 ↩

  2. 出处:「The cost of system calls」第 337 段(text/43-fm-the-cost-of-system-calls.txt:337,搜「5.247 s」)与第 341 段(text/43-fm-the-cost-of-system-calls.txt:341,搜「206.1 ms」)。 ↩

  3. 出处:「The cost of system calls」第 374 段(text/43-fm-the-cost-of-system-calls.txt:374,搜「how rarely you are interrupted」)。 ↩ ↩2

  4. 出处:「The cost of system calls」第 130 段(text/43-fm-the-cost-of-system-calls.txt:130,搜「opt out of buffering」)与第 176 段(text/43-fm-the-cost-of-system-calls.txt:176,搜「long-standing UNIX convention」)。 ↩

  5. 出处:「The cost of system calls」第 35 段(text/43-fm-the-cost-of-system-calls.txt:35,搜「immediate responsiveness」)。 ↩

  6. 出处:「The cost of system calls」第 234 段(text/43-fm-the-cost-of-system-calls.txt:234,搜「most common bug」)。 ↩

  7. 出处:「Counting lines, words, and characters」第 35 段(text/44-fm-counting-lines-words-and-characters.txt:35,搜「single pass」)。 ↩

  8. 出处:「Counting lines, words, and characters」第 120 段(text/44-fm-counting-lines-words-and-characters.txt:120,搜「transitions」)。 ↩

  9. 出处:「Counting lines, words, and characters」第 233 段(text/44-fm-counting-lines-words-and-characters.txt:233,搜「Hi there」)。 ↩

  10. 出处:「Achieving high-performance text counting with SIMD」第 13 段(text/45-fm-achieving-high-performance-text-counting-with-si.txt:13,搜「Single Instruction, Multiple Data」)。 ↩

  11. 出处:「Achieving high-performance text counting with SIMD」第 94 段(text/45-fm-achieving-high-performance-text-counting-with-si.txt:94,搜「AVX2」)。 ↩

  12. 出处:「Comparing the two ZWC implementations」第 25 段(text/46-fm-comparing-the-two-zwc-implementations.txt:25,搜「32.80x」)。 ↩

  13. 出处:「Comparing the two ZWC implementations」第 57 段(text/46-fm-comparing-the-two-zwc-implementations.txt:57,搜「guess wrong」)与第 79 段(text/46-fm-comparing-the-two-zwc-implementations.txt:79,搜「never stalls or flushes」)。 ↩ ↩2

  14. 出处:「Achieving high-performance text counting with SIMD」第 441 段(text/45-fm-achieving-high-performance-text-counting-with-si.txt:441,搜「only position 2 matches」)。 ↩ ↩2

  15. 出处:「Achieving high-performance text counting with SIMD」第 348 段(text/45-fm-achieving-high-performance-text-counting-with-si.txt:348,搜「prev_was_space」)。 ↩

  16. 出处:「Comparing zwc with wc」第 138 段(text/47-fm-comparing-zwc-with-wc.txt:138,搜「regrouping them into 6-bit chunks」)与第 172 段(text/47-fm-comparing-zwc-with-wc.txt:172,搜「33% larger」)。 ↩

  17. 出处:「Comparing zwc with wc」第 238 段(text/47-fm-comparing-zwc-with-wc.txt:238,搜「Zig!」)。 ↩

  18. 出处:「Comparing zwc with wc」第 224 段(text/47-fm-comparing-zwc-with-wc.txt:224,搜「shift the 24-bit block right by 18」)。 ↩

  19. 出处:「Implementing the encoder logic」第 173 段(text/48-fm-implementing-the-encoder-logic.txt:173,搜「leftover bytes must be preserved」)。 ↩

  20. 出处:「Implementing the encoder logic」第 191 段(text/48-fm-implementing-the-encoder-logic.txt:191,搜「edge case」)。 ↩

  21. 出处:「Decoding streams」第 111 段(text/49-fm-decoding-streams.txt:111,搜「0xFF」)。 ↩

  22. 出处:「Decoding streams」第 81 段(text/49-fm-decoding-streams.txt:81,搜「end-of-stream marker」)。 ↩ ↩2

  23. 出处:「Decoding streams」第 185 段(text/49-fm-decoding-streams.txt:185,搜「Zig Lang」)。 ↩

  24. 出处:「Benchmarking reading speed for various buffer sizes」第 199 段(text/50-fm-benchmarking-reading-speed-for-various-buffer-si.txt:199,搜「245.689」)。 ↩

  25. 出处:「How the buffer size affects writing speed」第 74 段(text/51-fm-how-the-buffer-size-affects-writing-speed.txt:74,搜「213.547」)与第 124 段(text/51-fm-how-the-buffer-size-affects-writing-speed.txt:124,搜「0.386 ms」)。 ↩ ↩2

  26. 出处:「Benchmarking file seeking with sequential reading」第 305 段(text/52-fm-benchmarking-file-seeking-with-sequential-readin.txt:305,搜「128 KB」)。 ↩

  27. 出处:「Benchmarking file seeking with sequential reading」第 53 段(text/52-fm-benchmarking-file-seeking-with-sequential-readin.txt:53,搜「positional reads」)。 ↩

  28. 出处:「Benchmarking file seeking with sequential reading」第 115 段(text/52-fm-benchmarking-file-seeking-with-sequential-readin.txt:115,搜「1143.18」)与第 161 段(text/52-fm-benchmarking-file-seeking-with-sequential-readin.txt:161,搜「skip what you do not need」)。 ↩

  29. 出处:「Benchmarking file seeking with sequential reading」第 171 段(text/52-fm-benchmarking-file-seeking-with-sequential-readin.txt:171,搜「block layer」)。 ↩ ↩2

  30. 出处:「Benchmarking file seeking with sequential reading」第 247 段(text/52-fm-benchmarking-file-seeking-with-sequential-readin.txt:247,搜「none scheduler」)。 ↩ ↩2

  31. 出处:「Practical guidance for Zig programs」第 33 段(text/53-fm-practical-guidance-for-zig-programs.txt:33,搜「dirty_ratio」)。 ↩

  32. 出处:「Benchmarking file seeking with sequential reading」第 353 段(text/52-fm-benchmarking-file-seeking-with-sequential-readin.txt:353,搜「aligned to the logical block size」)。 ↩

  33. 出处:「Comparing zwc with wc」第 60 段(text/47-fm-comparing-zwc-with-wc.txt:60,搜「not optimized for modern hardware」)。 ↩

  34. 出处:「Practical guidance for Zig programs」第 5 段(text/53-fm-practical-guidance-for-zig-programs.txt:5,搜「near-optimal」)。 ↩

On this page