C# TCP文件传输实战:消息头、分块与粘包处理教程

发布时间:2026/10/21 13:05:11
C# TCP文件传输实战:消息头、分块与粘包处理教程 1. 从聊天Demo到文件传输这中间到底差了多远先交代背景我在地产子公司做内部工具开发日常维护一套WinForm系统。这类公司内网环境相对封闭外网盘用不了网传文件又担心留下记录。以前同事之间传大文件都是开共享文件夹但会被人乱改乱删。所以我做了一个C# WinForms的TCP文件传输Demo用来验证内网机器之间直接传大文件的可行性。标题里的“例子”两个字很关键。很多人觉得TCP文件传输就是个数据搬运写个循环把字节流读出来Send一下就完事。真动手踩过坑的话你会发现没这么简单。一个稍完整、能扛住大文件的传输例子里至少要解决这些事消息头怎么封装、分块多大合适、粘包半包怎么处理、进度怎么算、传输中途断线了哪些数据要重发、UI界面怎么不卡死。这些都要处理干净才算一个能拿得出手的Demo。这套代码后来被我改成了生产环境使用的内部小工具实现了两个内网常见场景一是把某台工控机上的日志文件拉到服务器指定目录二是开发人员互相传递200MB左右的数据库备份文件。实测下来稳定性和速度都符合预期才决定把这套写法梳理分享出来。整个过程用到的思路、配置参数、踩坑记录下面逐个说。适合谁参考刚学完Socket基本收发但从未做过真实文件传输的C#开发者需要给公司内部快速出一个传文件小工具的运维开发人员想理解磁盘文件、内存缓冲、TCP窗口如何协同的人。我尽量把每一步背后的原因讲明白不是只贴代码就完事。2. 方案选型的本质为什么坚持原生TCP而不走HTTP或UDP2.1 为什么不是HTTP也不是UDP内网工具第一考虑因素往往是能最快落地、最容易被IT审计接受。很多人的第一反应是干脆起一个HTTP文件服务对方浏览器里下载就行。但HTTP有个非常不方便的地方——它天生是单向拉取模型客户端只管发起请求服务器被动响应。如果我想主动把某台电脑上的文件推到另一台电脑HTTP这层就不太顺手。虽然写个轮询接口也不是不行但那个同步逻辑会越写越复杂。UDP做个快速小包传输可以但文件传输最怕的就是丢包。UDP不保证顺序、不保证送达每个包还得自己设计序号、确认重传机制。说白了就是用应用层代码重写一遍TCP的可靠性保证工程量远大于直接用TCP。如果只是传个几十KB的配置文件UDP还能凑合传到几百MB的数据库备份文件时一个包丢了整个文件就坏了重传的代价极高。所以方案上我直接选定原生TCP。Socket是所有Windows平台自带的能力不需要额外安装任何组件同时它在公网和内网的表现都非常稳健。公司里面跑这个工具IT部门也不会觉得引入新组件有什么风险。2.2 文件是按块传好还是按整个流传好这是整个Demo设计中我认为最核心的决策点。很多人刚开始写文件传输时喜欢把文件全部读到内存里再Send一次。首选的坑在于一个1GB的文件你就得先在内存里分配出接近1GB的字节数组32位程序直接内存爆掉64位程序也容易让GC频繁整理大对象堆。其次Socket的发送缓冲区也不是无限大你一次性Send一个巨大的字节数组底层不会一次性全发出去而是会把数据拆分进TCP发送缓冲区。如果你没有处理返回值和剩余字节数据就会悄悄丢。因此我的方案比较老派但非常可靠文件按固定大小分块循环读取磁盘内容写入Socket。这里有一个经验值——每个分块的大小建议设置在16KB到64KB之间。小于16KB磁盘IO和Socket发送的系统调用次数太多CPU空转大于64KB内存里的大对象数组也会变多GC压力变大而且TCP的窗口不太可能给你保证一包全发完。我最后用的是16KB作为默认分块大小理由有三点16KB是多数Window文件系统磁盘簇大小的整数倍读写效率高与TCP发送接收缓冲区通常默认8KB到64KB配合比较顺滑即使某个包需要重传代价也非常小。当然网络环境如果特别理想你可以把块大小提到32KB或64KB。但在内网百兆或者千兆环境里16KB实测的吞吐量跟64KB差距不大反而更稳。2.3 进度计算和断点续传的考量凡是要给用户看的进度条都有个绕不开的问题FileStream的Length属性从哪知道进度百分比怎么算。我的设计是先发一个文件信息头里面包含文件总长度、文件名长度、文件名内容。接收端拿到文件总长度后再根据每个分块的字节数累加已完成长度这样进度条就能实时显示。断点续传是另一个让人纠结的点。生产环境的工具如果传了一半断掉让用户重新整个传一遍是挺烦的。但断点续传需要双方都支持“从哪个偏移量继续发”协议的复杂度会成倍增加。对Demo而言我做了简化妥协发生断线就提示用户重新传但是保留接收到的临时文件而不是直接删除。下次重传时程序可以跳过已有长度再开始追加写入。这个实现其实不复杂就是多了个“查询另一半的已有长度”的握手步骤罢了。我把它作为一个扩展点留给读者。核心Demo仍然保持简单直接先把正确性搞明白再谈优化。3. 关键协议设计自定义消息头之所有文件型数据都要封包3.1 消息头的结构和字节布局纯流的文件传输是没法直接识别边界的。TCP从路由器到网卡再到Socket缓冲区数据被切割成什么形状完全由网络堆栈决定。就算你在应用层一次Send了16KB的块接收方Recv出来的也可能是8KB或者是两次Send的数据粘在一起。这就要用到消息头的设计了。我给每个数据包都加了一个固定长度的Header整体包结构长这样字段长度字节说明消息类型4用int标识1表示文件头2表示文件数据块3表示传输完成确认数据包长度4本次包携带的负载payload长度文件ID4区分是哪个文件的消息因为可能并行传输多个文件保留字段4预留比如放块序号、放偏移量等负载数据不定长真实载荷我用C#代码写一个辅助方法把消息类型、包长和载荷拼接成一个完整的字节数组一次性返回。这里有个容易被忽略的细节头字段如果直接拼接int用的是本机字节序小端序收发两端都在同架构机器上就没问题但如果以后要跨平台建议用BitConverter.IsLittleEndian做一次统一转换或者显式调用IPAddress.HostToNetworkOrder。不过公司内部都是Windows机器我这一版没处理跨端的情况。3.2 粘包、半包问题的正确处理方式这是TCP开发中最经典也最容易让新手走弯路的部分。TCP是流协议它没有“消息边界”的概念。你发送方视角是一条一条的包但在接收方的Socket缓冲区里数据是连绵一片的。接收方Recv出来的一次数据可能包含情况一粘包缓冲区里同时有数据包1的后半截和完整的数据包2甚至数据包3的前半截 情况二半包缓冲区里只有一个不完整的消息头或者消息头收到了但负载数据没到齐。解决方式业界非常统一在接收过程中维护一个“读取状态的暂存区”。每次Recv到新数据后先把字节流追加进一个MemoryStream(或者直接用List )然后从头开始解析。先检查能不能读满4字节消息类型再检查能不能读满4字节长度拿到负载长度后如果暂存区里的可用字节数大于等于预期负载长度就认为一个包完整了截取出来交给业务层处理剩余数据继续留在暂存区里等下一轮Recv。我习惯把这套解析逻辑放在专门的消息解析类里。外界只负责把原始字节流喂进来它负责吐出一个一个完整的消息对象。这样代码边界清晰后续接新消息类型也不会把收发逻辑搅在一起。4. 服务端和客户端的核心代码实现从握手到传输完成4.1 通信流程总览我采用的是典型的命令模式客户端发起连接服务端监听。在这里我把两个角色定义为发送端主动连接对方读取本地文件并发送接收端启动监听等待入站连接收下文件写到磁盘跟很多把服务端固定为“文件接收方”的架构不太一样我用发送端主动连接收端主要考虑的是因在生产环境里需求往往是操作员在发送端发起推送接收端平时保持监听服务等待被连接就行。如果想做多个接收端同时收一个文件还可以把发送端循环连接多个端口所有块轮询发过去这个属于扩展能力先不展开。一个典型完整传输流程包括下面几个阶段发送端连接接收端。连接成功后发送端先把文件消息头发过去里面包含文件名和总长度。接收端收到文件头在目标目录创建临时文件写文件头信息然后向发送端返回一个“准备就绪”的确认消息。发送端收到“准备就绪”后开始按照16KB分块循环读取本地文件逐块发送。接收端每收到一个完整的数据块消息就把负载字节写入临时文件同时更新累计长度。全部块发送完毕后发送端发送“传输完成”消息。接收端收到“传输完成”后把临时文件重命名为最终文件名给客户端回一个“完成确认”整个流程结束。4.2 发送端的主要代码逻辑这是发送端最关键的处理函数我把它精简成下面这个片段private void SendFile(string filePath, Socket clientSocket) { FileInfo fileInfo new FileInfo(filePath); string fileName fileInfo.Name; // 1. 发送文件头 byte[] headPacket PacketBuilder.BuildFileHeadPacket(1, fileName, fileInfo.Length); SendAll(clientSocket, headPacket); // 2. 等待接收端准备就绪 byte[] readyBuffer new byte[8]; int readyBytes ReceiveExactly(clientSocket, readyBuffer, 4); // 先读4字节确认信息 // ... 校验消息类型, 假设后面有扩展实现 // 3. 分块读文件并发送 byte[] buffer new byte[16 * 1024]; long totalSent 0; using (FileStream fs new FileStream(filePath, FileMode.Open, FileAccess.Read)) { int readCount 0; while ((readCount fs.Read(buffer, 0, buffer.Length)) 0) { byte[] dataPacket PacketBuilder.BuildDataPacket(1, buffer, readCount); SendAll(clientSocket, dataPacket); totalSent readCount; UpdateProgress(totalSent, fileInfo.Length); } } // 4. 发送完成消息 byte[] finishPacket PacketBuilder.BuildFinishPacket(1); SendAll(clientSocket, finishPacket); }注意其中几个容易被忽略的细节。第一while循环结束说明文件已经全部读完但发送端的Socket缓冲区里可能还有数据在排队所以不能马上关闭连接要等接收方的完成确认到达后再执行Socket的干净关闭。第二读文件用fs.Read而不是ReadAsync是为了让Demo更容易理解。生产环境如果文件上G我建议换成ReadAsync配合await这样网络发送等待时不会阻塞文件读。第三SendAll这个方法要特别注意返回值一次Send返回的字节数很可能少于要发的数据长度必须循环补发直到全部发完。这是很多新手栽跟头的地方。4.3 接收端的循环接收与写文件接收端的核心是一个接收循环它在后台线程里不断调用Socket.Receive把收到的原始数据追加到消息解析暂存区然后尝试解析消息包。private void ReceiveLoop(Socket clientSocket) { byte[] recvBuffer new byte[16 * 1024]; MemoryStream ms new MemoryStream(); while (clientSocket.Connected) { int byteCount clientSocket.Receive(recvBuffer); if (byteCount 0) break; // 对端关闭 ms.Write(recvBuffer, 0, byteCount); ms.Position 0; // 尝试解析所有完整的包 while (TryParsePacket(ms, out byte[] packet)) { HandlePacket(packet); } // 把剩余未完整的数据保持在流中 byte[] remain ms.ToArray(); ms.Dispose(); ms new MemoryStream(); ms.Write(remain, 0, remain.Length); } }这段代码有一些性能隐患每轮把没解析完的数据转成新数组再重建MemoryStream当数据包比较多时会频繁分配小对象。生产版本里我改成了自定义环形缓冲区或者用偏移量来控制原有数组的读写位置。不过Demo阶段先求逻辑清楚性能优化放在后面提。HandlePacket里对不同类型的消息做分支如果是文件头消息就记录文件名和总长度创建临时文件如果是数据块消息就写文件并更新进度如果是完成消息就把临时文件改名。这里写文件时也要注意一个细节写完字节后最好不要每一次都调用Flush否则磁盘IO次数暴增性能会掉非常多。等整个文件写完了再统一Flush一次并Close。4.4 后台线程和界面控件的更新WinForm的UI只能在主线程更新。Socket的接收循环如果在UI线程里跑一旦开始Receive阻塞界面就卡死了。所以代码里要把接收逻辑放到独立的后台线程去执行。等需要更新进度条时通过Control.BeginInvoke把更新操作丢回主线程。BeginInvoke是异步的不会等待UI线程回应这比Invoke更安全不容易造成死锁。我封装了一个简单的进度通知方法private void UpdateProgress(long sent, long total) { if (progressBar1.IsHandleCreated) { progressBar1.BeginInvoke(new Action(() { progressBar1.Maximum (int)(total / 1024); progressBar1.Value (int)(sent / 1024); labelStatus.Text string.Format(已发送 {0:N2} MB / {1:N2} MB, sent / 1024.0 / 1024.0, total / 1024.0 / 1024.0); })); } }这里ProgressBar的Maximum和Value都是int类型如果文件超过2GB直接转换成int就会溢出。所以我取的是KB为单位2GB约等于2百万KBint上限约21亿这样可以支撑到约2TB的文件完全够用。这个细节别看小文件一旦超过2GB进度条直接抛异常或者跳成负数排查起来还挺隐蔽。5. 实操过程中的坑我遇到过的常见问题与排查记录5.1 问题一连接建立后接收端一直收不到文件头这类问题十有八九不是网络不通而是消息粘包处理没做好或者对面的消息发出来了但解析逻辑卡住了。我遇到过最典型的情况是第一次Send文件头时由于TCP连接刚建立数据从应用层到Socket缓冲区再到对端整个链路非常快文件头可以跟第一个数据块粘在一起到达接收端。如果解析逻辑只做一次Receive然后就去读固定长度的字节就会发生数据错位后续每个包都解析失败。解决的办法很简单解析暂存区这个方案天然就兼容这种场景它会把连续到达的数据先拼在一起然后按包边界一个个解析。哪怕文件头和数据块在同一个接收批次里到达也能逐个拆出来。但如果你没有用暂存区而是假设“一次Receive就是一个包”这个问题就很难排查。还有一个低级但隐蔽的原因发送端SendAll以后接收端代码里是先做别的初始化再进ReceiveLoop中间时间差内文件头数据其实已经在Socket缓冲区里等着了。这时候如果接的是telnet或者可交互式调试工具就会看到一片空白容易误判成没发出来。5.2 问题二传大文件传到一半进度条突然不动了出现这个现象时不要急着怀疑网络卡了。优先检查是否出现了TCP窗口满而发送端还在死等的情况。当接收端把数据从操作系统缓冲区取走的速率降低比如磁盘写入慢、或者UI线程在做大量同步操作发送端会一直send直到对方窗口关闭这时Socket的时间片被占满界面线程也跟着遭殃。我的排查套路是这样梳理的接收端的磁盘IO是不是瓶颈如果是机械硬盘写入速度可能只有几十MB/s而千兆网卡的理论速度是125MB/s瓶颈就落到磁盘上了。这时候适当调大接收缓冲区的读取块大小或者在写入文件时减少不必要的多次Open/Close能明显改善。如果接收端用的是SSD或者网络盘还要检查是不是被杀毒软件实时扫描拦截了。公司电脑上装的杀毒软件会重点扫描exe、iso、zip这类文件每次写完一个块都要扫描一遍传输速率直线下滑。还有一次诡异的问题是进度条卡住的同时程序毫无反应排查下来发现是BeginInvoke调用太频繁UI线程来不及处理积压了大量委托。每传一个16KB块就BeginInvoke一次一个500MB文件要调用三万多回UI线程被刷爆了。后来我把进度更新限制成每隔200毫秒刷新一次问题就消失了。优化方法很简单在UpdateProgress里记一下上次更新时间没到200毫秒就直接return。5.3 问题三文件传完了却发现文件大小对不上这个问题的隐蔽性比较高。传完之后文件大小比源文件少了几KB甚至几十KB很多人第一反应是自己写的包解析代码有问题。实际上我遇到过一次是FileStream在读取时没有用using语句导致整个文件还没写完就提前关闭了连接。比如数据全发完了发送端立刻关闭Socket接收端还没有把缓冲区里残存的数据写完。这种“最后一截数据”丢失的现象在TCP里是常态保护机制要处理的。排查这个问题的正确姿势是先在发送端确认Socket关闭前是否收到了接收端的完成确认且接收端确认回包发出了。如果没有确认就关闭接收端可能丢失已进入Socket缓冲区但还没被应用层读取的数据。所以前面流程里我特地加了一个“接收端回完成确认”的环节就是为了防止这种提前关闭。另外一个隐藏的坑是文件名编码。如果文件名里带中文直接用Encoding.Default发送没问题因为两端都是Windows中文环境。但传输日志时可能会看到乱码。稳妥的做法是统一用UTF-8去编码文件名再发送接收端同样用UTF-8解码这样跨Windows语言环境的兼容性也更好。5.4 问题四多个文件并发传输时接收端文件相互覆盖我最初的Demo没有考虑并发后来给同事使用时有人同时传两个文件结果后到的文件把先到的文件覆盖了。原因是我在接收端用一个全局变量记录当前接收的文件名并发连接时这个变量被后来的连接覆盖。解决方式是为每个客户端连接分配一个独立的会话对象在会话对象内部维护文件名、临时文件路径、已经接收的长度。接收端虽然用同一个监听Socket但是每个Accept出来的连接都是独立线程和独立会话互不干扰。这个改动的逻辑量不大却让工具从Demo提升到了可用的生产工具级别。6. 深入优化链路大文件不掉速的进阶手段6.1 磁盘缓冲区与发送窗口的协同如果传输的文件很大比如超过1GB还需要考虑磁盘读取和网络发送的节奏协调。FileStream默认是同步读取一次Read返回后数据已经躺在内存里再调Send把数据推给Socket。这个“读一块发一块”的模式看起来没问题但磁盘IO和网络IO存在等待实际吞吐量可能只有理论值的一半。更高效的是用生产者/消费者模式开一个后台线程把文件预读到内存缓冲区列表里主发送线程从缓冲区列表里取数据发送。这样磁盘在读下一块的时候网络正在发上一块两个IO可以重叠。这就是常说的双缓冲能显著提高大文件的传输吞吐。我在生产工具里用的是生产者线程负责读文件到BlockingCollectionbyte[]消费者线程负责从队列取数据并调用Socket发送。BlockingCollection可以限制最大缓冲数比如最多缓冲64块避免内存占用过高。这个改动做下来千兆内网传一个1GB文件从原来的可能掉速到几十MB/s稳定跑到了80-110MB/s几乎贴满网卡上限。6.2 发送超时和心跳保活内网工具一般不怎么断线但偶尔也会遇到交换机重启或者网线松动。TCP天然自带超时重传机制但它的超时时间很长应用层可能要好几分钟才知道连接断了。尤其是传一段时间后网络恢复双方可能已经处于半死连接状态。我的习惯是在连接Socket上设置SendTimeout和ReceiveTimeout比如设为30秒。如果某个发送或接收操作超过30秒没有任何数据流动就抛出异常触发重连逻辑。同时再额外加一个心跳包发送端每隔10秒向接收端发送一个极短的消息接收端如果连续3次没收到心跳就判定发送端离线并清理会话状态。心跳包的实现也不复杂就在已有的消息头类型里加一个“心跳”类型即可。注意心跳包不需要等待接收端回确认因为TCP本身保证消息可靠到达。如果频繁心跳影响带宽可以把间隔调到30秒再配合3次超时判断已经足够用了。6.3 传输加密的取舍公司内网的威胁模型比较明确内部工具一般不需要加密。但如果你要把这个Demo放到稍有安全要求的环境建议至少在链路层加上TLS加密。C#里直接用SslStream包一层就行改动量比想象中小。不过SslStream对性能的影响是实打实的传大文件时CPU会有一份额外的加密开销。这里我不主张Demo阶段就引入TLS而是诚恳地说一句先跑通正确性再按业务需要加安全层。一旦确认需要上安全务必把证书管理、信任链校验一起考虑进去不要只套一个SslStream就当完事。公司内部可以使用自签证书但要在所有客户端机器上安装信任该证书否则握手直接失败。6.4 性能实测数据与参数参考我针对典型参数做了一轮实测验证测试环境是两台千兆网卡直连的机器中间隔一台普通交换机。分块大小传输1GB文件耗时CPU占用率内存峰值备注4KB约42秒24%35MB系统调用频繁CPU偏高16KB约28秒12%42MB均衡推荐默认64KB约26秒9%78MB吞吐略高内存占用略涨256KB约27秒8%210MB吞吐没有明显提升内存占用上升较快从上表可以看出分块大小超过64KB后内存占用明显上升但吞吐提升并不大这跟TCP窗口的大小有关。窗口不够大时应用层塞再多数据也只能排队不会提升网络利用率。所以我会建议多数场景固定在16KB到64KB之间不必盲目追求更大的块。接收缓冲区设置也有讲究Socket.ReceiveBufferSize可以把默认8KB调大一些例如设成64KB。这能减少操作系统到应用层的拷贝次数但也不要调得太大64KB以上对吞吐提升非常有限。发送端的SendBufferSize同理64KB是个稳妥值。实测下来接收端和发送端同时把这两个值调到64KB比全部保持默认配置时在百兆网络环境里提升了接近30%的吞吐。7. 一次失败实验的复盘为什么直接Send大数组必挂我在做这个小工具时第一次实验就是非常“聪明”地一次性把整个文件读进内存然后Send过去。当时传的是一个500MB的数据库备份文件。在32位系统上编译的程序直接抛了OutOfMemoryException换到64位系统后不抛异常了但界面直接卡死等了几分钟进度条才跳了一下。后来用网络抓包工具看数据实际上分成了几百个TCP段慢慢发送中间的等待非常长。我复盘后得出一个结论谈TCP传输、尤其是文件传输时永远不要在应用层把文件当成一个整体去Send。正确的抽象是“网络链路是水管程序不是把整桶水倒进去而是用杯子一杯一杯递出去”。这个比喻有点粗糙但很直观。16KB一个块就好比一个正好能握住的杯子。杯子太小递的次数太多浪费体力杯子太大手累还不方便。同理接收端也不要指望一次Receive能收到一个完整块。Socket的ReceiveSize和文件块大小不一定是同一个数所以消息头提供的数据包长度字段就有了意义解析器先用长度字段确认负载的完整字节数再决定是否交付给业务层。这个约定在整个协议里贯穿始终。8. 如何在这个Demo基础上扩展成真正可用的内部工具8.1 增加自动重试和任务队列单次传输示例只能解决“能不能传通”的问题。生产环境里用户更期望的是把文件拖进列表点一下开始就自动按顺序传谁断了就自动补传。我基于这个Demo扩展过一个带任务队列的版本每个传输任务都有独立状态字段待发送、发送中、成功、失败。后台有一个任务调度线程循环扫描队列遇到失败任务会尝试重连并续传。这个改造的关键是把前面提到的“握手阶段记录已接收长度改成明文确认”做成了真实逻辑。发送端在连接建立后先发一个“请求续传”消息附带文件名和总长度接收端检查本地是否已存在同名临时文件如果存在且长度小于总长度就返回该文件已有的字节数作为偏移量发送端从该偏移量继续读取并发送剩余部分。断点续传做出来之后传文件这个功能才真正可以说是生产可用的。8.2 增加MD5校验和传输日志文件传过去之后用户往往会问“我怎么知道文件是完整的”。TCP本身能保证传输过程中字节不丢不乱但不能保证磁盘有没有坏道、源文件本身是否损坏。所以我建议在发送端计算源文件的MD5值放到文件头消息里传过去接收端在文件写完后再计算一遍接收文件的MD5两边比对一致才算成功。这在新版本的工具里已经默认开启。不过计算MD5对大文件来说有点耗时1GB文件大概需要两三秒。如果对性能敏感可以换成计算文件的前1MB、中1MB、后1MB的哈希拼接后比较。虽然安全性低一些但内网传文件主要防的是“整包传错”这个方案性价比更高。传输日志则是为了事后审计。每个文件传输完成后记录文件名、大小、发送端IP、接收端IP、开始时间、结束时间、总耗时、校验结果。这些日志在排查问题时特别有用。尤其是有同事反馈“昨天传的文件今天打不开”有了日志就能快速定位是哪次传输、哪个文件、校验结果如何。8.3 并行分块传输的可能性如果你基于这个Demo继续往高性能方向折腾我会建议探究一下并行分块传输。原理是把一个文件切成N段每段从不同的偏移量去读然后通过N个TCP连接同时传。这个方案会极大提升吞吐因为TCP的多连接可以充分利用带宽而且避免单个连接遇到窗口满时发不动的窘境。但代价是复杂度明显上升需要维护每段传输的偏移量和长度、处理每段独立的断点续传、最终在接收端按偏移量把分片合并。以我现在这个Demo的复杂度来说暂时不会在代码里直接加这个能力。但如果你对性能要求极高而内网网络质量又非常好这是比调块大小更有效的进阶方案。要往这个方向走我建议先保持单连接传输稳定再逐步加多连接不要一上来就并行否则定位问题会把你的精力消耗得干干净净。9. 一个特殊的取舍内存池和对象复用后台传输工具如果常年在服务器上跑还需要考虑内存分配的稳定性。前面代码里每收到一个数据包都会新建一个字节数组来保存负载频繁大数组分配会持续给GC压力服务器跑一两天容易出现内存碎片。我在生产版本里把数据包解析改成了基于ArrayPoolbyte的对象池。接收端的暂存区固定复用一组16KB缓冲区业务层处理完某个包后会把缓冲区归还给池子下次接收直接取现成的。这个优化在Demo阶段可以不写但如果是给公司持续运行的服务用建议至少有所了解。NumberArrayPool还有个好处是能避免大对象堆LOH碎片化。16KB以上的数组在.NET里会进大对象堆而大对象堆不会像小对象堆那样高效压缩碎片化后内存占用越来越高最终影响整个进程的稳定性。如果分块是16KB正好卡在大对象堆阈值上虽然进了LOH但因为它数量可控、定期被回收反而还好。这也是我坚持用16KB作为分块大小的另一个理由。10. C#实现TCP文件传输的个人体会说实话这个Demo前前后后我改过很多次。最初只是想验证Socket收发的正确性后来逐步加入消息头、分块、进度、断点续传、并发会话、MD5校验最终变成内部多人使用的小工具。这一路下来最深的体会就是TCP传输的代码不难懂真正难的是对“边界”的敬畏。文件有边界Socket缓冲区有边界磁盘读写速率有边界UI线程的处理能力也有边界。每一个边界都可能成为你Debug不出结果时的元凶。如果让我给新手一个最朴素的建议就是先把消息头和粘包解析彻底吃透再动手做文件字节流的发送。这个基本功过关了文件传输无非就是往解析器里塞不同的负载类型。反过来一上来就用最简单的流式发送把文件推过去短期看像是跑通了但任何一点异常都会让你手足无措。最后再分享一个小技巧如果你也在WinForm里做Socket调试建议把服务端的监听端口固定下来而客户端的连接端口设为服务器上某一个配置值。不要把端口号写死在代码里因为公司内部如果有多台机器同时跑这个工具端口冲突会非常快暴露出来。把端口提到配置里五分钟改完的事省下来的是未来一晚上的排查时间。