Skip to content

第 9 章:数据落盘与校验 —— 一个 Block 如何跨越多个文件

BitTorrent 的逻辑地址是一条连续字节流,但用户看到的是目录和文件。存储层要在二者之间精确映射,同时处理预分配、.part、文件句柄上限、校验线程和恢复缓存。

一、连续字节流与文件树

假设 torrent 有三个文件:

text
A.bin  10 KiB  → 全局字节 [0, 10240)
B.bin  20 KiB  → 全局字节 [10240, 30720)
C.bin   5 KiB  → 全局字节 [30720, 35840)

一个 16 KiB block 从字节 8192 开始,就会横跨 A 和 B。Peer 协议不关心文件边界,只给 (piece, offset, length)tr_ioWrite() 必须循环切分为两次文件写。

二、tr_block_info:纯算术坐标系

它只需要 total_sizepiece_size,就能计算:

  • piece/block 数量;
  • 最后一个 piece/block 的特殊大小;
  • 任意 byte 对应的 piece、piece offset、block、block offset;
  • piece 的 byte span 与 block span;
  • request 的 byte span。

把这些计算集中在一个近似值类型中,能避免 parser、wishlist、I/O 各自写一套边界公式。大量方法是 constexpr,测试可以用很小的构造输入覆盖边界。

三、跨文件读写循环

tr_ioRead() / tr_ioWrite() 的主逻辑相似:

text
把逻辑位置转成 (file_index, file_offset)
while buffer 还有数据:
  bytes_this_pass = min(buffer剩余, 当前文件剩余)
  对当前文件 read_at / write_at
  buffer 前移
  file_index++
  file_offset = 0

使用 read_at/write_at(或平台等价物)而不是共享 seek position,使多个逻辑操作不会因文件游标互相干扰。

四、打开文件前做哪些决定

内部 get_fd() 先查 tr_open_files

  1. (torrent id, file index) 是否已有合适的 fd;
  2. 只读 fd 是否需要升级为可写;
  3. 文件在 download dir 还是 incomplete dir;
  4. 是否存在 .part 后缀版本;
  5. 新文件是否需要 full/sparse/none 预分配;
  6. 父目录是否要创建;
  7. 最终文件大小应是多少。

文件首次创建会增加 Session filesAdded 统计。I/O 错误除返回 errno 外,也会在 torrent 尚无更具体错误时设置 local error,供 UI/RPC 显示。

五、为什么文件句柄池只有 32 项

tr_open_files 用固定容量 32 的 LRU,以 (torrent_id, file_index) 为 key。原因不是硬盘只能开 32 个文件,而是:

  • 多 torrent × 多文件可迅速消耗进程 fd 限额;
  • Windows 删除/移动打开文件更困难;
  • 重用热点文件 fd 能避免每个 block 都 open/close;
  • 固定上限让资源消耗可预测。

停止、移动、删除、文件完成时都会主动关闭相应 fd,而不是只等 LRU 淘汰。

六、.part 和 incomplete directory

当 incomplete file naming 开启时,未完成文件以 .part 保存;incomplete directory 开启时,下载中的数据可位于另一目录。文件完成后:

  1. 关闭 fd;
  2. 记录 mtime,供下次恢复判断;
  3. .part 改回正式名;
  4. torrent 全部完成时再按配置移动到 download dir。

current_dir_ 并非简单等于 download dir:它根据 incomplete 设置、是否已有 metainfo、现有文件实际位置动态刷新。

七、Piece 校验怎样读取数据

tr_ioTestPiece() 重新计算 SHA-1:按该 piece 覆盖的 blocks 逐块读入固定 buffer,再裁剪首尾 block 中超出 piece 的部分,持续喂给 SHA-1。

为什么要裁剪?Block 与 piece 的边界不保证对齐;一个 block 可能尾部属于下一个 piece。校验必须严格覆盖 metainfo 定义的 piece byte span。

最终 hash 与 tor.piece_hash(piece) 比较。失败意味着磁盘上的这段连续字节流不可信,无论文件系统是否报告写入成功。

八、完整验证为何使用独立 worker

对数十 GB 数据顺序做 SHA-1 是 CPU 与 I/O 密集任务,不能放在 Session 事件线程。tr_verify_worker 维护:

  • todo_ 优先队列;
  • 当前 Node;
  • 独立线程 id;
  • stop_current_ 原子标志;
  • 条件变量与节流间隔。

队列比较会考虑 torrent priority,并偏好更小 torrent,让短任务更快完成、降低平均等待。

Mediator 向 Torrent 报告 queued、started、每个 piece 结果和 done。Torrent 侧再把这些状态安全地映射回活动状态与启动意图。

九、验证取消为什么不是杀线程

remove(info_hash) 会从 todo 中移除未开始任务;若正验证当前 torrent,则设置 abort flag 并等待 worker 在 piece/file 检查点退出。直接终止线程可能让文件状态、回调和 mutex 停在不一致位置。

worker 析构也会等待验证线程自然退场。源码使用 detach 加 thread-id/condition 协调,值得重点测试关闭竞态。

十、Resume 的 progress 是可信度优化

保存时记录:

  • blocks/pieces 完成 bitfield;
  • checked pieces;
  • 每个文件的 mtime;
  • 下载/上传/corrupt 统计;
  • 文件名覆盖和 wanted/priority。

加载时必须先恢复文件名,再恢复 progress,因为查找本地文件依赖最终路径;progress 又必须先于 priority,才能判断任务是否已经完成并跳过无意义字段。

如果 mtime 与保存值匹配,部分 piece 可视为已检查;不匹配则重新验证。这用 metadata 换启动速度,但不会把 mtime 当成内容证明。

十一、文件不想下载,为什么仍出现少量数据

因为 piece 是校验单位,文件是用户单位。边界 piece 同时覆盖 wanted 与 unwanted 文件时,为了得到 wanted 文件末尾的正确 hash,客户端必须下载整个 piece,其中一部分落在 unwanted 文件。这是 BitTorrent v1 分块模型决定的,不是选择逻辑失效。

十二、数据正确性的分层保证

text
协议层:index/offset/length 合法
请求层:block 确实 active 且 wanted
I/O 层:跨文件写入完整或报告 errno
Piece 层:SHA-1 与 metainfo 匹配
Resume 层:只缓存已知状态,mtime 变化触发重新判断
Peer 层:坏 piece 反馈 blame/strike

任何单层都不够。write() 成功不代表数据正确;hash 正确也不代表文件路径符合用户设置;resume 说完成更不代表离线期间没有被修改。

源码锚点

文档采用 CC BY-SA 4.0;源码片段保留 Transmission 上游许可。