C# Socket网络编程实战:从服务端到多客户端并发与粘包处理 C#里聊网络通信十次有八次会落到Socket这个话题上。前两天有个做设备调试的朋友问我两台工控机之间想在局域网里传指令用什么方案最省事我说把Socket跑通就够了。他照着网上的教程抄了一段代码服务端监听、客户端连接都能建立但真正收发数据的时候各种莫名其妙消息发过去收不到、程序一跑就卡死、客户端一关服务端就报错。这其实不是他运气差而是Socket通信里面那些藏在API背后的细节教程通常不会展开讲。这篇文章我把一个能跑通的C# Socket服务端和客户端从创建Socket到多客户端并发从粘包处理到异常排查完整拆开讲一遍。内容以原生Socket类为主线附上可直接复制运行的代码关键位置会解释为什么这么写、以及换了写法会踩什么坑。适合刚接触网络编程的C#新手也适合做上位机、设备通信但没系统捋过Socket原理的开发者。1. 为什么我坚持从原生Socket讲起技术选型背后的取舍1.1 Socket在通信体系里的位置很多人上来就急着写代码结果连自己写的程序在跟谁说话、数据走了一条什么路都不清楚。我习惯先画一条线应用层 - Socket - 传输层(TCP/UDP) - IP层 - 链路层。我们写的C#程序处在最上面Socket是操作系统提供给应用层的编程接口它帮你屏蔽掉底下TCP三次握手、IP分片、路由转发这些乱七八糟的过程。打个比方Socket就是一排电话插孔。服务端把自己的电话插孔IP端口插好并挨个接听客户端拿起电话拨号Connect拨通了就说话Send/Receive说完挂断Close。这个类比能解释后面90%的代码逻辑。C#里做网络通信入门路径有三条直接用System.Net.Sockets.Socket类、用包装好的TcpListener/TcpClient、或者上第三方框架如SuperSocket、SignalR。我的建议是第一条路起步哪怕你最终要用框架也先把原生API跑明白因为项目中遇到消息错乱、连接断开、内存泄漏这些问题时能帮你定位问题的还是对原生API的理解。1.2 TCP和UDP的取舍先决策再编码服务端和客户端之间用什么协议通信这是开工前就要定的事。我直接给一个对比清单维度TCPUDP连接面向连接先建立可靠通道无连接发出去就不管可靠性可靠传输丢包重传不可靠丢了就丢了数据边界字节流没有消息边界数据报保留边界时序有序到达可能乱序速度相对慢有握手开销快无握手适用场景指令请求、文件传输、数据上报音视频流、心跳、广播我的经验是除非你有明确的实时性要求比如音视频推流、高频率传感器广播否则一律用TCP。TCP代码虽然比UDP多了连接管理的几行但省下的心是几何级数的。你不需要处理丢包重发、不需要拼序、不需要担心自己发的报文被网络切成碎片后怎么重组这些TCP都帮你干了。1.3 原生Socket、TcpListener和第三方框架怎么选TcpListener/TcpClient本质上是对原生Socket的轻量封装比如TcpClient内部就是持有一个Socket实例把连接、流式读取包装成了更友好的API。但它的封装也带来一些盲区你不太容易直接控制Send/Receive的底层行为排查问题时反而多隔了一层。第三方框架如SuperSocket确实功能齐全断线重连、协议解析、连接池全都替你做好了但如果你是新手直接用框架遇到问题会非常痛苦——你不知道它是怎么管理连接的也不知道粘包问题它帮你处理到哪一步。所以我在这篇文章里选了原生Socket类同步阻塞模式 每连接一线程。这个组合虽然在高并发场景下不算最优但它最直观、最好理解跑通之后你再看任何异步模型和框架都会轻松得多。2. 服务端代码逐段拆解从Bind到Accept再到Receive2.1 创建Socket地址族、套接字类型和协议怎么组合服务端第一行代码就是创建Socket实例Socket listenSocket new Socket( AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);三个参数的含义分别是AddressFamily.InterNetwork使用IPv4地址。如果要用IPv6则写InterNetworkV6。绝大多数局域网通信用IPv4就行但注意如果你的机器同时开了IPv6某些情况下域名解析会优先返回IPv6地址这时候连接本地服务端可能失败我后面调试章节会提到这个坑。SocketType.Stream流式套接字对应TCP。它保证数据是按顺序、无重复、不丢失的字节流。如果是UDP这里写SocketType.Dgram。ProtocolType.Tcp明确使用TCP协议。这三个参数必须匹配不能出现Stream Udp这种组合。很多新手随机组合之后发现创建Socket就抛异常就是因为协议类型和套接字类型对不上。注意Socket本身不是连接。它只是一个通信的端点。服务端的Socket和客户端的Socket都要各自创建然后通过IP和端口建立联系。2.2 Bind和Listen端口是怎么占住的创建完Socket之后服务端要绑定地址和端口IPEndPoint endPoint new IPEndPoint(IPAddress.Any, 8888); listenSocket.Bind(endPoint); listenSocket.Listen(10);IPAddress.Any表示监听本机所有网卡上的8888端口。如果你只想让本机自己调试可以用IPAddress.Loopback127.0.0.1如果只允许局域网某个特定IP连入就填那个具体的IP。调试阶段的建议是先用IPAddress.Any等程序稳定了再收紧。Listen(10)里面的参数叫backlog是等待Accept处理的连接队列长度。简单说它决定了还来得及被Accept的连接最多能排多少队。给0或1的话多个客户端同时发起连接时后到的会直接被拒绝。我习惯在局域网环境给100左右既不浪费资源也不会让客户端遇到连接被重置。这里还有一个常见的理解误区Bind不产生连接Listen也不产生连接。Bind只是告诉操作系统我要在这个IP端口上接收数据Listen是告诉它有人敲门先排队。真正的连接是在Accept之后才建立的。2.3 Accept是阻塞的程序会卡住的原因写服务端最容易产生的一个困惑是我明明监听了端口程序却像死了一样一动不动。原因就是这句Socket clientSocket listenSocket.Accept();Accept是阻塞方法它会让当前线程停在这里直到有一个客户端连接进来才返回。返回的clientSocket是一个全新创建的Socket专门用于和那个客户端通信而listenSocket继续干它监听的事。这里有两个关键点阻塞的不是整个程序只是当前线程。如果你在主线程里调用Accept表现就是整个程序卡住如果你放在后台线程或Task里界面和主逻辑照常运行。Accept返回的Socket和listenSocket不是同一个对象。很多人误以为服务端一直用一个Socket收发所有数据不是的后面讲多客户端并发时还会再强调。在.NET里还有一套基于SocketAsyncEventArgs的异步Accept方案性能更好适合高并发。但初学者先把同步Accept跑通异步模型本质上仍是在处理Accept - 处理 - 再Accept这条逻辑只是把阻塞换成了回调通知。2.4 循环接收数据Receive返回值是理解TCP的钥匙拿到clientSocket之后核心就是收发数据byte[] buffer new byte[1024]; int length clientSocket.Receive(buffer);Receive方法会阻塞等待数据到达然后返回收到的字节数。有三个细节必须讲透buffer是接收缓冲区你可以把它理解成一个水杯Receive负责往里面倒水返回的length就是这次倒进来的水量。TCP是字节流没有消息边界。这次Receive拿到的可能只是对方一条消息的前半截也可能把对方两三条消息全装进来了。这就是粘包/半包问题的根源后面第七章单独讲。Receive返回0代表对方已经优雅地关闭了连接。这是判断客户端走没走的最可靠信号比依赖异常来判断要干净得多。完整的最小服务端代码长这样你新建一个控制台项目直接粘贴就能跑using System; using System.Net; using System.Net.Sockets; using System.Text; using System.Threading; class TcpServer { public static void Main() { Socket listenSocket new Socket( AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); IPEndPoint endPoint new IPEndPoint(IPAddress.Any, 8888); listenSocket.Bind(endPoint); listenSocket.Listen(10); Console.WriteLine($服务端已启动监听端口: 8888); while (true) { Socket clientSocket listenSocket.Accept(); Console.WriteLine($客户端接入: {clientSocket.RemoteEndPoint}); Thread handlerThread new Thread(() HandleClient(clientSocket)); handlerThread.IsBackground true; handlerThread.Start(); } } private static void HandleClient(Socket clientSocket) { try { byte[] buffer new byte[1024]; while (true) { int length clientSocket.Receive(buffer); if (length 0) { Console.WriteLine(客户端优雅关闭连接); break; } string message Encoding.UTF8.GetString(buffer, 0, length); Console.WriteLine($收到: {message}); byte[] response Encoding.UTF8.GetBytes($服务端已收到: {message}); clientSocket.Send(response); } } catch (SocketException ex) { Console.WriteLine($连接异常: {ex.SocketErrorCode}); } finally { clientSocket.Close(); Console.WriteLine(连接已释放); } } }这段程序已经能应付单客户端和简单多客户端场景了。你不要急着优化先把它的运行逻辑在脑子里走一遍Accept阻塞等待新连接 - 每来一个连接就开一个线程处理 - 处理线程里循环Receive直到客户端离开。记住这个模型后面的所有东西都是在这个基础上的升级。3. 客户端侧的核心链路连接、发送、优雅关闭的三个细节3.1 客户端的四步走创建、连接、发送、接收客户端的创建和服务端一样区别在于它不Bind、不Listen而是直接Connect到服务端的IP和端口。核心代码只有四步using System; using System.Net; using System.Net.Sockets; using System.Text; class TcpClientDemo { public static void Main() { Socket clientSocket new Socket( AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); IPEndPoint serverEndPoint new IPEndPoint(IPAddress.Parse(127.0.0.1), 8888); clientSocket.Connect(serverEndPoint); Console.WriteLine(已连接服务端); byte[] data Encoding.UTF8.GetBytes(你好服务端); clientSocket.Send(data); byte[] buffer new byte[1024]; int length clientSocket.Receive(buffer); Console.WriteLine($服务端响应: {Encoding.UTF8.GetString(buffer, 0, length)}); clientSocket.Shutdown(SocketShutdown.Both); clientSocket.Close(); } }把127.0.0.1换成局域网里服务端机器的IP比如192.168.1.100客户端就能跨机器连接。这里有个新手很容易绕进去的问题Connect到底在干什么它不只是发个包告诉服务端我要来了而是会触发TCP的三次握手。握手成功Connect才返回握手失败比如端口没人监听Connect会抛异常。这一步是阻塞的而且默认没有超时时间如果网络不通你的客户端可能会卡在Connect上好几分钟。解决方案是设置clientSocket.ReceiveTimeout 3000; clientSocket.SendTimeout 3000;或者在调用Connect之前先用Task.Run(() clientSocket.Connect(serverEndPoint))包一层再配合Task.WhenAny实现手动超时。我通常在关键项目里两者都用。3.2 Send的返回值你以为发完了其实不一定Send方法有一个容易忽略的返回值——它返回实际发送到系统缓冲区的字节数。对TCP来说在局域网这种好网络环境下你一次性Send整个缓冲区返回值通常和缓冲区长度相等。但在高负载、或发送缓冲区快满的极端情况下Send可能只发送了一部分数据。严谨的写法是循环发送private static void SendAll(Socket socket, byte[] data) { int offset 0; while (offset data.Length) { int sent socket.Send(data, offset, data.Length - offset, SocketFlags.None); if (sent 0) throw new SocketException((int)SocketError.ConnectionReset); offset sent; } }别小看这个写法。我见过不下五个项目因为直接用了Send(data)在数据量一大就出现客户端收到的内容少了一段的问题排查半天最后发现是发送端只发了一半。凡是传输超过几百字节的数据我都建议用SendAll这种循环发送的方式。3.3 Shutdown与Close断开连接是有讲究的我看到很多人直接调Close()就完事这在大部分场景下没问题但在协议层面它是有副作用的Close是立刻释放Socket资源而它内部的TCP连接不是优雅关闭的对端会收到一个重置信号RST而不是正常的连接关闭序列。结果是服务端的Receive可能抛出异常而不是返回0这会让服务端误判网络故障而不是客户端主动断开。标准的优雅关闭是两步clientSocket.Shutdown(SocketShutdown.Both); clientSocket.Close();Shutdown(SocketShutdown.Both)表示我不再发送也不再接收它会给对端发FIN包让对端的Receive能得到0从而正常退出接收循环。之后再Close彻底释放资源。注意Shutdown之后不能再Send或Receive否则会抛异常。如果你只想停止发送而还想接收应该用SocketShutdown.Send很少用到但知道总比不知道好。3.4 结合异常处理的最小客户端如果客户端在连接过程中要兼顾服务端没启动网络不通服务端中途崩溃这些情况代码就应该把所有网络操作包进try-catchtry { clientSocket.Connect(serverEndPoint); SendAll(clientSocket, data); byte[] buffer new byte[1024]; int length clientSocket.Receive(buffer); if (length 0) { Console.WriteLine(Encoding.UTF8.GetString(buffer, 0, length)); } } catch (SocketException ex) { Console.WriteLine($连接或通信出错: {ex.SocketErrorCode}); } finally { clientSocket.Shutdown(SocketShutdown.Both); clientSocket.Close(); }我给客户写通信模块时客户端永远遵循这套模板Connect失败要能快速反馈通信异常要能区分是对端关闭还是网络中断看SocketErrorCodefinally里保证Socket一定被释放。4. 多客户端并发线程模型怎么选资源边界在哪里4.1 单线程服务端的死穴把第2章的代码拿过来如果在主线程里直接Receive而不开线程会出现一个典型问题第一个客户端连上之后服务端就卡死在Receive里第二个客户端呢虽然TCP握手能完成因为Listen的队列在起作用但永远不会被Accept表现为能连上但没反应。解决方案在2.4的代码里已经展示了每收到一个客户端连接就开一个后台线程去处理。这是多客户端并发最朴素也最有效的模型对于连接数在几十个以内的场景完全够用。4.2 从Thread到Task再到SAEA三版模型的演进我见过不少初学者直接在网上搜到异步方案抄完代码一头雾水。这里把演进路线讲清楚Thread版本每连接一个专用线程。优点是代码直白缺点线程很贵——一个线程默认栈空间1MB开几百个线程内存就吃紧而且线程切换有开销。适合连接数小于50的场景。Task/ThreadPool版本用Task.Run替代new Thread让线程池复用线程避免频繁创建销毁。代码改动极小适合连接数几百的场景。SocketAsyncEventArgs(SAEA)真正的异步I/O模型使用重叠IO和回调不依赖线程等待适合数千上万的连接。代码复杂度最高。我的排序建议是先把Thread版本跑通然后改成Task版本等你真的有上千连接的需求再去啃SAEA。绝大多数上位机、设备通信场景第二个阶段已经足够了。用Task改写处理逻辑很简单private static Dictionarystring, Socket _clients new Dictionarystring, Socket(); private static object _lockObj new object(); // Accept循环里 while (true) { Socket clientSocket listenSocket.Accept(); string clientId Guid.NewGuid().ToString(); lock (_lockObj) { _clients[clientId] clientSocket; } Console.WriteLine($客户端 {clientId} 接入当前连接数: {_clients.Count}); Task.Run(() HandleClient(clientId, clientSocket)); }4.3 连接列表的维护与线程安全如果你只是把收到的数据打印出来那维护连接列表不是必须的。但实际项目中你通常需要给指定客户端发消息或者广播给所有客户端这时候一个连接列表就跑不掉了。上面代码里用了Dictionarystring, Socket加lock来管理连接核心考虑是线程安全。因为每个客户端都有自己的处理线程处理线程在客户端退出时要删除自身对应的Socket主线程在广播时要遍历整个列表。多线程同时操作普通List或Dictionary会发生不可预期的异常所以要么加锁要么用ConcurrentDictionary。我的建议是初期用Dictionary lock简单且你能清楚看到锁的范围。在HandleClient的finally里要记得finally { lock (_lockObj) { _clients.Remove(clientId); } clientSocket.Shutdown(SocketShutdown.Both); clientSocket.Close(); Console.WriteLine($客户端 {clientId} 断开剩余连接数: {_clients.Count}); }如果不及时删除已断开的Socket会留在字典里你把数据发给一个已经关闭的Socket会抛SocketException整个广播循环都崩掉。4.4 广播消息的一个稳妥姿势广播给所有客户端时切忌在锁里面做Send。因为Send是阻塞操作如果某个客户端接收很慢锁会被长时间占住其他线程全部卡在等待锁上。正确姿势是先把需要发送的Socket拷贝出来锁内只做复制锁外再逐个发送public static void Broadcast(string message) { ListSocket snapshot; lock (_lockObj) { snapshot _clients.Values.ToList(); } byte[] data Encoding.UTF8.GetBytes(message); foreach (Socket socket in snapshot) { try { SendAll(socket, data); } catch (SocketException) { // 单个客户端异常不影响其他人交给各自的处理线程去清理 } } }这里再强调一个容易被忽略的点Socket不是线程安全的。同一个Socket不能同时被两个线程调Send或Receive。要避免广播线程在发数据同时那个连接的处理线程也在收数据这种情况规范的做法是只在一个线程里对特定Socket做接收发送则通过队列串行化。对小型项目来说只要广播发送的Socket和它自身的接收循环不是同一批数据风险可控但你要清楚这个边界。5. 粘包与半包TCP字节流最典型的数据边界问题5.1 问题长什么样消息多了、少了、拼错了先看三个真实场景场景A客户端连续执行Send(消息1)和Send(消息2)服务端一次Receive居然读到消息1消息2。这叫粘包。场景B客户端发送一长串文本比如1万个字符服务端设置了1024字节的接收缓冲区结果Receive分多次才把数据取完每次拿到的都是半截话。这叫半包。场景C客户端发送两条消息因为网络拥塞或调度服务端第一次Receive收到消息1的一半第二次Receive收到消息1剩下的一半加消息2的前半段。这是粘包和半包同时发生。很多新手遇到第一种就懵了以为是自己代码写错了。其实TCP从设计上就不保证消息边界。它保证的是你从A发出去的字节流会按照同样的顺序完整地到达B。至于中间被切成了多少片、几片拼一起才到那是操作系统和网络设备的自由。5.2 底层原因TCP是流不是消息队列为什么会有这种特性TCP协议栈会把你的数据放进发送缓冲区然后根据网络状况MSS、拥塞窗口、Nagle算法决定什么时候把多少字节打包成一个TCP段发出去。Nagle算法会尝试把小数据合并成大段以减少小包数量这就直接导致多个Send的数据在网络上被拼成一个大段。接收方是自己决定什么时候、从缓冲区里取出多少数据的缓冲区里可能积压了多个Send的数据也可能一个大Send的数据还没全到。**结论应用层必须自己约定消息的边界。**字节流本身没有这条消息从哪开始、到哪结束的概念。5.3 三种封包方案与我的推荐方案原理优点缺点固定长度每条消息固定N字节解析最简单短消息浪费带宽长消息拆包复杂分隔符消息结束处加\r\n实现直观可变长正文不能包含分隔符需转义拆包判断繁琐长度前缀4字节int 正文可变长且高效解析快需要自己实现拆包逻辑但工作量很小我的强烈推荐是长度前缀方案。它在实际项目里最通用前4个字节用二进制表示正文长度后面跟着正文。接收方先读4个字节算出正文长度再读正好那么多个字节的正文。5.4 一个通用解包器处理粘包半包的核心逻辑有了长度前缀协议剩下的就是实现一个能从任意长度的接收数据里拆出完整消息的解包器。核心思路是维护一个累积缓冲区可以通俗地称为拼接池每次收到新数据先追加进池子里然后循环检查池子里是否有一条或好几条完整消息取走完整消息剩下的继续留在池子里等下次。private static byte[] _bufferPool new byte[0]; private static Listbyte[] DecodePackets(byte[] received) { Listbyte[] messages new Listbyte[](); byte[] merged new byte[_bufferPool.Length received.Length]; Buffer.BlockCopy(_bufferPool, 0, merged, 0, _bufferPool.Length); Buffer.BlockCopy(received, 0, merged, _bufferPool.Length, received.Length); int offset 0; while (merged.Length - offset 4) { int bodyLength BitConverter.ToInt32(merged, offset); // 防止恶意数据导致数组越界或分配超大内存 if (bodyLength 0 || bodyLength 1024 * 1024) { _bufferPool new byte[0]; return messages; } // 数据还不够一条完整消息等下次Receive if (merged.Length - offset - 4 bodyLength) break; byte[] body new byte[bodyLength]; Array.Copy(merged, offset 4, body, 0, bodyLength); messages.Add(body); offset 4 bodyLength; } _bufferPool new byte[merged.Length - offset]; Array.Copy(merged, offset, _bufferPool, 0, _bufferPool.Length); return messages; }对应的组包函数private static byte[] EncodePacket(string message) { byte[] body Encoding.UTF8.GetBytes(message); byte[] header BitConverter.GetBytes(body.Length); byte[] packet new byte[4 body.Length]; Buffer.BlockCopy(header, 0, packet, 0, 4); Buffer.BlockCopy(body, 0, packet, 4, body.Length); return packet; }注意两个做得保守的地方一是bodyLength要校验上限比如1MB否则客户端发来一个伪造的4字节超大数字你的解包器会尝试分配几百MB内存然后进程崩溃。二是在数据不够的时候跳出循环千万不能把这段残留数据丢掉因为下次Receive的数据要跟它拼接在一起才算完整。在HandleClient里这么用int length clientSocket.Receive(buffer); if (length 0) break; foreach (byte[] body in DecodePackets(buffer.AsSpan(0, length).ToArray())) { Console.WriteLine($收到完整消息: {Encoding.UTF8.GetString(body)}); }这一段能解决掉我遇到过80%的消息错乱问题。剩下20%是协议本身设计不合理比如在同一连接上混用了不同协议的包头格式那不是技术问题是约定问题。6. 稳定运行半年后我总结的异常处理与调试经验6.1 SocketException常见错误码看到这些数字不要慌C#里Socket通信出错时抛的大多是SocketException代码里有六个错误码高频出现错误码含义常见原因应对10061目标机器积极拒绝端口没监听、地址写错、防火墙拦截确认服务端是否启动用telnet测端口10054远程主机重置连接对端崩溃、调了Close未Shutdown服务端代码用Shutdown优雅关闭10053软件导致连接中止调用了Close覆盖未发送的数据先用Shutdown再Close10060连接超时网络不可达、防火墙丢弃检查网络与ACL规则10048地址已被占用端口被其他进程占用换端口或设置ReuseAddress10049地址不可用绑定了不存在的IP检查IPAddress设置一见SocketException就慌是新手常态。我的建议是先看ErrorCode再决定处理方式10054和10053往往意味着对端关闭连接程序应该清理资源而不是弹错误框10061是配置问题要检查对方服务10060则是环境问题代码层往往做不了什么。6.2 调试时端口冲突AddressAlreadyInUse的来龙去脉开发阶段你会反复启动和关闭服务端这时很容易碰到SocketException(10048)。原因是TCP连接关闭后连接进入TIME_WAIT状态端口会在一段时间内默认约60秒不能重用。你在服务端代码里设置ReuseAddress可以缓解listenSocket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true); listenSocket.Bind(endPoint);这个选项在开发调试时几乎是必加的。但同时要明白它相当于告诉系统允许重用这个地址不该在生产环境滥用否则可能出现新旧实例绑到同一个端口的混乱。6.3 没有网络条件怎么调试回环、telnet和抓包在开发机上没有第二台机器怎么办127.0.0.1回环地址就是你的调试利器。回环流量只走协议栈不经过物理网卡两个控制台窗口就能模拟完整的C/S交互。验证端口是否真的在监听用Windows自带的telnet最直接telnet 127.0.0.1 8888能进入黑窗口说明服务端监听正常连接被接受了。也可以使用PowerShell命令Test-NetConnection -ComputerName 127.0.0.1 -Port 8888真到了要分析数据内容的时候Wireshark是终极武器。抓包能看到TCP三次握手是否完成、每段数据在应用层字节流里的位置、Nagle算法是否把小包合并了。我调试粘包问题时很多时候不是靠看代码而是靠抓包确认这条消息在网络上到底是怎么走的。6.4 我的几条防坑建议第一接收缓冲区不要设太小。1024在纯文本演示里够用但真实场景建议4096或8192。缓冲区只是一次最多取多少数据即使设大了也不意味着内存浪费严重因为它分配的是固定数组不是每次Receive都新建。第二Receive循环一定要写在try-catch里。TCP的一半断连信号是通过异常表达的不是所有断开都会返回0。正确姿势是Receive返回0或抛SocketException都当作连接已死在finally里清理资源。第三日志要打到位。我调试通信程序时默认每个阶段都打日志Accept成功打一条、Receive收到多少字节打一条、每次Send打一条、异常打一条。等到程序稳定了再决定哪些日志降级成Debug级别或去掉。宁可初期待完整不可一开始图省事。第四不要在主线程里做任何网络操作。控制台程序无所谓但如果你是写有界面的程序一个阻塞的Receive就能把整个UI卡死。用后台线程处理网络是铁律。尾声一个让我少走半年弯路的小技巧最后分享一个实际操作中摸索出来的经验先用明文分隔符把链路跑通再切换长度前缀协议。我最初写Socket通信时直接设计了一套复杂的协议头结果服务端客户端都写好了却分不清问题是出在协议设计还是网络传输上。后来我退回到最原始的方式——每条消息加个\r\n结尾把粘包半包问题彻底暴露出来看清了TCP流的脾气再把长度前缀协议替换上去问题一次就定位了。这个顺序能帮你把传输层的问题和应用层的问题分开先保证数据能准确到达再去优化消息的编码格式。网络编程入门最难的不是API记不住而是心里缺少一条数据从发送方到接收方到底经历了什么的思路。顺着这条路走把上面六章的内容亲手跑一遍你就能建立起这条思路以后遇到任何通信问题都知道该去排查哪一层了。