简介面向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 位进程直接 OutOfMemory64 位进程也会触发 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 是断点续传的起始偏移首次传输为 0useAsync: 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 ArraySegmentbyte(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 ArraySegmentbyte(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 一个从零开始的完整会话发送端与接收端的握手流程把上面的发送端和接收端串起来一次完整传输的时序是这样发送端连接接收端的监听端口TCP 三次握手建立连接发送端发送文件头帧包含文件名、总大小接收端收到文件头创建同名空文件返回一个确认帧命令字4发送端从偏移 0 开始逐块发送数据帧接收端每收到一块就写入文件对应位置并累计已接收字节数发送端发送文件尾帧携带整个文件的哈希值接收端计算本地文件哈希与帧尾哈希比对返回成功或失败应答这个流程里接收端返回的确认帧就是断点续传的基础——它携带了接收端当前已完整写入的字节数。发送端下次连接时直接带上这个偏移量继续而不是从头传。握手流程看起来简单但断点续传的可靠性就藏在这几个确认帧里第 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 继续但接收端上次已收到块 10-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 timestampsenabled # 结合网络模拟工具如 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 断点续传确实要多写不少代码。但它在可控性上是值得的协议自定义、进度精确到块、发送节奏自己掌握这在工业上位机、军工、医疗设备这类对传输过程有审计要求的场景里是一个拿得出手的底牌。如果你也在做同一个方向把状态机拆帧和偏移粒度这两块做扎实后面基本不会再翻车。希望帮到你。本文还有配套的精品资源点击获取