简介:C#编写的TCP/IP服务端与客户端源码包,面向.NET网络编程学习者,提供一套完整、可直接运行的客户端/服务端通信实现。压缩包共273个文件,核心为77个.cs源码文件,另含20个.exe可执行程序,以及解决方案、工程配置、资源文件、说明文档等,整体约5.94MB,目录结构便于按项目模块查阅。服务端利用TcpListener循环监听端口,客户端通过TcpClient建立连接,并以NetworkStream完成双向数据收发;资源覆盖异常处理、多线程与异步操作等进阶思路,可支撑从基础通信到并发管理的学习路径。解决方案与工程文件齐全,可直接编译运行并观察完整交互流程。目前已有69人学习下载,适合课程设计、技术验证或毕设起步阶段,既可对照源码理解TCP/IP机制,也可作为二次开发的基础模板。
1. C# 写 TCP/IP 服务端与客户端:一个能跑通的最小闭环
做上位机或者设备联调的人,迟早会遇到这么一件事:手头设备只给了一个 TCP 端口,协议文档写得模棱两可,你要自己写个服务端收数据,或者写个客户端去连对方的服务端。网上搜 C# TCPIP 服务端与客户端源码,能搜出一堆,但多数要么只贴了循环接收的片段,要么直接丢给你两个能跑但完全听不懂的工程。这篇笔记我按自己拆过的一套源码来讲——它有完整服务端、完整客户端、带心跳和断线重连,能直接编译跑起来,也适合拿去做二次开发。新手可以按步骤复现一遍,跑通了再看接收缓冲区和拆包的逻辑;熟手可以直接跳到第 5 章的踩坑部分,那几条都是实际联调里翻过车的。
2. 先把协议定清楚:同步模型、报文体与端口选型
2.1 为什么不是 Socket 裸写,而是用 TcpClient / TcpListener
很多新手一搜到 C# Socket 教程,上来就 new Socket 然后 Bind、Listen、Accept 一套组合拳。这套 API 能跑,但写多客户端管理的时候,要自己维护 Socket 列表、自己处理异常断开、自己处理半包,代码很容易散。常见做法是服务端用 TcpListener 包装监听逻辑,客户端用 TcpClient 包装连接逻辑,底层还是 Socket,但网络流的读写直接用 NetworkStream 完成,省掉很多底层细节。
这套源码里服务端用的就是 TcpListener + NetworkStream,客户端是 TcpClient + NetworkStream。同步还是异步这个要选清楚:异步写法(BeginAccept / ReadAsync)能撑更高的并发,但调试时调用链难跟;同步写法代码直白,配合 Task.Run 放后台线程,单机几百路连接完全够用。对大多数设备联调场景,我一般建议先落地同步模型,跑通业务后再根据连接数改异步,不要一上来就异步,出了问题连断点都不好打。
// 服务端启动监听的核心骨架 TcpListener listener = new TcpListener(IPAddress.Any, 9000); listener.Start(); Console.WriteLine("服务端已启动,监听端口 9000"); while (true) { TcpClient client = await listener.AcceptTcpClientAsync(); _ = HandleClientAsync(client); // 丢到后台处理,不阻塞接收新连接 }这里 AcceptTcpClientAsync 是异步接收客户端连接,await 关键字保证监视线程不被卡死。HandleClientAsync 是处理单个客户端的方法,每个客户端进来都开一个独立 Task 跑,这样多个设备同时上报时不会互相排队影响。
2.2 报文体设计:为什么建议带长度字段而不是靠分隔符
协议这块是最容易返工的地方。市面上常见做法有两种:一种是按 \r\n 或者 0xFF 结尾去拆包,简单,但业务数据里如果恰好出现结尾字符,就翻车;另一种是包头带长度字段,比如 4 字节 Int32 表示后续报文长度,再接正文。我在实际项目里几乎只用第二种,而且建议你也用第二种,尤其是做设备通信——设备端报文里什么字节都可能出现,靠分隔符太脆弱。
这套源码采用的是「4 字节长度 + JSON 正文」的简化协议:先收 4 个字节解析出长度,再按长度读满正文。为什么不直接用 JSON 序列化后按流读取?因为 TCP 是流协议,底层不知道你的报文边界在哪,发两次数据可能一次收到,发一次数据可能分两次收到,不自己定边界就无法可靠还原。这就是后面要讲的粘包半包问题的根源,协议设计从这里就已经决定了。
// 将对象序列化为 JSON 并封包:4字节长度 + 正文 static byte[] BuildPacket(object message) { string json = JsonConvert.SerializeObject(message); byte[] body = Encoding.UTF8.GetBytes(json); byte[] header = BitConverter.GetBytes(body.Length); return header.Concat(body).ToArray(); }BitConverter.GetBytes 生成的是小端字节序的 4 字节长度头,Concat 把包头和正文拼成一个完整的包。注意这里有个隐含约定:如果对方设备是用 C/C++ 写的,默认也是小端,一致;但如果对端是大端序的 Java 服务,或者做了大端处理的单片机,这里就要先做字节序转换,否则长度解析全错,这是很多联调事故的根源。
2.3 端口号与防火墙:9000 这个端口不是随便选的
选端口有个实用原则:避开知名端口(80、443、8080),避不开也尽量不用 1024 以下的。常见做法是挑 9000 到 19000 之间的端口,不容易跟本机其他服务冲突。源码里默认监听 9000,实际部署时建议改成 9001 或其他高位端口。
还要提醒一点,Win10 以上系统默认会有一部分 TCP 动态端口范围,用 netsh int ipv4 show excludedportrange tcp 能看到,选端口前先看一眼,免得监听时偶发报「地址已被占用」的错。防火墙也要注意,服务端第一次启动时 Windows 会弹防火墙授权窗口,局域网内的客户端连不上通常是这个原因,后面第 5 章会展开说。
3. 服务端实现:TcpListener 下的多客户端管理与拆包
3.1 客户端连接管理:ConcurrentDictionary 存会话
服务端不能只跑一个客户端,实际场景里至少得同时挂三五台设备。源码里用一个 ConcurrentDictionary<string, TcpClient> 保存当前所有在线客户端,key 可以用设备编号或者客户端连接时首包上报的 ID。为什么不直接用 List?因为多线程同时读写,List 要自己加锁,ConcurrentDictionary 内部做了分段锁,省心。
每次客户端连接进来,先收到一条握手消息,里面带上设备号,服务端把它注册进字典;断开时从字典移除。这里有个关键处理:客户端断开是异常驱动的,不能靠轮询去探测,要在读数据抛异常或返回 0 字节时触发清理逻辑,否则字典里全是死连接,内存和端口都会慢慢耗尽。
// 处理单个客户端连接的完整方法骨架 private async Task HandleClientAsync(TcpClient client) { string deviceId = null; try { using (client) using (NetworkStream stream = client.GetStream()) { // 第一步:等待握手消息,拿到设备ID byte[] handshakeBuffer = new byte[1024]; int read = await stream.ReadAsync(handshakeBuffer, 0, handshakeBuffer.Length); deviceId = ParseDeviceId(handshakeBuffer, read); _clients.TryAdd(deviceId, client); // 第二步:循环读报文 while (true) { byte[] packet = await ReadCompletePacketAsync(stream); if (packet == null) break; await HandlePacket(deviceId, packet); } } } catch (Exception ex) { Console.WriteLine($"[{deviceId}] 连接异常: {ex.Message}"); } finally { if (deviceId != null) { _clients.TryRemove(deviceId, out _); Console.WriteLine($"[{deviceId}] 已断开,当前在线 {_clients.Count} 台"); } } }这段代码有几个细节要注意:using 保证连接最终会释放;ReadAsync 返回 0 说明对方正常关闭;TryAdd、TryRemove 都是线程安全的。这里的关键是你如何区分「一次性握手消息」和「持续的数据报文」——如果每台设备的握手消息格式不同,就需要在 ParseDeviceId 里做不同的解析分支。
3.2 拆包逻辑:ReadCompletePacketAsync 的完整实现
这是整个服务端最容易写错的地方。TCP 流的边界不在你发送的报文边界上,必须自己收满一个完整的包。实现思路是:先读 4 字节长度头,再根据长度头循环读 body,直到读满为止。重点在于循环 Read 的时候,第一次可能只读到了部分 body,要计算剩余字节数继续读。
// 从 NetworkStream 读取一个完整报文,解决粘包半包问题 private static async Task<byte[]> ReadCompletePacketAsync(NetworkStream stream) { // 第一步:完整读取 4 字节长度头 byte[] header = new byte[4]; int headerRead = 0; while (headerRead < 4) { int n = await stream.ReadAsync(header, headerRead, 4 - headerRead); if (n <= 0) return null; headerRead += n; } // 第二步:解析出 body 长度 int bodyLength = BitConverter.ToInt32(header, 0); if (bodyLength <= 0 || bodyLength > 1024 * 1024) throw new InvalidDataException($"非法报文长度: {bodyLength}"); // 第三步:循环读够 body byte[] body = new byte[bodyLength]; int bodyRead = 0; while (bodyRead < bodyLength) { int n = await stream.ReadAsync(body, bodyRead, bodyLength - bodyRead); if (n <= 0) return null; bodyRead += n; } return body; }这个拆包方法是全篇的核心,三个关键点对应三个常见 bug:第一,header 必须循环读满 4 字节,不能以为一次 ReadAsync 就能拿全;第二,bodyLength 要做合法性校验,否则对端发来一个超大长度值,new byte[bodyLength] 会直接抛 OutOfMemory;第三,body 的偏移量是 bodyRead,不是 0,否则第二次 Read 会把前面的数据覆盖。
这里我踩过一次:以前图省事,header 只读一次,结果在高并发小报文场景下偶发解析出错误长度,表现为客户端莫名其妙断线,服务端日志全是 InvalidDataException。后来抓包才发现第一次 Read 可能只读到了 2 字节,后面 2 字节跟 body 的第一个字节一起到了,必须循环读。
3.3 心跳与超时:断线检测不能只靠 TCP
TCP 本身有 KeepAlive 机制,但默认触发时间太长(Windows 默认 2 小时),设备突然断电、网线被拔这种场景,服务端可能要很久才能感知。源码里使用的是应用层心跳:客户端每 10 秒发一个心跳包,服务端如果连续 30 秒没收到任何数据,就判定超时,主动断开并清理会话。
// 服务端超时检测:最后活跃时间 + 定时扫描 public class ClientSession { public string DeviceId { get; set; } public TcpClient TcpClient { get; set; } public DateTime LastActive { get; set; } } // 定时器每 5 秒扫描一次 private void CheckTimeout(object state) { DateTime now = DateTime.Now; foreach (var kv in _sessions) { if ((now - kv.Value.LastActive).TotalSeconds > 30) { kv.Value.TcpClient.Close(); _sessions.TryRemove(kv.Key, out _); Console.WriteLine($"[{kv.Key}] 心跳超时,已强制断开"); } } }这个做法的思路是服务端不主动关闭连接,只被动记录。LastActive 在收到任何完整报文时更新一次,包括心跳和数据报文。定时器每 5 秒扫一遍,超过 30 秒没活跃就 Close。Close 之后客户端的读操作会立刻抛异常,从而触发 3.1 里的 finally 清理逻辑,这样整个生命周期是闭环的。
4. 客户端实现:TcpClient 连接、重连与异步接收
4.1 客户端基类:连接、发送、断线重连的事件模型
客户端在结构上跟服务端不是简单镜像关系,它更关注「断线了怎么办」。设备端一般不会自己重启,客户端作为上位机或者采集端,要能在服务端重启、网线松动、服务端换 IP 后自动恢复。这套源码里客户端封装了一个重连机制:断线后按 1s、2s、5s、10s 倍增重试,最多 30s 封顶,无限重试直到成功或用户取消。
// 客户端核心逻辑:循环尝试连接 public async Task ConnectWithRetryAsync(string host, int port) { int retryDelay = 1000; while (!_cancelled) { try { _client = new TcpClient(); await _client.ConnectAsync(host, port); _client.NoDelay = true; Console.WriteLine($"连接成功: {host}:{port}"); OnConnected?.Invoke(); await ReceiveLoopAsync(); } catch (Exception ex) { Console.WriteLine($"连接异常({retryDelay}ms 后重试): {ex.Message}"); } if (_cancelled) break; await Task.Delay(retryDelay); retryDelay = Math.Min(retryDelay * 2, 30000); // 指数退避,上限30秒 } }指数退避的设计参考了 TCP 慢启动的思路:刚断线时频繁重试没意义,还不如等网络稳定;重试间隔逐步加大,避免对服务端造成连接风暴。NoDelay = true 也比较关键,它关闭了 Nagle 算法,小报文不会在缓冲里滞留,对实时性要求高的设备通信建议加上。客户端的所有业务逻辑都挂在 OnConnected 和 OnDataReceived 这两个事件上,换项目时只要改事件处理函数就行。
4.2 客户端接收循环与发送封装
客户端的接收逻辑跟服务端一样要处理拆包,同一个 ReadCompletePacketAsync 直接复用。差别是客户端要额外处理收到服务端主动下发的指令,比如服务端要求设备回传当前状态。发送这边做了一层线程安全封装:多个线程可能同时调用发送,NetworkStream.Write 本身不是线程安全的,并发写会报异常或者数据交错,所以要加锁。
// 线程安全的发送封装 private readonly object _sendLock = new object(); public void Send(object message) { if (_client == null || !_client.Connected) throw new InvalidOperationException("客户端未连接"); byte[] packet = BuildPacket(message); lock (_sendLock) { NetworkStream stream = _client.GetStream(); stream.Write(packet, 0, packet.Length); stream.Flush(); } }lock 确保同一时刻只有一个线程在写流,避免数据帧交错。这里最易犯的错误是以为 TcpClient.Connected 属性可靠——它只反映最近一次 IO 操作时的状态,不能作为连接是否有效的依据。判断连接是否可用,标准做法是尝试一次小数据读取或发送。源码里错误处理逻辑是:发送抛 IOException 时触发重连,并在下次心跳时确认通道是否真的重新建立。
4.3 和上位机场景的衔接:数据如何推向 UI
如果你是在 WinForms 或 WPF 里做上位机,接收线程不能直接更新 UI 控件,否则会报线程间操作无效的异常。这块源码里用了一个简单方案:客户端收到 JSON 报文后解析为业务对象,通过事件抛出去,界面层用 SynchronizationContext 或者控件本身的 Invoke 方法切回 UI 线程。实测用 100ms 的定时器去读队列也行,但事件 + Invoke 响应更快。
// 接收数据后触发事件,UI 层订阅并切换线程 public event Action<string> DataReceived; private async Task ReceiveLoopAsync() { NetworkStream stream = _client.GetStream(); while (true) { byte[] body = await ReadCompletePacketAsync(stream); if (body == null) break; string json = Encoding.UTF8.GetString(body); DataReceived?.Invoke(json); } } // WinForms 中订阅 client.DataReceived += (json) => { textBox1.Invoke(new Action(() => textBox1.AppendText(json + "\r\n"))); };Invoke 的方式在数据频率低时够用。每秒几百条报文时建议改用生产者消费者队列,接收线程只入队,UI 定时器批量出队刷新,不然高频 Invoke 会把界面卡死。这个技巧对上位机场景尤其重要——处理设备实时上报扭矩、温度这类高频数据时,卡 UI 是最大的败笔。
5. 常见问题排查:粘包、端口占用与 UI 卡死的现场还原
5.1 粘包与半包:一次性收到多条报文导致 JSON 解析失败
现象:客户端连续两次调用 Send,服务端收到一条拼在一起的报文,反序列化报错「意外的字符」;或者一条长报文拆成了两截,服务端解析不完整。原因:TCP 是流协议,只保证字节顺序,不保证报文边界。发送两次的数据可能在一次 Read 里全部到达(粘包),一次大数据量的 Read 也可能只拿到报文的一部分(半包)。解决:不要改接收逻辑去硬拆,标准做法就是第 3.2 节那套「先读长度、再循环读满 body」的拆包器。如果你已经引入了这套逻辑还出问题,多半是长度头的字节序和对方不一致,两边的 BitConverter 结果对不上。检查方法是抓包看前 4 字节和 body 长度是否吻合。
5.2 端口被占用:服务端重启时报错「地址已被使用」
现象:开发机上一旦服务端崩溃重启,监听的 9000 端口要等几十秒才释放,或者直接报 SocketException:地址已被使用。原因:TCP 连接断开后进入 TIME_WAIT 状态,端口要等 2MSL(最大报文段生存期)才释放。常见做法是在开发调试时开启 SO_REUSEADDR,让端口可以快速重新绑定。解决:绑定监听端口前,设置 TcpListener 底层的 Socket 选项。源码里在 listener.Start() 之前,通过 listener.Server 配置 SocketOptionName.ReuseAddress。注意:生产环境要不要开取决于业务,如果端口在多个实例间快速重启切换,一般建议开;如果可能有两个实例同时绑定同一个端口来做负载均衡,就不能开。
TcpListener listener = new TcpListener(IPAddress.Any, 9000); listener.Server.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true); listener.Start();5.3 UI 线程卡死:上位机点击按钮后界面无响应
现象:WinForms 里点「开始连接」按钮后,整个窗口拖不动、按钮按不了,几秒后突然恢复,或者直接报异常。原因:在按钮点击事件里直接调用了同步的 Connect 或者 Read 方法,UI 线程被阻塞了。ConnectTimeout 是 20 秒时,界面就卡 20 秒。解决:所有网络操作放到 Task.Run 或 async/await 里,UI 线程只做状态展示。点击事件改成 async void 然后 await ConnectAsync,这个模式最简单。另外 5.3 里说的接收线程切 UI 也要注意,Invoke 在窗口关闭后调用会抛异常,关闭窗口前先把 DataReceived 事件取消订阅。
5.4 局域网内连接失败:客户端报超时但服务端日志无记录
现象:客户端能 ping 通服务端 IP,但 ConnectAsync 超时,服务端日志一行都没新增。原因:九成是 Windows 防火墙拦了服务端的入站连接。开发机上跑客户端和服务端没问题,换到局域网另一台机器就连不上,基本就是这个原因。解决:第一次启动服务端时弹出的防火墙授权要勾选「专用网络」,如果弹窗被忽略了,去控制面板手动添加入站规则,允许 TCP 9000 端口入站。注意:改了防火墙不用重启服务端,但测试前要先确认服务端的监听地址是 IPAddress.Any 而不是 127.0.0.1——后者只会监听本机回环地址,局域网内永远连不上。
5.5 大量数据时接收越来越慢:最终内存涨到几个 GB
现象:每秒超过 500 条报文时,内存肉眼可见地涨,GC 频繁,最终服务端卡死。原因:每次收到报文都 new 一个 byte[bodyLength],短连接场景无所谓,高并发场景频繁分配大数组导致 LOH(大型对象堆)碎片化;或者接收方消费速度跟不上生产速度,队列越积越多。解决:压测时把 body 长度上限调低并加限制;接收到的报文在 100ms 内处理完,不要在接收循环里做耗时操作;高频场景复用 byte[] 缓冲池,但这套源码默认没做池化,你需要自己加一个简单的 ArrayPool 或队列复用缓冲。90% 的上位机场景先做消费速度优化就够,别急着上内存池。
6. 联调与压测:用脚本验证服务端上限的一个土办法
源码交付时除了服务端和客户端两个 C# 工程,我还建议你保留一个 Python 脚本,用来做快速压力验证。为什么用 Python 而不是拿客户端工程来测?因为客户端有断线重连、事件回调这些业务逻辑,测的是完整链路;Python 脚本可以写得更脏更直接,纯粹从裸 Socket 发数据,验证服务端拆包器的正确性和上限。这样以后改协议或者调参数,用脚本压一遍就能定位到问题,不用一直盯着 VS 的调试器。
先做一个粘包测试:把两条报文一次写完(不等对端读取),看服务端能不能正确解析成两条独立消息。这个测试直接验证 3.2 节的拆包逻辑。
import socket import struct import json s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.connect(("127.0.0.1", 9000)) # 模拟一次写入两条报文,验证粘包处理 def pack(obj): body = json.dumps(obj).encode('utf-8') return struct.pack('<i', len(body)) + body s.sendall(pack({"type": "heartbeat", "id": "dev01"}) + pack({"type": "data", "value": 99})) s.close()struct.pack('<i') 表示小端 4 字节,和 C# 里 BitConverter 的默认行为对齐。粘包测试能过,说明服务端的拆包器是可靠的。接下来做一个吞吐测试:循环发 10000 条小报文,看服务端能否全部正确解析、有没有丢包或错包,同时监控内存是否稳定。
import socket import struct import json import time s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.connect(("127.0.0.1", 9000)) body = json.dumps({"type": "data", "value": 1}).encode('utf-8') packet = struct.pack('<i', len(body)) + body start = time.time() for i in range(10000): s.sendall(packet) s.close() print("发送耗时:", time.time() - start, "秒")我一般把主要精力放在内存曲线和日志数量:服务端每解析一条报文打一行日志,脚本跑完后对比日志行数和发送条数,如果一致说明没有丢;再看任务管理器里内存是否平稳。还有个常用的边界测试是发空报文和超大报文(比如 5MB),看服务端拒收逻辑是否生效——直接发 struct.pack('<i', 510241024) 后的满包,服务端应该在 bodyLength 合法性校验处抛异常并主动断开,而不是分配 5MB 内存后继续解析。
这套方式陪我验证过不少协议改动,尤其是把 JSON 换成二进制协议、把同步改成异步那两次大改,都是先用脚本压完才敢往设备上放。从那以后,我每次写 TCP 服务端都会先把压测脚本放在工程目录里,改完代码先跑一轮粘包和吞吐,再联调真实设备。希望帮到你。
本文还有配套的精品资源,点击获取