
简介C# Socket网络通讯完整源码面向初学C#网络编程或需要快速搭建通讯原型的开发者。代码基于.NET Windows窗体实现包含服务端与客户端两个独立工程直接打开解决方案即可运行清晰演示了Socket建立连接、收发消息等核心流程便于理解基本原理并在此基础上扩展。资源共61个文件以C#源码14个.cs为主另含解决方案与工程文件.sln/.csproj、窗口界面资源.resx以及可执行程序与调试符号.exe/.pdb等压缩包仅1.17MB。项目结构简洁服务端与客户端分目录组织适合对照学习两端交互机制。已有2914人浏览学习代码由作者自写思路直白如果对实现有疑问还可联系作者交流适合初学者快速上手也可作为自定义通讯功能的起点。1. C# Socket 网络通讯源码先看懂“完整”这两个字意味着什么我见过不少人从网上下载了所谓完整的 C# Socket 网络通讯源码跑起来很简单客户端一连、发几句字符串、再收回来以为这就完事了。等放上业务流量才发现粘包、半包、断线、客户端多了之后互相挤掉全来了。这篇就把“完整”拆开看你会得到一套结构清楚、能直接改的 C# Socket 网络通讯方案同步阻塞模型做连接管理4 字节大端长度前缀做拆包心跳加断线重连兜底。它不是什么万兆高并发框架但适合上位机、设备网关和中小型通讯服务也适合刚把注意力从“能连通”转移到“能稳定跑”的人。2. 选型与最小实现TcpClient 和 Socket 到底用哪套2.1 为什么我用 TcpListener / TcpClient 而不是裸 Socket网上很多“完整源码”喜欢直接 new Socket然后铺满 BeginReceive、EndReceive、回调嵌套看着很唬人实际上把新手能看懂的部分全藏进了委托里。我用 TcpListener / TcpClient 是这么考虑的它们本身是对 Socket 的封装把抓包、绑定、监听这些脏活吃了同时你在需要时还能通过 client.Client 把底层 Socket 拿回来设置 NoDelay、Linger 这些关键参数并不会有功能损失。裸 Socket 的优势在需要精细控制介入点的时候比如实现 IOCP、收原始包、控制收发粒度。而我见过的绝大多数上位机需求本质上是“若干个客户端连进来交换定长或变长命令”这个负载用同步阻塞模型完全扛得住。有人说同步模型性能差得分场景。长连接应用的主要瓶颈往往不在阻塞读而在业务处理线程的排队策略。几百个连接、每秒几十上百个报文同步模型在可控范围内代码却比异步回调好维护一个量级。等真到了单机几千连接再换 SocketAsyncEventArgs 不迟到时这些封帧、拆包、心跳代码照样能复用。至于 Task 异步接受客户端我也只在第一层用 —— 一个专门 accept 循环每个客户端进来丢给线程池处理。这样监听线程不会被阻塞新客户端也能尽快接入而入站数据的读取在各自任务里保持同步顺序读好调试多了。2.2 最小服务端监听、接客户端、读字节流先把最基础的跑通。这段代码是后面所有逻辑的地基先不要做帧解析只验证能连上、能读数据。using System.Net; using System.Net.Sockets; using System.Text; var listener new TcpListener(IPAddress.Any, 9000); listener.Start(100); // backlog100允许排队等待 accept 的连接数 Console.WriteLine(listening on 0.0.0.0:9000); while (true) { TcpClient client await listener.AcceptTcpClientAsync(); _ Task.Run(() HandleClientAsync(client)); } static async Task HandleClientAsync(TcpClient client) { using (client) using (NetworkStream stream client.GetStream()) { Console.WriteLine($client in: {client.Client.RemoteEndPoint}); var buffer new byte[4096]; int read; while ((read stream.Read(buffer, 0, buffer.Length)) 0) { string chunk Encoding.UTF8.GetString(buffer, 0, read); Console.WriteLine($recv: {chunk}); } Console.WriteLine(client disconnected); } }逻辑说明AcceptTcpClientAsync 每等到一个连接就返回一个 TcpClient我把它丢给线程池处理。HandleClientAsync 里先拿 NetworkStream这是读写的核心对象注意 GetStream 返回的是包装实例循环里复用本地变量不要每次调用。Read 是阻塞的没有数据时线程就挂在那里来数据才继续。Read 返回 0 表示对端正常关闭这是“连接结束”的信号不是错误不做特殊处理循环退出后 using 自动释放连接。参数说明监听地址用 IPAddress.Any 表示绑定到本机所有网卡适合服务端。如果只希望本机访问可以改成 IPAddress.Loopback。端口 9000 是测试端口实际部署避开常见端口冲突就行。backlog 写 100很多源码不填这个参数默认值在不同操作系统上表现不一致填 100 能让突发连接不被内核直接拒绝。buffer 4096 是读取缓冲不是单条报文上限实际报文可以比它大Read 只会返回当前内核缓冲里能拿到的一块完整报文可能要分多次读这就是后文要解决的半包问题。2.3 最小客户端连接、发数据、优雅关闭客户端的重点是把 NoDelay 打开。Nagle 算法会把小数据包攒在一起发送交互式命令就会莫名慢个几十毫秒。NoDelay 牺牲一点网络利用率换来命令即时性上位机场景默认打开。using System.Net.Sockets; using System.Text; using var client new TcpClient(); client.NoDelay true; client.SendTimeout 5000; client.ReceiveTimeout 5000; await client.ConnectAsync(127.0.0.1, 9000); await using NetworkStream stream client.GetStream(); byte[] body Encoding.UTF8.GetBytes(ping); byte[] frame new byte[4 body.Length]; Array.Copy(body, 0, frame, 4, body.Length); await stream.WriteAsync(frame, 0, frame.Length); Console.WriteLine(sent);这段只演示连接和发送注意 frame 前 4 个字节还没填长度值因为真正可用的封帧要放进第 3 章协议里。SendTimeout 和 ReceiveTimeout 是同步读写时的超时时间单位毫秒。超过这个时间读写会抛 IOException线程得以退出而不是永远卡死。这个超时在断线检测里很重要配合心跳能兜住网络异常。选型到这里已经有了结论TcpListener/TcpClient 做壳NetworkStream 做读写NoDelay 必开超时必设。这套模型代码直白读线程就是一个 while 循环加一个 Read断线时沿着调用栈往上翻就能看到原因比回调套回调的异步写法容易排查多了。3. 数据帧设计与拆包粘包半包问题是分水岭3.1 TCP 是字节流根本没有报文边界把上章的代码丢到真实环境你会发现客户端连续发三条消息服务端可能一次 Read 就全读到也可能发一条 10KB 的消息服务端分四次才读完。这不是 Bug是 TCP 的本质。TCP 只保证字节有序到达不保证应用层的“一条消息”完整地出现在一次 Read 里。多条消息被合并读出来叫粘包一条消息被拆成多次读叫半包两者是同一个问题的两面。很多入门源码直接用 Read 的返回长度去解析一条消息然后把解析逻辑写在 while 循环里。半包时第一条消息没读全就被拿去解析了第二条消息开头被切掉粘包时第二个包被错当成第一个包的尾巴。表现就是本地调试偶尔正常一上局域网就乱码、卡死、命令错乱而且不好复现。这种不稳定是最影响信任感的。解决思路很统一应用层自己定义帧格式接收端先把字节囤起来凑够一帧再交给业务逻辑。这就是拆包器的职责。3.2 协议设计4 字节大端长度 负载我用来降低理解成本又不短视的格式是字段长度说明FrameLen4 字节大端表示后面负载的字节数不含这 4 字节本身Body变长负载内部第 1 字节可作命令字后续是数据区长度字段用 4 字节而不是 2 字节是怕日后单条消息超过 64KB。2 字节上限 65535看起来够用但你要是传个固件包、传段配置 JSON 超过这个数就得改协议所有客户端跟着重发后悔药都没处买。4 字节上限 2GB对绝大多数场景等于没有上限。字节序要统一用大端。C# 在 Windows 上默认小端C/C 的 socket 实现、Java、PLC 网关、Modbus TCP 网关这类设备普遍按网络字节序也就是大端理解整数。如果你只在 C# 之间通讯大小端无所谓一旦哪天对接西门子 OPC、第三方便携式设备或网关小端直接读成相反字节序数字就全错了。所以协议从第一天就按大端走。public static byte[] BuildFrame(byte command, byte[] payload) { int bodyLen payload null ? 0 : payload.Length; var frame new byte[4 bodyLen]; // 大端写入长度跨语言、跨平台通用 byte[] lenBytes BitConverter.GetBytes(bodyLen); if (BitConverter.IsLittleEndian) { Array.Reverse(lenBytes); } Array.Copy(lenBytes, 0, frame, 0, 4); frame[4] command; // 第1字节命令字如 0x01PING if (payload ! null bodyLen 0) { Array.Copy(payload, 0, frame, 5, bodyLen); } return frame; }封帧时把长度写到前 4 字节然后直接放命令字和负载。用 BitConverter 加一次 IsLittleEndian 判断是为了兼容 .NET Framework 4.x 老工程那里没有 BinaryPrimitives。如果你在 .NET 6 环境可以换成 BinaryPrimitives.WriteInt32BigEndian(frame, bodyLen)代码更干净。拆包时我维护一个接收缓冲区可以用 List 简单清楚流量不大时完全够用。核心逻辑是循环先看有没有 4 字节长度有就解析出 bodyLen再看缓冲区里的字节够不够一帧够就取出、移除不够就退出循环等下次读到的字节。这一步同时解决了粘包和半包——粘包时循环能取出多帧半包时剩下的不足一帧就攒着。public Listbyte[] TryDecodeFrames(Listbyte buffer, out int offset) { var frames new Listbyte[](); offset 0; while (buffer.Count - offset 4) { byte[] lenBytes buffer.GetRange(offset, 4).ToArray(); if (BitConverter.IsLittleEndian) { Array.Reverse(lenBytes); } int bodyLen BitConverter.ToInt32(lenBytes, 0); // 协议破坏时直接断开不能死等 if (bodyLen 0 || bodyLen 1024 * 1024 * 64) { throw new InvalidDataException($invalid frame length {bodyLen}); } if (buffer.Count - offset 4 bodyLen) { break; // 半包剩余字节不够等下次 Read 追加 } byte[] body buffer.GetRange(offset 4, bodyLen).ToArray(); frames.Add(body); offset 4 bodyLen; } return frames; }调用处把 Read 到的字节 Append 进缓冲区再把 offset 之前的内容移除避免 List 无限增长。参数说明bodyLen 上限 64MB 是保护值防止对端异常时抛出一个 2GB 长度值这里会一直等不到数据连接变成僵尸。凡是长度不在合理区间一律按协议错误断开。注意拆包器返回的 body 第 1 字节是命令字。业务分发时先 switch 命令字再解析后续数据不要把命令字和数据区混在一起切。拆包器怎么做完把缓冲区移除逻辑补上Listbyte recvBuffer new(); int read await stream.ReadAsync(buffer, 0, buffer.Length); if (read 0) break; // 对端关闭 recvBuffer.AddRange(buffer.Take(read)); var frames TryDecodeFrames(recvBuffer, out offset); recvBuffer.RemoveRange(0, offset);参数和边界offset 是本次成功解析出的总字节数没解析完的半包保留在 recvBuffer 里继续攒。这样不管对端怎么粘包拆包上层拿到的都是完整帧。这一步做完通讯框架才算过了分水岭。4. 避坑连接被强关、接收返回 0、跨线程 UI 等 5 个高频问题4.1 远程主机强迫关闭了一个现有的连接现象发送或接收时抛出 SocketException错误码 10054 / WSAECONNRESET意思是“对端已经把连接重置了”。常见于客户端崩溃后服务端还在往这条连接上写数据或者服务端收到了一个在连接关闭时已发出的 RST 包。原因TCP 连接在异常退出时内核会发送 RST 而不是 FIN。RST 一旦发出连接立刻销毁双方缓冲区全部丢弃。发送方只要往这个已死连接上再碰一下马上触发 10054。解决发送逻辑不能只靠 TcpClient.Connected 判断这个属性在 RST 之后往往还是 true。真正可靠的是发送时捕获 SocketException检测 ErrorCode 为 10054 或 10053然后触发断线重连流程把这条连接的所有资源释放掉。像这样try { await stream.WriteAsync(frame, 0, frame.Length); } catch (SocketException ex) when (ex.ErrorCode is 10054 or 10053 or 10060) { MarkDisconnected(); // 清理本地连接状态进入重连逻辑 }10060 是连接超时也一并归入断线。很多人把异常吞掉继续下一个循环发送结果是每次发送都抛一次异常日志刷屏连接永远不恢复。正确的做法是任何 SocketException 都视为连接已损坏直接退出读写循环。4.2 Read 返回 0 当成错误处理现象服务端日志里莫名出现 IOException“无法从传输连接中读取数据”查代码发现有人在 read 0 时直接抛了异常。原因TCP 对端正常关闭时Read 返回 0这是 EOF不是错误。把 0 当异常处理客户端主动断开后服务端就要多抛一次无意义异常还可能掩盖真正的断线逻辑。解决recv 循环里 read 0 一律走正常断开分支释放资源、清理会话不打印堆栈。收到 0 就表示对方不会再发数据了后续也可以从业务状态里标记在线离线。这样“对端正常退出”和“网络异常中断”两个语义被分开前者静默处理后者走重连。4.3 NetworkStream.Write 不一定一次全写进去现象发送大报文时对端收到的数据被截断或者出异常 “无法将数据写入传输连接”。很多人以为 Stream.Write 和 Read 一样能写多少写多少。实际上 NetworkStream.Write 会尝试写完所有字节但对于正处于限速或缓冲满的 socket底层会出现部分写入。原因套接字发送缓冲区满、对端接收速度跟不上、Nagle 与延迟 ACK 相互作用都可能导致单次写操作无法完全耗尽你的数据。解决自己实现一个发送全量数据的函数循环补写直到所有字节都落进 socket 缓冲或者发生异常退出private static async Task WriteAllAsync(NetworkStream stream, byte[] frame) { int offset 0; while (offset frame.Length) { int written await stream.WriteAsync(frame, offset, frame.Length - offset); if (written 0) { throw new SocketException((int)SocketError.ConnectionReset); } offset written; } }如果只在 C# 里使用 TcpClientNetworkStream.Write 本身做了全量保证这个函数可作为防御层如果你改用 Socket.Send这个循环就是必须的。注意粘包、半包的工作交给第 3 章的帧解析发送端不要为了“省流量”而合并多条消息也不要为了“防粘包”故意把消息拆开发送。发送端只保证帧完整写入接收端只负责按帧边界解析各司其职。4.4 跨线程访问 UI 控件现象WinForms / WPF 程序里Recv 线程处理完消息后直接给 TextBox 或日志控件赋值调试时抛“跨线程操作无效”或者控件显示卡顿、闪退。原因UI 控件只能在创建它的线程主线程上操作。Socket 接收回调在 ThreadPool 线程上执行直接访问控件就是非法操作。网上的简单源码大多数忽略了这层一放真实项目必然翻车。解决把要更新的内容打包切换到同步上下文。WinForms 就用 BeginInvokeWPF 用 Dispatcherprivate void AppendLog(string message) { if (txtLog.InvokeRequired) { txtLog.BeginInvoke(new Action(() txtLog.AppendText(message Environment.NewLine))); } else { txtLog.AppendText(message Environment.NewLine); } }注意 BeginInvoke 是异步的接收线程不会停下来等 UI这样高频消息时也能流畅追加。如果消息量很大建议在接收线程只把原始帧塞进队列UI 用自己的 Timer 周期性取队列消费避免每条消息都跨线程这个模型放到 5.3 的生产者消费者部分讲。4.5 端口被占用或 backlog 设置错误现象服务端重启时报 SocketException “通常每个套接字地址协议/网络地址/端口只允许使用一次”或者客户端连接被拒绝。多个实例同时监听同一个端口是最常见原因但还有个隐蔽场景上一次程序退出时连接没释放干净端口进入 TIME_WAIT。原因TCP 主动关闭方会进入 TIME_WAIT持续 2MSL约 1-4 分钟期间端口不能立即复用。如果服务器在代码里主动 Close 了监听 socket但没有关闭所有客户端连接重启时监听端口就可能被占用。解决监听 socket 设置 ExclusiveAddressUse 为 false 并打开 ReuseAddress允许端口快速重用listener.Server.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true); listener.Server.ExclusiveAddressUse false; listener new TcpListener(IPAddress.Any, 9000);但注意这只解决监听端口复用不能解决客户端连接复用。客户端主动断开前要调用 Shutdown(SocketShutdown.Both) 让对端感知再关闭这样 TIME_WAIT 留在客户端侧而不是服务端侧。服务端被动断开不会产生 TIME_WAIT所以服务端要尽量避免作为主动关闭方。5. 多客户端接入与心跳保活把玩具变成能用框架5.1 ConcurrentDictionary 管理在线客户端前面的 HandleClientAsync 处理完一个客户端就结束了多个客户端同时连接时任务之间互相不知道对方的存在。要支撑心跳检查和广播就需要一个全局字典把连接标识映射到客户端状态。我一般这么组织public class ClientSession { public string Id { get; set; } public TcpClient Tcp { get; set; } public NetworkStream Stream { get; set; } public volatile int LastAliveTick; public CancellationTokenSource Cts { get; } new(); } ConcurrentDictionarystring, ClientSession _sessions new();客户端接入时生成一个唯一 IdGuid.NewGuid().ToString(N)注册进字典。接收线程退出后从字典移除。用 ConcurrentDictionary 而不是普通 Dictionary是因为监听线程、业务线程、心跳巡检线程会同时读写字典普通 Dictionary 在高并发下会内部状态损坏甚至死锁。LastAliveTick 用 volatile int每次接收到任何字节就更新为 Environment.TickCount。为什么不用 DateTime.Now因为环境可能被校时机器时钟往回跳会让“最后活跃时间”倒退导致心跳超时误判。Environment.TickCount 是开机以来的毫秒数单调递增虽然 25 天左右会翻转到负数但差值计算不受翻转影响比如 now - lastAliveTick 只要注意用 int 运算短连接场景完全够。5.2 服务端心跳巡检超时没消息就断开TCP 长连接有一个问题客户端断电、网线拔掉、路由重启时本端不会立刻收到 FIN 或 RST连接就像“假死”。要探测假死就得靠应用层心跳。我通常在协议里加两个命令字0x01 PING0x02 PONG。客户端每隔 10 秒发 PING服务端收到 PING 就回 PONG服务端同时记录 LastAliveTick。巡检线程每 2 秒扫一遍所有会话如果 now - LastAliveTick 超过 30 秒就强制断开。这里参数不能随便拍PING 10 秒、巡检 2 秒、超时 30 秒。超时比 PING 间隔大 3 倍是为了容忍网络瞬时抖动和 GC 卡顿避免误杀正常客户端。超时设成 6 秒局域网还有可能跨交换机稍微一拥塞就全断。private async Task WatchdogLoopAsync(CancellationToken token) { while (!token.IsCancellationRequested) { await Task.Delay(2000, token); foreach (var kv in _sessions.ToArray()) { ClientSession s kv.Value; if (Environment.TickCount - s.LastAliveTick 30_000) { s.Cts.Cancel(); // 中断阻塞在 ReadAsync 的线程 } } } }这里的高频操作是遍历字典并检查时间不涉及锁。取消用的是 CancellationTokenSource不是因为 ReadAsync 必须用它而是因为它能干净地把阻塞中的接收循环打断。HandleClientAsync 里要把 s.Cts.Token 传给 ReadAsyncint read await s.Stream.ReadAsync(buffer, 0, buffer.Length, s.Cts.Token);一旦 CancelReadAsync 抛 OperationCanceledException接收循环退出finally 里关闭连接并移出字典。注意不要直接调用 tcp.Close() 去打断 ReceiveClose 同时会释放 Socket 句柄接收线程可能还在操作一个已释放的句柄导致 ObjectDisposedException 满天飞。先 Cancel 让接收线程自己退出再用 using 清理资源是这个方案的关键顺序。5.3 客户端断线重连与生产者消费者队列客户端侧要做的和 Timing 无关只要记住断线是常态重连要有退避。我常用的重连循环是int retry 1; while (true) { try { await tcp.ConnectAsync(ip, port); break; // 连上 } catch (SocketException) { int delay Math.Min(5000, 200 * (1 Math.Min(retry, 5))); await Task.Delay(delay); retry; } }退避从 200ms 起步每次翻倍封顶 5 秒。连上后 retry 重置为 1。不能做成每隔 5 秒固定重试因为服务器重启那几秒内连接会密集失败退避能避免把服务器日志刷爆。业务数据不直接塞给 NetworkStream而是放进一个有界队列由单一发送协程消费。这就是生产者消费者模型好处是削峰防止业务线程追赶网络线程导致内存无上限增长。var queue new BlockingCollectionbyte[](boundedCapacity: 1024); async Task SendLoopAsync(CancellationToken token) { foreach (byte[] frame in queue.GetConsumingEnumerable(token)) { await WriteAllAsync(session.Stream, frame); } }boundedCapacity 设为 1024 时队列满后生产端会阻塞这是背压机制业务方必须等网络消费完才能继续生产而不是无限堆内存。真实项目里遇到内存暴涨先查这里是不是有界队列。这样一套组合下来服务端和客户端都有了连接管理、帧解析、心跳、断线重连、背压保护。源码不再是一坨演示代码而是一个能撑住真实业务流量的骨架。6. 压测与上线前检查跑满 50 连接 x 2000 次回显再说写个小压测工具比嘴上说“没问题”可靠得多。我习惯这样验证50 个并发连接每个连接连续发 2000 条回显消息每条消息带 GUID 校验只要一条对不上就算失败。Parallel.For(0, 50, i { using var tcp new TcpClient(); tcp.Connect(127.0.0.1, 9000); using NetworkStream stream tcp.GetStream(); for (int j 0; j 2000; j) { string body $msg-{i}-{j}-{Guid.NewGuid():N}; byte[] frame BuildFrame(0x10, Encoding.UTF8.GetBytes(body)); WriteAllAsync(stream, frame).GetAwaiter().GetResult(); byte[] reply ReadOneFrame(stream); if (Encoding.UTF8.GetString(reply) ! body) { throw new Exception($mismatch {i}-{j}); } } Console.WriteLine($conn {i} done); });压测通过不代表上线无忧我再顺手做三件事先用 netstat -an 确认没有大量 CLOSE_WAIT 堆积再观察服务端内存发送端队列有没有无限增长最后把日志级别调到 Debug确认没有半包重组异常和重连风暴。这套流程走完我心里才踏实。做上位机头两年我也迷信过异步高性能那一套后来发现通讯系统的坑从来不在 API 层级而在帧边界、心跳时序、资源释放这些基础细节上。参数是死的网络是活的这些底盘没搭稳换个框架照样翻车。希望帮到你。本文还有配套的精品资源点击获取