C#上位机TCP Server实现:异步Socket、粘包处理与心跳保活
简介这是一份C#语言实现的TCP通信服务器端程序基于Socket编程模型面向需要学习网络通信或快速搭建TCP服务端的开发者可解决TCP监听、客户端接入与数据交互等基础问题。压缩包内含26个文件以cs源代码、sln工程文件、exe可执行程序为主附带pdb调试符号、resources/resx资源与配置文件等整体大小仅50KB适合直接加载到Visual Studio中查看与构建。目前已有344人学习下载属于轻量、易上手的C#网络编程参考案例。透过核心源代码文件可学习服务器端Socket的创建、绑定、监听、接受连接及数据收发流程借助Visual Studio工程文件可快速运行调试配套的exe程序便于直接体验效果帮助初学者将理论结合实践是理解TCP通信机制与C#异步处理思路的实用样例。1. 入手一个 TCPServer.rarC# TCP Server 到底解决上位机的什么问题设备已经摆在产线上了PLC、扫码枪、视觉相机或者某个工控仪表都在等你开一个监听端口——这个 TCPServer.rar 里的东西大概率就是一段最小可跑的 C# TCP server 源码通过 TCP socket 在指定端口上监听接收客户端连接把字节流收进来再交给上位机界面去处理。它要解决的三个问题是设备连得上来、数据粘得住、断线不把进程拖死。这个方向适合做 C# 上位机、设备数据采集、以及想快速把手上的通信协议跑通的人结论是值得投入因为不管设备端是 Modbus TCP、私有协议还是 JSON over TCP服务器端骨架都一样一次写好换协议只是换解析器的事。2. 为什么服务器的 Socket 必须异步同步阻塞的坑与回调模型的本质很多刚从控制台程序转来做上位机的人写 TCP 服务器时会先试同步写法一个TcpListener放在while(true)里AcceptTcpClient()然后GetStream().Read读到天荒地老。这写法在单连接测试时看起来很正常一旦客户端数量上到 5 个立刻露馅——不是收不到数据而是整个服务器像被卡住一样。2.1 同步阻塞为什么一卡全卡同步模型的问题不在Accept而在Read。NetworkStream.Read()在没有数据可读时是阻塞的线程停在调用栈上不回来。如果你用单线程循环第一个连接建立后你就永远停在它的Read里后面所有客户端的连接请求只能在操作系统的 accept 队列里排队永远轮不到你。标准解决办法是每个连接开一个线程但线程不是免费资源一个线程默认栈空间 1MB上千个连接就是上千个线程光上下文切换就能把 CPU 吃掉一半。更隐蔽的是只要某个客户端发一个半包不发了它的读线程就永远阻塞这叫“连接耗尽”比崩溃还难排查。// 反例同步阻塞写法只适合一个客户端的演示不要直接用在产线 var listener new TcpListener(IPAddress.Any, 9000); listener.Start(10); while (true) { using var client listener.AcceptTcpClient(); // 阻塞在这里等新连接 NetworkStream stream client.GetStream(); byte[] buffer new byte[4096]; while (true) { int read stream.Read(buffer, 0, buffer.Length); // 阻塞在这里等数据 if (read 0) break; // 如果这个分支要解析业务、回数据库、绘图处理多久后面连接就等多久 } }这段代码的问题看两处就够外层AcceptTcpClient()一次只接受一个连接using又把生命周期框死在单个连接里内层Read()是彻底阻塞的数据没来这条线程就在那空转。等到数据真的来了你在回调里做的业务处理又占着时间其他客户端全部排队。这种“一卡全卡”的现象在 C# 上位机面试里几乎是必考题考官想考的就是你有没有用异步模型的意识。2.2 BeginReceive 与 async/await两种异步写法和选型解决阻塞的两个方向传统异步编程模型APM和基于任务的异步模式TAP。APM 就是Socket.BeginAccept/Socket.BeginReceive加回调这是老教程里的常客也是 C# 3.0 时代写 TCP 服务器的标准姿势TAP 则是AcceptAsync/ReceiveAsync配async/await代码更像同步写法但用的是线程池调度。说得直白一点APM 是“我在连接上挂了个回调数据到了系统叫我”TAP 是“我让出线程数据到了再继续往下写”。// TAP 写法逻辑看着像同步但没有一条线程被白白阻塞 private async Task AcceptLoopAsync() { while (true) { Socket client await _listenSocket.AcceptAsync(); // 没有连接时不会占线程 Console.WriteLine($[TcpServer] client {client.RemoteEndPoint} connected); _ HandleClientAsync(client); // fire-and-forget不阻塞接受循环 } } private async Task HandleClientAsync(Socket client) { byte[] buffer new byte[4096]; try { while (true) { int read await client.ReceiveAsync(buffer, SocketFlags.None); if (read 0) break; // 对端正常关闭 // 在这里把 buffer[0..read] 交给业务解析 } } catch (SocketException) { // 连接被重置时直接进异常分支不用手动判断 } }两种写法的取舍我一般这样定老项目、老 .NET Framework 或者想在 .NET Framework 4.x 上同时兼容多平台时优先 Begin/End 回调新项目 .NET 6/8 直接用 async/await代码量少、可读性好异常处理也更自然。关键点是无论选哪种AcceptAsync和BeginAccept之后必须立刻再次发起下一个 accept让同一个监听 Socket 同时挂多个连接请求这是在 Socket 层面实现并发的基础不是靠多线程而是靠一个 socket 上同时挂多个“等数据/等连接”的异步调用。3. 搭一个可复用的 C# TCP Server监听、接收、会话管理完整代码理解了异步模型就可以搭骨架了。常见做法是写一个TcpServer类把监听、接收、会话管理放在一起业务层通过事件订阅数据。这一段代码我尽量控制得短但该有的都有地址复用、backlog、断线清理、数据回调。3.1 30 行核心骨架Bind、Listen、BeginAccept 的正确顺序public class TcpServer { private Socket _listenSocket; private readonly int _bufferSize 4096; public event ActionClientSession, byte[] DataReceived; public void Start(int port, int backlog 100) { _listenSocket new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); _listenSocket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true); _listenSocket.Bind(new IPEndPoint(IPAddress.Any, port)); _listenSocket.Listen(backlog); // backlog 是内核中等待 accept 的连接数上限 _listenSocket.BeginAccept(AcceptCallback, null); Console.WriteLine($[TcpServer] listening on {port}, backlog{backlog}); } private void AcceptCallback(IAsyncResult ar) { try { Socket client _listenSocket.EndAccept(ar); _listenSocket.BeginAccept(AcceptCallback, null); // 立即挂下一个 accept不能放在后面 var session new ClientSession(Guid.NewGuid().ToString(), client); var state new ReceiveState { Session session, Buffer new byte[_bufferSize] }; client.BeginReceive(state.Buffer, 0, state.Buffer.Length, SocketFlags.None, ReceiveCallback, state); } catch (ObjectDisposedException) { // Stop() 时监听 socket 已释放到这里静默退出即可 } catch (SocketException ex) { Console.WriteLine($[TcpServer] accept failed: {ex.SocketErrorCode}); } } private void ReceiveCallback(IAsyncResult ar) { var state (ReceiveState)ar.AsyncState; try { int read state.Session.Socket.EndReceive(ar); if (read 0) { state.Session.Close(); return; } // 客户端正常关闭 byte[] data new byte[read]; Array.Copy(state.Buffer, data, read); DataReceived?.Invoke(state.Session, data); state.Session.Socket.BeginReceive(state.Buffer, 0, state.Buffer.Length, SocketFlags.None, ReceiveCallback, state); } catch (SocketException) { state.Session.Close(); // 连接重置、强杀、拔网线都进这里 } } }这段代码有几个顺序不能反SetSocketOption必须在Bind之前Listen必须在Bind之后BeginAccept必须挂上之后才能开始收连接。BeginReceive里的state.Buffer是可以循环复用的——EndReceive返回read后数据已经拷进byte[] data缓冲区可以立刻用于下一次接收。不需要释放ReceiveState因为每次接收完成后直接再次挂上同一个对象。3.2 客户端会话管理连接对象、接收缓冲和断线释放public class ClientSession { public string Id { get; } public Socket Socket { get; } public DateTime LastActive { get; set; } public ClientSession(string id, Socket socket) { Id id; Socket socket; LastActive DateTime.UtcNow; } public void Close() { try { Socket.Shutdown(SocketShutdown.Both); } catch { } Socket.Close(); } } public class ReceiveState { public ClientSession Session { get; set; } public byte[] Buffer { get; set; } }ClientSession的核心价值是把 Socket 和它的业务状态绑在一起。数据库里的会话、刚才收到的半包缓存、心跳时间全部挂在这个对象上清理时只关一次。Close()方法要先Shutdown(SocketShutdown.Both)再Close()Shutdown告诉对端“我不再收发”Close才真正释放资源跳过 Shutdown 直接 Close 在某些 Windows 场景下会让对端收不到 FIN表现为“我关了对面还卡着”。这里的LastActive是给心跳用的下一章会用到。3.3 TcpListener 与原生 Socket两种封装怎么选对比点TcpListener 封装原生 Socket获取客户端AcceptTcpClient 返回 TcpClientEndAccept 返回 Socket收发数据GetStream().Read/WriteBeginReceive / Send高级选项要拿底下的 Socket 再设置直接设置适用场景简单连接、快速原型、面试手写需要控制接收缓冲、超时、复用、大量连接TcpListener 不是不能用而是它把 Socket 藏在 TcpClient 里你要设置ReuseAddress、ReceiveTimeout或拿原生句柄时反而要绕一圈。.NET 6之后官方也开始偏向Socket直用所以我更建议直接用原生 Socket 写骨架。.NET 7还有await listener.AcceptAsync()这种更现代的写法逻辑和上面的BeginAccept等价只是语法层面更简洁。4. 协议层是服务器的一半黏包/断包分包与心跳保活Socket 收上来的是一串字节流不是一个一个完整“包”。这是 TCP 的传输特性决定的发送端连续调两次 Send内核可能合成一个 TCP 段发出去接收端一次 Receive 可能拿到两个业务的完整数据或者只拿到一个大包的前一半。没有协议层的处理服务器端看到的永远是错位的数据。4.1 黏包和断包的分包处理长度头还是分隔符分包方案就两大类长度前缀和分隔符。长度前缀是绝大多数工控协议选择的方案——先收 4 字节长度头再按长度收 body分隔符适合行协议比如 AT 指令、NMEA 语句、HTTP 头用\r\n分帧。做 C# 上位机时设备端往往写死了一种方案你只能适配不能选。常见做法是写一个PacketParser维护一个内部缓冲把“收够了”的完整帧吐出来。public class PacketParser { private byte[] _pending Array.Emptybyte(); // data 是某一次 socket 接收到的原始字节可能包含 0 个、1 个或多个完整帧 public Listbyte[] Feed(byte[] data) { var frames new Listbyte[](); _pending _pending.Concat(data).ToArray(); int offset 0; while (offset 4 _pending.Length) { // 长度头取 4 字节小端之前先和设备手册核对大小端 int len BitConverter.ToInt32(_pending, offset); if (len 0 || len 1024 * 1024) { throw new InvalidDataException($invalid frame length {len}); } if (offset 4 len _pending.Length) break; // 半包还差几个字节等下一次 Feed byte[] frame new byte[len]; Array.Copy(_pending, offset 4, frame, 0, len); frames.Add(frame); offset 4 len; } _pending _pending.Skip(offset).ToArray(); // 剩余没凑齐的字节留到下一轮 return frames; } }重点在_pending这个成员变量每次收到的数据只追加不丢弃只有确认完整帧才从缓冲区移除。BitConverter.ToInt32默认读小端很多嵌入式设备发的是大端如果不核对所有长度超过一个字节的帧都会错位。遇到长度异常要果断丢弃整条连接的缓存而不是尝试恢复因为错位之后不可能再找到正确的帧边界。帧长上限设 1MB 是防止恶意或失控设备把缓冲区撑爆实际按业务包大小调整到最大帧的 1.5 倍即可。4.2 半开连接检测心跳超时扫描与清理代码TCP 有一个特点只要你敢不处理客户端拔电、断网、跳闸服务器端永远不知道。TCP 的 keepalive 探测默认要等 2 小时等不起。应用层心跳的做法是服务端给每个会话记一个LastActive后台定时任务每隔一段时间扫描一次超时就把连接当死连接关掉。// 在 TcpServer 里加一个心跳扫描定时器 private Timer _heartbeatTimer; private void StartHeartbeat(int intervalSeconds, int timeoutSeconds) { _heartbeatTimer new Timer(_ { DateTime now DateTime.UtcNow; foreach (var session in _allSessions.Values) { bool expired (now - session.LastActive).TotalSeconds timeoutSeconds; if (expired) { Console.WriteLine($[heartbeat] {session.Socket.RemoteEndPoint} timeout, close); session.Close(); } } }, null, TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(intervalSeconds)); }Timer 回调里不能做耗时操作它只负责判断超时和关闭连接不要在里面调用业务逻辑。参数的经验范围intervalSeconds取 10timeoutSeconds取 30 到 45也就是最多允许丢 3 次心跳间隔太短会误杀慢设备太长又等不起。_allSessions建议用ConcurrentDictionary存会话键用会话 Id 而不是 Socket因为 Socket 在断线重连后对象会变但同一台设备的业务身份应该由上层去区分。这里的心跳是服务器主动清理机制如果设备端协议本身有周期性上报就不需要再发心跳帧如果设备不发服务器要主动发探测帧比如发一个PING\r\n等PONG收到任何字节都刷新LastActive。5. C# TCP Server 避坑五个让服务器“看起来正常却反复翻车”的疑难这部分是血泪经验也是我做 C# 上位机以来被问得最多的五个问题。每个我都踩过按“现象→原因→解决”说清楚能救一个是一个。5.1 端口 10048 玄学为什么停止重启后端口还在被占用现象调试器里 CtrlF5 停掉服务马上重新启动抛SocketException: 通常每个套接字地址(协议/网络地址/端口)只允许使用一次。看起来就像端口被占用netstat -ano | findstr :9000又查不到监听进程。原因Windows 上关闭监听 Socket 后连接进入 TIME_WAIT四次挥手后主动关闭方要等 2MSL或前一个进程释放时有延迟。默认情况下该 Socket 不允许立即重新绑定同一地址。解决SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true)必须在Bind()之前调用顺序反了完全不生效。另一个更隐蔽的场景是 Stop 方法里只调了Close()但没把监听 Socket 置空下次 Start 用的是旧对象去 Bind 新端口同样报 10048。正确姿势是 Stop 时_listenSocket.Close()再_listenSocket null下次 Start 重新 new。5.2 客户端拔线后服务器毫无感知半开连接与内存涨现象客户端程序被强杀或直接拔网线服务器端界面看起来一切正常但内存和句柄数缓慢上涨过一会儿恢复再过一会儿又涨。原因拔线不掉连接TCP 在完全空闲时探测不到对端消失。服务端连接不关闭会话对象、接收缓存、Socket 句柄全部挂在内核和托管堆里。Receive只会因为这些连接永远阻塞不会通知你。解决把第 4.2 节的心跳扫描接上同时在ReceiveCallback的catch (SocketException ex)里判断ex.SocketErrorCode SocketError.ConnectionReset把异常当成“连接已死”处理别只在控制台打印。还有一个细节BeginReceive挂上后如果一直没数据缓冲区引用会被回调持有ReceiveState不能靠 GC 回收所以别把ReceiveState对象创建在临时变量里后就不管了要把它们放进会话对象统一管理。5.3 局域网连不上backlog 和 Windows 防火墙各占一半现象客户端TcpClient.Connect(192.168.x.x, 9000)抛超时或被拒但ping 192.168.x.x能通服务器端监听也显示正常。原因两个高频点并行存在。一个是另一台电脑的 Windows 防火墙默认拦截入站 TCPPing 用的是 ICMP不走 TCP 规则所以 Ping 通不代表端口通另一个是Listen(backlog)的 backlog 太小大量连接在 accept 队列里被内核直接丢弃表现为客户端连不上或连上就断。解决先开防火墙入站端口最直接的是给当前程序加一条入站规则在“高级安全 Windows Defender 防火墙”里新建入站规则协议 TCP、端口 9000、允许连接用命令行就是netsh advfirewall firewall add rule nametcpserver dirin actionallow protocolTCP localport9000。同时把监听 backlog 调到 100 以上注意Listen(int)的单位不是秒是最大挂起连接数。测试连通性时别用 Ping用客户端程序去 Connect 或者用Test-NetConnection 192.168.x.x -Port 9000这才是 TCP 层的结果。5.4 设备发来中文乱码编码不一致怎么都对不上现象收到的十六进制字节能打印但转成字符串后中文全是锟斤拷或者问号换成 UTF-8 还是乱。原因设备端用 GB2312/GBK 编码你默认用Encoding.UTF8去解码或者反过来设备发 UTF-8 你用 ASCII 解码。上位机领域最容易犯的错是想当然不查设备手册就写Encoding.Default。在 .NET Core/.NET 5 里Encoding.GetEncoding(GB2312)还会抛NotSupportedException因为代码页默认没注册。解决先按设备手册确认编码名然后Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); Encoding gb Encoding.GetEncoding(GB2312); string text gb.GetString(payload);RegisterProvider是 .NET Core 里才能用.NET Framework 不需要。乱码问题的好习惯是凡是中文文本调试时先打十六进制对比设备手册里的编码表不要直接在界面上看对不对界面显示容易骗人十六进制不会。5.5 在 Receive 回调里写耗时业务收包越顺畅丢包越严重现象单连接时一切正常多个客户端同时发数据出现“这个客户端的包处理了那个客户端的包过了好几秒才处理”甚至 TCP 客户端报发送超时但服务器 CPU 使用率并不高。原因ReceiveCallback跑在线程池线程上它把数据解析、写数据库、刷新界面全部做完了才调下一次BeginReceive。这段耗时里当前连接的新数据在内核缓冲区堆积其他连接的 accept 也被拉长。回调函数不是“后台任务”它是网络收发流水线上的一环拖不得。解决回调里只做两件事——拷贝字节、入队。下一章给的就是这个标准解法。6. 进阶技巧用 ConcurrentQueue 把收发、心跳、业务串成一条调度线程TcpServer类的DataReceived事件不要直接抛给 UI 线程也不要抛给回调线程。正确做法是加一个ConcurrentQueueFrameContext回调只负责入队单独开一条消费线程按顺序处理。这个结构看起来多了一层实际收益很大网络回调永远不被业务拖住业务处理也不会被网络频率打乱。public class FrameContext { public ClientSession Session { get; } public byte[] Data { get; } public FrameContext(ClientSession session, byte[] data) { Session session; Data data; } } private readonly ConcurrentQueueFrameContext _queue new(); private volatile bool _stopped; private void OnDataReceived(ClientSession session, byte[] data) { _queue.Enqueue(new FrameContext(session, data)); } private void DispatchLoop() { while (!_stopped) { if (_queue.TryDequeue(out FrameContext ctx)) { byte[] response HandleFrame(ctx); // 协议解析、业务处理都在这里做 if (response ! null) ctx.Session.Socket.Send(response); } else { Thread.Sleep(5); } } }Thread.Sleep(5)是避免空转的常用让步方式追求更高性能可以用ChannelFrameContext里的ReadAsync配合async/await实现事件驱动的队列消费。消费线程只有一个天然保证同一帧的处理顺序也避免了多回调线程同时写界面导致的跨线程异常如果处理不够快队列堆积反而是好事——它能让你判断业务吞吐瓶颈而不会把网络层拖进泥潭。我之前就是偷懒在ReceiveCallback里直接做解析和绘图连接数到 6 个时前 3 个客户端的数据要等 2 秒才出结果界面像死机。改成队列加单消费线程之后这个毛病几乎没再犯过。这也是我后来看所有 C# 上位机项目时优先检查的一个细节——只要回调里出现了数据库或控件操作代码再漂亮也建议重构。TCP Server 本身不难难的是把“收、解、处、发”四个环节的职责切开。这个方向值得投入骨架一次搭好后面适配各种设备协议只是换个PacketParser和HandleFrame的事。希望帮到你。本文还有配套的精品资源点击获取