简介:面向C#网络编程初学者的Socket通信完整源码包,基于Windows窗体实现,包含服务端与客户端两个可直接运行的项目。整体代码风格简洁,逻辑清晰,直接打开即可运行,方便查看Socket建立连接、收发数据以及关闭连接的基本流程。压缩包共有61个文件,其中包含14个C#源代码文件,以及解决方案工程文件、可执行程序、调试符号、窗体资源文件等多种类型,包体仅1.17MB,轻量易用。目前已有2915人学习下载。这套源码既适合新手对照练习网络通信基础,也可以继续扩展多客户端管理、消息加密、断线重连、文件传输等功能,或作为课程设计、自研小工具的基础框架,整体实用价值高。
1. 为什么要找一份“简单、清楚”的C# Socket完整源码
接手过上位机项目的人大多有过这样的经历:网上搜“C# Socket通讯源码”,下载下来要么是三年前的异步长文,要么塞满了自定义类、事件委托、状态机,注释还没看懂框架已经把人绕晕。真正到了现场对接PLC、读仪表扭矩值,或者给产线设备做数据采集时,你需要的不是架构表演,而是一段打开就能编译、看懂就能改、跑起来能坚持一周不掉线的通讯底座。这个标题之所以打动人,是因为它承诺了两件事:源码是闭环的,也就是能建连、能收发、能处理断开;代码是清晰的,也就是变量命名直白、流程不绕弯。这篇笔记就按这个标准,把一套可运行的服务端/客户端拆开讲透,从模型选型一路写到心跳、重连和抓包验证,看完你完全可以照着搭进自己的WinForm上位机或工控服务里。
2. 通讯模型先立住:TCP选型与最小双向收发骨架
2.1 为什么局域网设备通讯优先选TCP而不是UDP
在做C# Socket网络编程时,第一步就要把传输层协议定死。绝大多数上位机和设备通讯场景,比如读取PLC变量、控制AGV、采集传感器数据,要的是“这条消息一定送达、顺序不颠倒”。TCP天然保证这两点:它有序列号、确认重传、拥塞控制,链路断了会通知应用层;而UDP是尽力而为,丢包不通知、乱序不纠正,适合视频流、广播发现这类容忍丢失的场景。用表格看更直接:
| 对比项 | TCP | UDP |
|---|---|---|
| 可靠性 | 可靠,重传丢失数据 | 不可靠,可能丢包 |
| 消息顺序 | 保证按发送顺序到达 | 不保证 |
| 连接状态 | 有连接,需要握手/挥手 | 无连接 |
| C#实现复杂度 | 中等,要处理粘包拆包 | 低,一条Datagram收发 |
| 适用场景 | 指令下发、数据采集、文件传输 | 设备发现、心跳广播、音视频 |
我见过有人在采集项目里用UDP发扭矩指令,结果现场一开大功率电机,丢两条报文设备就没反应了,排查了两天才换成TCP。所以只要不是对实时性极端敏感、能容忍丢包的场景,TCP是默认答案,下面的源码也只讲TCP。
2.2 TcpListener/TcpClient还是原生Socket:我选前者的理由
在.NET里做TCP有两条路:直接操作Socket类,或者用TcpListener配合TcpClient。原生Socket能让你拿到Send/Receive/Select底层控制,适合做高并发网关、自定义协议栈;但它的细节太多,Bind、Listen、Accept、BeginAccept一长串,新手很容易在某个环节漏掉一个方法导致黑匣子式报错。TcpListener和TcpClient是对Socket的封装,内部还是那套机制,但把地址复用、端口绑定、连接池都简化了,暴露的Stream可以直接读字节,配合NetworkStream非常顺手。
对一个“简单、清楚”的源码来说,我一般选用封装层,理由很简单:代码减少三分之一,读代码的人不用去啃底层API;而且封装类提供了IDisposable,用using包住就能正确释放连接,这对防止文件句柄泄漏极其重要。如果你将来要写几百上千连接的网关,再回去用原生Socket也不迟;上位机设备通讯这种一两百连接以内的场景,TcpClient足够扛住。
2.3 30行跑通本地回环消息:最小服务端与客户端
理论讲完,先给一个不掺任何框架的最小例子,让你确认本机Socket环境是通的。服务端监听9000端口,来一个客户端就开一个后台线程回显一条欢迎消息:
// Server: 监听本机9000端口,每来一个客户端开独立线程处理 using System.Net; using System.Net.Sockets; using System.Text; var listener = new TcpListener(IPAddress.Any, 9000); listener.Start(10); // backlog=10,控制等待队列长度 Console.WriteLine($"服务端已监听: {listener.LocalEndpoint}"); while (true) { TcpClient client = listener.AcceptTcpClient(); // 阻塞等待连接 Console.WriteLine($"客户端接入: {client.Client.RemoteEndPoint}"); Thread t = new Thread(() => { using (client) using (NetworkStream stream = client.GetStream()) { byte[] data = Encoding.UTF8.GetBytes("welcome"); stream.Write(data, 0, data.Length); } }); t.IsBackground = true; t.Start(); }这段代码有几个值得注意的参数和习惯:IPAddress.Any表示监听所有网卡,如果你的工控机有多张网卡且只想服务内网,可以改成具体IP;backlog设10是给TCP握手还未完成但已经进来的连接排队用的,不是最大连接数,上线时不需要调太大。线程设IsBackground,保证主进程退出时这些工作线程会被强制终止,不会拖住进程无法关闭。顺便说一句,如果你在服务启动时碰到“failed to create server shutdown socket on address [localhost] and port [802”这类日志,多半是端口被占用或监听地址不可用,原因和排查方法第四章节会细讲。
客户端的代码更短:
// Client: 连接127.0.0.1:9000,读取服务端发来的消息 using System.Net.Sockets; using System.Text; using (var tcp = new TcpClient()) { tcp.Connect("127.0.0.1", 9000); // 同步连接,超时默认由系统决定 using NetworkStream stream = tcp.GetStream(); byte[] lenBuf = new byte[1024]; int read = stream.Read(lenBuf, 0, lenBuf.Length); Console.WriteLine($"收到: {Encoding.UTF8.GetString(lenBuf, 0, read)}"); }注意同步Connect在网络不通时会阻塞挺久,生产环境我建议用ConnectAsync加超时控制,后面的断线重连章节会给出完整写法。先把这个骨架跑通,你就拥有了一套最原始的C# Socket网络通讯通道。
3. 完整源码的核心:消息封帧、发送与可靠接收
3.1 帧格式先行:4字节长度头加JSON消息体
Socket通讯里最坑的不是建连,而是消息边界。TCP是字节流,你连续发两次Write,对端可能一次Read全部收走;你发一个很大的包,对端可能分三次才读完整,这就是粘包和拆包。要把业务消息从字节流里切出来,必须自己做帧格式。最稳妥且好调试的做法就是“长度头”方案:每个消息前面放4字节,表示后面消息体有多少字节;消息体里放什么格式可以自己定,我强烈建议放JSON文本,用System.Text.Json序列化,这样上位机里看到的日志直接能读,排查问题时眼睛就是最好的调试器,而不用拿十六进制去猜含义。
一个典型帧长这样:
| 4字节 小端 int32 长度 | N字节 UTF-8 JSON文本 |为什么强调小端?因为BitConverter在Windows默认就是小端,直接用就行。如果将来要对接嵌入式大端设备,需要改成网络序,但那是跨平台的问题,纯Windows环境下不用操心。选JSON而不是二进制还因为消息字段可以演进:旧客户端收到新字段能忽略,新客户端收到缺省字段能补默认值,不用担心序列号错位。
3.2 发送端封帧:把业务对象变成字节流
我现在写发送代码时,一律封装两个函数,一个把对象转成frame,一个把frame写进流里。这样业务层永远只接触对象,不管字节,后面换序列化方案也只有一个改动点:
// FrameBuilder.cs: 把任意对象封成 [4字节长度 + JSON字节] public static byte[] MakeFrame<T>(T payload) { string json = JsonSerializer.Serialize(payload); byte[] body = Encoding.UTF8.GetBytes(json); // 长度头用小端int,默认就是BitConverter的字节序 byte[] header = BitConverter.GetBytes(body.Length); if (!BitConverter.IsLittleEndian) Array.Reverse(header); byte[] frame = new byte[4 + body.Length]; Buffer.BlockCopy(header, 0, frame, 0, 4); Buffer.BlockCopy(body, 0, frame, 4, body.Length); return frame; } // NetworkStreamExtensions.cs: 将frame安全写入并清空发送缓冲 public static void WriteFrame(this NetworkStream stream, byte[] frame) { stream.Write(frame, 0, frame.Length); stream.Flush(); // 把应用层缓冲区数据推向网卡,但不保证立即发出 }这里的Flush很关键,虽然NetworkStream的缓冲区几乎不缓存内容,但养成Flush的习惯能防止以后换成BufferedStream时出现“数据没发出去”的玄学问题。要注意的坑是帧长度用checked转int,如果JSON超过2GB那纯属设计错误,正常业务包几千字节,int足够。实际项目中,我还会在MakeFrame里限制最大长度,超过10MB直接抛异常,防止内存被打爆。
3.3 接收端拆帧:先读长度,再循环读够消息体
接收比发送难在“必须读满指定长度”。NetworkStream.Read的一次调用并不保证读够你要求的字节数,尤其是大包或网络拥堵时。所以需要一个ReadExactly函数,循环直到凑满:
// NetworkStreamExtensions.cs: 核心拆帧函数,一次返回一条完整消息 public static string ReadMessage(this NetworkStream stream) { byte[] lenBuf = new byte[4]; ReadExactly(stream, lenBuf, 4); // 先读4字节长度头 int len = BitConverter.ToInt32(lenBuf, 0); // 获取消息体长度 if (len <= 0 || len > MAX_MESSAGE_SIZE) throw new InvalidDataException($"非法帧长度: {len}"); byte[] body = new byte[len]; ReadExactly(stream, body, len); // 再循环读满body return Encoding.UTF8.GetString(body); } private static void ReadExactly(NetworkStream stream, byte[] buffer, int count) { int offset = 0; while (offset < count) { int bytesRead = stream.Read(buffer, offset, count - offset); if (bytesRead == 0) throw new EndOfStreamException("连接已被对端关闭"); offset += bytesRead; } }逻辑说明:外层先读4字节长度头,然后根据这个长度申请buffer,再用ReadExactly去读。ReadExactly里每轮Read都会把当前读到的字节数累加到offset上,直到填满整个count。如果哪次Read返回0,说明对端正常关闭连接(FIN包已经送达),这时候不用继续等数据,直接抛EndOfStreamException让上层清理资源。这个处理是整个“清楚”源码的核心,很多网上的半吊子代码只读一次就丢给解析器,碰上拆包直接Json解析失败,然后你怀疑是不是对方的设备发错了数据,排查一整天才发现是自己读取逻辑有缺陷。
如果你要做的是高并发服务端,这里可以把同步循环改成async,用ReadAsync配合ValueTask;但原理完全一样,先ReadExactly拆帧,再交给业务线程。同步版本虽然占线程,但在连接量小于200的上位机场景里反而更好排查,因为调用栈清晰,断点一打就知道卡在哪一行。
3.4 把消息安全送到UI线程:跨线程更新是上位机的必修课
C#上位机里,服务端收到设备数据后通常要刷新WinForm/WPF界面,比如把扭矩值显示到一个TextBox。但直接在Socket回调线程里操作控件会抛InvalidOperationException,因为控件拥有自己的线程上下文。我常用的做法是封装一个UI转发器:
// UiDispatcher.cs: 把跨线程调用统一转到UI线程执行 public static class UiDispatcher { public static void Post(Control control, Action action) { if (control.IsDisposed) return; // 控件已被销毁,丢掉本次更新 if (control.InvokeRequired) control.BeginInvoke(action); // 异步投递,不阻塞Socket接收线程 else action(); // 本就属于UI线程,直接执行 } }参数上说明一下:BeginInvoke是异步的,适合高频刷新场景,比如每秒50条设备数据,不会因为UI卡顿拖累Socket接收;但如果业务逻辑要求“下一条数据的处理必须等到上一条UI更新完成”,那就改用Invoke同步等待。很多新手把InvokeRequired判断漏掉,直接BeginInvoke,结果在UI线程本身调用时反而报错。把这段代码融入第3章的接收循环里,你就拥有了一套从上位机UI到Socket链路的完整闭环,这也是“完整源码”不只是一个控制台Ctrl+C/V的原因。
4. 常见问题排查:这套Socket代码最容易翻车的5个坑与对策
4.1 端口被占用:你看到的“每个套接字地址(协议/网络地址/端口)只允许使用一次”
现象:服务端重开时报SocketException,提示“通常每个套接字地址(协议/网络地址/端口)只允许使用一次。”,还有可能是another process。这是C#上位机里最高频的启动报错。
原因:要么上一个进程还没完全退出,端口还被占用着;要么上次异常退出后TCP进入TIME_WAIT状态,要等几十秒到几分钟才能重新绑定。Windows对TIME_WAIT的回收时间默认为240秒,反复改代码跑调试时特别容易踩。
解决:先查占用,再在代码层面放开地址复用。我一般用两个命令先定位:
netstat -ano | findstr :9000 tasklist /FI "PID eq <pid>"确认是残留进程直接taskkill /PID /F。如果只是TIME_WAIT,代码里在listener.Start前加一行:
listener.Server.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true); listener.Start(10);这行必须在Start之前调用,否则不生效。注意加了ReuseAddress后,如果同一端口上有两个进程都在监听,后来的会成功但收不到连接,所以排错时不要只看“没报错”就完事。
4.2 粘包半包解析翻车:收到不完整的JSON
现象:设备上报数据,JsonDocument.Parse偶尔报“不应包含任何其他根级别对象”,或者读到的字符串开头是上一帧的残尾。
原因:TCP传输是按段发送的,对端一条消息可能拆成两个TCP包到达,与此同时多条小消息又可能合并进同一个TCP段。如果接收端只做一次Read,必然遇到半包或粘包。
解决:严格按照3.2的帧格式处理,不读长度头就不读数据。这里补充一个现场经验:如果粘包问题只在业务量大的时段出现,先检查发送端是不是用了多个线程同时往同一个NetworkStream写数据。Stream.Write本身不是线程安全的,多线程并发写会让字节交叉,再好的拆帧逻辑都救不回来。必须用lock锁住WriteFrame调用,或者给每个连接单独一个发送线程加队列。
4.3 Read方法卡死与返回0的区别:断线判定马虎不得
现象:客户端拔了网线,服务端的Read一直不返回,线程卡住像死了一样;或者客户端正常关闭,服务端在循环里读到0却还在继续解析。
原因:Read阻塞是常态,它要等数据到达才返回;对端直接断电拔网线时TCP没有机会发FIN,所以阻塞Read可能一直等下去。Read返回0则是对端已发送FIN并关闭连接的信号,不是网络故障,是正常关闭。
解决:给NetworkStream设置读超时,超时后抛IOException,让上层重连:
stream.ReadTimeout = 10_000; // 10秒没有数据就超时然后在一个高频调用Read的接收线程里,把位图超时当成断开处理。判断断线时不要用TcpClient.Connected属性,它是直接读socket底层状态,在TCP已经断开但没检测到时依然返回true,是用来判断“上次操作是否失败”的,不是实时状态。正确做法是:Read返回0视为正常关闭;Read抛IOException视为网络异常;业务层再用心跳兜底。
4.4 一个线程只处理一个连接:第二个客户端连上就把第一个搞卡
现象:服务端用AcceptTcpClient后直接在循环里Read,第二个客户端连上时第一个客户端不响应了。
原因:主线程在第二次Accept后,代码路径只有一个,处理第一个连接的Read阻塞了主流程,导致无法接受新客户端。
解决:每个客户端必须独立线程,或者用异步Accept,最简单的方式就是2.3节里的写法,接受连接后new Thread处理事务,同时记录该线程句柄便于后续统一关闭。一个额外的经验:控制线程数量,每来一个客户端就开线程,在内网几十个连接没问题,但如果可能被大量短连接攻击,请改用SemaphoreSlim限制最大并发处理数。
4.5 跨线程更新UI:Invoke用不对,界面直接无响应
现象:Socket回调里给ListView加行,有时抛线程间操作异常,程序退出;有时界面模糊了一下才刷新,像是卡在垃圾回收。
原因:回调线程不是UI线程,直接操作控件句柄没有约束。丢异常算好的,更可怕的是WinForm里有个隐藏开关会把跨线程访问当作非法操作,关闭检查后干脆直接崩溃。
解决:使用3.4节的UiDispatcher;另一个稳健措施是不要在UI线程里做封帧解析,接收线程只负责把原始字符串塞进并发队列,UI线程用定时器从队列里取数据渲染。Queue 加锁,或者直接上Channel ,既有背压又有缓存,适配高频设备数据。这个方案比反复Invoke更优雅,也是C#上位机开发中应对大量实时数据的主流玩法。
5. 产线级加固:心跳保活、断线重连与多客户端管理
5.1 应用层心跳比TCP KeepAlive更可控
TCP有一个KeepAlive选项,但默认要两小时才开始探测,且Windows上对空闲连接只发探测包、不感知业务状态。对设备通讯来说,你需要的是“业务层保活链路”,因此双方约定一个心跳消息最实在。心跳包就两行内容:
// 心跳消息体定义 public class HeartbeatMessage { public string Type { get; set; } = "Ping"; public DateTime SentUtc { get; set; } }用System.Text.Json序列化后走正常的帧格式发送,不要单独拆另一套收发逻辑。心跳间隔不是拍脑袋定的,设备侧一般设5秒,服务端15秒没收到任何消息就判定这个连接死亡。这个比例能容忍一次网络抖动,又不会让踢线判定太慢。注意心跳消息要统一走同一个接收循环,服务端把它当成一次活跃事件即可,不需要单独拉线程处理。
5.2 服务端超时踢线:别让死连接占着资源
服务端每收到一条消息就更新该客户端的LastActiveUtc,然后由一个后台线程定期扫描超时连接,发现超时就关闭。代码逻辑如下:
// ServerHealthChecker.cs: 每5秒扫描一次死连接 private static readonly Dictionary<TcpClient, DateTime> _lastSeen = new(); private static readonly object _locker = new(); public static void Touch(TcpClient client) { lock (_locker) _lastSeen[client] = DateTime.UtcNow; } public static void StartMonitoring() { new Thread(() => { while (true) { Thread.Sleep(5000); var now = DateTime.UtcNow; List<TcpClient> dead = new(); lock (_locker) { foreach (var kvp in _lastSeen) { if ((now - kvp.Value).TotalSeconds > 15) dead.Add(kvp.Key); } foreach (var client in dead) _lastSeen.Remove(client); } foreach (var client in dead) { try { client.Close(); } catch { /* 重复关闭可以忽略 */ } Console.WriteLine($"已清理超时连接: {client.Client.RemoteEndPoint}"); } } }) { IsBackground = true }.Start(); }重点在于处理方式:先用List收集超时对象,再统一关闭,避免在遍历Dictionary的同时修改。Close可能会抛异常,因为是网络操作,必须包try catch,而且重复Close是安全的。如果你在监控线程里关闭了一个正在Read的客户端,那边会立刻抛ObjectDisposedException,这是预期行为,不是错误,接收线程要捕获它当作正常退出信号。
5.3 客户端断线重连:指数退避是血泪经验
客户端在断线后立即重连,在网络还没恢复时会反复触发Connect超时,每个线程都满频率试,可能把服务端打到过载。我一般用指数退避:
// ReconnectingClient.cs: 断线后指数退避重连 private static void RunWithReconnect() { int retrySeconds = 1; while (!_shutdown) { try { using var tcp = new TcpClient(); tcp.Connect(_host, _port); Console.WriteLine($"连接成功: {_host}:{_port}"); retrySeconds = 1; // 连接成功,重置退避 using NetworkStream stream = tcp.GetStream(); // 业务循环里做ReadMessage/WriteFrame,遇到异常跳到外层catch while (!_shutdown) { string msg = stream.ReadMessage(); Console.WriteLine($"收到: {msg}"); } } catch (EndOfStreamException) { Console.WriteLine("服务端已正常关闭连接,等待重连..."); } catch (SocketException ex) { Console.WriteLine($"连接异常: {ex.SocketErrorCode}"); } catch (IOException ex) { Console.WriteLine($"读超时或IO错误: {ex.Message}"); } if (_shutdown) break; Console.WriteLine($"将在 {retrySeconds}s 后重连..."); Thread.Sleep(retrySeconds * 1000); retrySeconds = Math.Min(retrySeconds * 2, 30); // 上限30秒 } }要点说明:Connect成功一次就重置retrySeconds,让正常运营时恢复最快响应;失败时1、2、4、8秒翻倍,到30秒封顶,避免长期无脑轰炸。捕获异常分三类,EndOfStreamException是对方正常关闭,SocketException是网络层错误,IOException可能是超时。你这样分层抓,日志里就能直接看出是“对端挥手”还是“链路故障”,不用再去猜。这是我最推荐照抄的一段代码,因为它同时解决了阻塞线程安全退出和重连风暴两个难题。
5.4 多客户端管理:用一个会话对象封装状态
当有十几个设备同时接入时,裸用Dictionary<TcpClient, DateTime>会越来越乱。我习惯把每个连接封装成ClientSession,把流、缓冲区、最后活跃时间、业务心跳都放进去:
// ClientSession.cs: 一个连接一个实例,状态清晰 public class ClientSession { public TcpClient Tcp { get; } public NetworkStream Stream { get; } public DateTime LastActiveUtc { get; set; } public byte[] Buffer { get; } = new byte[4096]; public ClientSession(TcpClient tcp) { Tcp = tcp; Stream = tcp.GetStream(); LastActiveUtc = DateTime.UtcNow; Stream.ReadTimeout = 10_000; } }服务端用ConcurrentDictionary<string, ClientSession>按设备编号或连接ID管理,这样踢线、广播、查询状态都有明确入口。Buffer大小默认4096字节,如果你的消息体平均大2KB以上,直接调大到8192或16384,避免接收时多次重新分配;反之如果设备只上报几百字节,4096够了,太大反而浪费内存。到这一步,从建连、封帧、拆帧、心跳到重连的多客户端管理全部闭环,这才算配得上“完整源码”四个字。
6. 验证与调试技巧:压测脚本、抓包观察与日志习惯
6.1 做好这层验证,再开喝酒
每写完一套通讯代码,我习惯先跑一轮10万条消息压测。客户端开一个线程,循环发100000条包含随机业务ID的JSON消息;服务端收到后校验ID能对上连续性。这一步能暴露并发写冲突、漏读、缓冲区不够等问题。你自己的测试工程就照这个思路写成一个控制台对发,数据量从一万起步,加到一百万,观察内存占用和CPU。
6.2 抓包不是玄学,是必要手段
如果压测过了但现场偶发问题,别猜,用Wireshark抓回环或局域网包。选接口时,本机通讯要选Loopback虚拟网卡,局域网通讯选实际物理网卡;过滤表达式就是tcp.port == 9000。看三个核心现象:TCP三次握手颜色标记、PSH标志位是否频繁(说明小包多)、连接关闭时FIN/RST是哪个方向发起。RST包出现几乎必有问题——往往是某端试图往已关闭的socket写数据。
6.3 日志和超时参数的黄金组合
留一份带时间戳的收发日志是我见过最划算的习惯。每收到一条消息,写一行[时间] [设备ID] [消息类型] [数据];发送同样记录。出问题时对比日志和抓包时间线,两分钟就能定位是发送端没发、还是服务端没收、还是中途丢段。超时参数也一样,不要同时把收发超时设成同一个值,实际发送超时通常比接收短,因为发送失败很快;接收超时拉长一点。最后说个我的个人教训:曾经图省事没写日志,现场设备半小时掉线一次,最后靠WireShark抓了三小时包才发现是我自己发送线程里用了Async并发了多条,写操作顺序被打乱导致粘帧。从那以后,日志和抓包成了我上线的必修课,希望帮到你。
本文还有配套的精品资源,点击获取