第 9 章:数据落盘与校验 —— 一个 Block 如何跨越多个文件
BitTorrent 的逻辑地址是一条连续字节流,但用户看到的是目录和文件。存储层要在二者之间精确映射,同时处理预分配、
.part、文件句柄上限、校验线程和恢复缓存。
一、连续字节流与文件树
假设 torrent 有三个文件:
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_size 与 piece_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() 的主逻辑相似:
把逻辑位置转成 (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:
(torrent id, file index)是否已有合适的 fd;- 只读 fd 是否需要升级为可写;
- 文件在 download dir 还是 incomplete dir;
- 是否存在
.part后缀版本; - 新文件是否需要 full/sparse/none 预分配;
- 父目录是否要创建;
- 最终文件大小应是多少。
文件首次创建会增加 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 开启时,下载中的数据可位于另一目录。文件完成后:
- 关闭 fd;
- 记录 mtime,供下次恢复判断;
- 将
.part改回正式名; - 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 分块模型决定的,不是选择逻辑失效。
十二、数据正确性的分层保证
协议层:index/offset/length 合法
请求层:block 确实 active 且 wanted
I/O 层:跨文件写入完整或报告 errno
Piece 层:SHA-1 与 metainfo 匹配
Resume 层:只缓存已知状态,mtime 变化触发重新判断
Peer 层:坏 piece 反馈 blame/strike任何单层都不够。write() 成功不代表数据正确;hash 正确也不代表文件路径符合用户设置;resume 说完成更不代表离线期间没有被修改。
源码锚点
libtransmission/block-info.h:连续字节坐标libtransmission/file-piece-map.cc:文件与 piece 映射libtransmission/inout.cc:跨文件读写与 piece hashlibtransmission/open-files.cc:32 项 fd LRU 与预分配libtransmission/verify.cc:异步完整验证libtransmission/resume.cc:progress/mtime 恢复顺序libtransmission/torrent-files.cc:移动、删除与.part