C# Socket TCP大文件传输:分块、断点续传与粘包拆包实战

发布时间:2026/10/27 7:47:33
C# Socket TCP大文件传输:分块、断点续传与粘包拆包实战

简介:面向C#网络开发者的TCP大文件传输与断点续传完整示例工程,基于Socket API实现可靠传输,适用于需要处理大文件传输、支持断点续传的客户端/服务器场景。资源共73个文件,压缩包约190KB,主要文件类型包括C#源代码、可执行程序、配置文件、调试符号等,cs源码覆盖服务端与客户端的完整逻辑,exe程序可直接运行验证,pdb便于调试排错。已有966人学习/下载。通过双端工程,完整演示了TCP连接建立、文件分块与块序号管理、断点续传进度记录、数据发送与接收确认、超时重试、文件完整性检查及合并等关键环节。项目还涉及多线程/异步传输、缓存与流量控制等性能优化思路,代码结构清晰,适合中级C#开发者深入学习网络编程并直接复用。

1. 大文件传输为什么必须自己写 socket TCP:内存、半包和断点续传的三个现实问题

做过 C# 上位机或者局域网文件工具的工程师,迟早会撞上同一个需求:把几个 GB 的采集数据、日志包或者固件从一台机器搬到另一台。一开始大家都会想用现成方案——HTTP 上传、FTP、甚至直接 File.Copy 到共享文件夹。但真到现场,带宽受限、连接不稳、文件一大就会遇到内存溢出、传输中断后从头再来的问题,这时候就得回到 socket TCP 自己实现大文件传输,并把断点续传做进去。

这篇文章讲的就是这条落地路径:用 C# 的 Socket 做 TCP 长连接,按块读取文件、按帧发送数据、接收端边收边落盘,再通过偏移量协商实现断点续传。适合已经写过基本 socket 通信、想往文件传输方向深入的 C# 工程师,也适合上位机开发中需要传输大文件的场景。我先说结论:大文件传输的核心瓶颈不是网速,而是内存分配、TCP 粘包拆包和失败恢复这三个点,下面逐个展开。

2. 协议先行:分块传输与自定义帧头,把 2GB 文件切成 1MB 的消息

2.1 为什么不能一次 Send 整个文件:内存与 TCP 缓冲区的双重限制

最常见的错误写法是 File.ReadAllBytes 然后把 byte[] 丢给 Socket.Send。这个写法在小文件下没问题,但文件一旦超过 1GB,首先遭殃的是进程内存——ReadAllBytes 会一次性把整个文件加载进托管堆,加上 byte[] 的对齐开销,2GB 文件能吃掉近 2.5GB 内存,32 位进程直接 OutOfMemory,64 位进程也会触发 LOH(大对象堆)碎片化。

第二个限制在 TCP 协议栈本身。Socket.Send 并不是你把数据交给系统,系统就立刻发出去,而是写入发送缓冲区,由内核按拥塞窗口和 MTU 分段发送。默认发送缓冲区在 Windows 上通常是 64KB 左右,你一次性 Send 一个大 byte[],内核会把它切成多个 TCP 段,对端接收时这些段可能分多次到达。如果不做应用层协议来界定每次传输的边界,接收方根本不知道一次“完整消息”在哪里结束。这就是 TCP 粘包和拆包问题的来源,下面第 5 章会详细讲。

常见的做法是把文件按固定大小分块,比如 1MB 一块,逐块读取、逐块发送。这样内存占用被限制在“块大小 + 协议头”的量级,无论文件多大,进程内存都稳定。分块还有一个附带好处:断点续传时可以按块记录进度,不用精细到字节级去处理磁盘文件的随机读写。

2.2 自定义帧结构:魔数、命令、序号与长度的设计

既然要分块,就要让接收方能从字节流中正确切出每一块,这就需要自定义一个应用层帧头。我一般会设计成定长帧头 + 变长数据体的结构,帧头至少包含以下字段:

字段类型占用字节说明
魔数int4固定值如 0x5A5A5A5A,用于帧同步和校验
命令字int41=文件头,2=数据块,3=文件尾,4=续传请求,5=续传应答
文件IDlong8一次传输会话的唯一标识,防止并发传输串包
块序号long8当前数据块在整个文件中的序号,从 0 开始
数据长度int4本帧携带的字节数,最大不超过块大小
文件总大小long8只在文件头发送一次,其他帧填 0
文件名长度int4只在文件头发送一次
文件名byte[]变长文件头专用

算一下,定长部分 40 字节,加上变长文件名。接收方的解析逻辑就清晰了:先读满 40 字节帧头,解析出命令字和数据长度,再按数据长度读满数据体。整个过程可以用状态机来维护,当前处于“读帧头”还是“读数据体”状态,避免每次 Receive 的数据跨越多个帧时处理遗漏。

2.3 块大小怎么选:1MB 是经验值,但不是唯一答案

块大小直接影响传输效率和断点续传的粒度。块太大,单帧内存占用高,而且如果这一块在网络上损坏或丢失,重传成本高;块太小,帧头占比上升,协议开销变大,而且接收方需要频繁写磁盘,IO 次数增多。

我一般默认为 1MB,这个值在局域网千兆环境下性能表现不错。如果是跨公网传输、带宽窄且延迟高,建议降到 256KB 或 512KB;如果走万兆内网且文件超大,可以调到 4MB 甚至 8MB,但这时候要确认接收方的写入磁盘速度跟得上,否则内核缓冲区堆积会造成内存压力。块大小应该在协议里做成可配置参数,不要硬编码,后面做性能调优时会反复改这个值。

2.4 文件头与文件尾:传输开始前和结束后各做一次校验

分块传输不能只传数据体,必须在开始前通过文件头告诉接收方“我要传谁、多大、分多少块”,在结束后通过文件尾告诉接收方“传完了,可以校验了”。文件头的文件名和大小是接收方创建文件的依据,文件尾则触发接收方的完整性验证。

文件尾帧的数据体里我建议带上文件总大小的 MD5 或 SHA256 值。接收方收完所有数据块后,对本地文件重新计算哈希,与帧尾携带的哈希对比。一致则返回成功应答,不一致则返回失败应答并携带已接收的字节数,发送方据此决定是重传还是终止。这一步在网络环境不稳时是后悔药,能避免“文件能打开但内容损坏”的隐蔽问题。

3. C# 实现发送端与接收端:从 Socket 连接到落盘校验的最小可跑代码

3.1 发送端:分块读取、逐帧发送、控制内存峰值

下面这段代码是发送端的核心循环,只保留主干逻辑,便于直接理解:

// 发送端核心:逐块读取文件并发送 private static async Task SendFileAsync(Socket socket, string filePath, long resumeOffset) { string fileName = Path.GetFileName(filePath); long fileSize = new FileInfo(filePath).Length; int blockSize = 1024 * 1024; // 1MB,可按网络环境调整 // 发送文件头:文件名、总大小、文件名长度 byte[] fileNameBytes = Encoding.UTF8.GetBytes(fileName); using (var headerStream = new MemoryStream()) using (var writer = new BinaryWriter(headerStream)) { writer.Write(0x5A5A5A5A); // 魔数,用于帧同步 writer.Write((int)Command.FileHeader); // 命令字=1 writer.Write((long)0); // 文件ID,断点续传时由服务端分配 writer.Write((long)-1); // 块序号,文件头填-1 writer.Write(fileNameBytes.Length); // 数据长度=文件名长度 writer.Write(fileSize); // 文件总大小 writer.Write(fileNameBytes.Length); // 文件名长度字段 writer.Write(fileNameBytes); // 文件名数据体 await socket.SendAsync(headerStream.ToArray(), SocketFlags.None); } // 分块发送文件内容,从resumeOffset开始跳过已传部分 using (var fs = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read, blockSize, useAsync: true)) { fs.Seek(resumeOffset, SeekOrigin.Begin); int blockIndex = (int)(resumeOffset / blockSize); long totalSent = resumeOffset; byte[] buffer = new byte[blockSize]; while (totalSent < fileSize) { int read = await fs.ReadAsync(buffer, 0, buffer.Length); if (read <= 0) break; // 构造数据块帧:帧头 + 实际读到的字节 byte[] frame = BuildDataFrame(blockIndex, buffer, read); await socket.SendAsync(frame, SocketFlags.None); totalSent += read; blockIndex++; // 可在这里抛出进度事件,更新UI或日志 } } }

核心逻辑分三段:先发送文件头,让接收方知道即将开始的传输元数据;然后用 FileStream 配合 ReadAsync 分块读文件,每读一块就构造成一帧发出;最后通过 totalSent 累计已发送字节数,用于断点续传时跳过已经传过的部分。

参数说明:blockSize 是分块大小,决定了每次 ReadAsync 读入内存的数据量;resumeOffset 是断点续传的起始偏移,首次传输为 0;useAsync: true 让 FileStream 使用异步 IO,避免线程池线程在磁盘读取时被阻塞。这里没有用 ReadAllBytes,也没有把整个文件加载进内存,进程峰值内存大约是 blockSize 的两倍加上 Socket 缓冲区的量级。

3.2 接收端:状态机拆帧、边收边写、只保留最后一帧的残包

接收端比发送端复杂,因为它面对的是 TCP 字节流,一次 Receive 可能收到半个帧、一个整帧、或者多个帧粘在一起。必须用一个状态机来管理拆帧过程:

// 接收端拆帧状态机核心逻辑 public enum FrameParseState { ReadingHeader, ReadingPayload } private async Task ReceiveLoopAsync(Socket socket, string saveDir) { // 帧头定长40字节(魔数4+命令4+文件ID8+块序号8+长度4+总大小8+文件名长度4) byte[] headerBuffer = new byte[40]; byte[] payloadBuffer = null; int headerFilled = 0; int payloadFilled = 0; FrameParseState state = FrameParseState.ReadingHeader; while (true) { if (state == FrameParseState.ReadingHeader) { int received = await socket.ReceiveAsync(new ArraySegment<byte>(headerBuffer, headerFilled, 40 - headerFilled), SocketFlags.None); if (received == 0) break; // 对端关闭连接 headerFilled += received; if (headerFilled == 40) { // 解析帧头 int magic = BitConverter.ToInt32(headerBuffer, 0); if (magic != 0x5A5A5A5A) throw new InvalidDataException("帧同步丢失"); int command = BitConverter.ToInt32(headerBuffer, 4); long blockIndex = BitConverter.ToInt64(headerBuffer, 16); int dataLength = BitConverter.ToInt32(headerBuffer, 24); // 按命令字分派:文件头/数据块/文件尾/续传应答 payloadBuffer = new byte[dataLength]; state = FrameParseState.ReadingPayload; headerFilled = 0; } } else { int received = await socket.ReceiveAsync(new ArraySegment<byte>(payloadBuffer, payloadFilled, payloadBuffer.Length - payloadFilled), SocketFlags.None); if (received == 0) break; payloadFilled += received; if (payloadFilled == payloadBuffer.Length) { // 完整帧处理:数据块写入文件,文件尾做哈希校验 ProcessFrame(command, blockIndex, payloadBuffer); state = FrameParseState.ReadingHeader; payloadFilled = 0; } } } }

这段代码的关键在于:接收缓冲区只有 40 字节的帧头缓冲和一个按需分配的数据体缓冲,不会一次性申请巨大的 byte[]。ReceiveAsync 返回多少就填多少,填满帧头再填数据体。TCP 粘包时,一个 Receive 可能返回“一个半帧”,下一轮循环继续把剩下半个帧读完;TCP 拆包时,一个 Receive 可能返回多个完整帧,但外层 while 循环会逐个处理,不会丢失。

ProcessFrame 里对数据块的处理是打开 FileStream、Seek 到 blockIndex * blockSize、写入 payloadBody。这里要注意,由于 TCP 传输是有序的,正常情况下块序号是递增的,但断点续传后从中间开始,Seek 定位是必须的。为了避免频繁打开关闭文件句柄,接收端应该在收到文件头时创建 FileStream,文件尾时刷新并关闭。

3.3 一个从零开始的完整会话:发送端与接收端的握手流程

把上面的发送端和接收端串起来,一次完整传输的时序是这样:

  1. 发送端连接接收端的监听端口,TCP 三次握手建立连接
  2. 发送端发送文件头帧,包含文件名、总大小
  3. 接收端收到文件头,创建同名空文件,返回一个确认帧(命令字=4)
  4. 发送端从偏移 0 开始,逐块发送数据帧
  5. 接收端每收到一块就写入文件对应位置,并累计已接收字节数
  6. 发送端发送文件尾帧,携带整个文件的哈希值
  7. 接收端计算本地文件哈希,与帧尾哈希比对,返回成功或失败应答

这个流程里,接收端返回的确认帧就是断点续传的基础——它携带了接收端当前已完整写入的字节数。发送端下次连接时直接带上这个偏移量继续,而不是从头传。握手流程看起来简单,但断点续传的可靠性就藏在这几个确认帧里,第 4 章详细展开。

4. 断点续传的偏移量管理:服务端记录已接收长度,客户端从断层继续

4.1 续传的语义:以接收端落盘字节数为准,而不是发送端发出去多少

断点续传最容易搞错的一点是偏移量的基准。发送端发了 800MB,不代表接收端落了 800MB,因为内核缓冲区可能还没把数据写到应用层,或者接收端在写入第 500MB 时进程崩了。可靠的做法是:续传时以接收端实际落盘的字节数为准。

接收端在写入每个数据块时,除了 Seek 定位,还要维护一个 receivedBytes 字段。每成功写完一块,receivedBytes 就增加该块的实际长度。这个值在以下两个时机必须持久化:每次写完一块后,以及收到文件尾之前。持久化方式可以简单到写一个同名 .part 文件,内容就是数字字符串;也可以用 SQLite 或配置文件,视项目规模而定。

发送端发起续传时,先发送一个续传请求帧(命令字=4),接收端收到后检查本地是否有对应文件的 .part 记录,有则返回记录中的已接收字节数,没有则返回 0。发送端拿到这个值后,用文件总大小减去它得到剩余字节数,直接从偏移处继续分块传输。这样就做到了断点续传——发多少不重要,收了多少才重要。

4.2 客户端发起的续传:检测 .part 文件与主动询问偏移

发送端每次开启传输前,先检查目标目录下是否有该文件的 .part 记录文件。这个检查不能替代接收端的查询,因为接收端可能换了目录、可能没有写 .part、可能 .part 记录的是上一次失败会话的残留。正确的模式是始终以接收端返回的偏移为准,本地检查只是减少一次网络交互的优化手段。

伪代码逻辑:

// 发送端决定起始偏移的流程 long resumeOffset = 0; if (File.Exists(partRecordPath)) { // 读取本地记录作为猜测值,但不直接使用 long cachedOffset = long.Parse(File.ReadAllText(partRecordPath)); resumeOffset = cachedOffset; } // 连接接收端,发送续传请求帧 SendResumeRequest(socket, fileId); long confirmedOffset = await ReceiveResumeResponse(socket); resumeOffset = confirmedOffset; // 以接收端返回值为准 SendFileAsync(socket, filePath, resumeOffset);

这里有个常见陷阱:接收端返回的偏移是“成功写入并落盘的字节数”,如果上次崩溃发生在写入一半时,receivedBytes 可能不是块大小的整数倍。处理方式是我推荐接收端只统计完整块的偏移量,即 receivedBytes 始终是 blockSize 的倍数,写入半块的情况视为该块未完成,不累计。代价是最后一帧可能少于 blockSize,续传时重传整个块会造成少量数据重复,但接收端按块序号覆盖写入,重复数据不影响最终结果。

4.3 服务端的断点恢复:进程重启后不丢进度的落地做法

服务端(接收端)进程崩溃重启后,要能从磁盘上恢复断点状态。设计时我习惯把 .part 文件和数据文件放在同一目录,命名规则是“原文件名.part”。.part 内容格式我用 JSON 序列化,预留扩展字段:

{ "fileId": "20240515-001", "fileName": "sensor_data_20240515.bin", "receivedBytes": 734003200, "blockSize": 1048576, "lastUpdated": "2024-05-15T10:32:08Z" }

服务端启动时扫描目录下的 .part 文件,加载到内存字典,key 是 fileId。收到发送端的续传请求帧时,查字典返回 receivedBytes。发送端传完最后一个块后,服务端删除 .part 文件,并把临时文件名改成正式文件名。这里注意:建议先写临时文件(如 fileName.tmp),全部完成且哈希一致后再重命名,避免传输中途文件名暴露给其他程序读取到半个文件。

4.4 重复块与乱序块的处理:写覆盖还是跳过

断点续传必然带来重复块的场景:发送端从偏移 1048576 继续,但接收端上次已收到块 1(0-1048575),如果接收端不是严格的”完整块计数“模式,可能已经把这块的偏移写进 receivedBytes,导致续传起点携带了块 1 的数据。处理方式有两种:

第一种是写覆盖,最简单,收到块后不管本地有没有,直接 Seek 到 blockIndex * blockSize 覆盖写入。因为 TCP 保证顺序,未完成的块只可能从中间某块开始,覆盖后结果正确,而已经写过的完整块被再写一次,内容一致。

第二种是写前检查,在数据帧头带文件级哈希或者块级哈希,接收端写前先读本地对应位置做对比,一致则跳过。这个做法的开销很大——每块都要读盘和计算,局域网下可能让吞吐量下降 30% 以上,不建议用。

我一般选择写覆盖。只有在传输超大文件且带宽成本极高时才考虑块级哈希跳过,比如卫星链路、窄带公网传输等场景。

5. 避坑:TCP 粘包拆包、缓冲区与超时这几个最常翻车的地方

5.1 帧同步丢失:接收到的第一个字节不是魔数开头

现象:接收端解析帧头时,BitConverter.ToInt32 读到的魔数不等于 0x5A5A5A5A,直接抛异常或者写入了错误数据。

原因:TCP 是字节流,如果发送端的第一个帧在传输过程中被对端的内核合并、或者接收端在连接建立后先收到了对端 TCP 协议栈的某些握手数据,第一帧并不一定从字节流的第 0 字节开始。更常见的是,上一帧处理完毕时,接收缓冲里残留了几个字节属于下一帧,而下一轮循环没有正确把残留字节纳入新帧头的解析。

解决:解析时必须进入“找魔数”状态——不要求第一个字节是魔数开头,而是扫描整个头缓冲,找到魔数的位置,把后续数据对齐到魔数。我在生产代码里是这么做的:如果 magic 校验失败,不直接抛异常,而是从当前已经填满的 40 字节里逐字节后移,反复比较 4 字节是否等于魔数,找到后把剩余字节前移,继续读帧头。这个“滑动窗口找魔数”逻辑虽然多几次内存拷贝,但能避免整个连接报废后重传整个文件。

5.2 粘包与拆包:Receive 一次收到两帧,或者半帧

现象:局域网高带宽下,发送端连续 Send 两个帧,服务端一次 Receive 拿到了 4MB 数据,包含两个完整数据块;或者发送端 Send 一个 1MB 的块,接收端分了三次 Receive 才收完。

原因:TCP 是流协议,没有消息边界,内核缓冲区、接收窗口、Nagle 算法都会影响数据的到达时机。这不是 bug,是 TCP 的固有行为,必须靠应用层拆帧解决。

解决:严格使用第 3 章的状态机,不允许“先 Receive 再按收到的字节数当一帧”。状态机里不管一次 Receive 返回多少字节,都先把数据塞进当前帧的剩余空间,塞满了再处理。注意处理“一次 Receive 填满当前帧后,剩余字节属于下一帧”的情况——代码里必须有一个 buffer 对象保存剩余字节,而不是丢弃。这个剩余字节的保存逻辑是拆帧实现中翻车率最高的地方,我见过不少项目因为漏掉这个细节,导致大文件传完后文件头几个字节错乱。

5.3 设置 Send 超时后文件尾发不出去,误报传输失败

现象:大文件传完,文件尾帧发送超时,SocketException 抛出,接收端显示收到 99.9% 但缺文件尾,无法触发哈希校验。

原因:Socket.SendTimeout 只影响 Send 调用本身,如果发送缓冲区已满且对端不再读取(接收端处理速度慢、或者接收端出错关闭了接收方向),Send 会阻塞直到超时。传大文件时,接收端写磁盘慢,导致发送端的内核缓冲区堆积,客户端还在继续 Send,最终触发超时。

解决:不要把 SendTimeout 设置得很短,建议至少 30 秒以上;另外心跳/进度确认机制值得做——发送端每发 N 块,等待接收端回一个“已收到块序号”的确认,确认超时再重试。这种应用层 ACK 能同时解决超时误判和断网检测两个问题。还有一点,检测到发送失败时,不要立即销毁 Socket,先尝试从 SocketError 判断是超时还是对端主动关闭,超时可以考虑加大超时时间后重试发送剩余部分。

5.4 接收端落盘慢导致发送端内核缓冲区堆积,内存涨到几个 GB

现象:传输 60GB 文件时,任务管理器看到发送端进程内存 2GB 且持续增长,接收端磁盘 IO 100%,但网络吞吐很低。

原因:发送端不断 Send,但接收端读取慢(可能是机械硬盘、或者 FileStream 没开异步写、或者设置了 WriteThrough 标志),TCP 接收窗口变小,发送端数据在内核发送队列堆积。如果发送端应用层用同步 Send,线程阻塞;如果用异步 Send,回调节奏快于对端消费速度,同时进行的 Send 调用叠加,缓冲区膨胀。

解决:核心是控制发送节奏。做法一,使用 SafeSocketHandle 的发送窗口机制,SemaphoreSlim 限制未确认帧的数量,我一般限制并发未确认帧不超过 4 个。做法二,接收端写入时使用 1MB 的 BufferedStream,并设置 FileStream.Flush 不要太频繁,默认让 OS 页面缓存发挥作用,每 32MB 或每 100 块手动 Flush 一次即可。还有个细节,FileStream 的 bufferSize 参数不要小于块大小,否则它内部会重复分配缓冲区。

5.5 断点续传后接受端文件尾部出现重复字节

现象:续传完成后,文件尾部多出一段与前面某块相同的数据,文件长度超过原始文件大小。

原因:发送端从 resumeOffset 继续发送,但 resumeOffset 不是块的整数倍时,发送端 Seek 到任意偏移,读取是从该偏移开始的整块,导致同一块的数据被当作两个块发送,接收端用 blockIndex * blockSize 定位覆盖写入时,超出的尾部字节落在文件末尾。

解决:严格限制偏移粒度。续传偏移的粒度必须等于 blockSize 的整数倍,即接收端返回的 receivedBytes 必须向上取整到块边界。两种做法:接收端返回块数量而不是字节数,发送端用块数量乘以块大小计算偏移;或者接收端每写完一块就同步记录“下一块序号”。保险起见,文件尾帧带上原始文件大小,接收端在哈希比对前先检查文件长度,长度不一致直接判失败。

5.6 大文件传输中网络闪断,连接重置却没有任何异常抛出

现象:传输途中拔网线,发送端 Send 一直不报错,直到 5 分钟后 TCP 超时才抛出异常。

原因:TCP 是可靠传输,拔网线后连接处于半开状态,发送端不知道对端已经不可达。默认的 keepalive 探测间隔在 Windows 上是 2 小时,等它发现断线黄花菜都凉了。

解决:应用层做心跳+超时确认。我推荐每发送 16 个数据块,发送端主动要求接收端返回一个确认帧,确认帧携带当前已写入的块序号。如果连续 3 次请求没有收到确认,判定连接已断,立即停止传输并保存当前进度。注意,这里“保存进度”是在发送端保存重传位置,接收端在断开后要能继续接受同一连接的新数据或者等重连后恢复偏移。

6. 验证与进阶:模拟断网测试、吞吐量调优与异步改造

6.1 验证方法:用 PowerShell 和 netsh 模拟受限网络

传输功能写完后的第一件事不是上线,是制造故障。我在本地验证时通常用两种手段:一是直接把网线,或者在虚拟机上禁用网卡,验证断线检测机制能否在合理时间内触发;二是用 PowerShell 的 Set-NetTCPSetting 或者 netsh 命令调整本机 TCP 参数,模拟弱网环境:

# 模拟高延迟和低带宽环境(以 Windows 为例) # 注意:以下命令需要管理员权限,且仅用于测试验证,生产环境不要随意修改全局参数 netsh int tcp set global timestamps=enabled # 结合网络模拟工具(如 Clumsy)设置丢包率10%、延迟200ms,观察传输与续传行为

Clumsy 这类工具可以在不修改系统参数的情况下,对指定进程的 TCP 流量做丢包、延迟、乱序模拟。我的验证动作一般是:正常传一个 2GB 文件,再在传输到 30% 时强制杀掉接收端进程,等重启后用续传功能恢复,对比最终文件的 SHA256 是否与源文件一致。这一步必须用自动化脚本跑,人工盯着传完一次不现实。

6.2 吞吐量调优:缓冲区、窗口与 Nagle 算法的取舍

传输调优的核心参数有四个:Socket 收发缓冲区大小、块大小、TCP 窗口规模因子、Nagle 算法开关。误区是盲目把 Socket.SendBufferSize 调到 8MB,内核并不保证提交这么多空间,反而可能造成内存浪费。

我调优的顺序是:先固定块大小 1MB,用 64KB Socket 缓冲区跑一次千兆内网吞吐量基准;再分别调到 256KB、1MB 的 Socket 缓冲区,看吞吐量变化;最后再试块大小 256KB vs 4MB 的差异。通常千兆内网下,Socket 缓冲区 256KB、块大小 1MB 就能跑满 90% 带宽。如果吞吐量上不去,先查接收端磁盘写入速度,再查防火墙或者安全软件是否做了深度包检测。

Nagle 算法(Socket.NoDelay)需要注意:开启 NoDelay 是为减少小包延迟,但它会把多个小帧都独立立刻发送,反而导致更多的小包,增加协议开销。如果帧头很小且交互频繁(比如确认帧),NoDelay 是必要的;但传大文件时,数据帧本身已经很大,NoDelay 的作用不大,建议关闭 NoDelay,让内核尽量合并小帧,减少网络中断。

6.3 异步改造:不要阻塞 UI 线程,用 async/await 全程贯穿

Socket 的 ReceiveAsync 和 SendAsync 在 .NET Framework 4.5 之后的版本是真正的异步操作,不占用线程池线程,而是基于 IOCP。文件的读写用 FileStream 的 ReadAsync/WriteAsync。UI 更新用 IProgress 或者 SynchronizationContext 回传进度事件。千万不要在异步方法里用 .Result 或 .Wait() 同步等待,一旦在 UI 线程上下文里阻塞,必然死锁。

这里有个容易被忽视的细节:SocketAsyncEventArgs 是老的 APM 模式的替代品,性能和异步方法接近,但代码复杂度高很多,新项目直接用 async/await 即可,不要为了“性能好”去用 SocketAsyncEventArgs,在 .NET 6+ 里它的性能优势已经基本消失。

6.4 写入策略:顺序写还是随机写

接收端把每块数据直接 Seek 到对应位置写入,看似随机写,但 TCP 顺序保证块序号递增,所以实际上也是顺序写——只要发送端不重传旧块。断点续传后,续传起点往前所有块已经写完,后续块序号依然递增,所以整体就是顺序写。唯一可能打乱顺序的是发送端应用层并发发送多个块,接收端收到乱序块,此时随机写会造成磁盘性能灾难。

我建议发送端严格串行发块,不要用多个 Socket 连接并发传同一文件的不同部分。并发分片传输只在万兆内网或者多网卡绑定时有意义,局域网千兆单连接已经够快。如果确实需要并发,那接收端必须按块序号排序后写入,这需要额外的缓冲和排序逻辑,复杂度会翻倍。

6.5 最后的习惯:每传完一个文件,全链路校验一遍

我把这套方案在几个项目里落地后的习惯是:传输模块永远不要只做一个 Receive 成功后就算完成。文件尾哈希校验、文件长度校验、块序号连续性检查,这三件套必须都过。即使加校验让总耗时多几百毫秒,它换来的是一年运行中少几次“文件坏了但没人发现”的事故。项目上线后如果遇到玄学问题,优先查是不是哈希没校验、是不是断点偏移没对齐、是不是状态机里残留字节没处理——这三个点是我这两年排查别人代码时遇到最多的病灶。

我也不否认,纯靠 socket TCP 手写大文件传输方案,对比用 HTTP chunked 上传加 Range 断点续传,确实要多写不少代码。但它在可控性上是值得的:协议自定义、进度精确到块、发送节奏自己掌握,这在工业上位机、军工、医疗设备这类对传输过程有审计要求的场景里,是一个拿得出手的底牌。如果你也在做同一个方向,把状态机拆帧和偏移粒度这两块做扎实,后面基本不会再翻车。希望帮到你。

本文还有配套的精品资源,点击获取