跳到正文
ZLIB × LEAN / 算法精读与验证

暂停状态与调用接续

用小输出缓冲区和分批输入,继续同一个解压任务。

这一环节的任务

应用常把大任务拆成多批输入和输出。输入暂时不够,或本轮输出空间用完时,流状态记住处理到哪里;下一次换了缓冲区仍能继续。zlib 与 gzip 的头尾信息也沿同一个任务处理。

在全局图中定位 ↗

假设应用要解压一个很大的文件,希望每次只拿到一小批原文字节,交给后续程序后,再重复使用同一块小内存。它不想一次为整个解压结果分配空间;压缩输入也可能分批到达。流式调用就是多次调用同一个解码器,让它记住上一次处理到哪里。

解压的方向是:压缩字节作为输入 → 恢复出的原文字节作为输出。“输出缓冲区”是应用提供的可写内存。它的容量限制本轮最多写出多少原文字节,不是整个文件的大小。应用先处理本轮写出的字节,再让下轮调用覆盖这块内存。

用五字节的输出空间解出八字节

使用完整的裸 DEFLATE 压缩数据 73 74 82 40 00。它表示两个字面量 A、B,再执行长度 6、距离 2 的匹配,最后结束最终块。恢复出的完整结果是 ABABABAB。

这里的 inflate 是解压操作。传入的 z_stream 是持续保存的控制记录:它记录当前输入、输出的位置,并指向解码器内部状态。保留这份记录,下一次调用才能接续任务;五字节的 output 数组只负责暂时接收本轮结果。

一次提供全部五个压缩字节,但只给 inflate 五个输出位置。下一轮保持同一个 z_stream,重复使用这五个位置。完整 C 程序包括裸模式模式初始化、调用循环和资源释放。next_in 最初指向这五个压缩字节,avail_in 为 5。这两个输入字段在循环前设置一次;每次调用都保持上一轮返回的剩余输入位置。每轮提供输出空间并调用解码器的部分如下:

stream.next_out = output;             /* writable destination */
stream.avail_out = sizeof output;     /* five bytes of room */

int result = inflate(&stream, Z_NO_FLUSH);
unsigned produced = sizeof output - stream.avail_out;

next_out 指向第一个可写位置,avail_out 表示可写位置的数量。inflate 一边写入,一边前移指针、减少数量。因此,produced 就是返回后应用可以从 output 读取的字节数。重新指定输出位置,不会创建一个新的解码任务。

这个程序在固定版本的 C 上运行,得到以下记录:

调用 消耗的压缩输入 写出的原文字节 返回值 应用接下来做什么
第 1 次 所提供 5 字节中的 4 字节 5 字节:ABABA Z_OK 处理这五个字节,再把同一块缓冲区交给下一轮写入。
第 2 次 剩余 1 字节 3 字节:BAB Z_STREAM_END 处理这三个字节;这个裸模式流已完成。

应用先收到 ABABA,再收到 BAB,合起来是八字节。物理输出缓冲区只有五个位置,反复使用,从未同时装下八字节。这是一次真实 C 执行的结果,不是所有流式调用都正确的普遍证明。

五字节输出缓冲区先装入 ABABA,再被重复使用,装入 BAB。应用把两批结果接成 ABABABAB;解码器负责记住尚未完成的匹配。

两次调用之间的保存状态

最初两次写入来自字面量,接下来三次来自匹配。第一次返回时,匹配还欠最后三个字节:

已经收到: A B A B A
匹配复制:     1 2 3       还需复制 = 3,距离 = 2

保存的阶段是 MATCH,length 表示还剩 3 字节,offset 表示距离为 2。下次要继续复制,不能重新解析同一组长度和距离。距离所指向的字节也必须还在。返回之前,解码器把必要后缀留在自己的历史窗口中。应用之后可以在调用者存储契约允许的范围内交付、覆盖或释放旧输出存储。

第 2 次调用接下来写入 距离 2 对应的来源 字节
第 1 个 保留历史中的 B B
第 2 个 保留历史末尾的 A A
第 3 个 本轮刚写出的第一个 B B

因此,仅保存两个整数还不够,还必须保有有效的字节存储。第二次调用不能读取指向旧调用者内存的失效指针。匹配复制逐次解释来源读取。

解码器用容量有限的环形数组保留旧输出。写到最后一个位置之后,再从索引零继续。它保存的是最新后缀,不一定保存整个解压结果。先用八字节教学窗口接收 ABABA:

保存量 本例的值 读者怎样使用它
容量 wsize 8 字节 数组最多保留多少历史。
有效量 whave 5 字节 此时只有 ABABA 可用,其余空格不能作为来源。
下一写入下标 wnext 5 下一字节放在数组第 5 格,下标从零开始。

窗口填满后,再写入会覆盖最老的字节,时间顺序便从 wnext 开始,经过数组末端再绕回开头。负责更新窗口的函数叫 updatewindow:若本轮输出已经达到一个完整窗口,只复制最新的那个窗口;否则把较短的新后缀追加进去,跨过数组末端时分两段复制。这个例子只解释存储,真实窗口参数范围见封装与配置。

C 在哪里暂停

state->mode 决定下一步执行什么工作。MATCH 表示展开已经解析好的长度/距离对,LEN 表示读取下一个字面量/长度符号。局部变量 left 以字节计量本轮剩余的输出空间;它变成零,只能说明当前匹配需要暂停,不能说明匹配已经结束。

固定版本的 MATCH 分支从这里开始:

state->mode = MATCH;
    /* fallthrough */
case MATCH:
    if (left == 0) goto inf_leave;

left 是本轮还可写入的空间。第一次调用中,它变成零时,匹配还剩三个字节。这里的 goto 让 mode 保持为 MATCH。同一分支后面按实际复制量减少 state->length,只有剩余长度为零才回到 LEN。这就是“继续同一个任务”在代码中的含义。跨调用组合还未完成:要把这个模式和剩余长度关系从初始化保持到每个返回边界。

保存中的工作

inflate 使用状态机,是因为几乎每个阶段都可能缺少输入或输出空间。一次调用不一定包含完整头部、完整 Huffman 码,也不一定容得下整个匹配。内部的 inflate_state 保存停在哪个阶段,以及已经解析出的信息。

表中有几项反复出现的保存量:hold 是暂存已取入但尚未消耗的比特的整数,bits 计量其中有效的位数。lens 数组则在构造动态 Huffman 表时保存各符号的码长,单位也是位。它们不同于输入字节数,也不同于输出匹配的长度。

尚未完成的工作 相关模式与保存信息
封装头部 HEAD 和 gzip 头部模式保存标志、字段长度和元数据读取进度。
块头部或数值字段 hold、bits 保存已经从输入中取出、但尚未消耗的低位。
动态表描述 TABLE、LENLENS、CODELENS 保存数量、lens 已初始化的前缀和描述用的表。
符号解释 LEN、LENEXT、DIST、DISTEXT 保存长度、距离及额外位的解析阶段。
原始字节或匹配展开 COPY、MATCH 保存还需产生多少字节;匹配还保存距离。
尾部 CHECK 及 gzip 的 LENGTH 等待尾部位,同时保存校验和与已产生长度。

表格按任务分组,没有合并真实 C 中的各个 case。尤其是距离尚未解析完成时,恢复调用不能假装匹配来源已经验证完毕。

位缓冲和调用者输入是两个不同的位置。一个字节被取入后,next_in 已经前移,但其中部分位仍可能保存在 hold。因此 avail_in == 0 只说明调用者当前提供的范围已耗尽,不说明解码器已经没有位可以解释。反过来,缓冲区里还有位,也不保证下一个字段所需的位已经足够。输入位逐位追踪了这个边界。

C 把频繁使用的寄存器加载到局部变量。正常暂停进入 inf_leave,由 RESTORE 写回当前指针和位寄存器。这里的“当前”包括此前已经完成的读取和写入。离开循环不会撤销一次做了一半的操作。完整公开调用历史的证明还未完成,其中需要逐条连接局部变化与返回堆,涵盖输入耗尽、输出耗尽和错误出口。

调用者可见的进展

调用前,应用通过 next_in/avail_in 提供可读字节,通过 next_out/avail_out 提供可写空间。返回后,根据剩余数量计算本轮进展:

已消耗 = 调用前 avail_in  - 返回后 avail_in
已产生 = 调用前 avail_out - 返回后 avail_out

输入指针前移“已消耗”个字节,输出指针前移“已产生”个字节。尚未读取的输入必须保留。若解码器只用了一个输入块的前半段,就用无关新数据替换整块,会丢失压缩流后半段。可以在范围耗尽后补入新块;也可以明确移动或追加数据,但必须保持未读后缀及其正确指针和数量。

Z_OK 表示有进展,但流尚未完整终止。Z_BUF_ERROR 不一定表示数据畸形:当前输入和空间可能不足以继续,也可能是 Z_FINISH 尚不能完成。反复重试同一份受阻配置,不会自动补齐缺少的资源。Z_NEED_DICT 请求 zlib 字典;Z_DATA_ERROR 报告检测到的格式错误或已启用的完整性检查错误,此前可能已经产生输出。Z_STREAM_END 表示当前流或 gzip 成员完成,后面仍可留下未读输入。全部情形和 flush 值见 inflate 完整契约。

总计数还需要另作区分。普通返回路径根据产生的字节更新总量和已启用的输出校验和。但历史窗口的延迟分配可能在输出已经写出、指针已经恢复后失败。此时的 Z_MEM_ERROR 跳过后续总量与校验和更新,不能解释为“什么都没发生”。本轮影响应从返回后的指针和可用量识别。证明章会说明,为什么无条件使用同一条总计数等式是错误的。

压缩与完成协议

压缩的方向则是:原文字节作为输入 → 压缩字节作为输出。它也有多级进展位置:接收的输入进入窗口,其中尚未处理的后缀称为预读输入,供比较可能的匹配使用。匹配决策提交符号,块输出把符号编码,待发送存储再把字节交给应用。输入字节被接收时,表示它的压缩字节可能还没有交付;没有新输入的调用,也可能继续交付已有待发送输出。

普通调用先以 Z_NO_FLUSH 送入数据,再请求 Z_FINISH。进入完成阶段后,继续使用 Z_FINISH,按需提供输出空间,不再追加新输入,直到 Z_STREAM_END。输出缓冲区写满,不代表最终块、对齐和尾部都已交付。deflateEnd 释放资源,不会补做缺失的完成步骤。

Z_SYNC_FLUSH 把已经接受的数据推进到完成且按字节对齐的刷新边界,同时保留压缩历史。Z_FULL_FLUSH 还建立重新开始的边界,清除后续匹配使用的历史。两者都需要足够的调用与空间来完成所请求的刷新。Z_BLOCK 停在块边界,没有相同的字节对齐承诺;Z_PARTIAL_FLUSH 则使用其规定的部分刷新机制。这些操作都不等同于完成整个流。准确的允许序列见 deflate。

下一章把 DEFLATE 主体放进头部、字典和尾部。之后,流式执行的证明把输入前缀、保留历史、实际返回路径和完成条件连接起来。

这一机制的证明思路跨调用的证明思路 ↗
ZLIB × LEAN

全局目录

当前位置暂停状态与调用接续