简介:这是一份用 C# 语言实现的 TCP 通信服务器端程序,面向需要学习 Socket 编程或搭建局域网通信服务的初中级开发者,也适合计算机网络相关课程的学生进行实验参考。程序以 Visual Studio 解决方案形式组织,核心代码包括服务器主类、程序入口以及点对点服务类,完整展示套接字创建、端口监听、客户端接入和消息收发的基本流程,同时也涉及消息编码与异常处理等常见细节,适合作为课程设计或毕业设计的基础原型。压缩包共含 26 个文件,整体大小仅 50KB,属于轻量级示例工程。除 7 个 C# 源文件外,还提供了可直接运行的执行文件、调试所需的符号文件、界面资源文件、工程与解决方案配置以及文本说明,其中源码目录、编译输出目录和资源目录划分明确,便于打开学习或二次修改。目前已有 344 人学习下载,可帮助读者快速建立 TCP 服务端编程的整体认识,并为后续扩展多线程并发、心跳检测或异步通信提供清晰的改造思路,实用性较强。
1. C# TCP Server 值不值得下:先想清楚它是给谁用的
C# TCP Server 这份资源,说白了就是一个能直接跑的 TCP 服务器端工程。不管你是做上位机、数据采集还是设备联调,迟早会遇到"写个程序把设备数据收上来"的需求。很多第一次碰 socket 的 C# 开发者,会觉得自己写的不是代码是玄学——客户端连不上、收发卡死、数据对不上,十有八九不是协议问题,而是对 TCP 流式传输的行为理解不透。这篇拆解把 TCPServer 源码的核心段落逐块讲清楚:两种建连方式怎么选、粘包怎么切、断线怎么检测、上线前怎么压测。照着走,比从零翻 MSDN 快得多,适合刚入门的 C# 开发者和正在调上位机通信的工程师。
2. 从 TcpListener 到 Socket:两类建连方式的选型与骨架
2.1 TcpListener 封装:适合起步与低频命令交互
C# 里写 TCP 服务端,最快的是 TcpListener。它把 bind、listen、accept 都包了一层,处理"客户端连上来、发一条命令、等一条回复"这类低频交互非常够用。下载站上那份 TCPServer 资源,对应关键词就是 C# TCP server / TCP socket / TCP通信服务器端程序,解压出来第一版骨架就是这套。
先看监听和接受连接的代码:
// 在 9000 端口上监听所有本机网卡 TcpListener listener = new TcpListener(IPAddress.Any, 9000); listener.Start(50); // 50 是挂起连接队列长度,超过会被对端当拒连处理 while (!_cts.IsCancellationRequested) { TcpClient client = await listener.AcceptTcpClientAsync(); // 每个客户端开一个任务独立处理,避免一个卡住全队堵车 _ = HandleClientAsync(client, _cts.Token); }然后是接收循环:
private async Task HandleClientAsync(TcpClient client, CancellationToken token) { using (client) using (NetworkStream stream = client.GetStream()) { byte[] buffer = new byte[4096]; while (!token.IsCancellationRequested) { int n = await stream.ReadAsync(buffer, 0, buffer.Length, token); if (n == 0) break; // 对端正常关闭,读返回 0 // 这里拿到的是裸数据,下一步要做拆包,见第 3 章 ProcessRawData(buffer, n); } } }逻辑说明:TcpListener 的 Start(int) 参数是 backlog,即内核为监听 socket 排队等待 accept 的连接数。不传默认为 int.MaxValue,生产环境建议显式给 50 或 100,否则客户端瞬间并发上来时队列溢出,对端会收到拒连。AcceptTcpClientAsync 是异步方法,不会阻塞主线程,放在 WinForm、WPF 上位机里不会导致界面卡死。
参数说明:IPAddress.Any 表示监听所有本地 IP,如果只监听内网特定网卡,改成 IPAddress.Parse("192.168.1.10")。buffer 4096 在多数工控报文场景够用,但传大图或文件流时要加大到 8192 以上,并配合第 3 章的拆包逻辑,不能拿裸接收去拼文件。
TcpListener 的缺点也明显:每次 Accept 都新建 TcpClient 对象,高频连接下分配开销大,对 Socket 层高级选项控制弱。资源包里保留这一版,是给刚接触 TCP 的人一条最容易上手的路。真正压吞吐量,要切到原生 Socket。
2.2 原生 Socket:把网络层选项握在自己手里
原生 Socket 代码量变长,但换来对网络行为的完全控制。典型的服务端监听骨架:
Socket listenSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); // 允许端口复用,否则服务端重启时会遇到“每个套接字地址只允许使用一次” listenSocket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true); listenSocket.Bind(new IPEndPoint(IPAddress.Any, 9000)); listenSocket.Listen(100);接收连接用 AcceptAsync 池化方案:
private void BeginAccept() { Socket client = listenSocket.EndAccept(_acceptArgs); // 每次用完要把 AcceptSocket 置空,否则下次会复用旧连接 _acceptArgs.AcceptSocket = null; // 分发给处理逻辑 HandleClient(client); listenSocket.AcceptAsync(_acceptArgs); }逻辑说明:Socket 直接面向 TCP/IP 协议栈,SetSocketOption 设置的 ReuseAddress 非常关键。服务端异常退出再重启,内核的 TIME_WAIT 连接还没释放完,新进程 bind 同一端口就报"每个套接字地址(协议/网络地址/端口)只允许使用一次"。这行写在 bind 之前,是资源包里有意为之的设计。
参数说明:AcceptAsync 基于 IO 完成端口,连接量大后比 Begin/End 模式减少回调分配。它返回 false 表示同步完成,true 表示异步进行中,两种情况都要在 Completed 回调里 EndAccept 并重新投递下一次。漏了重新投递,是"服务端跑一晚上就再也连不上"的常见原因。
建连部分到这里有两条路线:快出活、内网低频交互,用 TcpListener;要控制端口复用、准备异步池化,用原生 Socket。我一般把两套骨架都留在工程里,联调阶段拿 TcpListener 跑通协议,上线前换原生 Socket 版本压测。
3. 把收发缓冲区设计好:粘包、半包与断线识别三板斧
3.1 定长包头 + 长度字段:TCP 粘包拆包的常规解法
TCP 是流协议,没有消息边界,这是所有 socket 新手第一次翻车的地方。send 两次数据,服务端可能合成一次收到;send 一次大数据,又可能被切成两段。所谓粘包、拆包,本质是应用层没做定界。最常见的解法是自定义协议头:前 4 字节存报文长度,后面跟报文体。服务端接收时要先把数据收进累计缓冲区,再按长度字段逐条切出完整报文。
资源包里的拆包逻辑:
private Queue<byte[]> SplitPacket(byte[] incoming, ref byte[] cache) { Queue<byte[]> ready = new Queue<byte[]>(); // 新数据追加到已有缓冲末尾 byte[] merged = new byte[cache.Length + incoming.Length]; Buffer.BlockCopy(cache, 0, merged, 0, cache.Length); Buffer.BlockCopy(incoming, 0, merged, cache.Length, incoming.Length); cache = merged; while (cache.Length >= 4) { int bodyLen = BitConverter.ToInt32(cache, 0); // 前 4 字节是长度 if (cache.Length < 4 + bodyLen) break; // 半包,继续等 byte[] body = new byte[bodyLen]; Buffer.BlockCopy(cache, 4, body, 0, bodyLen); ready.Enqueue(body); // 切掉已消费的头部和消息体 byte[] rest = new byte[cache.Length - 4 - bodyLen]; Buffer.BlockCopy(cache, 4 + bodyLen, rest, 0, rest.Length); cache = rest; } return ready; }逻辑说明:Buffer.BlockCopy 按字节复制,不涉及类型转换,网络字节流里效率最高。BitConverter.ToInt32 把长度字段当小端 int 解析;如果协议约定大端,要配合 IPAddress.NetworkToHostOrder 转换字节序,否则对端发来的长度在这边会变成天文数字,直接撑爆缓冲区。
参数说明:4 字节长度上限约 2GB,对绝大多数设备和上位机交互够用;报文可能超大的场景要改 8 字节长度字段。cache 是每个连接独立的缓冲区,不能在多个客户端线程间共享,否则拆包会串数据。while 循环一次能切出所有完整报文,比一条消息来一次回调更高效。
注意:bodyLen 要做一层上限校验(比如不能超过 16MB),否则异常报文会把内存吃满。资源包实现里默认加了 16MB 检查,实际部署时按你的报文上限再收紧。
3.2 心跳与断线识别:服务端别死等一个已经不存在的客户端
TCP 三次握手建立连接后,对端突然断电、拔网线,服务端感知不到,ReadAsync 会一直阻塞到超时。工控现场拉闸是家常便饭,服务端不能干等。资源包的做法是每个连接记录最后活跃时间,后台定时器周期性扫描,超过阈值直接掐掉:
private async Task HeartbeatCheckAsync(ConcurrentDictionary<long, ClientSession> sessions, int timeoutSec) { while (!_cts.IsCancellationRequested) { await Task.Delay(1000); var now = DateTime.UtcNow; foreach (var kv in sessions) { if ((now - kv.Value.LastSeen).TotalSeconds > timeoutSec) { kv.Value.Socket.Close(); sessions.TryRemove(kv.Key, out _); } } } }逻辑说明:LastSeen 在每次收到完整报文时更新,服务端不主动发心跳包,这是被动策略。它要求客户端周期性上报数据,适合设备本来就持续上报的场景。如果客户端可能长时间静默,要换成服务端主动发 Ping、客户端回 Pong、两次没回就断。资源包里两种模式都写了,默认走被动策略,因为改动最小。
参数说明:timeoutSec 根据业务数据频率定,建议是正常上报间隔的 3 到 5 倍。设备每 5 秒上报一次,超时设 15 秒;太小会误判正常间隙为掉线,太大失去心跳意义。扫描间隔 1 秒是通用值,连接数以千计时,扫描本身有开销,定时器可拉长到 5 秒一轮。
血泪经验:心跳超时后不要只 Close 等 GC,要主动 Shutdown(SocketShutdown.Both) 再 Close 回收句柄。直接 Close 在 Windows 上走 RST 流程,对端能感知异常,但本端资源释放不如 Shutdown 后再 Close 干净。
4. 多客户端连接的线程模型:并发处理的开销与取舍
4.1 每连接一线程的适用边界
很多新手拿到 TCPServer 的第一反应是:来一个客户端就 new 一个 Thread。这在 20 个连接以内没毛病,代码直观、调试方便。但线程栈空间默认 1MB,每个线程还有内核对象开销,连接数到几百就明显吃力;如果连接里还有阻塞读,线程就挂在 Read 上,纯浪费。
资源包保留了每连接一线程版本,并标注适用边界:连接数几十、报文频率低、协议调试阶段:
Thread t = new Thread(() => HandleClientRaw(client)); t.IsBackground = true; t.Start();逻辑说明:IsBackground = true 很关键。主程序退出时,后台线程随进程终止,不会被挂起的 Read 阻塞拖住,控制台和上位机都能利落退出。漏了这行,关程序时会发现进程退不掉,任务管理器里残留进程占着端口。
参数说明:Thread 方式没有内置取消机制,关闭服务端时线程还在阻塞读,只能靠 Close socket 让 Read 抛异常退出。所以这个模型只用于联调期,正式版本切 Task 或异步循环。
4.2 Task 异步接收:代码可读性与并发能力的平衡点
Task 比裸线程轻得多,配合 await Socket.ReceiveAsync 或带 CancellationToken 的 ReadAsync,接收循环写起来几乎和同步代码一样直白,又能撑住上千连接。这是资源包正式推荐的模型:
private async Task ReceiveLoopAsync(Socket socket, CancellationToken token) { byte[] head = new byte[4]; while (!token.IsCancellationRequested) { int n = await socket.ReceiveAsync(head, SocketFlags.None, token); if (n == 0) break; if (n < 4) { /* 长度头没读全,需要补读,完整实现见资源包 */ } int bodyLen = BitConverter.ToInt32(head, 0); byte[] body = new byte[bodyLen]; int offset = 0; while (offset < bodyLen) { int m = await socket.ReceiveAsync(body.AsMemory(offset), SocketFlags.None, token); if (m == 0) break; offset += m; } ProcessOne(body); } }逻辑说明:ReceiveAsync 的泛型版本一次能只读 4 字节头部,也可配合 Memory 把报文体读满。第二个 while 循环专门处理半包——TCP 不保证一次读满 bodyLen,所以要按已读位置继续收,直到凑齐。逐段补读比把缓冲区开大更可控,内存占用稳定。
参数说明:SocketFlags.None 表示普通读。异步方法传 CancellationToken,停机时直接取消所有连接的读循环,比 Close 抛异常优雅——走 OperationCanceledException 而不是 SocketException,日志里能分清是主动取消还是网络故障。
Task 模型不是没代价:每个连接仍要维护接收缓冲和状态对象,连接数过万后 GC 压力是主要矛盾。真上万连接,要换 SocketAsyncEventArgs 池化方案,把实例在连接间复用,这是 .NET 官方推荐的高性能路径,但代码复杂度明显上升。资源包先给 Task 版本,是让你先在功能和性能间找到能落地的点。
5. 排查与避坑:端口占用、半开连接与防火墙三座山
5.1 现象:服务端重启报"每个套接字地址只允许使用一次"
原因:进程上次退出时连接没正常关闭,TCP 进入 TIME_WAIT 状态,默认要等 2 分钟(MSL 两倍)才能重新绑定同一端口。Windows 上服务端退出、客户端异常断开都可能留下这个状态。
解决:代码里 bind 前加 ReuseAddress,前面 2.2 节已经提到,这是服务端程序的"后悔药"。如果加上还报错,用命令查哪个进程占着端口:
netstat -ano | findstr :9000拿到 PID 后在任务管理器核对进程名,确认是残留服务再结束。不要无脑杀 pid,可能误杀别的服务。
5.2 现象:客户端断电后服务端 Read 一直阻塞,连接像幽灵一样挂着
原因:纯收数据的协议里,服务端对断电没有感知,内核不会立刻通知。这就是 3.2 节心跳要解决的核心问题。
解决:按资源包的 HeartbeatCheckAsync 做超时清理。不想改代码,可以给 Socket 开操作系统级探活:
socket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.KeepAlive, true);但系统探活默认间隔约 2 小时,实际用起来很难受,应用层心跳才可靠。
5.3 现象:服务端明明在监听,客户端就是连不上
原因:Windows 防火墙默认拦截入站 TCP 端口,工控现场内网机器常见这个坑。先在服务端放行端口:
netsh advfirewall firewall add rule name="TCPServer 9000" dir=in action=allow protocol=TCP localport=9000排查时先确认端口通再回来看代码:
netstat -ano | findstr LISTENING也能用 Test-NetConnection 或 telnet 试连通性,把网络层和代码层隔离开。
5.4 现象:小包高延迟,收发正常但感觉卡顿
原因:Nagle 算法在未收到确认前合并小包,对频繁交互的工控协议是灾难。服务端和客户端都要把 NoDelay 设为 true:
TcpClient tcp = new TcpClient(); tcp.NoDelay = true; // 原生 Socket 版本 socket.NoDelay = true;设置完再观察到达间隔,还慢就查对端是否也启了 Nagle,单改一边没用。NoDelay 关闭 Nagle 合并,代价是每个小包独立发送,在低带宽链路会放大开销。内网跑没问题,跨公网传输要权衡。
如果连接建立成功但数据收不全,先把这条命令的输出过一遍:
netsh int tcp show global查有没有全局参数把窗口或时间戳改坏了,比如 timestamps 被强制开启或关闭,会影响某些协议栈兼容性。多数时候保持默认就行。
5.5 现象:服务端重启瞬间旧客户端疯狂抛 SocketException
原因:客户端没有处理服务端主动关闭后的重连退避,一个进程起来就狂连,把日志刷爆。
解决:客户端重连加指数退避,第一次等 1 秒,失败后 2 秒、4 秒、8 秒,最大 30 秒封顶:
private async Task ReconnectWithBackoffAsync(CancellationToken token) { int delay = 1; while (!token.IsCancellationRequested) { try { await ConnectAsync("127.0.0.1", 9000); return; } catch { await Task.Delay(TimeSpan.FromSeconds(delay), token); } delay = Math.Min(delay * 2, 30); } }逻辑说明:指数退避把重连风暴变成稳定渐进的试探,服务端刚起来时不会被几百个客户端同时冲击。参数说明:delay 初始 1 秒,上限 30 秒,适合绝大多数内网场景;跨公网可以把上限放到 60 秒。
6. 上线前压测与保活:验证并发与断线恢复的最后一步
资源包做到能跑只是第一步,上线前我会固定做两轮验证。第一轮是并发压测,用 C# 写一个多任务模拟客户端,同时建立 100 个连接,每个连接循环收发 1000 条报文,观察服务端有无丢包、卡死或内存暴涨:
var tasks = Enumerable.Range(0, 100).Select(i => Task.Run(async () => { using var client = new TcpClient(); await client.ConnectAsync("127.0.0.1", 9000); for (int j = 0; j < 1000; j++) { byte[] body = Encoding.UTF8.GetBytes($"msg-{i}-{j}"); byte[] head = BitConverter.GetBytes(body.Length); await client.GetStream().WriteAsync(head.Concat(body).ToArray()); // 读回包,验证协议闭环 } })); await Task.WhenAll(tasks);这轮压测能一次性暴露四种问题:拆包逻辑在并发下是否串数据、接收缓冲区是否有线程安全缺陷、半包补读是否能撑住乱序到达、服务端在 GC 压力下表现如何。压测时盯着任务管理器的内存和句柄数,句柄只增不减,大概率有连接没走完 Shutdown/Close 流程。
第二轮是断线恢复。我会在压测进行到一半时,用任务管理器直接结束几个模拟客户端进程,再观察心跳逻辑能否在 timeoutSec 内把对应连接清理干净。想模拟拔网线,就把虚拟机网卡直接禁用,那才是真正的无通知断线,比 Close socket 更接近现场。
两轮跑完,TCPServer 才算真正能落地。我第一次上线就是在压测这步翻车的:当时只测了功能没测掉线,结果工位现场断电重连几次,服务器上挂了几十个僵尸连接,最后摸到心跳超时设置的问题。从那以后每次改服务端代码,我都强制走一遍"并发压测 + 断线恢复"这套组合,半小时验证完再挪到现场机。希望帮到你。
本文还有配套的精品资源,点击获取