简介: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); // backlog=100,允许排队等待 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 字节大端长度 + 负载
我用来降低理解成本又不短视的格式是:
| 字段 | 长度 | 说明 |
|---|---|---|
| FrameLen | 4 字节,大端 | 表示后面负载的字节数,不含这 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字节命令字,如 0x01=PING 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 List<byte[]> TryDecodeFrames(List<byte> buffer, out int offset) { var frames = new List<byte[]>(); 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 命令字,再解析后续数据,不要把命令字和数据区混在一起切。
拆包器怎么做完?把缓冲区移除逻辑补上:
List<byte> 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# 里使用 TcpClient,NetworkStream.Write 本身做了全量保证,这个函数可作为防御层;如果你改用 Socket.Send,这个循环就是必须的。
注意:粘包、半包的工作交给第 3 章的帧解析,发送端不要为了“省流量”而合并多条消息,也不要为了“防粘包”故意把消息拆开发送。发送端只保证帧完整写入,接收端只负责按帧边界解析,各司其职。
4.4 跨线程访问 UI 控件
现象:WinForms / WPF 程序里,Recv 线程处理完消息后直接给 TextBox 或日志控件赋值,调试时抛“跨线程操作无效”,或者控件显示卡顿、闪退。
原因:UI 控件只能在创建它的线程(主线程)上操作。Socket 接收回调在 ThreadPool 线程上执行,直接访问控件就是非法操作。网上的简单源码大多数忽略了这层,一放真实项目必然翻车。
解决:把要更新的内容打包,切换到同步上下文。WinForms 就用 BeginInvoke,WPF 用 Dispatcher:
private 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(); } ConcurrentDictionary<string, ClientSession> _sessions = new();客户端接入时生成一个唯一 Id(Guid.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 PING,0x02 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 传给 ReadAsync:
int read = await s.Stream.ReadAsync(buffer, 0, buffer.Length, s.Cts.Token);一旦 Cancel,ReadAsync 抛 OperationCanceledException,接收循环退出,finally 里关闭连接并移出字典。注意:不要直接调用 tcp.Close() 去打断 Receive,Close 同时会释放 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 BlockingCollection<byte[]>(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 层级,而在帧边界、心跳时序、资源释放这些基础细节上。参数是死的,网络是活的,这些底盘没搭稳,换个框架照样翻车。希望帮到你。
本文还有配套的精品资源,点击获取