fastcp——从零开始做一个多平台文件传输同步工具
约 2178 字大约 7 分钟
2026-02-27
计网课有一项作业是写一个简单文件传输软件,需要支持:
文件分片、并发传输、重传机制、校验(例如 MD5/SHA256 校验每个文件或分片)、断点续传(使用文件快照或增量校验)、跨平台路径与权限处理
可选优化:传输前做可选压缩、采用差异同步(rsync 风格算法)、使用多路复用/并发连接提高吞吐量。
既然这里提到了rsync,那我们也来做一个类似rsync支持Delta 增量同步的工具
首先要求了需要跨平台,由于为了能够更精细化地进行syscall,以及我们要尽可能的轻量、高性能,因此选用了C++
在压缩库上,我选择传输常用的zstd,哈希则选用zstd同款的xxhash
我们可以分为全量同步和增量同步(相当于只有首次是全量同步了)
全量同步
Virtual Archive
对于这种一次性下载全部内容的,我首先想到的是steam depot
由于游戏资产经常有很多重复的部分,因此steam的做法是根据哈希来把文件组装成chunk,这样steam内容分发服务器会减轻很大的储存负担,不过我们这种通用的文件传输工具显然不会有这种场景
而steam最值得我们学习的一点是,它使用了Virtual Archive(虚拟字节流),他把所有的文件都合并起来组成了一个完整的字节流,然后把chunk对应文件关系的manifest发过去,这样在传输的时候不会因为文件传输完成而反复断开建立连接
同时,客户端先提前开辟出需要的空间,然后在等待网络的时候先根据索引用00字节填充建立出所有空文件(这是很重要的一点,网络与磁盘/计算解耦,能够最大化传输效率),接受到字节流后,按照索引将字节信息连续写入覆盖00
客户端在接收的同时还要计算一下hash,做好完整性校验
断点续传
由于所有文件被合并成了一个连续字节流,我们不能简单地记录传到第几个文件了,而是需要记录字节流里哪些chunk已经写入磁盘了,因此我们可以设计一个续传文件格式:
[16 bytes] manifest token(标识这次传输的唯一 ID)
[4 bytes] total_chunks
[N bytes] bitset(bit=1 表示该 chunk 还需要,bit=0 表示已完成)每收到一个chunk 并写入磁盘后,立即翻转对应的bit并刷新进度文件。这样即使进程被强制 kill,下次启动时也能从bitset里精确知道哪些 chunk 还缺失
恢复时,客户端重新连接服务端,收到manifest后计算token与进度文件对比。匹配则只请求缺失的chunk,服务端按需发送,客户端打开已有文件继续写入
manifest token是对所有文件的 (rel_path + mtime_ns + file_size) 排序后做哈希得到的。这保证了只要源目录内容没变,token就不变,进度文件就可以复用。同时服务端在构建Virtual Archive时也必须按rel_path排序,否则重启后chunk布局会变,进度文件里记录的chunk索引就对应错误的数据了。
还有一个后来发现的坑,服务端关闭时,TCP发送缓冲区里可能还有一些数据没有真正发出去,如果用普通的FIN关闭连接,内核会继续把缓冲区里的数据发完,客户端会消费这些数据并更新进度。但这些数据属于上一次session,下次重连后服务端会重新发送这部分chunk,客户端却认为已经完成而跳过,导致文件内容错误,解决方案是服务端关闭时发RST,强制丢弃发送缓冲区,客户端立即收到连接重置错误,不会消费到残留数据
增量同步
如何判断是增量同步呢,由于第一次一定是全量同步,所以我们可以在全量同步后缓存文件树,如果这个缓存存在,那么就进行增量同步,同时缓存的文件树可以通过哈希比较来在零更改的情况下不用服务端再次发送文件树消耗时间
Pipeline Sync
增量同步的核心问题是客户端需要知道哪些文件变了,rsync的做法是服务端发文件列表,客户端逐一对比本地文件,然后对有差异的文件做rolling checksum计算delta。这个方案的问题是文件列表传输本身就有开销,而且客户端需要等文件列表全部收完才能开始对比
我们的做法是Pipeline Sync,服务端流式发送文件列表,客户端边收边对比本地文件,对需要更新的文件立即发送WANT_FILE请求,服务端边收WANT_FILE边发送文件数据。文件列表传输和文件数据传输完全并行,消除了等待,这就是刚刚说的网络与磁盘/计算解耦的思想
对于完全没有变化的情况,还可以进一步优化,客户端缓存上次同步的tree cache,下次连接时把tree token(树hash)发给服务端,服务端对比后如果一致直接回复TREE_CACHE_HIT,客户端用本地缓存直接发WANT_FILE,跳过整个文件树传输过程。这使得零变化的增量同步延迟降到了约100ms
bundle
每收到一个WANT_FILE就立刻发送小文件,每个小文件都是一个独立的TCP来回,开销极大,因此如果是小文件,我们不立即发送,而是追加到一个队列里,待客户端所有文件检查完毕后一并发送
并且由于小文件通常比较多,我们可以提前预读所有小文件到内存缓存,使所有任务都并行化,缩短传输时间
Delta 同步
对于增量同步中内容有变化的文件,如果文件很大而改动很小,重传整个文件很浪费,因此我们可以服务端把文件切成固定大小的块,客户端计算本地文件每个块的checksum发给服务端,服务端找出哪些块没变,只传新内容
TCP连接
缓冲
每次write()系统调用都会立刻发送一个TCP段,而syscall调用又会积少成多非常耗时间,因此我们可以建立一个缓冲区,当数据达到一定量之后再发送
功能位
我们会不断的做功能增加和优化,为了使旧版本和新版本的server和client能够使用,我们可以在握手期间协商功能位,这样保证了向后兼容性
多连接并发
单条TCP连接在高带宽高延迟网络下会受到拥塞窗口限制,因此我们支持多条并行 TCP连接,每条连接独立传输不同的chunk,类似于下载工具的多线程下载(注意这里的线程安全很重要)
benchmark
测试是在wsl里测的,磁盘性能较差,有波动正常,用tc限制本地回环网速
看下来和rsync性能基本持平,在零更改上优于rsync
最后一个测试(低带宽多文件)看得出来我们在协议上还是具有优势的
100MB,文件数量作为变量
| 场景 | 总量 | rsync 全量 | rsync 零变化 | rsync 10%变化 | fastcp 全量 | fastcp 零变化 | fastcp 10%变化 |
|---|---|---|---|---|---|---|---|
| 100 × 1MB | 100MB | 955ms | 157ms | 282ms | 1006ms | 106ms | 305ms |
| 1000 × 100KB | 98MB | 979ms | 116ms | 284ms | 1007ms | 107ms | 305ms |
| 10000 × 10KB | 98MB | 1011ms | 193ms | 304ms | 1008ms | 106ms | 406ms |
| 100000 × 1KB | 98MB | 5644ms | 496ms | 1757ms | 4362ms | 206ms | 2817ms |
1000×10KB,带宽作为变量
| Speed | rsync 全量 | rsync 零变化 | rsync 10%变化 | fastcp 全量 | fastcp 零变化 | fastcp 10%变化 |
|---|---|---|---|---|---|---|
| 5mbit | 16.38s | 115ms | 3136ms | 16.64s | 106ms | 3414ms |
| 10mbit | 8181ms | 164ms | 1587ms | 8324ms | 106ms | 1609ms |
10000×100B,带宽作为变量
| Speed | rsync 全量 | rsync 零变化 | rsync 10%变化 | fastcp 全量 | fastcp 零变化 | fastcp 10%变化 |
|---|---|---|---|---|---|---|
| 1mbit | 15.4s | 2413ms | 5765ms | 11.7s | 107ms | 3713ms |
| 5mbit | 3121ms | 373ms | 796ms | 2710ms | 106ms | 504ms |
| 10mbit | 1489ms | 180ms | 473ms | 1308ms | 106ms | 306ms |
| 40mbit | 603ms | 230ms | 297ms | 607ms | 106ms | 206ms |
| 100mbit | 561ms | 189ms | 334ms | 406ms | 106ms | 206ms |
