ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

C# TCP文件传输实战:从粘包半包到大文件分块与多客户端并发

C# TCP文件传输实战:从粘包半包到大文件分块与多客户端并发

1. 为什么还要聊TCP传文件这件事

你可能觉得,TCP传文件不是早就烂大街了吗?随便搜一下,Socket、FileStream、NetworkStream,三下五除二就能拼出一个能跑的Demo。但真正在生产环境里跑过文件传输的人都知道,能跑和跑得稳之间,隔着一整个太平洋。粘包、半包、大文件内存溢出、传输进度不更新、断线重连、多客户端并发写同一个文件……每一个坑都能让你加班到凌晨。

我这次拿到的项目标题是“使用TCP协议传输文件 C#(Demo全部代码)”,核心诉求很明确:用C#写一个基于TCP的文件传输Demo,并且代码要完整可复现。但我不想只给你一个“能跑就行”的版本,因为那种代码你在任何一本入门书里都能翻到。我想做的是一个结构清晰、能处理大文件、带进度反馈、支持多客户端、有基本异常处理的完整方案,同时把每一步为什么这么写讲透。

这篇文章适合谁看?如果你已经写过简单的Socket通信,但对文件传输的边界情况心里没底,那正好。如果你是完全没碰过Socket的新手,也没关系,我会从最基础的概念开始铺垫,用生活化的类比把TCP的流式传输讲清楚。全文的代码都是可以直接复制到Visual Studio里跑起来的,服务端和客户端各一个控制台项目,不需要任何第三方库,纯.NET原生实现。

先说清楚这个Demo能做什么:服务端监听指定端口,客户端连接后选择本地文件发送,服务端接收并保存到指定目录,双方都能看到传输进度和传输结果。它解决了“文件从A传到B”这个最朴素的需求,同时通过分块读写、长度前缀协议、异步编程等手段,避免了内存爆炸和数据错乱。下面我会从整体设计思路开始,一步步拆到每一行关键代码。

2. 整体设计思路与协议约定

2.1 为什么选TCP而不是UDP

文件传输的第一决策就是传输层协议选谁。TCP和UDP的区别,用寄快递来类比特别直观。UDP就像你把一箱玻璃杯直接扔到传送带上,不保证顺序、不保证到达、不保证完整,但速度快、开销小。TCP则像快递公司给你每一件包裹编号、签收、丢件重发,保证你收到的玻璃杯和寄出时一模一样,顺序也不会乱。

文件传输的核心诉求是数据完整性,一个字节都不能错、不能少、不能多。所以TCP是天然选择。UDP只在极少数场景下用于文件传输,比如内网视频流这种允许丢帧的场景,或者你自己在应用层实现可靠传输协议。对于这个Demo,TCP是唯一合理的选择。

2.2 应用层协议怎么定:长度前缀法

TCP是流式协议,它只保证字节流的顺序,不保证你发一次Send对方就收一次Receive。这就引出了两个经典问题:粘包和半包。

粘包是指你发了两次数据,对方一次Receive就把两段都读出来了。半包是指你发了一段数据,对方一次Receive只读到了一部分。这两个问题不是TCP的bug,而是流式协议的本质特征。解决它们的标准做法就是在应用层定义消息边界。

常见方案有三种:固定长度、分隔符、长度前缀。固定长度太浪费空间,分隔符需要转义处理且不适合二进制文件。所以这个Demo采用长度前缀法:每次发送数据块之前,先发送一个固定长度的头部,头部里包含接下来要发送的数据长度。接收方先读固定长度的头部,解析出数据长度,再精确读取那么多字节。

具体协议设计如下:

阶段发送方行为接收方行为
握手发送文件名长度(4字节int)+ 文件名(UTF8字节)读取4字节得到文件名长度,再读取对应字节得到文件名
握手发送文件总长度(8字节long)读取8字节得到文件总长度
数据传输循环:发送块长度(4字节int)+ 块数据循环:读取4字节得到块长度,再读取对应字节写入文件
结束发送块长度0表示结束读到块长度0时停止接收

这个协议简单、高效、无歧义。4字节int最大能表示约2GB的单块长度,8字节long能表示的文件总长度上限是9EB,实际使用完全够用。

2.3 分块传输:解决大文件内存问题

如果你把整个文件读进byte[]再一次性发送,一个2GB的文件就会直接让进程内存爆掉。所以必须分块读写。我选择的块大小是81920字节(80KB),这个数字不是随便拍的。

在.NET中,Stream.CopyTo的默认缓冲区大小就是81920字节。这个值在大多数网络环境下表现良好:太小会导致系统调用次数过多,CPU在用户态和内核态之间频繁切换;太大则会增加单次发送的延迟,且占用更多内存。80KB是一个经过大量实践验证的平衡点。当然你可以根据实际网络带宽和延迟调整,比如内网高带宽场景可以调到256KB甚至1MB。

分块传输的另一个好处是进度可计算。每发送一块,已发送字节数增加,除以文件总长度就是进度百分比。这让客户端可以实时显示进度条,用户体验直接拉满。

2.4 异步编程模型的选择

C#里做Socket通信,同步和异步都能用。同步代码写起来直观,但会阻塞线程。一个客户端还好,如果服务端要同时处理多个客户端,同步模型就需要为每个客户端开一个线程,线程上下文切换的开销在客户端数量多时会非常明显。

所以这个Demo全程使用async/await异步模型。NetworkStream.ReadAsync和WriteAsync底层基于IOCP(I/O完成端口),不会阻塞线程池线程。服务端用Task.Run为每个客户端启动独立的处理任务,配合CancellationToken实现优雅关闭。这样即使有几十个客户端同时传输,服务端也不会卡死。

注意:异步方法中不要混用同步的Read和Write,否则可能导致死锁。要么全异步,要么全同步,不要一半一半。

3. 服务端核心实现细节

3.1 监听与接受连接的完整流程

服务端的启动流程很固定:创建TcpListener、绑定端口、开始监听、循环接受客户端连接。但这里面有几个细节值得展开。

首先是端口选择。Demo里我用8888,实际部署时建议用1024以上的端口,避免和系统服务冲突。绑定地址用IPAddress.Any表示监听所有网卡,如果只想本机测试可以用IPAddress.Loopback。

var listener = new TcpListener(IPAddress.Any, 8888); listener.Start(); Console.WriteLine("服务端已启动,等待客户端连接..."); while (true) { var client = await listener.AcceptTcpClientAsync(); _ = Task.Run(() => HandleClientAsync(client)); }

这里AcceptTcpClientAsync返回一个TcpClient对象,代表一个已连接的客户端。我用Task.Run把它丢到线程池里处理,这样主循环可以立刻回去接受下一个连接。_ =是C#的丢弃操作符,表示我不关心这个Task的返回值,因为异常会在HandleClientAsync内部处理。

实操心得:AcceptTcpClientAsync本身是异步的,但如果你在循环里直接await它,主线程会在这里挂起等待新连接。用Task.Run包装处理逻辑后,主循环的await只负责接受连接,处理逻辑并行执行,这是高并发服务端的标准写法。

3.2 文件元数据的接收与解析

客户端连接后的第一件事是发送文件元数据:文件名和文件总长度。服务端需要精确地按协议读取。

读取文件名长度用ReadInt32Async,这是我封装的一个扩展方法,确保读满4个字节。为什么强调“读满”?因为NetworkStream.ReadAsync返回的字节数可能小于请求的字节数,这是TCP流式协议的正常行为。如果你直接ReadAsync(buffer, 0, 4)然后就用buffer里的数据,很可能只读到了2个字节,剩下2个字节还是旧的垃圾数据。

static async Task<int> ReadInt32Async(NetworkStream stream, CancellationToken token) { var buffer = new byte[4]; await ReadExactAsync(stream, buffer, 0, 4, token); return BitConverter.ToInt32(buffer, 0); } static async Task ReadExactAsync(NetworkStream stream, byte[] buffer, int offset, int count, CancellationToken token) { int totalRead = 0; while (totalRead < count) { int read = await stream.ReadAsync(buffer, offset + totalRead, count - totalRead, token); if (read == 0) throw new IOException("连接已关闭"); totalRead += read; } }

ReadExactAsync是整套代码的基石。它循环读取直到读满指定字节数,如果中途read == 0说明对方关闭了连接,直接抛异常。这个方法是解决半包问题的关键,所有固定长度的读取都必须走它。

文件名读取完后,接着读8字节的文件总长度。这里用BitConverter.ToInt64转换。拿到文件名和总长度后,服务端就可以创建文件写入流了。

3.3 分块接收与写入的循环逻辑

数据接收是一个循环:读4字节块长度,如果为0则结束;否则读对应长度的数据块,写入文件流。

var filePath = Path.Combine(saveDir, fileName); using var fileStream = new FileStream(filePath, FileMode.Create, FileAccess.Write, FileShare.None, 81920, useAsync: true); long totalReceived = 0; var buffer = new byte[81920]; while (true) { int blockLength = await ReadInt32Async(stream, token); if (blockLength == 0) break; await ReadExactAsync(stream, buffer, 0, blockLength, token); await fileStream.WriteAsync(buffer, 0, blockLength, token); totalReceived += blockLength; double progress = (double)totalReceived / fileTotalLength * 100; Console.WriteLine($"接收进度: {progress:F2}%"); }

这里有几个关键点。第一,FileStream的构造函数里useAsync: true必须设置,否则WriteAsync底层会退化成同步写入,失去异步的意义。第二,缓冲区复用同一个byte[],避免每次循环都分配新数组,减少GC压力。第三,块长度理论上不会超过81920,因为客户端就是按这个大小切的,但为了健壮性,服务端可以加一个校验:如果blockLength > buffer.Length就拒绝或扩容。

注意:FileShare.None表示独占文件。如果多个客户端同时传同名文件,第二个会直接抛异常。实际项目中你可能需要给文件名加时间戳或GUID前缀来避免冲突,或者用FileShare.Read允许其他进程读取。

3.4 多客户端并发处理的资源隔离

每个客户端连接都会创建一个独立的Task,任务内部有独立的NetworkStream、FileStream和缓冲区。这些资源都是方法内的局部变量,天然隔离,不会互相干扰。但有一个共享资源需要注意:保存目录。

如果两个客户端同时传同名文件,FileMode.Create会覆盖,FileShare.None会冲突。我的处理方式是在保存目录下为每个连接创建一个子目录,用连接时间戳或GUID命名。这样即使文件名相同也不会冲突。

var sessionDir = Path.Combine(saveDir, DateTime.Now.ToString("yyyyMMdd_HHmmss_fff")); Directory.CreateDirectory(sessionDir); var filePath = Path.Combine(sessionDir, fileName);

这个做法还有一个好处:方便排查问题。每个连接的文件都独立存放,传输失败时不会污染其他连接的数据。

4. 客户端核心实现细节

4.1 连接建立与文件选择

客户端流程比服务端简单:连接服务端、选择文件、发送元数据、分块发送、等待完成。

var client = new TcpClient(); await client.ConnectAsync("127.0.0.1", 8888); var stream = client.GetStream(); Console.Write("请输入要发送的文件路径: "); string filePath = Console.ReadLine(); if (!File.Exists(filePath)) { Console.WriteLine("文件不存在"); return; } var fileInfo = new FileInfo(filePath); string fileName = fileInfo.Name; long fileLength = fileInfo.Length;

这里用ConnectAsync而不是Connect,避免UI线程阻塞。文件路径从控制台读取,实际项目中你可以用文件选择对话框。

4.2 元数据发送与编码选择

文件名需要转成字节数组发送。编码选择上,Encoding.UTF8是首选,因为它支持中文文件名且跨平台一致。注意UTF8编码的中文文件名长度不固定,所以必须先发长度再发内容。

byte[] fileNameBytes = Encoding.UTF8.GetBytes(fileName); await stream.WriteAsync(BitConverter.GetBytes(fileNameBytes.Length), 0, 4); await stream.WriteAsync(fileNameBytes, 0, fileNameBytes.Length); await stream.WriteAsync(BitConverter.GetBytes(fileLength), 0, 8);

BitConverter.GetBytes把int和long转成小端序字节数组。服务端用BitConverter.ToInt32和ToInt64解析,两端字节序一致,没有问题。如果你要跨平台和不同架构的机器通信,需要注意大端小端问题,但x86和ARM在.NET运行时都是小端序,实际不会出问题。

4.3 分块发送与进度计算

发送循环和服务端接收循环对称:读一块文件数据,发块长度,发块数据,直到文件读完,最后发一个0表示结束。

using var fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read, 81920, useAsync: true); var buffer = new byte[81920]; long totalSent = 0; int bytesRead; while ((bytesRead = await fileStream.ReadAsync(buffer, 0, buffer.Length)) > 0) { await stream.WriteAsync(BitConverter.GetBytes(bytesRead), 0, 4); await stream.WriteAsync(buffer, 0, bytesRead); totalSent += bytesRead; double progress = (double)totalSent / fileLength * 100; Console.WriteLine($"发送进度: {progress:F2}%"); } await stream.WriteAsync(BitConverter.GetBytes(0), 0, 4); Console.WriteLine("文件发送完成");

fileStream.ReadAsync返回实际读取的字节数,最后一次可能小于缓冲区大小。所以发送块长度时用的是bytesRead而不是buffer.Length,这一点非常关键。如果你发的是buffer.Length,服务端会多读垃圾数据,文件就损坏了。

实操心得:进度计算用double而不是int,否则小文件会一直显示0%。F2格式化保留两位小数,显示效果比较友好。如果你要做进度条,可以把百分比映射到控制台宽度,用#和-画一个简单的文本进度条。

4.4 异常处理与连接关闭

网络编程中异常是常态,不是意外。连接断开、对方强制关闭、网络超时,这些都必须处理。

try { // 发送逻辑 } catch (IOException ex) { Console.WriteLine($"网络异常: {ex.Message}"); } catch (Exception ex) { Console.WriteLine($"未知异常: {ex.Message}"); } finally { stream?.Close(); client?.Close(); }

finally块确保无论成功还是失败,连接都会被关闭。NetworkStream.Close会同时关闭底层的TcpClient,但显式关闭TcpClient更保险。实际项目中你还可以加超时控制,用CancellationTokenSource配合CancelAfter实现。

5. 常见问题与排查技巧实录

5.1 粘包半包问题速查表

现象原因解决方案
文件名乱码或包含多余字符半包,文件名没读满用ReadExactAsync读满指定长度
文件内容错位、损坏粘包,块长度和数据不对应严格按“长度前缀+数据”协议读写
服务端读到0字节后卡死客户端已关闭连接ReadExactAsync中检测read==0并抛异常
大文件传输内存暴涨一次性读入整个文件分块读写,缓冲区复用
进度显示超过100%块长度计算错误发送时用bytesRead而非buffer.Length

5.2 大文件传输的性能调优

80KB的块大小在千兆内网下能跑满带宽,但在高延迟广域网环境下,可能需要调大块大小来减少往返次数。你可以做一个简单的测试:传一个1GB的文件,分别用80KB、256KB、1MB的块大小,记录耗时。

另一个优化点是禁用Nagle算法。TCP默认会攒小包一起发,这在文件传输场景下会增加延迟。设置client.NoDelay = true可以让每个块立即发送。对于80KB这种大块,Nagle算法影响不大,但如果你把块调得很小,这个设置就很重要。

client.NoDelay = true;

5.3 断线重连与断点续传的思路

这个Demo没有实现断点续传,但思路可以简单说一下。断点续传的核心是记录已传输的偏移量,重连后从该偏移量继续。协议上需要增加一个“续传请求”消息,包含文件名和已传字节数。服务端检查文件是否存在、已传字节数是否匹配,然后从对应位置开始追加写入。

实现断点续传的难点在于状态管理:服务端需要为每个文件维护传输状态,客户端需要持久化已传进度。对于Demo来说这过于复杂,但如果你要做生产级工具,这是必须考虑的功能。

注意:断点续传的文件校验很重要。如果客户端记录的已传字节数和服务端文件实际大小不一致,必须从头开始传,否则文件会损坏。通常用MD5或SHA256做完整性校验。

5.4 防火墙与端口占用的排查

服务端启动时报“地址已在使用”,说明端口被占用了。用netstat -ano | findstr 8888找到占用进程的PID,然后在任务管理器里结束它。或者直接换一个端口。

客户端连不上服务端,先检查服务端是否真的在监听。用telnet 127.0.0.1 8888测试端口通不通。如果telnet不通,可能是防火墙拦截了。Windows防火墙默认会拦截入站连接,你需要在“高级安全Windows Defender防火墙”里为你的程序添加一条入站规则,允许TCP 8888端口。

还有一个容易忽略的点:如果服务端和客户端不在同一台机器,客户端连接时用的IP必须是服务端的局域网IP或公网IP,不能用127.0.0.1。127.0.0.1永远指向本机。

6. 完整代码结构与关键扩展点

6.1 服务端完整代码骨架

using System; using System.IO; using System.Net; using System.Net.Sockets; using System.Text; using System.Threading; using System.Threading.Tasks; class FileServer { static async Task Main(string[] args) { string saveDir = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "ReceivedFiles"); Directory.CreateDirectory(saveDir); var listener = new TcpListener(IPAddress.Any, 8888); listener.Start(); Console.WriteLine($"服务端启动,保存目录: {saveDir}"); while (true) { var client = await listener.AcceptTcpClientAsync(); _ = Task.Run(() => HandleClientAsync(client, saveDir)); } } static async Task HandleClientAsync(TcpClient client, string saveDir) { var token = CancellationToken.None; try { using (client) using (var stream = client.GetStream()) { int nameLen = await ReadInt32Async(stream, token); var nameBytes = new byte[nameLen]; await ReadExactAsync(stream, nameBytes, 0, nameLen, token); string fileName = Encoding.UTF8.GetString(nameBytes); long fileLength = await ReadInt64Async(stream, token); var sessionDir = Path.Combine(saveDir, DateTime.Now.ToString("yyyyMMdd_HHmmss_fff")); Directory.CreateDirectory(sessionDir); var filePath = Path.Combine(sessionDir, fileName); using var fileStream = new FileStream(filePath, FileMode.Create, FileAccess.Write, FileShare.None, 81920, useAsync: true); var buffer = new byte[81920]; long totalReceived = 0; while (true) { int blockLength = await ReadInt32Async(stream, token); if (blockLength == 0) break; await ReadExactAsync(stream, buffer, 0, blockLength, token); await fileStream.WriteAsync(buffer, 0, blockLength, token); totalReceived += blockLength; double progress = (double)totalReceived / fileLength * 100; Console.WriteLine($"接收 {fileName}: {progress:F2}%"); } Console.WriteLine($"文件接收完成: {filePath}"); } } catch (Exception ex) { Console.WriteLine($"处理客户端时出错: {ex.Message}"); } } static async Task<int> ReadInt32Async(NetworkStream stream, CancellationToken token) { var buffer = new byte[4]; await ReadExactAsync(stream, buffer, 0, 4, token); return BitConverter.ToInt32(buffer, 0); } static async Task<long> ReadInt64Async(NetworkStream stream, CancellationToken token) { var buffer = new byte[8]; await ReadExactAsync(stream, buffer, 0, 8, token); return BitConverter.ToInt64(buffer, 0); } static async Task ReadExactAsync(NetworkStream stream, byte[] buffer, int offset, int count, CancellationToken token) { int totalRead = 0; while (totalRead < count) { int read = await stream.ReadAsync(buffer, offset + totalRead, count - totalRead, token); if (read == 0) throw new IOException("连接已关闭"); totalRead += read; } } }

6.2 客户端完整代码骨架

using System; using System.IO; using System.Net.Sockets; using System.Text; using System.Threading.Tasks; class FileClient { static async Task Main(string[] args) { Console.Write("服务端IP (默认127.0.0.1): "); string ip = Console.ReadLine(); if (string.IsNullOrWhiteSpace(ip)) ip = "127.0.0.1"; Console.Write("服务端端口 (默认8888): "); string portStr = Console.ReadLine(); int port = string.IsNullOrWhiteSpace(portStr) ? 8888 : int.Parse(portStr); Console.Write("要发送的文件路径: "); string filePath = Console.ReadLine(); if (!File.Exists(filePath)) { Console.WriteLine("文件不存在"); return; } var fileInfo = new FileInfo(filePath); string fileName = fileInfo.Name; long fileLength = fileInfo.Length; using var client = new TcpClient(); client.NoDelay = true; try { await client.ConnectAsync(ip, port); using var stream = client.GetStream(); byte[] nameBytes = Encoding.UTF8.GetBytes(fileName); await stream.WriteAsync(BitConverter.GetBytes(nameBytes.Length), 0, 4); await stream.WriteAsync(nameBytes, 0, nameBytes.Length); await stream.WriteAsync(BitConverter.GetBytes(fileLength), 0, 8); using var fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read, 81920, useAsync: true); var buffer = new byte[81920]; long totalSent = 0; int bytesRead; while ((bytesRead = await fileStream.ReadAsync(buffer, 0, buffer.Length)) > 0) { await stream.WriteAsync(BitConverter.GetBytes(bytesRead), 0, 4); await stream.WriteAsync(buffer, 0, bytesRead); totalSent += bytesRead; double progress = (double)totalSent / fileLength * 100; Console.WriteLine($"发送进度: {progress:F2}%"); } await stream.WriteAsync(BitConverter.GetBytes(0), 0, 4); Console.WriteLine("发送完成"); } catch (Exception ex) { Console.WriteLine($"发送失败: {ex.Message}"); } } }

6.3 可以继续扩展的方向

这个Demo是一个起点,不是终点。如果你想把它做成真正能用的工具,可以考虑这几个方向。

多文件传输:在协议里增加文件数量字段,循环发送多个文件。每个文件之间用结束标记分隔。

压缩传输:发送前用GZipStream压缩,接收后解压。对于文本类文件,压缩率通常能达到70%以上,传输时间大幅缩短。但压缩本身消耗CPU,需要权衡。

加密传输:用SslStream包装NetworkStream,实现TLS加密。这样文件内容在网络上是密文,防止被窃听。SslStream的用法和NetworkStream几乎一样,替换掉底层流即可。

传输队列:客户端维护一个待发送文件队列,依次发送,失败自动重试。这需要更复杂的状态管理,但能大幅提升可靠性。

图形界面:用WinForms或WPF做一个简单的界面,文件列表、进度条、日志窗口。控制台版本适合调试,图形界面适合最终用户。

实操心得:扩展功能时,建议先把协议设计清楚再写代码。协议是双方通信的契约,协议改一次,两端都要改。我习惯用一张表格把每个字段的偏移、长度、含义列出来,写代码时对照表格,不容易出错。

7. 一些踩过的坑和真实体会

第一次写TCP传文件的时候,我犯了一个很蠢的错误:客户端发送文件名时用了stream.Write(nameBytes, 0, nameBytes.Length),但没有先发长度。服务端用stream.Read(buffer, 0, 1024)去读,结果读到了文件名加后面的一堆数据。文件名后面跟着文件长度和文件内容,全被当成文件名了。这个bug让我调试了整整一个下午,最后用Wireshark抓包才看明白。

后来我养成了一个习惯:任何通过TCP发送的数据,都必须有明确的边界。要么定长,要么带长度前缀,要么用分隔符。没有边界的协议就是在给自己挖坑。

另一个坑是NetworkStream.ReadAsync的返回值。我一开始以为它一定会读满请求的字节数,后来发现它可能只读一部分。这个行为在局域网低负载时很少出现,但在跨公网或高负载时非常常见。ReadExactAsync这个封装方法看起来简单,但它是整个Demo稳定性的基石。没有它,文件传输在弱网环境下必然出错。

还有一个体会是关于异步的。async/await写起来很舒服,但如果你在异步方法里调用了同步的Thread.Sleep或者.Result,就会阻塞线程,甚至死锁。我见过有人在ReadAsync后面跟.Result,结果整个程序卡死。记住一个原则:异步方法里只用异步调用,不要混用同步阻塞。

最后说一个性能相关的观察。在千兆内网下,这个Demo传输1GB文件大约需要10到12秒,平均速度在85MB/s到100MB/s之间,基本跑满了千兆带宽。瓶颈不在代码,而在网络本身。如果你用万兆网卡,可以把块大小调到1MB,速度还能再提升。但块大小不是越大越好,太大会增加单次传输的延迟,且占用更多内存。80KB到256KB是一个比较稳妥的范围。

这个Demo的代码我反复改了好几版,每一版都在解决上一版暴露的问题。从最初的“能跑就行”,到现在的“边界清晰、异常可控、进度可见”,中间踩的坑都变成了经验。如果你照着这篇文章的代码敲一遍,再自己改改参数、加加功能,对TCP文件传输的理解会比看十篇教程都深。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进