简介这是一份面向C#网络编程初学者的TCP通信示例工程目标是用一个程序实现TCP客户端与服务器之间的互发消息并支持在客户端界面点击按钮弹出服务器界面。资源围绕System.Net命名空间下的TcpListener与TcpClient展开覆盖端口绑定、连接监听、消息收发、异常处理与资源释放等关键环节适合用于课程设计、毕业设计或即时通讯等入门场景。压缩包共118个文件包含工程文件.sln、.csproj、C#源码.cs、窗体资源.resx、.resources、配置文件.config、可执行文件.exe及示例数据等打包后约183KB。目前已有713人学习下载。用户既可直接运行exe快速体验通信效果也可对照源码理解网络编程的实现细节便于在此基础上进行二次扩展。1. C# 实现 TCP 服务器与客户端双向消息通道的骨架怎么搭很多开发者第一次做 TCP 通信都是从 C# 的控制台程序开始的一个 Server 监听端口一个 Client 连上去两边互相发消息。听着简单真上手会发现处处是坑Server 怎么同时处理多个 ClientClient 在等待输入的时候怎么还能收到 Server 主动推过来的消息收到的数据为什么有时候是半截、有时候又拼在一起这篇笔记就是围绕这些实际诉求写的目标是把「C# 实现 TCP 客户端和服务器Server 和 Client 可以互相发消息」这件事从原理到代码、从参数到踩坑完整讲透。适合刚入门的学员、做小工具验证的开发者也包括需要在局域网内做设备通信、做消息中转的从业者。读完你能自己搭出一个可运行的双向通信骨架并且知道消息会丢、会粘、会断的时候该去哪找原因。2. 通信模型与选型为什么 TCP 双向通信要拆成读线程与写线程2.1 TcpClient/TcpListener 与 Socket 的选择C# 里做 TCP 通信有三套常见写法直接用 Socket 类、用 TcpClient/TcpListener 封装类、用 NetworkStream 配合异步 API。我一般会优先选 TcpListener TcpClient原因很直接它们是 Socket 的高级封装内部帮你处理了 bind、listen、accept 的细节同时暴露了 NetworkStream收发消息的代码可以写得很干净。对比项Socket 原生TcpClient/TcpListenerNetworkStream代码量多需手动管理 bind/accept少封装了连接生命周期最少只管读写控制粒度最高可自由设置 SO_* 选项中常用选项都暴露了低只负责流读写适合场景自定义协议、需要精细控制收发缓冲常规业务通信、教学演示、中小型工具配合 TcpClient 使用做实际读写如果你只是做一个双向互通的控制台或窗体工具TcpListener TcpClient NetworkStream 是最省心的组合。但要注意TcpClient 默认会把接收缓冲区的数据一次性交给 Read这不代表你一次 Read 就拿到一条完整消息后面第 5 章会专门讲这件事。2.2 双向通信的关键读线程与写线程分离Socket 本身是全双工的TCP 连接两端的任何一方都可以随时发送数据。问题出在应用层的循环结构上很多初学者写 Client 时先等待用户输入然后 Send再循环结果 Server 发来的消息一直收不到因为程序卡在 Console.ReadLine() 上了。解决方式是读、写分离。Client 端开一个后台线程专门循环读 NetworkStream主线程负责读键盘输入并发送。Server 端也同理每个接入的 Client 都要有一个独立的接收线程否则一个 Client 的消息会阻塞整个 Server。我在项目里通常直接开 Thread而不是用 Task 去做长连接接收循环。原因很简单长连接的生命周期和短任务不一样Thread 有明确的关系对应调试时线程栈一眼能看清是谁在处理哪个连接Task 在线程池里跑长时间阻塞读会占着线程池线程高并发时反而不好控制。当然如果追求异步性能用 async/await ReadAsync 是更现代的做法但本文先按最易懂、最容易调试的 Thread 写法展开。2.3 粘包与半包TCP 字节流不是按消息打的包这是新手的第一个翻车点。TCP 是流式协议它只保证字节顺序和可靠传输不保证你每次 Read 拿到的恰好是一条完整的业务消息。一次 Send(hello) 可能被拆成两个包发送对端 Read 两次才拿到完整数据这叫半包两次 Send 的数据也可能被合并成一次到达对端一次 Read 读到了两条消息这叫粘包。解决粘包有两个最常见方案一是消息结尾用分隔符如 \n适用于文本协议二是消息头部用固定长度的字节数表示正文长度接收方先读长度再读正文。第二个方案通用性更强我在第 5 章会给出可直接复制的实现。这一节先记住一句话网络上没有「消息」这个概念只有字节流应用层协议负责把字节流切开、拼成消息。3. 服务端实现监听接入、维护连接池、主动发消息3.1 最小可用的 TcpListener 服务端监听与接入先写一个能跑起来的最小 Server。它监听本机 8888 端口接受客户端接入并为每个客户端启动一个独立线程处理后续通信。using System.Net; using System.Net.Sockets; using System.Text; TcpListener listener new TcpListener(IPAddress.Any, 8888); listener.Start(); Console.WriteLine($服务端已启动监听端口 8888); while (true) { TcpClient client await listener.AcceptTcpClientAsync(); Console.WriteLine($客户端接入{client.Client.RemoteEndPoint}); // 每个客户端一个独立线程避免一个连接的阻塞影响其他连接 Thread handler new Thread(() HandleClient(client)); handler.IsBackground true; handler.Start(); }这段代码里有两个参数值得留意。IPAddress.Any 表示监听本机所有网卡地址这样局域网内其他机器也能通过你的内网 IP 连上来端口 8888 是自定义业务端口1024 以下通常需要管理员权限调试时避开即可。AcceptTcpClientAsync 是异步接入好处是主循环可以立刻回到等待状态不会因为某个客户端握手慢而卡住后续接入。handler.IsBackground true 确保主程序退出时这些线程不会拦着进程结束。3.2 接收循环与消息回显处理每个客户端的读线程接入之后核心是 HandleClient 里的接收循环。这个循环反复从 NetworkStream 读取字节读不到数据说明连接断开异常则说明网络层出了问题。void HandleClient(TcpClient client) { NetworkStream stream client.GetStream(); byte[] buffer new byte[4096]; try { while (true) { int readCount stream.Read(buffer, 0, buffer.Length); if (readCount 0) { // Read 返回 0 表示对端已正常关闭连接 Console.WriteLine(客户端关闭了连接); break; } string message Encoding.UTF8.GetString(buffer, 0, readCount); Console.WriteLine($收到消息{message}); // 回显给客户端同时验证双向通道 byte[] reply Encoding.UTF8.GetBytes($服务端已收到{message}); stream.Write(reply, 0, reply.Length); } } catch (Exception ex) { Console.WriteLine($连接异常{ex.Message}); } finally { client.Close(); Console.WriteLine(连接已释放); } }Read 是阻塞方法循环会停在这里等数据。readCount 是实际读到的字节数不一定等于 buffer 长度。这段代码里有个明显的业务问题当客户端发来多条消息被合并成一次 Read 时或者一条消息被拆成两次 Read 时回显和打印都会出现错乱这正好对应第 2 章说过的粘包半包。先不要急着改第 5 章会给完整的封包方案。另一个值得注意的点catch 里捕获了所有异常包括客户端突然拔网线、进程被杀、防火墙重置连接等场景。在这些情况下 Read 会抛异常而不是返回 0所以异常处理不是可有可无的它是断线检测的一部分。3.3 服务端主动向指定客户端发消息连接池与封装回显只是被动应答。如果要让 Server 能主动向某个 Client 发消息Server 就得把已接入的 TcpClient 保存到集合里并提供一个按标识发送的方法。多线程同时操作这个集合所以要用并发安全的字典。using System.Collections.Concurrent; ConcurrentDictionarystring, TcpClient clients new ConcurrentDictionarystring, TcpClient(); void HandleClientWithRegister(TcpClient client) { string clientId client.Client.RemoteEndPoint.ToString(); clients[clientId] client; Console.WriteLine($[{clientId}] 已注册当前在线{clients.Count}); // ... 接收循环代码同 3.2 ... // 循环结束后移除连接 clients.TryRemove(clientId, out _); Console.WriteLine($[{clientId}] 已移除当前在线{clients.Count}); } void SendToClient(string clientId, string message) { if (clients.TryGetValue(clientId, out TcpClient? target)) { byte[] data Encoding.UTF8.GetBytes(message); NetworkStream stream target.GetStream(); stream.Write(data, 0, data.Length); } }连接池的核心是那个 ConcurrentDictionarykey 用 RemoteEndPoint 拼出来的字符串作为客户端标识。实际项目中这个 key 应该换成业务 ID比如设备编号、用户名因为在公网场景下同一台机器可能发起多个连接端口不同 RemoteEndPoint 就不同但在内网工具里 RemoteEndPoint 够用。发送方法里没有判断连接状态这是个隐患如果对端已经断开但尚未从字典里移除Write 会抛出异常。上线前要在 SendToClient 里加 try/catch并在 catch 中触发移除逻辑否则异常会积压在服务端线程里影响后续发送。4. 客户端实现TcpClient 连接与收发闭环4.1 建立连接连接地址、端口与超时设置客户端这边用 TcpClient 直接连接服务端连接参数集中在地址、端口、超时三项上。using System.Net.Sockets; using System.Text; string serverIp 127.0.0.1; // 本机调试连局域网服务器时改为对方内网 IP int serverPort 8888; // 必须与服务端监听端口一致 TcpClient client new TcpClient(); client.SendTimeout 3000; // 发送超时 3 秒避免网络异常时无限阻塞 client.ReceiveTimeout 3000; // 接收超时 3 秒配合心跳机制使用 try { await client.ConnectAsync(serverIp, serverPort); Console.WriteLine(已连接到服务端); } catch (SocketException ex) { Console.WriteLine($连接失败{ex.Message}); return; }ConnectAsync 是异步连接不会像 Connect 那样把界面线程卡死几秒。SendTimeout 和 ReceiveTimeout 是 TcpClient 的两个重要参数很多初学者不设结果连接建立后网络故障时 Read 永远阻塞在那里程序看起来就像死了。超时只对同步 Read/Write 生效对异步方法不生效这个差别后面还会提到。这里要明确一件事连接失败不等于服务端挂了可能是 IP 写错、端口不一致、防火墙拦截或是服务端 backlog 队列已满。排查时先看 SocketException 的错误码超时是 TimedOut拒绝是 ConnectionRefused两者对应的排查方向完全不同。4.2 客户端持续接收读线程与 Buffer 的匹配客户端必须开一个后台线程持续读否则 Server 发过来的消息没人处理。这个线程的代码和服务端的接收循环几乎一样区别在于它不需要 Accept只需要 Read。void ReceiveLoop(TcpClient client) { NetworkStream stream client.GetStream(); byte[] buffer new byte[8192]; while (true) { try { int readCount stream.Read(buffer, 0, buffer.Length); if (readCount 0) { Console.WriteLine(服务端关闭了连接); break; } string message Encoding.UTF8.GetString(buffer, 0, readCount); Console.WriteLine($收到服务端消息{message}); } catch (Exception ex) { Console.WriteLine($接收异常{ex.Message}); break; } } client.Close(); } Thread receiver new Thread(() ReceiveLoop(client)); receiver.IsBackground true; receiver.Start();Buffer 的大小选择是个经验题。设得太小一次读不完整条消息要多次 Read 才能拼齐设得太大又浪费内存。我一般按应用层消息的最大长度来定例如业务消息不超过 2KBBuffer 给 4096 已经足够。但注意Buffer 大只意味着单次 Read 的容量大不意味着消息一定完整粘包半包问题不因 Buffer 变大而消失。如果客户端界面上要显示消息接收线程里不能直接操作 UI 控件这就是后面第 5 章要展开的跨线程问题。控制台程序打印没有这个限制所以先用 Console.WriteLine 验证通道。4.3 从控制台读取输入并发送完整双向闭环接收线程已经就绪主线程剩下的任务就是读取用户输入并发送。这一步做完Server 和 Client 之间就形成了双向消息闭环。while (true) { string? input Console.ReadLine(); if (string.IsNullOrEmpty(input)) continue; if (input exit) break; byte[] data Encoding.UTF8.GetBytes(input); NetworkStream stream client.GetStream(); stream.Write(data, 0, data.Length); } client.Close();如果你把这段代码和第 3 章的 Server 跑在一起效果就是Server 收到 Client 消息后回显Client 收到回显并打印两边都能主动发消息互不阻塞。到这里标题里的「互相发消息」在功能上已经成立。但先别急着欢呼第 5 章要处理的坑才是真正决定这个 Demo 能不能变成工具的分水岭。5. 避坑指南粘包、断线、跨线程 UI 这三个坎5.1 粘包与半包为什么客户端收到的消息是拼在一起的现象Client 连续发十条短消息Server 只打印出一条内容是十条消息拼接在一起。或者一条长消息被分两次打印内容被截断。原因TCP 是字节流没有消息边界。无论发送端 Send 多少次接收端 Read 拿到的只是字节序列的一次快照应用层必须自己界定消息从哪里开始、到哪里结束。解决消息头用固定 4 字节存正文长度接收端先读长度再读正文。发送端封装如下byte[] body Encoding.UTF8.GetBytes(message); byte[] lengthHeader BitConverter.GetBytes(body.Length); // 4字节int byte[] packet new byte[4 body.Length]; Buffer.BlockCopy(lengthHeader, 0, packet, 0, 4); Buffer.BlockCopy(body, 0, packet, 4, body.Length); stream.Write(packet, 0, packet.Length);接收端需要维护一个缓存区先把读到的字节放入缓存再循环尝试从缓存里切出完整的「4 字节长度 正文」切不出来就等下一轮 Read。Listbyte receiveBuffer new Listbyte(); byte[] temp new byte[8192]; while (true) { int readCount stream.Read(temp, 0, temp.Length); if (readCount 0) break; for (int i 0; i readCount; i) receiveBuffer.Add(temp[i]); while (receiveBuffer.Count 4) { int bodyLength BitConverter.ToInt32(receiveBuffer.ToArray(), 0); if (receiveBuffer.Count 4 bodyLength) break; // 半包等待后续数据 string msg Encoding.UTF8.GetString(receiveBuffer.ToArray(), 4, bodyLength); Console.WriteLine($完整消息{msg}); receiveBuffer.RemoveRange(0, 4 bodyLength); } }注意 BitConverter 默认使用主机字节序如果 Server 和 Client 跑在不同架构的平台上要在收发的两端约定大端序用 BinaryPrimitives.WriteInt32BigEndian 这类 API 更保险。5.2 掉线检测Read 返回 0 与心跳超时现象一端拔网线或进程被强制结束另一端没有收到任何消息连接状态看起来还是正常的直到下一次 Write 才发现异常。原因TCP 的 FIN 和 RST 都依赖网络包的传输。拔网线不产生任何包对端永远不知道连接已失效。Read 返回 0 只表示收到 FIN即对方「正常关闭」异常断开是收不到 FIN 的。解决两层配合。一是设置 ReceiveTimeout让 Read 在超时后抛异常二是应用层增加心跳消息规定服务端每 N 秒向客户端发一个空包客户端 N 秒收不到就判定连接失效。简单的心跳可以用一个定时器while (true) { Thread.Sleep(5000); // 每5秒发一次心跳 try { byte[] ping Encoding.UTF8.GetBytes(__ping__); stream.Write(ping, 0, ping.Length); } catch { Console.WriteLine(心跳发送失败连接已断开); break; } }不要把心跳和业务消息混在一个线程里否则业务线程忙的时候心跳会延迟造成误判。我一般把心跳放到单独的定时器线程并记下最后一次成功收发的时间供业务线程查询。5.3 跨线程更新 UIInvoke 不是可选项现象在 WinForms 或 WPF 里运行第 4 章的代码接收线程收到消息后直接给文本框赋值程序抛 InvalidOperationException提示不是创建控件的线程不能操作它。原因UI 控件只能在创建它的 UI 线程上访问。接收线程和 UI 线程是两个不同的线程直接操作控件违反了这一约束。解决用控件.Invoke 把操作切回 UI 线程再执行。void AppendMessage(string text) { if (txtLog.InvokeRequired) { txtLog.Invoke(() AppendMessage(text)); return; } txtLog.AppendText(text Environment.NewLine); }InvokeRequired 判断当前线程是否为 UI 线程不是则 Invoke 回去。这段代码要写进接收线程里调用替换掉 Console.WriteLine。不少开发者嫌 Invoke 性能差改用 BeginInvoke两者差异在于是否等待 UI 线程执行完高频消息场景用 BeginInvoke 更合适但要注意它会堆积消息界面卡顿时要合并刷新而不是逐条追加。5.4 关闭顺序与端口占用重启失败怎么排查现象程序正常运行后关闭 Server 端进程立刻重新启动抛 AddressAlreadyInUse 异常端口被占用。原因TCP 连接关闭后连接进入 TIME_WAIT 状态端口在一段时间内约 2 分钟不会被系统释放。这个状态内核管理应用层无法直接绕过。解决开发阶段临时处理启动前设置一个 SO_REUSEADDR 选项允许重用处于 TIME_WAIT 的端口。TcpListener 的底层 Socket 可以这样配置TcpListener listener new TcpListener(IPAddress.Any, 8888); listener.Server.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true); listener.Start();这段配置只为你提供开发便利生产环境不建议全开否则会产生端口被多个进程绑定的风险。排查占用时先确认到底是谁占用了端口Windows 用 netstat -ano | findstr 8888Linux 用 ss -lntp看 PID 对应的进程名再决定是重启进程还是改业务端口。关闭顺序这条我踩过不少次先停接收循环再关 NetworkStream最后关 TcpClient。顺序反了容易在释放阶段二次抛异常因为 Close 时内部还会尝试通知对端如果对端已经走了就可能触发 ObjectDisposedException。安全的写法是把 Close 都包进 try/catch并在 finally 里只处理一次。6. 进阶把 Demo 变成工具的三个习惯6.1 给消息加协议头类型、长度与版本号第 5 章的长度前缀方案解决了粘包但只解决了一部分。真正要对外使用的协议头部至少要有三个字段消息类型1 字节、协议版本1 字节、正文长度4 字节。消息类型决定业务怎么分发版本号决定兼容性判断长度决定怎么切包。不要偷懒只保留长度否则后期增加心跳、错误码、文件传输这些类型时接收端要用 if 字符串匹配做分流代码会变脏。6.2 日志与可观测性双向通信最容易黑盒化TCP 调试最难的地方是两端不在同一台机器上你没法直观看到对端进程在做什么。我现在的习惯是每个关键节点打一行日志连接建立、消息到达、发送完成、异常发生、心跳超时都写到文件里并带上时间戳和线程 ID。看似简单实际排查线上问题时这些日志就是唯一的线索。异常信息里不要只记 Message要连带 StackTrace否则拿到线上日志也定位不到具体是哪个调用链出了问题。我做 TCP 通信这几年最深的一条教训是所有的偶发问题第一次出现时就要当成确定性 bug 去查。粘包在早期数据量小的时候可能一周都不出现等联调时突然冒出再回头找原因代价就大了。把封包、心跳、断线重连、日志这些基础设施在第一个 Demo 就搭好后面换多少个业务都不用再重新踩一遍坑。这份骨架你留着希望帮到你。本文还有配套的精品资源点击获取